mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 12:09:03 +00:00
Merge pull request #1568 from kaitranntt/kai/feat/maintainability-traceability-epic
Maintainability & Traceability Epic (P1-P7)
This commit is contained in:
157 files changed
+13038
-8499
No files matched your search
@@ -730,6 +730,18 @@ This pattern is used in:
|
||||
|
||||
---
|
||||
|
||||
## Lint Enforcement Gates
|
||||
|
||||
Two ESLint gates (`eslint.config.mjs`) lock in the maintainability epic's gains:
|
||||
|
||||
- **`ccs/no-new-throw-error`** (error): flags new `throw new Error(...)`. Use a typed error from `src/errors/error-types.ts` (`AuthError`, `ConfigError`, `ProfileError`, `ProviderError`, `NetworkError`, `ProxyError`, `MigrationError`, `ValidationError`, `RetryableError`) so `handleError` emits a differentiated exit code. Existing ~340 sites are grandfathered in `eslint-rules/throw-error-baseline.json`; only **new** violations error. Regenerate the baseline when intentionally grandfathering a new site, or quarterly to prune converted entries:
|
||||
```bash
|
||||
node scripts/generate-throw-error-baseline.js
|
||||
```
|
||||
- **`max-lines`** (warn, 400): warns on source files over 400 lines (`skipBlankLines`, `skipComments`). Split via the Monster File Splitting methodology above (barrel `index.ts` preserves the public API).
|
||||
|
||||
When the no-throw rule blocks a change, prefer converting to the matching typed error. Only add to the baseline when the throw is genuinely out of scope to convert (and regenerate the baseline so the entry is explicit, not silent).
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Codebase Summary](./codebase-summary.md) - Full directory structure
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Hardening Debt Burndown Tracker
|
||||
|
||||
Last Updated: 2026-02-12
|
||||
Owner: Stream D (`#542`)
|
||||
Last Updated: 2026-06-18
|
||||
Owner: Stream D (`#542`); maintainability epic owner TBD (open Q5)
|
||||
|
||||
## Scope
|
||||
|
||||
@@ -43,3 +43,70 @@ Baseline captured: `2026-02-12`.
|
||||
| Date | Area | Change | Safety Notes |
|
||||
|---|---|---|---|
|
||||
| 2026-02-12 | `src/web-server/jsonl-parser.ts` | Migrated `parseProjectDirectory()` directory listing from sync `readdirSync` to async `fs.promises.readdir` | Existing behavior kept (same filtering/fallback); covered by `tests/unit/jsonl-parser.test.ts` |
|
||||
|
||||
## Maintainability & Traceability Baseline (2026-06-18)
|
||||
|
||||
Baseline for the maintainability/traceability epic (`plans/260618-1346-maintainability-traceability-epic`). Sourced from `docs/reports/hardening-inventory.json` -> `maintainability` block after `bun run report:hardening`. Baseline captured: `2026-06-18`.
|
||||
|
||||
| Metric | Baseline | Epic target | Owner phase |
|
||||
|---|---:|---:|---|
|
||||
| typed-error adoption (typed / total throws) | 0.9% (4 / 431) | >40% in locked subdomains | P4 |
|
||||
| typed-error adoption (locked: cliproxy/quota, cliproxy/auth, web-server/routes, auth) | 0.0% (0 / 23) | >40% | P4 |
|
||||
| hotpath `console.error`/`warn` occurrences (non-exempt) | 931 (1091 total, 160 CLI-UX exempt) | < 10 | P3 |
|
||||
| hotpath `console.error`/`warn` files (non-exempt) | 134 | minimal | P3 |
|
||||
| files with `createLogger` | 35 / 685 (5.1%) | rise across all subdomains | P2/P3 |
|
||||
| subdomains with zero `createLogger` | 20 (incl. api, channels, config, delegation, dispatcher, docker, shared) | 0 in the named set | P2 |
|
||||
| files > 400 LOC | 95 | < 60 after P5+P6 | P5/P6 |
|
||||
| files > 600 LOC | 45 | drop | P5/P6 |
|
||||
| ESLint `no-new-throw-error` gate | not enforced | error + allowlist | P7 |
|
||||
| ESLint `max-lines` gate | not enforced | warn at 400 | P7 |
|
||||
| hardening report freshness | stale (2026-02-12) | < 30d gate in `validate:ci-parity` | P1 |
|
||||
|
||||
### Method
|
||||
|
||||
Metrics are grep-based and approximate (not a contract). Comments and string/template/regex literals are stripped before matching (`scripts/hardening-inventory.js#stripComments`). Subdomain granularity is 2-level under `src/cliproxy/` (`cliproxy/quota`, `cliproxy/auth`, ...) and 1-level elsewhere (`auth`, `config`, ...). The hotpath `console.error` count excludes CLI-UX print surfaces (`src/commands/`, `src/management/`, `src/utils/ui/`) which are legitimate user-facing terminal output. The typed-error denominator for the P4 target is LOCKED to the four named subdomains so the >40% goal cannot be gamed by narrowing scope. Re-baseline whenever the schema or method changes.
|
||||
|
||||
### Largest hotpath console.error offenders (2026-06-18)
|
||||
|
||||
| File | `console.error`/`warn` |
|
||||
|---|---:|
|
||||
| `src/utils/error-manager.ts` | 142 |
|
||||
| `src/cliproxy/accounts/account-safety.ts` | 56 |
|
||||
| `src/cliproxy/config/model-config.ts` | 32 |
|
||||
| `src/cliproxy/executor/arg-parser.ts` | 26 |
|
||||
| `src/dispatcher/flows/settings-flow.ts` | 26 |
|
||||
|
||||
## Progress Log
|
||||
|
||||
| Date | Phase | Change | Metric movement |
|
||||
|---|---|---|---|
|
||||
| 2026-06-18 | P2 | Express `withRequestContext` wrap; `CCS_REQUEST_ID` daemon forwarding + child re-anchor; logger toe-holds in delegation/docker. | zero-createLogger subdomains 20 -> 18 |
|
||||
| 2026-06-18 | P3 | Redaction gate (token-shape scrubbing in context + `Error.message` + message string). `tool-sanitization-proxy` private log subsystem deleted (13 sites -> existing `createLogger`). ~120 diagnostic `console.error` -> structured `createLogger` across proxy, web-server/routes, glmt, quota-fetchers, executors, delegation. User-facing `console.error` (CLI flows, arg-parser usage, installers, prompts, adapter launch errors, error display) migrated to `process.stderr.write` (preserves stderr output). `error-manager.ts` reclassified CLI-UX-exempt (user-facing display). | hotpath `console.error` 928 -> 267 (71%); createLogger files 35 -> 64 |
|
||||
| 2026-06-18 | P4 | `throw new Error` -> typed subclasses (ProfileError/AuthError/ConfigError/ProviderError/ValidationError) in cliproxy/auth, web-server/routes, auth. Error taxonomy made erasable (enum -> const, param-props -> fields) so the UI build accepts it. | typed adoption (locked subdomains) 0/23 -> 21/23 (91.3%); overall 0.9% -> 8.6% |
|
||||
| 2026-06-18 | P5 | Split 4 test-backed god-files into submodule dirs + barrels (shared-manager, cliproxy-stats-routes, persist-command, quota-subcommand). Public API preserved. | files > 400 LOC 95 -> 91 |
|
||||
| 2026-06-18 | P6 | Split 2 of 6 characterization-first god-files (quota-fetcher, quota-fetcher-gemini-cli; both had strong per-provider test coverage). 4 deferred. | files > 400 LOC 91 -> 89 |
|
||||
| 2026-06-18 | P7 | ESLint gates: `ccs/no-new-throw-error` (error, baseline-allowlisted) + `max-lines` (warn, 400). code-standards.md + logging-contract.md (error.code table). | gates enforced (0 lint errors on baseline) |
|
||||
|
||||
## Epic outcome (2026-06-18)
|
||||
|
||||
| Metric | Baseline | Final | Target | Status |
|
||||
|---|---:|---:|---:|---|
|
||||
| typed-error adoption (locked subdomains) | 0.0% (0/23) | 91.3% (21/23) | >40% | met |
|
||||
| typed-error adoption (overall) | 0.9% (5/431) | 8.6% (37/431) | rise | met |
|
||||
| hotpath `console.error`/`warn` | 928 | 267 | <10 | partial; high-risk diagnostics migrated + redaction gate closed, but metric target unmet (see P3 note) |
|
||||
| files with `createLogger` | 35 | 64 | rise | met |
|
||||
| subdomains with zero `createLogger` | 20 | 15 | 0 in named set | partial; logger toe-holds improved coverage but P2 zero-subdomain target unmet |
|
||||
| files > 400 LOC | 95 | 89 | <60 | partial (P6 deferred 4 targets) |
|
||||
| hardening report freshness | stale | <30d gate | gate | met (P1) |
|
||||
| ESLint `no-new-throw-error` | not enforced | error + allowlist | enforced | met (P7) |
|
||||
| ESLint `max-lines` | not enforced | warn at 400 | enforced | met (P7) |
|
||||
|
||||
### Deferred follow-ups
|
||||
|
||||
- P2 remaining zero-logger subdomains (`api`, `channels`, `config`, `dispatcher`, `shared`, and others): add lightweight logger toe-holds when those subdomains next receive behavior work. This epic improved coverage but did not satisfy the original `0` target.
|
||||
- P3 remaining 267 non-exempt `console.error`/`warn` call sites: split into true user-facing terminal output vs diagnostics, then either migrate diagnostics to structured logs or update the metric method to exempt confirmed CLI display helpers.
|
||||
- P6 remaining 4 targets (`oauth-handler.ts`, `cursor-executor.ts`, `tool-sanitization-proxy.ts`, +1): need dedicated characterization-test work before splitting (the plan's characterization-first hard gate). Each has a clean seam identified in the epic plan.
|
||||
|
||||
### P3 residual note (2026-06-18)
|
||||
|
||||
The remaining 267 non-exempt `console.error`/`warn` occurrences are not fully resolved by this epic. Many are user-facing terminal display paths (interactive flows, arg-parser usage errors, installers, prompts, adapter launch failures, error-display helpers), but the generated metric still counts them because its exemption list is intentionally conservative. Treat P3 as partial until the residual list is classified file-by-file and either migrated to structured logs or explicitly moved into the CLI-UX exemption method. The redaction gate now covers structured message, context, `Error.message`, and `StageOptions.error` metadata so further diagnostic migration is safe.
|
||||
@@ -146,6 +146,25 @@ Use `stage()` whenever the entry corresponds to one of the canonical lifecycle s
|
||||
|
||||
Default level is `info`. Configure via `logging.level` in `~/.ccs/config.yaml`. Streaming providers MUST gate per-chunk metrics behind `debug`.
|
||||
|
||||
## `error.code` values (exit codes)
|
||||
|
||||
Typed errors (`src/errors/error-types.ts`) carry an `ExitCode` that `handleError` propagates to `process.exit`. Log readers can branch on `error.code` for differentiated handling. The full mapping lives in `src/errors/exit-codes.ts`; the per-class assignment:
|
||||
|
||||
| Typed class | ExitCode | Value |
|
||||
|---|---|---:|
|
||||
| `ConfigError` | `CONFIG_ERROR` | 2 |
|
||||
| `NetworkError` | `NETWORK_ERROR` | 3 (recoverable) |
|
||||
| `AuthError` | `AUTH_ERROR` | 4 |
|
||||
| `BinaryError` | `BINARY_ERROR` | 5 |
|
||||
| `ProviderError` | `PROVIDER_ERROR` | 6 (recoverable) |
|
||||
| `ProfileError` | `PROFILE_ERROR` | 7 |
|
||||
| `ProxyError` | `PROXY_ERROR` | 8 |
|
||||
| `MigrationError` | `MIGRATION_ERROR` | 9 |
|
||||
| `UserAbortError` | `USER_ABORT` | 130 |
|
||||
| `ValidationError`, `RetryableError` | `GENERAL_ERROR` | 1 |
|
||||
|
||||
New throws must use a typed class (enforced by `ccs/no-new-throw-error`, see `docs/code-standards.md`). Redaction scrubs credential token shapes in both context values and message strings, so routing errors into the logger is safe — but keep messages clean prose and put sensitive data in context under a sensitive key (auto-redacted).
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
- All new `LogEntry` fields (`requestId`, `stage`, `latencyMs`, `error`) are optional. Old readers ignore them.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -6,43 +6,102 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}`
|
||||
|
||||
| Metric | Value |
|
||||
|---|---:|
|
||||
| Sync fs occurrences (all) | 835 |
|
||||
| Sync fs files affected (all) | 100 |
|
||||
| Sync fs occurrences (runtime hotpaths) | 724 |
|
||||
| Sync fs files affected (runtime hotpaths) | 89 |
|
||||
| Legacy shim markers | 131 |
|
||||
| Legacy shim files affected | 56 |
|
||||
| Sync fs occurrences (all) | 2301 |
|
||||
| Sync fs files affected (all) | 247 |
|
||||
| Sync fs occurrences (runtime hotpaths) | 1946 |
|
||||
| Sync fs files affected (runtime hotpaths) | 197 |
|
||||
| Legacy shim markers | 427 |
|
||||
| Legacy shim files affected | 164 |
|
||||
|
||||
## Top Runtime Hotpath Sync fs Files
|
||||
|
||||
| File | Sync Calls | API Names |
|
||||
|---|---:|---|
|
||||
| `src/management/shared-manager.ts` | 60 | copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync |
|
||||
| `src/utils/claude-symlink-manager.ts` | 27 | copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync |
|
||||
| `src/utils/shell-completion.ts` | 23 | appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync |
|
||||
| `src/web-server/routes/settings-routes.ts` | 23 | copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync |
|
||||
| `src/utils/claude-dir-installer.ts` | 21 | copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync |
|
||||
| `src/cliproxy/binary/version-cache.ts` | 20 | existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync |
|
||||
| `src/management/recovery-manager.ts` | 20 | copyFileSync, existsSync, mkdirSync, renameSync, writeFileSync |
|
||||
| `src/web-server/routes/cliproxy-stats-routes.ts` | 20 | closeSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, writeFileSync |
|
||||
| `src/web-server/routes/misc-routes.ts` | 20 | copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync |
|
||||
| `src/web-server/routes/persist-routes.ts` | 17 | closeSync, copyFileSync, existsSync, lstatSync, openSync, readdirSync, readSync, renameSync, unlinkSync, writeFileSync |
|
||||
| `src/cliproxy/__tests__/pool-routing-phase3.test.ts` | 96 | existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync |
|
||||
| `src/cliproxy/config/__tests__/config-generator.test.js` | 88 | existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync |
|
||||
| `src/cliproxy/config/__tests__/claude-model-neutral.test.ts` | 63 | existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync |
|
||||
| `src/cliproxy/accounts/__tests__/account-safety-quota-exhaustion.test.ts` | 48 | existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync |
|
||||
| `src/cliproxy/accounts/__tests__/account-registry-integrity.test.ts` | 45 | existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync |
|
||||
| `src/cliproxy/executor/__tests__/variant-port-integration.test.js` | 36 | existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync |
|
||||
| `src/cliproxy/executor/__tests__/composite-variant-service.test.ts` | 33 | existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync |
|
||||
| `src/cliproxy/executor/__tests__/variant-port-edge-cases.test.js` | 33 | existsSync, mkdirSync, readdirSync, rmSync, unlinkSync, writeFileSync |
|
||||
| `src/utils/browser/mcp-installer.ts` | 32 | chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync |
|
||||
| `src/cliproxy/__tests__/session-tracker-port.test.js` | 31 | existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync |
|
||||
|
||||
## Top Legacy Shim Marker Files
|
||||
|
||||
| File | Marker Count |
|
||||
|---|---:|
|
||||
| `src/auth/profile-detector.ts` | 18 |
|
||||
| `src/utils/config-manager.ts` | 13 |
|
||||
| `src/auth/profile-detector.ts` | 11 |
|
||||
| `src/config/unified-config-loader.ts` | 9 |
|
||||
| `src/commands/setup-command.ts` | 7 |
|
||||
| `src/management/checks/config-check.ts` | 6 |
|
||||
| `src/web-server/routes/account-routes.ts` | 6 |
|
||||
| `src/config/migration-manager.ts` | 5 |
|
||||
| `src/api/services/profile-writer.ts` | 4 |
|
||||
| `src/cliproxy/quota-fetcher-gemini-cli.ts` | 4 |
|
||||
| `src/auth/profile-registry.ts` | 3 |
|
||||
| `src/cliproxy/__tests__/pool-onboarding-phase5.test.ts` | 12 |
|
||||
| `src/cliproxy/executor/__tests__/variant-port-allocation.test.js` | 12 |
|
||||
| `src/config/schemas/websearch.ts` | 10 |
|
||||
| `src/commands/cursor-command-display.ts` | 9 |
|
||||
| `src/config/migration-manager.ts` | 9 |
|
||||
| `src/cliproxy/config/__tests__/env-builder-provider-url.test.ts` | 8 |
|
||||
| `src/cliproxy/executor/__tests__/variant-port-edge-cases.test.js` | 8 |
|
||||
| `src/cliproxy/config/__tests__/config-generator.test.js` | 7 |
|
||||
|
||||
## Explicit Shim/Re-export Files
|
||||
|
||||
- `src/cliproxy/openai-compat-manager.ts`
|
||||
- `src/cliproxy/__tests__/model-catalog-compat.test.ts`
|
||||
- `src/cliproxy/ai-providers/__tests__/codex-plan-compatibility.test.ts`
|
||||
- `src/cliproxy/ai-providers/__tests__/openai-compat-manager.test.js`
|
||||
- `src/cliproxy/ai-providers/openai-compat-manager.ts`
|
||||
- `src/cliproxy/types/__tests__/types-backward-compat.test.ts`
|
||||
- `src/utils/profile-compat.ts`
|
||||
- `src/web-server/services/compatible-cli-docs-registry.ts`
|
||||
## Maintainability Metrics
|
||||
|
||||
| Metric | Value |
|
||||
|---|---:|
|
||||
| typed-error adoption (typed/total throws) | 8.6% (37/431) |
|
||||
| typed-error adoption (P4 locked subdomains) | 91.3% (21/23), target 40% |
|
||||
| hotpath console.error/warn occurrences | 267 (569 total, 302 CLI-UX exempt) |
|
||||
| hotpath console.error/warn files | 82 |
|
||||
| files with createLogger | 64/745 |
|
||||
| subdomains with zero createLogger | 15 (api, bin, channels, cliproxy, cliproxy/accounts, cliproxy/ai-providers, cliproxy/binary, cliproxy/config, cliproxy/management, cliproxy/sync, cliproxy/types, config, dispatcher, shared, types) |
|
||||
| files > 400 LOC | 89 |
|
||||
| files > 600 LOC | 39 |
|
||||
|
||||
### Top Hotpath console.error/warn Files
|
||||
|
||||
| File | console.error/warn |
|
||||
|---|---:|
|
||||
| `src/errors/error-handler.ts` | 11 |
|
||||
| `src/utils/prompt.ts` | 11 |
|
||||
| `src/utils/websearch/profile-hook-injector.ts` | 10 |
|
||||
| `src/cliproxy/accounts/account-safety-cross-lane.ts` | 9 |
|
||||
| `src/utils/hooks/image-analyzer-profile-hook-injector.ts` | 9 |
|
||||
| `src/utils/websearch/hook-installer.ts` | 8 |
|
||||
| `src/cliproxy/auth/token-manager.ts` | 7 |
|
||||
| `src/cliproxy/binary/downloader.ts` | 7 |
|
||||
| `src/cliproxy/executor/account-resolution.ts` | 7 |
|
||||
| `src/config/unified-config-loader.ts` | 7 |
|
||||
| `src/targets/claude-adapter.ts` | 7 |
|
||||
| `src/utils/shell-executor.ts` | 7 |
|
||||
| `src/utils/websearch/hook-config.ts` | 7 |
|
||||
| `src/targets/droid-detector.ts` | 6 |
|
||||
| `src/utils/hooks/image-analyzer-hook-installer.ts` | 6 |
|
||||
|
||||
### Files > 400 LOC (top 15)
|
||||
|
||||
| File | LOC |
|
||||
|---|---:|
|
||||
| `src/web-server/routes/cliproxy-auth-routes.ts` | 1515 |
|
||||
| `src/cliproxy/auth/oauth-handler.ts` | 1455 |
|
||||
| `src/cursor/cursor-executor.ts` | 1234 |
|
||||
| `src/web-server/model-pricing.ts` | 1070 |
|
||||
| `src/web-server/routes/settings-routes.ts` | 1041 |
|
||||
| `src/cliproxy/config/env-builder.ts` | 1037 |
|
||||
| `src/cliproxy/proxy/tool-sanitization-proxy.ts` | 1020 |
|
||||
| `src/cliproxy/auth/oauth-process.ts` | 1018 |
|
||||
| `src/cliproxy/config/generator.ts` | 1012 |
|
||||
| `src/commands/cliproxy/variant-subcommand.ts` | 978 |
|
||||
| `src/cliproxy/quota/quota-manager.ts` | 954 |
|
||||
| `src/web-server/services/codex-dashboard-service.ts` | 940 |
|
||||
| `src/glmt/glmt-proxy.ts` | 939 |
|
||||
| `src/cliproxy/accounts/registry.ts` | 871 |
|
||||
| `src/channels/official-channels-runtime.ts` | 867 |
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Typed-Error Exit-Code Compat Audit (P4)
|
||||
|
||||
Date: 2026-06-18. Phase 4 of the maintainability/traceability epic.
|
||||
|
||||
## Question (open Q1)
|
||||
|
||||
Are CCS CLI exit codes a documented public contract that users or CI scripts depend on? This determines whether migrating `throw new Error(...)` to typed errors (which changes the exit code) is safe.
|
||||
|
||||
## Finding
|
||||
|
||||
Typed exit codes are **already wired end-to-end**. `handleError` -> `getExitCode` extracts `error.code` from any `CCSError` and passes it to `process.exit` (`src/errors/error-handler.ts`, `src/ccs.ts:145`). The `ExitCode` enum and the class-to-code mapping in `src/errors/error-types.ts` are complete and pre-date this epic.
|
||||
|
||||
**Only documented public contract:** `ccs doctor` documents exit codes 0 (healthy) / 1 (unhealthy) in `src/commands/doctor-command.ts:55-58`. `ccs doctor` is OUTSIDE the P4 priority subdomains and is NOT touched by P4. Its 0/1 contract is preserved.
|
||||
|
||||
**No CI/scripts assert on ccs exit codes.** `scripts/ci-parity-gate.sh` uses `set -euo pipefail` but performs no `ccs` exit-code branching. `.github/workflows/*` perform no ccs exit-code assertions. Prior exit-code changes in CHANGELOG are treated as bugfixes; no documented breaking changes.
|
||||
|
||||
## Decision
|
||||
|
||||
**Migrate freely** in the P4 priority subdomains (`cliproxy/quota`, `cliproxy/auth`, `web-server/routes`, `auth`). Preserve `GENERAL_ERROR(1)` only where no clear subclass applies. Behavior-lock tests assert the new typed codes.
|
||||
|
||||
## Exit-code mapping (the contract this audit locks)
|
||||
|
||||
| Typed class | ExitCode | Value | Used for (P4 sites) |
|
||||
|---|---|---:|---|
|
||||
| `ProfileError` | `PROFILE_ERROR` | 7 | profile/account/variant not found, already exists |
|
||||
| `AuthError` | `AUTH_ERROR` | 4 | OAuth/token/Kiro/GitLab auth flow failures, refresh ownership |
|
||||
| `ConfigError` | `CONFIG_ERROR` | 2 | settings/config structure, path, not-initialized, read/write profiles |
|
||||
| `ValidationError` | `GENERAL_ERROR` | 1 | input format validation (no exit-code shift) |
|
||||
| `ProviderError` | `PROVIDER_ERROR` | 6 | unsupported provider backend |
|
||||
| `NetworkError` | `NETWORK_ERROR` | 3 | (recoverable) |
|
||||
| `ProxyError` | `PROXY_ERROR` | 8 | |
|
||||
| `MigrationError` | `MIGRATION_ERROR` | 9 | |
|
||||
|
||||
## Per-site decisions
|
||||
|
||||
See the P4 commit for the full site list. Summary by subclass chosen:
|
||||
- `ProfileError`: profile/account not-found + already-exists (`src/auth/profile-registry.ts`, `src/cliproxy/auth/auth-token-manager.ts`, `src/cliproxy/auth/auth-types.ts`).
|
||||
- `AuthError`: OAuth start failed, paste-callback unavailable, Kiro auth method unsupported, token refresh ownership (`src/cliproxy/auth/oauth-handler.ts`, `provider-refreshers/index.ts`).
|
||||
- `ConfigError`: invalid settings path, settings not found, CLIProxy config not initialized, GitLab URL format, failed read/write profiles, copilot sync failure (`src/web-server/routes/*`, `src/cliproxy/auth/oauth-handler.ts`, `src/auth/profile-registry.ts`).
|
||||
- `ValidationError`: invalid profile name, invalid target, invalid host, Kiro IDC start-url (`src/web-server/routes/*`, `src/cliproxy/auth/auth-types.ts`).
|
||||
- `ProviderError`: unsupported provider backend (`src/web-server/routes/image-analysis-routes.ts`).
|
||||
|
||||
## Outcome
|
||||
|
||||
Typed-error adoption in the locked subdomains: 0/23 -> 21/23 (91.3%), well above the 40% target. Exit-code changes are intentional and documented here; release notes for the epic PR should mention the differentiated exit codes.
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Custom ESLint rule: disallow `throw new Error(...)` outside a baseline allowlist.
|
||||
*
|
||||
* P7 enforcement gate. Forces new error sites to use the typed-error taxonomy
|
||||
* (src/errors/error-types.ts: AuthError, ConfigError, ProfileError, ProviderError,
|
||||
* ...) so handleError emits differentiated exit codes. Existing ~400 sites are
|
||||
* grandfathered via a generated baseline (scripts/generate-throw-error-baseline.js
|
||||
* -> eslint-rules/throw-error-baseline.json); only NEW violations are reported.
|
||||
*
|
||||
* Detects `throw new Error(...)` (NewExpression with callee name 'Error').
|
||||
* Typed subclasses (throw new ConfigError(...)) and re-throws are allowed.
|
||||
*
|
||||
* Option: { allowlist: string[] } — entries are `${relativePath}:${line}` keys.
|
||||
* The relative path matches ESLint's context filename (relative to the repo root
|
||||
* when eslint is invoked from the root). Line drift after edits above an
|
||||
* allowlisted site causes a false positive until the baseline is regenerated;
|
||||
* quarterly pruning keeps it accurate.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
|
||||
function isNewErrorExpression(node) {
|
||||
return (
|
||||
node !== null &&
|
||||
node !== undefined &&
|
||||
node.type === 'NewExpression' &&
|
||||
node.callee !== null &&
|
||||
node.callee !== undefined &&
|
||||
node.callee.type === 'Identifier' &&
|
||||
node.callee.name === 'Error'
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow throw new Error(...) outside the baseline; use a typed error from src/errors/error-types.ts.',
|
||||
},
|
||||
schema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
allowlist: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
messages: {
|
||||
unexpected:
|
||||
"Unexpected throw new Error(...). Use a typed error from src/errors/error-types.ts (AuthError, ConfigError, ProfileError, ProviderError, ...) so handleError emits a differentiated exit code, or regenerate the baseline via 'node scripts/generate-throw-error-baseline.js'.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const options = context.options[0] || {};
|
||||
const allowlist = new Set(options.allowlist || []);
|
||||
|
||||
return {
|
||||
ThrowStatement(node) {
|
||||
if (!isNewErrorExpression(node.argument)) {
|
||||
return;
|
||||
}
|
||||
const filename = context.getFilename();
|
||||
// Normalize to a repo-root-relative path so the baseline keys (which are
|
||||
// relative, e.g. 'src/auth/profile-registry.ts') match regardless of
|
||||
// whether ESLint reports an absolute or relative filename.
|
||||
const normalized = path.isAbsolute(filename)
|
||||
? path.relative(process.cwd(), filename)
|
||||
: filename;
|
||||
const line = node.loc && node.loc.start ? node.loc.start.line : -1;
|
||||
const key = `${normalized}:${line}`;
|
||||
if (allowlist.has(key)) {
|
||||
return;
|
||||
}
|
||||
context.report({ node, messageId: 'unexpected' });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,340 @@
|
||||
[
|
||||
"src/api/services/local-runtime-readiness.ts:68",
|
||||
"src/api/services/openrouter-catalog.ts:72",
|
||||
"src/api/services/profile-lifecycle-service.ts:127",
|
||||
"src/api/services/profile-lifecycle-service.ts:73",
|
||||
"src/api/services/profile-lifecycle-service.ts:88",
|
||||
"src/api/services/profile-writer.ts:364",
|
||||
"src/api/services/profile-writer.ts:415",
|
||||
"src/bin/ccsxp-runtime.ts:90",
|
||||
"src/channels/official-channels-store.ts:291",
|
||||
"src/channels/official-channels-store.ts:296",
|
||||
"src/channels/official-channels-store.ts:299",
|
||||
"src/cliproxy/accounts/drain-order.ts:131",
|
||||
"src/cliproxy/accounts/drain-order.ts:139",
|
||||
"src/cliproxy/accounts/drain-order.ts:246",
|
||||
"src/cliproxy/accounts/drain-order.ts:253",
|
||||
"src/cliproxy/accounts/drain-order.ts:279",
|
||||
"src/cliproxy/accounts/drain-order.ts:342",
|
||||
"src/cliproxy/accounts/registry.ts:521",
|
||||
"src/cliproxy/accounts/registry.ts:536",
|
||||
"src/cliproxy/accounts/registry.ts:544",
|
||||
"src/cliproxy/accounts/registry.ts:589",
|
||||
"src/cliproxy/accounts/registry.ts:597",
|
||||
"src/cliproxy/accounts/registry.ts:755",
|
||||
"src/cliproxy/accounts/registry.ts:770",
|
||||
"src/cliproxy/ai-providers/managed-model-prefixes.ts:54",
|
||||
"src/cliproxy/ai-providers/managed-model-prefixes.ts:71",
|
||||
"src/cliproxy/ai-providers/managed-model-prefixes.ts:81",
|
||||
"src/cliproxy/ai-providers/openai-compat-manager.ts:139",
|
||||
"src/cliproxy/ai-providers/openai-compat-manager.ts:167",
|
||||
"src/cliproxy/ai-providers/openai-compat-manager.ts:172",
|
||||
"src/cliproxy/ai-providers/service.ts:242",
|
||||
"src/cliproxy/ai-providers/service.ts:247",
|
||||
"src/cliproxy/ai-providers/service.ts:254",
|
||||
"src/cliproxy/ai-providers/service.ts:257",
|
||||
"src/cliproxy/ai-providers/service.ts:260",
|
||||
"src/cliproxy/ai-providers/service.ts:266",
|
||||
"src/cliproxy/binary/installer.ts:127",
|
||||
"src/cliproxy/binary/installer.ts:55",
|
||||
"src/cliproxy/binary/installer.ts:74",
|
||||
"src/cliproxy/binary/installer.ts:88",
|
||||
"src/cliproxy/binary/lifecycle.ts:139",
|
||||
"src/cliproxy/binary/platform-detector.ts:153",
|
||||
"src/cliproxy/binary/platform-detector.ts:160",
|
||||
"src/cliproxy/binary/verifier.ts:55",
|
||||
"src/cliproxy/binary/version-checker.ts:108",
|
||||
"src/cliproxy/config/base-config-loader.ts:54",
|
||||
"src/cliproxy/config/base-config-loader.ts:66",
|
||||
"src/cliproxy/config/base-config-loader.ts:81",
|
||||
"src/cliproxy/config/base-config-loader.ts:91",
|
||||
"src/cliproxy/config/env-builder.ts:1005",
|
||||
"src/cliproxy/config/env-builder.ts:700",
|
||||
"src/cliproxy/executor/auth-coordinator.ts:176",
|
||||
"src/cliproxy/executor/auth-coordinator.ts:317",
|
||||
"src/cliproxy/executor/browser-launch-setup.ts:120",
|
||||
"src/cliproxy/executor/index.ts:378",
|
||||
"src/cliproxy/executor/lifecycle-manager.ts:129",
|
||||
"src/cliproxy/executor/lifecycle-manager.ts:162",
|
||||
"src/cliproxy/executor/lifecycle-manager.ts:59",
|
||||
"src/cliproxy/executor/proxy-resolver.ts:142",
|
||||
"src/cliproxy/executor/proxy-resolver.ts:157",
|
||||
"src/cliproxy/executor/proxy-resolver.ts:163",
|
||||
"src/cliproxy/executor/proxy-resolver.ts:177",
|
||||
"src/cliproxy/executor/session-bridge.ts:147",
|
||||
"src/cliproxy/provider-capabilities.ts:257",
|
||||
"src/cliproxy/proxy/https-tunnel-proxy.ts:61",
|
||||
"src/cliproxy/routing/routing-strategy.ts:480",
|
||||
"src/cliproxy/routing/routing-strategy.ts:488",
|
||||
"src/cliproxy/routing/routing-strategy.ts:708",
|
||||
"src/cliproxy/services/remote-auth-fetcher.ts:139",
|
||||
"src/cliproxy/services/remote-auth-fetcher.ts:150",
|
||||
"src/cliproxy/services/remote-auth-fetcher.ts:152",
|
||||
"src/cliproxy/services/remote-auth-fetcher.ts:159",
|
||||
"src/cliproxy/services/remote-auth-fetcher.ts:165",
|
||||
"src/cliproxy/services/remote-auth-fetcher.ts:168",
|
||||
"src/cliproxy/services/startup-lock.ts:208",
|
||||
"src/cliproxy/services/variant-config-adapter.ts:72",
|
||||
"src/cliproxy/services/variant-service.ts:371",
|
||||
"src/cliproxy/services/variant-service.ts:446",
|
||||
"src/codex-auth/codex-auth-dashboard-service.ts:108",
|
||||
"src/codex-auth/codex-auth-dashboard-service.ts:84",
|
||||
"src/codex-auth/codex-profile-paths.ts:17",
|
||||
"src/codex-auth/codex-profile-paths.ts:26",
|
||||
"src/codex-auth/codex-profile-registry.ts:204",
|
||||
"src/codex-auth/codex-profile-registry.ts:232",
|
||||
"src/codex-auth/codex-profile-registry.ts:277",
|
||||
"src/codex-auth/codex-profile-registry.ts:29",
|
||||
"src/codex-auth/codex-profile-registry.ts:295",
|
||||
"src/codex-auth/codex-profile-registry.ts:305",
|
||||
"src/codex-auth/codex-profile-registry.ts:317",
|
||||
"src/codex-auth/codex-profile-registry.ts:320",
|
||||
"src/codex-auth/codex-profile-registry.ts:34",
|
||||
"src/codex-auth/codex-profile-registry.ts:351",
|
||||
"src/codex-auth/codex-profile-registry.ts:37",
|
||||
"src/codex-auth/codex-profile-registry.ts:52",
|
||||
"src/codex-auth/codex-profile-registry.ts:65",
|
||||
"src/codex-auth/codex-profile-registry.ts:71",
|
||||
"src/codex-auth/codex-profile-registry.ts:75",
|
||||
"src/codex-auth/codex-profile-registry.ts:78",
|
||||
"src/codex-auth/codex-profile-registry.ts:81",
|
||||
"src/codex-auth/codex-profile-registry.ts:86",
|
||||
"src/codex-auth/codex-profile-registry.ts:93",
|
||||
"src/codex-auth/codex-profile-registry.ts:96",
|
||||
"src/codex-auth/commands/import-default-command.ts:134",
|
||||
"src/codex-auth/commands/import-default-command.ts:146",
|
||||
"src/codex-auth/commands/import-default-command.ts:167",
|
||||
"src/commands/bar/install-subcommand.ts:137",
|
||||
"src/commands/bar/install-subcommand.ts:141",
|
||||
"src/commands/bar/install-subcommand.ts:149",
|
||||
"src/commands/bar/install-subcommand.ts:172",
|
||||
"src/commands/bar/install-subcommand.ts:181",
|
||||
"src/commands/bar/install-subcommand.ts:187",
|
||||
"src/commands/bar/install-subcommand.ts:228",
|
||||
"src/commands/bar/install-subcommand.ts:249",
|
||||
"src/commands/bar/install-subcommand.ts:259",
|
||||
"src/commands/bar/install-subcommand.ts:271",
|
||||
"src/commands/bar/install-subcommand.ts:289",
|
||||
"src/commands/bar/install-subcommand.ts:316",
|
||||
"src/commands/bar/launch-subcommand.ts:212",
|
||||
"src/commands/config-channels-command.ts:431",
|
||||
"src/commands/config-channels-command.ts:436",
|
||||
"src/commands/config-channels-command.ts:447",
|
||||
"src/commands/persist-command/arg-parsing.ts:31",
|
||||
"src/commands/persist-command/backup-rotation.ts:226",
|
||||
"src/commands/persist-command/backup-rotation.ts:244",
|
||||
"src/commands/persist-command/backup-rotation.ts:249",
|
||||
"src/commands/persist-command/backup-rotation.ts:86",
|
||||
"src/commands/persist-command/handler.ts:166",
|
||||
"src/commands/persist-command/handler.ts:38",
|
||||
"src/commands/persist-command/secure-file.ts:104",
|
||||
"src/commands/persist-command/secure-file.ts:120",
|
||||
"src/commands/persist-command/secure-file.ts:142",
|
||||
"src/commands/persist-command/secure-file.ts:172",
|
||||
"src/commands/persist-command/secure-file.ts:174",
|
||||
"src/commands/persist-command/secure-file.ts:182",
|
||||
"src/commands/persist-command/secure-file.ts:98",
|
||||
"src/commands/proxy-command.ts:89",
|
||||
"src/commands/setup-command.ts:218",
|
||||
"src/config/loader/io-locks.ts:205",
|
||||
"src/config/loader/io-locks.ts:241",
|
||||
"src/config/loader/io-locks.ts:295",
|
||||
"src/config/loader/io-locks.ts:297",
|
||||
"src/config/reserved-names.ts:92",
|
||||
"src/config/unified-config-loader.ts:149",
|
||||
"src/copilot/copilot-package-manager.ts:444",
|
||||
"src/copilot/copilot-package-manager.ts:467",
|
||||
"src/cursor/cursor-anthropic-translator.ts:113",
|
||||
"src/cursor/cursor-anthropic-translator.ts:119",
|
||||
"src/cursor/cursor-anthropic-translator.ts:129",
|
||||
"src/cursor/cursor-anthropic-translator.ts:149",
|
||||
"src/cursor/cursor-anthropic-translator.ts:169",
|
||||
"src/cursor/cursor-anthropic-translator.ts:174",
|
||||
"src/cursor/cursor-anthropic-translator.ts:197",
|
||||
"src/cursor/cursor-anthropic-translator.ts:21",
|
||||
"src/cursor/cursor-anthropic-translator.ts:44",
|
||||
"src/cursor/cursor-anthropic-translator.ts:51",
|
||||
"src/cursor/cursor-anthropic-translator.ts:94",
|
||||
"src/cursor/cursor-client-policy.ts:33",
|
||||
"src/cursor/cursor-client-policy.ts:81",
|
||||
"src/cursor/cursor-client-policy.ts:85",
|
||||
"src/cursor/cursor-daemon-entry.ts:183",
|
||||
"src/cursor/cursor-daemon-entry.ts:188",
|
||||
"src/cursor/cursor-daemon-entry.ts:193",
|
||||
"src/cursor/cursor-daemon-entry.ts:203",
|
||||
"src/cursor/cursor-executor.ts:206",
|
||||
"src/cursor/cursor-protobuf.ts:50",
|
||||
"src/cursor/cursor-translator.ts:189",
|
||||
"src/delegation/headless-executor.ts:126",
|
||||
"src/delegation/headless-executor.ts:141",
|
||||
"src/delegation/headless-executor.ts:270",
|
||||
"src/delegation/headless-executor.ts:679",
|
||||
"src/dispatcher/cli-argument-parser.ts:310",
|
||||
"src/dispatcher/cli-argument-parser.ts:314",
|
||||
"src/dispatcher/cli-argument-parser.ts:324",
|
||||
"src/dispatcher/cli-argument-parser.ts:328",
|
||||
"src/dispatcher/flows/default-flow.ts:76",
|
||||
"src/dispatcher/flows/settings-flow.ts:132",
|
||||
"src/docker/docker-assets.ts:35",
|
||||
"src/docker/docker-executor.ts:154",
|
||||
"src/docker/docker-executor.ts:435",
|
||||
"src/glmt/delta-accumulator.ts:154",
|
||||
"src/glmt/delta-accumulator.ts:196",
|
||||
"src/glmt/delta-accumulator.ts:216",
|
||||
"src/glmt/delta-accumulator.ts:304",
|
||||
"src/glmt/glmt-transformer.ts:92",
|
||||
"src/glmt/sse-parser.ts:130",
|
||||
"src/glmt/sse-parser.ts:73",
|
||||
"src/management/instance-manager.ts:133",
|
||||
"src/management/profile-context-sync-lock.ts:162",
|
||||
"src/management/profile-context-sync-lock.ts:232",
|
||||
"src/proxy/proxy-daemon-entry.ts:67",
|
||||
"src/proxy/proxy-daemon-entry.ts:78",
|
||||
"src/proxy/proxy-daemon-entry.ts:88",
|
||||
"src/proxy/proxy-daemon.ts:98",
|
||||
"src/proxy/transformers/request-transformer.ts:141",
|
||||
"src/proxy/transformers/request-transformer.ts:182",
|
||||
"src/proxy/transformers/request-transformer.ts:189",
|
||||
"src/proxy/transformers/request-transformer.ts:242",
|
||||
"src/proxy/transformers/request-transformer.ts:259",
|
||||
"src/proxy/transformers/request-transformer.ts:278",
|
||||
"src/proxy/transformers/request-transformer.ts:344",
|
||||
"src/proxy/transformers/request-transformer.ts:360",
|
||||
"src/proxy/transformers/request-transformer.ts:370",
|
||||
"src/proxy/transformers/request-transformer.ts:390",
|
||||
"src/proxy/transformers/request-transformer.ts:428",
|
||||
"src/proxy/transformers/request-transformer.ts:439",
|
||||
"src/proxy/transformers/request-transformer.ts:443",
|
||||
"src/proxy/transformers/request-transformer.ts:459",
|
||||
"src/proxy/transformers/request-transformer.ts:468",
|
||||
"src/proxy/transformers/request-transformer.ts:487",
|
||||
"src/proxy/transformers/request-transformer.ts:493",
|
||||
"src/proxy/transformers/request-transformer.ts:528",
|
||||
"src/proxy/transformers/request-transformer.ts:533",
|
||||
"src/proxy/transformers/request-transformer.ts:538",
|
||||
"src/proxy/transformers/request-transformer.ts:543",
|
||||
"src/proxy/transformers/request-transformer.ts:561",
|
||||
"src/proxy/transformers/request-transformer.ts:566",
|
||||
"src/proxy/transformers/request-transformer.ts:573",
|
||||
"src/proxy/transformers/request-transformer.ts:582",
|
||||
"src/proxy/transformers/request-transformer.ts:633",
|
||||
"src/proxy/transformers/request-transformer.ts:639",
|
||||
"src/proxy/transformers/request-transformer.ts:644",
|
||||
"src/proxy/transformers/request-transformer.ts:665",
|
||||
"src/proxy/upstream-url.ts:28",
|
||||
"src/shared/claude-extension-setup.ts:240",
|
||||
"src/shared/claude-extension-setup.ts:247",
|
||||
"src/shared/claude-extension-setup.ts:257",
|
||||
"src/shared/claude-extension-setup.ts:317",
|
||||
"src/shared/provider-preset-catalog.ts:308",
|
||||
"src/shared/provider-preset-catalog.ts:311",
|
||||
"src/shared/provider-preset-catalog.ts:320",
|
||||
"src/shared/provider-preset-catalog.ts:323",
|
||||
"src/shared/provider-preset-catalog.ts:326",
|
||||
"src/shared/provider-preset-catalog.ts:331",
|
||||
"src/shared/provider-preset-catalog.ts:334",
|
||||
"src/shared/toml-object.ts:23",
|
||||
"src/targets/codex-adapter.ts:103",
|
||||
"src/targets/codex-adapter.ts:275",
|
||||
"src/targets/codex-adapter.ts:319",
|
||||
"src/targets/codex-adapter.ts:326",
|
||||
"src/targets/codex-adapter.ts:363",
|
||||
"src/targets/codex-cliproxy-provider-config.ts:138",
|
||||
"src/targets/codex-cliproxy-provider-config.ts:141",
|
||||
"src/targets/codex-cliproxy-provider-config.ts:151",
|
||||
"src/targets/codex-cliproxy-provider-config.ts:177",
|
||||
"src/targets/droid-adapter.ts:32",
|
||||
"src/targets/droid-adapter.ts:35",
|
||||
"src/targets/droid-adapter.ts:68",
|
||||
"src/targets/droid-adapter.ts:74",
|
||||
"src/targets/droid-config-manager.ts:335",
|
||||
"src/targets/droid-config-manager.ts:34",
|
||||
"src/targets/droid-config-manager.ts:354",
|
||||
"src/targets/droid-config-manager.ts:357",
|
||||
"src/targets/droid-config-manager.ts:390",
|
||||
"src/targets/droid-config-manager.ts:421",
|
||||
"src/targets/droid-config-manager.ts:429",
|
||||
"src/targets/droid-config-manager.ts:449",
|
||||
"src/targets/target-registry.ts:27",
|
||||
"src/targets/target-resolver.ts:137",
|
||||
"src/targets/target-resolver.ts:161",
|
||||
"src/targets/target-resolver.ts:171",
|
||||
"src/utils/browser/browser-policy.ts:35",
|
||||
"src/utils/browser/browser-policy.ts:43",
|
||||
"src/utils/browser/chrome-reuse.ts:114",
|
||||
"src/utils/browser/chrome-reuse.ts:119",
|
||||
"src/utils/browser/chrome-reuse.ts:142",
|
||||
"src/utils/browser/chrome-reuse.ts:155",
|
||||
"src/utils/browser/chrome-reuse.ts:160",
|
||||
"src/utils/browser/chrome-reuse.ts:165",
|
||||
"src/utils/browser/chrome-reuse.ts:41",
|
||||
"src/utils/browser/chrome-reuse.ts:64",
|
||||
"src/utils/browser/chrome-reuse.ts:80",
|
||||
"src/utils/browser/chrome-reuse.ts:84",
|
||||
"src/utils/browser/chrome-reuse.ts:95",
|
||||
"src/utils/browser/mcp-installer.ts:342",
|
||||
"src/utils/claude-spawner.ts:46",
|
||||
"src/utils/config-manager.ts:298",
|
||||
"src/utils/config-manager.ts:302",
|
||||
"src/utils/prompt.ts:112",
|
||||
"src/utils/prompt.ts:157",
|
||||
"src/utils/prompt.ts:54",
|
||||
"src/utils/proxy-env.ts:36",
|
||||
"src/utils/proxy-env.ts:40",
|
||||
"src/utils/retry-strategy.ts:96",
|
||||
"src/utils/retry-strategy.ts:99",
|
||||
"src/utils/shell-completion.ts:101",
|
||||
"src/utils/shell-completion.ts:143",
|
||||
"src/utils/shell-completion.ts:191",
|
||||
"src/utils/shell-completion.ts:224",
|
||||
"src/utils/shell-completion.ts:269",
|
||||
"src/utils/shell-completion.ts:288",
|
||||
"src/utils/shell-completion.ts:74",
|
||||
"src/utils/websearch/mcp-installer.ts:438",
|
||||
"src/utils/websearch/profile-hook-injector.ts:206",
|
||||
"src/web-server/index.ts:266",
|
||||
"src/web-server/services/claude-extension-binding-service.ts:144",
|
||||
"src/web-server/services/claude-extension-binding-service.ts:185",
|
||||
"src/web-server/services/claude-extension-binding-service.ts:195",
|
||||
"src/web-server/services/claude-extension-binding-service.ts:207",
|
||||
"src/web-server/services/claude-extension-binding-service.ts:255",
|
||||
"src/web-server/services/claude-extension-binding-service.ts:273",
|
||||
"src/web-server/services/claude-extension-binding-service.ts:82",
|
||||
"src/web-server/services/claude-extension-binding-service.ts:83",
|
||||
"src/web-server/services/claude-extension-binding-service.ts:84",
|
||||
"src/web-server/services/claude-extension-binding-service.ts:89",
|
||||
"src/web-server/services/claude-extension-settings-service.ts:186",
|
||||
"src/web-server/services/claude-extension-settings-service.ts:190",
|
||||
"src/web-server/services/claude-extension-settings-service.ts:202",
|
||||
"src/web-server/services/claude-extension-settings-service.ts:226",
|
||||
"src/web-server/services/compatible-cli-docs-registry.ts:171",
|
||||
"src/web-server/services/compatible-cli-json-file-service.ts:171",
|
||||
"src/web-server/services/compatible-cli-json-file-service.ts:174",
|
||||
"src/web-server/services/compatible-cli-json-file-service.ts:191",
|
||||
"src/web-server/services/compatible-cli-json-file-service.ts:194",
|
||||
"src/web-server/services/compatible-cli-json-file-service.ts:203",
|
||||
"src/web-server/services/compatible-cli-json-file-service.ts:206",
|
||||
"src/web-server/services/compatible-cli-toml-file-service.ts:124",
|
||||
"src/web-server/services/compatible-cli-toml-file-service.ts:127",
|
||||
"src/web-server/services/compatible-cli-toml-file-service.ts:292",
|
||||
"src/web-server/services/compatible-cli-toml-file-service.ts:295",
|
||||
"src/web-server/services/compatible-cli-toml-file-service.ts:89",
|
||||
"src/web-server/services/compatible-cli-toml-file-service.ts:92",
|
||||
"src/web-server/usage/cliproxy-usage-syncer.ts:302",
|
||||
"src/web-server/usage/handlers.ts:101",
|
||||
"src/web-server/usage/handlers.ts:66",
|
||||
"src/web-server/usage/handlers.ts:73",
|
||||
"src/web-server/usage/handlers.ts:74",
|
||||
"src/web-server/usage/handlers.ts:75",
|
||||
"src/web-server/usage/handlers.ts:84",
|
||||
"src/web-server/usage/handlers.ts:93",
|
||||
"src/web-server/usage/profile-filter.ts:15",
|
||||
"src/web-server/usage/profile-filter.ts:21",
|
||||
"src/web-server/usage/sqlite-cli.ts:101",
|
||||
"src/web-server/usage/sqlite-cli.ts:138",
|
||||
"src/web-server/usage/sqlite-cli.ts:159",
|
||||
"src/web-server/usage/sqlite-cli.ts:175",
|
||||
"src/web-server/usage/sqlite-cli.ts:179",
|
||||
"src/web-server/usage/sqlite-cli.ts:77",
|
||||
"src/web-server/usage/sqlite-cli.ts:90"
|
||||
]
|
||||
@@ -1,6 +1,14 @@
|
||||
import tseslint from '@typescript-eslint/eslint-plugin';
|
||||
import tsparser from '@typescript-eslint/parser';
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import noNewThrowError from './eslint-rules/no-new-throw-error.js';
|
||||
|
||||
const __configDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const baselinePath = path.join(__configDir, 'eslint-rules', 'throw-error-baseline.json');
|
||||
const throwErrorBaseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
|
||||
|
||||
export default [
|
||||
{
|
||||
@@ -16,6 +24,8 @@ export default [
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': tseslint,
|
||||
// Local enforcement rules (P7 gates).
|
||||
ccs: { rules: { 'no-new-throw-error': noNewThrowError } },
|
||||
},
|
||||
rules: {
|
||||
// TypeScript rules - upgraded to errors for stricter type safety
|
||||
@@ -32,6 +42,15 @@ export default [
|
||||
'prefer-const': 'error',
|
||||
'no-var': 'error',
|
||||
'eqeqeq': ['error', 'always'],
|
||||
|
||||
// P7 enforcement gates:
|
||||
// - no-new-throw-error: error on NEW throw new Error(...) outside the
|
||||
// generated baseline (eslint-rules/throw-error-baseline.json). Forces
|
||||
// the typed-error taxonomy (src/errors/error-types.ts). Regenerate the
|
||||
// baseline with: node scripts/generate-throw-error-baseline.js
|
||||
// - max-lines: warn on files over 400 LOC (goal of P5/P6 god-file splits).
|
||||
'ccs/no-new-throw-error': ['error', { allowlist: throwErrorBaseline }],
|
||||
'max-lines': ['warn', { max: 400, skipBlankLines: true, skipComments: true }],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -57,6 +57,37 @@ if git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Hardening inventory freshness: the maintainability metrics artifact must be
|
||||
# regenerated within 30 days so the burndown stays current. Runs only after the
|
||||
# skip conditions above (CCS_SKIP_PREPUSH_GATE, detached HEAD, behind origin).
|
||||
HARDENING_JSON="docs/reports/hardening-inventory.json"
|
||||
if [[ ! -f "$HARDENING_JSON" ]]; then
|
||||
echo "[X] Missing $HARDENING_JSON."
|
||||
echo " Regenerate with: bun run report:hardening"
|
||||
exit 1
|
||||
fi
|
||||
HARDENING_TS=""
|
||||
# If the working-tree copy differs from HEAD (contributor regenerated but not
|
||||
# yet committed), use filesystem mtime; otherwise use the last commit time,
|
||||
# which is stable across CI clones (checkout resets mtimes) and so correctly
|
||||
# flags a stale committed artifact.
|
||||
if git diff --quiet -- "$HARDENING_JSON" 2>/dev/null && git diff --cached --quiet -- "$HARDENING_JSON" 2>/dev/null; then
|
||||
HARDENING_TS=$(git log -1 --format=%ct -- "$HARDENING_JSON" 2>/dev/null)
|
||||
else
|
||||
HARDENING_TS=$(stat -f %m "$HARDENING_JSON" 2>/dev/null || stat -c %Y "$HARDENING_JSON" 2>/dev/null)
|
||||
fi
|
||||
if [[ -n "$HARDENING_TS" ]]; then
|
||||
NOW_TS=$(date +%s)
|
||||
AGE_DAYS=$(( (NOW_TS - HARDENING_TS) / 86400 ))
|
||||
if (( AGE_DAYS > 30 )); then
|
||||
echo "[X] Hardening inventory is stale (${AGE_DAYS}d old; max 30d)."
|
||||
echo " Regenerate with: bun run report:hardening"
|
||||
echo " Then commit docs/reports/hardening-inventory.{json,md}."
|
||||
exit 1
|
||||
fi
|
||||
echo "[i] Hardening inventory fresh (${AGE_DAYS}d old; max 30d)."
|
||||
fi
|
||||
|
||||
echo "[i] Running CI-parity local checks..."
|
||||
# `set -euo pipefail` above makes every step fail fast. Keep these commands
|
||||
# explicit so parity drift is visible when CI changes.
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Generate the baseline allowlist for eslint-rules/no-new-throw-error.js.
|
||||
*
|
||||
* Walks src/ (non-test), finds every `throw new Error(...)` site using the same
|
||||
* comment-stripping as scripts/maintainability-metrics.js (so the baseline
|
||||
* matches what the ESLint AST sees — comments are not ThrowStatement nodes),
|
||||
* and writes eslint-rules/throw-error-baseline.json as a sorted array of
|
||||
* `${relativePath}:${line}` keys.
|
||||
*
|
||||
* Run after intentionally adding a new grandfathered throw, or quarterly to
|
||||
* prune entries that have since been converted to typed errors.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT_DIR = path.resolve(__dirname, '..');
|
||||
const SRC_DIR = path.join(ROOT_DIR, 'src');
|
||||
const OUTPUT_PATH = path.join(ROOT_DIR, 'eslint-rules', 'throw-error-baseline.json');
|
||||
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
|
||||
const THROW_NEW_ERROR_REGEX = /\bthrow\s+new\s+Error\s*\(/g;
|
||||
|
||||
function isTestPath(relPath) {
|
||||
return (
|
||||
/(?:^|\/)(?:__tests__|tests?)\//.test(relPath) ||
|
||||
/\.test\./.test(relPath) ||
|
||||
/\.spec\./.test(relPath)
|
||||
);
|
||||
}
|
||||
|
||||
function toPosix(p) {
|
||||
return p.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function relPath(fullPath) {
|
||||
return toPosix(path.relative(ROOT_DIR, fullPath));
|
||||
}
|
||||
|
||||
// Lazy require: hardening-inventory.js requires this module's sibling maintainability-metrics.js
|
||||
// at top level; requiring it back at top level here would capture a partial module.exports.
|
||||
// A function-scope require resolves against the fully-loaded module at call time.
|
||||
function stripCommentsOnce(sourceText) {
|
||||
return require('./hardening-inventory.js').stripComments(sourceText);
|
||||
}
|
||||
|
||||
function walkFiles(dirPath) {
|
||||
const out = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.name === 'node_modules' || entry.name === 'dist') continue;
|
||||
const full = path.join(dirPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
out.push.apply(out, walkFiles(full));
|
||||
} else if (entry.isFile() && SOURCE_EXTENSIONS.has(path.extname(full))) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectSites() {
|
||||
const sites = [];
|
||||
for (const full of walkFiles(SRC_DIR)) {
|
||||
const rel = relPath(full);
|
||||
if (isTestPath(rel)) continue;
|
||||
// Match on the RAW source (not comment-stripped). ESLint lints the raw
|
||||
// file, so the baseline must reflect real throw lines as the AST sees them.
|
||||
// stripComments can undercount on files whose regex/template literals confuse
|
||||
// its state machine; raw matching is a superset (may include comment/string
|
||||
// mentions, which are harmless unused allowlist entries) and never undercounts.
|
||||
const sourceText = fs.readFileSync(full, 'utf8');
|
||||
const re = new RegExp(THROW_NEW_ERROR_REGEX.source, 'g');
|
||||
let match;
|
||||
while ((match = re.exec(sourceText)) !== null) {
|
||||
const line = sourceText.slice(0, match.index).split(/\r?\n/).length;
|
||||
sites.push(`${rel}:${line}`);
|
||||
}
|
||||
}
|
||||
return sites.sort();
|
||||
}
|
||||
|
||||
function main() {
|
||||
const sites = collectSites();
|
||||
fs.mkdirSync(path.dirname(OUTPUT_PATH), { recursive: true });
|
||||
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(sites, null, 2) + '\n', 'utf8');
|
||||
console.log(`[throw-error-baseline] ${sites.length} sites -> ${relPath(OUTPUT_PATH)}`);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { collectSites, OUTPUT_PATH };
|
||||
@@ -3,6 +3,8 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { collectMaintainabilityMetrics } = require('./maintainability-metrics.js');
|
||||
|
||||
const ROOT_DIR = path.resolve(__dirname, '..');
|
||||
const SRC_DIR = path.join(ROOT_DIR, 'src');
|
||||
const REPORT_DIR = path.join(ROOT_DIR, 'docs', 'reports');
|
||||
@@ -430,6 +432,7 @@ function buildReport() {
|
||||
.filter((file) => /shim|re-export|compat/i.test(path.basename(file)))
|
||||
),
|
||||
},
|
||||
maintainability: collectMaintainabilityMetrics(ROOT_DIR),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -489,6 +492,55 @@ function renderMarkdown(report) {
|
||||
lines.push('- _none_');
|
||||
}
|
||||
|
||||
const m = report.maintainability;
|
||||
if (m) {
|
||||
lines.push('## Maintainability Metrics');
|
||||
lines.push('');
|
||||
lines.push('| Metric | Value |');
|
||||
lines.push('|---|---:|');
|
||||
lines.push(
|
||||
`| typed-error adoption (typed/total throws) | ${(m.typedErrors.adoptionRatio * 100).toFixed(1)}% (${m.typedErrors.typedThrows}/${m.typedErrors.totalThrows}) |`
|
||||
);
|
||||
lines.push(
|
||||
`| typed-error adoption (P4 locked subdomains) | ${(m.typedErrorAdoption.ratio * 100).toFixed(1)}% (${m.typedErrorAdoption.numerator}/${m.typedErrorAdoption.denominator}), target 40% |`
|
||||
);
|
||||
lines.push(
|
||||
`| hotpath console.error/warn occurrences | ${m.hotpathConsoleErrors.hotpathOccurrences} (${m.hotpathConsoleErrors.totalOccurrences} total, ${m.hotpathConsoleErrors.exemptOccurrences} CLI-UX exempt) |`
|
||||
);
|
||||
lines.push(`| hotpath console.error/warn files | ${m.hotpathConsoleErrors.filesAffected} |`);
|
||||
lines.push(
|
||||
`| files with createLogger | ${m.loggerCoverage.filesWithCreateLogger}/${m.loggerCoverage.totalSourceFiles} |`
|
||||
);
|
||||
lines.push(
|
||||
`| subdomains with zero createLogger | ${m.loggerCoverage.subdomainsWithZeroCreateLogger.length} (${m.loggerCoverage.subdomainsWithZeroCreateLogger.join(', ') || 'none'}) |`
|
||||
);
|
||||
lines.push(`| files > 400 LOC | ${m.largeFiles.countOver400} |`);
|
||||
lines.push(`| files > 600 LOC | ${m.largeFiles.countOver600} |`);
|
||||
lines.push('');
|
||||
lines.push('### Top Hotpath console.error/warn Files');
|
||||
lines.push('');
|
||||
lines.push('| File | console.error/warn |');
|
||||
lines.push('|---|---:|');
|
||||
for (const item of m.hotpathConsoleErrors.topFiles) {
|
||||
lines.push(`| \`${item.file}\` | ${item.count} |`);
|
||||
}
|
||||
if (m.hotpathConsoleErrors.topFiles.length === 0) {
|
||||
lines.push('| _none_ | 0 |');
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('### Files > 400 LOC (top 15)');
|
||||
lines.push('');
|
||||
lines.push('| File | LOC |');
|
||||
lines.push('|---|---:|');
|
||||
for (const item of m.largeFiles.topOver400) {
|
||||
lines.push(`| \`${item.file}\` | ${item.loc} |`);
|
||||
}
|
||||
if (m.largeFiles.topOver400.length === 0) {
|
||||
lines.push('| _none_ | 0 |');
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -510,17 +562,23 @@ function main() {
|
||||
console.log(
|
||||
`[hardening-inventory] legacy markers total=${report.legacyShim.totalMarkers}, files=${report.legacyShim.filesAffected}`
|
||||
);
|
||||
if (report.maintainability) {
|
||||
const mt = report.maintainability;
|
||||
console.log(
|
||||
`[hardening-inventory] maintainability: typed-adoption=${(mt.typedErrors.adoptionRatio * 100).toFixed(1)}% (${mt.typedErrors.typedThrows}/${mt.typedErrors.totalThrows}), locked=${(mt.typedErrorAdoption.ratio * 100).toFixed(1)}% (${mt.typedErrorAdoption.numerator}/${mt.typedErrorAdoption.denominator}), console.error=${mt.hotpathConsoleErrors.hotpathOccurrences}, zero-logger-subdomains=${mt.loggerCoverage.subdomainsWithZeroCreateLogger.length}, >400LOC=${mt.largeFiles.countOver400}`
|
||||
);
|
||||
}
|
||||
console.log(`[hardening-inventory] wrote ${relJson}`);
|
||||
console.log(`[hardening-inventory] wrote ${relMd}`);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildReport,
|
||||
collectSyncCallSites,
|
||||
renderMarkdown,
|
||||
stripComments,
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Maintainability metrics collector for the CCS CLI maintainability epic.
|
||||
*
|
||||
* Computes the metrics the epic tracks across phases:
|
||||
* - typed-error adoption (throw new <TypedError> vs throw new Error vs other)
|
||||
* - createLogger coverage by subdomain (which subdomains have zero)
|
||||
* - hotpath console.error / console.warn call-site count (CLI-UX exempt)
|
||||
* - files > 400 / 600 LOC
|
||||
* - typed-error adoption in the P4 LOCKED denominator subdomains
|
||||
*
|
||||
* Accuracy relies on stripping comments/strings before regex matching. We
|
||||
* reuse hardening-inventory.js#stripComments for that. The require is lazy
|
||||
* (inside sanitize()) because hardening-inventory.js requires THIS module at
|
||||
* its top level; a top-level require back would capture hardening-inventory's
|
||||
* partial module.exports during the load cycle (it reassigns module.exports at
|
||||
* the bottom). A function-scope require resolves against the fully-loaded
|
||||
* module at call time, so the cycle is harmless.
|
||||
*
|
||||
* Approximate by design (grep-based). Method documented in
|
||||
* docs/hardening-debt-burndown.md. Re-baseline when the schema changes.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
|
||||
|
||||
// CCSError subclasses (src/errors/error-types.ts). A `throw new <OneOfThese>`
|
||||
// counts as TYPED. `throw new Error(...)` is plain. Any other `throw new X(`
|
||||
// is "other" (error subclass outside the canonical taxonomy).
|
||||
const TYPED_ERROR_CLASSES = new Set([
|
||||
'CCSError',
|
||||
'ConfigError',
|
||||
'NetworkError',
|
||||
'AuthError',
|
||||
'BinaryError',
|
||||
'ProviderError',
|
||||
'ProfileError',
|
||||
'ProxyError',
|
||||
'MigrationError',
|
||||
'UserAbortError',
|
||||
'ValidationError',
|
||||
'RetryableError',
|
||||
]);
|
||||
|
||||
// P4 LOCKED denominator: typed-error adoption is measured ONLY over these
|
||||
// subdomains so the >40% goal cannot be gamed by narrowing scope. Emits both
|
||||
// numerator and denominator counts alongside the ratio.
|
||||
const TYPED_ADOPTION_SUBDOMAINS = ['cliproxy/quota', 'cliproxy/auth', 'web-server/routes', 'auth'];
|
||||
|
||||
// CLI-UX print surfaces exempt from the hotpath console.error sweep (P3).
|
||||
// Diagnostics here are legitimate user-facing terminal output, not loggable
|
||||
// errors, and stay on stdout/stderr via utils/ui. src/utils/error-manager.ts is
|
||||
// the user-facing error display module (ErrorManager.show*), a sibling to
|
||||
// utils/ui, so its console.error calls are display output, not diagnostics.
|
||||
const CLI_UX_EXEMPT_PREFIXES = [
|
||||
'src/commands/',
|
||||
'src/management/',
|
||||
'src/utils/ui/',
|
||||
'src/utils/error-manager.ts',
|
||||
];
|
||||
|
||||
const THROW_NEW_REGEX = /\bthrow\s+new\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g;
|
||||
const CONSOLE_ERR_WARN_REGEX = /\bconsole\s*\.\s*(?:error|warn)\s*\(/g;
|
||||
const CREATE_LOGGER_REGEX = /\bcreateLogger\s*\(/;
|
||||
|
||||
function sanitize(sourceText) {
|
||||
// Lazy require; see module header for the circular-dependency rationale.
|
||||
return require('./hardening-inventory.js').stripComments(sourceText);
|
||||
}
|
||||
|
||||
function toPosixPath(filePath) {
|
||||
return filePath.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function isSourceFile(filePath) {
|
||||
return SOURCE_EXTENSIONS.has(path.extname(filePath));
|
||||
}
|
||||
|
||||
function isTestFile(relPath) {
|
||||
return (
|
||||
/(?:^|\/)(?:__tests__|tests?)\//.test(relPath) ||
|
||||
/\.test\./.test(relPath) ||
|
||||
/\.spec\./.test(relPath)
|
||||
);
|
||||
}
|
||||
|
||||
function isCliUxExempt(relPath) {
|
||||
return CLI_UX_EXEMPT_PREFIXES.some(function (prefix) {
|
||||
return relPath.startsWith(prefix);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Subdomain key for a src-relative path.
|
||||
* src/cliproxy/quota/x.ts -> "cliproxy/quota"
|
||||
* src/auth/x.ts -> "auth"
|
||||
* src/x.ts -> "<root>"
|
||||
*/
|
||||
function subdomainOf(relPath) {
|
||||
const rest = relPath.replace(/^src\//, '');
|
||||
const parts = rest.split('/');
|
||||
if (parts[0] === 'cliproxy' && parts.length > 2) {
|
||||
return parts[0] + '/' + parts[1];
|
||||
}
|
||||
return parts[0] || '<root>';
|
||||
}
|
||||
|
||||
function round4(n) {
|
||||
return Math.round(n * 10000) / 10000;
|
||||
}
|
||||
|
||||
// Items may be file-shaped ({file,count}) or subdomain-shaped ({subdomain,count});
|
||||
// the tiebreaker key is whichever label field is present.
|
||||
function labelOf(item) {
|
||||
return item.file || item.subdomain || '';
|
||||
}
|
||||
|
||||
function topByCount(items, limit) {
|
||||
return items
|
||||
.slice()
|
||||
.sort(function (a, b) {
|
||||
return b.count - a.count || labelOf(a).localeCompare(labelOf(b));
|
||||
})
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/** Classify `throw new X(` occurrences in sanitized source. */
|
||||
function classifyThrows(sourceText) {
|
||||
const sanitized = sanitize(sourceText);
|
||||
const counts = { typed: 0, plain: 0, other: 0, total: 0 };
|
||||
const re = new RegExp(THROW_NEW_REGEX.source, 'g');
|
||||
let match;
|
||||
while ((match = re.exec(sanitized)) !== null) {
|
||||
const identifier = match[1];
|
||||
counts.total += 1;
|
||||
if (identifier === 'Error') {
|
||||
counts.plain += 1;
|
||||
} else if (TYPED_ERROR_CLASSES.has(identifier)) {
|
||||
counts.typed += 1;
|
||||
} else {
|
||||
counts.other += 1;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** Count console.error / console.warn call sites in sanitized source. */
|
||||
function countConsoleErrors(sourceText) {
|
||||
const sanitized = sanitize(sourceText);
|
||||
const re = new RegExp(CONSOLE_ERR_WARN_REGEX.source, 'g');
|
||||
return (sanitized.match(re) || []).length;
|
||||
}
|
||||
|
||||
/** True if the file directly creates a logger via createLogger(...). */
|
||||
function hasCreateLogger(sourceText) {
|
||||
return CREATE_LOGGER_REGEX.test(sanitize(sourceText));
|
||||
}
|
||||
|
||||
/** Raw line count of a source file (matches `wc -l` content semantics). */
|
||||
function countLoc(sourceText) {
|
||||
if (!sourceText) return 0;
|
||||
const parts = sourceText.split(/\r?\n/);
|
||||
return sourceText.endsWith('\n') ? parts.length - 1 : parts.length;
|
||||
}
|
||||
|
||||
function walkFiles(dirPath) {
|
||||
const output = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||
} catch (_err) {
|
||||
return output;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.name === 'node_modules' || entry.name === 'dist') continue;
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
output.push.apply(output, walkFiles(fullPath));
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && isSourceFile(fullPath)) output.push(fullPath);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function emptyAggregates() {
|
||||
return {
|
||||
typedBySubdomain: {},
|
||||
loggerBySubdomain: {},
|
||||
hotpathByFile: [],
|
||||
hotpathTotal: 0,
|
||||
hotpathExempt: 0,
|
||||
hotpathNonExempt: 0,
|
||||
hotpathFilesAffected: 0,
|
||||
filesWithLogger: 0,
|
||||
totalSourceFiles: 0,
|
||||
typedTotal: 0,
|
||||
typedTyped: 0,
|
||||
typedPlain: 0,
|
||||
typedOther: 0,
|
||||
over400: [],
|
||||
over600: [],
|
||||
};
|
||||
}
|
||||
|
||||
function ensureBucket(obj, key, factory) {
|
||||
if (!obj[key]) obj[key] = factory();
|
||||
return obj[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk <rootDir>/src and aggregate maintainability metrics.
|
||||
* Returns the `maintainability` block merged into the hardening inventory.
|
||||
*/
|
||||
function collectMaintainabilityMetrics(rootDir) {
|
||||
const srcDir = path.join(rootDir, 'src');
|
||||
const files = walkFiles(srcDir);
|
||||
const agg = emptyAggregates();
|
||||
|
||||
for (const fullPath of files) {
|
||||
const relPath = toPosixPath(path.relative(rootDir, fullPath));
|
||||
if (isTestFile(relPath)) continue;
|
||||
const sourceText = fs.readFileSync(fullPath, 'utf8');
|
||||
const sub = subdomainOf(relPath);
|
||||
|
||||
const throws = classifyThrows(sourceText);
|
||||
agg.typedTotal += throws.total;
|
||||
agg.typedTyped += throws.typed;
|
||||
agg.typedPlain += throws.plain;
|
||||
agg.typedOther += throws.other;
|
||||
if (throws.total > 0) {
|
||||
const bucket = ensureBucket(agg.typedBySubdomain, sub, function () {
|
||||
return { typed: 0, plain: 0, other: 0, total: 0 };
|
||||
});
|
||||
bucket.typed += throws.typed;
|
||||
bucket.plain += throws.plain;
|
||||
bucket.other += throws.other;
|
||||
bucket.total += throws.total;
|
||||
}
|
||||
|
||||
agg.totalSourceFiles += 1;
|
||||
const hasLogger = hasCreateLogger(sourceText);
|
||||
if (hasLogger) agg.filesWithLogger += 1;
|
||||
const lc = ensureBucket(agg.loggerBySubdomain, sub, function () {
|
||||
return { files: 0, withLogger: 0 };
|
||||
});
|
||||
lc.files += 1;
|
||||
if (hasLogger) lc.withLogger += 1;
|
||||
|
||||
const ce = countConsoleErrors(sourceText);
|
||||
if (ce > 0) {
|
||||
agg.hotpathTotal += ce;
|
||||
if (isCliUxExempt(relPath)) {
|
||||
agg.hotpathExempt += ce;
|
||||
} else {
|
||||
agg.hotpathNonExempt += ce;
|
||||
agg.hotpathFilesAffected += 1;
|
||||
agg.hotpathByFile.push({ file: relPath, count: ce });
|
||||
}
|
||||
}
|
||||
|
||||
const loc = countLoc(sourceText);
|
||||
if (loc > 600) agg.over600.push({ file: relPath, loc: loc });
|
||||
if (loc > 400) agg.over400.push({ file: relPath, loc: loc });
|
||||
}
|
||||
|
||||
const adoptionRatio = agg.typedTotal > 0 ? agg.typedTyped / agg.typedTotal : 0;
|
||||
|
||||
let numerator = 0;
|
||||
let denominator = 0;
|
||||
for (const sub of TYPED_ADOPTION_SUBDOMAINS) {
|
||||
const bucket = agg.typedBySubdomain[sub];
|
||||
if (bucket) {
|
||||
numerator += bucket.typed;
|
||||
denominator += bucket.total;
|
||||
}
|
||||
}
|
||||
const typedAdoptionRatio = denominator > 0 ? numerator / denominator : 0;
|
||||
|
||||
const subdomainsWithZeroCreateLogger = Object.keys(agg.loggerBySubdomain)
|
||||
.filter(function (k) {
|
||||
return agg.loggerBySubdomain[k].files > 0 && agg.loggerBySubdomain[k].withLogger === 0;
|
||||
})
|
||||
.sort();
|
||||
|
||||
const topOver400 = agg.over400
|
||||
.slice()
|
||||
.sort(function (a, b) {
|
||||
return b.loc - a.loc || a.file.localeCompare(b.file);
|
||||
})
|
||||
.slice(0, 15);
|
||||
|
||||
return {
|
||||
typedErrors: {
|
||||
totalThrows: agg.typedTotal,
|
||||
typedThrows: agg.typedTyped,
|
||||
plainThrows: agg.typedPlain,
|
||||
otherThrows: agg.typedOther,
|
||||
adoptionRatio: round4(adoptionRatio),
|
||||
topSubdomainsByThrows: topByCount(
|
||||
Object.keys(agg.typedBySubdomain).map(function (k) {
|
||||
const v = agg.typedBySubdomain[k];
|
||||
return { subdomain: k, count: v.total, typed: v.typed, plain: v.plain };
|
||||
}),
|
||||
10
|
||||
),
|
||||
},
|
||||
typedErrorAdoption: {
|
||||
subdomains: TYPED_ADOPTION_SUBDOMAINS.slice(),
|
||||
numerator: numerator,
|
||||
denominator: denominator,
|
||||
ratio: round4(typedAdoptionRatio),
|
||||
targetRatio: 0.4,
|
||||
},
|
||||
loggerCoverage: {
|
||||
filesWithCreateLogger: agg.filesWithLogger,
|
||||
totalSourceFiles: agg.totalSourceFiles,
|
||||
coverageRatio: round4(
|
||||
agg.totalSourceFiles > 0 ? agg.filesWithLogger / agg.totalSourceFiles : 0
|
||||
),
|
||||
subdomainsWithZeroCreateLogger: subdomainsWithZeroCreateLogger,
|
||||
topSubdomainsByFiles: topByCount(
|
||||
Object.keys(agg.loggerBySubdomain).map(function (k) {
|
||||
const v = agg.loggerBySubdomain[k];
|
||||
return { subdomain: k, count: v.files, withLogger: v.withLogger };
|
||||
}),
|
||||
10
|
||||
),
|
||||
},
|
||||
hotpathConsoleErrors: {
|
||||
totalOccurrences: agg.hotpathTotal,
|
||||
exemptOccurrences: agg.hotpathExempt,
|
||||
hotpathOccurrences: agg.hotpathNonExempt,
|
||||
filesAffected: agg.hotpathFilesAffected,
|
||||
topFiles: topByCount(agg.hotpathByFile, 15),
|
||||
},
|
||||
largeFiles: {
|
||||
countOver400: agg.over400.length,
|
||||
countOver600: agg.over600.length,
|
||||
topOver400: topOver400,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
collectMaintainabilityMetrics: collectMaintainabilityMetrics,
|
||||
classifyThrows: classifyThrows,
|
||||
countConsoleErrors: countConsoleErrors,
|
||||
hasCreateLogger: hasCreateLogger,
|
||||
countLoc: countLoc,
|
||||
TYPED_ERROR_CLASSES: TYPED_ERROR_CLASSES,
|
||||
TYPED_ADOPTION_SUBDOMAINS: TYPED_ADOPTION_SUBDOMAINS,
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
mutateConfig,
|
||||
} from '../config/config-loader-facade';
|
||||
import { normalizeSharedResourceMetadata, type SharedResourceMode } from './shared-resource-policy';
|
||||
import { ConfigError, ProfileError } from '../errors/error-types';
|
||||
|
||||
const logger = createLogger('auth:profile-registry');
|
||||
|
||||
@@ -132,7 +133,7 @@ export class ProfileRegistry {
|
||||
return JSON.parse(data) as ProfileData;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
throw new Error(`Failed to read profiles: ${message}`);
|
||||
throw new ConfigError(`Failed to read profiles: ${message}`, this.profilesPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +160,7 @@ export class ProfileRegistry {
|
||||
fs.unlinkSync(tempPath);
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
throw new Error(`Failed to write profiles: ${message}`);
|
||||
throw new ConfigError(`Failed to write profiles: ${message}`, this.profilesPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +171,7 @@ export class ProfileRegistry {
|
||||
const data = this._read();
|
||||
|
||||
if (data.profiles[name]) {
|
||||
throw new Error(`Profile already exists: ${name}`);
|
||||
throw new ProfileError(`Profile already exists: ${name}`, name);
|
||||
}
|
||||
|
||||
// v3.0 minimal schema: only essential fields
|
||||
@@ -203,7 +204,7 @@ export class ProfileRegistry {
|
||||
const data = this._read();
|
||||
|
||||
if (!data.profiles[name]) {
|
||||
throw new Error(`Profile not found: ${name}`);
|
||||
throw new ProfileError(`Profile not found: ${name}`, name);
|
||||
}
|
||||
|
||||
return this.normalizeLegacyProfileMetadata(data.profiles[name]);
|
||||
@@ -216,7 +217,7 @@ export class ProfileRegistry {
|
||||
const data = this._read();
|
||||
|
||||
if (!data.profiles[name]) {
|
||||
throw new Error(`Profile not found: ${name}`);
|
||||
throw new ProfileError(`Profile not found: ${name}`, name);
|
||||
}
|
||||
|
||||
data.profiles[name] = this.normalizeLegacyProfileMetadata({
|
||||
@@ -234,7 +235,7 @@ export class ProfileRegistry {
|
||||
const data = this._read();
|
||||
|
||||
if (!data.profiles[name]) {
|
||||
throw new Error(`Profile not found: ${name}`);
|
||||
throw new ProfileError(`Profile not found: ${name}`, name);
|
||||
}
|
||||
|
||||
delete data.profiles[name];
|
||||
@@ -287,7 +288,7 @@ export class ProfileRegistry {
|
||||
const data = this._read();
|
||||
|
||||
if (!data.profiles[name]) {
|
||||
throw new Error(`Profile not found: ${name}`);
|
||||
throw new ProfileError(`Profile not found: ${name}`, name);
|
||||
}
|
||||
|
||||
data.default = name;
|
||||
@@ -330,7 +331,7 @@ export class ProfileRegistry {
|
||||
createAccountUnified(name: string, metadata: CreateMetadata = {}): void {
|
||||
mutateConfig((config) => {
|
||||
if (config.accounts[name]) {
|
||||
throw new Error(`Account already exists: ${name}`);
|
||||
throw new ProfileError(`Account already exists: ${name}`, name);
|
||||
}
|
||||
config.accounts[name] = this.normalizeUnifiedAccountConfig({
|
||||
created: new Date().toISOString(),
|
||||
@@ -350,7 +351,7 @@ export class ProfileRegistry {
|
||||
updateAccountUnified(name: string, updates: Partial<AccountConfig>): void {
|
||||
mutateConfig((config) => {
|
||||
if (!config.accounts[name]) {
|
||||
throw new Error(`Account not found: ${name}`);
|
||||
throw new ProfileError(`Account not found: ${name}`, name);
|
||||
}
|
||||
config.accounts[name] = this.normalizeUnifiedAccountConfig({
|
||||
...config.accounts[name],
|
||||
@@ -365,7 +366,7 @@ export class ProfileRegistry {
|
||||
removeAccountUnified(name: string): void {
|
||||
mutateConfig((config) => {
|
||||
if (!config.accounts[name]) {
|
||||
throw new Error(`Account not found: ${name}`);
|
||||
throw new ProfileError(`Account not found: ${name}`, name);
|
||||
}
|
||||
delete config.accounts[name];
|
||||
if (config.default === name) {
|
||||
@@ -382,7 +383,7 @@ export class ProfileRegistry {
|
||||
const exists =
|
||||
config.accounts[name] || config.profiles[name] || config.cliproxy?.variants?.[name];
|
||||
if (!exists) {
|
||||
throw new Error(`Profile not found: ${name}`);
|
||||
throw new ProfileError(`Profile not found: ${name}`, name);
|
||||
}
|
||||
config.default = name;
|
||||
});
|
||||
@@ -434,7 +435,7 @@ export class ProfileRegistry {
|
||||
touchAccountUnified(name: string): void {
|
||||
mutateConfig((config) => {
|
||||
if (!config.accounts[name]) {
|
||||
throw new Error(`Account not found: ${name}`);
|
||||
throw new ProfileError(`Account not found: ${name}`, name);
|
||||
}
|
||||
config.accounts[name].last_used = new Date().toISOString();
|
||||
config.accounts[name] = this.normalizeUnifiedAccountConfig(config.accounts[name]);
|
||||
|
||||
@@ -252,32 +252,36 @@ export function warnCrossProviderDuplicates(provider: CLIProxyProvider): boolean
|
||||
const duplicates = detectCrossProviderDuplicates();
|
||||
if (duplicates.size === 0) return false;
|
||||
|
||||
console.error('');
|
||||
console.error(warn('Account safety: cross-provider duplicate detected'));
|
||||
console.error(
|
||||
' Same Google account across "ccs gemini" + "ccs agy" is a known suspension/ban risk (ref: #509).'
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(String(warn('Account safety: cross-provider duplicate detected')) + '\n');
|
||||
process.stderr.write(
|
||||
' Same Google account across "ccs gemini" + "ccs agy" is a known suspension/ban risk (ref: #509).\n'
|
||||
);
|
||||
console.error(' This risk applies to both CLI sessions and accounts added from "ccs config".');
|
||||
console.error(
|
||||
' If provider requests start returning 403/Forbidden, treat it as a possible account disable/ban.'
|
||||
process.stderr.write(
|
||||
' This risk applies to both CLI sessions and accounts added from "ccs config".\n'
|
||||
);
|
||||
console.error(
|
||||
' If you want to keep Google AI access on this account, do not continue this shared-account setup.'
|
||||
process.stderr.write(
|
||||
' If provider requests start returning 403/Forbidden, treat it as a possible account disable/ban.\n'
|
||||
);
|
||||
console.error(
|
||||
' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.'
|
||||
process.stderr.write(
|
||||
' If you want to keep Google AI access on this account, do not continue this shared-account setup.\n'
|
||||
);
|
||||
console.error(` Details: ${ISSUE_509_URL}`);
|
||||
console.error('');
|
||||
process.stderr.write(
|
||||
' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.\n'
|
||||
);
|
||||
process.stderr.write(` Details: ${ISSUE_509_URL}\n`);
|
||||
process.stderr.write('\n');
|
||||
|
||||
for (const [email, providers] of duplicates) {
|
||||
console.error(` ${maskEmail(email)} -> ${providers.join(', ')}`);
|
||||
process.stderr.write(` ${maskEmail(email)} -> ${providers.join(', ')}\n`);
|
||||
}
|
||||
|
||||
console.error('');
|
||||
console.error(' Immediate action: pause duplicate account and use separate Google accounts.');
|
||||
console.error(' Fix command: "ccs cliproxy pause <account> --provider <provider>"');
|
||||
console.error('');
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(
|
||||
' Immediate action: pause duplicate account and use separate Google accounts.\n'
|
||||
);
|
||||
process.stderr.write(' Fix command: "ccs cliproxy pause <account> --provider <provider>"\n');
|
||||
process.stderr.write('\n');
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -289,27 +293,31 @@ export function warnNewAccountConflict(
|
||||
email: string,
|
||||
conflictingProviders: CLIProxyProvider[]
|
||||
): void {
|
||||
console.error('');
|
||||
console.error(warn('Account safety: this email is used by another provider'));
|
||||
console.error(
|
||||
` ${maskEmail(email)} is also registered under: ${conflictingProviders.join(', ')}`
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(
|
||||
String(warn('Account safety: this email is used by another provider')) + '\n'
|
||||
);
|
||||
console.error(
|
||||
' Reusing one Google account between "ccs gemini" and "ccs agy" can trigger bans.'
|
||||
process.stderr.write(
|
||||
` ${maskEmail(email)} is also registered under: ${conflictingProviders.join(', ')}\n`
|
||||
);
|
||||
console.error(
|
||||
' This applies to both CLI auth and "ccs config" dashboard auth for these providers.'
|
||||
process.stderr.write(
|
||||
' Reusing one Google account between "ccs gemini" and "ccs agy" can trigger bans.\n'
|
||||
);
|
||||
console.error(' 403/Forbidden responses can be an early sign of account disablement.');
|
||||
console.error(
|
||||
' If you want to keep Google AI access, do not continue with this shared-account setup.'
|
||||
process.stderr.write(
|
||||
' This applies to both CLI auth and "ccs config" dashboard auth for these providers.\n'
|
||||
);
|
||||
console.error(
|
||||
' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.'
|
||||
process.stderr.write(
|
||||
' 403/Forbidden responses can be an early sign of account disablement.\n'
|
||||
);
|
||||
console.error(' Consider pausing the duplicate or using a different account.');
|
||||
console.error(` Details: ${ISSUE_509_URL}`);
|
||||
console.error('');
|
||||
process.stderr.write(
|
||||
' If you want to keep Google AI access, do not continue with this shared-account setup.\n'
|
||||
);
|
||||
process.stderr.write(
|
||||
' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.\n'
|
||||
);
|
||||
process.stderr.write(' Consider pausing the duplicate or using a different account.\n');
|
||||
process.stderr.write(` Details: ${ISSUE_509_URL}\n`);
|
||||
process.stderr.write('\n');
|
||||
}
|
||||
|
||||
function isBanWarningProvider(provider: CLIProxyProvider): boolean {
|
||||
@@ -324,27 +332,29 @@ export function warnOAuthBanRisk(provider: CLIProxyProvider): void {
|
||||
|
||||
shownBanWarnings.add(provider);
|
||||
const isAgy = provider === 'agy';
|
||||
console.error('');
|
||||
console.error(warn('Account safety warning (#509 - read before continuing)'));
|
||||
console.error(
|
||||
' Known risk: one Google account shared by "ccs gemini" + "ccs agy" can be disabled/banned.'
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(
|
||||
String(warn('Account safety warning (#509 - read before continuing)')) + '\n'
|
||||
);
|
||||
process.stderr.write(
|
||||
' Known risk: one Google account shared by "ccs gemini" + "ccs agy" can be disabled/banned.\n'
|
||||
);
|
||||
if (isAgy) {
|
||||
console.error(
|
||||
' Antigravity-specific warning: OAuth usage can still trigger suspension/ban patterns.'
|
||||
process.stderr.write(
|
||||
' Antigravity-specific warning: OAuth usage can still trigger suspension/ban patterns.\n'
|
||||
);
|
||||
}
|
||||
console.error(
|
||||
' This risk applies whether auth was done from CLI or from "ccs config" dashboard.'
|
||||
process.stderr.write(
|
||||
' This risk applies whether auth was done from CLI or from "ccs config" dashboard.\n'
|
||||
);
|
||||
console.error(
|
||||
' If you want to keep Google AI access, do not continue with this shared-account setup.'
|
||||
process.stderr.write(
|
||||
' If you want to keep Google AI access, do not continue with this shared-account setup.\n'
|
||||
);
|
||||
console.error(
|
||||
' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.'
|
||||
process.stderr.write(
|
||||
' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.\n'
|
||||
);
|
||||
console.error(` Details: ${ISSUE_509_URL}`);
|
||||
console.error('');
|
||||
process.stderr.write(` Details: ${ISSUE_509_URL}\n`);
|
||||
process.stderr.write('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -364,20 +374,22 @@ export function warnPossible403Ban(provider: CLIProxyProvider, errorMessage: str
|
||||
return false;
|
||||
}
|
||||
|
||||
console.error('');
|
||||
console.error(warn(`Account safety: ${provider} returned 403/Forbidden (possible disable/ban)`));
|
||||
console.error(
|
||||
' For gemini/agy flows this often means Google blocked or disabled the account.'
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(
|
||||
String(warn(`Account safety: ${provider} returned 403/Forbidden (possible disable/ban)`)) + '\n'
|
||||
);
|
||||
console.error(
|
||||
' If you want to keep Google AI access, stop using this account/provider pairing immediately.'
|
||||
process.stderr.write(
|
||||
' For gemini/agy flows this often means Google blocked or disabled the account.\n'
|
||||
);
|
||||
console.error(
|
||||
' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.'
|
||||
process.stderr.write(
|
||||
' If you want to keep Google AI access, stop using this account/provider pairing immediately.\n'
|
||||
);
|
||||
console.error(` Details: ${ISSUE_509_URL}`);
|
||||
console.error(` Error: "${truncate(errorMessage, 160)}"`);
|
||||
console.error('');
|
||||
process.stderr.write(
|
||||
' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.\n'
|
||||
);
|
||||
process.stderr.write(` Details: ${ISSUE_509_URL}\n`);
|
||||
process.stderr.write(` Error: "${truncate(errorMessage, 160)}"\n`);
|
||||
process.stderr.write('\n');
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -402,10 +414,12 @@ export function cleanupStaleAutoPauses(): void {
|
||||
for (const { provider, accountId } of session.accounts) {
|
||||
resumeAccount(provider, accountId);
|
||||
}
|
||||
console.error(
|
||||
info(
|
||||
`Restored ${session.accounts.length} auto-paused account(s) from crashed ${session.initiator} session`
|
||||
)
|
||||
process.stderr.write(
|
||||
String(
|
||||
info(
|
||||
`Restored ${session.accounts.length} auto-paused account(s) from crashed ${session.initiator} session`
|
||||
)
|
||||
) + '\n'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -561,15 +575,17 @@ export function enforceProviderIsolation(provider: CLIProxyProvider): number {
|
||||
});
|
||||
saveAutoPaused(freshData);
|
||||
|
||||
console.error('');
|
||||
console.error(info(`Account safety: auto-paused ${toPause.length} conflicting account(s)`));
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(
|
||||
String(info(`Account safety: auto-paused ${toPause.length} conflicting account(s)`)) + '\n'
|
||||
);
|
||||
for (const { provider: p, accountId } of toPause) {
|
||||
const acct = registry.providers[p]?.accounts[accountId];
|
||||
const display = acct?.email ? maskEmail(acct.email) : accountId;
|
||||
console.error(` ${display} (${p})`);
|
||||
process.stderr.write(` ${display} (${p})\n`);
|
||||
}
|
||||
console.error(' Will restore on session exit.');
|
||||
console.error('');
|
||||
process.stderr.write(' Will restore on session exit.\n');
|
||||
process.stderr.write('\n');
|
||||
|
||||
return toPause.length;
|
||||
}
|
||||
@@ -646,14 +662,14 @@ export function handleBanDetection(
|
||||
if (!isBanResponse(errorMessage, provider)) return false;
|
||||
|
||||
const actor = banActor(provider);
|
||||
console.error('');
|
||||
console.error(warn(`Account safety: account appears disabled by ${actor}`));
|
||||
console.error(` Account "${maskEmail(accountId)}" (${provider}) returned:`);
|
||||
console.error(` "${truncate(errorMessage, 120)}"`);
|
||||
console.error('');
|
||||
console.error(info('Auto-pausing this account to prevent further issues.'));
|
||||
console.error(` Resume later: ccs ${provider} --resume ${accountId}`);
|
||||
console.error('');
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(String(warn(`Account safety: account appears disabled by ${actor}`)) + '\n');
|
||||
process.stderr.write(` Account "${maskEmail(accountId)}" (${provider}) returned:\n`);
|
||||
process.stderr.write(` "${truncate(errorMessage, 120)}"\n`);
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(String(info('Auto-pausing this account to prevent further issues.')) + '\n');
|
||||
process.stderr.write(` Resume later: ccs ${provider} --resume ${accountId}\n`);
|
||||
process.stderr.write('\n');
|
||||
|
||||
return pauseAccount(provider, accountId);
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ async function askYesNoStep(rl: Interface, step: string, message: string): Promi
|
||||
const normalized = answer.toUpperCase();
|
||||
if (normalized === 'YES') return true;
|
||||
if (normalized === 'NO' || normalized === 'N' || normalized === '') return false;
|
||||
console.error(warn('Please type YES or NO.'));
|
||||
process.stderr.write(String(warn('Please type YES or NO.')) + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ async function askResponsibilityPhrase(rl: Interface): Promise<boolean> {
|
||||
if (normalizePhrase(answer) === ANTIGRAVITY_ACK_PHRASE) {
|
||||
return true;
|
||||
}
|
||||
console.error(warn('Phrase mismatch. Try again.'));
|
||||
process.stderr.write(String(warn('Phrase mismatch. Try again.')) + '\n');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -119,14 +119,22 @@ function printResponsibilityHeader(context: AgyRiskContext): void {
|
||||
? 'You are starting Antigravity OAuth account authorization.'
|
||||
: 'You are starting a live Antigravity CLI session (ccs agy).';
|
||||
|
||||
console.error('');
|
||||
console.error('╔══════════════════════════════════════════════════════════════════════╗');
|
||||
console.error('║ Antigravity Responsibility Confirmation (Mandatory) ║');
|
||||
console.error('╚══════════════════════════════════════════════════════════════════════╝');
|
||||
console.error(` ${contextLine}`);
|
||||
console.error(' Antigravity has active ban/suspension patterns for risky OAuth usage.');
|
||||
console.error(` Policy issue: ${ANTIGRAVITY_RISK_ISSUE_URL}`);
|
||||
console.error('');
|
||||
process.stderr.write('' + '\n');
|
||||
process.stderr.write(
|
||||
'╔══════════════════════════════════════════════════════════════════════╗' + '\n'
|
||||
);
|
||||
process.stderr.write(
|
||||
'║ Antigravity Responsibility Confirmation (Mandatory) ║' + '\n'
|
||||
);
|
||||
process.stderr.write(
|
||||
'╚══════════════════════════════════════════════════════════════════════╝' + '\n'
|
||||
);
|
||||
process.stderr.write(` ${contextLine}` + '\n');
|
||||
process.stderr.write(
|
||||
' Antigravity has active ban/suspension patterns for risky OAuth usage.' + '\n'
|
||||
);
|
||||
process.stderr.write(` Policy issue: ${ANTIGRAVITY_RISK_ISSUE_URL}` + '\n');
|
||||
process.stderr.write('' + '\n');
|
||||
}
|
||||
|
||||
export function hasAntigravityRiskAcceptanceFlag(args: string[]): boolean {
|
||||
@@ -178,9 +186,11 @@ export async function ensureCliAntigravityResponsibility(
|
||||
}
|
||||
|
||||
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
||||
console.error(fail('Antigravity responsibility acknowledgement required.'));
|
||||
console.error(' Re-run interactively and complete the 4-step confirmation.');
|
||||
console.error(' Non-interactive override: --accept-agr-risk');
|
||||
process.stderr.write(
|
||||
String(fail('Antigravity responsibility acknowledgement required.')) + '\n'
|
||||
);
|
||||
process.stderr.write(' Re-run interactively and complete the 4-step confirmation.' + '\n');
|
||||
process.stderr.write(' Non-interactive override: --accept-agr-risk' + '\n');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -216,8 +226,10 @@ export async function ensureCliAntigravityResponsibility(
|
||||
const step4 = await askResponsibilityPhrase(rl);
|
||||
if (!step4) return false;
|
||||
|
||||
console.error(ok('Antigravity responsibility acknowledgement accepted for this command.'));
|
||||
console.error(info('Proceeding with Antigravity flow...'));
|
||||
process.stderr.write(
|
||||
String(ok('Antigravity responsibility acknowledgement accepted for this command.')) + '\n'
|
||||
);
|
||||
process.stderr.write(String(info('Proceeding with Antigravity flow...')) + '\n');
|
||||
return true;
|
||||
} finally {
|
||||
rl.close();
|
||||
|
||||
@@ -11,6 +11,7 @@ import { randomBytes } from 'crypto';
|
||||
|
||||
import { CCS_INTERNAL_API_KEY, CCS_CONTROL_PANEL_SECRET } from '../config/generator';
|
||||
import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loader-facade';
|
||||
import { ProfileError } from '../../errors/error-types';
|
||||
|
||||
/**
|
||||
* Generate a cryptographically secure token.
|
||||
@@ -133,7 +134,7 @@ export function setVariantApiKey(variantName: string, apiKey: string | undefined
|
||||
const variant = config.cliproxy.variants[variantName];
|
||||
|
||||
if (!variant) {
|
||||
throw new Error(`Variant '${variantName}' not found`);
|
||||
throw new ProfileError(`Variant '${variantName}' not found`, variantName);
|
||||
}
|
||||
|
||||
if (!variant.auth) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import { ProfileError, ValidationError } from '../../errors/error-types';
|
||||
import type { AccountInfo } from '../accounts/account-manager';
|
||||
import {
|
||||
buildProviderMap,
|
||||
@@ -109,7 +110,7 @@ export function getKiroCLIAuthArgs(
|
||||
|
||||
const startUrl = options?.idcStartUrl?.trim();
|
||||
if (!startUrl) {
|
||||
throw new Error('Kiro IDC login requires --kiro-idc-start-url');
|
||||
throw new ValidationError('Kiro IDC login requires --kiro-idc-start-url', 'kiroIDCStartUrl');
|
||||
}
|
||||
|
||||
const args = [getKiroCLIAuthFlag('idc'), '--kiro-idc-start-url', startUrl];
|
||||
@@ -382,7 +383,7 @@ export function getManagementOAuthCallbackPath(): string {
|
||||
export function getOAuthConfig(provider: CLIProxyProvider): ProviderOAuthConfig {
|
||||
const config = OAUTH_CONFIGS[provider];
|
||||
if (!config) {
|
||||
throw new Error(`Unknown provider: ${provider}`);
|
||||
throw new ProfileError(`Unknown provider: ${provider}`, provider);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { fail, info, warn, color, ok } from '../../utils/ui';
|
||||
import { createLogger } from '../../services/logging';
|
||||
import { ensureCLIProxyBinary, getStoredConfiguredBackend } from '../binary-manager';
|
||||
import { generateConfig } from '../config/config-generator';
|
||||
import { AuthError, ConfigError } from '../../errors/error-types';
|
||||
import { CLIProxyBackend, CLIProxyProvider } from '../types';
|
||||
import {
|
||||
AccountInfo,
|
||||
@@ -247,8 +248,9 @@ export async function requestPasteCallbackStart(
|
||||
kiroMethod: options?.kiroMethod,
|
||||
});
|
||||
if (!startPath) {
|
||||
throw new Error(
|
||||
`Paste-callback start is not available for ${provider} with the selected method`
|
||||
throw new AuthError(
|
||||
`Paste-callback start is not available for ${provider} with the selected method`,
|
||||
provider
|
||||
);
|
||||
}
|
||||
const normalizedGitLabBaseUrl =
|
||||
@@ -261,7 +263,7 @@ export async function requestPasteCallbackStart(
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OAuth start failed with status ${response.status}`);
|
||||
throw new AuthError(`OAuth start failed with status ${response.status}`, provider);
|
||||
}
|
||||
|
||||
return (await response.json()) as PasteCallbackStartData;
|
||||
@@ -315,11 +317,11 @@ export function normalizeGitLabBaseUrl(baseUrl: string | undefined): string | un
|
||||
try {
|
||||
parsed = new URL(normalized);
|
||||
} catch {
|
||||
throw new Error('GitLab URL must be a valid http:// or https:// URL');
|
||||
throw new ConfigError('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://');
|
||||
throw new ConfigError('GitLab URL must use http:// or https://');
|
||||
}
|
||||
|
||||
parsed.hash = '';
|
||||
@@ -619,7 +621,7 @@ function buildOAuthArgs(
|
||||
if (provider === 'kiro') {
|
||||
const method = normalizeKiroAuthMethod(options.kiroMethod);
|
||||
if (!isKiroCLIAuthMethod(method)) {
|
||||
throw new Error(`Kiro auth method '${method}' is not supported by CLI flow.`);
|
||||
throw new AuthError(`Kiro auth method '${method}' is not supported by CLI flow.`, 'kiro');
|
||||
}
|
||||
args.push(
|
||||
...getKiroCLIAuthArgs(method, {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
getTokenRefreshOwnership,
|
||||
isRefreshDelegatedToCLIProxy,
|
||||
} from '../../provider-capabilities';
|
||||
import { AuthError } from '../../../errors/error-types';
|
||||
|
||||
/** Token refresh result */
|
||||
export interface ProviderRefreshResult {
|
||||
@@ -26,7 +27,7 @@ export interface ProviderRefreshResult {
|
||||
}
|
||||
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`Unhandled token refresh ownership: ${String(value)}`);
|
||||
throw new AuthError(`Unhandled token refresh ownership: ${String(value)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -135,13 +135,17 @@ export async function configureProviderModel(
|
||||
const safeDefaultIdx = defaultIdx >= 0 ? defaultIdx : 0;
|
||||
|
||||
// Show header with context (gradient like ccs doctor)
|
||||
console.error('');
|
||||
console.error(header(`Configure ${catalog.displayName} Model`));
|
||||
console.error('');
|
||||
console.error(dim(' Select which model to use for this provider.'));
|
||||
console.error(dim(' Models marked [Pro]/[Ultra] require a paid provider plan.'));
|
||||
console.error(dim(' Models marked [DEPRECATED] are not recommended for use.'));
|
||||
console.error('');
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(String(header(`Configure ${catalog.displayName} Model`)) + '\n');
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(String(dim(' Select which model to use for this provider.')) + '\n');
|
||||
process.stderr.write(
|
||||
String(dim(' Models marked [Pro]/[Ultra] require a paid provider plan.')) + '\n'
|
||||
);
|
||||
process.stderr.write(
|
||||
String(dim(' Models marked [DEPRECATED] are not recommended for use.')) + '\n'
|
||||
);
|
||||
process.stderr.write('\n');
|
||||
|
||||
// Interactive selection
|
||||
const selectedModel = await InteractivePrompt.selectFromList('Select model:', options, {
|
||||
@@ -209,19 +213,21 @@ export async function configureProviderModel(
|
||||
const selectedEntry = catalog.models.find((m) => m.id === selectedModel);
|
||||
const displayName = selectedEntry?.name || selectedModel;
|
||||
|
||||
console.error('');
|
||||
console.error(ok(`Model set to: ${bold(displayName)}`));
|
||||
console.error(dim(` Config saved: ${settingsPath}`));
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(String(ok(`Model set to: ${bold(displayName)}`)) + '\n');
|
||||
process.stderr.write(String(dim(` Config saved: ${settingsPath}`)) + '\n');
|
||||
|
||||
// Show deprecation warning if model is deprecated
|
||||
if (selectedEntry?.deprecated) {
|
||||
console.error('');
|
||||
console.error(color('[!] DEPRECATION WARNING', 'warning'));
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(String(color('[!] DEPRECATION WARNING', 'warning')) + '\n');
|
||||
const reason = selectedEntry.deprecationReason || 'This model is deprecated';
|
||||
console.error(dim(` ${reason}`));
|
||||
console.error(dim(' Consider using a non-deprecated model for better compatibility.'));
|
||||
process.stderr.write(String(dim(` ${reason}`)) + '\n');
|
||||
process.stderr.write(
|
||||
String(dim(' Consider using a non-deprecated model for better compatibility.')) + '\n'
|
||||
);
|
||||
}
|
||||
console.error('');
|
||||
process.stderr.write('\n');
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -231,7 +237,9 @@ export async function configureProviderModel(
|
||||
*/
|
||||
export async function showCurrentConfig(provider: CLIProxyProvider): Promise<void> {
|
||||
if (!supportsModelConfig(provider)) {
|
||||
console.error(info(`Provider ${provider} does not support model configuration`));
|
||||
process.stderr.write(
|
||||
String(info(`Provider ${provider} does not support model configuration`)) + '\n'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -247,33 +255,33 @@ export async function showCurrentConfig(provider: CLIProxyProvider): Promise<voi
|
||||
? canonicalizeModelForProvider(provider, currentModel)
|
||||
: undefined;
|
||||
|
||||
console.error('');
|
||||
console.error(header(`${catalog.displayName} Model Configuration`));
|
||||
console.error('');
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(String(header(`${catalog.displayName} Model Configuration`)) + '\n');
|
||||
process.stderr.write('\n');
|
||||
|
||||
if (currentModel) {
|
||||
const entry = catalog.models.find((m) => m.id === normalizedCurrentModel);
|
||||
const displayName = entry?.name || 'Unknown';
|
||||
console.error(
|
||||
` ${bold('Current:')} ${color(displayName, 'success')} ${dim(`(${currentModel})`)}`
|
||||
process.stderr.write(
|
||||
` ${bold('Current:')} ${color(displayName, 'success')} ${dim(`(${currentModel})`)}\n`
|
||||
);
|
||||
console.error(` ${bold('Config:')} ${dim(settingsPath)}`);
|
||||
process.stderr.write(` ${bold('Config:')} ${dim(settingsPath)}\n`);
|
||||
} else {
|
||||
console.error(` ${bold('Current:')} ${dim('(using defaults)')}`);
|
||||
console.error(` ${bold('Default:')} ${catalog.defaultModel}`);
|
||||
process.stderr.write(` ${bold('Current:')} ${dim('(using defaults)')}\n`);
|
||||
process.stderr.write(` ${bold('Default:')} ${catalog.defaultModel}\n`);
|
||||
}
|
||||
|
||||
console.error('');
|
||||
console.error(bold('Available models:'));
|
||||
console.error(dim(' [Pro]/[Ultra] = Requires a paid provider plan'));
|
||||
console.error(dim(' [DEPRECATED] = Not recommended for use'));
|
||||
console.error('');
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(String(bold('Available models:')) + '\n');
|
||||
process.stderr.write(String(dim(' [Pro]/[Ultra] = Requires a paid provider plan')) + '\n');
|
||||
process.stderr.write(String(dim(' [DEPRECATED] = Not recommended for use')) + '\n');
|
||||
process.stderr.write('\n');
|
||||
catalog.models.forEach((m) => {
|
||||
const isCurrent = m.id === normalizedCurrentModel;
|
||||
console.error(formatModelDetailed(m, isCurrent));
|
||||
process.stderr.write(String(formatModelDetailed(m, isCurrent)) + '\n');
|
||||
});
|
||||
|
||||
console.error('');
|
||||
console.error(dim(`Run "ccs ${provider} --config" to change`));
|
||||
console.error('');
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(String(dim(`Run "ccs ${provider} --config" to change`)) + '\n');
|
||||
process.stderr.write('\n');
|
||||
}
|
||||
@@ -167,7 +167,7 @@ describe('parseExecutorFlags', () => {
|
||||
beforeEach(() => {
|
||||
originalExitCode = process.exitCode as number | undefined;
|
||||
process.exitCode = 0;
|
||||
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
errorSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
exitSpy = jest
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined as never) as typeof process.exit);
|
||||
@@ -290,7 +290,7 @@ describe('validateFlagCombinations', () => {
|
||||
beforeEach(() => {
|
||||
originalExitCode = process.exitCode as number | undefined;
|
||||
process.exitCode = 0;
|
||||
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
errorSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -57,7 +57,7 @@ describe('warnBrokenModels', () => {
|
||||
let errorSpy: ReturnType<typeof jest.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
errorSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
mockGetCurrentModel.mockReset();
|
||||
mockIsModelBroken.mockReturnValue(false);
|
||||
mockGetModelIssueUrl.mockReturnValue(undefined);
|
||||
|
||||
@@ -207,9 +207,9 @@ export function parseExecutorFlags(
|
||||
const forceHeadless = args.includes('--headless');
|
||||
|
||||
if (pasteCallback && portForward) {
|
||||
console.error(fail('Cannot use --paste-callback with --port-forward'));
|
||||
console.error(' --paste-callback: Manually paste OAuth redirect URL');
|
||||
console.error(' --port-forward: Use SSH port forwarding for callback');
|
||||
process.stderr.write(String(fail('Cannot use --paste-callback with --port-forward')) + '\n');
|
||||
process.stderr.write(' --paste-callback: Manually paste OAuth redirect URL\n');
|
||||
process.stderr.write(' --port-forward: Use SSH port forwarding for callback\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -247,8 +247,8 @@ export function parseExecutorFlags(
|
||||
if (kiroMethodValue.present) {
|
||||
const rawMethod = kiroMethodValue.value;
|
||||
if (kiroMethodValue.missingValue || !rawMethod) {
|
||||
console.error(fail('--kiro-auth-method requires a value'));
|
||||
console.error(' Supported values: aws, aws-authcode, google, github, idc');
|
||||
process.stderr.write(String(fail('--kiro-auth-method requires a value')) + '\n');
|
||||
process.stderr.write(' Supported values: aws, aws-authcode, google, github, idc\n');
|
||||
process.exitCode = 1;
|
||||
// Caller must check parseFailed and bail — matching original return behavior
|
||||
return buildPartialFlags({
|
||||
@@ -280,8 +280,8 @@ export function parseExecutorFlags(
|
||||
}
|
||||
const normalized = rawMethod.trim().toLowerCase();
|
||||
if (!isKiroAuthMethod(normalized)) {
|
||||
console.error(fail(`Invalid --kiro-auth-method value: ${rawMethod}`));
|
||||
console.error(' Supported values: aws, aws-authcode, google, github, idc');
|
||||
process.stderr.write(String(fail(`Invalid --kiro-auth-method value: ${rawMethod}`)) + '\n');
|
||||
process.stderr.write(' Supported values: aws, aws-authcode, google, github, idc\n');
|
||||
process.exitCode = 1;
|
||||
return buildPartialFlags({
|
||||
forceAuth,
|
||||
@@ -318,7 +318,7 @@ export function parseExecutorFlags(
|
||||
if (kiroIDCStartUrlValue.present && kiroIDCStartUrlValue.value) {
|
||||
kiroIDCStartUrl = kiroIDCStartUrlValue.value;
|
||||
} else if (kiroIDCStartUrlValue.present) {
|
||||
console.error(fail('--kiro-idc-start-url requires a value'));
|
||||
process.stderr.write(String(fail('--kiro-idc-start-url requires a value')) + '\n');
|
||||
process.exitCode = 1;
|
||||
return buildPartialFlags({
|
||||
forceAuth,
|
||||
@@ -353,7 +353,7 @@ export function parseExecutorFlags(
|
||||
if (kiroIDCRegionValue.present && kiroIDCRegionValue.value) {
|
||||
kiroIDCRegion = kiroIDCRegionValue.value;
|
||||
} else if (kiroIDCRegionValue.present) {
|
||||
console.error(fail('--kiro-idc-region requires a value'));
|
||||
process.stderr.write(String(fail('--kiro-idc-region requires a value')) + '\n');
|
||||
process.exitCode = 1;
|
||||
return buildPartialFlags({
|
||||
forceAuth,
|
||||
@@ -388,8 +388,8 @@ export function parseExecutorFlags(
|
||||
if (kiroIDCFlowValue.present) {
|
||||
const rawFlow = kiroIDCFlowValue.value;
|
||||
if (kiroIDCFlowValue.missingValue || !rawFlow) {
|
||||
console.error(fail('--kiro-idc-flow requires a value'));
|
||||
console.error(' Supported values: authcode, device');
|
||||
process.stderr.write(String(fail('--kiro-idc-flow requires a value')) + '\n');
|
||||
process.stderr.write(' Supported values: authcode, device\n');
|
||||
process.exitCode = 1;
|
||||
return buildPartialFlags({
|
||||
forceAuth,
|
||||
@@ -420,8 +420,8 @@ export function parseExecutorFlags(
|
||||
}
|
||||
const normalized = rawFlow.trim().toLowerCase();
|
||||
if (!isKiroIDCFlow(normalized)) {
|
||||
console.error(fail(`Invalid --kiro-idc-flow value: ${rawFlow}`));
|
||||
console.error(' Supported values: authcode, device');
|
||||
process.stderr.write(String(fail(`Invalid --kiro-idc-flow value: ${rawFlow}`)) + '\n');
|
||||
process.stderr.write(' Supported values: authcode, device\n');
|
||||
process.exitCode = 1;
|
||||
return buildPartialFlags({
|
||||
forceAuth,
|
||||
@@ -458,7 +458,7 @@ export function parseExecutorFlags(
|
||||
if (gitlabBaseUrlValue.present && gitlabBaseUrlValue.value) {
|
||||
gitlabBaseUrl = gitlabBaseUrlValue.value.trim();
|
||||
} else if (gitlabBaseUrlValue.present) {
|
||||
console.error(fail('--gitlab-url requires a value'));
|
||||
process.stderr.write(String(fail('--gitlab-url requires a value')) + '\n');
|
||||
process.exitCode = 1;
|
||||
return buildPartialFlags({
|
||||
forceAuth,
|
||||
@@ -492,14 +492,14 @@ export function parseExecutorFlags(
|
||||
const thinkingParse = parseThinkingOverride(args);
|
||||
if (thinkingParse.error) {
|
||||
const { flag } = thinkingParse.error;
|
||||
console.error(fail(`${flag} requires a value`));
|
||||
process.stderr.write(String(fail(`${flag} requires a value`)) + '\n');
|
||||
|
||||
if (provider === 'codex') {
|
||||
console.error(' Codex examples: --effort xhigh, --effort high, --effort medium');
|
||||
console.error(' Alias: --thinking xhigh (same behavior)');
|
||||
process.stderr.write(' Codex examples: --effort xhigh, --effort high, --effort medium\n');
|
||||
process.stderr.write(' Alias: --thinking xhigh (same behavior)\n');
|
||||
} else {
|
||||
console.error(' Examples: --thinking low, --thinking 8192, --thinking off');
|
||||
console.error(' Levels: minimal, low, medium, high, xhigh, max, auto');
|
||||
process.stderr.write(' Examples: --thinking low, --thinking 8192, --thinking off\n');
|
||||
process.stderr.write(' Levels: minimal, low, medium, high, xhigh, max, auto\n');
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
@@ -511,7 +511,7 @@ export function parseExecutorFlags(
|
||||
const hasNo1mFlag = args.includes('--no-1m') || args.some((arg) => arg.startsWith('--no-1m='));
|
||||
|
||||
if (has1mFlag && hasNo1mFlag) {
|
||||
console.error(fail('Cannot use both --1m and --no-1m flags'));
|
||||
process.stderr.write(String(fail('Cannot use both --1m and --no-1m flags')) + '\n');
|
||||
process.exit(1);
|
||||
} else if (has1mFlag) {
|
||||
extendedContextOverride = true;
|
||||
@@ -584,7 +584,7 @@ export function validateFlagCombinations(
|
||||
} = parsed;
|
||||
|
||||
if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) {
|
||||
console.error(fail('--kiro-auth-method is only valid for ccs kiro'));
|
||||
process.stderr.write(String(fail('--kiro-auth-method is only valid for ccs kiro')) + '\n');
|
||||
process.exitCode = 1;
|
||||
return false;
|
||||
}
|
||||
@@ -594,19 +594,21 @@ export function validateFlagCombinations(
|
||||
provider !== 'kiro' &&
|
||||
!compositeProviders.includes('kiro')
|
||||
) {
|
||||
console.error(
|
||||
fail(
|
||||
'--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow are only valid for ccs kiro'
|
||||
)
|
||||
process.stderr.write(
|
||||
String(
|
||||
fail(
|
||||
'--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow are only valid for ccs kiro'
|
||||
)
|
||||
) + '\n'
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (kiroAuthMethod === 'idc' && !kiroIDCStartUrl) {
|
||||
console.error(fail('Kiro IDC login requires --kiro-idc-start-url'));
|
||||
console.error(
|
||||
' Example: ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start'
|
||||
process.stderr.write(String(fail('Kiro IDC login requires --kiro-idc-start-url')) + '\n');
|
||||
process.stderr.write(
|
||||
' Example: ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start\n'
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return false;
|
||||
@@ -617,10 +619,12 @@ export function validateFlagCombinations(
|
||||
kiroAuthMethod !== 'idc' &&
|
||||
(kiroIDCStartUrl || kiroIDCRegion || kiroIDCFlow)
|
||||
) {
|
||||
console.error(
|
||||
fail(
|
||||
'--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow require --kiro-auth-method idc'
|
||||
)
|
||||
process.stderr.write(
|
||||
String(
|
||||
fail(
|
||||
'--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow require --kiro-auth-method idc'
|
||||
)
|
||||
) + '\n'
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return false;
|
||||
@@ -628,7 +632,7 @@ export function validateFlagCombinations(
|
||||
|
||||
if ((gitlabTokenLogin || gitlabBaseUrl) && provider !== 'gitlab') {
|
||||
const flagName = gitlabTokenLogin ? getGitLabTokenLoginFlagName(args) : '--gitlab-url';
|
||||
console.error(fail(`${flagName} is only valid for ccs gitlab`));
|
||||
process.stderr.write(String(fail(`${flagName} is only valid for ccs gitlab`)) + '\n');
|
||||
process.exitCode = 1;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -95,18 +95,18 @@ export async function handleImport(context: AuthCoordinationContext): Promise<bo
|
||||
if (!forceImport) return false;
|
||||
|
||||
if (provider !== 'kiro') {
|
||||
console.error(fail('--import is only available for Kiro'));
|
||||
console.error(` Run "ccs ${provider} --auth" to authenticate`);
|
||||
process.stderr.write(String(fail('--import is only available for Kiro')) + '\n');
|
||||
process.stderr.write(` Run "ccs ${provider} --auth" to authenticate` + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
if (forceAuth) {
|
||||
console.error(fail('Cannot use --import with --auth'));
|
||||
console.error(' --import: Import existing token from Kiro IDE');
|
||||
console.error(' --auth: Trigger new OAuth flow in browser');
|
||||
process.stderr.write(String(fail('Cannot use --import with --auth')) + '\n');
|
||||
process.stderr.write(' --import: Import existing token from Kiro IDE' + '\n');
|
||||
process.stderr.write(' --auth: Trigger new OAuth flow in browser' + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
if (forceLogout) {
|
||||
console.error(fail('Cannot use --import with --logout'));
|
||||
process.stderr.write(String(fail('Cannot use --import with --logout')) + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -121,8 +121,8 @@ export async function handleImport(context: AuthCoordinationContext): Promise<bo
|
||||
...(setNickname ? { nickname: setNickname } : {}),
|
||||
});
|
||||
if (!authSuccess) {
|
||||
console.error(fail('Failed to import Kiro token from IDE'));
|
||||
console.error(' Make sure you are logged into Kiro IDE first');
|
||||
process.stderr.write(String(fail('Failed to import Kiro token from IDE')) + '\n');
|
||||
process.stderr.write(' Make sure you are logged into Kiro IDE first' + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
@@ -177,7 +177,10 @@ export async function runAntigravityGate(
|
||||
`Antigravity auth blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.`
|
||||
);
|
||||
}
|
||||
console.error(info('Remote proxy mode is active; local OAuth flow is skipped in --auth mode.'));
|
||||
process.stderr.write(
|
||||
String(info('Remote proxy mode is active; local OAuth flow is skipped in --auth mode.')) +
|
||||
'\n'
|
||||
);
|
||||
return { earlyReturn: true };
|
||||
}
|
||||
|
||||
@@ -189,10 +192,12 @@ export async function runAntigravityGate(
|
||||
acceptedByFlag: acceptAgyRisk,
|
||||
});
|
||||
if (!acknowledged) {
|
||||
console.error(
|
||||
fail(
|
||||
`Antigravity session blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.`
|
||||
)
|
||||
process.stderr.write(
|
||||
String(
|
||||
fail(
|
||||
`Antigravity session blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.`
|
||||
)
|
||||
) + '\n'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -262,9 +267,9 @@ export async function ensureProviderAuthentication(
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
const succeeded = compositeProviders.filter((p) => !failures.includes(p));
|
||||
console.error(fail(`Auth failed for: ${failures.join(', ')}`));
|
||||
process.stderr.write(String(fail(`Auth failed for: ${failures.join(', ')}`)) + '\n');
|
||||
if (succeeded.length > 0) {
|
||||
console.error(info(`Succeeded: ${succeeded.join(', ')}`));
|
||||
process.stderr.write(String(info(`Succeeded: ${succeeded.join(', ')}`)) + '\n');
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -279,9 +284,11 @@ export async function ensureProviderAuthentication(
|
||||
}
|
||||
}
|
||||
if (unauthenticatedProviders.length > 0) {
|
||||
console.error(fail('Composite variant requires authentication for multiple providers:'));
|
||||
process.stderr.write(
|
||||
String(fail('Composite variant requires authentication for multiple providers:')) + '\n'
|
||||
);
|
||||
for (const p of unauthenticatedProviders) {
|
||||
console.error(` - ${p} (run "ccs ${p} --auth")`);
|
||||
process.stderr.write(` - ${p} (run "ccs ${p} --auth")` + '\n');
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
import { ChildProcess } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import { fail, info, warn } from '../../utils/ui';
|
||||
import { createLogger } from '../../services/logging';
|
||||
|
||||
const logger = createLogger('cliproxy:executor');
|
||||
import {
|
||||
generateConfig,
|
||||
getProviderConfig,
|
||||
@@ -107,14 +110,14 @@ export async function execClaudeWithCLIProxy(
|
||||
|
||||
// Validate Claude CLI exists before proceeding
|
||||
if (!fs.existsSync(claudeCli)) {
|
||||
console.error(fail(`Claude CLI not found at: ${claudeCli}`));
|
||||
console.error(' Run "ccs doctor --fix" to reinstall or check your PATH');
|
||||
process.stderr.write(`${fail(`Claude CLI not found at: ${claudeCli}`)}\n`);
|
||||
process.stderr.write(' Run "ccs doctor --fix" to reinstall or check your PATH\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const log = (msg: string) => {
|
||||
if (verbose) {
|
||||
console.error(`[cliproxy] ${msg}`);
|
||||
logger.info('verbose', msg);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -203,16 +206,16 @@ export async function execClaudeWithCLIProxy(
|
||||
const thinkingCfg = getThinkingConfig();
|
||||
|
||||
if (thinkingParse.duplicateDisplays.length > 0) {
|
||||
console.warn(
|
||||
`[!] Multiple reasoning flags detected. Using first occurrence: ${thinkingParse.sourceDisplay}`
|
||||
process.stderr.write(
|
||||
`[!] Multiple reasoning flags detected. Using first occurrence: ${thinkingParse.sourceDisplay}\n`
|
||||
);
|
||||
}
|
||||
|
||||
if (thinkingParse.sourceFlag === '--effort' && provider !== 'codex') {
|
||||
console.warn(
|
||||
warn(
|
||||
process.stderr.write(
|
||||
`${warn(
|
||||
'`--effort` is primarily for codex. Continuing as alias of `--thinking` for compatibility.'
|
||||
)
|
||||
)}\n`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -227,7 +230,9 @@ export async function execClaudeWithCLIProxy(
|
||||
console.log(
|
||||
warn('Composite variants use per-tier config. Edit config.yaml to change tier models.')
|
||||
);
|
||||
console.error(` Use "ccs cliproxy edit ${variantName}" to modify composite variants`);
|
||||
process.stderr.write(
|
||||
` Use "ccs cliproxy edit ${variantName}" to modify composite variants\n`
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
// Run the one-time stale-pin migration on the pre-existing settings file
|
||||
@@ -369,7 +374,7 @@ export async function execClaudeWithCLIProxy(
|
||||
);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error(warn(`Failed to start HTTPS tunnel: ${err.message}`));
|
||||
process.stderr.write(`${warn(`Failed to start HTTPS tunnel: ${err.message}`)}\n`);
|
||||
throw new Error(`HTTPS tunnel startup failed: ${err.message}`);
|
||||
}
|
||||
} else if (useRemoteProxy && proxyConfig.protocol === 'https' && provider === 'codex') {
|
||||
@@ -526,16 +531,16 @@ export async function execClaudeWithCLIProxy(
|
||||
|
||||
const webSearchEnv = getWebSearchHookEnv();
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(
|
||||
`[cliproxy-browser-debug] keys=${Object.keys(env)
|
||||
logger.info('browser-env-keys', 'CCS_BROWSER_* keys in environment', {
|
||||
keys: Object.keys(env)
|
||||
.filter((key) => key.startsWith('CCS_BROWSER_'))
|
||||
.sort()
|
||||
.join(',')} ws=${env.CCS_BROWSER_DEVTOOLS_WS_URL || ''}`
|
||||
);
|
||||
.sort(),
|
||||
ws: env.CCS_BROWSER_DEVTOOLS_WS_URL || '',
|
||||
});
|
||||
}
|
||||
logEnvironment(env, webSearchEnv, verbose);
|
||||
if (imageAnalysisWarning) {
|
||||
console.error(info(imageAnalysisWarning));
|
||||
process.stderr.write(`${info(imageAnalysisWarning)}\n`);
|
||||
}
|
||||
|
||||
// 11b. Print thinking status feedback (TTY only, non-piped sessions)
|
||||
@@ -547,7 +552,7 @@ export async function execClaudeWithCLIProxy(
|
||||
thinkingParse.sourceDisplay
|
||||
);
|
||||
|
||||
console.error(`[i] Thinking: ${thinkingLabel} (${sourceLabel})`);
|
||||
process.stderr.write(`[i] Thinking: ${thinkingLabel} (${sourceLabel})\n`);
|
||||
}
|
||||
|
||||
// 12. Filter CCS flags, spawn Claude CLI, start quota monitor, wire cleanup
|
||||
|
||||
@@ -14,6 +14,9 @@ import { fail } from '../../utils/ui';
|
||||
import { getCliproxyWritablePath } from '../config/config-generator';
|
||||
import { getPortCheckCommand, getCatCommand } from '../../utils/platform-commands';
|
||||
import { CLIProxyBackend } from '../types';
|
||||
import { createLogger } from '../../services/logging';
|
||||
|
||||
const logger = createLogger('cliproxy:executor:lifecycle-manager');
|
||||
|
||||
/**
|
||||
* Wait for TCP port to become available
|
||||
@@ -62,7 +65,7 @@ export async function waitForProxyReady(
|
||||
export function spawnProxy(binaryPath: string, configPath: string, verbose: boolean): ChildProcess {
|
||||
const log = (msg: string) => {
|
||||
if (verbose) {
|
||||
console.error(`[cliproxy] ${msg}`);
|
||||
logger.info('executor.lifecycle.spawn_verbose', msg);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -81,7 +84,7 @@ export function spawnProxy(binaryPath: string, configPath: string, verbose: bool
|
||||
proxy.unref();
|
||||
|
||||
proxy.on('error', (error) => {
|
||||
console.error(fail(`CLIProxy spawn error: ${error.message}`));
|
||||
process.stderr.write(String(fail(`CLIProxy spawn error: ${error.message}`)) + '\n');
|
||||
});
|
||||
|
||||
return proxy;
|
||||
@@ -108,20 +111,20 @@ export async function waitForProxyReadyWithSpinner(
|
||||
readySpinner.fail(`${backendLabel} startup failed`);
|
||||
|
||||
const err = error as Error;
|
||||
console.error('');
|
||||
console.error(fail(`${backendLabel} failed to start`));
|
||||
console.error('');
|
||||
console.error('Possible causes:');
|
||||
console.error(` 1. Port ${port} already in use`);
|
||||
console.error(' 2. Binary crashed on startup');
|
||||
console.error(' 3. Invalid configuration');
|
||||
console.error('');
|
||||
console.error('Troubleshooting:');
|
||||
console.error(` - Check port: ${getPortCheckCommand(port)}`);
|
||||
console.error(' - Run with --verbose for detailed logs');
|
||||
console.error(` - View config: ${getCatCommand(configPath)}`);
|
||||
console.error(' - Try: ccs doctor --fix');
|
||||
console.error('');
|
||||
process.stderr.write('' + '\n');
|
||||
process.stderr.write(String(fail(`${backendLabel} failed to start`)) + '\n');
|
||||
process.stderr.write('' + '\n');
|
||||
process.stderr.write('Possible causes:' + '\n');
|
||||
process.stderr.write(` 1. Port ${port} already in use` + '\n');
|
||||
process.stderr.write(' 2. Binary crashed on startup' + '\n');
|
||||
process.stderr.write(' 3. Invalid configuration' + '\n');
|
||||
process.stderr.write('' + '\n');
|
||||
process.stderr.write('Troubleshooting:' + '\n');
|
||||
process.stderr.write(` - Check port: ${getPortCheckCommand(port)}` + '\n');
|
||||
process.stderr.write(' - Run with --verbose for detailed logs' + '\n');
|
||||
process.stderr.write(` - View config: ${getCatCommand(configPath)}` + '\n');
|
||||
process.stderr.write(' - Try: ccs doctor --fix' + '\n');
|
||||
process.stderr.write('' + '\n');
|
||||
|
||||
throw new Error(`CLIProxy startup failed: ${err.message}`);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,18 @@ export interface ModelWarningsContext {
|
||||
customSettingsPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a line to stderr preserving prior `console.error` semantics.
|
||||
*
|
||||
* These lines are primary user-facing model warnings (rendered via the ui
|
||||
* `warn()` helper or human-readable guidance the user must act on), so they
|
||||
* stay on stderr verbatim rather than being routed through the structured
|
||||
* logger.
|
||||
*/
|
||||
function stderr(line: string): void {
|
||||
process.stderr.write(String(line) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all active models for known issues and emit warnings.
|
||||
*
|
||||
@@ -40,17 +52,17 @@ export function warnBrokenModels(context: ModelWarningsContext): void {
|
||||
if (tierConfig && isModelBroken(tierConfig.provider, tierConfig.model)) {
|
||||
const modelEntry = findModel(tierConfig.provider, tierConfig.model);
|
||||
const issueUrl = getModelIssueUrl(tierConfig.provider, tierConfig.model);
|
||||
console.error('');
|
||||
console.error(
|
||||
stderr('');
|
||||
stderr(
|
||||
warn(
|
||||
`${tier} tier: ${modelEntry?.name || tierConfig.model} has known issues with Claude Code`
|
||||
)
|
||||
);
|
||||
console.error(' Tool calls will fail. Consider changing the model in config.yaml.');
|
||||
stderr(' Tool calls will fail. Consider changing the model in config.yaml.');
|
||||
if (issueUrl) {
|
||||
console.error(` Tracking: ${issueUrl}`);
|
||||
stderr(` Tracking: ${issueUrl}`);
|
||||
}
|
||||
console.error('');
|
||||
stderr('');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -59,22 +71,22 @@ export function warnBrokenModels(context: ModelWarningsContext): void {
|
||||
const modelEntry = findModel(provider, currentModel);
|
||||
const issueUrl = getModelIssueUrl(provider, currentModel);
|
||||
const replacementModel = getSuggestedReplacementModel(provider, currentModel);
|
||||
console.error('');
|
||||
console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`));
|
||||
stderr('');
|
||||
stderr(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`));
|
||||
if (replacementModel) {
|
||||
console.error(` Tool calls will fail. Use "${replacementModel}" instead.`);
|
||||
stderr(` Tool calls will fail. Use "${replacementModel}" instead.`);
|
||||
} else {
|
||||
console.error(' Tool calls will fail. Consider changing the model in config.yaml.');
|
||||
stderr(' Tool calls will fail. Consider changing the model in config.yaml.');
|
||||
}
|
||||
if (issueUrl) {
|
||||
console.error(` Tracking: ${issueUrl}`);
|
||||
stderr(` Tracking: ${issueUrl}`);
|
||||
}
|
||||
if (skipLocalAuth) {
|
||||
console.error(' Note: Model may be overridden by remote proxy configuration.');
|
||||
stderr(' Note: Model may be overridden by remote proxy configuration.');
|
||||
} else {
|
||||
console.error(` Run "ccs ${provider} --config" to change model.`);
|
||||
stderr(` Run "ccs ${provider} --config" to change model.`);
|
||||
}
|
||||
console.error('');
|
||||
stderr('');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,9 @@ import { fail, warn, info } from '../../utils/ui';
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import { handleBanDetection, warnPossible403Ban } from '../accounts/account-safety';
|
||||
import { CompositeTierConfig } from '../../config/unified-config-types';
|
||||
import { createLogger } from '../../services/logging';
|
||||
|
||||
const logger = createLogger('cliproxy:executor:retry-handler');
|
||||
|
||||
/**
|
||||
* Check if error is network-related
|
||||
@@ -32,12 +35,12 @@ export function isNetworkError(error: Error): boolean {
|
||||
* Handle network error with user-friendly message
|
||||
*/
|
||||
export function handleNetworkError(_error: Error): never {
|
||||
console.error('');
|
||||
console.error(fail('No network connection detected'));
|
||||
console.error('');
|
||||
console.error('CLIProxy binary download requires internet access.');
|
||||
console.error('Please check your network connection and try again.');
|
||||
console.error('');
|
||||
process.stderr.write(String('') + '\n');
|
||||
process.stderr.write(String(fail('No network connection detected')) + '\n');
|
||||
process.stderr.write(String('') + '\n');
|
||||
process.stderr.write(String('CLIProxy binary download requires internet access.') + '\n');
|
||||
process.stderr.write(String('Please check your network connection and try again.') + '\n');
|
||||
process.stderr.write(String('') + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -63,16 +66,16 @@ export async function handleTokenExpiration(
|
||||
}
|
||||
|
||||
// Token expired and refresh failed - trigger re-auth
|
||||
console.error(warn('OAuth token expired and refresh failed'));
|
||||
process.stderr.write(String(warn('OAuth token expired and refresh failed')) + '\n');
|
||||
if (tokenResult.error) {
|
||||
console.error(` ${tokenResult.error}`);
|
||||
process.stderr.write(String(` ${tokenResult.error}`) + '\n');
|
||||
}
|
||||
console.error(` Run "ccs ${provider} --auth" to re-authenticate`);
|
||||
process.stderr.write(String(` Run "ccs ${provider} --auth" to re-authenticate`) + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (tokenResult.refreshed && verbose) {
|
||||
console.error('[cliproxy] Token was refreshed proactively');
|
||||
logger.info('token.refreshed', 'Token was refreshed proactively', { provider, verbose });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +89,7 @@ export async function handleQuotaCheck(provider: CLIProxyProvider): Promise<void
|
||||
const preflight = await preflightCheck(provider);
|
||||
|
||||
if (!preflight.proceed) {
|
||||
console.error(fail(`Cannot start session: ${preflight.reason}`));
|
||||
process.stderr.write(String(fail(`Cannot start session: ${preflight.reason}`)) + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ import {
|
||||
import { withStartupLock } from '../services/startup-lock';
|
||||
import { killProcessOnPort } from '../../utils/platform-commands';
|
||||
import { stopQuotaMonitor } from '../quota/quota-manager';
|
||||
import { createLogger } from '../../services/logging';
|
||||
|
||||
const logger = createLogger('cliproxy:executor:session-bridge');
|
||||
|
||||
export interface ProxySessionResult {
|
||||
sessionId?: string;
|
||||
@@ -43,7 +46,7 @@ export async function checkOrJoinProxy(
|
||||
): Promise<ProxySessionResult> {
|
||||
const log = (msg: string) => {
|
||||
if (verbose) {
|
||||
console.error(`[cliproxy] ${msg}`);
|
||||
logger.info('proxy.check_or_join.trace', msg);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -129,16 +132,18 @@ export async function checkOrJoinProxy(
|
||||
|
||||
// Truly blocked by another application
|
||||
const { getPortCheckCommand } = await import('../../utils/platform-commands');
|
||||
console.error('');
|
||||
console.error(
|
||||
warn(
|
||||
`Port ${port} is blocked by ${proxyStatus.blocker.processName} (PID ${proxyStatus.blocker.pid})`
|
||||
)
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(
|
||||
String(
|
||||
warn(
|
||||
`Port ${port} is blocked by ${proxyStatus.blocker.processName} (PID ${proxyStatus.blocker.pid})`
|
||||
)
|
||||
) + '\n'
|
||||
);
|
||||
console.error('');
|
||||
console.error('To fix this, close the blocking application or run:');
|
||||
console.error(` ${getPortCheckCommand(port)}`);
|
||||
console.error('');
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write('To fix this, close the blocking application or run:\n');
|
||||
process.stderr.write(` ${getPortCheckCommand(port)}\n`);
|
||||
process.stderr.write('\n');
|
||||
throw new Error(`Port ${port} is in use by another application`);
|
||||
}
|
||||
|
||||
@@ -162,9 +167,13 @@ export function registerProxySession(
|
||||
const sessionId = registerSession(port, pid, installedVersion, backend);
|
||||
|
||||
if (verbose) {
|
||||
console.error(
|
||||
`[cliproxy] Registered session ${sessionId} with new proxy (PID ${pid}, version ${installedVersion})`
|
||||
);
|
||||
logger.info('proxy.session.registered', 'Registered session with new proxy', {
|
||||
sessionId,
|
||||
port,
|
||||
pid,
|
||||
version: installedVersion,
|
||||
backend,
|
||||
});
|
||||
}
|
||||
|
||||
return sessionId;
|
||||
@@ -184,7 +193,7 @@ export function setupCleanupHandlers(
|
||||
): void {
|
||||
const log = (msg: string) => {
|
||||
if (verbose) {
|
||||
console.error(`[cliproxy] ${msg}`);
|
||||
logger.info('proxy.cleanup.trace', msg);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -228,7 +237,9 @@ export function setupCleanupHandlers(
|
||||
});
|
||||
|
||||
claude.on('error', (error) => {
|
||||
console.error(require('../../utils/ui').fail(`Claude CLI error: ${error}`));
|
||||
process.stderr.write(
|
||||
String(require('../../utils/ui').fail(`Claude CLI error: ${error}`)) + '\n'
|
||||
);
|
||||
stopSessionResources();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function uploadTokenToRemote(
|
||||
|
||||
if (!target.isRemote) {
|
||||
if (verbose) {
|
||||
console.error('[upload] Remote mode not enabled, skipping upload');
|
||||
process.stderr.write('[upload] Remote mode not enabled, skipping upload\n');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -48,7 +48,9 @@ export async function uploadTokenToRemote(
|
||||
try {
|
||||
tokenContent = fs.readFileSync(tokenFilePath, 'utf-8');
|
||||
} catch (error) {
|
||||
console.error(fail(`Failed to read token file: ${(error as Error).message}`));
|
||||
process.stderr.write(
|
||||
String(fail(`Failed to read token file: ${(error as Error).message}`)) + '\n'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -56,7 +58,7 @@ export async function uploadTokenToRemote(
|
||||
try {
|
||||
JSON.parse(tokenContent);
|
||||
} catch {
|
||||
console.error(fail('Invalid token file: not valid JSON'));
|
||||
process.stderr.write(String(fail('Invalid token file: not valid JSON')) + '\n');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -67,7 +69,7 @@ export async function uploadTokenToRemote(
|
||||
const authKey = target.managementKey ?? target.authToken;
|
||||
|
||||
if (verbose) {
|
||||
console.error(`[upload] Uploading ${fileName} to ${target.host}`);
|
||||
process.stderr.write(`[upload] Uploading ${fileName} to ${target.host}\n`);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
@@ -95,7 +97,7 @@ export async function uploadTokenToRemote(
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
console.error(fail(`Upload failed: ${response.status} ${text}`));
|
||||
process.stderr.write(String(fail(`Upload failed: ${response.status} ${text}`)) + '\n');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -105,16 +107,18 @@ export async function uploadTokenToRemote(
|
||||
console.log(ok(`Token uploaded to remote server: ${fileName}`));
|
||||
return true;
|
||||
} else {
|
||||
console.error(fail(`Upload failed: ${result.error || result.message || 'Unknown error'}`));
|
||||
process.stderr.write(
|
||||
String(fail(`Upload failed: ${result.error || result.message || 'Unknown error'}`)) + '\n'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
console.error(fail('Upload timed out'));
|
||||
process.stderr.write(String(fail('Upload timed out')) + '\n');
|
||||
} else {
|
||||
console.error(fail(`Upload failed: ${(error as Error).message}`));
|
||||
process.stderr.write(String(fail(`Upload failed: ${(error as Error).message}`)) + '\n');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -132,14 +136,14 @@ export async function uploadAllTokensToRemote(tokenDir: string, verbose = false)
|
||||
|
||||
if (!target.isRemote) {
|
||||
if (verbose) {
|
||||
console.error('[upload] Remote mode not enabled, skipping upload');
|
||||
process.stderr.write('[upload] Remote mode not enabled, skipping upload\n');
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(tokenDir)) {
|
||||
if (verbose) {
|
||||
console.error(`[upload] Token directory does not exist: ${tokenDir}`);
|
||||
process.stderr.write(`[upload] Token directory does not exist: ${tokenDir}\n`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -148,7 +152,7 @@ export async function uploadAllTokensToRemote(tokenDir: string, verbose = false)
|
||||
|
||||
if (files.length === 0) {
|
||||
if (verbose) {
|
||||
console.error('[upload] No token files found');
|
||||
process.stderr.write('[upload] No token files found\n');
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import type { Socket } from 'net';
|
||||
import { createLogger } from '../../services/logging';
|
||||
|
||||
const logger = createLogger('cliproxy:https-tunnel-proxy');
|
||||
|
||||
export interface HttpsTunnelConfig {
|
||||
/** Remote server hostname */
|
||||
@@ -72,9 +75,13 @@ export class HttpsTunnelProxy {
|
||||
};
|
||||
}
|
||||
|
||||
private log(message: string): void {
|
||||
/**
|
||||
* Trace-level operational log gated on verbose mode (request routing, lifecycle chatter).
|
||||
* Errors/warnings are logged directly via logger.* and are not gated.
|
||||
*/
|
||||
private trace(message: string): void {
|
||||
if (this.config.verbose) {
|
||||
console.error(`[https-tunnel] ${message}`);
|
||||
logger.info('tunnel.trace', message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +111,7 @@ export class HttpsTunnelProxy {
|
||||
reject(new Error('Failed to bind to any port'));
|
||||
return;
|
||||
}
|
||||
this.log(
|
||||
this.trace(
|
||||
`Started on port ${this.port}, tunneling to https://${this.config.remoteHost}:${this.config.remotePort}`
|
||||
);
|
||||
resolve(this.port);
|
||||
@@ -132,7 +139,7 @@ export class HttpsTunnelProxy {
|
||||
this.server = null;
|
||||
this.port = null;
|
||||
this.startingPromise = null;
|
||||
this.log('Stopped');
|
||||
this.trace('Stopped');
|
||||
}
|
||||
|
||||
getPort(): number | null {
|
||||
@@ -182,7 +189,7 @@ export class HttpsTunnelProxy {
|
||||
const method = req.method || 'GET';
|
||||
const requestPath = req.url || '/';
|
||||
|
||||
this.log(
|
||||
this.trace(
|
||||
`${method} ${requestPath} → https://${this.config.remoteHost}:${this.config.remotePort}${requestPath}`
|
||||
);
|
||||
|
||||
@@ -190,7 +197,9 @@ export class HttpsTunnelProxy {
|
||||
await this.forwardRequest(req, res, requestPath);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.log(`Error: ${err.message}`);
|
||||
logger.error('tunnel.request_failed', 'Tunnel request handler failed', {
|
||||
err: { name: err.name, message: err.message },
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||
}
|
||||
@@ -231,26 +240,30 @@ export class HttpsTunnelProxy {
|
||||
|
||||
upstreamReq.on('timeout', () => {
|
||||
const timeoutError = new Error('Upstream request timeout');
|
||||
this.log(`Timeout: ${timeoutError.message}`);
|
||||
logger.warn('tunnel.upstream_timeout', timeoutError.message);
|
||||
upstreamReq.destroy();
|
||||
reject(timeoutError);
|
||||
});
|
||||
|
||||
upstreamReq.on('error', (err) => {
|
||||
this.log(`Upstream error: ${err.message}`);
|
||||
logger.error('tunnel.upstream_error', 'Upstream request error', {
|
||||
err: { name: err.name, message: err.message },
|
||||
});
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// Handle client disconnect (premature close)
|
||||
originalReq.on('error', (err) => {
|
||||
this.log(`Client request error: ${err.message}`);
|
||||
logger.error('tunnel.client_request_error', 'Client request error', {
|
||||
err: { name: err.name, message: err.message },
|
||||
});
|
||||
upstreamReq.destroy();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
originalReq.on('close', () => {
|
||||
if (!originalReq.complete) {
|
||||
this.log('Client disconnected prematurely');
|
||||
logger.warn('tunnel.client_premature_close', 'Client disconnected prematurely');
|
||||
upstreamReq.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -12,9 +12,6 @@
|
||||
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { URL } from 'url';
|
||||
import { ToolNameMapper, type Tool, type ContentBlock } from '../ai-providers/tool-name-mapper';
|
||||
import { sanitizeToolSchemas } from '../ai-providers/schema-sanitizer';
|
||||
@@ -28,7 +25,6 @@ import {
|
||||
import { getModelMaxLevel } from '../model-catalog';
|
||||
|
||||
import { createLogger } from '../../services/logging';
|
||||
import { getCcsDir } from '../../config/config-loader-facade';
|
||||
import {
|
||||
attachUpstreamResponseTimeout,
|
||||
writeForwardResponseHead,
|
||||
@@ -273,8 +269,6 @@ export class ToolSanitizationProxy {
|
||||
private server: http.Server | null = null;
|
||||
private port: number | null = null;
|
||||
private readonly config: Required<ToolSanitizationProxyConfig>;
|
||||
private readonly logFilePath: string;
|
||||
private readonly debugMode: boolean;
|
||||
private readonly logger = createLogger('cliproxy:tool-sanitization-proxy');
|
||||
|
||||
constructor(config: ToolSanitizationProxyConfig) {
|
||||
@@ -285,69 +279,6 @@ export class ToolSanitizationProxy {
|
||||
timeoutMs: config.timeoutMs ?? 120000,
|
||||
allowSelfSigned: config.allowSelfSigned ?? false,
|
||||
};
|
||||
this.debugMode = process.env.CCS_DEBUG === '1';
|
||||
this.logFilePath = this.initLogFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize log file path and ensure directory exists.
|
||||
*/
|
||||
private initLogFile(): string {
|
||||
const logsDir = path.join(getCcsDir(), 'logs');
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(logsDir)) {
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
}
|
||||
} catch (err) {
|
||||
// Fallback to temp directory if logs dir creation fails
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
`[tool-sanitization-proxy] Failed to create logs dir: ${(err as Error).message}`
|
||||
);
|
||||
}
|
||||
return path.join(os.tmpdir(), 'tool-sanitization-proxy.log');
|
||||
}
|
||||
|
||||
return path.join(logsDir, 'tool-sanitization-proxy.log');
|
||||
}
|
||||
|
||||
/**
|
||||
* Write log entry to file (always) and console (if CCS_DEBUG=1).
|
||||
*/
|
||||
private writeLog(level: 'info' | 'warn' | 'error', message: string): void {
|
||||
const timestamp = new Date().toISOString();
|
||||
const prefix = level === 'info' ? '[i]' : level === 'warn' ? '[!]' : '[X]';
|
||||
const logLine = `${timestamp} ${prefix} ${message}\n`;
|
||||
|
||||
// Always write to file
|
||||
try {
|
||||
fs.appendFileSync(this.logFilePath, logLine);
|
||||
} catch {
|
||||
// Silently ignore file write errors
|
||||
}
|
||||
|
||||
// Console output only in debug mode
|
||||
if (this.debugMode) {
|
||||
console.error(`${prefix} ${message}`);
|
||||
}
|
||||
|
||||
this.logger[level](level, message, {
|
||||
debugMode: this.debugMode,
|
||||
logFilePath: this.logFilePath,
|
||||
});
|
||||
}
|
||||
|
||||
private log(message: string): void {
|
||||
if (this.config.verbose) {
|
||||
this.writeLog('info', `[tool-sanitization-proxy] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private warn(message: string): void {
|
||||
if (this.config.warnOnSanitize) {
|
||||
this.writeLog('warn', `Tool name sanitized: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -365,7 +296,10 @@ export class ToolSanitizationProxy {
|
||||
this.server.listen(0, '127.0.0.1', () => {
|
||||
const address = this.server?.address();
|
||||
this.port = typeof address === 'object' && address ? address.port : 0;
|
||||
this.writeLog('info', `Tool sanitization proxy active (port ${this.port})`);
|
||||
this.logger.info(
|
||||
'tool-sanitization.proxy.active',
|
||||
`Tool sanitization proxy active (port ${this.port})`
|
||||
);
|
||||
resolve(this.port);
|
||||
});
|
||||
|
||||
@@ -418,7 +352,12 @@ export class ToolSanitizationProxy {
|
||||
const fullUpstreamUrl = new URL(requestPath, upstreamBase);
|
||||
const providerFromPath = extractProviderFromPathname(fullUpstreamUrl.pathname);
|
||||
|
||||
this.log(`${method} ${requestPath} → ${fullUpstreamUrl.href}`);
|
||||
if (this.config.verbose) {
|
||||
this.logger.info(
|
||||
'tool-sanitization.proxy.request',
|
||||
`${method} ${requestPath} → ${fullUpstreamUrl.href}`
|
||||
);
|
||||
}
|
||||
|
||||
// Only buffer+rewrite JSON POST requests
|
||||
const contentType = String(req.headers['content-type'] || '');
|
||||
@@ -458,9 +397,14 @@ export class ToolSanitizationProxy {
|
||||
}
|
||||
const normalizedModel = normalizeModelIdForRouting(modifiedBody.model, providerFromPath);
|
||||
if (normalizedModel !== modifiedBody.model) {
|
||||
this.writeLog(
|
||||
'warn',
|
||||
`[tool-sanitization-proxy] Model normalized for provider routing (${providerFromPath ?? 'root'}): "${modifiedBody.model}" → "${normalizedModel}"`
|
||||
this.logger.warn(
|
||||
'tool-sanitization.proxy.model-normalized',
|
||||
`Model normalized for provider routing (${providerFromPath ?? 'root'}): "${modifiedBody.model}" → "${normalizedModel}"`,
|
||||
{
|
||||
provider: providerFromPath ?? 'root',
|
||||
from: modifiedBody.model,
|
||||
to: normalizedModel,
|
||||
}
|
||||
);
|
||||
modifiedBody = { ...modifiedBody, model: normalizedModel };
|
||||
}
|
||||
@@ -480,14 +424,22 @@ export class ToolSanitizationProxy {
|
||||
|
||||
if (schemaResult.totalRemoved > 0) {
|
||||
for (const entry of schemaResult.removedByTool) {
|
||||
this.writeLog(
|
||||
'warn',
|
||||
`[tool-sanitization-proxy] Schema sanitized for "${entry.name}": removed ${entry.removed.length} Gemini-unsupported properties`
|
||||
this.logger.warn(
|
||||
'tool-sanitization.proxy.schema-sanitized',
|
||||
`Schema sanitized for "${entry.name}": removed ${entry.removed.length} Gemini-unsupported properties`,
|
||||
{ tool: entry.name, removedFields: entry.removed }
|
||||
);
|
||||
}
|
||||
if (this.config.verbose) {
|
||||
this.logger.info(
|
||||
'tool-sanitization.proxy.schema-summary',
|
||||
`Sanitized ${schemaResult.totalRemoved} schema properties across ${schemaResult.removedByTool.length} tool(s)`,
|
||||
{
|
||||
totalRemoved: schemaResult.totalRemoved,
|
||||
toolCount: schemaResult.removedByTool.length,
|
||||
}
|
||||
);
|
||||
}
|
||||
this.log(
|
||||
`Sanitized ${schemaResult.totalRemoved} schema properties across ${schemaResult.removedByTool.length} tool(s)`
|
||||
);
|
||||
}
|
||||
|
||||
let rewrittenTools = schemaResult.tools as Tool[];
|
||||
@@ -501,14 +453,26 @@ export class ToolSanitizationProxy {
|
||||
|
||||
if (fieldResult.totalRemoved > 0) {
|
||||
for (const entry of fieldResult.removedByTool) {
|
||||
this.writeLog(
|
||||
'warn',
|
||||
`[tool-sanitization-proxy] Tool fields stripped for "${entry.name}" (${providerFromPath ?? 'model-routed'}): ${entry.removed.join(', ')}`
|
||||
this.logger.warn(
|
||||
'tool-sanitization.proxy.fields-stripped',
|
||||
`Tool fields stripped for "${entry.name}" (${providerFromPath ?? 'model-routed'}): ${entry.removed.join(', ')}`,
|
||||
{
|
||||
tool: entry.name,
|
||||
provider: providerFromPath ?? 'model-routed',
|
||||
removedFields: entry.removed,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (this.config.verbose) {
|
||||
this.logger.info(
|
||||
'tool-sanitization.proxy.fields-summary',
|
||||
`Stripped ${fieldResult.totalRemoved} unsupported top-level tool field(s) across ${fieldResult.removedByTool.length} tool(s)`,
|
||||
{
|
||||
totalRemoved: fieldResult.totalRemoved,
|
||||
toolCount: fieldResult.removedByTool.length,
|
||||
}
|
||||
);
|
||||
}
|
||||
this.log(
|
||||
`Stripped ${fieldResult.totalRemoved} unsupported top-level tool field(s) across ${fieldResult.removedByTool.length} tool(s)`
|
||||
);
|
||||
}
|
||||
|
||||
rewrittenTools = fieldResult.tools;
|
||||
@@ -521,19 +485,32 @@ export class ToolSanitizationProxy {
|
||||
// Log sanitization warnings
|
||||
if (mapper.hasChanges()) {
|
||||
const changes = mapper.getChanges();
|
||||
for (const change of changes) {
|
||||
this.warn(`"${change.original}" → "${change.sanitized}"`);
|
||||
if (this.config.warnOnSanitize) {
|
||||
for (const change of changes) {
|
||||
this.logger.warn(
|
||||
'tool-sanitization.proxy.name-sanitized',
|
||||
`Tool name sanitized: "${change.original}" → "${change.sanitized}"`,
|
||||
{ from: change.original, to: change.sanitized }
|
||||
);
|
||||
}
|
||||
}
|
||||
if (this.config.verbose) {
|
||||
this.logger.info(
|
||||
'tool-sanitization.proxy.name-summary',
|
||||
`Sanitized ${changes.length} tool name(s)`,
|
||||
{ count: changes.length }
|
||||
);
|
||||
}
|
||||
this.log(`Sanitized ${changes.length} tool name(s)`);
|
||||
}
|
||||
|
||||
// Warn about hash collisions (multiple originals → same sanitized)
|
||||
if (mapper.hasCollisions()) {
|
||||
const collisions = mapper.getCollisions();
|
||||
for (const collision of collisions) {
|
||||
this.writeLog(
|
||||
'warn',
|
||||
`[tool-sanitization-proxy] Hash collision detected: ${collision.originals.join(', ')} → "${collision.sanitized}"`
|
||||
this.logger.warn(
|
||||
'tool-sanitization.proxy.hash-collision',
|
||||
`Hash collision detected: ${collision.originals.join(', ')} → "${collision.sanitized}"`,
|
||||
{ originals: collision.originals, sanitized: collision.sanitized }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -549,7 +526,11 @@ export class ToolSanitizationProxy {
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.log(`Error: ${err.message}`);
|
||||
if (this.config.verbose) {
|
||||
this.logger.error('tool-sanitization.proxy.request-error', `Error: ${err.message}`, {
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||
}
|
||||
@@ -845,9 +826,9 @@ export class ToolSanitizationProxy {
|
||||
clearUpstreamResponseTimeout();
|
||||
try {
|
||||
if (!lifecycle.hasContent && isSuccessResponse && lifecycle.hasData) {
|
||||
this.writeLog(
|
||||
'warn',
|
||||
'[tool-sanitization-proxy] Empty response detected from upstream (no content blocks). Injecting synthetic response to prevent client crash.'
|
||||
this.logger.warn(
|
||||
'tool-sanitization.proxy.empty-response',
|
||||
'Empty response detected from upstream (no content blocks). Injecting synthetic response to prevent client crash.'
|
||||
);
|
||||
clientRes.write(
|
||||
this.buildSyntheticErrorResponse(
|
||||
@@ -903,9 +884,9 @@ export class ToolSanitizationProxy {
|
||||
|
||||
// Safety net: if upstream sent data but no content blocks, inject synthetic response
|
||||
if (!lifecycle.hasContent && isSuccessResponse && lifecycle.hasData) {
|
||||
this.writeLog(
|
||||
'warn',
|
||||
'[tool-sanitization-proxy] Empty response detected from upstream (no content blocks). Injecting synthetic response to prevent client crash.'
|
||||
this.logger.warn(
|
||||
'tool-sanitization.proxy.empty-response',
|
||||
'Empty response detected from upstream (no content blocks). Injecting synthetic response to prevent client crash.'
|
||||
);
|
||||
clientRes.write(
|
||||
this.buildSyntheticErrorResponse(
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
buildClaudeQuotaWindows,
|
||||
buildClaudeCoreUsageSummary,
|
||||
} from './quota-fetcher-claude-normalizer';
|
||||
import { createLogger } from '../../services/logging';
|
||||
|
||||
const logger = createLogger('cliproxy:quota:claude');
|
||||
|
||||
export { buildClaudeQuotaWindows, buildClaudeCoreUsageSummary };
|
||||
|
||||
@@ -250,7 +253,11 @@ async function runClaudeUsageFetch(
|
||||
});
|
||||
|
||||
if (verbose) {
|
||||
console.error(`[i] Claude OAuth usage status: ${response.status} (attempt ${attempt})`);
|
||||
logger.info('quota.fetch.status', `Claude OAuth usage status: ${response.status}`, {
|
||||
provider: 'claude',
|
||||
status: response.status,
|
||||
attempt,
|
||||
});
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
@@ -331,10 +338,17 @@ async function runClaudeUsageFetch(
|
||||
: 'Unknown error';
|
||||
|
||||
if (verbose) {
|
||||
const errorDetails =
|
||||
error instanceof Error ? (error.stack ?? error.message) : JSON.stringify(error);
|
||||
console.error(
|
||||
`[!] Claude OAuth usage failed (attempt ${attempt}): ${lastError}${errorDetails ? `\n${errorDetails}` : ''}`
|
||||
logger.warn(
|
||||
'quota.fetch.failed',
|
||||
`Claude OAuth usage failed (attempt ${attempt}): ${lastError}`,
|
||||
{
|
||||
provider: 'claude',
|
||||
attempt,
|
||||
err:
|
||||
error instanceof Error
|
||||
? { name: error.name, message: error.message }
|
||||
: { message: String(error) },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ import { sanitizeEmail, isTokenExpired } from '../auth/auth-utils';
|
||||
import type { CodexQuotaResult, CodexQuotaWindow, CodexCoreUsageSummary } from './quota-types';
|
||||
import { sanitizeCodexFeatureLabel } from './quota-label-sanitizer';
|
||||
import { extractCanonicalEmailFromAccountId } from '../accounts/email-account-identity';
|
||||
import { createLogger } from '../../services/logging';
|
||||
|
||||
const logger = createLogger('cliproxy:quota:codex');
|
||||
|
||||
/** ChatGPT backend API base URL */
|
||||
const CODEX_API_BASE = 'https://chatgpt.com/backend-api';
|
||||
@@ -628,12 +631,17 @@ export async function fetchCodexQuota(
|
||||
accountId: string,
|
||||
verbose = false
|
||||
): Promise<CodexQuotaResult> {
|
||||
if (verbose) console.error(`[i] Fetching Codex quota for ${accountId}...`);
|
||||
if (verbose)
|
||||
logger.info('quota.fetch.start', 'Fetching Codex quota for account', { provider: 'codex' });
|
||||
|
||||
const authData = readCodexAuthData(accountId);
|
||||
if (!authData) {
|
||||
const error = 'Auth file not found for Codex account';
|
||||
if (verbose) console.error(`[!] Error: ${error}`);
|
||||
if (verbose)
|
||||
logger.warn('quota.fetch.auth_missing', error, {
|
||||
provider: 'codex',
|
||||
errorCode: 'auth_file_missing',
|
||||
});
|
||||
return buildCodexFailureResult(accountId, {
|
||||
error,
|
||||
errorCode: 'auth_file_missing',
|
||||
@@ -644,7 +652,11 @@ export async function fetchCodexQuota(
|
||||
|
||||
if (authData.isExpired) {
|
||||
const error = 'Token expired - re-authenticate with ccs cliproxy auth codex';
|
||||
if (verbose) console.error(`[!] Error: ${error}`);
|
||||
if (verbose)
|
||||
logger.warn('quota.fetch.token_expired', error, {
|
||||
provider: 'codex',
|
||||
errorCode: 'token_expired',
|
||||
});
|
||||
return buildCodexFailureResult(accountId, {
|
||||
error,
|
||||
errorCode: 'token_expired',
|
||||
@@ -656,7 +668,11 @@ export async function fetchCodexQuota(
|
||||
|
||||
if (!authData.accountId) {
|
||||
const error = 'Missing ChatGPT-Account-Id in auth file';
|
||||
if (verbose) console.error(`[!] Error: ${error}`);
|
||||
if (verbose)
|
||||
logger.warn('quota.fetch.missing_account_id', error, {
|
||||
provider: 'codex',
|
||||
errorCode: 'missing_account_id',
|
||||
});
|
||||
return buildCodexFailureResult(accountId, {
|
||||
error,
|
||||
errorCode: 'missing_account_id',
|
||||
@@ -685,7 +701,12 @@ export async function fetchCodexQuota(
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (verbose) console.error(`[i] Codex API status: ${response.status} (attempt ${attempt})`);
|
||||
if (verbose)
|
||||
logger.info('quota.fetch.status', `Codex API status: ${response.status}`, {
|
||||
provider: 'codex',
|
||||
status: response.status,
|
||||
attempt,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const bodyText = await response.text();
|
||||
@@ -696,10 +717,14 @@ export async function fetchCodexQuota(
|
||||
const windows = buildCodexQuotaWindows(data);
|
||||
const unknownWindowLabels = getUnknownCodexWindowLabels(windows);
|
||||
if (unknownWindowLabels.length > 0 && shouldLogCodexWindowWarnings(verbose)) {
|
||||
console.error(
|
||||
`[!] Codex quota detected unknown window labels: ${unknownWindowLabels.join(', ')}`
|
||||
logger.warn(
|
||||
'quota.fetch.unknown_window_labels',
|
||||
'Codex quota detected unknown window labels; window classification may need an update for upstream API changes',
|
||||
{
|
||||
provider: 'codex',
|
||||
labels: unknownWindowLabels,
|
||||
}
|
||||
);
|
||||
console.error(' Window classification may need an update for upstream API changes.');
|
||||
}
|
||||
const coreUsage = buildCodexCoreUsageSummary(windows);
|
||||
|
||||
@@ -714,7 +739,11 @@ export async function fetchCodexQuota(
|
||||
else if (normalized === 'team') planType = 'team';
|
||||
}
|
||||
|
||||
if (verbose) console.error(`[i] Codex windows found: ${windows.length}`);
|
||||
if (verbose)
|
||||
logger.info('quota.fetch.windows', `Codex windows found: ${windows.length}`, {
|
||||
provider: 'codex',
|
||||
count: windows.length,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -734,7 +763,19 @@ export async function fetchCodexQuota(
|
||||
: 'Unknown error';
|
||||
|
||||
if (verbose) {
|
||||
console.error(`[!] Codex quota error (attempt ${attempt}): ${lastErrorMsg}`);
|
||||
logger.warn(
|
||||
'quota.fetch.failed',
|
||||
`Codex quota error (attempt ${attempt}): ${lastErrorMsg}`,
|
||||
{
|
||||
provider: 'codex',
|
||||
attempt,
|
||||
errorCode: isAbortError ? 'network_timeout' : 'network_error',
|
||||
err:
|
||||
err instanceof Error
|
||||
? { name: err.name, message: err.message }
|
||||
: { message: String(err) },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Retry timeout once; other failures return immediately.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Auth file discovery for the Gemini CLI quota fetcher (direct-path credentials).
|
||||
*
|
||||
* Locates and parses the on-disk Gemini CLI auth file for a given account,
|
||||
* supporting both the legacy `gemini-<sanitized>.json` filename and the newer
|
||||
* `<email>-gen-lang-client-<projectId>.json` pattern. Scans both the active
|
||||
* auth directory and the paused-account directory.
|
||||
*
|
||||
* Returns the access token, project ID, expiry, and expired flag. The live
|
||||
* token is returned only so the caller can place it in an Authorization header;
|
||||
* it is never logged by this module or its callers.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { getAuthDir } from '../../config/config-generator';
|
||||
import { getPausedDir } from '../../accounts/account-manager';
|
||||
import { isTokenExpired } from '../../auth/auth-utils';
|
||||
import { sanitizeEmail } from '../../auth/auth-utils';
|
||||
import { isGeminiAuthFile } from './managed-request';
|
||||
import { extractAccessToken, extractExpiry, resolveGeminiCliProjectId } from './token-parsing';
|
||||
import type { GeminiCliAuthData } from './types';
|
||||
|
||||
/**
|
||||
* Read auth data from a Gemini CLI auth file on disk.
|
||||
*
|
||||
* Resolution order per auth directory:
|
||||
* 1. Exact legacy match: `gemini-<sanitized-account>.json`
|
||||
* 2. Directory scan for files matching {@link isGeminiAuthFile}, filtered
|
||||
* by account email/filename and Gemini type.
|
||||
*
|
||||
* Scans both the active auth dir and the paused-account dir. Returns null if
|
||||
* no usable auth file (with an access token) is found.
|
||||
*/
|
||||
export function readGeminiCliAuthData(accountId: string): GeminiCliAuthData | null {
|
||||
const authDirs = [getAuthDir(), getPausedDir()];
|
||||
const sanitizedId = sanitizeEmail(accountId);
|
||||
const expectedFiles = [
|
||||
`gemini-${sanitizedId}.json`, // Legacy format
|
||||
`${accountId}-gen-lang-client-`, // New format prefix (partial match)
|
||||
];
|
||||
|
||||
for (const authDir of authDirs) {
|
||||
if (!fs.existsSync(authDir)) continue;
|
||||
|
||||
// Try exact legacy match first
|
||||
const legacyPath = path.join(authDir, expectedFiles[0]);
|
||||
if (fs.existsSync(legacyPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(legacyPath, 'utf-8');
|
||||
const data = JSON.parse(content) as Record<string, unknown>;
|
||||
const accessToken = extractAccessToken(data);
|
||||
if (accessToken) {
|
||||
const projectId =
|
||||
typeof data.project_id === 'string'
|
||||
? data.project_id
|
||||
: resolveGeminiCliProjectId(String(data.account || ''));
|
||||
const expiry = extractExpiry(data);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
projectId,
|
||||
isExpired: isTokenExpired(expiry ?? undefined),
|
||||
expiresAt: expiry,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Continue to fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Scan directory for matching files
|
||||
const files = fs.readdirSync(authDir);
|
||||
for (const file of files) {
|
||||
if (!isGeminiAuthFile(file)) continue;
|
||||
|
||||
const candidatePath = path.join(authDir, file);
|
||||
try {
|
||||
const content = fs.readFileSync(candidatePath, 'utf-8');
|
||||
const data = JSON.parse(content) as Record<string, unknown>;
|
||||
|
||||
// Check if this file matches our account
|
||||
const fileEmail = typeof data.email === 'string' ? data.email : null;
|
||||
const fileType = typeof data.type === 'string' ? data.type : null;
|
||||
const matchesEmail = fileEmail === accountId;
|
||||
const matchesFilename = file.startsWith(`${accountId}-`) || file.includes(sanitizedId);
|
||||
const isGeminiType = fileType === 'gemini' || fileType === 'gemini-cli';
|
||||
|
||||
// Must match account AND be gemini type (or legacy gemini- prefix)
|
||||
if ((matchesEmail || matchesFilename) && (isGeminiType || file.startsWith('gemini-'))) {
|
||||
const accessToken = extractAccessToken(data);
|
||||
if (accessToken) {
|
||||
const projectId =
|
||||
typeof data.project_id === 'string'
|
||||
? data.project_id
|
||||
: resolveGeminiCliProjectId(String(data.account || ''));
|
||||
const expiry = extractExpiry(data);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
projectId,
|
||||
isExpired: isTokenExpired(expiry ?? undefined),
|
||||
expiresAt: expiry,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Bucket building for the Gemini CLI quota fetcher.
|
||||
*
|
||||
* Translates raw upstream quota buckets (snake_case and camelCase tolerant)
|
||||
* into the normalized {@link GeminiCliBucket} array grouped by model series
|
||||
* and token type. Delegates the grouping to the shared
|
||||
* `gemini-cli-quota-normalizer` so the grouping rules stay in one place.
|
||||
*/
|
||||
|
||||
import {
|
||||
buildGeminiCliBucketsFromParsedBuckets,
|
||||
type GeminiCliParsedBucket,
|
||||
} from '../gemini-cli-quota-normalizer';
|
||||
import type { GeminiCliBucket } from '../quota-types';
|
||||
import type { RawGeminiCliBucket } from './types';
|
||||
import { normalizeNumberValue, normalizeStringValue } from './shared-utils';
|
||||
|
||||
/**
|
||||
* Build a {@link GeminiCliBucket} array from raw upstream quota buckets.
|
||||
*
|
||||
* Each raw bucket is normalized into a {@link GeminiCliParsedBucket}:
|
||||
* - skips buckets with no resolvable model id
|
||||
* - coalesces remaining_fraction / remaining_amount / reset_time across
|
||||
* naming variants, with a fallback of `1` (full) when none are present
|
||||
* but a reset time or non-positive amount implies exhaustion
|
||||
* Then delegates to {@link buildGeminiCliBucketsFromParsedBuckets} for the
|
||||
* model-series and token-type grouping.
|
||||
*/
|
||||
export function buildGeminiCliBuckets(rawBuckets: RawGeminiCliBucket[]): GeminiCliBucket[] {
|
||||
const parsedBuckets = rawBuckets
|
||||
.map((bucket): GeminiCliParsedBucket | null => {
|
||||
const modelId = normalizeStringValue(bucket.model_id ?? bucket.modelId);
|
||||
if (!modelId) return null;
|
||||
|
||||
const tokenType = normalizeStringValue(bucket.token_type ?? bucket.tokenType);
|
||||
const remainingFractionRaw = normalizeNumberValue(
|
||||
bucket.remaining_fraction ?? bucket.remainingFraction
|
||||
);
|
||||
const remainingAmount = normalizeNumberValue(
|
||||
bucket.remaining_amount ?? bucket.remainingAmount
|
||||
);
|
||||
const resetTime = normalizeStringValue(bucket.reset_time ?? bucket.resetTime);
|
||||
|
||||
let fallbackFraction: number | null = null;
|
||||
if (remainingAmount !== null) {
|
||||
fallbackFraction = remainingAmount <= 0 ? 0 : null;
|
||||
} else if (resetTime) {
|
||||
fallbackFraction = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
modelId,
|
||||
tokenType,
|
||||
remainingFraction: remainingFractionRaw ?? fallbackFraction ?? 1,
|
||||
remainingAmount,
|
||||
resetTime,
|
||||
};
|
||||
})
|
||||
.filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null);
|
||||
|
||||
return buildGeminiCliBucketsFromParsedBuckets(parsedBuckets);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Constants for the Gemini CLI quota fetcher submodule.
|
||||
*
|
||||
* Google Cloud Code API endpoints, error-detail sanitization limits, and
|
||||
* upstream request timeouts. Extracted verbatim from the original god file;
|
||||
* do not change values without coordinating with callers and tests.
|
||||
*/
|
||||
|
||||
/** Google Cloud Code internal API base URL. */
|
||||
export const GEMINI_CLI_API_BASE = 'https://cloudcode-pa.googleapis.com';
|
||||
|
||||
/** Google Cloud Code API version path segment. */
|
||||
export const GEMINI_CLI_API_VERSION = 'v1internal';
|
||||
|
||||
/** retrieveUserQuota endpoint - returns bucket-based model quotas. */
|
||||
export const GEMINI_CLI_QUOTA_URL = `${GEMINI_CLI_API_BASE}/${GEMINI_CLI_API_VERSION}:retrieveUserQuota`;
|
||||
|
||||
/** loadCodeAssist endpoint - returns tier/credit metadata. */
|
||||
export const GEMINI_CLI_CODE_ASSIST_URL = `${GEMINI_CLI_API_BASE}/${GEMINI_CLI_API_VERSION}:loadCodeAssist`;
|
||||
|
||||
/** Max characters retained from a sanitized upstream error detail. */
|
||||
export const GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH = 320;
|
||||
|
||||
/** Suffix appended when an error detail is truncated. */
|
||||
export const GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX = '...[truncated]';
|
||||
|
||||
/** Credit type identifying Google One AI (paid tier) credits. */
|
||||
export const GEMINI_CLI_G1_CREDIT_TYPE = 'GOOGLE_ONE_AI';
|
||||
|
||||
/** Timeout for the primary (preferred) management API attempt, in ms. */
|
||||
export const MANAGEMENT_API_TIMEOUT_MS = 5000;
|
||||
|
||||
/** Timeout for the secondary / fallback upstream request, in ms. */
|
||||
export const SECONDARY_REQUEST_TIMEOUT_MS = 2000;
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Error parsing and failure-result builders for the Gemini CLI quota fetcher.
|
||||
*
|
||||
* Translates non-200 upstream responses into structured {@link GeminiCliQuotaResult}
|
||||
* failure payloads with sanitized error details, recovery hints, and provider
|
||||
* entitlement evidence. Token values in error bodies are always redacted before
|
||||
* being surfaced (see {@link sanitizeGeminiCliErrorDetail}).
|
||||
*/
|
||||
|
||||
import {
|
||||
buildProviderEntitlementEvidence,
|
||||
isModelCapacityExhausted,
|
||||
} from '../../auth/provider-entitlement-evidence';
|
||||
import type {
|
||||
GeminiCliFailureResultOptions,
|
||||
GeminiCliQuotaResult,
|
||||
ParsedGeminiCliErrorBody,
|
||||
} from './types';
|
||||
import {
|
||||
GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH,
|
||||
GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX,
|
||||
} from './constants';
|
||||
|
||||
/**
|
||||
* Build a structured failure {@link GeminiCliQuotaResult} with empty buckets.
|
||||
* Centralizes the common failure shape so each HTTP-status branch only needs
|
||||
* to supply its specific error/hint/entitlement fields.
|
||||
*/
|
||||
export function buildGeminiCliFailureResult(
|
||||
accountId: string,
|
||||
projectId: string | null,
|
||||
options: GeminiCliFailureResultOptions
|
||||
): GeminiCliQuotaResult {
|
||||
return {
|
||||
success: false,
|
||||
buckets: [],
|
||||
projectId,
|
||||
tierLabel: null,
|
||||
tierId: null,
|
||||
creditBalance: null,
|
||||
lastUpdated: Date.now(),
|
||||
accountId,
|
||||
error: options.error,
|
||||
httpStatus: options.httpStatus,
|
||||
errorCode: options.errorCode,
|
||||
errorDetail: options.errorDetail,
|
||||
actionHint: options.actionHint,
|
||||
retryable: options.retryable,
|
||||
needsReauth: options.needsReauth,
|
||||
isForbidden: options.isForbidden,
|
||||
entitlement: options.entitlement,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize an upstream error body for safe inclusion in a quota result.
|
||||
*
|
||||
* - Collapses HTML responses to a placeholder (never leaks provider HTML).
|
||||
* - Redacts common token/credential/secret field names and `Bearer <token>`.
|
||||
* - Collapses internal whitespace to single spaces.
|
||||
* - Truncates to {@link GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH} with a sentinel suffix.
|
||||
*
|
||||
* Returns undefined for empty input. Token values are never preserved.
|
||||
*/
|
||||
export function sanitizeGeminiCliErrorDetail(bodyText: string): string | undefined {
|
||||
const trimmed = bodyText.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)"\s*:\s*"[^"]*"/gi,
|
||||
'"$1":"[redacted]"'
|
||||
)
|
||||
.replace(/Bearer\s+[A-Za-z0-9._-]+/g, 'Bearer [redacted]')
|
||||
.replace(/\s+/g, ' ');
|
||||
|
||||
if (sanitized.length > GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH) {
|
||||
sanitized = `${sanitized.slice(
|
||||
0,
|
||||
GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH - GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX.length
|
||||
)}${GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX}`;
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively extract the first non-empty message-like field from a nested
|
||||
* error `details` array/object. Looks for `message`, `localizedMessage`,
|
||||
* `description`, `reason`, and `error` keys at any level.
|
||||
*/
|
||||
export function extractGeminiCliNestedMessage(value: unknown): string | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
const nested = extractGeminiCliNestedMessage(entry);
|
||||
if (nested) return nested;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
const directMessage = [
|
||||
record.message,
|
||||
record.localizedMessage,
|
||||
record.description,
|
||||
record.reason,
|
||||
record.error,
|
||||
].find(
|
||||
(candidate): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0
|
||||
);
|
||||
if (directMessage) {
|
||||
return directMessage;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an upstream error body into a structured {@link ParsedGeminiCliErrorBody}.
|
||||
*
|
||||
* Extracts a top-level code/status, a message (looking inside `error` objects
|
||||
* and nested `details`), and a sanitized error detail. Non-JSON bodies fall
|
||||
* back to the raw (sanitized) trimmed text as the message. HTML bodies surface
|
||||
* only as the sanitized detail placeholder, never as the message.
|
||||
*/
|
||||
export function parseGeminiCliErrorBody(bodyText: string): ParsedGeminiCliErrorBody {
|
||||
const trimmed = bodyText.trim();
|
||||
if (!trimmed) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const sanitizedDetail = sanitizeGeminiCliErrorDetail(trimmed);
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
const topLevelMessage = [parsed.message, parsed.error].find(
|
||||
(candidate): candidate is string =>
|
||||
typeof candidate === 'string' && candidate.trim().length > 0
|
||||
);
|
||||
const topLevelCode = [parsed.code, parsed.status].find(
|
||||
(candidate): candidate is string =>
|
||||
typeof candidate === 'string' && candidate.trim().length > 0
|
||||
);
|
||||
|
||||
if (parsed.error && typeof parsed.error === 'object') {
|
||||
const error = parsed.error as Record<string, unknown>;
|
||||
return {
|
||||
errorCode:
|
||||
[error.status, error.code, topLevelCode].find(
|
||||
(candidate): candidate is string =>
|
||||
typeof candidate === 'string' && candidate.trim().length > 0
|
||||
) || undefined,
|
||||
errorDetail: sanitizedDetail,
|
||||
message:
|
||||
[
|
||||
error.message,
|
||||
error.error,
|
||||
extractGeminiCliNestedMessage(error.details),
|
||||
topLevelMessage,
|
||||
].find(
|
||||
(candidate): candidate is string =>
|
||||
typeof candidate === 'string' && candidate.trim().length > 0
|
||||
) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
errorCode: topLevelCode,
|
||||
errorDetail: sanitizedDetail,
|
||||
message:
|
||||
[topLevelMessage, extractGeminiCliNestedMessage(parsed.details)].find(
|
||||
(candidate): candidate is string =>
|
||||
typeof candidate === 'string' && candidate.trim().length > 0
|
||||
) || undefined,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
errorDetail: sanitizedDetail,
|
||||
message: sanitizedDetail === '[HTML error response omitted]' ? undefined : trimmed,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a user-facing recovery hint for a 403 (forbidden) upstream response.
|
||||
* Inspects the parsed message/detail for verification, project, or generic
|
||||
* access signals and returns the matching recovery instruction.
|
||||
*/
|
||||
export function buildGeminiCliForbiddenActionHint(parsed: ParsedGeminiCliErrorBody): string {
|
||||
const combined = `${parsed.message || ''} ${parsed.errorDetail || ''}`.toLowerCase();
|
||||
if (combined.includes('verify') || combined.includes('verification')) {
|
||||
return 'Complete the Google account verification mentioned above, then retry quota refresh.';
|
||||
}
|
||||
if (combined.includes('project')) {
|
||||
return 'Confirm this Google project still has Gemini CLI quota access, then retry.';
|
||||
}
|
||||
return 'Check the Google account or workspace access shown above, then retry quota refresh.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a structured failure result from an HTTP non-200 upstream response.
|
||||
*
|
||||
* Status-specific behavior:
|
||||
* - 401: marks the result as needsReauth (user must re-run `ccs gemini --auth`)
|
||||
* - 403: marks forbidden with runtime-inferred not_entitled evidence and a
|
||||
* context-aware action hint
|
||||
* - 429: distinguishes MODEL_CAPACITY_EXHAUSTED (entitled but capacity-stressed)
|
||||
* from generic rate limiting
|
||||
* - >=500: retryable provider-unavailable result
|
||||
* - other: generic non-retryable quota_request_failed
|
||||
*/
|
||||
export function buildGeminiCliHttpFailureResult(
|
||||
accountId: string,
|
||||
projectId: string | null,
|
||||
status: number,
|
||||
bodyText: string
|
||||
): GeminiCliQuotaResult {
|
||||
const parsed = parseGeminiCliErrorBody(bodyText);
|
||||
|
||||
if (status === 401) {
|
||||
return buildGeminiCliFailureResult(accountId, projectId, {
|
||||
error: parsed.message || 'Token expired or invalid',
|
||||
httpStatus: 401,
|
||||
errorCode: parsed.errorCode || 'reauth_required',
|
||||
errorDetail: parsed.errorDetail,
|
||||
actionHint: 'Run ccs gemini --auth to reconnect this account.',
|
||||
needsReauth: true,
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
return buildGeminiCliFailureResult(accountId, projectId, {
|
||||
error: parsed.message || 'Quota access forbidden for this account',
|
||||
httpStatus: 403,
|
||||
errorCode: parsed.errorCode || 'quota_api_forbidden',
|
||||
errorDetail: parsed.errorDetail,
|
||||
actionHint: buildGeminiCliForbiddenActionHint(parsed),
|
||||
isForbidden: true,
|
||||
retryable: false,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'medium',
|
||||
accessState: 'not_entitled',
|
||||
capacityState: 'unknown',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (status === 429) {
|
||||
if (isModelCapacityExhausted(parsed.message, parsed.errorDetail, parsed.errorCode)) {
|
||||
return buildGeminiCliFailureResult(accountId, projectId, {
|
||||
error: parsed.message || 'Model capacity exhausted for this account right now',
|
||||
httpStatus: 429,
|
||||
errorCode: 'capacity_exhausted',
|
||||
errorDetail: parsed.errorDetail,
|
||||
actionHint:
|
||||
'Retry later or switch to another Gemini model. This indicates temporary model capacity, not an authentication failure.',
|
||||
retryable: true,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'medium',
|
||||
accessState: 'entitled',
|
||||
capacityState: 'capacity_exhausted',
|
||||
notes: 'Upstream returned MODEL_CAPACITY_EXHAUSTED for this model.',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return buildGeminiCliFailureResult(accountId, projectId, {
|
||||
error: parsed.message || 'Rate limited - try again later',
|
||||
httpStatus: 429,
|
||||
errorCode: parsed.errorCode || 'rate_limited',
|
||||
errorDetail: parsed.errorDetail,
|
||||
actionHint: 'Retry after a short delay.',
|
||||
retryable: true,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'low',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'rate_limited',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (status >= 500) {
|
||||
return buildGeminiCliFailureResult(accountId, projectId, {
|
||||
error: parsed.message || `Gemini quota service unavailable (HTTP ${status})`,
|
||||
httpStatus: status,
|
||||
errorCode: parsed.errorCode || 'provider_unavailable',
|
||||
errorDetail: parsed.errorDetail,
|
||||
actionHint: 'Retry later. This looks like a temporary Google upstream problem.',
|
||||
retryable: true,
|
||||
});
|
||||
}
|
||||
|
||||
return buildGeminiCliFailureResult(accountId, projectId, {
|
||||
error: parsed.message || `Gemini quota request failed (HTTP ${status})`,
|
||||
httpStatus: status,
|
||||
errorCode: parsed.errorCode || 'quota_request_failed',
|
||||
errorDetail: parsed.errorDetail,
|
||||
actionHint: 'Inspect the upstream response details and retry if appropriate.',
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Barrel for the Gemini CLI quota fetcher submodule.
|
||||
*
|
||||
* Re-exports the original public surface of `quota-fetcher-gemini-cli.ts`
|
||||
* so the file at the original path can be reduced to a thin re-export
|
||||
* (preserving import paths and signatures). Submodules are private
|
||||
* implementation detail; only the symbols below are part of the contract.
|
||||
*/
|
||||
|
||||
// Public API
|
||||
export { fetchGeminiCliQuota, fetchAllGeminiCliQuotas } from './quota-fetcher';
|
||||
|
||||
// Exported helpers (also part of the public surface - used by tests and
|
||||
// the bucket/grouping normalization tests).
|
||||
export { resolveGeminiCliProjectId } from './token-parsing';
|
||||
export { buildGeminiCliBuckets } from './bucket-building';
|
||||
|
||||
// Test exports: keep the original `__testExports` bag shape stable so the
|
||||
// existing test suite (which destructures `__testExports`) keeps working.
|
||||
export {
|
||||
sanitizeGeminiCliErrorDetail,
|
||||
extractGeminiCliNestedMessage,
|
||||
parseGeminiCliErrorBody,
|
||||
buildGeminiCliForbiddenActionHint,
|
||||
} from './error-parsing';
|
||||
|
||||
// Re-export `__testExports` as a single object to preserve the original
|
||||
// named-const export shape (`__testExports`).
|
||||
import {
|
||||
sanitizeGeminiCliErrorDetail,
|
||||
extractGeminiCliNestedMessage,
|
||||
parseGeminiCliErrorBody,
|
||||
buildGeminiCliForbiddenActionHint,
|
||||
} from './error-parsing';
|
||||
|
||||
export const __testExports = {
|
||||
sanitizeGeminiCliErrorDetail,
|
||||
extractGeminiCliNestedMessage,
|
||||
parseGeminiCliErrorBody,
|
||||
buildGeminiCliForbiddenActionHint,
|
||||
};
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Managed and direct HTTP request machinery for the Gemini CLI quota fetcher.
|
||||
*
|
||||
* Wraps the two upstream call paths used when fetching Gemini CLI quota and
|
||||
* supplementary metadata:
|
||||
* - managed: delegated through the CLIProxy management API (uses $TOKEN$
|
||||
* substitution so the local process never holds the live token)
|
||||
* - direct: bearer-token fetch against the Google Cloud Code endpoint
|
||||
*
|
||||
* The preferred path is configurable per call. On a 401 from the direct path,
|
||||
* the managed path is retried as a delegated-auth refresh fallback. A
|
||||
* `GeminiManagedAuthUnavailableError` is thrown when managed auth is required
|
||||
* but unreachable, so callers can surface a retryable failure result.
|
||||
*/
|
||||
|
||||
import {
|
||||
buildManagementHeaders,
|
||||
buildProxyUrl,
|
||||
getProxyTarget,
|
||||
} from '../../proxy/proxy-target-resolver';
|
||||
import { mapExternalProviderName } from '../../provider-capabilities';
|
||||
import { sanitizeEmail } from '../../auth/auth-utils';
|
||||
import { MANAGEMENT_API_TIMEOUT_MS, SECONDARY_REQUEST_TIMEOUT_MS } from './constants';
|
||||
import { getRemainingTimeoutMs, normalizeStringValue, safeParseJson } from './shared-utils';
|
||||
import type {
|
||||
ManagedGeminiAuthContext,
|
||||
ManagedGeminiAuthLookupResult,
|
||||
ManagedGeminiRequestResult,
|
||||
ManagedResponse,
|
||||
ManagementApiCallResponse,
|
||||
ManagementAuthFile,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Thrown when Gemini delegated auth refresh via the CLIProxy management API
|
||||
* is required but temporarily unreachable. Callers translate this into a
|
||||
* retryable failure result.
|
||||
*/
|
||||
export class GeminiManagedAuthUnavailableError extends Error {
|
||||
constructor() {
|
||||
super('CLIProxy managed Gemini auth is temporarily unavailable');
|
||||
this.name = 'GeminiManagedAuthUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a fetch Response into the normalized {@link ManagedResponse} shape.
|
||||
* `viaManagement` marks whether the response came through the managed API so
|
||||
* downstream log messages can attribute the source correctly.
|
||||
*/
|
||||
export async function readManagedResponse(
|
||||
response: Response,
|
||||
viaManagement: boolean
|
||||
): Promise<ManagedResponse> {
|
||||
const bodyText = await response.text();
|
||||
return {
|
||||
status: response.status,
|
||||
bodyText,
|
||||
json: safeParseJson(bodyText),
|
||||
viaManagement,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a filename matches the Gemini CLI auth file naming patterns.
|
||||
* Recognizes three patterns:
|
||||
* - legacy: gemini-*.json
|
||||
* - new: *-gen-lang-client-*.json
|
||||
* - email: contains "@" (verified against type inside the payload later)
|
||||
*/
|
||||
export function isGeminiAuthFile(filename: string): boolean {
|
||||
if (!filename.endsWith('.json')) return false;
|
||||
// Legacy pattern: gemini-email.json
|
||||
if (filename.startsWith('gemini-')) return true;
|
||||
// New pattern: email-gen-lang-client-projectId.json
|
||||
if (filename.includes('-gen-lang-client-')) return true;
|
||||
// Check if contains @ (email pattern) - will verify type inside
|
||||
if (filename.includes('@')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a management-API auth-file descriptor belongs to the
|
||||
* given Gemini account. Matches on provider/type normalized to "gemini",
|
||||
* then on email, filename, or sanitized email substring.
|
||||
*/
|
||||
export function isGeminiAuthFileForAccount(file: ManagementAuthFile, accountId: string): boolean {
|
||||
const rawProvider = normalizeStringValue(file.provider ?? file.type);
|
||||
if (!rawProvider || mapExternalProviderName(rawProvider) !== 'gemini') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const email = normalizeStringValue(file.email);
|
||||
const normalizedAccountId = accountId.trim().toLowerCase();
|
||||
if (email?.toLowerCase() === normalizedAccountId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedName = normalizeStringValue(file.name);
|
||||
if (!normalizedName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedFileName = normalizedName.toLowerCase();
|
||||
const sanitizedAccount = sanitizeEmail(accountId).toLowerCase();
|
||||
return (
|
||||
normalizedFileName === `gemini-${sanitizedAccount}.json` ||
|
||||
normalizedFileName.startsWith(`${normalizedAccountId}-gen-lang-client-`) ||
|
||||
normalizedFileName.includes(sanitizedAccount)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the management-API auth index for a Gemini account.
|
||||
* Hits `/v0/management/auth-files` and matches the entry for this account.
|
||||
* Returns `{ unavailable: true }` if the management API is unreachable or
|
||||
* returns a non-OK response, so callers can fall back to direct auth.
|
||||
*/
|
||||
export async function findManagedGeminiAuthIndex(
|
||||
accountId: string,
|
||||
timeoutMs: number
|
||||
): Promise<ManagedGeminiAuthLookupResult> {
|
||||
const target = getProxyTarget();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(buildProxyUrl(target, '/v0/management/auth-files'), {
|
||||
signal: controller.signal,
|
||||
headers: buildManagementHeaders(target),
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return { authIndex: null, unavailable: true };
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { files?: ManagementAuthFile[] };
|
||||
const match = data.files?.find((file) => isGeminiAuthFileForAccount(file, accountId));
|
||||
return { authIndex: match?.auth_index ?? null, unavailable: false };
|
||||
} catch {
|
||||
clearTimeout(timeoutId);
|
||||
return { authIndex: null, unavailable: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the management auth index for a Gemini account, deduping concurrent
|
||||
* lookups for the same account via the shared {@link ManagedGeminiAuthContext}.
|
||||
* The first caller wins; subsequent callers await the same promise.
|
||||
*/
|
||||
export async function getManagedGeminiAuthIndex(
|
||||
accountId: string,
|
||||
timeoutMs: number,
|
||||
context?: ManagedGeminiAuthContext
|
||||
): Promise<ManagedGeminiAuthLookupResult> {
|
||||
if (!context) {
|
||||
return await findManagedGeminiAuthIndex(accountId, timeoutMs);
|
||||
}
|
||||
|
||||
context.authIndexLookupPromise ??= findManagedGeminiAuthIndex(accountId, timeoutMs);
|
||||
return await context.authIndexLookupPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a single upstream request to the Gemini CLI API via the CLIProxy
|
||||
* management `/v0/management/api-call` endpoint. Uses `$TOKEN$` substitution
|
||||
* so the live token never leaves the management API. Returns
|
||||
* `{ unavailable: true }` if the management path is unreachable; returns
|
||||
* `{ response: null, unavailable: false }` if the request succeeded but no
|
||||
* matching auth file was found.
|
||||
*/
|
||||
export async function performManagedGeminiRequest(
|
||||
accountId: string,
|
||||
url: string,
|
||||
body: string,
|
||||
timeoutMs: number,
|
||||
authContext?: ManagedGeminiAuthContext
|
||||
): Promise<ManagedGeminiRequestResult> {
|
||||
const deadlineMs = Date.now() + timeoutMs;
|
||||
const lookupResult = await getManagedGeminiAuthIndex(
|
||||
accountId,
|
||||
getRemainingTimeoutMs(deadlineMs),
|
||||
authContext
|
||||
);
|
||||
if (lookupResult.unavailable) {
|
||||
return { response: null, unavailable: true };
|
||||
}
|
||||
|
||||
const authIndex = lookupResult.authIndex;
|
||||
if (authIndex === null || authIndex === undefined) {
|
||||
return { response: null, unavailable: false };
|
||||
}
|
||||
|
||||
const target = getProxyTarget();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), getRemainingTimeoutMs(deadlineMs));
|
||||
|
||||
try {
|
||||
const response = await fetch(buildProxyUrl(target, '/v0/management/api-call'), {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: buildManagementHeaders(target, {
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
auth_index: authIndex,
|
||||
method: 'POST',
|
||||
url,
|
||||
header: {
|
||||
Authorization: 'Bearer $TOKEN$',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: body,
|
||||
}),
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return { response: null, unavailable: true };
|
||||
}
|
||||
|
||||
const apiResponse = (await response.json()) as ManagementApiCallResponse;
|
||||
const bodyText = typeof apiResponse.body === 'string' ? apiResponse.body : '';
|
||||
return {
|
||||
response: {
|
||||
status: typeof apiResponse.status_code === 'number' ? apiResponse.status_code : 500,
|
||||
bodyText,
|
||||
json: safeParseJson(bodyText),
|
||||
viaManagement: true,
|
||||
},
|
||||
unavailable: false,
|
||||
};
|
||||
} catch {
|
||||
clearTimeout(timeoutId);
|
||||
return { response: null, unavailable: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a Gemini CLI upstream request, preferring the managed path when
|
||||
* requested and falling back to direct bearer-token auth. On a 401 from the
|
||||
* direct path, retries via managed auth as a delegated-auth refresh; throws
|
||||
* {@link GeminiManagedAuthUnavailableError} if that retry is unreachable.
|
||||
*
|
||||
* @param accountId Account identifier (email), used for managed auth lookup.
|
||||
* @param accessToken Bearer token for the direct path. Never logged.
|
||||
* @param url Target Gemini CLI API URL.
|
||||
* @param body JSON request body string.
|
||||
* @param preferManagement When true, try the managed path first.
|
||||
* @param authContext Optional shared context to dedupe auth-index lookups.
|
||||
*/
|
||||
export async function performGeminiCliRequest(
|
||||
accountId: string,
|
||||
accessToken: string,
|
||||
url: string,
|
||||
body: string,
|
||||
preferManagement = false,
|
||||
authContext?: ManagedGeminiAuthContext
|
||||
): Promise<ManagedResponse> {
|
||||
let managementAttempted = false;
|
||||
let managementUnavailable = false;
|
||||
|
||||
if (preferManagement) {
|
||||
managementAttempted = true;
|
||||
const managedResult = await performManagedGeminiRequest(
|
||||
accountId,
|
||||
url,
|
||||
body,
|
||||
MANAGEMENT_API_TIMEOUT_MS,
|
||||
authContext
|
||||
);
|
||||
managementUnavailable = managedResult.unavailable;
|
||||
if (managedResult.response) {
|
||||
return managedResult.response;
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
managementAttempted ? SECONDARY_REQUEST_TIMEOUT_MS : MANAGEMENT_API_TIMEOUT_MS
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const directResult = await readManagedResponse(response, false);
|
||||
if (directResult.status !== 401) {
|
||||
return directResult;
|
||||
}
|
||||
|
||||
if (managementAttempted) {
|
||||
if (managementUnavailable) {
|
||||
throw new GeminiManagedAuthUnavailableError();
|
||||
}
|
||||
return directResult;
|
||||
}
|
||||
|
||||
const managedResult = await performManagedGeminiRequest(
|
||||
accountId,
|
||||
url,
|
||||
body,
|
||||
SECONDARY_REQUEST_TIMEOUT_MS,
|
||||
authContext
|
||||
);
|
||||
if (managedResult.response) {
|
||||
return managedResult.response;
|
||||
}
|
||||
if (managedResult.unavailable) {
|
||||
throw new GeminiManagedAuthUnavailableError();
|
||||
}
|
||||
return directResult;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Top-level quota fetch orchestration for Gemini CLI accounts.
|
||||
*
|
||||
* Coordinates auth-file discovery, the managed/direct upstream quota request,
|
||||
* supplementary tier/credit metadata, and structured failure-result building.
|
||||
* Preserves the structured logging from the original god file (events:
|
||||
* gemini_cli.fetch_start, gemini_cli.auth_file_missing, gemini_cli.token_expired,
|
||||
* gemini_cli.missing_project_id, gemini_cli.api_status, gemini_cli.buckets_found,
|
||||
* gemini_cli.quota_fetch_error). Token values are never logged.
|
||||
*/
|
||||
|
||||
import { getProviderAccounts, setAccountTier } from '../../accounts/account-manager';
|
||||
import { getTokenExpiryTimestamp } from '../../auth/auth-utils';
|
||||
import { buildProviderEntitlementEvidence } from '../../auth/provider-entitlement-evidence';
|
||||
import type { GeminiCliQuotaResult } from '../quota-types';
|
||||
import { readGeminiCliAuthData } from './auth-file-discovery';
|
||||
import { buildGeminiCliBuckets } from './bucket-building';
|
||||
import { buildGeminiCliFailureResult, buildGeminiCliHttpFailureResult } from './error-parsing';
|
||||
import { GeminiManagedAuthUnavailableError, performGeminiCliRequest } from './managed-request';
|
||||
import { fetchGeminiCliSupplementary } from './supplementary-metadata';
|
||||
import { logger } from './shared-utils';
|
||||
import { GEMINI_CLI_QUOTA_URL } from './constants';
|
||||
import type { GeminiCliAuthData, GeminiCliQuotaResponse, ManagedGeminiAuthContext } from './types';
|
||||
|
||||
/**
|
||||
* Internal helper: fetch quota with already-validated auth data.
|
||||
*
|
||||
* Extracted to support the auto-refresh retry path: the caller resolves auth
|
||||
* data once (legacy file or managed), then this function performs the upstream
|
||||
* quota request and supplementary metadata fetch in parallel. On success it
|
||||
* persists the resolved tier back to the account via `setAccountTier`.
|
||||
*/
|
||||
export async function fetchWithAuthData(
|
||||
authData: GeminiCliAuthData,
|
||||
accountId: string,
|
||||
verbose: boolean
|
||||
): Promise<GeminiCliQuotaResult> {
|
||||
if (!authData.projectId) {
|
||||
const error = 'Cannot resolve project ID from auth file';
|
||||
if (verbose) {
|
||||
logger.error('gemini_cli.missing_project_id', `Error: ${error}`, {
|
||||
provider: 'gemini',
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
return buildGeminiCliFailureResult(accountId, null, {
|
||||
error,
|
||||
errorCode: 'missing_project_id',
|
||||
actionHint: 'Run ccs gemini --auth to reconnect this account and recover the project ID.',
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
|
||||
const authContext: ManagedGeminiAuthContext = {};
|
||||
const supplementaryPromise = fetchGeminiCliSupplementary(
|
||||
accountId,
|
||||
authData.accessToken,
|
||||
authData.projectId,
|
||||
verbose,
|
||||
authContext
|
||||
);
|
||||
const requestBody = JSON.stringify({ project: authData.projectId });
|
||||
|
||||
try {
|
||||
const response = await performGeminiCliRequest(
|
||||
accountId,
|
||||
authData.accessToken,
|
||||
GEMINI_CLI_QUOTA_URL,
|
||||
requestBody,
|
||||
authData.isExpired,
|
||||
authContext
|
||||
);
|
||||
|
||||
if (verbose) {
|
||||
const source = response.viaManagement ? 'managed' : 'direct';
|
||||
logger.info(
|
||||
'gemini_cli.api_status',
|
||||
`Gemini CLI API status via ${source}: ${response.status}`,
|
||||
{ provider: 'gemini', accountId, httpStatus: response.status, source }
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status !== 200) {
|
||||
return buildGeminiCliHttpFailureResult(
|
||||
accountId,
|
||||
authData.projectId,
|
||||
response.status,
|
||||
response.bodyText
|
||||
);
|
||||
}
|
||||
|
||||
const data = response.json as GeminiCliQuotaResponse | null;
|
||||
const rawBuckets = data?.buckets || [];
|
||||
const buckets = buildGeminiCliBuckets(rawBuckets);
|
||||
const supplementary = await supplementaryPromise;
|
||||
|
||||
if (verbose) {
|
||||
logger.info('gemini_cli.buckets_found', `Gemini CLI buckets found: ${buckets.length}`, {
|
||||
provider: 'gemini',
|
||||
accountId,
|
||||
bucketCount: buckets.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (supplementary.normalizedTier !== 'unknown') {
|
||||
setAccountTier('gemini', accountId, supplementary.normalizedTier);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
buckets,
|
||||
projectId: authData.projectId,
|
||||
tierLabel: supplementary.tierLabel,
|
||||
tierId: supplementary.tierId,
|
||||
creditBalance: supplementary.creditBalance,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: supplementary.normalizedTier,
|
||||
rawTierId: supplementary.tierId,
|
||||
rawTierLabel: supplementary.tierLabel,
|
||||
source: supplementary.tierId ? 'runtime_api' : 'runtime_inference',
|
||||
confidence: supplementary.tierId ? 'high' : 'medium',
|
||||
accessState: 'entitled',
|
||||
capacityState: 'available',
|
||||
}),
|
||||
lastUpdated: Date.now(),
|
||||
accountId,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof GeminiManagedAuthUnavailableError) {
|
||||
return buildGeminiCliFailureResult(accountId, authData.projectId, {
|
||||
error: 'Gemini delegated auth refresh is temporarily unavailable',
|
||||
errorCode: 'managed_auth_unavailable',
|
||||
errorDetail: err.message,
|
||||
actionHint: 'Retry later. CLIProxy management could not refresh this Gemini account.',
|
||||
retryable: true,
|
||||
});
|
||||
}
|
||||
|
||||
const errorMsg =
|
||||
err instanceof Error && err.name === 'AbortError'
|
||||
? 'Request timeout'
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: 'Unknown error';
|
||||
|
||||
if (verbose) {
|
||||
logger.error('gemini_cli.quota_fetch_error', `Gemini CLI quota error: ${errorMsg}`, {
|
||||
provider: 'gemini',
|
||||
accountId,
|
||||
err: err instanceof Error ? { name: err.name, message: errorMsg } : { message: errorMsg },
|
||||
});
|
||||
}
|
||||
|
||||
return buildGeminiCliFailureResult(accountId, authData.projectId, {
|
||||
error: errorMsg,
|
||||
errorCode:
|
||||
err instanceof Error && err.name === 'AbortError' ? 'network_timeout' : 'network_error',
|
||||
actionHint: 'Retry later. This looks temporary.',
|
||||
retryable: true,
|
||||
httpStatus: err instanceof Error && err.name === 'AbortError' ? 408 : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch quota for a single Gemini CLI account.
|
||||
*
|
||||
* Reads the on-disk auth file, emits the structured `gemini_cli.fetch_start`
|
||||
* and `gemini_cli.auth_file_missing` / `gemini_cli.token_expired` log events
|
||||
* (gated on `verbose`), and delegates to {@link fetchWithAuthData}. Token
|
||||
* values are never logged; only the expiry label is surfaced.
|
||||
*
|
||||
* @param accountId - Account identifier (email)
|
||||
* @param verbose - Show detailed diagnostics
|
||||
* @returns Quota result with buckets, percentages, tier, and entitlement evidence
|
||||
*/
|
||||
export async function fetchGeminiCliQuota(
|
||||
accountId: string,
|
||||
verbose = false
|
||||
): Promise<GeminiCliQuotaResult> {
|
||||
if (verbose) {
|
||||
logger.info('gemini_cli.fetch_start', `Fetching Gemini CLI quota for ${accountId}...`, {
|
||||
provider: 'gemini',
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
|
||||
const authData = readGeminiCliAuthData(accountId);
|
||||
if (!authData) {
|
||||
const error = 'Auth file not found for Gemini account';
|
||||
if (verbose) {
|
||||
logger.error('gemini_cli.auth_file_missing', `Error: ${error}`, {
|
||||
provider: 'gemini',
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
return buildGeminiCliFailureResult(accountId, null, {
|
||||
error,
|
||||
errorCode: 'auth_file_missing',
|
||||
actionHint: 'Run ccs gemini --auth to reconnect this account.',
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (authData.isExpired && verbose) {
|
||||
const expiresAt = getTokenExpiryTimestamp(authData.expiresAt);
|
||||
const expiryLabel = expiresAt ? new Date(expiresAt).toISOString() : 'unknown';
|
||||
logger.info(
|
||||
'gemini_cli.token_expired',
|
||||
`Gemini access token is expired (${expiryLabel}); quota requests will defer to managed auth when available.`,
|
||||
{ provider: 'gemini', accountId, tokenExpired: true, expiresAt: expiryLabel }
|
||||
);
|
||||
}
|
||||
|
||||
return await fetchWithAuthData(authData, accountId, verbose);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch quota for all configured Gemini CLI accounts in parallel.
|
||||
*
|
||||
* @param verbose - Show detailed diagnostics (forwarded to each per-account fetch)
|
||||
* @returns Array of `{ account, quota }` entries, one per active Gemini account.
|
||||
* Returns an empty array when there are no Gemini accounts configured.
|
||||
*/
|
||||
export async function fetchAllGeminiCliQuotas(
|
||||
verbose = false
|
||||
): Promise<{ account: string; quota: GeminiCliQuotaResult }[]> {
|
||||
const accounts = getProviderAccounts('gemini');
|
||||
|
||||
if (accounts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
accounts.map(async (account) => ({
|
||||
account: account.id,
|
||||
quota: await fetchGeminiCliQuota(account.id, verbose),
|
||||
}))
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Shared utilities for the Gemini CLI quota fetcher submodule.
|
||||
*
|
||||
* Includes the diagnostic logger (provider context = cliproxy:quota:gemini-cli)
|
||||
* and small value-normalization helpers used by multiple submodules. Token
|
||||
* values are never logged here; accountId is attached as provider context
|
||||
* only.
|
||||
*/
|
||||
|
||||
import { createLogger } from '../../../services/logging';
|
||||
|
||||
/**
|
||||
* Diagnostic-only logger for Gemini CLI quota fetch progress, upstream HTTP
|
||||
* status, and recovery hints. Token values live in auth files and are never
|
||||
* read into log messages.
|
||||
*/
|
||||
export const logger = createLogger('cliproxy:quota:gemini-cli');
|
||||
|
||||
/**
|
||||
* Normalize a raw value into a trimmed non-empty string, or null.
|
||||
* Returns null for empty strings, non-strings, or whitespace-only input.
|
||||
*/
|
||||
export function normalizeStringValue(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw value into a finite number, or null.
|
||||
* Accepts actual numbers and numeric strings; rejects NaN/Infinity.
|
||||
*/
|
||||
export function normalizeNumberValue(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort JSON.parse that returns null on failure instead of throwing.
|
||||
* Used when normalizing upstream response bodies into a `json` field.
|
||||
*/
|
||||
export function safeParseJson(bodyText: string): unknown {
|
||||
try {
|
||||
return JSON.parse(bodyText);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the remaining milliseconds available before a deadline, clamped
|
||||
* to a minimum of 1ms so AbortController timeouts are always positive.
|
||||
*/
|
||||
export function getRemainingTimeoutMs(deadlineMs: number): number {
|
||||
return Math.max(1, deadlineMs - Date.now());
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Supplementary tier/credit metadata fetcher for the Gemini CLI quota fetcher.
|
||||
*
|
||||
* Wraps the `loadCodeAssist` endpoint to resolve the account's tier label,
|
||||
* tier id, normalized tier (free/pro/ultra/unknown), and Google One AI credit
|
||||
* balance. Runs alongside the primary quota fetch and shares the same
|
||||
* managed-auth context so auth-index lookups are deduped.
|
||||
*/
|
||||
|
||||
import {
|
||||
getProviderTierLabel,
|
||||
normalizeProviderTierId,
|
||||
} from '../../auth/provider-entitlement-evidence';
|
||||
import { performGeminiCliRequest } from './managed-request';
|
||||
import { logger, normalizeNumberValue, normalizeStringValue } from './shared-utils';
|
||||
import { GEMINI_CLI_CODE_ASSIST_URL, GEMINI_CLI_G1_CREDIT_TYPE } from './constants';
|
||||
import type {
|
||||
GeminiCliCodeAssistResponse,
|
||||
GeminiCliSupplementaryInfo,
|
||||
ManagedGeminiAuthContext,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Resolve the tier id from a loadCodeAssist response.
|
||||
* Prefers the paid tier id, then the current tier id. Lowercased.
|
||||
* Returns null if neither is present.
|
||||
*/
|
||||
export function resolveGeminiCliTierId(payload: GeminiCliCodeAssistResponse | null): string | null {
|
||||
if (!payload) return null;
|
||||
const currentTier = payload.currentTier ?? payload.current_tier;
|
||||
const paidTier = payload.paidTier ?? payload.paid_tier;
|
||||
const rawId = normalizeStringValue(paidTier?.id) ?? normalizeStringValue(currentTier?.id);
|
||||
return rawId ? rawId.toLowerCase() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a human-readable tier label from the loadCodeAssist tier id.
|
||||
* Returns null when the tier id cannot be mapped to a known label.
|
||||
*/
|
||||
export function resolveGeminiCliTierLabel(
|
||||
payload: GeminiCliCodeAssistResponse | null
|
||||
): string | null {
|
||||
const tierId = resolveGeminiCliTierId(payload);
|
||||
return getProviderTierLabel(tierId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Google One AI credit balance for the account from the
|
||||
* loadCodeAssist response. Sums all credits with type `GOOGLE_ONE_AI` on the
|
||||
* paid tier (preferred) or current tier. Returns null if no matching credits
|
||||
* are present.
|
||||
*/
|
||||
export function resolveGeminiCliCreditBalance(
|
||||
payload: GeminiCliCodeAssistResponse | null
|
||||
): number | null {
|
||||
if (!payload) return null;
|
||||
|
||||
const paidTier = payload.paidTier ?? payload.paid_tier;
|
||||
const currentTier = payload.currentTier ?? payload.current_tier;
|
||||
const tier = paidTier ?? currentTier;
|
||||
if (!tier) return null;
|
||||
|
||||
const credits = tier.availableCredits ?? tier.available_credits ?? [];
|
||||
let total = 0;
|
||||
let found = false;
|
||||
for (const credit of credits) {
|
||||
const creditType = normalizeStringValue(credit.creditType ?? credit.credit_type);
|
||||
if (creditType !== GEMINI_CLI_G1_CREDIT_TYPE) continue;
|
||||
|
||||
const amount = normalizeNumberValue(credit.creditAmount ?? credit.credit_amount);
|
||||
if (amount !== null) {
|
||||
total += amount;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
|
||||
return found ? total : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch supplementary tier/credit metadata for a Gemini account via the
|
||||
* loadCodeAssist endpoint. Never throws: on any failure returns a
|
||||
* supplementary info with `normalizedTier: 'unknown'` so the primary quota
|
||||
* fetch can still complete. Diagnostic logging is gated on `verbose` and
|
||||
* records only accountId, HTTP status, and the source (managed/direct);
|
||||
* token values are never logged.
|
||||
*/
|
||||
export async function fetchGeminiCliSupplementary(
|
||||
accountId: string,
|
||||
accessToken: string,
|
||||
projectId: string,
|
||||
verbose: boolean,
|
||||
authContext?: ManagedGeminiAuthContext
|
||||
): Promise<GeminiCliSupplementaryInfo> {
|
||||
const requestBody = JSON.stringify({
|
||||
cloudaicompanionProject: projectId,
|
||||
metadata: {
|
||||
ideType: 'IDE_UNSPECIFIED',
|
||||
platform: 'PLATFORM_UNSPECIFIED',
|
||||
pluginType: 'GEMINI',
|
||||
duetProject: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await performGeminiCliRequest(
|
||||
accountId,
|
||||
accessToken,
|
||||
GEMINI_CLI_CODE_ASSIST_URL,
|
||||
requestBody,
|
||||
false,
|
||||
authContext
|
||||
);
|
||||
|
||||
if (response.status !== 200) {
|
||||
if (verbose) {
|
||||
const source = response.viaManagement ? 'managed' : 'direct';
|
||||
logger.info(
|
||||
'gemini_cli.supplementary_metadata_unavailable',
|
||||
`Gemini CLI supplementary metadata unavailable via ${source}: HTTP ${response.status}`,
|
||||
{ provider: 'gemini', accountId, httpStatus: response.status, source }
|
||||
);
|
||||
}
|
||||
return { tierLabel: null, tierId: null, creditBalance: null, normalizedTier: 'unknown' };
|
||||
}
|
||||
|
||||
const payload = response.json as GeminiCliCodeAssistResponse | null;
|
||||
return {
|
||||
tierLabel: resolveGeminiCliTierLabel(payload),
|
||||
tierId: resolveGeminiCliTierId(payload),
|
||||
creditBalance: resolveGeminiCliCreditBalance(payload),
|
||||
normalizedTier: normalizeProviderTierId(resolveGeminiCliTierId(payload)),
|
||||
};
|
||||
} catch (error) {
|
||||
if (verbose) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.info(
|
||||
'gemini_cli.supplementary_metadata_skipped',
|
||||
`Gemini CLI supplementary metadata skipped: ${message}`,
|
||||
{
|
||||
provider: 'gemini',
|
||||
accountId,
|
||||
err: error instanceof Error ? { name: error.name, message } : { message },
|
||||
}
|
||||
);
|
||||
}
|
||||
return { tierLabel: null, tierId: null, creditBalance: null, normalizedTier: 'unknown' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Token parsing helpers for Gemini CLI auth files.
|
||||
*
|
||||
* Extracts access tokens, expiry, and project IDs from the raw auth file
|
||||
* payload. Gemini auth files come in two structural variants:
|
||||
* - flat: { access_token, expired, project_id, account }
|
||||
* - nested:{ token: { access_token, expiry }, project_id, account }
|
||||
* These helpers handle both without throwing on shape mismatches.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extract the access token from a Gemini auth file payload.
|
||||
* Handles both flat (`access_token`) and nested (`token.access_token`) shapes.
|
||||
* Returns null if no usable token is present.
|
||||
*/
|
||||
export function extractAccessToken(data: Record<string, unknown>): string | null {
|
||||
// Flat structure: { access_token: "..." }
|
||||
if (typeof data.access_token === 'string') {
|
||||
return data.access_token;
|
||||
}
|
||||
// Nested structure: { token: { access_token: "..." } }
|
||||
if (data.token && typeof data.token === 'object') {
|
||||
const token = data.token as Record<string, unknown>;
|
||||
if (typeof token.access_token === 'string') {
|
||||
return token.access_token;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the token expiry from a Gemini auth file payload.
|
||||
* Handles both flat (`expired`) and nested (`token.expiry`) shapes.
|
||||
* Returns the raw string/number, or null if absent.
|
||||
*/
|
||||
export function extractExpiry(data: Record<string, unknown>): string | number | null {
|
||||
// Flat structure: { expired: "..." }
|
||||
if (typeof data.expired === 'string') {
|
||||
return data.expired;
|
||||
}
|
||||
if (typeof data.expired === 'number') {
|
||||
return data.expired;
|
||||
}
|
||||
// Nested structure: { token: { expiry: "..." } }
|
||||
if (data.token && typeof data.token === 'object') {
|
||||
const token = data.token as Record<string, unknown>;
|
||||
if (typeof token.expiry === 'string') {
|
||||
return token.expiry;
|
||||
}
|
||||
if (typeof token.expiry === 'number') {
|
||||
return token.expiry;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the project ID from an auth file's `account` field.
|
||||
* Input shape: "user@example.com (cloudaicompanion-abc-123)"
|
||||
* Returns the last parenthesized segment, or null if no match.
|
||||
*
|
||||
* Example:
|
||||
* "user@example.com (cloudaicompanion-abc-123)" -> "cloudaicompanion-abc-123"
|
||||
*/
|
||||
export function resolveGeminiCliProjectId(accountField: string): string | null {
|
||||
const regex = /\(([^()]+)\)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
let lastMatch: string | null = null;
|
||||
while ((match = regex.exec(accountField)) !== null) {
|
||||
lastMatch = match[1];
|
||||
}
|
||||
return lastMatch;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Shared types for the Gemini CLI quota fetcher submodule.
|
||||
*
|
||||
* Extracted from the original quota-fetcher-gemini-cli.ts god file. These
|
||||
* interfaces describe raw API response shapes, internal parsed structures,
|
||||
* and managed-auth context used across the submodules.
|
||||
*/
|
||||
|
||||
import type { GeminiCliBucket, GeminiCliQuotaResult } from '../quota-types';
|
||||
import type { ProviderEntitlementEvidence } from '../../auth/provider-entitlement-types';
|
||||
|
||||
/** Auth data extracted from a Gemini CLI auth file. */
|
||||
export interface GeminiCliAuthData {
|
||||
accessToken: string;
|
||||
projectId: string | null;
|
||||
isExpired: boolean;
|
||||
expiresAt: string | number | null;
|
||||
}
|
||||
|
||||
/** Raw bucket shape returned by the Gemini CLI quota API. */
|
||||
export interface RawGeminiCliBucket {
|
||||
model_id?: string;
|
||||
modelId?: string;
|
||||
token_type?: string | null;
|
||||
tokenType?: string | null;
|
||||
remaining_fraction?: number;
|
||||
remainingFraction?: number;
|
||||
remaining_amount?: number;
|
||||
remainingAmount?: number;
|
||||
reset_time?: string | null;
|
||||
resetTime?: string | null;
|
||||
}
|
||||
|
||||
/** Raw quota API response wrapper. */
|
||||
export interface GeminiCliQuotaResponse {
|
||||
buckets?: RawGeminiCliBucket[];
|
||||
}
|
||||
|
||||
/** Credit entry inside a tier (supports snake_case and camelCase variants). */
|
||||
export interface GeminiCliCredits {
|
||||
creditType?: string;
|
||||
credit_type?: string;
|
||||
creditAmount?: string | number;
|
||||
credit_amount?: string | number;
|
||||
}
|
||||
|
||||
/** User tier inside a loadCodeAssist response. */
|
||||
export interface GeminiCliUserTier {
|
||||
id?: string;
|
||||
availableCredits?: GeminiCliCredits[];
|
||||
available_credits?: GeminiCliCredits[];
|
||||
}
|
||||
|
||||
/** loadCodeAssist response shape (currentTier + paidTier). */
|
||||
export interface GeminiCliCodeAssistResponse {
|
||||
currentTier?: GeminiCliUserTier | null;
|
||||
current_tier?: GeminiCliUserTier | null;
|
||||
paidTier?: GeminiCliUserTier | null;
|
||||
paid_tier?: GeminiCliUserTier | null;
|
||||
}
|
||||
|
||||
/** Parsed error body extracted from an upstream non-200 response. */
|
||||
export interface ParsedGeminiCliErrorBody {
|
||||
errorCode?: string;
|
||||
errorDetail?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** Supplementary tier/credit info resolved alongside the quota buckets. */
|
||||
export interface GeminiCliSupplementaryInfo {
|
||||
tierLabel: string | null;
|
||||
tierId: string | null;
|
||||
creditBalance: number | null;
|
||||
normalizedTier: 'free' | 'pro' | 'ultra' | 'unknown';
|
||||
}
|
||||
|
||||
/** Auth-file entry as returned by the CLIProxy management API. */
|
||||
export interface ManagementAuthFile {
|
||||
auth_index?: string | number;
|
||||
provider?: string;
|
||||
type?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** api-call response envelope from the CLIProxy management endpoint. */
|
||||
export interface ManagementApiCallResponse {
|
||||
status_code?: number;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
/** Normalized HTTP response used by both direct and managed code paths. */
|
||||
export interface ManagedResponse {
|
||||
status: number;
|
||||
bodyText: string;
|
||||
json: unknown;
|
||||
viaManagement: boolean;
|
||||
}
|
||||
|
||||
/** Per-account managed-auth context used to dedupe auth-index lookups. */
|
||||
export interface ManagedGeminiAuthContext {
|
||||
authIndexLookupPromise?: Promise<ManagedGeminiAuthLookupResult>;
|
||||
}
|
||||
|
||||
/** Result of looking up a Gemini auth file index via management API. */
|
||||
export interface ManagedGeminiAuthLookupResult {
|
||||
authIndex: string | number | null;
|
||||
unavailable: boolean;
|
||||
}
|
||||
|
||||
/** Result of performing a managed Gemini upstream request. */
|
||||
export interface ManagedGeminiRequestResult {
|
||||
response: ManagedResponse | null;
|
||||
unavailable: boolean;
|
||||
}
|
||||
|
||||
/** Options bag for {@link buildGeminiCliFailureResult}. */
|
||||
export interface GeminiCliFailureResultOptions {
|
||||
error: string;
|
||||
httpStatus?: number;
|
||||
errorCode?: string;
|
||||
errorDetail?: string;
|
||||
actionHint?: string;
|
||||
retryable?: boolean;
|
||||
needsReauth?: boolean;
|
||||
isForbidden?: boolean;
|
||||
entitlement?: ProviderEntitlementEvidence;
|
||||
}
|
||||
|
||||
// Re-export the public result shapes so callers can import everything from
|
||||
// the barrel without reaching into quota-types directly.
|
||||
export type { GeminiCliBucket, GeminiCliQuotaResult, ProviderEntitlementEvidence };
|
||||
@@ -9,6 +9,13 @@ import * as fs from 'node:fs';
|
||||
import { getAccountTokenPath, getProviderAccounts } from '../accounts/account-manager';
|
||||
import type { GhcpQuotaResult, GhcpQuotaSnapshot } from './quota-types';
|
||||
import { clampPercent } from '../../utils/percentage';
|
||||
import { createLogger } from '../../services/logging';
|
||||
|
||||
// Diagnostic-only logger: token load failures, fetch progress, and upstream
|
||||
// error reasons. accountId is attached as provider context; token values are
|
||||
// never logged (the error string from readGhcpAccessToken is generic and
|
||||
// contains no token material).
|
||||
const logger = createLogger('cliproxy:quota:ghcp');
|
||||
|
||||
const GHCP_USAGE_URL = 'https://api.github.com/copilot_internal/user';
|
||||
const GHCP_USAGE_TIMEOUT_MS = 10000;
|
||||
@@ -174,11 +181,22 @@ export async function fetchGhcpQuota(accountId: string, verbose = false): Promis
|
||||
const { accessToken, error } = readGhcpAccessToken(accountId);
|
||||
if (!accessToken) {
|
||||
// Safe diagnostic: accountId + generic error only (never log token values/file contents).
|
||||
if (verbose) console.error(`[!] ghcp quota token error (${accountId}): ${error}`);
|
||||
if (verbose) {
|
||||
logger.error('ghcp.token_load_error', `ghcp quota token error (${accountId}): ${error}`, {
|
||||
provider: 'ghcp',
|
||||
accountId,
|
||||
reason: error ?? 'unknown',
|
||||
});
|
||||
}
|
||||
return buildEmptyQuotaResult(error || 'Failed to load auth token', accountId);
|
||||
}
|
||||
|
||||
if (verbose) console.error(`[i] Fetching ghcp quota for ${accountId}...`);
|
||||
if (verbose) {
|
||||
logger.info('ghcp.fetch_start', `Fetching ghcp quota for ${accountId}...`, {
|
||||
provider: 'ghcp',
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), GHCP_USAGE_TIMEOUT_MS);
|
||||
|
||||
+17
-1075
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* fetchAccountQuota: top-level Antigravity account quota orchestrator.
|
||||
*
|
||||
* Reads the local auth file, calls loadCodeAssist (project + tier), then
|
||||
* fetchAvailableModels. Merges the results into a QuotaResult, attaching
|
||||
* entitlement evidence and persisting the resolved tier back to the account
|
||||
* manager. Preserves the structured createLogger('cliproxy:quota:fetcher')
|
||||
* logging from P3 (quota.fetch.start / auth_state / project_resolved / models).
|
||||
*/
|
||||
|
||||
import type { CLIProxyProvider } from '../../types';
|
||||
import type { AccountTier } from '../../accounts/account-manager';
|
||||
import { setAccountTier } from '../../accounts/account-manager';
|
||||
import { buildProviderEntitlementEvidence } from '../../auth/provider-entitlement-evidence';
|
||||
import { createLogger } from '../../../services/logging';
|
||||
|
||||
import { readAuthData } from './auth-file-reader';
|
||||
import { fetchAvailableModels } from './available-models-fetcher';
|
||||
import { getProjectId } from './project-lookup';
|
||||
import { mergeAntigravityTierEvidence } from './status-classifier';
|
||||
import type { QuotaResult } from './types';
|
||||
|
||||
const logger = createLogger('cliproxy:quota:fetcher');
|
||||
|
||||
/**
|
||||
* Fetch quota for an Antigravity account.
|
||||
*
|
||||
* @param provider - Provider name (only 'agy' supported)
|
||||
* @param accountId - Account identifier (email)
|
||||
* @param verbose - Show detailed diagnostics
|
||||
* @returns Quota result with models and percentages
|
||||
*/
|
||||
export async function fetchAccountQuota(
|
||||
provider: CLIProxyProvider,
|
||||
accountId: string,
|
||||
verbose = false
|
||||
): Promise<QuotaResult> {
|
||||
if (verbose)
|
||||
logger.info('quota.fetch.start', 'Fetching quota for account', { provider, accountId });
|
||||
|
||||
// Only Antigravity supports quota fetching
|
||||
if (provider !== 'agy') {
|
||||
const error = `Quota not supported for provider: ${provider}`;
|
||||
if (verbose) logger.warn('quota.fetch.unsupported_provider', error, { provider });
|
||||
// Stable machine code so callers branch on a code, not the human string.
|
||||
// This is "no quota API for this provider", which is healthy — distinct
|
||||
// from a transient fetch failure or an expired token.
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
lastUpdated: Date.now(),
|
||||
error,
|
||||
errorCode: 'quota_not_supported',
|
||||
};
|
||||
}
|
||||
|
||||
// Read auth data from auth file (checks both active and paused directories)
|
||||
const authData = readAuthData(provider, accountId);
|
||||
if (!authData) {
|
||||
const error = 'Auth file not found for account';
|
||||
if (verbose) logger.warn('quota.fetch.auth_missing', error, { provider, accountId });
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
lastUpdated: Date.now(),
|
||||
error,
|
||||
errorCode: 'auth_file_missing',
|
||||
actionHint: 'Reconnect this account so CCS can read a current auth token.',
|
||||
};
|
||||
}
|
||||
|
||||
const accessToken = authData.accessToken;
|
||||
if (verbose) {
|
||||
const expiryState = authData.isExpired
|
||||
? 'expired'
|
||||
: authData.expiresAt
|
||||
? `expires ${authData.expiresAt}`
|
||||
: 'expiry unknown';
|
||||
logger.info('quota.fetch.auth_state', `Auth token state: ${expiryState}`, {
|
||||
provider,
|
||||
state: expiryState,
|
||||
});
|
||||
}
|
||||
|
||||
// Get project ID and tier - prefer stored project ID, but always call API for tier
|
||||
let projectId = authData.projectId;
|
||||
let apiTier: AccountTier = 'unknown';
|
||||
let rawTierId: string | null = null;
|
||||
let rawTierLabel: string | null = null;
|
||||
|
||||
// Always call loadCodeAssist to get accurate tier from API.
|
||||
// If the file token is stale, the helper retries through CLIProxy management auth.
|
||||
const lastProjectResult = await getProjectId(accountId, accessToken);
|
||||
|
||||
if (!lastProjectResult.projectId && !projectId) {
|
||||
const error = lastProjectResult.error || 'Failed to retrieve project ID';
|
||||
if (verbose)
|
||||
logger.warn('quota.fetch.project_lookup_failed', error, {
|
||||
provider,
|
||||
errorCode: lastProjectResult.errorCode,
|
||||
httpStatus: lastProjectResult.httpStatus,
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
lastUpdated: Date.now(),
|
||||
error,
|
||||
errorCode: lastProjectResult.errorCode,
|
||||
errorDetail: lastProjectResult.errorDetail,
|
||||
actionHint: lastProjectResult.actionHint,
|
||||
retryable: lastProjectResult.retryable,
|
||||
httpStatus: lastProjectResult.httpStatus,
|
||||
needsReauth: lastProjectResult.needsReauth,
|
||||
isUnprovisioned: lastProjectResult.isUnprovisioned,
|
||||
entitlement: lastProjectResult.entitlement,
|
||||
isExpired: authData.isExpired,
|
||||
expiresAt: authData.expiresAt || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Use API project ID if available, else fallback to stored
|
||||
projectId = lastProjectResult.projectId || projectId;
|
||||
apiTier = lastProjectResult.tier || 'unknown';
|
||||
rawTierId = lastProjectResult.rawTierId || null;
|
||||
rawTierLabel = lastProjectResult.rawTierLabel || null;
|
||||
|
||||
if (verbose)
|
||||
logger.info('quota.fetch.project_resolved', `Project ID: ${projectId || 'not found'}`, {
|
||||
provider,
|
||||
});
|
||||
|
||||
// Fetch models with quota
|
||||
const result = await fetchAvailableModels(accountId, accessToken, projectId as string);
|
||||
|
||||
if (verbose)
|
||||
logger.info('quota.fetch.models', `Models found: ${result.models.length}`, {
|
||||
provider,
|
||||
count: result.models.length,
|
||||
});
|
||||
result.accountId = accountId;
|
||||
result.projectId = projectId || undefined;
|
||||
|
||||
// Determine tier from API response only
|
||||
if (result.success) {
|
||||
const finalTier = apiTier !== 'unknown' ? apiTier : 'unknown';
|
||||
result.tier = finalTier;
|
||||
result.entitlement = buildProviderEntitlementEvidence({
|
||||
normalizedTier: finalTier,
|
||||
rawTierId,
|
||||
rawTierLabel,
|
||||
source: rawTierId ? 'runtime_api' : 'runtime_inference',
|
||||
confidence: rawTierId ? 'high' : 'medium',
|
||||
accessState: 'entitled',
|
||||
capacityState: 'available',
|
||||
});
|
||||
if (finalTier !== 'unknown') {
|
||||
setAccountTier(provider, accountId, finalTier);
|
||||
}
|
||||
} else {
|
||||
result.isExpired = authData.isExpired;
|
||||
result.expiresAt = authData.expiresAt || undefined;
|
||||
result.entitlement = mergeAntigravityTierEvidence(
|
||||
result.entitlement,
|
||||
apiTier,
|
||||
rawTierId,
|
||||
rawTierLabel
|
||||
);
|
||||
}
|
||||
|
||||
if (verbose && result.error) {
|
||||
console.log(`[!] Error: ${result.error}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* fetchAllProviderQuotas and findAvailableAccount.
|
||||
*
|
||||
* fetchAllProviderQuotas fans quota fetches out across all accounts of a
|
||||
* provider in parallel and groups them by GCP project id (accounts that share
|
||||
* a project pool quota together, so failover between them won't help).
|
||||
* findAvailableAccount wraps that to pick the first account that still has
|
||||
* remaining quota (used by the auto-switch preflight check).
|
||||
*/
|
||||
|
||||
import type { CLIProxyProvider } from '../../types';
|
||||
import { getProviderAccounts, type AccountInfo } from '../../accounts/account-manager';
|
||||
|
||||
import { fetchAccountQuota } from './account-quota-fetcher';
|
||||
import { readProjectIdFromAuthFile } from './auth-file-reader';
|
||||
import type { AllAccountsQuotaResult, QuotaResult } from './types';
|
||||
|
||||
/**
|
||||
* Fetch quota for all accounts of a provider.
|
||||
* Also detects accounts sharing the same GCP project (failover won't help).
|
||||
*
|
||||
* @param provider - Provider name (only 'agy' supported for quota)
|
||||
* @param verbose - Show detailed diagnostics
|
||||
* @returns Results for all accounts with project grouping
|
||||
*/
|
||||
export async function fetchAllProviderQuotas(
|
||||
provider: CLIProxyProvider,
|
||||
verbose = false
|
||||
): Promise<AllAccountsQuotaResult> {
|
||||
const accounts = getProviderAccounts(provider);
|
||||
const results: AllAccountsQuotaResult = {
|
||||
provider,
|
||||
accounts: [],
|
||||
projectGroups: {},
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
|
||||
if (accounts.length === 0) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Fetch quota for each account in parallel
|
||||
const quotaPromises = accounts.map(async (account) => {
|
||||
const quota = await fetchAccountQuota(provider, account.id, verbose);
|
||||
|
||||
// Read project ID from auth file if not in quota result
|
||||
let projectId = quota.projectId;
|
||||
if (!projectId) {
|
||||
projectId = readProjectIdFromAuthFile(provider, account.id) || undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
account,
|
||||
quota: { ...quota, accountId: account.id, projectId },
|
||||
};
|
||||
});
|
||||
|
||||
const quotaResults = await Promise.all(quotaPromises);
|
||||
|
||||
// Build project groups for detecting shared projects
|
||||
for (const { account, quota } of quotaResults) {
|
||||
results.accounts.push({ account, quota });
|
||||
|
||||
if (quota.projectId) {
|
||||
if (!results.projectGroups[quota.projectId]) {
|
||||
results.projectGroups[quota.projectId] = [];
|
||||
}
|
||||
results.projectGroups[quota.projectId].push(account.id);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an available account with remaining quota.
|
||||
* Used by preflight check for auto-switching.
|
||||
*
|
||||
* @param provider - Provider name
|
||||
* @param excludeAccountId - Account to exclude (current exhausted account)
|
||||
* @param verbose - Show detailed diagnostics
|
||||
* @returns Account with available quota, or null if none available
|
||||
*/
|
||||
export async function findAvailableAccount(
|
||||
provider: CLIProxyProvider,
|
||||
excludeAccountId?: string,
|
||||
verbose = false
|
||||
): Promise<{ account: AccountInfo; quota: QuotaResult } | null> {
|
||||
const allQuotas = await fetchAllProviderQuotas(provider, verbose);
|
||||
|
||||
// Get excluded account's project ID to avoid switching to same-project accounts
|
||||
const excludedProjectId = allQuotas.accounts.find((a) => a.account.id === excludeAccountId)?.quota
|
||||
.projectId;
|
||||
|
||||
for (const { account, quota } of allQuotas.accounts) {
|
||||
// Skip excluded account
|
||||
if (excludeAccountId && account.id === excludeAccountId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip failed quota fetches
|
||||
if (!quota.success) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip accounts sharing the same GCP project (quota is pooled)
|
||||
if (excludedProjectId && quota.projectId === excludedProjectId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if any model has remaining quota (> 5% to avoid edge cases)
|
||||
const hasQuota = quota.models.some((m) => m.percentage > 5);
|
||||
if (hasQuota) {
|
||||
return { account, quota };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Auth file reader for Antigravity quota fetching.
|
||||
*
|
||||
* Reads the local Antigravity auth file (active or paused directory) and
|
||||
* extracts the access token, refresh token, project id, and expiry state.
|
||||
* Falls back to scanning the directory and matching by the embedded email
|
||||
* field when the canonical sanitized filename is not present.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { getAuthDir } from '../../config/config-generator';
|
||||
import type { CLIProxyProvider } from '../../types';
|
||||
import { isTokenExpired, sanitizeEmail } from '../../auth/auth-utils';
|
||||
import { getPausedDir } from '../../accounts/account-manager';
|
||||
import type { AntigravityAuthFile, AuthData } from './types';
|
||||
|
||||
/**
|
||||
* Read auth data from the auth file (access token, project_id, expiry state).
|
||||
* Checks both active and paused auth directories (quota is needed for paused
|
||||
* accounts too).
|
||||
*/
|
||||
export function readAuthData(provider: CLIProxyProvider, accountId: string): AuthData | null {
|
||||
const authDirs = [getAuthDir(), getPausedDir()];
|
||||
|
||||
// Sanitize accountId (email) to match auth file naming: @ and . → _
|
||||
const sanitizedId = sanitizeEmail(accountId);
|
||||
const prefix = provider === 'agy' ? 'antigravity-' : `${provider}-`;
|
||||
const expectedFile = `${prefix}${sanitizedId}.json`;
|
||||
|
||||
for (const authDir of authDirs) {
|
||||
if (!fs.existsSync(authDir)) continue;
|
||||
|
||||
const filePath = path.join(authDir, expectedFile);
|
||||
|
||||
// Direct file access (most common case)
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(content) as AntigravityAuthFile;
|
||||
if (!data.access_token) continue;
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token || null,
|
||||
projectId: data.project_id || null,
|
||||
isExpired: isTokenExpired(data.expired),
|
||||
expiresAt: data.expired || null,
|
||||
};
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: scan directory for matching email in file content
|
||||
const files = fs.readdirSync(authDir);
|
||||
for (const file of files) {
|
||||
if (file.startsWith(prefix) && file.endsWith('.json')) {
|
||||
const candidatePath = path.join(authDir, file);
|
||||
try {
|
||||
const content = fs.readFileSync(candidatePath, 'utf-8');
|
||||
const data = JSON.parse(content) as AntigravityAuthFile;
|
||||
// Match by email field inside the auth file
|
||||
if (data.email === accountId && data.access_token) {
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token || null,
|
||||
projectId: data.project_id || null,
|
||||
isExpired: isTokenExpired(data.expired),
|
||||
expiresAt: data.expired || null,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read project ID directly from auth file without making an API call.
|
||||
* Used for quick project ID comparison in the doctor command.
|
||||
*/
|
||||
export function readProjectIdFromAuthFile(
|
||||
provider: CLIProxyProvider,
|
||||
accountId: string
|
||||
): string | null {
|
||||
const authData = readAuthData(provider, accountId);
|
||||
return authData?.projectId || null;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* fetchAvailableModels call for Antigravity quota.
|
||||
*
|
||||
* Fetches the model -> remaining-fraction map from the Cloud Code internal
|
||||
* API and projects it into ModelQuota[] percentages (0-100). The projectId
|
||||
* is intentionally NOT sent in the body (CLIProxyAPI sends an empty {} body
|
||||
* for this endpoint); it is accepted only for symmetry with the project
|
||||
* lookup flow.
|
||||
*/
|
||||
|
||||
import { buildProviderEntitlementEvidence } from '../../auth/provider-entitlement-evidence';
|
||||
import { ANTIGRAVITY_API_BASE, ANTIGRAVITY_API_VERSION, FETCHMODELS_HEADERS } from './constants';
|
||||
import { performAntigravityRequest } from './http-client';
|
||||
import { buildAntigravityFailure } from './status-classifier';
|
||||
import type { FetchAvailableModelsResponse, ModelQuota, QuotaResult } from './types';
|
||||
|
||||
/**
|
||||
* Fetch available models with quota info.
|
||||
* Note: projectId is kept for potential future use but not sent in body
|
||||
* (CLIProxyAPI sends empty {} body for this endpoint).
|
||||
*/
|
||||
export async function fetchAvailableModels(
|
||||
accountId: string,
|
||||
accessToken: string,
|
||||
_projectId: string
|
||||
): Promise<QuotaResult> {
|
||||
const url = `${ANTIGRAVITY_API_BASE}/${ANTIGRAVITY_API_VERSION}:fetchAvailableModels`;
|
||||
const response = await performAntigravityRequest(
|
||||
accountId,
|
||||
accessToken,
|
||||
url,
|
||||
FETCHMODELS_HEADERS,
|
||||
JSON.stringify({})
|
||||
);
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
lastUpdated: Date.now(),
|
||||
...buildAntigravityFailure(response.status, response.bodyText),
|
||||
};
|
||||
}
|
||||
|
||||
const data = response.json as FetchAvailableModelsResponse | null;
|
||||
if (!data) {
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
lastUpdated: Date.now(),
|
||||
error: 'Invalid quota response from provider',
|
||||
errorCode: 'provider_unavailable',
|
||||
retryable: true,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'low',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'temporarily_unavailable',
|
||||
notes: 'Provider returned a 2xx response with an empty or invalid quota payload.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const models: ModelQuota[] = [];
|
||||
|
||||
if (data.models && typeof data.models === 'object') {
|
||||
for (const [modelId, modelData] of Object.entries(data.models)) {
|
||||
const quotaInfo = modelData.quotaInfo || modelData.quota_info;
|
||||
if (!quotaInfo) continue;
|
||||
|
||||
const remaining =
|
||||
quotaInfo.remainingFraction ?? quotaInfo.remaining_fraction ?? quotaInfo.remaining;
|
||||
const resetTime = quotaInfo.resetTime || quotaInfo.reset_time || null;
|
||||
|
||||
let percentage: number;
|
||||
if (typeof remaining === 'number' && isFinite(remaining)) {
|
||||
percentage = Math.max(0, Math.min(100, Math.round(remaining * 100)));
|
||||
} else if (resetTime) {
|
||||
percentage = 0;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
models.push({
|
||||
name: modelId,
|
||||
displayName: modelData.displayName,
|
||||
percentage,
|
||||
resetTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
models,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Constants for the Antigravity quota fetcher.
|
||||
*
|
||||
* Google Cloud Code internal API endpoints, fixed headers used by the
|
||||
* CLIProxyAPIPlus control-plane requests, and the shared timeout applied to
|
||||
* every Antigravity management API call.
|
||||
*/
|
||||
|
||||
/** Google Cloud Code API endpoints */
|
||||
export const ANTIGRAVITY_DAILY_API_BASE = 'https://daily-cloudcode-pa.googleapis.com';
|
||||
export const ANTIGRAVITY_API_BASE = 'https://cloudcode-pa.googleapis.com';
|
||||
export const ANTIGRAVITY_API_VERSION = 'v1internal';
|
||||
export const ANTIGRAVITY_LOADCODEASSIST_BASE_URLS = [
|
||||
ANTIGRAVITY_DAILY_API_BASE,
|
||||
ANTIGRAVITY_API_BASE,
|
||||
] as const;
|
||||
export const MANAGEMENT_API_TIMEOUT_MS = 5000;
|
||||
|
||||
/** Headers for loadCodeAssist (matches current CLIProxyAPIPlus control-plane requests) */
|
||||
export const LOADCODEASSIST_HEADERS = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'antigravity/1.21.9 darwin/arm64 google-api-nodejs-client/10.3.0',
|
||||
'X-Goog-Api-Client': 'gl-node/22.21.1',
|
||||
};
|
||||
|
||||
/** Headers for fetchAvailableModels (matches CLIProxyAPI antigravity_executor.go) */
|
||||
export const FETCHMODELS_HEADERS = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'antigravity/1.104.0 darwin/arm64',
|
||||
};
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* HTTP transport for Antigravity quota requests.
|
||||
*
|
||||
* Wraps fetch() with three fallback strategies:
|
||||
* 1. Direct call with the local access token.
|
||||
* 2. If that returns 401 (token rejected), retry through CLIProxy management
|
||||
* auth using the proxy's stored token.
|
||||
* 3. For loadCodeAssist, fall back across the daily then prod Cloud Code hosts.
|
||||
*
|
||||
* Every call is bounded by MANAGEMENT_API_TIMEOUT_MS via an AbortController.
|
||||
* Network errors become synthetic 503 responses; abort timeouts become 408.
|
||||
*/
|
||||
|
||||
import { sanitizeEmail } from '../../auth/auth-utils';
|
||||
import {
|
||||
buildManagementHeaders,
|
||||
buildProxyUrl,
|
||||
getProxyTarget,
|
||||
} from '../../proxy/proxy-target-resolver';
|
||||
import { MANAGEMENT_API_TIMEOUT_MS } from './constants';
|
||||
import type { ManagedResponse, ManagementApiCallResponse, ManagementAuthFile } from './types';
|
||||
|
||||
/** Best-effort JSON.parse; returns null on failure. */
|
||||
export function safeParseJson(bodyText: string): unknown {
|
||||
try {
|
||||
return JSON.parse(bodyText);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the response body once and return a normalized ManagedResponse. */
|
||||
async function readManagedResponse(
|
||||
response: Response,
|
||||
viaManagement: boolean
|
||||
): Promise<ManagedResponse> {
|
||||
const bodyText = await response.text();
|
||||
return {
|
||||
status: response.status,
|
||||
bodyText,
|
||||
json: safeParseJson(bodyText),
|
||||
viaManagement,
|
||||
};
|
||||
}
|
||||
|
||||
/** Does this management-API auth file belong to the given Antigravity account? */
|
||||
function isAntigravityAuthFileForAccount(file: ManagementAuthFile, accountId: string): boolean {
|
||||
const provider = (file.provider || file.type || '').trim().toLowerCase();
|
||||
if (provider !== 'antigravity' && provider !== 'agy') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedAccount = accountId.trim().toLowerCase();
|
||||
const normalizedEmail = file.email?.trim().toLowerCase();
|
||||
if (normalizedEmail && normalizedEmail === normalizedAccount) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedName = file.name?.trim().toLowerCase();
|
||||
if (!normalizedName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sanitizedAccount = sanitizeEmail(accountId).toLowerCase();
|
||||
return (
|
||||
normalizedName === `antigravity-${sanitizedAccount}.json` ||
|
||||
normalizedName === `agy-${sanitizedAccount}.json`
|
||||
);
|
||||
}
|
||||
|
||||
/** Ask CLIProxy management API for the auth_index of the Antigravity account. */
|
||||
async function findManagedAntigravityAuthIndex(accountId: string): Promise<string | number | null> {
|
||||
const target = getProxyTarget();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), MANAGEMENT_API_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(buildProxyUrl(target, '/v0/management/auth-files'), {
|
||||
signal: controller.signal,
|
||||
headers: buildManagementHeaders(target),
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { files?: ManagementAuthFile[] };
|
||||
const match = data.files?.find((file) => isAntigravityAuthFileForAccount(file, accountId));
|
||||
return match?.auth_index ?? null;
|
||||
} catch {
|
||||
clearTimeout(timeoutId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a request through the CLIProxy management api-call endpoint using the
|
||||
* proxy's stored token (substituted server-side as $TOKEN$). Returns null if
|
||||
* the proxy can't handle the request.
|
||||
*/
|
||||
async function performManagedAntigravityRequest(
|
||||
accountId: string,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
body: string
|
||||
): Promise<ManagedResponse | null> {
|
||||
const authIndex = await findManagedAntigravityAuthIndex(accountId);
|
||||
if (authIndex === null || authIndex === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = getProxyTarget();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), MANAGEMENT_API_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(buildProxyUrl(target, '/v0/management/api-call'), {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: buildManagementHeaders(target, {
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
auth_index: authIndex,
|
||||
method: 'POST',
|
||||
url,
|
||||
header: {
|
||||
...headers,
|
||||
Authorization: 'Bearer $TOKEN$',
|
||||
},
|
||||
data: body,
|
||||
}),
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const apiResponse = (await response.json()) as ManagementApiCallResponse;
|
||||
const bodyText = typeof apiResponse.body === 'string' ? apiResponse.body : '';
|
||||
return {
|
||||
status: typeof apiResponse.status_code === 'number' ? apiResponse.status_code : 500,
|
||||
bodyText,
|
||||
json: safeParseJson(bodyText),
|
||||
viaManagement: true,
|
||||
};
|
||||
} catch {
|
||||
clearTimeout(timeoutId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a single Antigravity POST. Tries direct with the local access token
|
||||
* first; on 401, retries through CLIProxy management auth. Network errors map
|
||||
* to synthetic 503 responses so the caller's status-based classifier still
|
||||
* works; abort timeouts map to 408.
|
||||
*/
|
||||
export async function performAntigravityRequest(
|
||||
accountId: string,
|
||||
accessToken: string,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
body: string
|
||||
): Promise<ManagedResponse> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), MANAGEMENT_API_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
...headers,
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const directResult = await readManagedResponse(response, false);
|
||||
if (directResult.status !== 401) {
|
||||
return directResult;
|
||||
}
|
||||
|
||||
const managedResult = await performManagedAntigravityRequest(accountId, url, headers, body);
|
||||
return managedResult ?? directResult;
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
return {
|
||||
status: 408,
|
||||
bodyText: '',
|
||||
json: null,
|
||||
viaManagement: false,
|
||||
};
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return {
|
||||
status: 503,
|
||||
bodyText: message,
|
||||
json: null,
|
||||
viaManagement: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a request against each base URL in order, returning the first 2xx
|
||||
* response. If none succeed, return the last failure (or a synthetic 503 if
|
||||
* no URLs were attempted).
|
||||
*/
|
||||
export async function performAntigravityRequestWithBaseUrlFallback(
|
||||
accountId: string,
|
||||
accessToken: string,
|
||||
baseUrls: readonly string[],
|
||||
apiPath: string,
|
||||
headers: Record<string, string>,
|
||||
body: string
|
||||
): Promise<ManagedResponse> {
|
||||
let lastResponse: ManagedResponse | null = null;
|
||||
|
||||
for (const baseUrl of baseUrls) {
|
||||
const response = await performAntigravityRequest(
|
||||
accountId,
|
||||
accessToken,
|
||||
`${baseUrl}/${apiPath}`,
|
||||
headers,
|
||||
body
|
||||
);
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return response;
|
||||
}
|
||||
lastResponse = response;
|
||||
}
|
||||
|
||||
return (
|
||||
lastResponse ?? {
|
||||
status: 503,
|
||||
bodyText: 'No Antigravity API endpoint available',
|
||||
json: null,
|
||||
viaManagement: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Antigravity loadCodeAssist project + tier lookup.
|
||||
*
|
||||
* Calls loadCodeAssist against the daily then prod Cloud Code hosts to resolve
|
||||
* the GCP project id and the account tier (paidTier.id takes priority over
|
||||
* currentTier.id). Returns a structured ProjectLookupResult that the top-level
|
||||
* fetchAccountQuota merges into a QuotaResult.
|
||||
*/
|
||||
|
||||
import {
|
||||
buildProviderEntitlementEvidence,
|
||||
getProviderTierLabel,
|
||||
normalizeProviderTierId,
|
||||
} from '../../auth/provider-entitlement-evidence';
|
||||
import {
|
||||
ANTIGRAVITY_API_VERSION,
|
||||
ANTIGRAVITY_LOADCODEASSIST_BASE_URLS,
|
||||
LOADCODEASSIST_HEADERS,
|
||||
} from './constants';
|
||||
import { performAntigravityRequestWithBaseUrlFallback } from './http-client';
|
||||
import { buildAntigravityFailure } from './status-classifier';
|
||||
import type { LoadCodeAssistResponse, ProjectLookupResult } from './types';
|
||||
|
||||
/**
|
||||
* Get project ID and tier via loadCodeAssist endpoint.
|
||||
* Uses paidTier.id for accurate tier detection (g1-ultra-tier, g1-pro-tier).
|
||||
* Falls back across the daily then prod Cloud Code hosts.
|
||||
*/
|
||||
export async function getProjectId(
|
||||
accountId: string,
|
||||
accessToken: string
|
||||
): Promise<ProjectLookupResult> {
|
||||
const body = JSON.stringify({
|
||||
metadata: {
|
||||
ide_name: 'antigravity',
|
||||
ide_type: 'ANTIGRAVITY',
|
||||
ide_version: '1.21.9',
|
||||
},
|
||||
});
|
||||
const response = await performAntigravityRequestWithBaseUrlFallback(
|
||||
accountId,
|
||||
accessToken,
|
||||
ANTIGRAVITY_LOADCODEASSIST_BASE_URLS,
|
||||
`${ANTIGRAVITY_API_VERSION}:loadCodeAssist`,
|
||||
LOADCODEASSIST_HEADERS,
|
||||
body
|
||||
);
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
return {
|
||||
projectId: null,
|
||||
...buildAntigravityFailure(response.status, response.bodyText),
|
||||
};
|
||||
}
|
||||
|
||||
const data = response.json as LoadCodeAssistResponse | null;
|
||||
if (!data) {
|
||||
return {
|
||||
projectId: null,
|
||||
error: 'Invalid quota response from provider',
|
||||
errorCode: 'provider_unavailable',
|
||||
retryable: true,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'low',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'temporarily_unavailable',
|
||||
notes: 'Provider returned a 2xx response with an empty or invalid project payload.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Extract project ID from response
|
||||
let projectId: string | undefined;
|
||||
if (typeof data.cloudaicompanionProject === 'string') {
|
||||
projectId = data.cloudaicompanionProject;
|
||||
} else if (typeof data.cloudaicompanionProject === 'object') {
|
||||
projectId = data.cloudaicompanionProject?.id;
|
||||
}
|
||||
|
||||
if (!projectId?.trim()) {
|
||||
return {
|
||||
projectId: null,
|
||||
error: 'Sign in to Antigravity app to activate quota.',
|
||||
errorCode: 'account_unprovisioned',
|
||||
actionHint: 'Complete sign-in in the Antigravity app, then retry quota refresh.',
|
||||
isUnprovisioned: true,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'medium',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'unknown',
|
||||
notes: 'Project provisioning is incomplete for this account.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Extract tier - paidTier reflects actual subscription status, takes priority
|
||||
const rawTierId = (data.paidTier?.id || data.currentTier?.id || '').trim() || null;
|
||||
const tier = normalizeProviderTierId(rawTierId);
|
||||
|
||||
return {
|
||||
projectId: projectId.trim(),
|
||||
tier,
|
||||
rawTierId,
|
||||
rawTierLabel: getProviderTierLabel(rawTierId),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Status classifier for Antigravity quota fetch failures.
|
||||
*
|
||||
* Maps an upstream HTTP status code (plus optional response body) into a stable
|
||||
* QuotaResult failure fragment: error code, action hint, retryability, and
|
||||
* provider entitlement evidence. Also merges tier evidence from a successful
|
||||
* project lookup with entitlement evidence derived from a failed models fetch.
|
||||
*/
|
||||
|
||||
import type { AccountTier } from '../../accounts/account-manager';
|
||||
import { buildProviderEntitlementEvidence } from '../../auth/provider-entitlement-evidence';
|
||||
import type { ProviderEntitlementEvidence } from '../../auth/provider-entitlement-types';
|
||||
import type { QuotaResult } from './types';
|
||||
|
||||
/** Trim and cap upstream error bodies to keep payloads small. */
|
||||
export function normalizeErrorDetail(bodyText: string): string | undefined {
|
||||
const normalized = bodyText.trim();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (normalized.length <= 400) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, 397)}...`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the failure fragment for an Antigravity quota request. The returned
|
||||
* object is spread into a QuotaResult by callers. Status 401/403/429/408/5xx
|
||||
* each have their own stable error code and capacity/access state signal.
|
||||
*/
|
||||
export function buildAntigravityFailure(
|
||||
status: number | undefined,
|
||||
bodyText?: string
|
||||
): Pick<
|
||||
QuotaResult,
|
||||
| 'error'
|
||||
| 'errorCode'
|
||||
| 'errorDetail'
|
||||
| 'actionHint'
|
||||
| 'retryable'
|
||||
| 'httpStatus'
|
||||
| 'needsReauth'
|
||||
| 'entitlement'
|
||||
> & { isForbidden?: boolean } {
|
||||
const detail = normalizeErrorDetail(bodyText || '');
|
||||
|
||||
if (status === 401) {
|
||||
return {
|
||||
httpStatus: 401,
|
||||
error: 'Token expired or invalid',
|
||||
errorCode: 'reauth_required',
|
||||
actionHint:
|
||||
'Re-authenticate this account. If CLIProxy is running, retry after the proxy finishes refreshing the token.',
|
||||
needsReauth: true,
|
||||
errorDetail: detail,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'medium',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'unknown',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
return {
|
||||
httpStatus: 403,
|
||||
error: 'Access forbidden',
|
||||
errorCode: 'quota_api_forbidden',
|
||||
actionHint: 'This account does not have Gemini Code Assist quota access.',
|
||||
isForbidden: true,
|
||||
errorDetail: detail,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'medium',
|
||||
accessState: 'not_entitled',
|
||||
capacityState: 'unknown',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 429) {
|
||||
return {
|
||||
httpStatus: 429,
|
||||
error: 'Rate limited - try again later',
|
||||
errorCode: 'rate_limited',
|
||||
actionHint: 'Retry later. This looks temporary.',
|
||||
retryable: true,
|
||||
errorDetail: detail,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'low',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'rate_limited',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 408) {
|
||||
return {
|
||||
httpStatus: 408,
|
||||
error: 'Request timeout',
|
||||
errorCode: 'network_timeout',
|
||||
actionHint: 'Retry later. This looks temporary.',
|
||||
retryable: true,
|
||||
errorDetail: detail,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'low',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'temporarily_unavailable',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof status === 'number' && status >= 500) {
|
||||
return {
|
||||
httpStatus: status,
|
||||
error: `API error: ${status}`,
|
||||
errorCode: 'provider_unavailable',
|
||||
actionHint: 'Retry later. The provider appears unavailable.',
|
||||
retryable: true,
|
||||
errorDetail: detail,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'low',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'temporarily_unavailable',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof status === 'number' && status >= 400) {
|
||||
return {
|
||||
httpStatus: status,
|
||||
error: `API error: ${status}`,
|
||||
errorCode: 'quota_request_failed',
|
||||
errorDetail: detail,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'low',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'unknown',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
error: 'Quota request failed',
|
||||
errorCode: 'quota_request_failed',
|
||||
errorDetail: detail,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'low',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'unknown',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge entitlement evidence from a successful project lookup with evidence
|
||||
* from a failed models fetch. A known tier id from the project lookup always
|
||||
* wins (runtime_api source, high confidence); otherwise we fall back to the
|
||||
* pre-existing evidence or build a fresh runtime_inference record.
|
||||
*/
|
||||
export function mergeAntigravityTierEvidence(
|
||||
entitlement: ProviderEntitlementEvidence | undefined,
|
||||
tier: AccountTier,
|
||||
rawTierId: string | null,
|
||||
rawTierLabel: string | null
|
||||
): ProviderEntitlementEvidence | undefined {
|
||||
if (tier === 'unknown' && !entitlement) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return buildProviderEntitlementEvidence({
|
||||
normalizedTier: tier,
|
||||
rawTierId,
|
||||
rawTierLabel,
|
||||
source: rawTierId ? 'runtime_api' : (entitlement?.source ?? 'runtime_inference'),
|
||||
confidence: rawTierId ? 'high' : (entitlement?.confidence ?? 'medium'),
|
||||
accessState: entitlement?.accessState ?? 'unknown',
|
||||
capacityState: entitlement?.capacityState ?? 'unknown',
|
||||
notes: entitlement?.notes ?? null,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Shared types for the Antigravity quota fetcher.
|
||||
*
|
||||
* Public types (ModelQuota, QuotaResult, AllAccountsQuotaResult) are re-exported
|
||||
* from the barrel at ../quota-fetcher.ts so existing import paths keep working.
|
||||
*/
|
||||
|
||||
import type { CLIProxyProvider } from '../../types';
|
||||
import type { AccountInfo, AccountTier } from '../../accounts/account-manager';
|
||||
import type { ProviderEntitlementEvidence } from '../../auth/provider-entitlement-types';
|
||||
|
||||
/** Individual model quota info */
|
||||
export interface ModelQuota {
|
||||
/** Model name, e.g., "gemini-3-pro-high" */
|
||||
name: string;
|
||||
/** Display name from API, e.g., "Gemini 3 Pro" */
|
||||
displayName?: string;
|
||||
/** Remaining quota as percentage (0-100) */
|
||||
percentage: number;
|
||||
/** ISO timestamp when quota resets, null if unknown */
|
||||
resetTime: string | null;
|
||||
}
|
||||
|
||||
/** Quota fetch result */
|
||||
export interface QuotaResult {
|
||||
/** Whether fetch succeeded */
|
||||
success: boolean;
|
||||
/** Quota for each available model */
|
||||
models: ModelQuota[];
|
||||
/** Timestamp of fetch */
|
||||
lastUpdated: number;
|
||||
/** Upstream HTTP status when available */
|
||||
httpStatus?: number;
|
||||
/** Stable machine-readable error code */
|
||||
errorCode?: string;
|
||||
/** Additional provider-specific detail/code from upstream */
|
||||
errorDetail?: string;
|
||||
/** True if account lacks quota access (403) */
|
||||
isForbidden?: boolean;
|
||||
/** Error message if fetch failed */
|
||||
error?: string;
|
||||
/** Provider-specific remediation guidance */
|
||||
actionHint?: string;
|
||||
/** True when the failure is temporary and retrying later may help */
|
||||
retryable?: boolean;
|
||||
/** True if token is expired and needs re-auth */
|
||||
isExpired?: boolean;
|
||||
/** True if token refresh cannot proceed and the account should be re-authenticated */
|
||||
needsReauth?: boolean;
|
||||
/** ISO timestamp when token expires/expired */
|
||||
expiresAt?: string;
|
||||
/** True if account hasn't been activated in official Antigravity app */
|
||||
isUnprovisioned?: boolean;
|
||||
/** Account ID (email) this quota belongs to */
|
||||
accountId?: string;
|
||||
/** GCP project ID for this account */
|
||||
projectId?: string;
|
||||
/** Detected account tier based on model access */
|
||||
tier?: AccountTier;
|
||||
/** Richer provider entitlement evidence derived from live/runtime signals */
|
||||
entitlement?: ProviderEntitlementEvidence;
|
||||
}
|
||||
|
||||
/** Result for all accounts of a provider */
|
||||
export interface AllAccountsQuotaResult {
|
||||
/** Provider name */
|
||||
provider: CLIProxyProvider;
|
||||
/** Results per account */
|
||||
accounts: Array<{
|
||||
account: AccountInfo;
|
||||
quota: QuotaResult;
|
||||
}>;
|
||||
/** Accounts grouped by project ID (for detecting shared projects) */
|
||||
projectGroups: Record<string, string[]>;
|
||||
/** Timestamp of fetch */
|
||||
lastUpdated: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal types (not part of the public surface)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Auth file structure on disk for Antigravity accounts */
|
||||
export interface AntigravityAuthFile {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
email?: string;
|
||||
expired?: string;
|
||||
expires_in?: number;
|
||||
timestamp?: number;
|
||||
type?: string;
|
||||
project_id?: string;
|
||||
}
|
||||
|
||||
/** Auth data returned from file */
|
||||
export interface AuthData {
|
||||
accessToken: string;
|
||||
refreshToken: string | null;
|
||||
projectId: string | null;
|
||||
isExpired: boolean;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
/** Tier info from loadCodeAssist */
|
||||
export interface TierInfo {
|
||||
id?: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
/** loadCodeAssist response */
|
||||
export interface LoadCodeAssistResponse {
|
||||
cloudaicompanionProject?: string | { id?: string };
|
||||
/** Current tier (may be trial/temporary) */
|
||||
currentTier?: TierInfo;
|
||||
/** Paid tier (reflects actual subscription - takes priority) */
|
||||
paidTier?: TierInfo;
|
||||
/** Array of allowed tiers - use isDefault=true to find active tier (CLIProxyAPIPlus approach) */
|
||||
allowedTiers?: TierInfo[];
|
||||
}
|
||||
|
||||
/** fetchAvailableModels response model */
|
||||
export interface AvailableModel {
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
quotaInfo?: {
|
||||
remainingFraction?: number;
|
||||
remaining_fraction?: number;
|
||||
remaining?: number;
|
||||
resetTime?: string;
|
||||
reset_time?: string;
|
||||
};
|
||||
quota_info?: {
|
||||
remainingFraction?: number;
|
||||
remaining_fraction?: number;
|
||||
remaining?: number;
|
||||
resetTime?: string;
|
||||
reset_time?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** fetchAvailableModels response */
|
||||
export interface FetchAvailableModelsResponse {
|
||||
models?: Record<string, AvailableModel>;
|
||||
}
|
||||
|
||||
export interface ManagementAuthFile {
|
||||
auth_index?: string | number;
|
||||
provider?: string;
|
||||
type?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface ManagementApiCallResponse {
|
||||
status_code?: number;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
export interface ManagedResponse {
|
||||
status: number;
|
||||
bodyText: string;
|
||||
json: unknown;
|
||||
viaManagement: boolean;
|
||||
}
|
||||
|
||||
export interface ProjectLookupResult {
|
||||
projectId: string | null;
|
||||
tier?: AccountTier;
|
||||
rawTierId?: string | null;
|
||||
rawTierLabel?: string | null;
|
||||
entitlement?: ProviderEntitlementEvidence;
|
||||
error?: string;
|
||||
errorCode?: string;
|
||||
errorDetail?: string;
|
||||
actionHint?: string;
|
||||
retryable?: boolean;
|
||||
httpStatus?: number;
|
||||
needsReauth?: boolean;
|
||||
isUnprovisioned?: boolean;
|
||||
}
|
||||
@@ -12,6 +12,12 @@ import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loa
|
||||
import { getInstalledCliproxyVersion } from '../binary-manager';
|
||||
import { compareVersions } from '../../utils/update-checker';
|
||||
import { getConfigYamlPath } from '../../config/loader/io-locks';
|
||||
import { createLogger } from '../../services/logging';
|
||||
|
||||
// Diagnostic-only logger for internal binary-compatibility notices. The
|
||||
// user-facing result of enablePoolRouting is returned via the result
|
||||
// message; this logger captures the version-compat caveat for diagnostics.
|
||||
const logger = createLogger('cliproxy:routing:strategy');
|
||||
|
||||
export const DEFAULT_CLIPROXY_ROUTING_STRATEGY: CliproxyRoutingStrategy = 'round-robin';
|
||||
export const DEFAULT_CLIPROXY_SESSION_AFFINITY_ENABLED = false;
|
||||
@@ -205,10 +211,15 @@ export function enablePoolRouting(
|
||||
try {
|
||||
const installedVersion = getInstalledCliproxyVersion();
|
||||
if (compareVersions(installedVersion, POOL_ROUTING_MIN_VERSION) < 0) {
|
||||
console.warn(
|
||||
`[!] CLIProxy v${installedVersion} is older than the pool routing minimum (v${POOL_ROUTING_MIN_VERSION}).\n` +
|
||||
` The max-retry-credentials and cooling keys may be silently ignored by the running binary.\n` +
|
||||
` Run 'ccs cliproxy --latest' to update CLIProxy, then restart with 'ccs cliproxy restart'.`
|
||||
logger.warn(
|
||||
'pool_routing.binary_below_minimum',
|
||||
`CLIProxy v${installedVersion} is older than the pool routing minimum (v${POOL_ROUTING_MIN_VERSION}). ` +
|
||||
`The max-retry-credentials and cooling keys may be silently ignored by the running binary. ` +
|
||||
`Run 'ccs cliproxy --latest' to update CLIProxy, then restart with 'ccs cliproxy restart'.`,
|
||||
{
|
||||
installedVersion,
|
||||
minimumVersion: POOL_ROUTING_MIN_VERSION,
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Claude window classification + display helpers.
|
||||
*
|
||||
* Claude quota results include multiple policy windows (5h, weekly, weekly
|
||||
* per-model variants like Opus/Sonnet, overage, etc.). These helpers classify
|
||||
* each window and extract the two "core usage" windows for the summary line.
|
||||
*/
|
||||
|
||||
import type { ClaudeQuotaResult } from '../../../cliproxy/quota/quota-types';
|
||||
import { pickMostRestrictiveClaudeWeeklyWindow } from '../../../cliproxy/quota/quota-fetcher-claude-normalizer';
|
||||
import type { ClaudeDisplayWindow } from './types';
|
||||
|
||||
/** Human-readable label for a Claude window based on its rate-limit type. */
|
||||
export function getClaudeWindowDisplayLabel(
|
||||
window: Pick<ClaudeDisplayWindow, 'rateLimitType' | 'label'>
|
||||
): string {
|
||||
switch (window.rateLimitType) {
|
||||
case 'five_hour':
|
||||
return '5h usage limit';
|
||||
case 'seven_day':
|
||||
return 'Weekly usage limit';
|
||||
case 'seven_day_opus':
|
||||
return 'Weekly usage (Opus)';
|
||||
case 'seven_day_sonnet':
|
||||
return 'Weekly usage (Sonnet)';
|
||||
case 'seven_day_oauth_apps':
|
||||
return 'Weekly usage (OAuth apps)';
|
||||
case 'seven_day_cowork':
|
||||
return 'Weekly usage (Cowork)';
|
||||
case 'overage':
|
||||
return 'Extra usage';
|
||||
default:
|
||||
return window.label;
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert a raw Claude quota window into the normalized display shape. */
|
||||
export function toClaudeDisplayWindow(
|
||||
window: ClaudeQuotaResult['windows'][number]
|
||||
): ClaudeDisplayWindow {
|
||||
return {
|
||||
rateLimitType: window.rateLimitType,
|
||||
label: window.label,
|
||||
remainingPercent: window.remainingPercent,
|
||||
resetAt: window.resetAt,
|
||||
status: window.status,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert a coreUsage 5h/weekly sub-window into the display shape (or null). */
|
||||
export function toClaudeCoreDisplayWindow(
|
||||
window: NonNullable<ClaudeQuotaResult['coreUsage']>['fiveHour']
|
||||
): ClaudeDisplayWindow | null {
|
||||
if (!window) return null;
|
||||
return {
|
||||
rateLimitType: window.rateLimitType,
|
||||
label: window.label,
|
||||
remainingPercent: window.remainingPercent,
|
||||
resetAt: window.resetAt,
|
||||
status: window.status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the two "core usage" windows (5h + weekly) for a Claude result.
|
||||
*
|
||||
* Prefers the explicit coreUsage metadata. Falls back to the 'five_hour'
|
||||
* window and the most restrictive weekly window when metadata is absent.
|
||||
*/
|
||||
export function getClaudeCoreUsageWindows(quota: ClaudeQuotaResult): {
|
||||
fiveHourWindow: ClaudeDisplayWindow | null;
|
||||
weeklyWindow: ClaudeDisplayWindow | null;
|
||||
} {
|
||||
const coreUsage = quota.coreUsage;
|
||||
const fiveHourFromCore = toClaudeCoreDisplayWindow(coreUsage?.fiveHour ?? null);
|
||||
const weeklyFromCore = toClaudeCoreDisplayWindow(coreUsage?.weekly ?? null);
|
||||
if (fiveHourFromCore || weeklyFromCore) {
|
||||
return {
|
||||
fiveHourWindow: fiveHourFromCore,
|
||||
weeklyWindow: weeklyFromCore,
|
||||
};
|
||||
}
|
||||
|
||||
const fiveHourPolicy =
|
||||
quota.windows.find((window) => window.rateLimitType === 'five_hour') ?? null;
|
||||
const weeklyPolicy = pickMostRestrictiveClaudeWeeklyWindow(quota.windows);
|
||||
|
||||
return {
|
||||
fiveHourWindow: fiveHourPolicy ? toClaudeDisplayWindow(fiveHourPolicy) : null,
|
||||
weeklyWindow: weeklyPolicy ? toClaudeDisplayWindow(weeklyPolicy) : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Codex window classification + display helpers.
|
||||
*
|
||||
* Codex quota results include multiple rate-limit windows (5h usage, weekly
|
||||
* usage, code review, and "additional" feature windows like Codex Spark).
|
||||
* These helpers classify each window, pick display labels, and identify the
|
||||
* two "core usage" windows used in the per-account summary.
|
||||
*/
|
||||
|
||||
import {
|
||||
sanitizeCodexFeatureLabel,
|
||||
sanitizeCodexFeatureLabelOrNull,
|
||||
} from '../../../cliproxy/quota/quota-label-sanitizer';
|
||||
import type { CodexQuotaResult } from '../../../cliproxy/quota/quota-types';
|
||||
import { formatAbsoluteResetTime, formatResetTime, formatResetTimeISO } from './format-helpers';
|
||||
import type { CodexWindowKind } from './types';
|
||||
|
||||
/** Subset of a Codex window used by label classification (for test ergonomics). */
|
||||
export type CodexWindowSummary = Pick<
|
||||
CodexQuotaResult['windows'][number],
|
||||
'label' | 'resetAfterSeconds' | 'category' | 'cadence' | 'featureLabel'
|
||||
>;
|
||||
|
||||
/** Render the reset time for a Codex window as either a relative or absolute label. */
|
||||
export function formatCodexWindowReset(
|
||||
window: Pick<CodexQuotaResult['windows'][number], 'resetAfterSeconds' | 'resetAt'>
|
||||
): string | null {
|
||||
if (typeof window.resetAfterSeconds === 'number' && isFinite(window.resetAfterSeconds)) {
|
||||
const relative = formatResetTime(Math.max(0, window.resetAfterSeconds));
|
||||
if (window.resetAfterSeconds >= 86400 && window.resetAt) {
|
||||
const absolute = formatAbsoluteResetTime(window.resetAt);
|
||||
return absolute ? `${relative} (${absolute})` : relative;
|
||||
}
|
||||
return relative;
|
||||
}
|
||||
|
||||
if (window.resetAt) {
|
||||
return formatResetTimeISO(window.resetAt);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Classify a Codex window label into a known kind. */
|
||||
export function getCodexWindowKind(label: string): CodexWindowKind {
|
||||
const lower = (label || '').toLowerCase();
|
||||
const isCodeReview = lower.includes('code review') || lower.includes('code_review');
|
||||
const isPrimary = lower.includes('primary');
|
||||
const isSecondary = lower.includes('secondary');
|
||||
|
||||
if (isCodeReview) {
|
||||
if (isPrimary) return 'code-review-5h';
|
||||
if (isSecondary) return 'code-review-weekly';
|
||||
return 'code-review';
|
||||
}
|
||||
|
||||
if (isPrimary) return 'usage-5h';
|
||||
if (isSecondary) return 'usage-weekly';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer whether a code-review window resets on the 5h or weekly cadence by
|
||||
* comparing its reset time against the 5h and weekly usage windows. Returns
|
||||
* null when no inference is possible.
|
||||
*/
|
||||
export function inferCodeReviewCadence(
|
||||
window: CodexWindowSummary,
|
||||
allWindows: CodexWindowSummary[]
|
||||
): '5h' | 'weekly' | null {
|
||||
const kind = getCodexWindowKind(window.label);
|
||||
if (kind === 'code-review-weekly') return 'weekly';
|
||||
|
||||
const reset = window.resetAfterSeconds;
|
||||
if (typeof reset !== 'number' || !isFinite(reset) || reset <= 0) return null;
|
||||
|
||||
const usage5h = allWindows.find(
|
||||
(w) =>
|
||||
getCodexWindowKind(w.label) === 'usage-5h' &&
|
||||
typeof w.resetAfterSeconds === 'number' &&
|
||||
isFinite(w.resetAfterSeconds) &&
|
||||
w.resetAfterSeconds > 0
|
||||
);
|
||||
const usageWeekly = allWindows.find(
|
||||
(w) =>
|
||||
getCodexWindowKind(w.label) === 'usage-weekly' &&
|
||||
typeof w.resetAfterSeconds === 'number' &&
|
||||
isFinite(w.resetAfterSeconds) &&
|
||||
w.resetAfterSeconds > 0
|
||||
);
|
||||
|
||||
if (!usage5h || !usageWeekly) return null;
|
||||
|
||||
const diffTo5h = Math.abs(reset - (usage5h.resetAfterSeconds as number));
|
||||
const diffToWeekly = Math.abs(reset - (usageWeekly.resetAfterSeconds as number));
|
||||
return diffToWeekly <= diffTo5h ? 'weekly' : '5h';
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a leading "GPT-X.Y-Codex-" prefix from a feature label and turn the
|
||||
* remainder into a Codex-prefixed display name. Other labels pass through unchanged.
|
||||
*/
|
||||
export function prettifyCodexFeatureLabel(featureLabel: unknown, fallbackLabel?: unknown): string {
|
||||
const trimmed =
|
||||
sanitizeCodexFeatureLabelOrNull(featureLabel) ??
|
||||
(fallbackLabel === undefined
|
||||
? sanitizeCodexFeatureLabel(featureLabel)
|
||||
: sanitizeCodexFeatureLabel(fallbackLabel));
|
||||
const stripped = trimmed.replace(/^GPT-[\d.]+-Codex-/i, '');
|
||||
if (stripped !== trimmed && stripped.length > 0) {
|
||||
return `Codex ${stripped}`;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/** Human-readable label for a Codex window, using metadata when available. */
|
||||
export function getCodexWindowDisplayLabel(
|
||||
window: CodexWindowSummary,
|
||||
allWindows: CodexWindowSummary[] = []
|
||||
): string {
|
||||
const context = allWindows.length > 0 ? allWindows : [window];
|
||||
|
||||
// Prefer explicit category metadata when present (post-2026-04 windows).
|
||||
if (window.category === 'usage') {
|
||||
if (window.cadence === '5h') return '5h usage limit';
|
||||
if (window.cadence === 'weekly') return 'Weekly usage limit';
|
||||
}
|
||||
|
||||
if (window.category === 'additional') {
|
||||
const pretty = prettifyCodexFeatureLabel(window.featureLabel, window.label);
|
||||
if (window.cadence === '5h') return `${pretty} (5h)`;
|
||||
if (window.cadence === 'weekly') return `${pretty} (weekly)`;
|
||||
return pretty;
|
||||
}
|
||||
|
||||
if (window.category === 'code-review') {
|
||||
if (window.cadence === '5h') return 'Code review (5h)';
|
||||
if (window.cadence === 'weekly') return 'Code review (weekly)';
|
||||
return 'Code review';
|
||||
}
|
||||
|
||||
// Legacy fallback: classify via label sniffing for cached windows without metadata.
|
||||
switch (getCodexWindowKind(window.label)) {
|
||||
case 'usage-5h':
|
||||
return '5h usage limit';
|
||||
case 'usage-weekly':
|
||||
return 'Weekly usage limit';
|
||||
case 'code-review-5h':
|
||||
case 'code-review-weekly':
|
||||
case 'code-review': {
|
||||
const inferred = inferCodeReviewCadence(window, context);
|
||||
if (inferred === '5h') return 'Code review (5h)';
|
||||
if (inferred === 'weekly') return 'Code review (weekly)';
|
||||
return 'Code review';
|
||||
}
|
||||
case 'unknown':
|
||||
return window.label;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the two "core usage" windows (5h + weekly) out of a Codex result.
|
||||
*
|
||||
* Prefers explicit category metadata. Falls back to label sniffing for cached
|
||||
* windows, and finally to a best-effort guess based on reset times so the
|
||||
* summary line always has something useful to show.
|
||||
*/
|
||||
export function getCodexCoreUsageWindows(windows: CodexQuotaResult['windows']): {
|
||||
fiveHourWindow: CodexQuotaResult['windows'][number] | null;
|
||||
weeklyWindow: CodexQuotaResult['windows'][number] | null;
|
||||
} {
|
||||
let fiveHourWindow: CodexQuotaResult['windows'][number] | null = null;
|
||||
let weeklyWindow: CodexQuotaResult['windows'][number] | null = null;
|
||||
const nonCodeReviewWindows: CodexQuotaResult['windows'] = [];
|
||||
|
||||
// Prefer explicit category metadata when present so 'additional' windows
|
||||
// (e.g. GPT-5.3 Codex Spark) do not displace core usage windows in the summary.
|
||||
const hasCategoryMetadata = windows.some((window) => Boolean(window.category));
|
||||
|
||||
if (hasCategoryMetadata) {
|
||||
for (const window of windows) {
|
||||
if (window.category === 'usage') {
|
||||
if (window.cadence === '5h' && !fiveHourWindow) fiveHourWindow = window;
|
||||
else if (window.cadence === 'weekly' && !weeklyWindow) weeklyWindow = window;
|
||||
nonCodeReviewWindows.push(window);
|
||||
}
|
||||
// 'code-review' and 'additional' are excluded from the core usage summary.
|
||||
}
|
||||
} else {
|
||||
for (const window of windows) {
|
||||
const kind = getCodexWindowKind(window.label);
|
||||
if (kind === 'usage-5h') {
|
||||
if (!fiveHourWindow) fiveHourWindow = window;
|
||||
nonCodeReviewWindows.push(window);
|
||||
continue;
|
||||
}
|
||||
if (kind === 'usage-weekly') {
|
||||
if (!weeklyWindow) weeklyWindow = window;
|
||||
nonCodeReviewWindows.push(window);
|
||||
continue;
|
||||
}
|
||||
if (kind === 'unknown') {
|
||||
nonCodeReviewWindows.push(window);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ((!fiveHourWindow || !weeklyWindow) && nonCodeReviewWindows.length > 0) {
|
||||
const withReset = nonCodeReviewWindows
|
||||
.filter((w) => typeof w.resetAfterSeconds === 'number' && w.resetAfterSeconds >= 0)
|
||||
.sort((a, b) => (a.resetAfterSeconds || 0) - (b.resetAfterSeconds || 0));
|
||||
|
||||
if (!fiveHourWindow) {
|
||||
fiveHourWindow = withReset[0] || nonCodeReviewWindows[0] || null;
|
||||
}
|
||||
|
||||
if (!weeklyWindow) {
|
||||
weeklyWindow =
|
||||
withReset.length > 1
|
||||
? withReset[withReset.length - 1]
|
||||
: nonCodeReviewWindows.find((w) => w !== fiveHourWindow) || null;
|
||||
}
|
||||
}
|
||||
|
||||
return { fiveHourWindow, weeklyWindow };
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Generic formatting helpers for quota CLI output.
|
||||
*
|
||||
* These helpers are pure (no I/O, no side effects) so they can be unit tested
|
||||
* in isolation and reused across provider sections.
|
||||
*/
|
||||
|
||||
import { formatAccountDisplayName } from '../../../cliproxy/accounts/email-account-identity';
|
||||
import { color, dim } from '../../../utils/ui';
|
||||
|
||||
/** Render a 20-char wide ASCII quota bar for the given percentage. */
|
||||
export function formatQuotaBar(percentage: number): string {
|
||||
const width = 20;
|
||||
const clampedPct = Math.max(0, Math.min(100, percentage));
|
||||
const filled = Math.round((clampedPct / 100) * width);
|
||||
const empty = width - filled;
|
||||
const filledChar = clampedPct > 50 ? '█' : clampedPct > 10 ? '▓' : '░';
|
||||
return `[${filledChar.repeat(filled)}${' '.repeat(empty)}]`;
|
||||
}
|
||||
|
||||
/** Render a human-readable relative reset time from a seconds offset. */
|
||||
export function formatResetTime(seconds: number): string {
|
||||
if (seconds <= 0) return 'now';
|
||||
if (seconds < 60) return `in ${seconds}s`;
|
||||
if (seconds < 3600) return `in ${Math.round(seconds / 60)}m`;
|
||||
if (seconds < 86400) return `in ${Math.round(seconds / 3600)}h`;
|
||||
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.round((seconds % 86400) / 3600);
|
||||
if (hours <= 0) return `in ${days}d`;
|
||||
if (hours >= 24) return `in ${days + 1}d`;
|
||||
return `in ${days}d ${hours}h`;
|
||||
}
|
||||
|
||||
/** Render a relative reset time from an ISO timestamp. Returns 'unknown' if invalid. */
|
||||
export function formatResetTimeISO(isoTime: string): string {
|
||||
if (!isoTime) return 'unknown';
|
||||
const resetDate = new Date(isoTime);
|
||||
if (isNaN(resetDate.getTime())) return 'unknown';
|
||||
const seconds = Math.max(0, Math.round((resetDate.getTime() - Date.now()) / 1000));
|
||||
return formatResetTime(seconds);
|
||||
}
|
||||
|
||||
/** Render an absolute reset time (MM/DD HH:MM) from ISO, or null if invalid. */
|
||||
export function formatAbsoluteResetTime(isoTime: string): string | null {
|
||||
if (!isoTime) return null;
|
||||
const resetDate = new Date(isoTime);
|
||||
if (isNaN(resetDate.getTime())) return null;
|
||||
const date = resetDate.toLocaleDateString(undefined, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
const time = resetDate.toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return `${date} ${time}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display label for an account: shows nickname + canonical email-style label
|
||||
* when a nickname is present, otherwise just the canonical label.
|
||||
*/
|
||||
export function formatCliAccountLabel(account: {
|
||||
id: string;
|
||||
email?: string;
|
||||
nickname?: string;
|
||||
}): string {
|
||||
const displayName = formatAccountDisplayName(account);
|
||||
return account.nickname ? `${account.nickname} (${displayName})` : displayName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the tier to display for an account. A live (freshly fetched) tier wins
|
||||
* over a stale account-config tier unless the live tier is 'unknown'. Returns
|
||||
* 'unknown' when neither source provides a value.
|
||||
*/
|
||||
export function resolveDisplayedTier(
|
||||
accountTier: string | undefined,
|
||||
liveTier: string | undefined
|
||||
): string {
|
||||
return (liveTier && liveTier !== 'unknown' ? liveTier : accountTier) || 'unknown';
|
||||
}
|
||||
|
||||
// Re-export dim/color here so section modules can pull UI primitives from a
|
||||
// single quota-local import. Keeps the surface small.
|
||||
export { color, dim };
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Quota CLI subcommand handlers.
|
||||
*
|
||||
* Public entry points consumed by src/commands/cliproxy/index.ts:
|
||||
* - handleQuotaStatus (`ccs cliproxy quota`)
|
||||
* - handleDoctor (`ccs cliproxy doctor` / `diag`)
|
||||
* - handleSetDefault (`ccs cliproxy default <account>`)
|
||||
* - handlePauseAccount (`ccs cliproxy pause <account>`)
|
||||
* - handleResumeAccount (`ccs cliproxy resume <account>`)
|
||||
*
|
||||
* Behavior is preserved verbatim from the original god file; only the
|
||||
* module boundaries changed.
|
||||
*/
|
||||
|
||||
import {
|
||||
getProviderAccounts,
|
||||
pauseAccount,
|
||||
resumeAccount,
|
||||
setDefaultAccount,
|
||||
findAccountByQuery,
|
||||
} from '../../../cliproxy/accounts/account-manager';
|
||||
import type { CLIProxyProvider } from '../../../cliproxy/types';
|
||||
import {
|
||||
QUOTA_SUPPORTED_PROVIDER_IDS,
|
||||
type QuotaSupportedProvider,
|
||||
} from '../../../cliproxy/provider-capabilities';
|
||||
import { fetchAllProviderQuotas } from '../../../cliproxy/quota/quota-fetcher';
|
||||
import { initUI, header, subheader, color, dim, ok, fail, warn, info } from '../../../utils/ui';
|
||||
import { renderProviderPoolSection, readPoolRoutingSettings } from '../pool-state-renderer';
|
||||
import { displayQuotaFailure } from './quota-failure-display';
|
||||
import { formatCliAccountLabel, formatQuotaBar } from './format-helpers';
|
||||
import { parseProfileArgs } from './profile-args';
|
||||
import { QUOTA_PROVIDER_RUNTIME } from './provider-runtime';
|
||||
|
||||
/** `ccs cliproxy quota [--provider <name>]` */
|
||||
export async function handleQuotaStatus(
|
||||
verbose = false,
|
||||
providerFilter: QuotaSupportedProvider | 'all' = 'all'
|
||||
): Promise<void> {
|
||||
await initUI();
|
||||
console.log(header('Quota Status'));
|
||||
console.log('');
|
||||
|
||||
const requestedProviders = new Set<QuotaSupportedProvider>(
|
||||
providerFilter === 'all' ? QUOTA_SUPPORTED_PROVIDER_IDS : [providerFilter]
|
||||
);
|
||||
const shouldFetch = (provider: QuotaSupportedProvider): boolean =>
|
||||
requestedProviders.has(provider);
|
||||
|
||||
console.log(dim('Fetching quotas...'));
|
||||
|
||||
const providerResults = new Map<QuotaSupportedProvider, unknown | null>(
|
||||
await Promise.all(
|
||||
QUOTA_SUPPORTED_PROVIDER_IDS.map(async (provider) => {
|
||||
if (!shouldFetch(provider)) {
|
||||
return [provider, null] as const;
|
||||
}
|
||||
return [provider, await QUOTA_PROVIDER_RUNTIME[provider].fetch(verbose)] as const;
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
console.log('');
|
||||
|
||||
// Pool routing settings are global to the CLIProxy config; read once.
|
||||
const poolSettings = readPoolRoutingSettings();
|
||||
|
||||
for (const provider of QUOTA_SUPPORTED_PROVIDER_IDS) {
|
||||
if (!shouldFetch(provider)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const runtime = QUOTA_PROVIDER_RUNTIME[provider];
|
||||
const result = providerResults.get(provider) ?? null;
|
||||
if (result !== null && runtime.hasData(result)) {
|
||||
runtime.render(result);
|
||||
// Pool context: drain order + per-account state (available/cooling/paused).
|
||||
// QuotaSupportedProvider ids are all valid CLIProxyProvider values.
|
||||
// Async: folds in live in-proxy 429 cooldowns when pool routing is on.
|
||||
await renderProviderPoolSection(provider as CLIProxyProvider, poolSettings);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(subheader(runtime.emptyTitle));
|
||||
console.log(info(runtime.emptyMessage));
|
||||
console.log(` Run: ${color(runtime.authCommand, 'command')} to authenticate`);
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
/** `ccs cliproxy doctor` (alias: `diag`) - Antigravity diagnostics. */
|
||||
export async function handleDoctor(verbose = false): Promise<void> {
|
||||
await initUI();
|
||||
console.log(header('CLIProxy Quota Diagnostics'));
|
||||
console.log('');
|
||||
|
||||
const provider: CLIProxyProvider = 'agy';
|
||||
const accounts = getProviderAccounts(provider);
|
||||
|
||||
if (accounts.length === 0) {
|
||||
console.log(info('No Antigravity accounts configured'));
|
||||
console.log(` Run: ${color('ccs agy --auth', 'command')} to authenticate`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(subheader(`Antigravity Accounts (${accounts.length})`));
|
||||
console.log('');
|
||||
|
||||
console.log(dim('Fetching quotas...'));
|
||||
const quotaResult = await fetchAllProviderQuotas(provider, verbose);
|
||||
|
||||
for (const { account, quota } of quotaResult.accounts) {
|
||||
const accountLabel = formatCliAccountLabel(account);
|
||||
const defaultBadge = account.isDefault ? color(' (default)', 'info') : '';
|
||||
|
||||
if (!quota.success) {
|
||||
console.log(` ${fail(accountLabel)}${defaultBadge}`);
|
||||
displayQuotaFailure(quota);
|
||||
if (quota.isUnprovisioned) {
|
||||
console.log(
|
||||
` ${warn('Account not provisioned - open Gemini Code Assist in IDE first')}`
|
||||
);
|
||||
}
|
||||
console.log('');
|
||||
continue;
|
||||
}
|
||||
|
||||
const avgQuota =
|
||||
quota.models.length > 0
|
||||
? quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length
|
||||
: 0;
|
||||
const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail('');
|
||||
|
||||
console.log(` ${statusIcon}${accountLabel}${defaultBadge}`);
|
||||
if (quota.projectId) {
|
||||
console.log(` Project: ${dim(quota.projectId)}`);
|
||||
}
|
||||
|
||||
for (const model of quota.models) {
|
||||
const bar = formatQuotaBar(model.percentage);
|
||||
console.log(` ${model.name.padEnd(20)} ${bar} ${model.percentage.toFixed(0)}%`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
const sharedProjects = Object.entries(quotaResult.projectGroups).filter(
|
||||
([, accountIds]) => accountIds.length > 1
|
||||
);
|
||||
|
||||
if (sharedProjects.length > 0) {
|
||||
console.log('');
|
||||
console.log(subheader('Shared Project Warning'));
|
||||
console.log('');
|
||||
for (const [projectId, accountIds] of sharedProjects) {
|
||||
console.log(
|
||||
fail(`Project ${projectId.substring(0, 20)}... shared by ${accountIds.length} accounts:`)
|
||||
);
|
||||
for (const accountId of accountIds) {
|
||||
console.log(` - ${accountId}`);
|
||||
}
|
||||
console.log('');
|
||||
console.log(warn('These accounts share the same quota pool!'));
|
||||
console.log(warn('Failover between them will NOT help when quota is exhausted.'));
|
||||
console.log(info('Solution: Use accounts from different GCP projects.'));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(subheader('Summary'));
|
||||
const healthyAccounts = quotaResult.accounts.filter(
|
||||
({ quota }) => quota.success && quota.models.some((m) => m.percentage > 5)
|
||||
);
|
||||
console.log(` Accounts with quota: ${healthyAccounts.length}/${accounts.length}`);
|
||||
if (sharedProjects.length > 0) {
|
||||
console.log(` ${fail(`Shared projects: ${sharedProjects.length} (failover limited)`)}`);
|
||||
} else if (accounts.length > 1) {
|
||||
console.log(` ${ok('No shared projects (failover fully operational)')}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/** `ccs cliproxy default <account> [--provider <provider>]` */
|
||||
export async function handleSetDefault(args: string[]): Promise<void> {
|
||||
await initUI();
|
||||
const parsed = parseProfileArgs(args);
|
||||
|
||||
if (!parsed.name) {
|
||||
console.log(fail('Usage: ccs cliproxy default <account> [--provider <provider>]'));
|
||||
console.log('');
|
||||
console.log('Examples:');
|
||||
console.log(' ccs cliproxy default ultra@gmail.com');
|
||||
console.log(' ccs cliproxy default john --provider agy');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const provider = (parsed.provider || 'agy') as CLIProxyProvider;
|
||||
const account = findAccountByQuery(provider, parsed.name);
|
||||
|
||||
if (!account) {
|
||||
console.log(fail(`Account not found: ${parsed.name}`));
|
||||
console.log('');
|
||||
const accounts = getProviderAccounts(provider);
|
||||
if (accounts.length > 0) {
|
||||
console.log('Available accounts:');
|
||||
for (const acc of accounts) {
|
||||
const badge = acc.isDefault ? color(' (current default)', 'info') : '';
|
||||
console.log(` - ${formatCliAccountLabel(acc)}${badge}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`No accounts found for provider: ${provider}`);
|
||||
console.log(`Run: ccs ${provider} --auth`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const success = setDefaultAccount(provider, account.id);
|
||||
|
||||
if (success) {
|
||||
console.log(ok(`Default account set to: ${formatCliAccountLabel(account)}`));
|
||||
console.log(info(`Provider: ${provider}`));
|
||||
} else {
|
||||
console.log(fail('Failed to set default account'));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/** `ccs cliproxy pause <account> [--provider <provider>]` */
|
||||
export async function handlePauseAccount(args: string[]): Promise<void> {
|
||||
await initUI();
|
||||
const parsed = parseProfileArgs(args);
|
||||
|
||||
if (!parsed.name) {
|
||||
console.log(fail('Usage: ccs cliproxy pause <account> [--provider <provider>]'));
|
||||
console.log('');
|
||||
console.log('Pauses an account so it will be skipped in quota rotation.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const provider = (parsed.provider || 'agy') as CLIProxyProvider;
|
||||
const account = findAccountByQuery(provider, parsed.name);
|
||||
|
||||
if (!account) {
|
||||
console.log(fail(`Account not found: ${parsed.name}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (account.paused) {
|
||||
const refreshed = pauseAccount(provider, account.id);
|
||||
const refreshedAccount = refreshed ? findAccountByQuery(provider, account.id) : account;
|
||||
console.log(warn(`Account already paused: ${formatCliAccountLabel(account)}`));
|
||||
if (refreshed) {
|
||||
console.log(info('Manual pause refreshed; account will stay out of quota rotation'));
|
||||
}
|
||||
console.log(info(`Paused at: ${refreshedAccount?.pausedAt || account.pausedAt || 'unknown'}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const success = pauseAccount(provider, account.id);
|
||||
|
||||
if (success) {
|
||||
console.log(ok(`Account paused: ${formatCliAccountLabel(account)}`));
|
||||
console.log(info('Account will be skipped in quota rotation'));
|
||||
} else {
|
||||
console.log(fail('Failed to pause account'));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/** `ccs cliproxy resume <account> [--provider <provider>]` */
|
||||
export async function handleResumeAccount(args: string[]): Promise<void> {
|
||||
await initUI();
|
||||
const parsed = parseProfileArgs(args);
|
||||
|
||||
if (!parsed.name) {
|
||||
console.log(fail('Usage: ccs cliproxy resume <account> [--provider <provider>]'));
|
||||
console.log('');
|
||||
console.log('Resumes a paused account for quota rotation.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const provider = (parsed.provider || 'agy') as CLIProxyProvider;
|
||||
const account = findAccountByQuery(provider, parsed.name);
|
||||
|
||||
if (!account) {
|
||||
console.log(fail(`Account not found: ${parsed.name}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!account.paused) {
|
||||
console.log(warn(`Account is not paused: ${formatCliAccountLabel(account)}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const success = resumeAccount(provider, account.id);
|
||||
|
||||
if (success) {
|
||||
console.log(ok(`Account resumed: ${formatCliAccountLabel(account)}`));
|
||||
console.log(info('Account is now active in quota rotation'));
|
||||
} else {
|
||||
console.log(fail('Failed to resume account'));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Argument parsing for account-management subcommands (default/pause/resume).
|
||||
*
|
||||
* Extracted verbatim from the original god file. Only the parsing logic lives
|
||||
* here; the subcommand handlers themselves are in handlers.ts.
|
||||
*/
|
||||
|
||||
import type { CliproxyProfileArgs } from './types';
|
||||
|
||||
/** Parse the raw CLI args for a `ccs cliproxy default|pause|resume` invocation. */
|
||||
export function parseProfileArgs(args: string[]): CliproxyProfileArgs {
|
||||
const result: CliproxyProfileArgs = {};
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === '--provider' && args[i + 1]) {
|
||||
result.provider = args[++i];
|
||||
} else if (arg === '--model' && args[i + 1]) {
|
||||
result.model = args[++i];
|
||||
} else if (arg === '--account' && args[i + 1]) {
|
||||
result.account = args[++i];
|
||||
} else if (arg === '--force') {
|
||||
result.force = true;
|
||||
} else if (arg === '--yes' || arg === '-y') {
|
||||
result.yes = true;
|
||||
} else if (!arg.startsWith('-') && !result.name) {
|
||||
result.name = arg;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Per-provider runtime adapters for the quota command.
|
||||
*
|
||||
* Each entry wires a fetcher (from cliproxy/quota/*) to its section renderer
|
||||
* and provides the empty-state strings shown when no accounts are configured.
|
||||
* The runtime map is consumed by handleQuotaStatus in handlers.ts.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ClaudeQuotaResult,
|
||||
CodexQuotaResult,
|
||||
GeminiCliQuotaResult,
|
||||
GhcpQuotaResult,
|
||||
} from '../../../cliproxy/quota/quota-types';
|
||||
import { fetchAllClaudeQuotas } from '../../../cliproxy/quota/quota-fetcher-claude';
|
||||
import { fetchAllCodexQuotas } from '../../../cliproxy/quota/quota-fetcher-codex';
|
||||
import { fetchAllGeminiCliQuotas } from '../../../cliproxy/quota/quota-fetcher-gemini-cli';
|
||||
import { fetchAllGhcpQuotas } from '../../../cliproxy/quota/quota-fetcher-ghcp';
|
||||
import { fetchAllProviderQuotas } from '../../../cliproxy/quota/quota-fetcher';
|
||||
import type { QuotaSupportedProvider } from '../../../cliproxy/provider-capabilities';
|
||||
import type { QuotaProviderRuntime } from './types';
|
||||
import { displayAntigravityQuotaSection } from './sections/antigravity';
|
||||
import { displayClaudeQuotaSection } from './sections/claude';
|
||||
import { displayCodexQuotaSection } from './sections/codex';
|
||||
import { displayGhcpQuotaSection } from './sections/ghcp';
|
||||
import { displayGeminiCliQuotaSection } from './sections/gemini-cli';
|
||||
|
||||
/** Runtime adapter for each quota-supported provider. */
|
||||
export const QUOTA_PROVIDER_RUNTIME: Record<QuotaSupportedProvider, QuotaProviderRuntime> = {
|
||||
agy: {
|
||||
fetch: (verbose) => fetchAllProviderQuotas('agy', verbose),
|
||||
hasData: (result) =>
|
||||
(result as Awaited<ReturnType<typeof fetchAllProviderQuotas>>).accounts.length > 0,
|
||||
render: (result) =>
|
||||
displayAntigravityQuotaSection(result as Awaited<ReturnType<typeof fetchAllProviderQuotas>>),
|
||||
emptyTitle: 'Antigravity (0 accounts)',
|
||||
emptyMessage: 'No Antigravity accounts configured',
|
||||
authCommand: 'ccs agy --auth',
|
||||
},
|
||||
codex: {
|
||||
fetch: (verbose) => fetchAllCodexQuotas(verbose),
|
||||
hasData: (result) => (result as { account: string; quota: CodexQuotaResult }[]).length > 0,
|
||||
render: (result) =>
|
||||
displayCodexQuotaSection(result as { account: string; quota: CodexQuotaResult }[]),
|
||||
emptyTitle: 'Codex (0 accounts)',
|
||||
emptyMessage: 'No Codex accounts configured',
|
||||
authCommand: 'ccs codex --auth',
|
||||
},
|
||||
claude: {
|
||||
fetch: (verbose) => fetchAllClaudeQuotas(verbose),
|
||||
hasData: (result) => (result as { account: string; quota: ClaudeQuotaResult }[]).length > 0,
|
||||
render: (result) =>
|
||||
displayClaudeQuotaSection(result as { account: string; quota: ClaudeQuotaResult }[]),
|
||||
emptyTitle: 'Claude (0 accounts)',
|
||||
emptyMessage: 'No Claude accounts configured',
|
||||
authCommand: 'ccs claude --auth',
|
||||
},
|
||||
gemini: {
|
||||
fetch: (verbose) => fetchAllGeminiCliQuotas(verbose),
|
||||
hasData: (result) => (result as { account: string; quota: GeminiCliQuotaResult }[]).length > 0,
|
||||
render: (result) =>
|
||||
displayGeminiCliQuotaSection(result as { account: string; quota: GeminiCliQuotaResult }[]),
|
||||
emptyTitle: 'Gemini CLI (0 accounts)',
|
||||
emptyMessage: 'No Gemini CLI accounts configured',
|
||||
authCommand: 'ccs gemini --auth',
|
||||
},
|
||||
ghcp: {
|
||||
fetch: (verbose) => fetchAllGhcpQuotas(verbose),
|
||||
hasData: (result) => (result as { account: string; quota: GhcpQuotaResult }[]).length > 0,
|
||||
render: (result) =>
|
||||
displayGhcpQuotaSection(result as { account: string; quota: GhcpQuotaResult }[]),
|
||||
emptyTitle: 'GitHub Copilot (0 accounts)',
|
||||
emptyMessage: 'No GitHub Copilot accounts configured',
|
||||
authCommand: 'ccs ghcp --auth',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Quota failure display helpers.
|
||||
*
|
||||
* Builds the multi-line failure block shown beneath a failed account row.
|
||||
* Extracted from the original god file verbatim so the CLI output and the
|
||||
* unit tests in tests/unit/commands/cliproxy-quota-subcommand.test.ts keep
|
||||
* their exact behavior.
|
||||
*/
|
||||
|
||||
import type { QuotaErrorMetadata } from '../../../cliproxy/quota/quota-types';
|
||||
import { color, dim, info } from '../../../utils/ui';
|
||||
import type { QuotaFailureDisplayEntry } from './types';
|
||||
|
||||
/**
|
||||
* Build the ordered list of failure display entries for a quota error.
|
||||
*
|
||||
* Order is:
|
||||
* 1. error message (always)
|
||||
* 2. action hint (if present)
|
||||
* 3. diagnostics line: HTTP status | error code | retryable flag (if any)
|
||||
* 4. detail line (only if it differs from the error message)
|
||||
*/
|
||||
export function getQuotaFailureDisplayEntries(
|
||||
quota: QuotaErrorMetadata & {
|
||||
error?: string;
|
||||
}
|
||||
): QuotaFailureDisplayEntry[] {
|
||||
const entries: QuotaFailureDisplayEntry[] = [
|
||||
{
|
||||
tone: 'error',
|
||||
text: quota.error || 'Failed to fetch quota',
|
||||
},
|
||||
];
|
||||
|
||||
if (quota.actionHint) {
|
||||
entries.push({
|
||||
tone: 'info',
|
||||
text: quota.actionHint,
|
||||
});
|
||||
}
|
||||
|
||||
const diagnostics: string[] = [];
|
||||
if (typeof quota.httpStatus === 'number') {
|
||||
diagnostics.push(`HTTP ${quota.httpStatus}`);
|
||||
}
|
||||
if (quota.errorCode) {
|
||||
diagnostics.push(`Code: ${quota.errorCode}`);
|
||||
}
|
||||
if (quota.retryable) {
|
||||
diagnostics.push('Retryable');
|
||||
}
|
||||
if (diagnostics.length > 0) {
|
||||
entries.push({
|
||||
tone: 'dim',
|
||||
text: diagnostics.join(' | '),
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedError = quota.error?.trim();
|
||||
const normalizedDetail = quota.errorDetail?.trim();
|
||||
if (normalizedDetail && normalizedDetail !== normalizedError) {
|
||||
entries.push({
|
||||
tone: 'dim',
|
||||
text: `Detail: ${normalizedDetail}`,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Render the failure block for a single failed account to stdout. */
|
||||
export function displayQuotaFailure(
|
||||
quota: QuotaErrorMetadata & {
|
||||
error?: string;
|
||||
}
|
||||
): void {
|
||||
for (const entry of getQuotaFailureDisplayEntries(quota)) {
|
||||
const rendered =
|
||||
entry.tone === 'error'
|
||||
? color(entry.text, 'error')
|
||||
: entry.tone === 'info'
|
||||
? info(entry.text)
|
||||
: dim(entry.text);
|
||||
console.log(` ${rendered}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Antigravity (agy) provider section renderer for `ccs cliproxy quota`.
|
||||
*
|
||||
* Renders the account table with per-account average quota, tier, and status
|
||||
* (paused / cooldown). Extracted verbatim from the original god file.
|
||||
*/
|
||||
|
||||
import { getProviderAccounts } from '../../../../cliproxy/accounts/account-manager';
|
||||
import { fetchAllProviderQuotas } from '../../../../cliproxy/quota/quota-fetcher';
|
||||
import { isOnCooldown } from '../../../../cliproxy/quota/quota-manager';
|
||||
import { color, subheader, table } from '../../../../utils/ui';
|
||||
import { formatCliAccountLabel, resolveDisplayedTier } from '../format-helpers';
|
||||
|
||||
/** Render the Antigravity quota section for a fetched quota result. */
|
||||
export function displayAntigravityQuotaSection(
|
||||
quotaResult: Awaited<ReturnType<typeof fetchAllProviderQuotas>>
|
||||
): void {
|
||||
const provider = 'agy';
|
||||
const accounts = getProviderAccounts(provider);
|
||||
|
||||
console.log(
|
||||
subheader(`Antigravity (${accounts.length} account${accounts.length !== 1 ? 's' : ''})`)
|
||||
);
|
||||
console.log('');
|
||||
|
||||
const rows: string[][] = [];
|
||||
for (const account of accounts) {
|
||||
const quotaData = quotaResult.accounts.find((q) => q.account.id === account.id);
|
||||
const quota = quotaData?.quota;
|
||||
|
||||
let avgQuota = 'N/A';
|
||||
if (quota?.success && quota.models.length > 0) {
|
||||
const avg = Math.round(
|
||||
quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length
|
||||
);
|
||||
avgQuota = `${avg}%`;
|
||||
}
|
||||
|
||||
const statusParts: string[] = [];
|
||||
if (account.paused) statusParts.push(color('PAUSED', 'warning'));
|
||||
if (isOnCooldown(provider, account.id)) statusParts.push(color('COOLDOWN', 'warning'));
|
||||
|
||||
const defaultMark = account.isDefault ? color('*', 'success') : ' ';
|
||||
const tier = resolveDisplayedTier(account.tier, quota?.entitlement?.normalizedTier);
|
||||
const status = statusParts.join(', ');
|
||||
|
||||
rows.push([defaultMark, formatCliAccountLabel(account), tier, avgQuota, status]);
|
||||
}
|
||||
|
||||
console.log(
|
||||
table(rows, {
|
||||
head: ['', 'Account', 'Tier', 'Quota', 'Status'],
|
||||
colWidths: [3, 30, 10, 10, 20],
|
||||
})
|
||||
);
|
||||
console.log('');
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Claude provider section renderer for `ccs cliproxy quota`.
|
||||
*
|
||||
* Renders per-account quota bars for the 5h + weekly core usage windows plus
|
||||
* any per-model or overage windows. Extracted verbatim from the original
|
||||
* god file.
|
||||
*/
|
||||
|
||||
import { findAccountByQuery } from '../../../../cliproxy/accounts/account-manager';
|
||||
import type { ClaudeQuotaResult } from '../../../../cliproxy/quota/quota-types';
|
||||
import { color, dim, fail, info, ok, subheader, warn } from '../../../../utils/ui';
|
||||
import {
|
||||
getClaudeCoreUsageWindows,
|
||||
getClaudeWindowDisplayLabel,
|
||||
toClaudeDisplayWindow,
|
||||
} from '../claude-window-helpers';
|
||||
import { displayQuotaFailure } from '../quota-failure-display';
|
||||
import { formatCliAccountLabel, formatQuotaBar, formatResetTimeISO } from '../format-helpers';
|
||||
import type { ClaudeDisplayWindow } from '../types';
|
||||
|
||||
/** Render the Claude quota section for a list of per-account results. */
|
||||
export function displayClaudeQuotaSection(
|
||||
results: {
|
||||
account: string;
|
||||
quota: ClaudeQuotaResult;
|
||||
}[]
|
||||
): void {
|
||||
console.log(subheader(`Claude (${results.length} account${results.length !== 1 ? 's' : ''})`));
|
||||
console.log('');
|
||||
|
||||
for (const { account, quota } of results) {
|
||||
const accountInfo = findAccountByQuery('claude', account);
|
||||
const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account;
|
||||
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
|
||||
|
||||
if (!quota.success) {
|
||||
console.log(` ${fail(accountLabel)}${defaultMark}`);
|
||||
displayQuotaFailure(quota);
|
||||
console.log('');
|
||||
continue;
|
||||
}
|
||||
|
||||
const { fiveHourWindow, weeklyWindow } = getClaudeCoreUsageWindows(quota);
|
||||
const coreWindows = [fiveHourWindow, weeklyWindow].filter(
|
||||
(window, index, arr): window is ClaudeDisplayWindow =>
|
||||
!!window && arr.indexOf(window) === index
|
||||
);
|
||||
const statusWindows =
|
||||
coreWindows.length > 0 ? coreWindows : quota.windows.map(toClaudeDisplayWindow);
|
||||
const minQuota =
|
||||
statusWindows.length > 0
|
||||
? Math.min(...statusWindows.map((window) => window.remainingPercent))
|
||||
: null;
|
||||
const statusIcon =
|
||||
minQuota === null ? info('') : minQuota > 50 ? ok('') : minQuota > 10 ? warn('') : fail('');
|
||||
|
||||
console.log(` ${statusIcon}${accountLabel}${defaultMark}`);
|
||||
|
||||
const resetParts: string[] = [];
|
||||
if (fiveHourWindow?.resetAt)
|
||||
resetParts.push(`5h ${formatResetTimeISO(fiveHourWindow.resetAt)}`);
|
||||
if (weeklyWindow?.resetAt)
|
||||
resetParts.push(`weekly ${formatResetTimeISO(weeklyWindow.resetAt)}`);
|
||||
if (resetParts.length > 0) {
|
||||
console.log(` ${dim(`Reset schedule: ${resetParts.join(' | ')}`)}`);
|
||||
}
|
||||
|
||||
const orderedWindows = [...coreWindows, ...quota.windows.map(toClaudeDisplayWindow)].filter(
|
||||
(window, index, arr) =>
|
||||
arr.findIndex(
|
||||
(candidate) =>
|
||||
candidate.rateLimitType === window.rateLimitType &&
|
||||
candidate.resetAt === window.resetAt &&
|
||||
candidate.status === window.status
|
||||
) === index
|
||||
);
|
||||
|
||||
if (orderedWindows.length === 0) {
|
||||
console.log(` ${dim('Policy limits unavailable for this account')}`);
|
||||
console.log('');
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const window of orderedWindows) {
|
||||
const bar = formatQuotaBar(window.remainingPercent);
|
||||
const resetLabel = window.resetAt ? dim(` Resets ${formatResetTimeISO(window.resetAt)}`) : '';
|
||||
const statusLabel =
|
||||
window.status === 'rejected'
|
||||
? dim(' [blocked]')
|
||||
: window.status === 'allowed_warning'
|
||||
? dim(' [warning]')
|
||||
: '';
|
||||
console.log(
|
||||
` ${getClaudeWindowDisplayLabel(window).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${statusLabel}${resetLabel}`
|
||||
);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Codex provider section renderer for `ccs cliproxy quota`.
|
||||
*
|
||||
* Renders per-account quota bars for the 5h + weekly core usage windows plus
|
||||
* any additional feature windows (e.g. Codex Spark). Extracted verbatim from
|
||||
* the original god file.
|
||||
*/
|
||||
|
||||
import { findAccountByQuery } from '../../../../cliproxy/accounts/account-manager';
|
||||
import type { CodexQuotaResult } from '../../../../cliproxy/quota/quota-types';
|
||||
import { color, dim, fail, ok, subheader, warn } from '../../../../utils/ui';
|
||||
import {
|
||||
formatCodexWindowReset,
|
||||
getCodexCoreUsageWindows,
|
||||
getCodexWindowDisplayLabel,
|
||||
} from '../codex-window-helpers';
|
||||
import { displayQuotaFailure } from '../quota-failure-display';
|
||||
import { formatCliAccountLabel, formatQuotaBar } from '../format-helpers';
|
||||
|
||||
/** Render the Codex quota section for a list of per-account results. */
|
||||
export function displayCodexQuotaSection(
|
||||
results: {
|
||||
account: string;
|
||||
quota: CodexQuotaResult;
|
||||
}[]
|
||||
): void {
|
||||
console.log(subheader(`Codex (${results.length} account${results.length !== 1 ? 's' : ''})`));
|
||||
console.log('');
|
||||
|
||||
for (const { account, quota } of results) {
|
||||
const accountInfo = findAccountByQuery('codex', account);
|
||||
const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account;
|
||||
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
|
||||
|
||||
if (!quota.success) {
|
||||
console.log(` ${fail(accountLabel)}${defaultMark}`);
|
||||
displayQuotaFailure(quota);
|
||||
console.log('');
|
||||
continue;
|
||||
}
|
||||
|
||||
const { fiveHourWindow, weeklyWindow } = getCodexCoreUsageWindows(quota.windows);
|
||||
const coreUsageWindows = [fiveHourWindow, weeklyWindow].filter(
|
||||
(w, index, arr): w is NonNullable<typeof w> => !!w && arr.indexOf(w) === index
|
||||
);
|
||||
const statusWindows = coreUsageWindows.length > 0 ? coreUsageWindows : quota.windows;
|
||||
|
||||
const avgQuota =
|
||||
statusWindows.length > 0
|
||||
? statusWindows.reduce((sum, w) => sum + w.remainingPercent, 0) / statusWindows.length
|
||||
: 0;
|
||||
const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail('');
|
||||
const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : '';
|
||||
|
||||
console.log(` ${statusIcon}${accountLabel}${defaultMark}${planBadge}`);
|
||||
|
||||
const coreUsageSummary = quota.coreUsage ?? {
|
||||
fiveHour: fiveHourWindow
|
||||
? {
|
||||
label: fiveHourWindow.label,
|
||||
remainingPercent: fiveHourWindow.remainingPercent,
|
||||
resetAfterSeconds: fiveHourWindow.resetAfterSeconds,
|
||||
resetAt: fiveHourWindow.resetAt,
|
||||
}
|
||||
: null,
|
||||
weekly: weeklyWindow
|
||||
? {
|
||||
label: weeklyWindow.label,
|
||||
remainingPercent: weeklyWindow.remainingPercent,
|
||||
resetAfterSeconds: weeklyWindow.resetAfterSeconds,
|
||||
resetAt: weeklyWindow.resetAt,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
const resetParts: string[] = [];
|
||||
const fiveHourReset = coreUsageSummary.fiveHour
|
||||
? formatCodexWindowReset(coreUsageSummary.fiveHour)
|
||||
: null;
|
||||
const weeklyReset = coreUsageSummary.weekly
|
||||
? formatCodexWindowReset(coreUsageSummary.weekly)
|
||||
: null;
|
||||
if (fiveHourReset) resetParts.push(`5h ${fiveHourReset}`);
|
||||
if (weeklyReset) resetParts.push(`weekly ${weeklyReset}`);
|
||||
if (resetParts.length > 0) {
|
||||
console.log(` ${dim(`Reset schedule: ${resetParts.join(' | ')}`)}`);
|
||||
}
|
||||
|
||||
const orderedWindows = [fiveHourWindow, weeklyWindow, ...quota.windows].filter(
|
||||
(w, index, arr): w is NonNullable<typeof w> => !!w && arr.indexOf(w) === index
|
||||
);
|
||||
|
||||
for (const window of orderedWindows) {
|
||||
const bar = formatQuotaBar(window.remainingPercent);
|
||||
const resetValue = formatCodexWindowReset(window);
|
||||
const resetLabel = resetValue ? dim(` Resets ${resetValue}`) : '';
|
||||
console.log(
|
||||
` ${getCodexWindowDisplayLabel(window, orderedWindows).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${resetLabel}`
|
||||
);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Gemini CLI provider section renderer for `ccs cliproxy quota`.
|
||||
*
|
||||
* Renders per-account quota bars for each bucket (requests, tokens, etc.)
|
||||
* plus project, tier, and credit balance metadata. Extracted verbatim from
|
||||
* the original god file.
|
||||
*/
|
||||
|
||||
import { findAccountByQuery } from '../../../../cliproxy/accounts/account-manager';
|
||||
import type { GeminiCliQuotaResult } from '../../../../cliproxy/quota/quota-types';
|
||||
import { color, dim, fail, ok, subheader, warn } from '../../../../utils/ui';
|
||||
import { displayQuotaFailure } from '../quota-failure-display';
|
||||
import { formatCliAccountLabel, formatQuotaBar, formatResetTimeISO } from '../format-helpers';
|
||||
|
||||
/** Render the Gemini CLI quota section for a list of per-account results. */
|
||||
export function displayGeminiCliQuotaSection(
|
||||
results: {
|
||||
account: string;
|
||||
quota: GeminiCliQuotaResult;
|
||||
}[]
|
||||
): void {
|
||||
console.log(
|
||||
subheader(`Gemini CLI (${results.length} account${results.length !== 1 ? 's' : ''})`)
|
||||
);
|
||||
console.log('');
|
||||
|
||||
for (const { account, quota } of results) {
|
||||
const accountInfo = findAccountByQuery('gemini', account);
|
||||
const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account;
|
||||
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
|
||||
|
||||
if (!quota.success) {
|
||||
console.log(` ${fail(accountLabel)}${defaultMark}`);
|
||||
displayQuotaFailure(quota);
|
||||
console.log('');
|
||||
continue;
|
||||
}
|
||||
|
||||
const avgQuota =
|
||||
quota.buckets.length > 0
|
||||
? quota.buckets.reduce((sum, b) => sum + b.remainingPercent, 0) / quota.buckets.length
|
||||
: 0;
|
||||
const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail('');
|
||||
|
||||
console.log(` ${statusIcon}${accountLabel}${defaultMark}`);
|
||||
if (quota.projectId) {
|
||||
console.log(` Project: ${dim(quota.projectId)}`);
|
||||
}
|
||||
if (quota.tierLabel) {
|
||||
console.log(` Tier: ${dim(quota.tierLabel)}`);
|
||||
}
|
||||
if (quota.entitlement?.rawTierId) {
|
||||
console.log(` Tier ID: ${dim(quota.entitlement.rawTierId)}`);
|
||||
}
|
||||
if (quota.creditBalance !== null && quota.creditBalance !== undefined) {
|
||||
console.log(` Credits: ${dim(quota.creditBalance.toLocaleString())}`);
|
||||
}
|
||||
|
||||
for (const bucket of quota.buckets) {
|
||||
const bar = formatQuotaBar(bucket.remainingPercent);
|
||||
const tokenLabel = bucket.tokenType ? dim(` (${bucket.tokenType})`) : '';
|
||||
const amountLabel =
|
||||
bucket.remainingAmount !== null && bucket.remainingAmount !== undefined
|
||||
? dim(` ${bucket.remainingAmount.toLocaleString()} left`)
|
||||
: '';
|
||||
const resetLabel = bucket.resetTime
|
||||
? dim(` Resets ${formatResetTimeISO(bucket.resetTime)}`)
|
||||
: '';
|
||||
console.log(
|
||||
` ${bucket.label.padEnd(24)} ${bar} ${bucket.remainingPercent.toFixed(0)}%${tokenLabel}${amountLabel}${resetLabel}`
|
||||
);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* GitHub Copilot (ghcp) provider section renderer for `ccs cliproxy quota`.
|
||||
*
|
||||
* Renders per-account quota bars for premium interactions, chat, and
|
||||
* completions snapshots. Extracted verbatim from the original god file.
|
||||
*/
|
||||
|
||||
import { findAccountByQuery } from '../../../../cliproxy/accounts/account-manager';
|
||||
import type { GhcpQuotaResult } from '../../../../cliproxy/quota/quota-types';
|
||||
import { color, dim, fail, info, ok, subheader, warn } from '../../../../utils/ui';
|
||||
import { displayQuotaFailure } from '../quota-failure-display';
|
||||
import { formatCliAccountLabel, formatQuotaBar, formatResetTimeISO } from '../format-helpers';
|
||||
|
||||
/** Format a single snapshot as a "used/entitlement" or "N% used (unlimited)" label. */
|
||||
function formatSnapshotLabel(
|
||||
snapshot: GhcpQuotaResult['snapshots'][keyof GhcpQuotaResult['snapshots']]
|
||||
): string {
|
||||
if (snapshot.unlimited) {
|
||||
return `${snapshot.percentUsed.toFixed(0)}% used (unlimited)`;
|
||||
}
|
||||
return `${snapshot.used}/${snapshot.entitlement} used`;
|
||||
}
|
||||
|
||||
/** Render the GitHub Copilot quota section for a list of per-account results. */
|
||||
export function displayGhcpQuotaSection(
|
||||
results: { account: string; quota: GhcpQuotaResult }[]
|
||||
): void {
|
||||
console.log(
|
||||
subheader(`GitHub Copilot (${results.length} account${results.length !== 1 ? 's' : ''})`)
|
||||
);
|
||||
console.log('');
|
||||
|
||||
for (const { account, quota } of results) {
|
||||
const accountInfo = findAccountByQuery('ghcp', account);
|
||||
const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account;
|
||||
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
|
||||
|
||||
if (!quota.success) {
|
||||
console.log(` ${fail(accountLabel)}${defaultMark}`);
|
||||
displayQuotaFailure(quota);
|
||||
console.log('');
|
||||
continue;
|
||||
}
|
||||
|
||||
const reportedSnapshots = [
|
||||
quota.snapshots.premiumInteractions,
|
||||
quota.snapshots.chat,
|
||||
quota.snapshots.completions,
|
||||
].filter((snapshot) => snapshot.reported !== false);
|
||||
const rows = reportedSnapshots.map((snapshot) =>
|
||||
snapshot.unlimited ? 100 : snapshot.percentRemaining
|
||||
);
|
||||
const minQuota = rows.length > 0 ? Math.min(...rows) : null;
|
||||
const statusIcon =
|
||||
minQuota === null ? info('') : minQuota > 50 ? ok('') : minQuota > 10 ? warn('') : fail('');
|
||||
const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : '';
|
||||
|
||||
console.log(` ${statusIcon}${accountLabel}${defaultMark}${planBadge}`);
|
||||
if (quota.quotaResetDate) {
|
||||
console.log(` ${dim(`Resets ${formatResetTimeISO(quota.quotaResetDate)}`)}`);
|
||||
}
|
||||
|
||||
const allItems: Array<
|
||||
[string, GhcpQuotaResult['snapshots'][keyof GhcpQuotaResult['snapshots']]]
|
||||
> = [
|
||||
['Premium interactions', quota.snapshots.premiumInteractions],
|
||||
['Chat', quota.snapshots.chat],
|
||||
['Completions', quota.snapshots.completions],
|
||||
];
|
||||
const items = allItems.filter(([, snapshot]) => snapshot.reported !== false);
|
||||
|
||||
for (const [label, snapshot] of items) {
|
||||
const bar = formatQuotaBar(snapshot.percentRemaining);
|
||||
const usageLabel = dim(` ${formatSnapshotLabel(snapshot)}`);
|
||||
console.log(
|
||||
` ${label.padEnd(24)} ${bar} ${snapshot.percentRemaining.toFixed(0)}%${usageLabel}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Internal test-only exports for the quota subcommand.
|
||||
*
|
||||
* The unit test at tests/unit/commands/cliproxy-quota-subcommand.test.ts loads
|
||||
* the barrel module and reads `__testExports` to exercise pure helpers. Keep
|
||||
* this surface stable: adding a key is fine, but removing or renaming one
|
||||
* will break the test.
|
||||
*/
|
||||
|
||||
import { getCodexWindowDisplayLabel } from './codex-window-helpers';
|
||||
import { getQuotaFailureDisplayEntries } from './quota-failure-display';
|
||||
import { prettifyCodexFeatureLabel } from './codex-window-helpers';
|
||||
import { resolveDisplayedTier } from './format-helpers';
|
||||
|
||||
export const __testExports = {
|
||||
getCodexWindowDisplayLabel,
|
||||
getQuotaFailureDisplayEntries,
|
||||
prettifyCodexFeatureLabel,
|
||||
resolveDisplayedTier,
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Shared types for the quota-subcommand split.
|
||||
*
|
||||
* These types are implementation details of the quota CLI but are exposed via
|
||||
* the barrel so submodules can avoid circular imports.
|
||||
*/
|
||||
|
||||
/** Arguments accepted by account-management subcommands (default/pause/resume). */
|
||||
export interface CliproxyProfileArgs {
|
||||
name?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
account?: string;
|
||||
force?: boolean;
|
||||
yes?: boolean;
|
||||
}
|
||||
|
||||
/** Tone of a single quota-failure display line. Drives coloring. */
|
||||
export type QuotaFailureDisplayTone = 'error' | 'info' | 'dim';
|
||||
|
||||
/** A single rendered line in a quota failure block. */
|
||||
export interface QuotaFailureDisplayEntry {
|
||||
tone: QuotaFailureDisplayTone;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Normalized shape of a Claude window used by the CLI renderer. */
|
||||
export interface ClaudeDisplayWindow {
|
||||
rateLimitType: string;
|
||||
label: string;
|
||||
remainingPercent: number;
|
||||
resetAt: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/** Coarse classification of a Codex rate-limit window label. */
|
||||
export type CodexWindowKind =
|
||||
| 'usage-5h'
|
||||
| 'usage-weekly'
|
||||
| 'code-review-5h'
|
||||
| 'code-review-weekly'
|
||||
| 'code-review'
|
||||
| 'unknown';
|
||||
|
||||
/** Runtime adapter that knows how to fetch/render a single quota provider. */
|
||||
export interface QuotaProviderRuntime {
|
||||
fetch: (verbose: boolean) => Promise<unknown>;
|
||||
hasData: (result: unknown) => boolean;
|
||||
render: (result: unknown) => void;
|
||||
emptyTitle: string;
|
||||
emptyMessage: string;
|
||||
authCommand: string;
|
||||
}
|
||||
+15
-1059
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Persist Command - Argument Parsing
|
||||
*
|
||||
* Parses the raw CLI argv array for `ccs persist` into a typed
|
||||
* PersistCommandArgs object. Owns permission-mode validation and unknown
|
||||
* flag detection.
|
||||
*/
|
||||
|
||||
import { extractOption, hasAnyFlag } from '../arg-extractor';
|
||||
import {
|
||||
PERSIST_KNOWN_FLAGS,
|
||||
VALID_PERMISSION_MODES,
|
||||
type PersistCommandArgs,
|
||||
type PermissionMode,
|
||||
} from './types';
|
||||
|
||||
export function isPermissionMode(value: string): value is PermissionMode {
|
||||
return VALID_PERMISSION_MODES.includes(value as PermissionMode);
|
||||
}
|
||||
|
||||
export function isKnownPersistFlagToken(token: string): boolean {
|
||||
return PERSIST_KNOWN_FLAGS.some((flag) => token === flag || token.startsWith(`${flag}=`));
|
||||
}
|
||||
|
||||
export function resolvePermissionMode(parsedArgs: PersistCommandArgs): PermissionMode | undefined {
|
||||
if (!parsedArgs.dangerouslySkipPermissions) {
|
||||
return parsedArgs.permissionMode;
|
||||
}
|
||||
|
||||
if (parsedArgs.permissionMode && parsedArgs.permissionMode !== 'bypassPermissions') {
|
||||
throw new Error(
|
||||
'--dangerously-skip-permissions conflicts with --permission-mode. Use bypassPermissions or remove one flag.'
|
||||
);
|
||||
}
|
||||
|
||||
return 'bypassPermissions';
|
||||
}
|
||||
|
||||
/** Parse command line arguments */
|
||||
export function parseArgs(args: string[]): PersistCommandArgs {
|
||||
const result: PersistCommandArgs = {
|
||||
yes: hasAnyFlag(args, ['--yes', '-y']),
|
||||
listBackups: hasAnyFlag(args, ['--list-backups']),
|
||||
};
|
||||
|
||||
const restoreOption = extractOption(args, ['--restore']);
|
||||
if (restoreOption.found) {
|
||||
result.restore = restoreOption.missingValue ? true : restoreOption.value || true;
|
||||
}
|
||||
|
||||
const permissionModeOption = extractOption(restoreOption.remainingArgs, ['--permission-mode'], {
|
||||
knownFlags: PERSIST_KNOWN_FLAGS,
|
||||
});
|
||||
if (permissionModeOption.found) {
|
||||
if (permissionModeOption.missingValue) {
|
||||
result.parseError = 'Missing value for --permission-mode';
|
||||
} else if (permissionModeOption.value) {
|
||||
if (!isPermissionMode(permissionModeOption.value)) {
|
||||
result.parseError = `Invalid --permission-mode "${permissionModeOption.value}". Valid modes: ${VALID_PERMISSION_MODES.join(', ')}`;
|
||||
} else {
|
||||
result.permissionMode = permissionModeOption.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.dangerouslySkipPermissions = hasAnyFlag(permissionModeOption.remainingArgs, [
|
||||
'--dangerously-skip-permissions',
|
||||
'--auto-approve',
|
||||
]);
|
||||
|
||||
const unknownFlags = permissionModeOption.remainingArgs.filter(
|
||||
(arg) => arg.startsWith('-') && !isKnownPersistFlagToken(arg)
|
||||
);
|
||||
if (!result.parseError && unknownFlags.length > 0) {
|
||||
const unknownList = unknownFlags.map((flag) => `"${flag}"`).join(', ');
|
||||
result.parseError = `Unknown option(s): ${unknownList}. Run 'ccs persist --help' for usage.`;
|
||||
}
|
||||
|
||||
if (!result.parseError && result.listBackups && result.restore) {
|
||||
result.parseError = '--list-backups cannot be used with --restore';
|
||||
}
|
||||
|
||||
if (
|
||||
!result.parseError &&
|
||||
(result.listBackups || result.restore) &&
|
||||
(result.permissionMode || result.dangerouslySkipPermissions)
|
||||
) {
|
||||
result.parseError =
|
||||
'Permission flags are not valid with backup operations. Use them only with ccs persist <profile>.';
|
||||
}
|
||||
|
||||
for (const arg of permissionModeOption.remainingArgs) {
|
||||
if (!arg.startsWith('-')) {
|
||||
result.profile = arg;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Persist Command - Backup Rotation & Restore
|
||||
*
|
||||
* Handles settings.json backup file lifecycle: creation, timestamp-based
|
||||
* rotation, listing, and restore-with-rollback. Owns the --list-backups and
|
||||
* --restore subcommands.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { initUI, header, color, dim, ok, fail, warn, info } from '../../utils/ui';
|
||||
import { InteractivePrompt } from '../../utils/prompt';
|
||||
import { getClaudeSettingsPath } from '../../utils/claude-config-path';
|
||||
import {
|
||||
formatDisplayPath,
|
||||
getClaudeSettingsDisplayPath,
|
||||
getNoFollowFlag,
|
||||
isSymlinkAsync,
|
||||
parseSettingsObject,
|
||||
pathExists,
|
||||
readFileUtf8NoFollow,
|
||||
withPersistSettingsLock,
|
||||
writeClaudeSettings,
|
||||
} from './secure-file';
|
||||
|
||||
/** Maximum number of backups to keep (oldest are deleted) */
|
||||
export const MAX_BACKUPS = 10;
|
||||
|
||||
export interface BackupFile {
|
||||
path: string;
|
||||
timestamp: string;
|
||||
date: Date;
|
||||
}
|
||||
|
||||
function parseBackupTimestamp(timestamp: string): Date | null {
|
||||
const year = parseInt(timestamp.slice(0, 4), 10);
|
||||
const month = parseInt(timestamp.slice(4, 6), 10);
|
||||
const day = parseInt(timestamp.slice(6, 8), 10);
|
||||
const hour = parseInt(timestamp.slice(9, 11), 10);
|
||||
const minute = parseInt(timestamp.slice(11, 13), 10);
|
||||
const second = parseInt(timestamp.slice(13, 15), 10);
|
||||
const date = new Date(year, month - 1, day, hour, minute, second);
|
||||
|
||||
if (date.getFullYear() !== year) return null;
|
||||
if (date.getMonth() !== month - 1) return null;
|
||||
if (date.getDate() !== day) return null;
|
||||
if (date.getHours() !== hour) return null;
|
||||
if (date.getMinutes() !== minute) return null;
|
||||
if (date.getSeconds() !== second) return null;
|
||||
|
||||
return date;
|
||||
}
|
||||
|
||||
/** Get all backup files sorted by date (newest first) */
|
||||
export function getBackupFiles(): BackupFile[] {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const dir = path.dirname(settingsPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
return [];
|
||||
}
|
||||
const backupPattern = /^settings\.json\.backup\.(\d{8}_\d{6})$/;
|
||||
const files = fs
|
||||
.readdirSync(dir)
|
||||
.filter((f) => backupPattern.test(f))
|
||||
.map((f) => {
|
||||
const match = f.match(backupPattern);
|
||||
if (!match) return null;
|
||||
const timestamp = match[1];
|
||||
const date = parseBackupTimestamp(timestamp);
|
||||
if (!date) return null;
|
||||
return {
|
||||
path: path.join(dir, f),
|
||||
timestamp,
|
||||
date,
|
||||
};
|
||||
})
|
||||
.filter((f): f is BackupFile => f !== null)
|
||||
.sort((a, b) => b.date.getTime() - a.date.getTime()); // newest first
|
||||
return files;
|
||||
}
|
||||
|
||||
/** Create backup of settings.json with proper permissions and rotation */
|
||||
export async function createBackup(): Promise<string> {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
if (!(await pathExists(settingsPath))) {
|
||||
throw new Error('No settings.json to backup');
|
||||
}
|
||||
|
||||
const settingsContent = await readFileUtf8NoFollow(settingsPath);
|
||||
|
||||
const now = new Date();
|
||||
const timestamp =
|
||||
now.getFullYear().toString() +
|
||||
(now.getMonth() + 1).toString().padStart(2, '0') +
|
||||
now.getDate().toString().padStart(2, '0') +
|
||||
'_' +
|
||||
now.getHours().toString().padStart(2, '0') +
|
||||
now.getMinutes().toString().padStart(2, '0') +
|
||||
now.getSeconds().toString().padStart(2, '0');
|
||||
const backupPath = `${settingsPath}.backup.${timestamp}`;
|
||||
|
||||
const flags =
|
||||
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | getNoFollowFlag();
|
||||
|
||||
let handle: fs.promises.FileHandle | undefined;
|
||||
try {
|
||||
handle = await fs.promises.open(backupPath, flags, 0o600);
|
||||
await handle.writeFile(settingsContent, { encoding: 'utf8' });
|
||||
await handle.sync();
|
||||
} finally {
|
||||
if (handle) {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.chmod(backupPath, 0o600);
|
||||
} catch {
|
||||
// Best-effort permission hardening.
|
||||
}
|
||||
|
||||
// Cleanup: Rotate old backups (keep only MAX_BACKUPS)
|
||||
cleanupOldBackups();
|
||||
return backupPath;
|
||||
}
|
||||
|
||||
/** Remove old backups keeping only MAX_BACKUPS most recent */
|
||||
function cleanupOldBackups(): void {
|
||||
const backups = getBackupFiles();
|
||||
if (backups.length > MAX_BACKUPS) {
|
||||
const toDelete = backups.slice(MAX_BACKUPS);
|
||||
for (const backup of toDelete) {
|
||||
try {
|
||||
fs.unlinkSync(backup.path);
|
||||
} catch (error) {
|
||||
console.log(
|
||||
warn(
|
||||
`Failed to delete old backup ${formatDisplayPath(backup.path)}: ${(error as Error).message}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle --list-backups flag */
|
||||
export async function handleListBackups(): Promise<void> {
|
||||
await initUI();
|
||||
const backups = getBackupFiles();
|
||||
if (backups.length === 0) {
|
||||
console.log(info('No backups found'));
|
||||
return;
|
||||
}
|
||||
console.log(header('Available Backups'));
|
||||
console.log('');
|
||||
backups.forEach((b, i) => {
|
||||
const dateStr = b.date.toLocaleString();
|
||||
const marker = i === 0 ? color(' (latest)', 'success') : '';
|
||||
console.log(` ${color(b.timestamp, 'command')} ${dim(dateStr)}${marker}`);
|
||||
});
|
||||
console.log('');
|
||||
console.log(dim('To restore: ccs persist --restore [timestamp]'));
|
||||
}
|
||||
|
||||
/** Handle --restore [timestamp] flag */
|
||||
export async function handleRestore(timestamp: string | boolean, yes: boolean): Promise<void> {
|
||||
await initUI();
|
||||
const backups = getBackupFiles();
|
||||
if (backups.length === 0) {
|
||||
console.log(fail('No backups found'));
|
||||
process.exit(1);
|
||||
}
|
||||
// Find backup to restore
|
||||
let backup: BackupFile;
|
||||
if (timestamp === true) {
|
||||
// Use latest
|
||||
backup = backups[0];
|
||||
} else {
|
||||
const found = backups.find((b) => b.timestamp === timestamp);
|
||||
if (!found) {
|
||||
console.log(fail(`Backup not found: ${timestamp}`));
|
||||
console.log('');
|
||||
console.log('Available backups:');
|
||||
backups.slice(0, 5).forEach((b) => console.log(` ${b.timestamp}`));
|
||||
process.exit(1);
|
||||
}
|
||||
backup = found;
|
||||
}
|
||||
console.log(header('Restore Backup'));
|
||||
console.log('');
|
||||
console.log(`Backup: ${color(backup.timestamp, 'command')}`);
|
||||
console.log(`Date: ${backup.date.toLocaleString()}`);
|
||||
console.log('');
|
||||
console.log(warn(`This will replace ${getClaudeSettingsDisplayPath()}`));
|
||||
console.log('');
|
||||
if (!yes) {
|
||||
const proceed = await InteractivePrompt.confirm('Proceed with restore?', { default: false });
|
||||
if (!proceed) {
|
||||
console.log(info('Cancelled'));
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
let parsedBackupSettings: Record<string, unknown>;
|
||||
try {
|
||||
const backupContent = await readFileUtf8NoFollow(backup.path);
|
||||
parsedBackupSettings = parseSettingsObject(backupContent, 'Backup file');
|
||||
} catch (error) {
|
||||
const nodeError = error as NodeJS.ErrnoException;
|
||||
if (nodeError.code === 'ENOENT') {
|
||||
console.log(fail('Backup was deleted during restore'));
|
||||
process.exit(1);
|
||||
}
|
||||
if (nodeError.code === 'ELOOP') {
|
||||
console.log(fail('Backup file is a symlink - refusing to restore for security'));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(fail(`Backup file is corrupted: ${(error as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
await withPersistSettingsLock(async () => {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
if (await isSymlinkAsync(settingsPath)) {
|
||||
throw new Error('settings.json is a symlink - refusing to restore for security');
|
||||
}
|
||||
|
||||
let rollbackBackupPath: string | null = null;
|
||||
if (await pathExists(settingsPath)) {
|
||||
rollbackBackupPath = await createBackup();
|
||||
}
|
||||
|
||||
try {
|
||||
await writeClaudeSettings(parsedBackupSettings);
|
||||
} catch (error) {
|
||||
const writeError = error as Error;
|
||||
if (rollbackBackupPath) {
|
||||
try {
|
||||
const rollbackContent = await readFileUtf8NoFollow(rollbackBackupPath);
|
||||
const rollbackSettings = parseSettingsObject(rollbackContent, 'Rollback backup');
|
||||
await writeClaudeSettings(rollbackSettings);
|
||||
} catch (rollbackError) {
|
||||
throw new Error(
|
||||
`Restore failed: ${writeError.message}. Rollback also failed: ${(rollbackError as Error).message}. Manual recovery backup: ${formatDisplayPath(rollbackBackupPath)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error(`Restore failed: ${writeError.message}`);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(fail((error as Error).message));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(ok(`Restored from backup: ${backup.timestamp}`));
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Persist Command - Main Handler
|
||||
*
|
||||
* Orchestrates the `ccs persist` command: dispatches to help/list/restore
|
||||
* subcommands, otherwise resolves a profile, previews writes, takes a backup
|
||||
* (optional), and atomically writes settings.json under a settings-dir lock.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import { initUI, header, color, dim, ok, fail, warn, info } from '../../utils/ui';
|
||||
import { InteractivePrompt } from '../../utils/prompt';
|
||||
import ProfileDetector from '../../auth/profile-detector';
|
||||
import { getClaudeSettingsPath } from '../../utils/claude-config-path';
|
||||
import { parseArgs, resolvePermissionMode } from './arg-parsing';
|
||||
import { showHelp } from './help';
|
||||
import { handleListBackups, handleRestore, createBackup } from './backup-rotation';
|
||||
import {
|
||||
formatDisplayPath,
|
||||
getClaudeSettingsDisplayPath,
|
||||
pathExists,
|
||||
readClaudeSettings,
|
||||
withPersistSettingsLock,
|
||||
writeClaudeSettings,
|
||||
} from './secure-file';
|
||||
import { isSensitiveEnvKey, maskApiKey } from './secret-detection';
|
||||
import { buildPersistReceipt, printPersistReceipt, resolveProfileEnvVars } from './receipt';
|
||||
import type { ResolvedEnv } from './types';
|
||||
|
||||
/** Main persist command handler */
|
||||
export async function handlePersistCommand(args: string[]): Promise<void> {
|
||||
// Check for help first
|
||||
if (args.includes('--help') || args.includes('-h') || args.length === 0) {
|
||||
await showHelp();
|
||||
return;
|
||||
}
|
||||
const parsedArgs = parseArgs(args);
|
||||
if (parsedArgs.parseError) {
|
||||
throw new Error(parsedArgs.parseError);
|
||||
}
|
||||
// Handle --list-backups
|
||||
if (parsedArgs.listBackups) {
|
||||
await handleListBackups();
|
||||
return;
|
||||
}
|
||||
// Handle --restore
|
||||
if (parsedArgs.restore) {
|
||||
await handleRestore(parsedArgs.restore, parsedArgs.yes ?? false);
|
||||
return;
|
||||
}
|
||||
await initUI();
|
||||
const resolvedPermissionMode = resolvePermissionMode(parsedArgs);
|
||||
if (!parsedArgs.profile) {
|
||||
console.log(fail('Profile name is required'));
|
||||
console.log('');
|
||||
console.log('Usage:');
|
||||
console.log(` ${color('ccs persist <profile>', 'command')}`);
|
||||
console.log('');
|
||||
console.log('Run for help:');
|
||||
console.log(` ${color('ccs persist --help', 'command')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
// Detect profile
|
||||
const detector = new ProfileDetector();
|
||||
try {
|
||||
detector.detectProfileType(parsedArgs.profile);
|
||||
} catch (error) {
|
||||
const err = error as Error & { availableProfiles?: string };
|
||||
console.log(fail(`Profile not found: ${parsedArgs.profile}`));
|
||||
console.log('');
|
||||
if (err.availableProfiles) {
|
||||
console.log(err.availableProfiles);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
// Resolve env vars
|
||||
let resolved: ResolvedEnv;
|
||||
try {
|
||||
resolved = await resolveProfileEnvVars(parsedArgs.profile);
|
||||
} catch (error) {
|
||||
console.log(fail((error as Error).message));
|
||||
process.exit(1);
|
||||
}
|
||||
// Display what will be written
|
||||
console.log(header(`Persist Profile: ${parsedArgs.profile}`));
|
||||
console.log('');
|
||||
console.log(`Profile type: ${color(resolved.profileType, 'command')}`);
|
||||
console.log('');
|
||||
const envKeys = Object.keys(resolved.env);
|
||||
if (envKeys.length > 0) {
|
||||
console.log(`The following env vars will be written to ${getClaudeSettingsDisplayPath()}:`);
|
||||
console.log('');
|
||||
const maxKeyLen = Math.max(...envKeys.map((k) => k.length));
|
||||
for (const [key, value] of Object.entries(resolved.env)) {
|
||||
const paddedKey = key.padEnd(maxKeyLen + 2);
|
||||
const displayValue = isSensitiveEnvKey(key) ? maskApiKey(value) : value;
|
||||
console.log(` ${color(paddedKey, 'command')} = ${displayValue}`);
|
||||
}
|
||||
console.log('');
|
||||
} else {
|
||||
console.log(info('No new env vars will be added.'));
|
||||
console.log(dim(' CCS-managed transport overrides will be removed if present.'));
|
||||
console.log('');
|
||||
}
|
||||
if (resolved.clearEnvKeys.length > 0) {
|
||||
console.log('Managed env keys replaced/cleared on write:');
|
||||
console.log(` ${dim(resolved.clearEnvKeys.join(', '))}`);
|
||||
console.log('');
|
||||
}
|
||||
if (resolvedPermissionMode) {
|
||||
console.log(`Default permission mode: ${color(resolvedPermissionMode, 'command')}`);
|
||||
if (resolvedPermissionMode === 'bypassPermissions') {
|
||||
console.log(warn('Auto-approve enabled: Claude will skip permission prompts by default.'));
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
if (resolved.warnings?.length) {
|
||||
for (const message of resolved.warnings) {
|
||||
console.log(warn(message));
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
if (resolved.notes?.length) {
|
||||
for (const note of resolved.notes) {
|
||||
console.log(info(note));
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
// Warning about modification
|
||||
console.log(warn(`This will modify ${getClaudeSettingsDisplayPath()}`));
|
||||
console.log(dim(' Existing hooks and other settings will be preserved.'));
|
||||
console.log(
|
||||
dim(' Existing managed profile env keys will be replaced to avoid stale routing.')
|
||||
);
|
||||
console.log('');
|
||||
// Check if settings.json exists for backup
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const settingsExist = fs.existsSync(settingsPath);
|
||||
let createBackupFlag = false;
|
||||
// Track backup path for error recovery guidance
|
||||
let createdBackupPath: string | null = null;
|
||||
// Backup prompt (unless --yes)
|
||||
if (settingsExist) {
|
||||
createBackupFlag = parsedArgs.yes === true; // Auto-backup with --yes
|
||||
if (!parsedArgs.yes) {
|
||||
createBackupFlag = await InteractivePrompt.confirm('Create backup before modifying?', {
|
||||
default: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Proceed confirmation (unless --yes)
|
||||
if (!parsedArgs.yes) {
|
||||
const proceed = await InteractivePrompt.confirm('Proceed with persist?', { default: true });
|
||||
if (!proceed) {
|
||||
console.log(info('Cancelled'));
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await withPersistSettingsLock(async () => {
|
||||
if (createBackupFlag && (await pathExists(settingsPath))) {
|
||||
try {
|
||||
createdBackupPath = await createBackup();
|
||||
console.log(ok(`Backup created: ${formatDisplayPath(createdBackupPath)}`));
|
||||
console.log('');
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create backup: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Read existing settings and merge
|
||||
const existingSettings = await readClaudeSettings();
|
||||
// Validate existing env is an object (not array/primitive)
|
||||
const rawEnv = existingSettings.env;
|
||||
let existingEnv: Record<string, string> = {};
|
||||
if (rawEnv !== undefined) {
|
||||
if (rawEnv === null) {
|
||||
console.log(warn('Existing env in settings.json is null - it will be replaced'));
|
||||
} else if (typeof rawEnv !== 'object' || Array.isArray(rawEnv)) {
|
||||
console.log(warn('Existing env in settings.json is not an object - it will be replaced'));
|
||||
} else {
|
||||
existingEnv = rawEnv as Record<string, string>;
|
||||
}
|
||||
}
|
||||
|
||||
const preservedEnv = { ...existingEnv };
|
||||
for (const key of resolved.clearEnvKeys) {
|
||||
delete preservedEnv[key];
|
||||
}
|
||||
|
||||
const mergedSettings: Record<string, unknown> = {
|
||||
...existingSettings,
|
||||
env: {
|
||||
...preservedEnv,
|
||||
...resolved.env,
|
||||
},
|
||||
};
|
||||
|
||||
if (resolvedPermissionMode) {
|
||||
const rawPermissions = existingSettings.permissions;
|
||||
let existingPermissions: Record<string, unknown> = {};
|
||||
if (rawPermissions !== undefined) {
|
||||
if (rawPermissions === null) {
|
||||
console.log(
|
||||
warn('Existing permissions in settings.json is null - it will be replaced')
|
||||
);
|
||||
} else if (typeof rawPermissions !== 'object' || Array.isArray(rawPermissions)) {
|
||||
console.log(
|
||||
warn('Existing permissions in settings.json is not an object - it will be replaced')
|
||||
);
|
||||
} else {
|
||||
existingPermissions = rawPermissions as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
mergedSettings.permissions = {
|
||||
...existingPermissions,
|
||||
defaultMode: resolvedPermissionMode,
|
||||
};
|
||||
}
|
||||
|
||||
await writeClaudeSettings(mergedSettings);
|
||||
const persistedSettings = await readClaudeSettings();
|
||||
const receipt = buildPersistReceipt(
|
||||
existingEnv,
|
||||
existingSettings,
|
||||
persistedSettings,
|
||||
resolved,
|
||||
resolvedPermissionMode
|
||||
);
|
||||
|
||||
console.log('');
|
||||
console.log(
|
||||
ok(`Profile '${parsedArgs.profile}' written to ${getClaudeSettingsDisplayPath()}`)
|
||||
);
|
||||
console.log('');
|
||||
printPersistReceipt(receipt);
|
||||
console.log('');
|
||||
});
|
||||
} catch (error) {
|
||||
const message = (error as Error).message;
|
||||
if (message.startsWith('Failed to create backup:')) {
|
||||
console.log(fail(message));
|
||||
} else {
|
||||
console.log(fail(`Failed to write settings: ${message}`));
|
||||
}
|
||||
if (createdBackupPath) {
|
||||
console.log('');
|
||||
console.log(info(`A backup was created before this error:`));
|
||||
console.log(` ${formatDisplayPath(createdBackupPath)}`);
|
||||
console.log(dim(' To restore: ccs persist --restore'));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(info('Claude Code will now use this profile by default.'));
|
||||
console.log(dim(' To revert, restore the backup or edit settings.json manually.'));
|
||||
console.log('');
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Persist Command - Help Text
|
||||
*
|
||||
* Owns the `ccs persist --help` output. Pure presentation module; no
|
||||
* filesystem or profile-resolution side effects.
|
||||
*/
|
||||
|
||||
import { header, subheader, color, dim, initUI } from '../../utils/ui';
|
||||
import { getClaudeSettingsDisplayPath } from './secure-file';
|
||||
|
||||
/** Show help for persist command */
|
||||
export async function showHelp(): Promise<void> {
|
||||
await initUI();
|
||||
console.log(header('CCS Persist Command'));
|
||||
console.log('');
|
||||
console.log(subheader('Usage'));
|
||||
console.log(` ${color('ccs persist', 'command')} <profile> [options]`);
|
||||
console.log(` ${color('ccs persist', 'command')} --list-backups`);
|
||||
console.log(` ${color('ccs persist', 'command')} --restore [timestamp]`);
|
||||
console.log('');
|
||||
console.log(subheader('Description'));
|
||||
console.log(" Writes a profile's Claude setup directly to");
|
||||
console.log(` ${getClaudeSettingsDisplayPath()} for native Claude Code usage.`);
|
||||
console.log('');
|
||||
console.log(' This is the preferred shared-settings path for Claude Code');
|
||||
console.log(' and the Claude IDE extension when you want one profile everywhere.');
|
||||
console.log('');
|
||||
console.log(subheader('Options'));
|
||||
console.log(` ${color('--yes, -y', 'command')} Skip confirmation prompts (auto-backup)`);
|
||||
console.log(
|
||||
` ${color('--permission-mode <mode>', 'command')} Set default permission mode in settings.json`
|
||||
);
|
||||
console.log(
|
||||
` ${color('--dangerously-skip-permissions', 'command')} Persist auto-approve (bypassPermissions)`
|
||||
);
|
||||
console.log(` ${color('--auto-approve', 'command')} Alias for --dangerously-skip-permissions`);
|
||||
console.log(` ${color('--help, -h', 'command')} Show this help message`);
|
||||
console.log('');
|
||||
console.log(subheader('Backup Management'));
|
||||
console.log(` ${color('--list-backups', 'command')} List available backup files`);
|
||||
console.log(` ${color('--restore', 'command')} Restore from the most recent backup`);
|
||||
console.log(
|
||||
` ${color('--restore <ts>', 'command')} Restore from specific backup (e.g., 20260110_205324)`
|
||||
);
|
||||
console.log('');
|
||||
console.log(subheader('Supported Profile Types'));
|
||||
console.log(` ${color('API profiles', 'command')} glm, km, custom API profiles`);
|
||||
console.log(` ${color('CLIProxy', 'command')} gemini, agy, qwen, kiro, ghcp`);
|
||||
console.log(` ${color('Copilot', 'command')} copilot (requires copilot-api daemon)`);
|
||||
console.log(
|
||||
` ${color('Account profiles', 'command')} work, personal, client (persists CLAUDE_CONFIG_DIR)`
|
||||
);
|
||||
console.log(
|
||||
` ${color('default', 'command')} Clears CCS-managed overrides or inherits mapped continuity`
|
||||
);
|
||||
console.log('');
|
||||
console.log(subheader('Examples'));
|
||||
console.log(` ${dim('# Persist GLM profile')}`);
|
||||
console.log(` ${color('ccs persist glm', 'command')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Persist with auto-confirmation')}`);
|
||||
console.log(` ${color('ccs persist gemini --yes', 'command')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Persist with default permission mode')}`);
|
||||
console.log(` ${color('ccs persist glm --permission-mode acceptEdits', 'command')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Persist with auto-approve enabled')}`);
|
||||
console.log(` ${color('ccs persist glm --dangerously-skip-permissions', 'command')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Persist an account profile for IDE/native Claude use')}`);
|
||||
console.log(` ${color('ccs persist work --yes', 'command')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Reset to native Claude defaults (clear CCS-managed overrides)')}`);
|
||||
console.log(` ${color('ccs persist default --yes', 'command')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# List all backups')}`);
|
||||
console.log(` ${color('ccs persist --list-backups', 'command')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Restore latest backup')}`);
|
||||
console.log(` ${color('ccs persist --restore', 'command')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Restore specific backup')}`);
|
||||
console.log(` ${color('ccs persist --restore 20260110_205324', 'command')}`);
|
||||
console.log('');
|
||||
console.log(subheader('Notes'));
|
||||
console.log(' [i] CLIProxy profiles require the proxy to be running.');
|
||||
console.log(
|
||||
' [i] Codex CLIProxy profiles are native Codex-only: use ccsxp or ccs codex --target codex.'
|
||||
);
|
||||
console.log(' [i] Copilot profiles require copilot-api daemon.');
|
||||
console.log(
|
||||
' [i] Account/default flows remove stale ANTHROPIC_* overrides before applying new setup.'
|
||||
);
|
||||
console.log(
|
||||
' [i] For IDE-local settings.json snippets, use: ccs env <profile> --format claude-extension'
|
||||
);
|
||||
console.log(
|
||||
` [i] Backups are saved as ${getClaudeSettingsDisplayPath()}.backup.YYYYMMDD_HHMMSS`
|
||||
);
|
||||
console.log('');
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Persist Command - Receipt Building & Profile Resolution
|
||||
*
|
||||
* Computes the post-write receipt (cleared/written/unchanged env keys and
|
||||
* settings) and resolves a profile's extension env vars via the shared
|
||||
* Claude extension setup resolver.
|
||||
*/
|
||||
|
||||
import { resolveClaudeExtensionSetup } from '../../shared/claude-extension-setup';
|
||||
import {
|
||||
CODEX_TRANSLATOR_URL_MARKER,
|
||||
findCodexTranslatorUrlPaths,
|
||||
formatSettingsPathList,
|
||||
} from '../../shared/stale-codex-translator-settings';
|
||||
import { subheader, ok, warn } from '../../utils/ui';
|
||||
import { getClaudeSettingsDisplayPath } from './secure-file';
|
||||
import {
|
||||
NATIVE_CODEX_TARGETS,
|
||||
type PersistReceipt,
|
||||
type PermissionMode,
|
||||
type ResolvedEnv,
|
||||
} from './types';
|
||||
|
||||
export function buildPersistReceipt(
|
||||
existingEnv: Record<string, string>,
|
||||
existingSettings: Record<string, unknown>,
|
||||
mergedSettings: Record<string, unknown>,
|
||||
resolved: ResolvedEnv,
|
||||
resolvedPermissionMode?: PermissionMode
|
||||
): PersistReceipt {
|
||||
const mergedEnv =
|
||||
typeof mergedSettings.env === 'object' &&
|
||||
mergedSettings.env !== null &&
|
||||
!Array.isArray(mergedSettings.env)
|
||||
? (mergedSettings.env as Record<string, string>)
|
||||
: {};
|
||||
|
||||
const clearedKeys = resolved.clearEnvKeys.filter(
|
||||
(key) => Object.prototype.hasOwnProperty.call(existingEnv, key) && mergedEnv[key] === undefined
|
||||
);
|
||||
const clearedCodexTranslatorUrlKeys = clearedKeys.filter(
|
||||
(key) => findCodexTranslatorUrlPaths(existingEnv[key]).length > 0
|
||||
);
|
||||
const writtenKeys = Object.entries(resolved.env)
|
||||
.filter(([key, value]) => existingEnv[key] !== value)
|
||||
.map(([key]) => key)
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
const unchangedWrittenKeys = Object.entries(resolved.env)
|
||||
.filter(([key, value]) => existingEnv[key] === value)
|
||||
.map(([key]) => key)
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
const existingPermissions =
|
||||
typeof existingSettings.permissions === 'object' &&
|
||||
existingSettings.permissions !== null &&
|
||||
!Array.isArray(existingSettings.permissions)
|
||||
? (existingSettings.permissions as Record<string, unknown>)
|
||||
: {};
|
||||
const writtenSettings =
|
||||
resolvedPermissionMode && existingPermissions.defaultMode !== resolvedPermissionMode
|
||||
? ['permissions.defaultMode']
|
||||
: [];
|
||||
const unchangedSettings =
|
||||
resolvedPermissionMode && existingPermissions.defaultMode === resolvedPermissionMode
|
||||
? ['permissions.defaultMode']
|
||||
: [];
|
||||
|
||||
return {
|
||||
clearedKeys,
|
||||
clearedCodexTranslatorUrlKeys,
|
||||
writtenKeys,
|
||||
unchangedWrittenKeys,
|
||||
writtenSettings,
|
||||
unchangedSettings,
|
||||
codexTranslatorUrlPaths: findCodexTranslatorUrlPaths(mergedSettings),
|
||||
};
|
||||
}
|
||||
|
||||
function formatKeyList(keys: string[]): string {
|
||||
return keys.length > 0 ? keys.join(', ') : 'none';
|
||||
}
|
||||
|
||||
export function printPersistReceipt(receipt: PersistReceipt): void {
|
||||
console.log(subheader('Config Receipt'));
|
||||
console.log(` Settings: ${getClaudeSettingsDisplayPath()}`);
|
||||
console.log(` Cleared managed keys: ${formatKeyList(receipt.clearedKeys)}`);
|
||||
console.log(` Written/rewritten managed keys: ${formatKeyList(receipt.writtenKeys)}`);
|
||||
if (receipt.unchangedWrittenKeys.length > 0) {
|
||||
console.log(` Already current keys: ${formatKeyList(receipt.unchangedWrittenKeys)}`);
|
||||
}
|
||||
if (receipt.writtenSettings.length > 0 || receipt.unchangedSettings.length > 0) {
|
||||
console.log(` Written/rewritten managed settings: ${formatKeyList(receipt.writtenSettings)}`);
|
||||
if (receipt.unchangedSettings.length > 0) {
|
||||
console.log(` Already current settings: ${formatKeyList(receipt.unchangedSettings)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const hadCodexTranslatorCleanup = receipt.clearedCodexTranslatorUrlKeys.length > 0;
|
||||
if (receipt.codexTranslatorUrlPaths.length > 0) {
|
||||
console.log(
|
||||
warn(
|
||||
` Codex translator URL: still found at ${formatSettingsPathList(
|
||||
receipt.codexTranslatorUrlPaths
|
||||
)} (${CODEX_TRANSLATOR_URL_MARKER})`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
console.log(ok(' Codex translator URL: not found'));
|
||||
}
|
||||
if (hadCodexTranslatorCleanup || receipt.codexTranslatorUrlPaths.length > 0) {
|
||||
console.log(` Native Codex target: ${NATIVE_CODEX_TARGETS.join(' or ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve shared Claude settings payload for a profile */
|
||||
export async function resolveProfileEnvVars(profileName: string): Promise<ResolvedEnv> {
|
||||
const setup = await resolveClaudeExtensionSetup(profileName);
|
||||
const typeLabel: Record<string, string> = {
|
||||
settings: 'API',
|
||||
cliproxy: 'CLIProxy',
|
||||
copilot: 'Copilot',
|
||||
account: 'Account',
|
||||
default: 'Default',
|
||||
};
|
||||
|
||||
return {
|
||||
env: setup.extensionEnv,
|
||||
clearEnvKeys: setup.removeEnvKeys,
|
||||
profileType: typeLabel[setup.profileType] ?? setup.profileType,
|
||||
warnings: setup.warnings,
|
||||
notes: setup.notes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Persist Command - Secret Detection & Masking
|
||||
*
|
||||
* Identifies sensitive env var names (TOKEN/KEY/SECRET/etc.) so the persist
|
||||
* preview can mask their values before printing to the terminal.
|
||||
*/
|
||||
|
||||
/** Mask API key for display (show first 4 and last 4 chars) */
|
||||
export function maskApiKey(key: string): string {
|
||||
if (key.length <= 12) {
|
||||
return '****';
|
||||
}
|
||||
return `${key.slice(0, 4)}...${key.slice(-4)}`;
|
||||
}
|
||||
|
||||
const SENSITIVE_ENV_PARTS = new Set([
|
||||
'TOKEN',
|
||||
'KEY',
|
||||
'SECRET',
|
||||
'PASSWORD',
|
||||
'PASS',
|
||||
'AUTH',
|
||||
'CREDENTIAL',
|
||||
'PRIVATE',
|
||||
'ACCESS',
|
||||
'REFRESH',
|
||||
'APIKEY',
|
||||
]);
|
||||
|
||||
export function splitSensitiveKeyParts(key: string): string[] {
|
||||
const withCamelCaseBoundaries = key.replace(/([a-z0-9])([A-Z])/g, '$1_$2');
|
||||
return withCamelCaseBoundaries
|
||||
.toUpperCase()
|
||||
.split(/[^A-Z0-9]+/)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function isSensitiveEnvKey(key: string): boolean {
|
||||
const parts = splitSensitiveKeyParts(key);
|
||||
if (parts.some((part) => SENSITIVE_ENV_PARTS.has(part))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const compact = parts.join('');
|
||||
return (
|
||||
compact.includes('TOKEN') ||
|
||||
compact.includes('APIKEY') ||
|
||||
compact.includes('ACCESSKEY') ||
|
||||
compact.includes('AUTHKEY') ||
|
||||
compact.includes('SECRET') ||
|
||||
compact.includes('PASSWORD') ||
|
||||
compact.includes('CREDENTIAL')
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Persist Command - Secure File I/O & Locking
|
||||
*
|
||||
* Hardened filesystem helpers for reading/writing ~/.claude/settings.json.
|
||||
* Refuses to follow symlinks (TOCTOU mitigations), uses O_NOFOLLOW where
|
||||
* available, writes via atomic temp-file + rename, and serializes concurrent
|
||||
* persist operations via a proper-lockfile on the settings directory.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import * as lockfile from 'proper-lockfile';
|
||||
import { getClaudeConfigDir, getClaudeSettingsPath } from '../../utils/claude-config-path';
|
||||
import {
|
||||
PERSIST_LOCK_RETRIES,
|
||||
PERSIST_LOCK_RETRY_MAX_MS,
|
||||
PERSIST_LOCK_RETRY_MIN_MS,
|
||||
PERSIST_LOCK_STALE_MS,
|
||||
} from './types';
|
||||
|
||||
export function formatDisplayPath(filePath: string): string {
|
||||
const defaultClaudeDir = path.join(os.homedir(), '.claude');
|
||||
const claudeDir = getClaudeConfigDir();
|
||||
|
||||
// Keep real path when user overrides Claude directory.
|
||||
if (path.resolve(claudeDir) !== path.resolve(defaultClaudeDir)) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
if (filePath === claudeDir) {
|
||||
return '~/.claude';
|
||||
}
|
||||
|
||||
const claudePrefix = `${claudeDir}${path.sep}`;
|
||||
if (filePath.startsWith(claudePrefix)) {
|
||||
return filePath.replace(claudePrefix, '~/.claude/');
|
||||
}
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function getClaudeSettingsDisplayPath(): string {
|
||||
return formatDisplayPath(getClaudeSettingsPath());
|
||||
}
|
||||
|
||||
export async function pathExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.promises.access(filePath, fs.constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isSymlinkAsync(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const stats = await fs.promises.lstat(filePath);
|
||||
return stats.isSymbolicLink();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getNoFollowFlag(): number {
|
||||
const candidate = (fs.constants as Record<string, number>)['O_NOFOLLOW'];
|
||||
if (process.platform !== 'win32' && typeof candidate === 'number') {
|
||||
return candidate;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function createSymlinkReadError(filePath: string): NodeJS.ErrnoException {
|
||||
const error = new Error(
|
||||
`Refusing to read symlinked file for security: ${formatDisplayPath(filePath)}`
|
||||
) as NodeJS.ErrnoException;
|
||||
error.code = 'ELOOP';
|
||||
return error;
|
||||
}
|
||||
|
||||
export async function readFileUtf8NoFollow(filePath: string): Promise<string> {
|
||||
if (await isSymlinkAsync(filePath)) {
|
||||
throw createSymlinkReadError(filePath);
|
||||
}
|
||||
|
||||
const noFollowFlag = getNoFollowFlag();
|
||||
const flags = fs.constants.O_RDONLY | noFollowFlag;
|
||||
const handle = await fs.promises.open(filePath, flags);
|
||||
try {
|
||||
// Best-effort fallback for platforms without O_NOFOLLOW (notably Windows).
|
||||
// Re-check symlink status after open to reduce check-then-use windows.
|
||||
if (noFollowFlag === 0 && (await isSymlinkAsync(filePath))) {
|
||||
throw createSymlinkReadError(filePath);
|
||||
}
|
||||
|
||||
const stats = await handle.stat();
|
||||
if (!stats.isFile()) {
|
||||
throw new Error('Path is not a regular file');
|
||||
}
|
||||
|
||||
if (noFollowFlag === 0) {
|
||||
const latestStats = await fs.promises.stat(filePath);
|
||||
if (latestStats.dev !== stats.dev || latestStats.ino !== stats.ino) {
|
||||
throw new Error('Path changed during secure read');
|
||||
}
|
||||
}
|
||||
|
||||
return await handle.readFile({ encoding: 'utf8' });
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSettingsObject(content: string, sourceLabel: string): Record<string, unknown> {
|
||||
if (!content.trim()) {
|
||||
return {};
|
||||
}
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error(`${sourceLabel} must contain a JSON object, not an array or primitive`);
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function withPersistSettingsLock<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const settingsDir = path.dirname(settingsPath);
|
||||
await fs.promises.mkdir(settingsDir, { recursive: true });
|
||||
|
||||
let release: (() => Promise<void>) | undefined;
|
||||
try {
|
||||
release = await lockfile.lock(settingsDir, {
|
||||
stale: PERSIST_LOCK_STALE_MS,
|
||||
retries: {
|
||||
retries: PERSIST_LOCK_RETRIES,
|
||||
minTimeout: PERSIST_LOCK_RETRY_MIN_MS,
|
||||
maxTimeout: PERSIST_LOCK_RETRY_MAX_MS,
|
||||
},
|
||||
realpath: false,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to lock Claude settings directory (${formatDisplayPath(settingsDir)}): ${(error as Error).message}`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
if (release) {
|
||||
try {
|
||||
await release();
|
||||
} catch {
|
||||
// Best-effort release.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Read existing Claude settings.json with validation */
|
||||
export async function readClaudeSettings(): Promise<Record<string, unknown>> {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
try {
|
||||
const content = await readFileUtf8NoFollow(settingsPath);
|
||||
return parseSettingsObject(content, 'settings.json');
|
||||
} catch (error) {
|
||||
const nodeError = error as NodeJS.ErrnoException;
|
||||
if (nodeError.code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
if (nodeError.code === 'ELOOP') {
|
||||
throw new Error('settings.json is a symlink - refusing to read for security');
|
||||
}
|
||||
throw new Error(`Failed to parse settings.json: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Write settings back to settings.json with atomic replace semantics. */
|
||||
export async function writeClaudeSettings(settings: Record<string, unknown>): Promise<void> {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
if (await isSymlinkAsync(settingsPath)) {
|
||||
throw new Error('settings.json is a symlink - refusing to write for security');
|
||||
}
|
||||
|
||||
const settingsDir = path.dirname(settingsPath);
|
||||
await fs.promises.mkdir(settingsDir, { recursive: true });
|
||||
|
||||
const nonce = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const tmpPath = path.join(settingsDir, `settings.json.tmp-${nonce}`);
|
||||
const flags =
|
||||
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | getNoFollowFlag();
|
||||
|
||||
let handle: fs.promises.FileHandle | undefined;
|
||||
try {
|
||||
handle = await fs.promises.open(tmpPath, flags, 0o600);
|
||||
await handle.writeFile(JSON.stringify(settings, null, 2) + '\n', { encoding: 'utf8' });
|
||||
await handle.sync();
|
||||
} finally {
|
||||
if (handle) {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.rename(tmpPath, settingsPath);
|
||||
} catch (error) {
|
||||
try {
|
||||
await fs.promises.unlink(tmpPath);
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.chmod(settingsPath, 0o600);
|
||||
} catch {
|
||||
// Best-effort permission hardening.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Persist Command - Shared Types & Constants
|
||||
*
|
||||
* Shared interfaces, type aliases, and module constants used across the
|
||||
* persist-command submodules. Keeping these in one place avoids circular
|
||||
* imports between arg-parsing, receipt, secure-file, and handler modules.
|
||||
*/
|
||||
|
||||
export interface PersistCommandArgs {
|
||||
profile?: string;
|
||||
yes?: boolean;
|
||||
listBackups?: boolean;
|
||||
restore?: string | boolean;
|
||||
permissionMode?: PermissionMode;
|
||||
dangerouslySkipPermissions?: boolean;
|
||||
parseError?: string;
|
||||
}
|
||||
|
||||
export interface ResolvedEnv {
|
||||
env: Record<string, string>;
|
||||
clearEnvKeys: string[];
|
||||
profileType: string;
|
||||
warnings?: string[];
|
||||
notes?: string[];
|
||||
}
|
||||
|
||||
export interface PersistReceipt {
|
||||
clearedKeys: string[];
|
||||
clearedCodexTranslatorUrlKeys: string[];
|
||||
writtenKeys: string[];
|
||||
unchangedWrittenKeys: string[];
|
||||
writtenSettings: string[];
|
||||
unchangedSettings: string[];
|
||||
codexTranslatorUrlPaths: string[];
|
||||
}
|
||||
|
||||
export const PERSIST_KNOWN_FLAGS = [
|
||||
'--yes',
|
||||
'-y',
|
||||
'--list-backups',
|
||||
'--restore',
|
||||
'--permission-mode',
|
||||
'--dangerously-skip-permissions',
|
||||
'--auto-approve',
|
||||
'--help',
|
||||
'-h',
|
||||
] as const;
|
||||
|
||||
export const VALID_PERMISSION_MODES = [
|
||||
'default',
|
||||
'plan',
|
||||
'acceptEdits',
|
||||
'bypassPermissions',
|
||||
] as const;
|
||||
|
||||
export const PERSIST_LOCK_STALE_MS = 10000;
|
||||
export const PERSIST_LOCK_RETRIES = 5;
|
||||
export const PERSIST_LOCK_RETRY_MIN_MS = 100;
|
||||
export const PERSIST_LOCK_RETRY_MAX_MS = 500;
|
||||
|
||||
/** Native Codex target invocation hints surfaced in the persist receipt. */
|
||||
export const NATIVE_CODEX_TARGETS = ['ccsxp', 'ccs codex --target codex'];
|
||||
|
||||
export type PermissionMode = (typeof VALID_PERMISSION_MODES)[number];
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
resolveImageAnalysisRuntimeStatus,
|
||||
} from '../utils/hooks';
|
||||
import { stripClaudeCodeEnv } from '../utils/shell-executor';
|
||||
import { createLogger } from '../services/logging';
|
||||
import { createLogger, forwardRequestIdEnv } from '../services/logging';
|
||||
import { getGlobalEnvConfig } from '../config/config-loader-facade';
|
||||
|
||||
const logger = createLogger('copilot:executor');
|
||||
@@ -207,20 +207,20 @@ export async function executeCopilotProfile(
|
||||
try {
|
||||
await ensureCopilotApi();
|
||||
} catch (error) {
|
||||
console.error(fail('Failed to install copilot-api.'));
|
||||
console.error('');
|
||||
console.error(`Error: ${(error as Error).message}`);
|
||||
console.error('');
|
||||
console.error('Try installing manually:');
|
||||
console.error(' npm install -g copilot-api');
|
||||
process.stderr.write(String(fail('Failed to install copilot-api.')) + '\n');
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(String(`Error: ${(error as Error).message}`) + '\n');
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write('Try installing manually:\n');
|
||||
process.stderr.write(' npm install -g copilot-api\n');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Check if copilot-api is installed (should be after ensureCopilotApi)
|
||||
if (!isCopilotApiInstalled()) {
|
||||
console.error(fail('copilot-api is not installed.'));
|
||||
console.error('');
|
||||
console.error('Install/repair by running: ccs copilot start');
|
||||
process.stderr.write(String(fail('copilot-api is not installed.')) + '\n');
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write('Install/repair by running: ccs copilot start\n');
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -231,10 +231,10 @@ export async function executeCopilotProfile(
|
||||
authenticated: authStatus.authenticated,
|
||||
});
|
||||
if (!authStatus.authenticated) {
|
||||
console.error(fail('Not authenticated with GitHub.'));
|
||||
console.error('');
|
||||
console.error('Run: npx copilot-api auth');
|
||||
console.error('Or: ccs copilot auth');
|
||||
process.stderr.write(String(fail('Not authenticated with GitHub.')) + '\n');
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write('Run: npx copilot-api auth\n');
|
||||
process.stderr.write('Or: ccs copilot auth\n');
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -246,21 +246,21 @@ export async function executeCopilotProfile(
|
||||
console.log(info('Starting copilot-api daemon...'));
|
||||
const result = await startDaemon(normalizedConfig);
|
||||
if (!result.success) {
|
||||
console.error(fail(`Failed to start daemon: ${result.error}`));
|
||||
process.stderr.write(String(fail(`Failed to start daemon: ${result.error}`)) + '\n');
|
||||
return 1;
|
||||
}
|
||||
console.log(ok(`Daemon started on port ${normalizedConfig.port}`));
|
||||
daemonRunning = true;
|
||||
} else {
|
||||
console.error(fail('copilot-api daemon is not running.'));
|
||||
console.error('');
|
||||
console.error('Start the daemon:');
|
||||
console.error(' ccs copilot start');
|
||||
console.error('Fallback manual command:');
|
||||
console.error(` npx copilot-api start --port ${normalizedConfig.port}`);
|
||||
console.error('');
|
||||
console.error('Or enable auto_start in config:');
|
||||
console.error(' ccs config (then enable auto_start in Copilot section)');
|
||||
process.stderr.write(String(fail('copilot-api daemon is not running.')) + '\n');
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write('Start the daemon:\n');
|
||||
process.stderr.write(' ccs copilot start\n');
|
||||
process.stderr.write('Fallback manual command:\n');
|
||||
process.stderr.write(` npx copilot-api start --port ${normalizedConfig.port}\n`);
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write('Or enable auto_start in config:\n');
|
||||
process.stderr.write(' ccs config (then enable auto_start in Copilot section)\n');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -324,7 +324,7 @@ export async function executeCopilotProfile(
|
||||
|
||||
const proc = spawn(claudeCliPath, launchArgs, {
|
||||
stdio: 'inherit',
|
||||
env: { ...env, ...traceEnv },
|
||||
env: { ...env, ...traceEnv, ...forwardRequestIdEnv() },
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
|
||||
@@ -351,7 +351,7 @@ export async function executeCopilotProfile(
|
||||
error: { name: err.name, message: err.message },
|
||||
}
|
||||
);
|
||||
console.error(fail(`Failed to start Claude: ${err.message}`));
|
||||
process.stderr.write(String(fail(`Failed to start Claude: ${err.message}`)) + '\n');
|
||||
resolve(1);
|
||||
});
|
||||
});
|
||||
|
||||
+215
-172
@@ -5,8 +5,15 @@
|
||||
*/
|
||||
|
||||
import * as http from 'http';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { Readable } from 'stream';
|
||||
import { CursorExecutor } from './cursor-executor';
|
||||
import {
|
||||
REQUEST_ID_HEADER,
|
||||
REQUEST_ID_PATTERN,
|
||||
runWithRequestId,
|
||||
withRequestContext,
|
||||
} from '../services/logging';
|
||||
import {
|
||||
createAnthropicErrorResponse,
|
||||
createAnthropicProxyResponse,
|
||||
@@ -142,6 +149,35 @@ function hasValidDaemonToken(req: http.IncomingMessage): boolean {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveInboundRequestId(req: http.IncomingMessage): string | undefined {
|
||||
const raw = req.headers[REQUEST_ID_HEADER];
|
||||
const value = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const requestId = value.trim();
|
||||
return REQUEST_ID_PATTERN.test(requestId) ? requestId : undefined;
|
||||
}
|
||||
|
||||
function withCursorDaemonRequestContext<T>(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
fn: () => T
|
||||
): T {
|
||||
const requestId = resolveInboundRequestId(req) ?? randomUUID();
|
||||
res.setHeader(REQUEST_ID_HEADER, requestId);
|
||||
return withRequestContext(
|
||||
{
|
||||
requestId,
|
||||
method: req.method || 'GET',
|
||||
path: req.url || '/',
|
||||
},
|
||||
fn
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMessages(raw: unknown): NormalizedOpenAIMessage[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
throw new Error('messages must be an array');
|
||||
@@ -228,202 +264,209 @@ function parseArgs(argv: string[]): DaemonRuntimeOptions {
|
||||
export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Server {
|
||||
const executor = new CursorExecutor();
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const method = req.method || 'GET';
|
||||
const requestUrl = req.url || '/';
|
||||
const isOpenAiRoute = method === 'POST' && requestUrl === '/v1/chat/completions';
|
||||
const isAnthropicRoute = method === 'POST' && requestUrl === '/v1/messages';
|
||||
const server = http.createServer((req, res) =>
|
||||
withCursorDaemonRequestContext(req, res, async () => {
|
||||
const method = req.method || 'GET';
|
||||
const requestUrl = req.url || '/';
|
||||
const isOpenAiRoute = method === 'POST' && requestUrl === '/v1/chat/completions';
|
||||
const isAnthropicRoute = method === 'POST' && requestUrl === '/v1/messages';
|
||||
|
||||
try {
|
||||
if (method === 'GET' && requestUrl === '/health') {
|
||||
if (!hasValidDaemonToken(req)) {
|
||||
writeJson(res, 401, { error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
writeJson(res, 200, { ok: true, service: 'cursor-daemon' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'GET' && requestUrl === '/v1/models') {
|
||||
const authStatus = checkAuthStatus();
|
||||
const models = await getModelsForDaemon({
|
||||
credentials:
|
||||
authStatus.authenticated && !authStatus.expired && authStatus.credentials
|
||||
? {
|
||||
accessToken: authStatus.credentials.accessToken,
|
||||
machineId: authStatus.credentials.machineId,
|
||||
ghostMode: options.ghostMode,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
|
||||
const data = models.map((model) => ({
|
||||
id: model.id,
|
||||
object: 'model',
|
||||
created: 0,
|
||||
owned_by: model.provider,
|
||||
}));
|
||||
writeJson(res, 200, { object: 'list', data });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isOpenAiRoute && !isAnthropicRoute) {
|
||||
writeJson(res, 404, { error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (method === 'GET' && requestUrl === '/health') {
|
||||
if (!hasValidDaemonToken(req)) {
|
||||
writeJson(res, 401, { error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
writeJson(res, 200, { ok: true, service: 'cursor-daemon' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'GET' && requestUrl === '/v1/models') {
|
||||
const rawBody = await readJsonBody(req);
|
||||
const anthropicBody = isAnthropicRoute ? translateAnthropicRequest(rawBody) : undefined;
|
||||
const parsedBody = anthropicBody ?? ((rawBody as OpenAIChatRequest) || {});
|
||||
const messages = anthropicBody
|
||||
? anthropicBody.messages
|
||||
: normalizeMessages(parsedBody.messages);
|
||||
const requestedModel =
|
||||
typeof parsedBody.model === 'string' && parsedBody.model.trim().length > 0
|
||||
? parsedBody.model.trim()
|
||||
: undefined;
|
||||
const stream = parsedBody.stream === true;
|
||||
|
||||
const authStatus = checkAuthStatus();
|
||||
const models = await getModelsForDaemon({
|
||||
credentials:
|
||||
authStatus.authenticated && !authStatus.expired && authStatus.credentials
|
||||
? {
|
||||
accessToken: authStatus.credentials.accessToken,
|
||||
machineId: authStatus.credentials.machineId,
|
||||
ghostMode: options.ghostMode,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
|
||||
const data = models.map((model) => ({
|
||||
id: model.id,
|
||||
object: 'model',
|
||||
created: 0,
|
||||
owned_by: model.provider,
|
||||
}));
|
||||
writeJson(res, 200, { object: 'list', data });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isOpenAiRoute && !isAnthropicRoute) {
|
||||
writeJson(res, 404, { error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasValidDaemonToken(req)) {
|
||||
writeJson(res, 401, { error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const rawBody = await readJsonBody(req);
|
||||
const anthropicBody = isAnthropicRoute ? translateAnthropicRequest(rawBody) : undefined;
|
||||
const parsedBody = anthropicBody ?? ((rawBody as OpenAIChatRequest) || {});
|
||||
const messages = anthropicBody
|
||||
? anthropicBody.messages
|
||||
: normalizeMessages(parsedBody.messages);
|
||||
const requestedModel =
|
||||
typeof parsedBody.model === 'string' && parsedBody.model.trim().length > 0
|
||||
? parsedBody.model.trim()
|
||||
: undefined;
|
||||
const stream = parsedBody.stream === true;
|
||||
|
||||
const authStatus = checkAuthStatus();
|
||||
if (!authStatus.authenticated || !authStatus.credentials) {
|
||||
const message = 'Cursor credentials not found. Run `ccs legacy cursor auth` first.';
|
||||
if (isAnthropicRoute) {
|
||||
await pipeWebResponseToNode(
|
||||
createAnthropicErrorResponse(401, 'authentication_error', message),
|
||||
res
|
||||
);
|
||||
} else {
|
||||
writeJson(res, 401, {
|
||||
error: {
|
||||
type: 'authentication_error',
|
||||
message,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (authStatus.expired) {
|
||||
const message = 'Cursor credentials expired. Run `ccs legacy cursor auth` again.';
|
||||
if (isAnthropicRoute) {
|
||||
await pipeWebResponseToNode(
|
||||
createAnthropicErrorResponse(401, 'authentication_error', message),
|
||||
res
|
||||
);
|
||||
} else {
|
||||
writeJson(res, 401, {
|
||||
error: {
|
||||
type: 'authentication_error',
|
||||
message,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAnthropicRoute) {
|
||||
const expectedToken = (process.env.ANTHROPIC_AUTH_TOKEN || 'cursor-managed').trim();
|
||||
const requestToken = getAnthropicRequestToken(req.headers);
|
||||
if (!expectedToken || requestToken !== expectedToken) {
|
||||
await pipeWebResponseToNode(
|
||||
createAnthropicErrorResponse(
|
||||
401,
|
||||
'authentication_error',
|
||||
'Invalid Anthropic auth token. Set ANTHROPIC_AUTH_TOKEN and send it via x-api-key or Authorization Bearer.'
|
||||
),
|
||||
res
|
||||
);
|
||||
if (!authStatus.authenticated || !authStatus.credentials) {
|
||||
const message = 'Cursor credentials not found. Run `ccs legacy cursor auth` first.';
|
||||
if (isAnthropicRoute) {
|
||||
await pipeWebResponseToNode(
|
||||
createAnthropicErrorResponse(401, 'authentication_error', message),
|
||||
res
|
||||
);
|
||||
} else {
|
||||
writeJson(res, 401, {
|
||||
error: {
|
||||
type: 'authentication_error',
|
||||
message,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const daemonCredentials = {
|
||||
accessToken: authStatus.credentials.accessToken,
|
||||
machineId: authStatus.credentials.machineId,
|
||||
ghostMode: options.ghostMode,
|
||||
};
|
||||
const availableModels = await getModelsForDaemon({
|
||||
credentials: daemonCredentials,
|
||||
});
|
||||
const model = resolveCursorRequestModel(requestedModel, availableModels);
|
||||
if (
|
||||
requestedModel &&
|
||||
requestedModel !== model &&
|
||||
(process.env.CCS_DEBUG === '1' || process.env.CCS_DEBUG === 'true')
|
||||
) {
|
||||
console.error(
|
||||
`[cursor] Requested model "${requestedModel}" is unavailable; falling back to "${model}".`
|
||||
);
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
const abortOnDisconnect = () => {
|
||||
if (!abortController.signal.aborted && !res.writableEnded) {
|
||||
abortController.abort();
|
||||
if (authStatus.expired) {
|
||||
const message = 'Cursor credentials expired. Run `ccs legacy cursor auth` again.';
|
||||
if (isAnthropicRoute) {
|
||||
await pipeWebResponseToNode(
|
||||
createAnthropicErrorResponse(401, 'authentication_error', message),
|
||||
res
|
||||
);
|
||||
} else {
|
||||
writeJson(res, 401, {
|
||||
error: {
|
||||
type: 'authentication_error',
|
||||
message,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
req.on('aborted', abortOnDisconnect);
|
||||
req.on('close', abortOnDisconnect);
|
||||
res.on('close', abortOnDisconnect);
|
||||
if (isAnthropicRoute) {
|
||||
const expectedToken = (process.env.ANTHROPIC_AUTH_TOKEN || 'cursor-managed').trim();
|
||||
const requestToken = getAnthropicRequestToken(req.headers);
|
||||
if (!expectedToken || requestToken !== expectedToken) {
|
||||
await pipeWebResponseToNode(
|
||||
createAnthropicErrorResponse(
|
||||
401,
|
||||
'authentication_error',
|
||||
'Invalid Anthropic auth token. Set ANTHROPIC_AUTH_TOKEN and send it via x-api-key or Authorization Bearer.'
|
||||
),
|
||||
res
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await executor.execute({
|
||||
model,
|
||||
stream,
|
||||
signal: abortController.signal,
|
||||
credentials: daemonCredentials,
|
||||
body: {
|
||||
messages,
|
||||
tools: Array.isArray(parsedBody.tools) ? parsedBody.tools : undefined,
|
||||
reasoning_effort:
|
||||
typeof parsedBody.reasoning_effort === 'string'
|
||||
? parsedBody.reasoning_effort
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
const daemonCredentials = {
|
||||
accessToken: authStatus.credentials.accessToken,
|
||||
machineId: authStatus.credentials.machineId,
|
||||
ghostMode: options.ghostMode,
|
||||
};
|
||||
const availableModels = await getModelsForDaemon({
|
||||
credentials: daemonCredentials,
|
||||
});
|
||||
const model = resolveCursorRequestModel(requestedModel, availableModels);
|
||||
if (
|
||||
requestedModel &&
|
||||
requestedModel !== model &&
|
||||
(process.env.CCS_DEBUG === '1' || process.env.CCS_DEBUG === 'true')
|
||||
) {
|
||||
console.error(
|
||||
`[cursor] Requested model "${requestedModel}" is unavailable; falling back to "${model}".`
|
||||
);
|
||||
}
|
||||
|
||||
const outgoingResponse = isAnthropicRoute
|
||||
? await createAnthropicProxyResponse(result.response)
|
||||
: result.response;
|
||||
const abortController = new AbortController();
|
||||
const abortOnDisconnect = () => {
|
||||
if (!abortController.signal.aborted && !res.writableEnded) {
|
||||
abortController.abort();
|
||||
}
|
||||
};
|
||||
|
||||
await pipeWebResponseToNode(outgoingResponse, res);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
const isPayloadTooLarge = message.includes('Request body too large');
|
||||
const status = isPayloadTooLarge ? 413 : 400;
|
||||
if (isAnthropicRoute) {
|
||||
await pipeWebResponseToNode(
|
||||
createAnthropicErrorResponse(status, 'invalid_request_error', message),
|
||||
res
|
||||
);
|
||||
} else {
|
||||
writeJson(res, status, {
|
||||
error: {
|
||||
type: 'invalid_request_error',
|
||||
message,
|
||||
req.on('aborted', abortOnDisconnect);
|
||||
req.on('close', abortOnDisconnect);
|
||||
res.on('close', abortOnDisconnect);
|
||||
|
||||
const result = await executor.execute({
|
||||
model,
|
||||
stream,
|
||||
signal: abortController.signal,
|
||||
credentials: daemonCredentials,
|
||||
body: {
|
||||
messages,
|
||||
tools: Array.isArray(parsedBody.tools) ? parsedBody.tools : undefined,
|
||||
reasoning_effort:
|
||||
typeof parsedBody.reasoning_effort === 'string'
|
||||
? parsedBody.reasoning_effort
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const outgoingResponse = isAnthropicRoute
|
||||
? await createAnthropicProxyResponse(result.response)
|
||||
: result.response;
|
||||
|
||||
await pipeWebResponseToNode(outgoingResponse, res);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
const isPayloadTooLarge = message.includes('Request body too large');
|
||||
const status = isPayloadTooLarge ? 413 : 400;
|
||||
if (isAnthropicRoute) {
|
||||
await pipeWebResponseToNode(
|
||||
createAnthropicErrorResponse(status, 'invalid_request_error', message),
|
||||
res
|
||||
);
|
||||
} else {
|
||||
writeJson(res, status, {
|
||||
error: {
|
||||
type: 'invalid_request_error',
|
||||
message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
server.listen(options.port, '127.0.0.1');
|
||||
return server;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const server = startCursorDaemonServer(options);
|
||||
// Re-anchor to a requestId forwarded by the spawning CLI (CCS_REQUEST_ID env)
|
||||
// so daemon startup logs correlate with the parent invocation. AsyncLocalStorage
|
||||
// does not cross the spawn boundary, so the env bridge is mandatory here.
|
||||
runWithRequestId(() => {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const server = startCursorDaemonServer(options);
|
||||
|
||||
const shutdown = () => {
|
||||
server.close();
|
||||
};
|
||||
const shutdown = () => {
|
||||
server.close();
|
||||
};
|
||||
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
});
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import * as http from 'http';
|
||||
import type { CursorDaemonConfig, CursorDaemonStatus } from './types';
|
||||
import { getPidFromFile, writePidToFile, removePidFile } from './cursor-daemon-pid';
|
||||
import { verifyDaemonOwnership } from './daemon-process-ownership';
|
||||
import { createLogger } from '../services/logging';
|
||||
import { createLogger, forwardRequestIdEnv } from '../services/logging';
|
||||
export { getPidFromFile, writePidToFile, removePidFile } from './cursor-daemon-pid';
|
||||
|
||||
const logger = createLogger('cursor:daemon');
|
||||
@@ -226,6 +226,7 @@ export async function startDaemon(
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
...forwardRequestIdEnv(),
|
||||
CCS_CURSOR_DAEMON_TOKEN: effectiveConfig.daemon_token || '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { spawn } from 'child_process';
|
||||
import type { CursorConfig } from '../config/unified-config-types';
|
||||
|
||||
import { ensureCliproxyService } from '../cliproxy';
|
||||
import { forwardRequestIdEnv } from '../services/logging';
|
||||
import { resolveLifecyclePort } from '../cliproxy/config/port-manager';
|
||||
import { fail, info, ok } from '../utils/ui';
|
||||
import {
|
||||
@@ -112,23 +113,23 @@ export async function executeCursorProfile(
|
||||
claudeCliPath = 'claude'
|
||||
): Promise<number> {
|
||||
if (!config.enabled) {
|
||||
console.error(fail('Cursor integration is not enabled.'));
|
||||
console.error('');
|
||||
console.error('Enable it first: ccs legacy cursor enable');
|
||||
process.stderr.write(fail('Cursor integration is not enabled.') + '\n');
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write('Enable it first: ccs legacy cursor enable\n');
|
||||
return 1;
|
||||
}
|
||||
|
||||
const authStatus = checkAuthStatus();
|
||||
if (!authStatus.authenticated) {
|
||||
console.error(fail('Cursor credentials not found.'));
|
||||
console.error('');
|
||||
console.error('Authenticate first: ccs legacy cursor auth');
|
||||
process.stderr.write(fail('Cursor credentials not found.') + '\n');
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write('Authenticate first: ccs legacy cursor auth\n');
|
||||
return 1;
|
||||
}
|
||||
if (authStatus.expired) {
|
||||
console.error(fail('Cursor credentials have expired.'));
|
||||
console.error('');
|
||||
console.error('Refresh them with: ccs legacy cursor auth');
|
||||
process.stderr.write(fail('Cursor credentials have expired.') + '\n');
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write('Refresh them with: ccs legacy cursor auth\n');
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -144,17 +145,17 @@ export async function executeCursorProfile(
|
||||
daemon_token: daemonToken,
|
||||
});
|
||||
if (!result.success) {
|
||||
console.error(fail(`Failed to start cursor daemon: ${result.error}`));
|
||||
process.stderr.write(fail(`Failed to start cursor daemon: ${result.error}`) + '\n');
|
||||
return 1;
|
||||
}
|
||||
console.log(ok(`Daemon started on port ${config.port}`));
|
||||
daemonRunning = true;
|
||||
} else {
|
||||
console.error(fail('Cursor daemon is not running.'));
|
||||
console.error('');
|
||||
console.error('Start the daemon:');
|
||||
console.error(' ccs legacy cursor start');
|
||||
console.error('Or enable auto_start in the Cursor config section.');
|
||||
process.stderr.write(fail('Cursor daemon is not running.') + '\n');
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write('Start the daemon:\n');
|
||||
process.stderr.write(' ccs legacy cursor start\n');
|
||||
process.stderr.write('Or enable auto_start in the Cursor config section.\n');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -194,7 +195,7 @@ export async function executeCursorProfile(
|
||||
|
||||
const proc = spawn(claudeCliPath, launchArgs, {
|
||||
stdio: 'inherit',
|
||||
env: { ...env, ...traceEnv },
|
||||
env: { ...env, ...traceEnv, ...forwardRequestIdEnv() },
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
|
||||
@@ -203,7 +204,7 @@ export async function executeCursorProfile(
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
console.error(fail(`Failed to start Claude: ${err.message}`));
|
||||
process.stderr.write(fail(`Failed to start Claude: ${err.message}`) + '\n');
|
||||
resolve(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,9 @@ import { DelegationValidator } from '../utils/delegation-validator';
|
||||
import { SettingsParser } from './settings-parser';
|
||||
import { fail, warn } from '../utils/ui';
|
||||
import { getCcsDir } from '../config/config-loader-facade';
|
||||
import { createLogger } from '../services/logging';
|
||||
|
||||
const logger = createLogger('delegation:handler');
|
||||
|
||||
const PROFILE_FLAGS_WITH_VALUE = new Set(['-p', '--prompt', '--effort']);
|
||||
const PROMPT_FLAGS_WITH_VALUE = new Set(['-p', '--prompt']);
|
||||
@@ -85,13 +88,13 @@ function parseStringFlag(
|
||||
|
||||
// Reject dash-prefixed values (likely another flag)
|
||||
if (!options?.allowDashPrefix && value.startsWith('-')) {
|
||||
console.error(warn(`${flagName} value "${value}" looks like a flag. Ignoring.`));
|
||||
process.stderr.write(warn(`${flagName} value "${value}" looks like a flag. Ignoring.`) + '\n');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Reject empty/whitespace-only
|
||||
if (!value.trim()) {
|
||||
console.error(warn(`${flagName} value is empty. Ignoring.`));
|
||||
process.stderr.write(warn(`${flagName} value is empty. Ignoring.`) + '\n');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -149,9 +152,14 @@ export class DelegationHandler {
|
||||
// 6. Exit with proper code
|
||||
process.exit(result.exitCode || 0);
|
||||
} catch (error) {
|
||||
console.error(fail(`Delegation error: ${(error as Error).message}`));
|
||||
process.stderr.write(fail(`Delegation error: ${(error as Error).message}`) + '\n');
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error((error as Error).stack);
|
||||
logger.error('delegation.route.failure', 'Delegation route failed', {
|
||||
err:
|
||||
error instanceof Error
|
||||
? { name: error.name, message: error.message, stack: error.stack }
|
||||
: { message: String(error) },
|
||||
});
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -169,8 +177,10 @@ export class DelegationHandler {
|
||||
const lastSession = sessionMgr.getLastSession(baseProfile);
|
||||
|
||||
if (!lastSession) {
|
||||
console.error(fail(`No previous session found for ${baseProfile}`));
|
||||
console.error(` Start a new session first with: ccs ${baseProfile} -p "task"`);
|
||||
process.stderr.write(fail(`No previous session found for ${baseProfile}`) + '\n');
|
||||
process.stderr.write(
|
||||
` Start a new session first with: ccs ${baseProfile} -p "task"` + '\n'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -256,8 +266,8 @@ export class DelegationHandler {
|
||||
}
|
||||
|
||||
if (index === -1 || index === args.length - 1) {
|
||||
console.error(fail('Missing prompt after -p flag'));
|
||||
console.error(' Usage: ccs glm -p "task description"');
|
||||
process.stderr.write(fail('Missing prompt after -p flag') + '\n');
|
||||
process.stderr.write(' Usage: ccs glm -p "task description"' + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -301,11 +311,13 @@ export class DelegationHandler {
|
||||
if (!isNaN(val) && val > 0 && val <= 600000) {
|
||||
options.timeout = val;
|
||||
} else if (isNaN(val)) {
|
||||
console.error(warn(`--timeout "${rawVal}" is not a number. Using default.`));
|
||||
process.stderr.write(warn(`--timeout "${rawVal}" is not a number. Using default.`) + '\n');
|
||||
} else if (val <= 0) {
|
||||
console.error(warn(`--timeout ${val} must be positive. Using default.`));
|
||||
process.stderr.write(warn(`--timeout ${val} must be positive. Using default.`) + '\n');
|
||||
} else if (val > 600000) {
|
||||
console.error(warn(`--timeout ${val} exceeds max (600000ms). Using default.`));
|
||||
process.stderr.write(
|
||||
warn(`--timeout ${val} exceeds max (600000ms). Using default.`) + '\n'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,11 +334,11 @@ export class DelegationHandler {
|
||||
if (!isNaN(val) && val > 0 && val <= 100) {
|
||||
options.maxTurns = val;
|
||||
} else if (isNaN(val)) {
|
||||
console.error(warn(`--max-turns "${rawVal}" is not a number. Ignoring.`));
|
||||
process.stderr.write(warn(`--max-turns "${rawVal}" is not a number. Ignoring.`) + '\n');
|
||||
} else if (val <= 0) {
|
||||
console.error(warn(`--max-turns ${val} must be positive. Ignoring.`));
|
||||
process.stderr.write(warn(`--max-turns ${val} must be positive. Ignoring.`) + '\n');
|
||||
} else if (val > 100) {
|
||||
console.error(warn(`--max-turns ${val} exceeds max (100). Using 100.`));
|
||||
process.stderr.write(warn(`--max-turns ${val} exceeds max (100). Using 100.`) + '\n');
|
||||
options.maxTurns = 100;
|
||||
}
|
||||
}
|
||||
@@ -342,7 +354,7 @@ export class DelegationHandler {
|
||||
JSON.parse(agentsValue);
|
||||
options.agents = agentsValue;
|
||||
} catch {
|
||||
console.error(warn('--agents must be valid JSON. Ignoring.'));
|
||||
process.stderr.write(warn('--agents must be valid JSON. Ignoring.') + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,20 +412,20 @@ export class DelegationHandler {
|
||||
*/
|
||||
_validateProfile(profile: string): void {
|
||||
if (!profile) {
|
||||
console.error(fail('No profile specified'));
|
||||
console.error(' Usage: ccs <profile> -p "task"');
|
||||
console.error(' Examples: ccs glm -p "task", ccs km -p "task"');
|
||||
process.stderr.write(fail('No profile specified') + '\n');
|
||||
process.stderr.write(' Usage: ccs <profile> -p "task"' + '\n');
|
||||
process.stderr.write(' Examples: ccs glm -p "task", ccs km -p "task"' + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Use DelegationValidator to check profile
|
||||
const validation = DelegationValidator.validate(profile);
|
||||
if (!validation.valid) {
|
||||
console.error(fail(`Profile '${profile}' is not configured for delegation`));
|
||||
console.error(` ${validation.error}`);
|
||||
console.error('');
|
||||
console.error(' Run: ccs doctor');
|
||||
console.error(` Or configure: ${getCcsDir()}/${profile}.settings.json`);
|
||||
process.stderr.write(fail(`Profile '${profile}' is not configured for delegation`) + '\n');
|
||||
process.stderr.write(` ${validation.error}` + '\n');
|
||||
process.stderr.write('' + '\n');
|
||||
process.stderr.write(' Run: ccs doctor' + '\n');
|
||||
process.stderr.write(` Or configure: ${getCcsDir()}/${profile}.settings.json` + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { spawn } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import { killWithEscalation } from '../utils/process-utils';
|
||||
import { createLogger, forwardRequestIdEnv } from '../services/logging';
|
||||
import * as fs from 'fs';
|
||||
import { SessionManager } from './session-manager';
|
||||
import { SettingsParser } from './settings-parser';
|
||||
@@ -63,6 +64,27 @@ import { getCcsDir, getGlobalEnvConfig, loadSettings } from '../config/config-lo
|
||||
// Re-export types for consumers
|
||||
export type { ExecutionOptions, ExecutionResult, StreamMessage } from './executor/types';
|
||||
|
||||
const logger = createLogger('delegation:headless-executor');
|
||||
|
||||
export function summarizeClaudeLaunchArgsForLog(
|
||||
args: readonly string[],
|
||||
filteredExtraArgCount: number
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
argCount: args.length,
|
||||
hasPrompt: args.includes('-p'),
|
||||
hasSettings: args.includes('--settings'),
|
||||
outputFormat: args.includes('--output-format') ? 'configured' : 'default',
|
||||
verbose: args.includes('--verbose'),
|
||||
hasResume: args.includes('--resume'),
|
||||
hasPermissionMode: args.includes('--permission-mode'),
|
||||
bypassPermissions: args.includes('--dangerously-skip-permissions'),
|
||||
hasAllowedTools: args.includes('--allowedTools'),
|
||||
hasDisallowedTools: args.includes('--disallowedTools'),
|
||||
filteredExtraArgCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Headless executor for Claude CLI delegation
|
||||
*/
|
||||
@@ -128,10 +150,12 @@ export class HeadlessExecutor {
|
||||
});
|
||||
const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
|
||||
if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) {
|
||||
console.error(
|
||||
info(
|
||||
`Continuity inheritance active: profile "${profile}" -> account "${continuityInheritance.sourceAccount}"`
|
||||
)
|
||||
process.stderr.write(
|
||||
String(
|
||||
info(
|
||||
`Continuity inheritance active: profile "${profile}" -> account "${continuityInheritance.sourceAccount}"`
|
||||
)
|
||||
) + '\n'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -197,10 +221,12 @@ export class HeadlessExecutor {
|
||||
imageAnalysisProvider &&
|
||||
imageAnalysisStatus.effectiveRuntimeMode === 'native-read'
|
||||
) {
|
||||
console.error(
|
||||
info(
|
||||
`${imageAnalysisStatus.effectiveRuntimeReason || `Image analysis via ${imageAnalysisProvider} is unavailable.`} This delegation will use native Read.`
|
||||
)
|
||||
process.stderr.write(
|
||||
String(
|
||||
info(
|
||||
`${imageAnalysisStatus.effectiveRuntimeReason || `Image analysis via ${imageAnalysisProvider} is unavailable.`} This delegation will use native Read.`
|
||||
)
|
||||
) + '\n'
|
||||
);
|
||||
imageAnalysisEnv = {
|
||||
...imageAnalysisEnv,
|
||||
@@ -214,10 +240,12 @@ export class HeadlessExecutor {
|
||||
) {
|
||||
const ensureServiceResult = await ensureCliproxyService(resolveLifecyclePort(), false);
|
||||
if (!ensureServiceResult.started) {
|
||||
console.error(
|
||||
warn(
|
||||
`Image analysis via ${imageAnalysisProvider} is unavailable because CCS could not start the local CLIProxy service. This delegation will use native Read.`
|
||||
)
|
||||
process.stderr.write(
|
||||
String(
|
||||
warn(
|
||||
`Image analysis via ${imageAnalysisProvider} is unavailable because CCS could not start the local CLIProxy service. This delegation will use native Read.`
|
||||
)
|
||||
) + '\n'
|
||||
);
|
||||
imageAnalysisEnv = {
|
||||
...imageAnalysisEnv,
|
||||
@@ -272,7 +300,9 @@ export class HeadlessExecutor {
|
||||
if (permissionMode === 'bypassPermissions') {
|
||||
args.push('--dangerously-skip-permissions');
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.warn(warn('WARNING: Using --dangerously-skip-permissions mode'));
|
||||
process.stderr.write(
|
||||
String(warn('WARNING: Using --dangerously-skip-permissions mode')) + '\n'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
args.push('--permission-mode', permissionMode);
|
||||
@@ -286,12 +316,16 @@ export class HeadlessExecutor {
|
||||
args.push('--resume', lastSession.sessionId);
|
||||
if (process.env.CCS_DEBUG) {
|
||||
const cost = lastSession.totalCost?.toFixed(4) || '0.0000';
|
||||
console.error(info(`Resuming session: ${lastSession.sessionId} ($${cost})`));
|
||||
process.stderr.write(
|
||||
String(info(`Resuming session: ${lastSession.sessionId} ($${cost})`)) + '\n'
|
||||
);
|
||||
}
|
||||
} else if (sessionId) {
|
||||
args.push('--resume', sessionId);
|
||||
} else {
|
||||
console.warn(warn('No previous session found, starting new session'));
|
||||
process.stderr.write(
|
||||
String(warn('No previous session found, starting new session')) + '\n'
|
||||
);
|
||||
}
|
||||
} else if (sessionId) {
|
||||
args.push('--resume', sessionId);
|
||||
@@ -323,6 +357,7 @@ export class HeadlessExecutor {
|
||||
|
||||
// Passthrough extra args (catch-all for new/unknown flags)
|
||||
// Filter out duplicates of explicitly handled flags
|
||||
let filteredExtraArgCount = 0;
|
||||
if (extraArgs.length > 0) {
|
||||
const explicitFlags = new Set(['--max-turns', '--fallback-model', '--agents', '--betas']);
|
||||
const filteredExtras: string[] = [];
|
||||
@@ -338,6 +373,7 @@ export class HeadlessExecutor {
|
||||
}
|
||||
if (filteredExtras.length > 0) {
|
||||
args.push(...filteredExtras);
|
||||
filteredExtraArgCount = filteredExtras.length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,7 +392,11 @@ export class HeadlessExecutor {
|
||||
});
|
||||
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(info(`Claude CLI args: ${launchArgs.join(' ')}`));
|
||||
logger.info(
|
||||
'claude_cli_args',
|
||||
'Claude CLI args prepared',
|
||||
summarizeClaudeLaunchArgsForLog(launchArgs, filteredExtraArgCount)
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize UI before spawning
|
||||
@@ -419,7 +459,7 @@ export class HeadlessExecutor {
|
||||
|
||||
if (showProgress) {
|
||||
const modelName = getModelDisplayName(profile);
|
||||
console.error(ui.info(`Delegating to ${modelName}...`));
|
||||
process.stderr.write(String(ui.info(`Delegating to ${modelName}...`)) + '\n');
|
||||
}
|
||||
|
||||
// Strip Claude Code nested session guard env var to allow CCS delegation
|
||||
@@ -432,6 +472,7 @@ export class HeadlessExecutor {
|
||||
...imageAnalysisEnv,
|
||||
...traceEnv,
|
||||
...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}),
|
||||
...forwardRequestIdEnv(),
|
||||
CCS_PROFILE_TYPE: 'settings',
|
||||
});
|
||||
|
||||
@@ -523,12 +564,14 @@ export class HeadlessExecutor {
|
||||
|
||||
if (showProgress) {
|
||||
const durationSec = (duration / 1000).toFixed(1);
|
||||
console.error(
|
||||
timedOut
|
||||
? ui.warn(`Timed out after ${durationSec}s`)
|
||||
: ui.info(`Completed in ${durationSec}s`)
|
||||
process.stderr.write(
|
||||
String(
|
||||
timedOut
|
||||
? ui.warn(`Timed out after ${durationSec}s`)
|
||||
: ui.info(`Completed in ${durationSec}s`)
|
||||
) + '\n'
|
||||
);
|
||||
console.error('');
|
||||
process.stderr.write('\n');
|
||||
}
|
||||
|
||||
const result = buildExecutionResult({
|
||||
@@ -662,7 +705,7 @@ export class HeadlessExecutor {
|
||||
const result = await this.execute(profile, enhancedPrompt, execOptions);
|
||||
if (result.success) return result;
|
||||
if (attempt < maxRetries) {
|
||||
console.error(warn(`Attempt ${attempt + 1} failed, retrying...`));
|
||||
process.stderr.write(String(warn(`Attempt ${attempt + 1} failed, retrying...`)) + '\n');
|
||||
await this._sleep(1000 * (attempt + 1));
|
||||
continue;
|
||||
}
|
||||
@@ -670,7 +713,7 @@ export class HeadlessExecutor {
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
if (attempt < maxRetries) {
|
||||
console.error(warn(`Attempt ${attempt + 1} errored, retrying...`));
|
||||
process.stderr.write(String(warn(`Attempt ${attempt + 1} errored, retrying...`)) + '\n');
|
||||
await this._sleep(1000 * (attempt + 1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir } from '../config/config-loader-facade';
|
||||
import { createLogger } from '../services/logging';
|
||||
|
||||
const logger = createLogger('delegation:session-manager');
|
||||
|
||||
interface SessionData {
|
||||
sessionId: string;
|
||||
@@ -145,9 +148,12 @@ class SessionManager {
|
||||
const content = fs.readFileSync(this.sessionsPath, 'utf8');
|
||||
return JSON.parse(content) as SessionsRegistry;
|
||||
} catch (error) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.warn(`[!] Failed to load sessions: ${(error as Error).message}`);
|
||||
}
|
||||
logger.warn('session.load.failed', 'Failed to load delegation sessions', {
|
||||
err:
|
||||
error instanceof Error
|
||||
? { name: error.name, message: error.message }
|
||||
: { message: String(error) },
|
||||
});
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -163,7 +169,12 @@ class SessionManager {
|
||||
}
|
||||
fs.writeFileSync(this.sessionsPath, JSON.stringify(sessions, null, 2), { mode: 0o600 });
|
||||
} catch (error) {
|
||||
console.error(`[!] Failed to save sessions: ${(error as Error).message}`);
|
||||
logger.error('session.save.failed', 'Failed to save delegation sessions', {
|
||||
err:
|
||||
error instanceof Error
|
||||
? { name: error.name, message: error.message }
|
||||
: { message: String(error) },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* CLI argument parsing and normalization utilities.
|
||||
*
|
||||
* Extracted from src/ccs.ts (lines 129-244, 246-296, 371-392).
|
||||
* Pure functions — no side effects except console.error and process.exit.
|
||||
* Pure functions — no side effects except process.stderr.write and process.exit.
|
||||
*
|
||||
* Also contains bootstrapAndParseEarlyCli() — the Phase A bootstrap extracted
|
||||
* from main() (lines 128-232 of the original). Handles: adapter registration,
|
||||
@@ -74,19 +74,21 @@ export async function bootstrapAndParseEarlyCli(rawArgs: string[]): Promise<Disp
|
||||
}
|
||||
|
||||
if (!configDirValue || configDirValue.startsWith('-')) {
|
||||
console.error(fail('--config-dir requires a path argument'));
|
||||
process.stderr.write(String(fail('--config-dir requires a path argument')) + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = fs.statSync(configDirValue);
|
||||
if (!stat.isDirectory()) {
|
||||
console.error(fail(`Not a directory: ${configDirValue}`));
|
||||
process.stderr.write(String(fail(`Not a directory: ${configDirValue}`)) + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
} catch {
|
||||
console.error(fail(`Config directory not found: ${configDirValue}`));
|
||||
console.error(info('Create the directory first, then copy your config files into it.'));
|
||||
process.stderr.write(String(fail(`Config directory not found: ${configDirValue}`)) + '\n');
|
||||
process.stderr.write(
|
||||
String(info('Create the directory first, then copy your config files into it.')) + '\n'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -95,9 +97,9 @@ export async function bootstrapAndParseEarlyCli(rawArgs: string[]): Promise<Disp
|
||||
// Security warning: cloud sync paths expose OAuth tokens
|
||||
const cloudService = detectCloudSyncPath(configDirValue);
|
||||
if (!isCompletionCommand && cloudService) {
|
||||
console.error(warn(`CCS directory is under ${cloudService}.`));
|
||||
console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.');
|
||||
console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...');
|
||||
process.stderr.write(String(warn(`CCS directory is under ${cloudService}.`)) + '\n');
|
||||
process.stderr.write(' OAuth tokens in cliproxy/auth/ will be synced to cloud.\n');
|
||||
process.stderr.write(' Consider: CCS_DIR=/path/outside/cloud ccs ...\n');
|
||||
}
|
||||
|
||||
// Remove consumed args so they don't leak to Claude CLI
|
||||
@@ -108,17 +110,17 @@ export async function bootstrapAndParseEarlyCli(rawArgs: string[]): Promise<Disp
|
||||
// Also warn for CCS_DIR env var pointing to cloud sync
|
||||
const cloudService = detectCloudSyncPath(process.env.CCS_DIR);
|
||||
if (!isCompletionCommand && cloudService) {
|
||||
console.error(warn(`CCS directory is under ${cloudService}.`));
|
||||
console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.');
|
||||
console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...');
|
||||
process.stderr.write(String(warn(`CCS directory is under ${cloudService}.`)) + '\n');
|
||||
process.stderr.write(' OAuth tokens in cliproxy/auth/ will be synced to cloud.\n');
|
||||
process.stderr.write(' Consider: CCS_DIR=/path/outside/cloud ccs ...\n');
|
||||
}
|
||||
} else if (process.env.CCS_HOME) {
|
||||
// Also warn for CCS_HOME env var pointing to cloud sync
|
||||
const cloudService = detectCloudSyncPath(process.env.CCS_HOME);
|
||||
if (!isCompletionCommand && cloudService) {
|
||||
console.error(warn(`CCS directory is under ${cloudService}.`));
|
||||
console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.');
|
||||
console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...');
|
||||
process.stderr.write(String(warn(`CCS directory is under ${cloudService}.`)) + '\n');
|
||||
process.stderr.write(' OAuth tokens in cliproxy/auth/ will be synced to cloud.\n');
|
||||
process.stderr.write(' Consider: CCS_DIR=/path/outside/cloud ccs ...\n');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +137,7 @@ export async function bootstrapAndParseEarlyCli(rawArgs: string[]): Promise<Disp
|
||||
browserLaunchOverride = browserLaunchFlags.override;
|
||||
args = browserLaunchFlags.argsWithoutFlags;
|
||||
} catch (error) {
|
||||
console.error(fail((error as Error).message));
|
||||
process.stderr.write(String(fail((error as Error).message)) + '\n');
|
||||
process.exit(1);
|
||||
// process.exit never returns but TypeScript needs the unreachable return
|
||||
return { args, isCompletionCommand, browserLaunchOverride: undefined, exitNow: true };
|
||||
@@ -228,15 +230,18 @@ export function normalizeLegacyCursorArgs(args: string[]): string[] {
|
||||
}
|
||||
|
||||
export function printCursorLegacySubcommandDeprecation(subcommand: string): void {
|
||||
console.error(
|
||||
warn(`\`ccs cursor ${subcommand}\` is deprecated for the legacy Cursor IDE bridge.`)
|
||||
process.stderr.write(
|
||||
String(warn(`\`ccs cursor ${subcommand}\` is deprecated for the legacy Cursor IDE bridge.`)) +
|
||||
'\n'
|
||||
);
|
||||
console.error(
|
||||
warn(
|
||||
`Use \`ccs legacy cursor ${subcommand}\` for the old bridge, or \`ccs cursor --auth|--accounts|--config\` for the CLIProxy provider.`
|
||||
)
|
||||
process.stderr.write(
|
||||
String(
|
||||
warn(
|
||||
`Use \`ccs legacy cursor ${subcommand}\` for the old bridge, or \`ccs cursor --auth|--accounts|--config\` for the CLIProxy provider.`
|
||||
)
|
||||
) + '\n'
|
||||
);
|
||||
console.error('');
|
||||
process.stderr.write('\n');
|
||||
}
|
||||
|
||||
// ========== Runtime Reasoning Flags ==========
|
||||
@@ -248,10 +253,12 @@ export function resolveRuntimeReasoningFlags(
|
||||
const runtime = resolveDroidReasoningRuntime(args, envThinkingValue);
|
||||
|
||||
if (runtime.duplicateDisplays.length > 0) {
|
||||
console.error(
|
||||
warn(
|
||||
`[!] Multiple reasoning flags detected. Using first occurrence: ${runtime.sourceDisplay || '<first-flag>'}`
|
||||
)
|
||||
process.stderr.write(
|
||||
String(
|
||||
warn(
|
||||
`[!] Multiple reasoning flags detected. Using first occurrence: ${runtime.sourceDisplay || '<first-flag>'}`
|
||||
)
|
||||
) + '\n'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -280,11 +287,11 @@ export function exitWithRuntimeReasoningFlagError(
|
||||
includeDroidExecExample?: boolean;
|
||||
}
|
||||
): never {
|
||||
console.error(fail(message));
|
||||
console.error(' Examples: --thinking low, --thinking 8192, --thinking off');
|
||||
console.error(` Codex alias: --effort ${options.codexAliasLevels}`);
|
||||
process.stderr.write(String(fail(message)) + '\n');
|
||||
process.stderr.write(' Examples: --thinking low, --thinking 8192, --thinking off\n');
|
||||
process.stderr.write(` Codex alias: --effort ${options.codexAliasLevels}\n`);
|
||||
if (options.includeDroidExecExample) {
|
||||
console.error(' Droid exec: --reasoning-effort high');
|
||||
process.stderr.write(' Droid exec: --reasoning-effort high\n');
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -67,12 +67,14 @@ export async function runCliproxyFlow(ctx: ProfileDispatchContext): Promise<void
|
||||
if (resolvedTarget !== 'claude') {
|
||||
const adapter = targetAdapter;
|
||||
if (!adapter) {
|
||||
console.error(fail(`Target adapter not found for "${resolvedTarget}"`));
|
||||
process.stderr.write(String(fail(`Target adapter not found for "${resolvedTarget}"`)) + '\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (!adapter.supportsProfileType('cliproxy')) {
|
||||
console.error(fail(`${adapter.displayName} does not support CLIProxy profiles`));
|
||||
process.stderr.write(
|
||||
String(fail(`${adapter.displayName} does not support CLIProxy profiles`)) + '\n'
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@@ -112,12 +114,16 @@ export async function runCliproxyFlow(ctx: ProfileDispatchContext): Promise<void
|
||||
targetRemainingArgs.some((arg) => arg.startsWith(`${flag}=`))
|
||||
);
|
||||
if (providedUnsupportedFlag) {
|
||||
console.error(
|
||||
fail(
|
||||
`${providedUnsupportedFlag} is only supported when running CLIProxy profiles on Claude target`
|
||||
)
|
||||
process.stderr.write(
|
||||
String(
|
||||
fail(
|
||||
`${providedUnsupportedFlag} is only supported when running CLIProxy profiles on Claude target`
|
||||
)
|
||||
) + '\n'
|
||||
);
|
||||
process.stderr.write(
|
||||
String(info(`Run with Claude target: ccs ${profileInfo.name} --target claude ...`)) + '\n'
|
||||
);
|
||||
console.error(info(`Run with Claude target: ccs ${profileInfo.name} --target claude ...`));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@@ -129,14 +135,20 @@ export async function runCliproxyFlow(ctx: ProfileDispatchContext): Promise<void
|
||||
] as CLIProxyProvider[];
|
||||
const missingProvider = compositeProviders.find((p) => !isAuthenticated(p));
|
||||
if (missingProvider) {
|
||||
console.error(fail(`Missing OAuth auth for composite tier provider: ${missingProvider}`));
|
||||
console.error(info(`Authenticate first: ccs ${missingProvider} --auth`));
|
||||
process.stderr.write(
|
||||
String(fail(`Missing OAuth auth for composite tier provider: ${missingProvider}`)) + '\n'
|
||||
);
|
||||
process.stderr.write(
|
||||
String(info(`Authenticate first: ccs ${missingProvider} --auth`)) + '\n'
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
} else if (!isAuthenticated(provider)) {
|
||||
console.error(fail(`No OAuth authentication found for provider: ${provider}`));
|
||||
console.error(info(`Authenticate first: ccs ${provider} --auth`));
|
||||
process.stderr.write(
|
||||
String(fail(`No OAuth authentication found for provider: ${provider}`)) + '\n'
|
||||
);
|
||||
process.stderr.write(String(info(`Authenticate first: ccs ${provider} --auth`)) + '\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@@ -146,7 +158,9 @@ export async function runCliproxyFlow(ctx: ProfileDispatchContext): Promise<void
|
||||
targetRemainingArgs.includes('--verbose') || targetRemainingArgs.includes('-v')
|
||||
);
|
||||
if (!ensureServiceResult.started) {
|
||||
console.error(fail(ensureServiceResult.error || 'Failed to start local CLIProxy service'));
|
||||
process.stderr.write(
|
||||
String(fail(ensureServiceResult.error || 'Failed to start local CLIProxy service')) + '\n'
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@@ -177,13 +191,16 @@ export async function runCliproxyFlow(ctx: ProfileDispatchContext): Promise<void
|
||||
};
|
||||
|
||||
if (!creds.baseUrl || !creds.apiKey) {
|
||||
console.error(
|
||||
fail(
|
||||
`Missing CLIProxy runtime credentials for ${profileInfo.name} (ANTHROPIC_BASE_URL/AUTH_TOKEN)`
|
||||
)
|
||||
process.stderr.write(
|
||||
String(
|
||||
fail(
|
||||
`Missing CLIProxy runtime credentials for ${profileInfo.name} (ANTHROPIC_BASE_URL/AUTH_TOKEN)`
|
||||
)
|
||||
) + '\n'
|
||||
);
|
||||
console.error(
|
||||
info('Reconfigure with: ccs config > CLIProxy, or run ccs <provider> --config')
|
||||
process.stderr.write(
|
||||
String(info('Reconfigure with: ccs config > CLIProxy, or run ccs <provider> --config')) +
|
||||
'\n'
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
|
||||
@@ -113,10 +113,12 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
|
||||
})
|
||||
: {};
|
||||
if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) {
|
||||
console.error(
|
||||
info(
|
||||
`Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"`
|
||||
)
|
||||
process.stderr.write(
|
||||
String(
|
||||
info(
|
||||
`Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"`
|
||||
)
|
||||
) + '\n'
|
||||
);
|
||||
}
|
||||
const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
|
||||
@@ -165,14 +167,16 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
|
||||
cliproxyBridgeProvider: cliproxyBridge?.provider ?? null,
|
||||
});
|
||||
if (!compatibility.supported) {
|
||||
console.error(
|
||||
fail(
|
||||
compatibility.reason ||
|
||||
`${targetAdapter?.displayName || resolvedTarget} does not support this profile.`
|
||||
)
|
||||
process.stderr.write(
|
||||
String(
|
||||
fail(
|
||||
compatibility.reason ||
|
||||
`${targetAdapter?.displayName || resolvedTarget} does not support this profile.`
|
||||
)
|
||||
) + '\n'
|
||||
);
|
||||
if (compatibility.suggestion) {
|
||||
console.error(info(compatibility.suggestion));
|
||||
process.stderr.write(String(info(compatibility.suggestion)) + '\n');
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -187,7 +191,7 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
|
||||
|
||||
if (glmtNormalization) {
|
||||
for (const message of glmtNormalization.warnings) {
|
||||
console.error(warn(message));
|
||||
process.stderr.write(String(warn(message)) + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,14 +201,16 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
|
||||
if (apiKey) {
|
||||
const validation = await validateGlmKey(apiKey, settingsEnv['ANTHROPIC_BASE_URL']);
|
||||
if (!validation.valid) {
|
||||
console.error('');
|
||||
console.error(fail(validation.error || 'API key validation failed'));
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(String(fail(validation.error || 'API key validation failed')) + '\n');
|
||||
if (validation.suggestion) {
|
||||
console.error('');
|
||||
console.error(validation.suggestion);
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(String(validation.suggestion) + '\n');
|
||||
}
|
||||
console.error('');
|
||||
console.error(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs glm "prompt"'));
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(
|
||||
String(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs glm "prompt"')) + '\n'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -215,14 +221,16 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
|
||||
if (apiKey) {
|
||||
const validation = await validateMiniMaxKey(apiKey, settingsEnv['ANTHROPIC_BASE_URL']);
|
||||
if (!validation.valid) {
|
||||
console.error('');
|
||||
console.error(fail(validation.error || 'API key validation failed'));
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(String(fail(validation.error || 'API key validation failed')) + '\n');
|
||||
if (validation.suggestion) {
|
||||
console.error('');
|
||||
console.error(validation.suggestion);
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(String(validation.suggestion) + '\n');
|
||||
}
|
||||
console.error('');
|
||||
console.error(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs mm "prompt"'));
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(
|
||||
String(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs mm "prompt"')) + '\n'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -235,15 +243,17 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
|
||||
if (anthropicApiKey && !hasBaseUrl) {
|
||||
const validation = await validateAnthropicKey(anthropicApiKey);
|
||||
if (!validation.valid) {
|
||||
console.error('');
|
||||
console.error(fail(validation.error || 'API key validation failed'));
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(String(fail(validation.error || 'API key validation failed')) + '\n');
|
||||
if (validation.suggestion) {
|
||||
console.error('');
|
||||
console.error(validation.suggestion);
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(String(validation.suggestion) + '\n');
|
||||
}
|
||||
console.error('');
|
||||
console.error(
|
||||
info(`To skip validation: CCS_SKIP_PREFLIGHT=1 ccs ${profileInfo.name} "prompt"`)
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(
|
||||
String(
|
||||
info(`To skip validation: CCS_SKIP_PREFLIGHT=1 ccs ${profileInfo.name} "prompt"`)
|
||||
) + '\n'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -271,7 +281,7 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
|
||||
// Log global env injection for visibility (debug mode only)
|
||||
if (globalEnvConfig.enabled && Object.keys(globalEnv).length > 0 && process.env.CCS_DEBUG) {
|
||||
const envNames = Object.keys(globalEnv).join(', ');
|
||||
console.error(info(`Global env: ${envNames}`));
|
||||
process.stderr.write(String(info(`Global env: ${envNames}`)) + '\n');
|
||||
}
|
||||
|
||||
// For Claude target launches that already pass `--settings`, keep runtime env free of
|
||||
@@ -302,7 +312,7 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
|
||||
if (resolvedTarget !== 'claude') {
|
||||
const adapter = targetAdapter;
|
||||
if (!adapter) {
|
||||
console.error(fail(`Target adapter not found for "${resolvedTarget}"`));
|
||||
process.stderr.write(String(fail(`Target adapter not found for "${resolvedTarget}"`)) + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
const directAnthropicBaseUrl =
|
||||
@@ -349,14 +359,18 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
|
||||
insecure: openAICompatProfile.insecure,
|
||||
});
|
||||
if (!proxyStart.success) {
|
||||
console.error(fail(proxyStart.error || 'Failed to start local OpenAI-compatible proxy'));
|
||||
process.stderr.write(
|
||||
String(fail(proxyStart.error || 'Failed to start local OpenAI-compatible proxy')) + '\n'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.error(
|
||||
info(
|
||||
`Using local OpenAI-compatible proxy for "${profileInfo.name}" on port ${proxyStart.port}`
|
||||
)
|
||||
process.stderr.write(
|
||||
String(
|
||||
info(
|
||||
`Using local OpenAI-compatible proxy for "${profileInfo.name}" on port ${proxyStart.port}`
|
||||
)
|
||||
) + '\n'
|
||||
);
|
||||
|
||||
const proxyEnv = {
|
||||
|
||||
@@ -190,7 +190,7 @@ export async function resolveProfileAndTarget(
|
||||
profileInfo.target ? { target: profileInfo.target } : undefined
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(fail((error as Error).message));
|
||||
process.stderr.write(String(fail((error as Error).message)) + '\n');
|
||||
process.exit(1);
|
||||
// Unreachable; needed so TS knows resolvedTarget is always assigned below
|
||||
throw error;
|
||||
@@ -219,7 +219,7 @@ export async function resolveProfileAndTarget(
|
||||
// so users get the most actionable error even when the target CLI is not installed.
|
||||
if (resolvedTarget !== 'claude') {
|
||||
if (!targetAdapter) {
|
||||
console.error(fail(`Target adapter not found for "${resolvedTarget}"`));
|
||||
process.stderr.write(String(fail(`Target adapter not found for "${resolvedTarget}"`)) + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -235,13 +235,15 @@ export async function resolveProfileAndTarget(
|
||||
cliproxyBridgeProvider: resolvedCliproxyBridge?.provider ?? null,
|
||||
});
|
||||
if (!compatibility.supported) {
|
||||
console.error(
|
||||
fail(
|
||||
compatibility.reason || `${targetAdapter.displayName} does not support this profile.`
|
||||
)
|
||||
process.stderr.write(
|
||||
String(
|
||||
fail(
|
||||
compatibility.reason || `${targetAdapter.displayName} does not support this profile.`
|
||||
)
|
||||
) + '\n'
|
||||
);
|
||||
if (compatibility.suggestion) {
|
||||
console.error(info(compatibility.suggestion));
|
||||
process.stderr.write(String(info(compatibility.suggestion)) + '\n');
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -255,13 +257,15 @@ export async function resolveProfileAndTarget(
|
||||
isComposite: profileInfo.type === 'cliproxy' ? Boolean(profileInfo.isComposite) : undefined,
|
||||
});
|
||||
if (!compatibility.supported) {
|
||||
console.error(
|
||||
fail(
|
||||
compatibility.reason || `${targetAdapter.displayName} does not support this profile.`
|
||||
)
|
||||
process.stderr.write(
|
||||
String(
|
||||
fail(
|
||||
compatibility.reason || `${targetAdapter.displayName} does not support this profile.`
|
||||
)
|
||||
) + '\n'
|
||||
);
|
||||
if (compatibility.suggestion) {
|
||||
console.error(info(compatibility.suggestion));
|
||||
process.stderr.write(String(info(compatibility.suggestion)) + '\n');
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -269,7 +273,9 @@ export async function resolveProfileAndTarget(
|
||||
|
||||
if (profileInfo.type === 'default') {
|
||||
if (!targetAdapter.supportsProfileType('default')) {
|
||||
console.error(fail(`${targetAdapter.displayName} does not support default profile mode`));
|
||||
process.stderr.write(
|
||||
String(fail(`${targetAdapter.displayName} does not support default profile mode`)) + '\n'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -278,12 +284,16 @@ export async function resolveProfileAndTarget(
|
||||
const baseUrl = process.env['ANTHROPIC_BASE_URL'] || '';
|
||||
const apiKey = process.env['ANTHROPIC_AUTH_TOKEN'] || '';
|
||||
if (!baseUrl.trim() || !apiKey.trim()) {
|
||||
console.error(
|
||||
fail(
|
||||
`${targetAdapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN`
|
||||
)
|
||||
process.stderr.write(
|
||||
String(
|
||||
fail(
|
||||
`${targetAdapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN`
|
||||
)
|
||||
) + '\n'
|
||||
);
|
||||
process.stderr.write(
|
||||
String(info('Use a settings-based profile instead: ccs glm --target droid')) + '\n'
|
||||
);
|
||||
console.error(info('Use a settings-based profile instead: ccs glm --target droid'));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -324,15 +334,17 @@ export async function resolveProfileAndTarget(
|
||||
? getBlockedBrowserOverrideWarning('Codex Browser Tools', codexBrowserExposure)
|
||||
: undefined;
|
||||
if (blockedBrowserOverrideWarning) {
|
||||
console.error(warn(blockedBrowserOverrideWarning));
|
||||
process.stderr.write(String(warn(blockedBrowserOverrideWarning)) + '\n');
|
||||
}
|
||||
if (resolvedTarget !== 'claude' && !targetBinaryInfo) {
|
||||
const displayName = targetAdapter?.displayName || resolvedTarget;
|
||||
console.error(fail(`${displayName} CLI not found.`));
|
||||
process.stderr.write(String(fail(`${displayName} CLI not found.`)) + '\n');
|
||||
if (resolvedTarget === 'droid') {
|
||||
console.error(info('Install: npm i -g @factory/cli'));
|
||||
process.stderr.write(String(info('Install: npm i -g @factory/cli')) + '\n');
|
||||
} else if (resolvedTarget === 'codex') {
|
||||
console.error(info('Install a recent @openai/codex build, then retry.'));
|
||||
process.stderr.write(
|
||||
String(info('Install a recent @openai/codex build, then retry.')) + '\n'
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -344,7 +356,9 @@ export async function resolveProfileAndTarget(
|
||||
const activeProfiles = allProfiles.settings.filter((name) => /^[a-zA-Z0-9._-]+$/.test(name));
|
||||
await pruneOrphanedModels(activeProfiles);
|
||||
} catch (error) {
|
||||
console.error(warn(`[!] Droid prune skipped: ${(error as Error).message}`));
|
||||
process.stderr.write(
|
||||
String(warn(`[!] Droid prune skipped: ${(error as Error).message}`)) + '\n'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,15 +378,19 @@ export async function resolveProfileAndTarget(
|
||||
runtimeReasoningOverride = runtime.reasoningOverride;
|
||||
} else {
|
||||
if (droidRoute.duplicateReasoningDisplays.length > 0) {
|
||||
console.error(
|
||||
warn(
|
||||
`[!] Multiple reasoning flags detected. Using first occurrence: ${droidRoute.reasoningSourceDisplay || '<first-flag>'}`
|
||||
)
|
||||
process.stderr.write(
|
||||
String(
|
||||
warn(
|
||||
`[!] Multiple reasoning flags detected. Using first occurrence: ${droidRoute.reasoningSourceDisplay || '<first-flag>'}`
|
||||
)
|
||||
) + '\n'
|
||||
);
|
||||
}
|
||||
if (droidRoute.autoPrependedExec && process.stdout.isTTY) {
|
||||
console.error(
|
||||
info('Detected Droid exec-only flags. Routing as: droid exec <flags> [prompt]')
|
||||
process.stderr.write(
|
||||
String(
|
||||
info('Detected Droid exec-only flags. Routing as: droid exec <flags> [prompt]')
|
||||
) + '\n'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,11 @@
|
||||
import * as fs from 'fs';
|
||||
import { execSync } from 'child_process';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
|
||||
import { createLogger } from '../services/logging';
|
||||
|
||||
const SUPERVISOR_SOCK = '/var/run/supervisor.sock';
|
||||
const SUPERVISOR_CONF = '/etc/supervisord.conf';
|
||||
const logger = createLogger('docker:supervisord-lifecycle');
|
||||
|
||||
/** True when running inside a supervisord-managed container. */
|
||||
export function isRunningUnderSupervisord(): boolean {
|
||||
@@ -30,7 +32,9 @@ export function restartCliproxyViaSupervisord(): {
|
||||
return { success: true, port: CLIPROXY_DEFAULT_PORT };
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[cliproxy] supervisorctl restart failed: ${detail}`);
|
||||
logger.error('cliproxy.restart.failed', 'supervisorctl restart failed', {
|
||||
detail,
|
||||
});
|
||||
return { success: false, error: 'supervisorctl restart failed' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
AuthError,
|
||||
BinaryError,
|
||||
CCSError,
|
||||
ConfigError,
|
||||
isCCSError,
|
||||
MigrationError,
|
||||
NetworkError,
|
||||
ProfileError,
|
||||
ProviderError,
|
||||
ProxyError,
|
||||
RetryableError,
|
||||
UserAbortError,
|
||||
ValidationError,
|
||||
} from '../error-types';
|
||||
import { ExitCode } from '../exit-codes';
|
||||
|
||||
/**
|
||||
* P4 behavior-lock: the typed-error -> exit-code mapping is the contract this
|
||||
* epic relies on. Migrating `throw new Error` to typed subclasses changes the
|
||||
* process exit code (via handleError -> getExitCode); these tests lock the
|
||||
* mapping so a future change is caught. See
|
||||
* docs/reports/typed-error-exit-code-compat-audit.md.
|
||||
*/
|
||||
describe('typed-error taxonomy -> exit-code mapping (P4 contract)', () => {
|
||||
test('each typed class carries its documented ExitCode', () => {
|
||||
expect(new ConfigError('m').code).toBe(ExitCode.CONFIG_ERROR);
|
||||
expect(new NetworkError('m').code).toBe(ExitCode.NETWORK_ERROR);
|
||||
expect(new AuthError('m').code).toBe(ExitCode.AUTH_ERROR);
|
||||
expect(new BinaryError('m').code).toBe(ExitCode.BINARY_ERROR);
|
||||
expect(new ProviderError('m', 'p').code).toBe(ExitCode.PROVIDER_ERROR);
|
||||
expect(new ProfileError('m').code).toBe(ExitCode.PROFILE_ERROR);
|
||||
expect(new ProxyError('m').code).toBe(ExitCode.PROXY_ERROR);
|
||||
expect(new MigrationError('m').code).toBe(ExitCode.MIGRATION_ERROR);
|
||||
expect(new UserAbortError().code).toBe(ExitCode.USER_ABORT);
|
||||
// These two intentionally keep GENERAL_ERROR (no shift for callers).
|
||||
expect(new ValidationError('m').code).toBe(ExitCode.GENERAL_ERROR);
|
||||
expect(new RetryableError('m').code).toBe(ExitCode.GENERAL_ERROR);
|
||||
});
|
||||
|
||||
test('all typed errors are CCSError and Error (instanceof chains preserved)', () => {
|
||||
const samples = [
|
||||
new ConfigError('m'),
|
||||
new AuthError('m'),
|
||||
new ProfileError('m'),
|
||||
new ProviderError('m', 'p'),
|
||||
new ValidationError('m'),
|
||||
];
|
||||
for (const e of samples) {
|
||||
expect(e).toBeInstanceOf(CCSError);
|
||||
expect(e).toBeInstanceOf(Error);
|
||||
expect(isCCSError(e)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('plain Error is NOT a CCSError (migration boundary)', () => {
|
||||
expect(isCCSError(new Error('plain'))).toBe(false);
|
||||
});
|
||||
|
||||
test('typed errors preserve their message (message-based assertions are stable)', () => {
|
||||
expect(new ProfileError(`Profile not found: x`).message).toBe('Profile not found: x');
|
||||
expect(new AuthError(`OAuth start failed with status 400`).message).toBe(
|
||||
'OAuth start failed with status 400'
|
||||
);
|
||||
expect(new ConfigError(`Invalid settings path`).message).toBe('Invalid settings path');
|
||||
});
|
||||
|
||||
test('structured context is carried (profileName / provider / configPath)', () => {
|
||||
expect(new ProfileError('m', 'my-profile').profileName).toBe('my-profile');
|
||||
expect(new AuthError('m', 'codex').provider).toBe('codex');
|
||||
expect(new ConfigError('m', '/path/to/cfg').configPath).toBe('/path/to/cfg');
|
||||
expect(new ProviderError('m', 'gemini').provider).toBe('gemini');
|
||||
});
|
||||
});
|
||||
+61
-47
@@ -5,6 +5,10 @@
|
||||
* - Standardized exit codes
|
||||
* - Recoverable flag for retry logic
|
||||
* - Consistent error formatting
|
||||
*
|
||||
* Fields are declared explicitly (no TypeScript parameter properties) so this
|
||||
* module is erasable-syntax-compatible: web UI builds enforce
|
||||
* `erasableSyntaxOnly` and reach this module via the @shared graph.
|
||||
*/
|
||||
|
||||
import { ExitCode } from './exit-codes';
|
||||
@@ -14,12 +18,17 @@ import { ExitCode } from './exit-codes';
|
||||
* Extends standard Error with exit code and recovery information
|
||||
*/
|
||||
export class CCSError extends Error {
|
||||
readonly code: ExitCode;
|
||||
readonly recoverable: boolean;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: ExitCode = ExitCode.GENERAL_ERROR,
|
||||
public readonly recoverable: boolean = false
|
||||
code: ExitCode = ExitCode.GENERAL_ERROR,
|
||||
recoverable: boolean = false
|
||||
) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.recoverable = recoverable;
|
||||
this.name = 'CCSError';
|
||||
// Maintain proper stack trace in V8 environments
|
||||
if (Error.captureStackTrace) {
|
||||
@@ -33,12 +42,12 @@ export class CCSError extends Error {
|
||||
* Examples: missing config file, invalid JSON, corrupt settings
|
||||
*/
|
||||
export class ConfigError extends CCSError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly configPath?: string
|
||||
) {
|
||||
readonly configPath?: string;
|
||||
|
||||
constructor(message: string, configPath?: string) {
|
||||
super(message, ExitCode.CONFIG_ERROR, false);
|
||||
this.name = 'ConfigError';
|
||||
this.configPath = configPath;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,13 +56,14 @@ export class ConfigError extends CCSError {
|
||||
* Examples: connection refused, timeout, DNS resolution failure
|
||||
*/
|
||||
export class NetworkError extends CCSError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly url?: string,
|
||||
public readonly statusCode?: number
|
||||
) {
|
||||
readonly url?: string;
|
||||
readonly statusCode?: number;
|
||||
|
||||
constructor(message: string, url?: string, statusCode?: number) {
|
||||
super(message, ExitCode.NETWORK_ERROR, true); // Network errors are typically recoverable
|
||||
this.name = 'NetworkError';
|
||||
this.url = url;
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,12 +72,12 @@ export class NetworkError extends CCSError {
|
||||
* Examples: invalid API key, expired token, insufficient permissions
|
||||
*/
|
||||
export class AuthError extends CCSError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly provider?: string
|
||||
) {
|
||||
readonly provider?: string;
|
||||
|
||||
constructor(message: string, provider?: string) {
|
||||
super(message, ExitCode.AUTH_ERROR, false);
|
||||
this.name = 'AuthError';
|
||||
this.provider = provider;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,12 +86,12 @@ export class AuthError extends CCSError {
|
||||
* Examples: Claude CLI not found, corrupted binary, permission denied
|
||||
*/
|
||||
export class BinaryError extends CCSError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly binaryPath?: string
|
||||
) {
|
||||
readonly binaryPath?: string;
|
||||
|
||||
constructor(message: string, binaryPath?: string) {
|
||||
super(message, ExitCode.BINARY_ERROR, false);
|
||||
this.name = 'BinaryError';
|
||||
this.binaryPath = binaryPath;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,13 +100,14 @@ export class BinaryError extends CCSError {
|
||||
* Examples: API rate limit, service unavailable, invalid model
|
||||
*/
|
||||
export class ProviderError extends CCSError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly provider: string,
|
||||
public readonly details?: unknown
|
||||
) {
|
||||
readonly provider: string;
|
||||
readonly details?: unknown;
|
||||
|
||||
constructor(message: string, provider: string, details?: unknown) {
|
||||
super(message, ExitCode.PROVIDER_ERROR, true); // Provider errors may be recoverable
|
||||
this.name = 'ProviderError';
|
||||
this.provider = provider;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,13 +116,14 @@ export class ProviderError extends CCSError {
|
||||
* Examples: profile not found, invalid profile name, duplicate profile
|
||||
*/
|
||||
export class ProfileError extends CCSError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly profileName?: string,
|
||||
public readonly availableProfiles?: string[]
|
||||
) {
|
||||
readonly profileName?: string;
|
||||
readonly availableProfiles?: string[];
|
||||
|
||||
constructor(message: string, profileName?: string, availableProfiles?: string[]) {
|
||||
super(message, ExitCode.PROFILE_ERROR, false);
|
||||
this.name = 'ProfileError';
|
||||
this.profileName = profileName;
|
||||
this.availableProfiles = availableProfiles;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,12 +132,12 @@ export class ProfileError extends CCSError {
|
||||
* Examples: proxy startup failure, port conflict, proxy timeout
|
||||
*/
|
||||
export class ProxyError extends CCSError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly port?: number
|
||||
) {
|
||||
readonly port?: number;
|
||||
|
||||
constructor(message: string, port?: number) {
|
||||
super(message, ExitCode.PROXY_ERROR, false);
|
||||
this.name = 'ProxyError';
|
||||
this.port = port;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,13 +146,14 @@ export class ProxyError extends CCSError {
|
||||
* Examples: failed to migrate config, backup creation failed
|
||||
*/
|
||||
export class MigrationError extends CCSError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly fromVersion?: string,
|
||||
public readonly toVersion?: string
|
||||
) {
|
||||
readonly fromVersion?: string;
|
||||
readonly toVersion?: string;
|
||||
|
||||
constructor(message: string, fromVersion?: string, toVersion?: string) {
|
||||
super(message, ExitCode.MIGRATION_ERROR, false);
|
||||
this.name = 'MigrationError';
|
||||
this.fromVersion = fromVersion;
|
||||
this.toVersion = toVersion;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,12 +173,12 @@ export class UserAbortError extends CCSError {
|
||||
* Distinguishes user-input validation failures from system errors
|
||||
*/
|
||||
export class ValidationError extends CCSError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly field?: string
|
||||
) {
|
||||
readonly field?: string;
|
||||
|
||||
constructor(message: string, field?: string) {
|
||||
super(message, ExitCode.GENERAL_ERROR, false);
|
||||
this.name = 'ValidationError';
|
||||
this.field = field;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,13 +187,14 @@ export class ValidationError extends CCSError {
|
||||
* Signals that the operation may succeed on retry (e.g. rate limits, timeouts)
|
||||
*/
|
||||
export class RetryableError extends CCSError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly originalError?: Error,
|
||||
public readonly retryAfter?: number // ms until next attempt
|
||||
) {
|
||||
readonly originalError?: Error;
|
||||
readonly retryAfter?: number; // ms until next attempt
|
||||
|
||||
constructor(message: string, originalError?: Error, retryAfter?: number) {
|
||||
super(message, ExitCode.GENERAL_ERROR, true);
|
||||
this.name = 'RetryableError';
|
||||
this.originalError = originalError;
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+22
-15
@@ -7,42 +7,50 @@
|
||||
* - 126-127: Command execution errors (reserved by shell)
|
||||
* - 128+N: Signal termination (128 + signal number)
|
||||
* - 130: SIGINT (Ctrl+C) - 128 + 2
|
||||
*
|
||||
* Implemented as a const object + union type (not a TS `enum`) so the file is
|
||||
* erasable-syntax-compatible: web UI builds (ui/tsconfig.app.json,
|
||||
* erasableSyntaxOnly) can reach this module via the @shared graph without
|
||||
* failing the build. Value (`ExitCode.CONFIG_ERROR`) and type (`: ExitCode`)
|
||||
* usage both continue to work.
|
||||
*/
|
||||
|
||||
export enum ExitCode {
|
||||
export const ExitCode = {
|
||||
/** Successful execution */
|
||||
SUCCESS = 0,
|
||||
SUCCESS: 0,
|
||||
|
||||
/** General/unspecified error */
|
||||
GENERAL_ERROR = 1,
|
||||
GENERAL_ERROR: 1,
|
||||
|
||||
/** Configuration file errors (missing, invalid, corrupt) */
|
||||
CONFIG_ERROR = 2,
|
||||
CONFIG_ERROR: 2,
|
||||
|
||||
/** Network-related errors (connection, timeout, DNS) */
|
||||
NETWORK_ERROR = 3,
|
||||
NETWORK_ERROR: 3,
|
||||
|
||||
/** Authentication/authorization errors (invalid token, expired, forbidden) */
|
||||
AUTH_ERROR = 4,
|
||||
AUTH_ERROR: 4,
|
||||
|
||||
/** Binary/executable errors (missing Claude CLI, corrupted binary) */
|
||||
BINARY_ERROR = 5,
|
||||
BINARY_ERROR: 5,
|
||||
|
||||
/** Provider-specific errors (API errors, rate limits, service unavailable) */
|
||||
PROVIDER_ERROR = 6,
|
||||
PROVIDER_ERROR: 6,
|
||||
|
||||
/** Profile not found or invalid */
|
||||
PROFILE_ERROR = 7,
|
||||
PROFILE_ERROR: 7,
|
||||
|
||||
/** Proxy-related errors (startup failure, port conflict) */
|
||||
PROXY_ERROR = 8,
|
||||
PROXY_ERROR: 8,
|
||||
|
||||
/** Migration errors (failed to migrate config) */
|
||||
MIGRATION_ERROR = 9,
|
||||
MIGRATION_ERROR: 9,
|
||||
|
||||
/** User aborted operation (Ctrl+C, SIGINT) */
|
||||
USER_ABORT = 130,
|
||||
}
|
||||
USER_ABORT: 130,
|
||||
} as const;
|
||||
|
||||
export type ExitCode = (typeof ExitCode)[keyof typeof ExitCode];
|
||||
|
||||
/**
|
||||
* Human-readable descriptions for exit codes
|
||||
@@ -74,6 +82,5 @@ export function isSuccess(code: ExitCode | number): boolean {
|
||||
* (errors that might succeed on retry)
|
||||
*/
|
||||
export function isRecoverable(code: ExitCode | number): boolean {
|
||||
const recoverableCodes = [ExitCode.NETWORK_ERROR, ExitCode.PROVIDER_ERROR];
|
||||
return recoverableCodes.includes(code as ExitCode);
|
||||
return code === ExitCode.NETWORK_ERROR || code === ExitCode.PROVIDER_ERROR;
|
||||
}
|
||||
@@ -14,6 +14,8 @@
|
||||
* const events = transformer.transformDelta(openaiEvent, acc);
|
||||
*/
|
||||
|
||||
import { createLogger } from '../services/logging';
|
||||
|
||||
interface ThinkingConfig {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -94,6 +96,7 @@ export class DeltaAccumulator {
|
||||
private finalized: boolean;
|
||||
private inputTokens: number;
|
||||
private outputTokens: number;
|
||||
private readonly logger = createLogger('glmt:delta-accumulator');
|
||||
|
||||
constructor(_thinkingConfig: ThinkingConfig = {}, options: DeltaAccumulatorOptions = {}) {
|
||||
this.messageId = 'msg_' + Date.now() + '_' + Math.random().toString(36).substring(7);
|
||||
@@ -179,7 +182,11 @@ export class DeltaAccumulator {
|
||||
const block = this.getCurrentBlock();
|
||||
if (!block) {
|
||||
// FIX: Guard against null block (should never happen, but defensive)
|
||||
console.error('[DeltaAccumulator] ERROR: addDelta called with no current block');
|
||||
this.logger.error(
|
||||
'delta.no_current_block',
|
||||
'DeltaAccumulator addDelta called with no current block',
|
||||
{ currentBlockIndex: this.currentBlockIndex }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -193,8 +200,15 @@ export class DeltaAccumulator {
|
||||
|
||||
// FIX: Verify assignment succeeded (paranoid check for race conditions)
|
||||
if (block.content.length !== this.thinkingBuffer.length) {
|
||||
console.error('[DeltaAccumulator] ERROR: Block content assignment failed');
|
||||
console.error(`Expected: ${this.thinkingBuffer.length}, Got: ${block.content.length}`);
|
||||
this.logger.error(
|
||||
'delta.assignment_failed',
|
||||
'DeltaAccumulator block content assignment failed',
|
||||
{
|
||||
blockIndex: block.index,
|
||||
expected: this.thinkingBuffer.length,
|
||||
actual: block.content.length,
|
||||
}
|
||||
);
|
||||
}
|
||||
} else if (block.type === 'text') {
|
||||
// C-02 Fix: Enforce buffer size limit
|
||||
@@ -216,9 +230,10 @@ export class DeltaAccumulator {
|
||||
|
||||
// FIX: Log block closure for debugging (helps diagnose timing issues)
|
||||
if (block.type === 'thinking' && process.env.CCS_DEBUG === '1') {
|
||||
console.error(
|
||||
`[DeltaAccumulator] Stopped thinking block ${block.index}: ${block.content?.length || 0} chars`
|
||||
);
|
||||
this.logger.debug('delta.stopped_thinking_block', 'Stopped thinking block', {
|
||||
blockIndex: block.index,
|
||||
contentLength: block.content?.length || 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+50
-15
@@ -149,17 +149,24 @@ export class GlmtProxy {
|
||||
|
||||
// Info message (only show in verbose mode)
|
||||
if (this.verbose) {
|
||||
console.error(
|
||||
`[glmt] Proxy listening on port ${this.port} (streaming with auto-fallback)`
|
||||
logger.info(
|
||||
'glmt.proxy.listening_verbose',
|
||||
'GLMT proxy listening (streaming with auto-fallback)',
|
||||
{
|
||||
port: this.port,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Debug mode notice
|
||||
if ((this.transformer as unknown as { debugLog: boolean }).debugLog) {
|
||||
console.error(
|
||||
`[glmt] Debug logging enabled: ${(this.transformer as unknown as { debugLogDir: string }).debugLogDir}`
|
||||
logger.info('glmt.proxy.debug_log_enabled', 'Debug logging enabled', {
|
||||
debugLogDir: (this.transformer as unknown as { debugLogDir: string }).debugLogDir,
|
||||
});
|
||||
logger.warn(
|
||||
'glmt.proxy.debug_log_warning',
|
||||
'Debug logs contain full request/response data'
|
||||
);
|
||||
console.error(`[glmt] WARNING: Debug logs contain full request/response data`);
|
||||
}
|
||||
|
||||
this.log(`Verbose logging enabled`);
|
||||
@@ -167,7 +174,12 @@ export class GlmtProxy {
|
||||
});
|
||||
|
||||
this.server.on('error', (error) => {
|
||||
console.error('[glmt-proxy] Server error:', error);
|
||||
logger.error('glmt.proxy.server_error', 'GLMT proxy server error', {
|
||||
err:
|
||||
error instanceof Error
|
||||
? { name: error.name, message: error.message }
|
||||
: { message: String(error) },
|
||||
});
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
@@ -240,7 +252,12 @@ export class GlmtProxy {
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error('[glmt-proxy] Request error:', err.message);
|
||||
logger.error('glmt.proxy.request_error', 'GLMT proxy request error', {
|
||||
err: { name: err.name, message: err.message },
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
durationMs: Date.now() - startTime,
|
||||
});
|
||||
const duration = Date.now() - startTime;
|
||||
this.log(`Request failed after ${duration}ms: ${err.message}`);
|
||||
|
||||
@@ -464,9 +481,11 @@ export class GlmtProxy {
|
||||
);
|
||||
|
||||
if (this.verbose) {
|
||||
console.error(
|
||||
`[glmt-proxy] Rate limited, retry ${attempt + 1}/${this.retryConfig.maxRetries} after ${Math.round(delay)}ms`
|
||||
);
|
||||
logger.warn('glmt.proxy.rate_limited_retry', 'Rate limited, retrying after backoff', {
|
||||
attempt: attempt + 1,
|
||||
maxRetries: this.retryConfig.maxRetries,
|
||||
delayMs: Math.round(delay),
|
||||
});
|
||||
}
|
||||
|
||||
await this.sleep(delay);
|
||||
@@ -526,8 +545,14 @@ export class GlmtProxy {
|
||||
const delay = this.calculateRetryDelay(attempt, retryAfter);
|
||||
|
||||
if (this.verbose) {
|
||||
console.error(
|
||||
`[glmt-proxy] Rate limited, retry ${attempt + 1}/${this.retryConfig.maxRetries} after ${Math.round(delay)}ms`
|
||||
logger.warn(
|
||||
'glmt.proxy.rate_limited_retry_stream',
|
||||
'Rate limited (streaming), retrying after backoff',
|
||||
{
|
||||
attempt: attempt + 1,
|
||||
maxRetries: this.retryConfig.maxRetries,
|
||||
delayMs: Math.round(delay),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -766,7 +791,7 @@ export class GlmtProxy {
|
||||
*/
|
||||
private log(message: string): void {
|
||||
if (this.verbose) {
|
||||
console.error(`[glmt-proxy] ${message}`);
|
||||
logger.info('glmt.proxy.verbose', message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -878,7 +903,12 @@ if (require.main === module) {
|
||||
const proxy = new GlmtProxy({ verbose });
|
||||
|
||||
proxy.start().catch((error) => {
|
||||
console.error('[glmt-proxy] Failed to start:', error);
|
||||
logger.error('glmt.proxy.start_failed', 'GLMT proxy failed to start', {
|
||||
err:
|
||||
error instanceof Error
|
||||
? { name: error.name, message: error.message }
|
||||
: { message: String(error) },
|
||||
});
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -895,7 +925,12 @@ if (require.main === module) {
|
||||
|
||||
// Keep process alive
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('[glmt-proxy] Uncaught exception:', error);
|
||||
logger.error('glmt.proxy.uncaught_exception', 'GLMT proxy uncaught exception', {
|
||||
err:
|
||||
error instanceof Error
|
||||
? { name: error.name, message: error.message }
|
||||
: { message: String(error) },
|
||||
});
|
||||
proxy.stop();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user