mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 14:14:47 +00:00
Merge pull request #1521 from kaitranntt/dev
feat(release): promote dev to main
This commit is contained in:
169 files changed
+27237
-620
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 223 KiB |
@@ -0,0 +1,224 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="light">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>CCS Bar Promo Evidence</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d0d0d;
|
||||
--fg: #e8e4df;
|
||||
--card: #1a1714;
|
||||
--border: #2d2923;
|
||||
--muted: #8a837d;
|
||||
--accent: #ef4444;
|
||||
--good: #22c55e;
|
||||
--code: #151210;
|
||||
}
|
||||
.light {
|
||||
--bg: #f8f6f3;
|
||||
--fg: #1a1714;
|
||||
--card: #ffffff;
|
||||
--border: #e5e0da;
|
||||
--muted: #6b6560;
|
||||
--accent: #dc2626;
|
||||
--good: #15803d;
|
||||
--code: #f3f0eb;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
main {
|
||||
width: min(1180px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 32px 0 56px;
|
||||
}
|
||||
button {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
color: var(--fg);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: clamp(28px, 4vw, 48px);
|
||||
line-height: 1.04;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.sub {
|
||||
margin-top: 10px;
|
||||
color: var(--muted);
|
||||
max-width: 760px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
.card,
|
||||
.shot,
|
||||
.section {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.card {
|
||||
padding: 16px;
|
||||
}
|
||||
.card h2 {
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card p {
|
||||
line-height: 1.45;
|
||||
}
|
||||
.section {
|
||||
padding: 18px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.section h2 {
|
||||
font-size: 20px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.shot {
|
||||
overflow: hidden;
|
||||
}
|
||||
.shot-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.pill {
|
||||
color: var(--accent);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
code {
|
||||
background: var(--code);
|
||||
border-radius: 6px;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
main {
|
||||
width: min(100% - 20px, 1180px);
|
||||
padding-top: 20px;
|
||||
}
|
||||
.top,
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
display: block;
|
||||
}
|
||||
.cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.shot + .shot {
|
||||
margin-top: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="top">
|
||||
<div>
|
||||
<h1>PR 1517 · CCS Bar README and dashboard promotion</h1>
|
||||
<p class="sub">
|
||||
Captures from local branch <code>kai/feat/ccs-bar-promo</code>. Dashboard capture uses
|
||||
Vite at <code>127.0.0.1:5187</code>, light theme, 1440x900 viewport, local auth-check
|
||||
stubbed open, and no private account data.
|
||||
</p>
|
||||
</div>
|
||||
<button id="theme-toggle" type="button">● Dark</button>
|
||||
</div>
|
||||
|
||||
<div class="cards">
|
||||
<section class="card">
|
||||
<h2>What Changed</h2>
|
||||
<p>README now introduces CCS Bar, and dashboard chrome shows a compact global CCS Bar link.</p>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2>Why It Matters</h2>
|
||||
<p>Users can discover the native macOS quota and usage companion from every dashboard route.</p>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2>Review Cue</h2>
|
||||
<p>Red callout marks the new top-bar button next to ClaudeKit and Sponsor controls.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="section">
|
||||
<h2>1. Global Dashboard Entry Point</h2>
|
||||
<p class="sub">The compact CCS Bar button is visible in the persistent dashboard header.</p>
|
||||
<div class="grid">
|
||||
<article class="shot">
|
||||
<div class="shot-header">
|
||||
<span class="pill">IMPLEMENTED</span>
|
||||
<span>Dashboard chrome</span>
|
||||
</div>
|
||||
<img
|
||||
src="dashboard-header-ccs-bar-button.png"
|
||||
alt="CCS dashboard header with red callout around the CCS Bar button"
|
||||
/>
|
||||
</article>
|
||||
<article class="shot">
|
||||
<div class="shot-header">
|
||||
<span class="pill">CURRENT SNAPSHOT</span>
|
||||
<span>Sanitized CCS Bar panel</span>
|
||||
</div>
|
||||
<img
|
||||
src="ccs-bar-panel.webp"
|
||||
alt="Sanitized CCS Bar macOS panel showing quota, spend, and account controls"
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
const button = document.querySelector("#theme-toggle");
|
||||
button.addEventListener("click", () => {
|
||||
document.documentElement.classList.toggle("light");
|
||||
button.textContent = document.documentElement.classList.contains("light")
|
||||
? "● Dark"
|
||||
: "○ Light";
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -62,3 +62,7 @@ tests/mocks/fixtures/*.js
|
||||
tests/mocks/fixtures/*.d.ts
|
||||
tests/mocks/fixtures/*.js.map
|
||||
tests/mocks/fixtures/*.d.ts.map
|
||||
|
||||
# Bar demo scaffolding (local dev only)
|
||||
_serve*.ts
|
||||
_serve*.log
|
||||
@@ -116,6 +116,17 @@ Need the full setup path instead of the short version?
|
||||
|
||||
## See CCS In Action
|
||||
|
||||
### CCS Bar For macOS
|
||||
|
||||

|
||||
|
||||
CCS Bar puts live subscription quota, daily spend, and account controls in the
|
||||
macOS menu bar. Install it with `ccs bar install`, launch it with `ccs bar`, and
|
||||
tell us what would make it more useful: issues and ideas are welcome in
|
||||
[GitHub Issues](https://github.com/kaitranntt/ccs/issues).
|
||||
|
||||
Deep dive: [CCS Bar](https://docs.ccs.kaitran.ca/features/dashboard/ccs-bar).
|
||||
|
||||
### Usage Analytics
|
||||
|
||||

|
||||
@@ -164,6 +175,7 @@ reference material.
|
||||
| Install CCS cleanly on a new machine | [Installation](https://docs.ccs.kaitran.ca/getting-started/installation) |
|
||||
| Go from install to a successful first run | [Your First CCS Session](https://docs.ccs.kaitran.ca/getting-started/first-session) |
|
||||
| See the dashboard and workflow surfaces before setup | [Product Tour](https://docs.ccs.kaitran.ca/getting-started/product-tour) |
|
||||
| Install the native macOS menu-bar companion | [CCS Bar](https://docs.ccs.kaitran.ca/features/dashboard/ccs-bar) |
|
||||
| Compare OAuth providers, Claude accounts, and API profiles | [Provider Overview](https://docs.ccs.kaitran.ca/providers/concepts/overview) |
|
||||
| Learn the dashboard structure and feature pages | [Dashboard Overview](https://docs.ccs.kaitran.ca/features/dashboard/overview) |
|
||||
| Configure profiles, paths, and environment variables | [Configuration](https://docs.ccs.kaitran.ca/getting-started/configuration) |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -1,10 +1,6 @@
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/claude",
|
||||
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
|
||||
"ANTHROPIC_MODEL": "claude-sonnet-4-6",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-opus-4-7",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4-6",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4-5-20251001"
|
||||
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
# CCS Bar — Native macOS Menu Bar App
|
||||
|
||||
CCS Bar is a native macOS menu-bar app that shows live subscription quota and usage at a glance for your Claude Code, Codex, and CLIProxy accounts, without opening the dashboard.
|
||||
|
||||
## What It Is
|
||||
|
||||
CCS Bar is a thin client of the CCS local web-server. It never talks to a provider directly: every call goes to `localhost`, and CCS performs any provider fetch server-side. Opening the menu fires a debounced force-refresh so the glance reflects live data without blocking the UI.
|
||||
|
||||
It is macOS only.
|
||||
|
||||
## What It Shows
|
||||
|
||||
- Per-account quota percent and reset countdown
|
||||
- Account tier
|
||||
- Today, 7-day, and 30-day cost
|
||||
- A 30-day usage sparkline
|
||||
- Account state (active, paused, default)
|
||||
- Native subscription rows for Claude Code and Codex
|
||||
|
||||
## Requirements
|
||||
|
||||
- macOS
|
||||
- CCS CLI installed and configured (`ccs config` works)
|
||||
- The CCS web-server reachable on loopback. `ccs bar` (or `ccs bar launch`) starts it for you.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
ccs bar install
|
||||
```
|
||||
|
||||
This downloads `CCS-Bar.app.zip` from the floating `ccs-bar-latest` GitHub release and installs `CCS Bar.app` into `~/Applications`. Downloads are restricted to `github.com` and `objects.githubusercontent.com`, and extraction is guarded against zip-slip.
|
||||
|
||||
If `CCS Bar.app` is already installed, the command shows the current version and proceeds as a reinstall.
|
||||
|
||||
After installation, CCS reads the app version directly from the bundle's `Info.plist` and pins it to `~/.ccs/bar/.version`. It then performs a reachability check against the bar API (`GET /api/bar/summary`). A 404 response means the running CCS server predates CCS Bar support — update CCS to a version that includes CCS Bar, then restart `ccs bar`.
|
||||
|
||||
After a successful install, CCS asks whether to launch CCS Bar immediately (default: yes). Pass `--launch` to skip the prompt and launch right away, or `--no-launch` to suppress the prompt entirely:
|
||||
|
||||
```bash
|
||||
ccs bar install --launch # install and launch immediately
|
||||
ccs bar install --no-launch # install, skip launch prompt
|
||||
```
|
||||
|
||||
### Gatekeeper note
|
||||
|
||||
The install command automatically clears the macOS Gatekeeper quarantine attribute (`xattr -dr com.apple.quarantine`) on the downloaded app. If clearing fails for any reason, the command falls back to printing the manual command:
|
||||
|
||||
```bash
|
||||
xattr -dr com.apple.quarantine "$HOME/Applications/CCS Bar.app"
|
||||
```
|
||||
|
||||
Or right-click the app and choose Open.
|
||||
|
||||
## Launch
|
||||
|
||||
```bash
|
||||
ccs bar # alias: ccs bar launch
|
||||
```
|
||||
|
||||
This checks whether a CCS web-server is already running (probing port 3000, 3001, 3002, 8000, and 8080, with the port from the previous `bar.json` checked first). If a live server is found it is reused; otherwise a new one is started. Either way the discovery file `~/.ccs/bar.json` is written and the app is opened. The discovery file looks like this:
|
||||
|
||||
```json
|
||||
{ "baseUrl": "http://127.0.0.1:3000", "port": 3000, "authMode": "loopback" }
|
||||
```
|
||||
|
||||
The Swift app reads `~/.ccs/bar.json` to find the server.
|
||||
|
||||
## Loopback / Localhost Requirement
|
||||
|
||||
CCS Bar talks only to `http://127.0.0.1:<port>`. v1 supports `authMode: "loopback"` only, meaning dashboard auth disabled on localhost.
|
||||
|
||||
If you bind the dashboard beyond localhost (for example `--host` set to a non-loopback address) with dashboard auth disabled, the bar's read endpoints (`GET /api/bar/summary`, `GET /api/bar/analytics`) are refused for non-loopback callers, and the app cannot reach the server. Keep the dashboard on loopback for CCS Bar to work.
|
||||
|
||||
## Uninstall
|
||||
|
||||
```bash
|
||||
ccs bar uninstall
|
||||
```
|
||||
|
||||
This removes `~/Applications/CCS Bar.app` and the installed version pin. It is a no-op if the app is not present.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Install fails with "server predates CCS Bar" or bar API returns 404: the CCS server running does not yet include CCS Bar. Update CCS (`npm i -g ccs@latest` or equivalent), then restart `ccs bar`.
|
||||
- Server failed to start: `ccs bar` first checks whether a CCS server is already running on the candidate ports (3000, 3001, 3002, 8000, 8080) and reuses it if found. A true failure here means a non-CCS process is occupying all candidate ports. Free one of those ports and re-run `ccs bar`.
|
||||
- App won't open (Gatekeeper): right-click and Open, or clear quarantine with the `xattr` command above.
|
||||
- Blank app: the web-server is not running. Re-run `ccs bar` and confirm `~/.ccs/bar.json` exists.
|
||||
- Quota not updating: re-open the menu to force a refresh, or confirm the server is still reachable on loopback.
|
||||
|
||||
## Development
|
||||
|
||||
The source lives in `macos-bar/`. Contributors can build and run the logic checks with a Swift 5.9+ toolchain (CommandLineTools is enough, full Xcode not required):
|
||||
|
||||
```bash
|
||||
swift build # build all targets, including the app
|
||||
swift run ccs-bar-check # run the logic tests
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
.build/
|
||||
dist/
|
||||
*.zip
|
||||
DerivedData/
|
||||
*.xcodeproj
|
||||
@@ -0,0 +1,23 @@
|
||||
// swift-tools-version:5.9
|
||||
import PackageDescription
|
||||
|
||||
// CCS Bar - native macOS menu bar client for CCS.
|
||||
//
|
||||
// Build/test note: full Xcode (and therefore XCTest) is not required. The
|
||||
// testable logic lives in the pure-Foundation `CCSBarCore` target and is
|
||||
// exercised by the `ccs-bar-check` executable (an assert harness) so it runs
|
||||
// on a CommandLineTools-only toolchain. The SwiftUI app target is added once
|
||||
// the core is verified.
|
||||
let package = Package(
|
||||
name: "CCSBar",
|
||||
platforms: [.macOS(.v13)],
|
||||
products: [
|
||||
.executable(name: "CCSBar", targets: ["CCSBarApp"]),
|
||||
.executable(name: "ccs-bar-check", targets: ["CCSBarCheck"]),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "CCSBarCore"),
|
||||
.executableTarget(name: "CCSBarApp", dependencies: ["CCSBarCore"]),
|
||||
.executableTarget(name: "CCSBarCheck", dependencies: ["CCSBarCore"]),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
# CCS Bar (macOS)
|
||||
|
||||
Native SwiftUI menu bar app for CCS. A thin client of the CCS local web-server:
|
||||
it glances per-account quota, cost and tier, and performs account control
|
||||
(pause/resume, set default, solo, tier-lock) from the menu bar.
|
||||
|
||||
The app never talks to a provider directly. Every call goes to `localhost`, and
|
||||
CCS performs any provider fetch server-side. Opening the menu fires a debounced
|
||||
force-refresh so the glance reflects live data without blocking the UI.
|
||||
|
||||
## Layout
|
||||
|
||||
- `Sources/CCSBarCore` — pure Foundation logic (no SwiftUI): API client,
|
||||
discovery handshake, models, formatting, refresh debounce. Fully unit-tested.
|
||||
- `Sources/CCSBarApp` — SwiftUI `MenuBarExtra` app: view-model + views.
|
||||
- `Sources/CCSBarCheck` — runnable assert harness used in place of XCTest
|
||||
(XCTest ships with full Xcode; this builds on a CommandLineTools toolchain).
|
||||
|
||||
## Build and test
|
||||
|
||||
Requires a Swift 5.9+ toolchain (CommandLineTools is enough; full Xcode not
|
||||
required for build/test).
|
||||
|
||||
```bash
|
||||
swift build # build all targets, including the app
|
||||
swift run ccs-bar-check # run the logic tests (exits non-zero on failure)
|
||||
```
|
||||
|
||||
## Discovery
|
||||
|
||||
The app reads `~/.ccs/bar.json` (written by `ccs bar launch`):
|
||||
|
||||
```json
|
||||
{ "baseUrl": "http://127.0.0.1:3000", "port": 3000, "authMode": "loopback" }
|
||||
```
|
||||
|
||||
v1 supports `authMode: "loopback"` only (dashboard auth disabled, localhost).
|
||||
|
||||
## Packaging
|
||||
|
||||
`Scripts/package_app.sh` assembles `CCS Bar.app` from a release build and signs
|
||||
it. v1 uses ad-hoc signing (`CCS_BAR_SIGNING=adhoc`, the default); users open it
|
||||
the first time via right-click then Open, or clear quarantine with
|
||||
`xattr -dr com.apple.quarantine "/Applications/CCS Bar.app"`. Developer ID
|
||||
signing + notarization (`CCS_BAR_SIGNING=developer-id`) is the public-launch
|
||||
path and is not required for ad-hoc distribution.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>CCSBar</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>ca.kaitran.ccs.bar</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>CCS Bar</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>CCS Bar</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>__VERSION__</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>__VERSION__</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>13.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>CCS Bar</string>
|
||||
</dict>
|
||||
</plist>
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# Assemble and sign "CCS Bar.app" from a release build.
|
||||
#
|
||||
# Signing mode (CCS_BAR_SIGNING):
|
||||
# adhoc (default) ad-hoc sign with `codesign -s -`. Free, no Apple
|
||||
# Developer account. Users open via right-click > Open or clear
|
||||
# quarantine with `xattr -dr com.apple.quarantine`.
|
||||
# developer-id Sign with a Developer ID Application identity (set
|
||||
# CCS_BAR_SIGN_IDENTITY) for the notarized public-launch path.
|
||||
#
|
||||
# Usage:
|
||||
# ./Scripts/package_app.sh [version]
|
||||
# CCS_BAR_SIGNING=developer-id CCS_BAR_SIGN_IDENTITY="Developer ID Application: ..." ./Scripts/package_app.sh 0.1.0
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-0.0.0}"
|
||||
SIGNING="${CCS_BAR_SIGNING:-adhoc}"
|
||||
APP_NAME="CCS Bar"
|
||||
EXEC_NAME="CCSBar"
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
DIST="$ROOT/dist"
|
||||
APP="$DIST/$APP_NAME.app"
|
||||
|
||||
echo "[i] Building release binary..."
|
||||
( cd "$ROOT" && swift build -c release )
|
||||
BIN="$ROOT/.build/release/$EXEC_NAME"
|
||||
if [[ ! -x "$BIN" ]]; then
|
||||
echo "[X] Release binary not found at $BIN" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[i] Assembling $APP_NAME.app (version $VERSION)..."
|
||||
rm -rf "$APP"
|
||||
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
||||
cp "$BIN" "$APP/Contents/MacOS/$EXEC_NAME"
|
||||
sed "s/__VERSION__/$VERSION/g" "$ROOT/Resources/Info.plist" > "$APP/Contents/Info.plist"
|
||||
|
||||
# Bundle the CCS icon assets (menu-bar color/template + header logo) so
|
||||
# Bundle.main can resolve them at runtime.
|
||||
if [[ -d "$ROOT/Resources/Assets" ]]; then
|
||||
cp "$ROOT/Resources/Assets/"*.png "$APP/Contents/Resources/" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "[i] Signing ($SIGNING)..."
|
||||
case "$SIGNING" in
|
||||
adhoc)
|
||||
codesign --force --deep --sign - "$APP"
|
||||
;;
|
||||
developer-id)
|
||||
: "${CCS_BAR_SIGN_IDENTITY:?Set CCS_BAR_SIGN_IDENTITY for developer-id signing}"
|
||||
codesign --force --deep --options runtime --timestamp \
|
||||
--sign "$CCS_BAR_SIGN_IDENTITY" "$APP"
|
||||
echo "[i] Signed with Developer ID. Notarize before public distribution:"
|
||||
echo " xcrun notarytool submit <zip> --keychain-profile <profile> --wait"
|
||||
;;
|
||||
*)
|
||||
echo "[X] Unknown CCS_BAR_SIGNING: $SIGNING (expected adhoc|developer-id)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
ZIP="$DIST/CCS-Bar.app.zip"
|
||||
echo "[i] Zipping -> $ZIP"
|
||||
rm -f "$ZIP"
|
||||
( cd "$DIST" && ditto -c -k --keepParent "$APP_NAME.app" "CCS-Bar.app.zip" )
|
||||
|
||||
echo "[OK] Packaged: $APP"
|
||||
echo "[OK] Asset: $ZIP"
|
||||
if [[ "$SIGNING" == "adhoc" ]]; then
|
||||
echo "[!] Ad-hoc build: first launch needs right-click > Open, or"
|
||||
echo " xattr -dr com.apple.quarantine \"/Applications/$APP_NAME.app\""
|
||||
fi
|
||||
echo "[i] To publish: gh release upload ccs-bar-latest \"$ZIP\" --clobber"
|
||||
@@ -0,0 +1,209 @@
|
||||
import SwiftUI
|
||||
import CCSBarCore
|
||||
|
||||
/// Demoted spend strip + surface/model breakdown.
|
||||
///
|
||||
/// Spend is informational pool context, NEVER the headline — so the loud 2×2
|
||||
/// StatCell grid + 28pt sparkline that used to dominate the dropdown collapses
|
||||
/// into one muted caption line ("today $NN · 7d $N.Nk") with an optional thin
|
||||
/// inline sparkline. The By-surface / Top-models breakdowns stay, tightened and
|
||||
/// subordinate, below the pool accounts.
|
||||
///
|
||||
/// When the trailing 30 days carry no spend the strip reads the honest idle line
|
||||
/// ("No usage in N days") instead of three dead "$0.00" cells.
|
||||
struct BarAnalyticsView: View {
|
||||
@Environment(\.barTheme) private var theme
|
||||
let analytics: BarAnalytics
|
||||
/// Which slot of the dropdown this instance renders. `.spend` is the thin strip
|
||||
/// placed below the subscriptions cockpit; `.breakdown` is the by-surface /
|
||||
/// top-models detail placed below the pool accounts.
|
||||
enum Section { case spend, breakdown }
|
||||
var section: Section = .spend
|
||||
/// Controls whether the spend sparkline renders as bars or a line graph.
|
||||
/// Passed in by BarMenuView so it reflects the user's persisted choice live.
|
||||
var spendChartStyle: SpendChartStyle = .bars
|
||||
/// Inline flip for the spend chart style, surfaced as a small toggle in the
|
||||
/// Spend header (using the otherwise-blank space) so the user switches
|
||||
/// bars/line in place rather than digging into Settings. nil for `.breakdown`.
|
||||
var onToggleSpendStyle: (() -> Void)? = nil
|
||||
|
||||
private var lastActive: String? {
|
||||
BarFormatting.lastActiveLabel(
|
||||
iso: analytics.lastActivityAt, daysSince: analytics.daysSinceLastActivity)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
switch section {
|
||||
case .spend:
|
||||
spendStrip
|
||||
case .breakdown:
|
||||
breakdown
|
||||
}
|
||||
}
|
||||
|
||||
/// By-surface + top-models detail, tightened and subordinate.
|
||||
@ViewBuilder private var breakdown: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Surface breakdown: "how much Claude Code vs Codex" — only shown when
|
||||
// the backend supplies at least one surface entry. Top 5 keeps it compact.
|
||||
if !analytics.bySurface.isEmpty {
|
||||
SectionLabel("By surface")
|
||||
let peakSurface = analytics.bySurface.map(\.cost).max() ?? 1
|
||||
ForEach(analytics.bySurface.prefix(5)) { surface in
|
||||
SurfaceBar(surface: surface, peak: peakSurface)
|
||||
}
|
||||
}
|
||||
|
||||
// Top models.
|
||||
if !analytics.topModels.isEmpty {
|
||||
let scope = analytics.topModelsWindow == "30d" ? "30d" : "all-time"
|
||||
SectionLabel("Top models · \(scope)")
|
||||
let peak = analytics.topModels.map(\.cost).max() ?? 1
|
||||
ForEach(analytics.topModels.prefix(4)) { model in
|
||||
ModelBar(model: model, peak: peak)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True when there's any surface/model detail worth a divider + section.
|
||||
var hasBreakdown: Bool {
|
||||
!analytics.bySurface.isEmpty || !analytics.topModels.isEmpty
|
||||
}
|
||||
|
||||
/// The collapsed informational spend strip: a "SPEND" label, one muted caption
|
||||
/// line, and a thin inline 30-day sparkline when there is real spend.
|
||||
private var spendStrip: some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(spacing: 6) {
|
||||
SectionLabel("Spend")
|
||||
Spacer()
|
||||
// Inline bars/line toggle in the header's blank space — no Settings trip.
|
||||
if let toggle = onToggleSpendStyle, analytics.hasRecentData, !sparklineIsEmpty {
|
||||
Button(action: toggle) {
|
||||
Image(
|
||||
systemName: spendChartStyle == .bars
|
||||
? "chart.line.uptrend.xyaxis" : "chart.bar.fill"
|
||||
)
|
||||
.font(.system(size: 10))
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.foregroundStyle(.tertiary)
|
||||
.help("Spend graph: switch to \(spendChartStyle == .bars ? "line" : "bars")")
|
||||
}
|
||||
}
|
||||
if analytics.hasRecentData {
|
||||
Text(spendCaption)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
if !sparklineIsEmpty {
|
||||
// height: 30 (up from 18) so daily spend gradations are clearly readable.
|
||||
Sparkline(values: analytics.byDay.map(\.cost), accent: theme.accent,
|
||||
style: spendChartStyle)
|
||||
.frame(height: 30)
|
||||
}
|
||||
} else {
|
||||
Text(idleCaption)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One-line rollup: "today $NN · 7d $N.Nk · 30d $N.Nk".
|
||||
private var spendCaption: String {
|
||||
"today \(BarFormatting.money(analytics.today.cost))"
|
||||
+ " · 7d \(BarFormatting.money(analytics.last7d.cost))"
|
||||
+ " · 30d \(BarFormatting.money(analytics.last30d.cost))"
|
||||
}
|
||||
|
||||
/// Honest idle caption when there's no recent spend, folding in last-active.
|
||||
private var idleCaption: String {
|
||||
let headline =
|
||||
analytics.daysSinceLastActivity.map { "No usage in \($0) days" } ?? "No usage in 30 days"
|
||||
if let lastActive { return "\(headline) · \(lastActive.lowercased())" }
|
||||
return headline
|
||||
}
|
||||
|
||||
private var sparklineIsEmpty: Bool {
|
||||
analytics.byDay.allSatisfy { $0.cost <= 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// One usage-surface row: surface name + proportional accent bar + cost and
|
||||
/// request count. Mirrors ModelBar visually so the two sections feel cohesive.
|
||||
private struct SurfaceBar: View {
|
||||
@Environment(\.barTheme) private var theme
|
||||
let surface: BarAnalyticsSurface
|
||||
let peak: Double
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let fraction = peak > 0 ? CGFloat(surface.cost / peak) : 0
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(theme.accent.opacity(0.16))
|
||||
.frame(width: max(8, geo.size.width * fraction))
|
||||
HStack {
|
||||
Text(surface.surface)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
HStack(spacing: 4) {
|
||||
Text(BarFormatting.count(surface.requests))
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(BarFormatting.money(surface.cost))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
}
|
||||
.frame(height: 26)
|
||||
}
|
||||
}
|
||||
|
||||
/// One top-model row: name + spend with a proportional accent bar behind.
|
||||
private struct ModelBar: View {
|
||||
@Environment(\.barTheme) private var theme
|
||||
let model: BarAnalytics.Model
|
||||
let peak: Double
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let fraction = peak > 0 ? CGFloat(model.cost / peak) : 0
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(theme.accent.opacity(0.16))
|
||||
.frame(width: max(8, geo.size.width * fraction))
|
||||
HStack {
|
||||
Text(model.model)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
Text(BarFormatting.money(model.cost))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
}
|
||||
.frame(height: 26)
|
||||
}
|
||||
}
|
||||
|
||||
/// Uppercase section divider label.
|
||||
struct SectionLabel: View {
|
||||
let text: String
|
||||
init(_ text: String) { self.text = text }
|
||||
var body: some View {
|
||||
Text(text.uppercased())
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
import CCSBarCore
|
||||
|
||||
/// App-side date phrasing for the subscription card. Kept out of Core so the
|
||||
/// shared formatting contract there stays untouched. Parses ISO-8601 the same
|
||||
/// way Core does (with and without fractional seconds) so timestamp handling
|
||||
/// matches the rest of the bar.
|
||||
enum BarCardFormatting {
|
||||
/// Parse an ISO-8601 timestamp, tolerating an optional fractional-seconds
|
||||
/// component. Mirrors Core's parser, which is module-internal there.
|
||||
private static func isoDate(_ iso: String) -> Date? {
|
||||
let withFraction = ISO8601DateFormatter()
|
||||
withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let d = withFraction.date(from: iso) { return d }
|
||||
let plain = ISO8601DateFormatter()
|
||||
plain.formatOptions = [.withInternetDateTime]
|
||||
return plain.date(from: iso)
|
||||
}
|
||||
|
||||
/// Compact reset form for the per-window bar chips and split lines:
|
||||
/// <24h → compact duration via BarQuotaGauge.compactDuration (e.g. "3h 15m", "22m")
|
||||
/// <7d → weekday abbreviation (e.g. "Fri")
|
||||
/// >=7d → calendar date (e.g. "Jun 14")
|
||||
/// Returns nil for a missing/unparseable timestamp (caller omits the clause).
|
||||
static func shortReset(iso: String?, now: Date) -> String? {
|
||||
guard let iso, let date = isoDate(iso) else { return nil }
|
||||
let secs = date.timeIntervalSince(now)
|
||||
if secs <= 0 { return "due" }
|
||||
if secs < 24 * 3600 {
|
||||
// Delegate to Core's authoritative compactDuration so both layers are
|
||||
// consistent and the days-tier is automatically handled if ever needed.
|
||||
let totalMinutes = Int(secs / 60)
|
||||
return BarQuotaGauge.compactDuration(minutes: totalMinutes)
|
||||
}
|
||||
let fmt = DateFormatter()
|
||||
fmt.locale = Locale(identifier: "en_US_POSIX")
|
||||
if secs < 7 * 24 * 3600 {
|
||||
fmt.dateFormat = "EEE" // weekday, e.g. "Fri"
|
||||
} else {
|
||||
fmt.dateFormat = "MMM d" // e.g. "Jun 14"
|
||||
}
|
||||
return fmt.string(from: date)
|
||||
}
|
||||
|
||||
/// Local wall-clock "HH:mm" for the Codex stale footnote, e.g. "13:42".
|
||||
/// Returns nil for a missing/unparseable timestamp.
|
||||
static func clockTime(iso: String?) -> String? {
|
||||
guard let iso, let date = isoDate(iso) else { return nil }
|
||||
let fmt = DateFormatter()
|
||||
fmt.locale = Locale(identifier: "en_US_POSIX")
|
||||
fmt.dateFormat = "HH:mm"
|
||||
return fmt.string(from: date)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import CCSBarCore
|
||||
|
||||
/// Dropdown content for the menu bar: a CCS-branded header, usage analytics,
|
||||
/// per-account rows + actions, an offline state when CCS isn't running, and
|
||||
/// footer controls.
|
||||
struct BarMenuView: View {
|
||||
@ObservedObject var viewModel: BarViewModel
|
||||
/// Resolved theme injected by ThemedRoot — used to tint the armed Quit control
|
||||
/// with the themed red ramp so it matches the dropdown on both plates.
|
||||
@Environment(\.barTheme) private var theme
|
||||
/// Two-step inline quit confirm. First footer-Quit click arms it (icon swaps
|
||||
/// hollow->filled, tints red); second click terminates. Reset on every popover
|
||||
/// open via .onAppear so a stale armed state never carries across sessions —
|
||||
/// no modal, no .confirmationDialog (those steal focus and dismiss the popover,
|
||||
/// the exact fragility of BUG 1).
|
||||
@State private var quitArmed = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
|
||||
if viewModel.offline {
|
||||
offlineState.padding(14)
|
||||
} else {
|
||||
// The scroll indicator is suppressed (.never, not just .hidden) AND the
|
||||
// enclosing NSScrollView's scroller is hard-disabled via ScrollerHider:
|
||||
// inside a MenuBarExtra popover the SwiftUI preference alone is sometimes
|
||||
// ignored and a scroller track steals width + misaligns content. With the
|
||||
// reorder + collapsed spend strip the important rows fit without scrolling
|
||||
// for the common 1-4 subscription setup; the scroll only engages for
|
||||
// genuine pool/model overflow.
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// (1) ALERTS first — urgent quota crossings surface above everything.
|
||||
// Spend-cap alerts are opt-in OFF by default, so by default only
|
||||
// quota/reauth/cooldown conditions appear here.
|
||||
if !viewModel.activeAlerts.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
SectionLabel("Alerts")
|
||||
ForEach(viewModel.activeAlerts) { alert in
|
||||
AlertRow(alert: alert)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (2) SUBSCRIPTIONS — the dominant section, opens here.
|
||||
accountsSection
|
||||
|
||||
// (3) SPEND — demoted to a thin informational strip below the cockpit.
|
||||
// spendChartStyle is threaded from the viewModel and toggled inline
|
||||
// from the Spend header, so a change updates the chart immediately.
|
||||
if let analytics = viewModel.analytics {
|
||||
Divider()
|
||||
BarAnalyticsView(
|
||||
analytics: analytics, section: .spend,
|
||||
spendChartStyle: viewModel.spendChartStyle,
|
||||
onToggleSpendStyle: {
|
||||
viewModel.spendChartStyle =
|
||||
viewModel.spendChartStyle == .bars ? .line : .bars
|
||||
})
|
||||
}
|
||||
|
||||
// (4) POOL ACCOUNTS — compact generic rows, subordinate.
|
||||
poolSection
|
||||
|
||||
// (5) BY-SURFACE / TOP MODELS — tightened detail, below the pool.
|
||||
if let analytics = viewModel.analytics,
|
||||
BarAnalyticsView(analytics: analytics, section: .breakdown).hasBreakdown
|
||||
{
|
||||
BarAnalyticsView(analytics: analytics, section: .breakdown)
|
||||
}
|
||||
|
||||
// Zero-size AppKit bridge that disables the popover's NSScrollView
|
||||
// scroller at runtime (belt-and-suspenders with .scrollIndicators).
|
||||
ScrollerHider().frame(width: 0, height: 0)
|
||||
}
|
||||
.padding(14)
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
// 700 gives more vertical breathing room before scroll engages — useful
|
||||
// for 3-4 subscription cards each carrying multiple quota windows.
|
||||
// Scroll still engages gracefully on genuine overflow.
|
||||
.frame(maxHeight: 700)
|
||||
}
|
||||
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
// 360 is narrower than the old 380, keeping the popover compact while still
|
||||
// fitting the bar-list fixed column widths (label 32 + bar 110 + pct 32 + chip 48).
|
||||
.frame(width: 360)
|
||||
.onAppear {
|
||||
viewModel.onOpen()
|
||||
// Disarm quit on every popover open so a stale armed state never persists.
|
||||
quitArmed = false
|
||||
}
|
||||
}
|
||||
|
||||
/// The cockpit. Native subscriptions (Claude Code / Codex) render as detailed
|
||||
/// `BarSubscriptionCard`s at the very top, ordered tightest-binding-first
|
||||
/// (closest to empty on top) so the window the user is about to run out of
|
||||
/// leads. CLIProxy pool accounts keep the compact generic `BarRowView` below,
|
||||
/// subordinate. The two-section split is suppressed when only one kind is
|
||||
/// present, preserving the single "Accounts" header for a CLIProxy-only setup.
|
||||
@ViewBuilder private var accountsSection: some View {
|
||||
let parts = BarFormatting.partitionSubscriptions(viewModel.rows)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let error = viewModel.lastError {
|
||||
ErrorBanner(message: error)
|
||||
}
|
||||
if viewModel.rows.isEmpty {
|
||||
SectionLabel("Accounts")
|
||||
Text("No accounts configured")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if parts.subscriptions.isEmpty {
|
||||
// CLIProxy-only setup: keep the single established header + generic rows.
|
||||
SectionLabel("Accounts")
|
||||
ForEach(parts.pool) { row in
|
||||
BarRowView(row: row, viewModel: viewModel)
|
||||
}
|
||||
} else {
|
||||
subscriptionsHeader(parts.subscriptions)
|
||||
ForEach(orderedSubscriptions(parts.subscriptions)) { row in
|
||||
BarSubscriptionCard(row: row)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// CLIProxy pool accounts as compact generic rows — subordinate, rendered below
|
||||
/// the spend strip. Suppressed entirely when there are no pool accounts, or
|
||||
/// when there are no subscriptions (the CLIProxy-only path renders pool rows
|
||||
/// under the single "Accounts" header in `accountsSection` instead).
|
||||
@ViewBuilder private var poolSection: some View {
|
||||
let parts = BarFormatting.partitionSubscriptions(viewModel.rows)
|
||||
if !parts.subscriptions.isEmpty && !parts.pool.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
SectionLabel("Pool accounts")
|
||||
ForEach(parts.pool) { row in
|
||||
BarRowView(row: row, viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "SUBSCRIPTIONS" header, with a right-aligned cross-tool headroom hint
|
||||
/// ("most room: <X> NN%") when there are >=2 subscriptions with quota data.
|
||||
/// Falls back to the bare label otherwise.
|
||||
@ViewBuilder private func subscriptionsHeader(_ subs: [BarSummaryRow]) -> some View {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
SectionLabel("Subscriptions")
|
||||
Spacer()
|
||||
if let leader = BarQuotaGauge.headroomLeader(subs) {
|
||||
Text("most room: \(leader.label) \(Int(leader.remainingPercent.rounded()))%")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Order subscription cards by tightest binding window ascending (closest to
|
||||
/// empty on top). Rows with no binding window (error/reauth) sink to the bottom
|
||||
/// so the actionable quota always leads.
|
||||
private func orderedSubscriptions(_ subs: [BarSummaryRow]) -> [BarSummaryRow] {
|
||||
subs.sorted { a, b in
|
||||
let ra = BarQuotaGauge.selectBindingWindow(a.quotaWindows ?? [])?.remainingPercent
|
||||
let rb = BarQuotaGauge.selectBindingWindow(b.quotaWindows ?? [])?.remainingPercent
|
||||
switch (ra, rb) {
|
||||
case let (.some(x), .some(y)):
|
||||
if x != y { return x < y }
|
||||
return (a.displayName ?? a.provider) < (b.displayName ?? b.provider)
|
||||
case (.some, .none):
|
||||
return true // a has quota, b doesn't → a first
|
||||
case (.none, .some):
|
||||
return false
|
||||
case (.none, .none):
|
||||
return (a.displayName ?? a.provider) < (b.displayName ?? b.provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(nsImage: MenuBarIcon.headerImage())
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("CCS").font(.headline)
|
||||
Text("usage & accounts").font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if viewModel.isRefreshing {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
if let v = BarVersionDisplay.string() {
|
||||
Text(v)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private var offlineState: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label("CCS is not running", systemImage: "bolt.slash.fill")
|
||||
.font(.body)
|
||||
Text("Start CCS, then reopen this menu.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Retry") { viewModel.reconnect(); viewModel.onOpen() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
openDashboard()
|
||||
} label: {
|
||||
Label("Dashboard", systemImage: "chart.bar.xaxis")
|
||||
}
|
||||
Button {
|
||||
viewModel.toggleIconStyle()
|
||||
} label: {
|
||||
Label(
|
||||
"Icon",
|
||||
systemImage: viewModel.iconStyle == .color ? "paintpalette" : "circle.lefthalf.filled"
|
||||
)
|
||||
}
|
||||
.help("Toggle the menu-bar icon between color and monochrome (does not change the bar theme)")
|
||||
Button {
|
||||
// Open Settings as a standalone AppKit NSWindow (NOT a .sheet on this
|
||||
// popover). A sheet hosted in a .window-style MenuBarExtra popover pulls
|
||||
// focus off the popover and auto-dismisses the whole bar (BUG 1). The
|
||||
// window opens beside the popover and leaves it untouched.
|
||||
SettingsWindowController.shared.show(viewModel: viewModel)
|
||||
} label: {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
}
|
||||
.help("Settings — appearance/theme, menu-bar glance, and alerts")
|
||||
Spacer()
|
||||
Button {
|
||||
viewModel.onOpen()
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.help("Refresh")
|
||||
// Quit confirms via a two-step INLINE arm/confirm — no modal, no sheet, no
|
||||
// .confirmationDialog. Those all steal focus and auto-dismiss the popover
|
||||
// (the exact fragility of BUG 1). A stray single click can no longer kill
|
||||
// the app: the first click only arms; the popover stays open and responsive.
|
||||
quitButton
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 11)
|
||||
}
|
||||
|
||||
/// Two visual states in one footer slot. Disarmed: hollow power icon that arms
|
||||
/// on click. Armed: filled power icon tinted themed red that terminates on
|
||||
/// click. Reopening the popover disarms it (.onAppear on the root VStack).
|
||||
@ViewBuilder private var quitButton: some View {
|
||||
if !quitArmed {
|
||||
Button {
|
||||
quitArmed = true
|
||||
} label: {
|
||||
Image(systemName: "power")
|
||||
}
|
||||
.help("Quit CCS Bar (click again to confirm)")
|
||||
} else {
|
||||
Button {
|
||||
NSApplication.shared.terminate(nil)
|
||||
} label: {
|
||||
Image(systemName: "power.circle.fill")
|
||||
}
|
||||
.help("Click to confirm quit")
|
||||
.foregroundStyle(theme.bandRed)
|
||||
}
|
||||
}
|
||||
|
||||
private func openDashboard() {
|
||||
// Open the dashboard if the server is up; otherwise start it via `ccs config`.
|
||||
Task { await DashboardLauncher.openOrStart() }
|
||||
}
|
||||
}
|
||||
|
||||
/// One account row — the strongest section of the glance.
|
||||
///
|
||||
/// Top line: health dot, name, default/paused/reauth badges. Subline: provider +
|
||||
/// tier chips, the honest tri-state quota label (NN% / "no quota" / "quota ?"),
|
||||
/// and a per-account "Last active <date>" caption. Trailing: today's cost (or a
|
||||
/// muted "no data" when unknown vs a real "$0.00"), a visible pause/resume
|
||||
/// toggle, and the overflow menu (set-default / solo / tier-lock).
|
||||
struct BarRowView: View {
|
||||
@Environment(\.barTheme) private var theme
|
||||
let row: BarSummaryRow
|
||||
@ObservedObject var viewModel: BarViewModel
|
||||
|
||||
/// A native first-party subscription (Claude Code / Codex) — drives the
|
||||
/// distinct "subscription" badge + indigo provider chip.
|
||||
private var isNativeSubscription: Bool {
|
||||
BarFormatting.isNativeSubscription(provider: row.provider)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Circle()
|
||||
.fill(healthColor)
|
||||
.frame(width: 8, height: 8)
|
||||
.padding(.top, 5)
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(spacing: 7) {
|
||||
Text(row.displayName ?? row.accountId)
|
||||
.font(.system(.body, design: .default).weight(.medium))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
if row.isDefault {
|
||||
Chip("default", tint: theme.accent)
|
||||
}
|
||||
if row.paused {
|
||||
Chip("paused", tint: .secondary)
|
||||
}
|
||||
if row.needsReauth {
|
||||
Chip("reauth", tint: theme.bandRed)
|
||||
}
|
||||
if isNativeSubscription {
|
||||
Chip("subscription", tint: theme.subscription)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
Chip(
|
||||
BarFormatting.providerLabel(row.provider),
|
||||
tint: isNativeSubscription ? theme.subscription : theme.accent)
|
||||
if let tier = row.tier { Chip(tier, tint: .secondary) }
|
||||
QuotaGaugeView(
|
||||
percentage: row.quotaPercentage,
|
||||
status: row.quotaStatus,
|
||||
nextReset: row.nextReset)
|
||||
}
|
||||
if let lastActive = BarFormatting.lastActiveLabel(
|
||||
iso: row.lastActivityAt, daysSince: nil)
|
||||
{
|
||||
Text(lastActive)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 4)
|
||||
|
||||
VStack(alignment: .trailing, spacing: 3) {
|
||||
costView
|
||||
HStack(spacing: 2) {
|
||||
pauseToggle
|
||||
overflowMenu
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 10)
|
||||
.background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
/// Today's cost: a real "$x.xx" when known (including a genuine $0.00), a muted
|
||||
/// "no data" when the value is null (no usage record on a possibly-stale snapshot).
|
||||
@ViewBuilder private var costView: some View {
|
||||
if let cost = row.todayCost {
|
||||
Text(BarFormatting.money(cost))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("no data")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
|
||||
/// Visible primary action: one tap to pause or resume the account.
|
||||
private var pauseToggle: some View {
|
||||
Button {
|
||||
if row.paused { viewModel.resume(row) } else { viewModel.pause(row) }
|
||||
} label: {
|
||||
Image(systemName: row.paused ? "play.circle" : "pause.circle")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help(row.paused ? "Resume account" : "Pause account")
|
||||
}
|
||||
|
||||
private var overflowMenu: some View {
|
||||
Menu {
|
||||
Button("Set as default") { viewModel.setDefault(row) }
|
||||
Button("Solo (pause others)") { viewModel.solo(row) }
|
||||
Divider()
|
||||
if let tier = row.tier {
|
||||
Button("Lock to \(tier)") { viewModel.tierLock(row, tier: tier) }
|
||||
}
|
||||
Button("Clear tier lock") { viewModel.tierLock(row, tier: nil) }
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.menuIndicator(.hidden)
|
||||
.frame(width: 24)
|
||||
}
|
||||
|
||||
/// Health dot. With the corrected backend, "unsupported" providers (ghcp/kiro)
|
||||
/// arrive as health "ok" (green) — no permanent orange dot. Orange is reserved
|
||||
/// for genuine transient fetch failures, red for accounts needing reauth.
|
||||
private var healthColor: Color {
|
||||
// Use the themed band ramp (not raw system .red/.orange/.green) so the dot
|
||||
// matches the rest of the dropdown and stays legible on both plates.
|
||||
switch row.health {
|
||||
case "error": return theme.bandRed
|
||||
case "warning": return theme.bandAmber
|
||||
default: return theme.bandGreen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-account quota gauge. When the row has a live "ok" quota with a percentage,
|
||||
/// renders a thin colored bar (filled by the remaining fraction, tinted by the
|
||||
/// severity band) plus a "resets in …" caption. When there is no live quota it
|
||||
/// falls back to the honest text label ("no quota" / "quota ?"). All branch,
|
||||
/// color, and countdown logic lives in the pure Core `BarQuotaGauge`; this view
|
||||
/// is a thin render.
|
||||
struct QuotaGaugeView: View {
|
||||
@Environment(\.barTheme) private var theme
|
||||
let percentage: Double?
|
||||
let status: String
|
||||
let nextReset: String?
|
||||
|
||||
var body: some View {
|
||||
let band = BarQuotaGauge.band(percentage: percentage, status: status)
|
||||
if band != .none, let fill = BarQuotaGauge.fillFraction(percentage: percentage, status: status) {
|
||||
HStack(spacing: 5) {
|
||||
gaugeBar(fill: fill, color: color(for: band))
|
||||
Text(BarFormatting.quotaLabel(percentage: percentage, status: status))
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(color(for: band))
|
||||
if let countdown = BarQuotaGauge.resetCountdown(nextReset: nextReset, now: Date()) {
|
||||
Text(countdown)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No live quota: keep the existing honest text ("no quota" / "quota ?").
|
||||
Text(BarFormatting.quotaLabel(percentage: percentage, status: status))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func gaugeBar(fill: Double, color: Color) -> some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(Color.primary.opacity(0.12))
|
||||
Capsule()
|
||||
.fill(color)
|
||||
.frame(width: max(2, geo.size.width * fill))
|
||||
}
|
||||
}
|
||||
.frame(width: 54, height: 6)
|
||||
}
|
||||
|
||||
private func color(for band: BarQuotaGauge.Band) -> Color {
|
||||
// Themed band ramp for whole-dropdown consistency. .orange maps to the coral
|
||||
// band (the warning step in the green→amber→coral→red ramp) so it stays
|
||||
// distinct from the brand accent orange on both plates.
|
||||
switch band {
|
||||
case .green: return theme.bandGreen
|
||||
case .yellow: return theme.bandAmber
|
||||
case .orange: return theme.bandCoral
|
||||
case .red: return theme.bandRed
|
||||
case .none: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline banner surfacing the last failed action so it is visible rather than
|
||||
/// silently swallowed. Success is confirmed by the default/paused badge updating.
|
||||
struct ErrorBanner: View {
|
||||
@Environment(\.barTheme) private var theme
|
||||
let message: String
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(theme.accent)
|
||||
Text(message)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
.padding(.vertical, 5)
|
||||
.padding(.horizontal, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(theme.accent.opacity(0.10), in: RoundedRectangle(cornerRadius: 7))
|
||||
}
|
||||
}
|
||||
|
||||
/// One in-dropdown alert row. Mirrors a delivered notification so the conditions
|
||||
/// are visible even when system notifications are denied. The icon is keyed off
|
||||
/// the alert kind so each rule reads at a glance.
|
||||
struct AlertRow: View {
|
||||
@Environment(\.barTheme) private var theme
|
||||
let alert: BarNotification
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(tint)
|
||||
.font(.caption)
|
||||
.padding(.top, 1)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(alert.title)
|
||||
.font(.caption.weight(.medium))
|
||||
Text(alert.body)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.vertical, 5)
|
||||
.padding(.horizontal, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(tint.opacity(0.10), in: RoundedRectangle(cornerRadius: 7))
|
||||
}
|
||||
|
||||
private var icon: String {
|
||||
switch alert.kind {
|
||||
case .quotaRemainingBelow: return "gauge.with.dots.needle.bottom.0percent"
|
||||
case .dailySpendAbove, .monthSpendAbove: return "dollarsign.circle"
|
||||
case .reauthNeeded: return "key.slash"
|
||||
case .accountCooldownOrPaused: return "pause.circle"
|
||||
}
|
||||
}
|
||||
|
||||
private var tint: Color {
|
||||
// Themed: quota warnings take the brand accent, reauth the critical band,
|
||||
// so alert chips match the rest of the dropdown on both plates.
|
||||
switch alert.kind {
|
||||
case .quotaRemainingBelow: return theme.accent
|
||||
case .dailySpendAbove, .monthSpendAbove: return theme.accent
|
||||
case .reauthNeeded: return theme.bandRed
|
||||
case .accountCooldownOrPaused: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Small pill label used in account sublines.
|
||||
struct Chip: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let text: String
|
||||
let tint: Color
|
||||
init(_ text: String, tint: Color) {
|
||||
self.text = text
|
||||
self.tint = tint
|
||||
}
|
||||
/// Lift the small 9pt label toward the opposite of the surface so it stays
|
||||
/// legible: toward white on the dark plate (the raw indigo subscription tint
|
||||
/// was too dim to read), toward black on the light plate (lifting toward white
|
||||
/// there would wash the text out). The forced scheme is already in effect on
|
||||
/// this subtree, so `colorScheme` reflects exactly the plate being drawn.
|
||||
private var textColor: Color {
|
||||
if tint == .secondary { return .secondary }
|
||||
let target: NSColor = (colorScheme == .light) ? .black : .white
|
||||
let lifted = NSColor(tint).blended(withFraction: 0.5, of: target) ?? NSColor(tint)
|
||||
return Color(nsColor: lifted)
|
||||
}
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1.5)
|
||||
.background(tint.opacity(0.22), in: Capsule())
|
||||
.foregroundStyle(textColor)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
import CCSBarCore
|
||||
|
||||
/// Real notification delivery backed by `UNUserNotificationCenter`.
|
||||
///
|
||||
/// Authorization is requested LAZILY on the first non-empty deliver, not at
|
||||
/// launch. Ad-hoc-signed menu-bar apps launched via `open` get a flaky / silently
|
||||
/// dropped prompt when authorization is requested during startup, so we defer the
|
||||
/// request until there is actually something to show — the prompt then lands with
|
||||
/// user-visible context.
|
||||
///
|
||||
/// When authorization is denied, `deliver` is a no-op, but the rule engine keeps
|
||||
/// updating its fired-keys regardless (the App persists them independent of
|
||||
/// delivery). That avoids a backlog of stale alerts replaying if the user later
|
||||
/// grants permission — only conditions still true at that later poll re-fire.
|
||||
@MainActor
|
||||
final class BarNotifier: NotificationDelivering {
|
||||
enum AuthState {
|
||||
case unknown
|
||||
case authorized
|
||||
case denied
|
||||
}
|
||||
|
||||
private let center: UNUserNotificationCenter?
|
||||
private var authState: AuthState = .unknown
|
||||
private var didRequest = false
|
||||
|
||||
/// `UNUserNotificationCenter.current()` traps when there is no main bundle
|
||||
/// identifier (e.g. a bare `swift run` with no .app wrapper). Guard it so the
|
||||
/// wiring still compiles and runs headlessly; delivery is simply a no-op there.
|
||||
init() {
|
||||
if Bundle.main.bundleIdentifier != nil {
|
||||
center = UNUserNotificationCenter.current()
|
||||
} else {
|
||||
center = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Deliver one notification. The first call with a notification triggers a
|
||||
/// one-time authorization request; subsequent calls reuse the cached state.
|
||||
nonisolated func deliver(_ notification: BarNotification) {
|
||||
Task { @MainActor in
|
||||
self.send(notification)
|
||||
}
|
||||
}
|
||||
|
||||
private func send(_ notification: BarNotification) {
|
||||
guard let center else { return }
|
||||
|
||||
if !didRequest {
|
||||
didRequest = true
|
||||
// Request once, lazily. The completion updates cached state; this in-flight
|
||||
// notification is enqueued after, so an accepted prompt still shows it.
|
||||
center.requestAuthorization(options: [.alert, .sound]) { [weak self] granted, _ in
|
||||
// Hop back to the main actor and re-read `self.center` there rather than
|
||||
// capturing the non-Sendable center across the closure boundary.
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.authState = granted ? .authorized : .denied
|
||||
if granted, let c = self.center { self.post(notification, on: c) }
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Already requested: post when authorized or still-unknown; a denied state is a no-op.
|
||||
if authState == .authorized || authState == .unknown {
|
||||
post(notification, on: center)
|
||||
}
|
||||
}
|
||||
|
||||
private func post(_ notification: BarNotification, on center: UNUserNotificationCenter) {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = notification.title
|
||||
content.body = notification.body
|
||||
content.sound = .default
|
||||
// Identifier == fired-key so the OS de-dupes at the delivery layer too: a
|
||||
// re-posted same-key request replaces rather than stacks.
|
||||
let request = UNNotificationRequest(
|
||||
identifier: notification.id, content: content, trigger: nil)
|
||||
center.add(request, withCompletionHandler: nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
import CCSBarCore
|
||||
|
||||
/// Live UserDefaults adapter for the alert/glance preferences. The pure
|
||||
/// key/default/parse contract lives in Core (`BarAlertPrefsStore`); this type is
|
||||
/// the thin App-side bridge that reads/writes the real defaults suite.
|
||||
///
|
||||
/// `register(defaults:)` is called once at launch so that an absent Bool key does
|
||||
/// NOT read back as `false` (which would silently disable every alert on a fresh
|
||||
/// install). All reads go through `load()`; all writes go through `save(_:)`.
|
||||
struct BarPreferences {
|
||||
let defaults: UserDefaults
|
||||
|
||||
/// Default to the standard suite. A stable suite name is intentionally NOT used
|
||||
/// here because the rest of the app (MenuBarIcon) already persists to
|
||||
/// `.standard`; keeping one suite avoids split state across the two.
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
/// Seed the registration domain so missing keys resolve to their real defaults
|
||||
/// rather than the type-zero value. Idempotent — safe to call on every launch.
|
||||
///
|
||||
/// The two pay-per-use spend-cap alerts default to OFF (opt-in): subscriptions
|
||||
/// are flat-rate, so a spend alert on them is meaningless, and pool spend is
|
||||
/// informational context, never a default-on alert. Quota / reauth / cooldown
|
||||
/// stay default-on (the quota-first alert set). The caps themselves keep Core's
|
||||
/// sane values so a user who opts in starts with $500 / $10000.
|
||||
func registerDefaults() {
|
||||
var d = BarAlertPrefsStore.registrationDefaults
|
||||
d[BarAlertPrefsStore.Key.dailyEnabled] = false
|
||||
d[BarAlertPrefsStore.Key.monthEnabled] = false
|
||||
defaults.register(defaults: d)
|
||||
}
|
||||
|
||||
/// Read the current preferences. Pulls each key into a plain dictionary and
|
||||
/// defers to the pure Core decoder so parsing/clamping stays in one place.
|
||||
func load() -> BarAlertPrefs {
|
||||
var dict: [String: Any] = [:]
|
||||
// Only forward keys that are actually present; the Core decoder fills the
|
||||
// rest from canonical defaults. `object(forKey:)` returns the registered
|
||||
// default when nothing was explicitly written, which is exactly what we want.
|
||||
dict[BarAlertPrefsStore.Key.quotaEnabled] = defaults.object(forKey: BarAlertPrefsStore.Key.quotaEnabled)
|
||||
dict[BarAlertPrefsStore.Key.quotaLevels] = defaults.object(forKey: BarAlertPrefsStore.Key.quotaLevels)
|
||||
dict[BarAlertPrefsStore.Key.dailyEnabled] = defaults.object(forKey: BarAlertPrefsStore.Key.dailyEnabled)
|
||||
dict[BarAlertPrefsStore.Key.dailyCapUSD] = defaults.object(forKey: BarAlertPrefsStore.Key.dailyCapUSD)
|
||||
dict[BarAlertPrefsStore.Key.monthEnabled] = defaults.object(forKey: BarAlertPrefsStore.Key.monthEnabled)
|
||||
dict[BarAlertPrefsStore.Key.monthCapUSD] = defaults.object(forKey: BarAlertPrefsStore.Key.monthCapUSD)
|
||||
dict[BarAlertPrefsStore.Key.reauthEnabled] = defaults.object(forKey: BarAlertPrefsStore.Key.reauthEnabled)
|
||||
dict[BarAlertPrefsStore.Key.cooldownPausedEnabled] =
|
||||
defaults.object(forKey: BarAlertPrefsStore.Key.cooldownPausedEnabled)
|
||||
dict[BarAlertPrefsStore.Key.glanceMode] = defaults.object(forKey: BarAlertPrefsStore.Key.glanceMode)
|
||||
return BarAlertPrefsStore.decode(from: dict.compactMapValues { $0 })
|
||||
}
|
||||
|
||||
/// Persist the preferences, encoding levels comma-joined and mode as raw value.
|
||||
func save(_ prefs: BarAlertPrefs) {
|
||||
for (key, value) in BarAlertPrefsStore.encode(prefs) {
|
||||
defaults.set(value, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Fired-key engine state (NOT user-editable)
|
||||
|
||||
/// The engine's persisted fired-key set. Overwritten verbatim each poll with
|
||||
/// `BarAlertEvaluation.firedKeys` so the stored set stays bounded.
|
||||
var firedKeys: Set<String> {
|
||||
get { Set(defaults.stringArray(forKey: BarAlertPrefsStore.Key.firedKeys) ?? []) }
|
||||
nonmutating set { defaults.set(Array(newValue), forKey: BarAlertPrefsStore.Key.firedKeys) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import SwiftUI
|
||||
import CCSBarCore
|
||||
|
||||
/// Preferences sheet reachable from the dropdown footer. Lets the user pick the
|
||||
/// menu-bar glance mode and toggle / tune each alert rule. Writes through to
|
||||
/// UserDefaults on every change and tells the view model to re-read prefs so the
|
||||
/// title updates live and the next poll re-evaluates with the new settings.
|
||||
struct BarPreferencesView: View {
|
||||
@ObservedObject var viewModel: BarViewModel
|
||||
let prefs: BarPreferences
|
||||
@Environment(\.barTheme) private var theme
|
||||
|
||||
// Local editable mirror of the persisted prefs. Loaded on appear; each change
|
||||
// is written through immediately so there is no separate "save" step.
|
||||
@State private var draft = BarAlertPrefs()
|
||||
// Quota levels are edited as free text and parsed on commit; keeping the raw
|
||||
// string in @State avoids fighting the user's keystrokes mid-edit.
|
||||
@State private var levelsText = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
Form {
|
||||
appearanceSection
|
||||
glanceSection
|
||||
quotaSection
|
||||
spendSection
|
||||
accountSection
|
||||
deliveryHint
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
// Fill the hosting window responsively (it is a real resizable NSWindow now,
|
||||
// not a fixed 360x460 sheet) so there are no dead margins and resizing works.
|
||||
.frame(minWidth: 420, idealWidth: 460, maxWidth: .infinity,
|
||||
minHeight: 520, idealHeight: 600, maxHeight: .infinity)
|
||||
.onAppear(perform: hydrate)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "bell.badge")
|
||||
.foregroundStyle(theme.accent)
|
||||
Text("Alerts & Glance").font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
/// Theme + chart style. Appearance affects the whole dropdown; chart style is
|
||||
/// scoped to the spend sparkline. Both are bound directly to viewModel properties
|
||||
/// whose `didSet` persist — no writeThrough() needed here.
|
||||
private var appearanceSection: some View {
|
||||
Section("Appearance") {
|
||||
Picker("Menu bar theme", selection: $viewModel.appearance) {
|
||||
Text("System").tag(BarAppearance.system)
|
||||
Text("Light").tag(BarAppearance.light)
|
||||
Text("Dark").tag(BarAppearance.dark)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
// Spend graph bars/line is toggled inline in the dropdown's Spend header,
|
||||
// not here — kept out of Settings so the choice lives where the chart is.
|
||||
}
|
||||
}
|
||||
|
||||
private var glanceSection: some View {
|
||||
Section("Menu-bar glance") {
|
||||
Picker("Show in menu bar", selection: $draft.glanceMode) {
|
||||
ForEach(BarGlanceMode.allCases, id: \.self) { mode in
|
||||
Text(glanceLabel(mode)).tag(mode)
|
||||
}
|
||||
}
|
||||
.onChange(of: draft.glanceMode) { _ in writeThrough() }
|
||||
}
|
||||
}
|
||||
|
||||
private var quotaSection: some View {
|
||||
Section("Quota") {
|
||||
Toggle("Alert on low quota", isOn: $draft.quotaEnabled)
|
||||
.onChange(of: draft.quotaEnabled) { _ in writeThrough() }
|
||||
HStack {
|
||||
Text("Levels (%)")
|
||||
Spacer()
|
||||
TextField("20,10,0", text: $levelsText)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(width: 120)
|
||||
.onSubmit { commitLevels() }
|
||||
}
|
||||
.disabled(!draft.quotaEnabled)
|
||||
Text("Fires once per account at the most-severe level crossed, then again after the next quota reset.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pay-per-use spend caps. Opt-in (OFF by default) and labelled for pool
|
||||
/// accounts, NOT subscriptions: flat-rate subscription plans have no spend to
|
||||
/// cap, so these alerts only make sense for metered pool usage.
|
||||
private var spendSection: some View {
|
||||
Section("Opt-in · pay-per-use spend") {
|
||||
Toggle("Daily spend cap (pool accounts)", isOn: $draft.dailySpendEnabled)
|
||||
.onChange(of: draft.dailySpendEnabled) { _ in writeThrough() }
|
||||
capRow(label: "Daily cap", value: $draft.dailyCapUSD, enabled: draft.dailySpendEnabled)
|
||||
|
||||
Toggle("Monthly spend cap (pool accounts)", isOn: $draft.monthSpendEnabled)
|
||||
.onChange(of: draft.monthSpendEnabled) { _ in writeThrough() }
|
||||
capRow(label: "Month cap", value: $draft.monthCapUSD, enabled: draft.monthSpendEnabled)
|
||||
Text("Subscriptions are flat-rate and unaffected. These caps only watch metered pay-per-use pool spend, and are off until you enable them.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var accountSection: some View {
|
||||
Section("Account state") {
|
||||
Toggle("Alert when an account needs re-auth", isOn: $draft.reauthEnabled)
|
||||
.onChange(of: draft.reauthEnabled) { _ in writeThrough() }
|
||||
Toggle("Alert when an account is paused / cooling down", isOn: $draft.cooldownPausedEnabled)
|
||||
.onChange(of: draft.cooldownPausedEnabled) { _ in writeThrough() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Always-present delivery note. We don't synchronously read the live UN
|
||||
/// authorization state here (it's async and ad-hoc signing makes the prompt
|
||||
/// land late), so rather than a flickering "denied" branch we tell the user
|
||||
/// where alerts surface either way: as system notifications when allowed, and
|
||||
/// always in the in-menu alert list.
|
||||
private var deliveryHint: some View {
|
||||
Section {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Alerts show as system notifications when allowed (System Settings › Notifications) and always appear in the menu's Alerts list.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func capRow(label: String, value: Binding<Double>, enabled: Bool) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
Spacer()
|
||||
Text("$")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("0", value: value, format: .number)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(width: 90)
|
||||
.onSubmit { writeThrough() }
|
||||
.onChange(of: value.wrappedValue) { _ in writeThrough() }
|
||||
}
|
||||
.disabled(!enabled)
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Done") { commitLevels(); SettingsWindowController.shared.close() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
// MARK: State plumbing
|
||||
|
||||
private func hydrate() {
|
||||
draft = prefs.load()
|
||||
levelsText = BarAlertPrefsStore.encodeQuotaLevels(draft.quotaLevels)
|
||||
}
|
||||
|
||||
/// Parse the free-text levels field into normalized ints, then persist.
|
||||
private func commitLevels() {
|
||||
let parsed = BarAlertPrefsStore.parseQuotaLevels(levelsText)
|
||||
if !parsed.isEmpty { draft.quotaLevels = parsed }
|
||||
// Reflect the normalized form back into the field so the user sees what stuck.
|
||||
levelsText = BarAlertPrefsStore.encodeQuotaLevels(draft.quotaLevels)
|
||||
writeThrough()
|
||||
}
|
||||
|
||||
/// Save the current draft and ask the view model to re-read it so the live
|
||||
/// title + next evaluation pick up the change.
|
||||
private func writeThrough() {
|
||||
prefs.save(draft)
|
||||
viewModel.reloadPrefs()
|
||||
}
|
||||
|
||||
private func glanceLabel(_ mode: BarGlanceMode) -> String {
|
||||
switch mode {
|
||||
case .auto: return "Auto (smart)"
|
||||
case .todaySpend: return "Today's spend"
|
||||
case .monthSpend: return "This month's spend"
|
||||
case .lowestQuota: return "Lowest quota"
|
||||
case .accountCount: return "Active account count"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import SwiftUI
|
||||
import CCSBarCore
|
||||
|
||||
/// Dedicated card for a first-party subscription (Claude Code / Codex).
|
||||
///
|
||||
/// Design goal: bar-first, glanceable in under a second. Each quota window is
|
||||
/// rendered as an aligned row:
|
||||
/// <label> [████████░░░░] 41% 9h 2m
|
||||
///
|
||||
/// The binding window (the one the subscription runs out of first) is
|
||||
/// highlighted and is the only place where the at-risk pace warning appears.
|
||||
/// Verbose prose lines ("week window · resets in ...") are removed entirely.
|
||||
struct BarSubscriptionCard: View {
|
||||
@Environment(\.barTheme) private var theme
|
||||
let row: BarSummaryRow
|
||||
/// Injected clock — defaults to live Date() in production, pinned in previews
|
||||
/// and tests so countdown math is deterministic.
|
||||
var now: Date = Date()
|
||||
|
||||
private var windows: [QuotaWindowDetail] { row.quotaWindows ?? [] }
|
||||
|
||||
private var binding: QuotaWindowDetail? {
|
||||
BarQuotaGauge.selectBindingWindow(windows)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// spacing: 4 (down from 6) and vertical padding: 8 (down from 11) keep the
|
||||
// card compact so 2-3 cards fit in the dropdown without triggering scroll.
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
titleRow
|
||||
if windows.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
windowBarList
|
||||
staleFootnote
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
theme.cardSurface,
|
||||
in: RoundedRectangle(cornerRadius: 9))
|
||||
}
|
||||
|
||||
// MARK: Title row
|
||||
|
||||
/// Health dot + product name + reauth chip + tier chip. No pause toggle —
|
||||
/// subscriptions are not routable pool accounts.
|
||||
private var titleRow: some View {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(healthColor)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(BarFormatting.providerLabel(row.provider))
|
||||
.font(.system(.body, design: .default).weight(.semibold))
|
||||
.lineLimit(1)
|
||||
if row.needsReauth {
|
||||
Chip("reauth", tint: theme.bandRed)
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
if let tier = row.tier {
|
||||
Chip(tier, tint: theme.subscription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Window bar list
|
||||
|
||||
/// All quota windows rendered as aligned bar rows, binding window highlighted.
|
||||
private var windowBarList: some View {
|
||||
// Ordered display: core windows first (5h, week), then Opus/Sonnet.
|
||||
let ordered = orderedWindows
|
||||
let bindingKey = binding?.key
|
||||
|
||||
// spacing: 4 (down from 5) — rows are already compact; tighter gap fits more
|
||||
// without hurting legibility.
|
||||
return VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(ordered) { w in
|
||||
windowBarRow(w, isBinding: w.key == bindingKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable display order: five_hour → seven_day → seven_day_opus → seven_day_sonnet.
|
||||
private var orderedWindows: [QuotaWindowDetail] {
|
||||
windows.sorted { a, b in
|
||||
keyRank(a.key) < keyRank(b.key)
|
||||
}
|
||||
}
|
||||
|
||||
private func keyRank(_ key: String) -> Int {
|
||||
switch key {
|
||||
case "five_hour": return 0
|
||||
case "seven_day": return 1
|
||||
case "seven_day_opus": return 2
|
||||
case "seven_day_sonnet": return 3
|
||||
default: return 4
|
||||
}
|
||||
}
|
||||
|
||||
/// One bar row: short label | bar | remaining% | reset chip | [atRisk warning].
|
||||
///
|
||||
/// Layout uses fixed column widths so bars across rows align vertically,
|
||||
/// making headroom comparisons instant.
|
||||
private func windowBarRow(_ w: QuotaWindowDetail, isBinding: Bool) -> some View {
|
||||
let band = BarQuotaGauge.band(percentage: w.remainingPercent, status: "ok")
|
||||
let fill = BarQuotaGauge.fillFraction(percentage: w.remainingPercent, status: "ok") ?? 0
|
||||
let barColor = color(for: band)
|
||||
let isAtRisk = isBinding && BarQuotaGauge.atRisk(
|
||||
usedPercent: w.usedPercent,
|
||||
remainingPercent: w.remainingPercent,
|
||||
resetAt: w.resetAt,
|
||||
windowMinutes: w.windowMinutes,
|
||||
now: now)
|
||||
|
||||
return HStack(spacing: 0) {
|
||||
// Short label: max 5 chars to keep alignment tight.
|
||||
Text(shortLabel(for: w))
|
||||
.font(
|
||||
isBinding
|
||||
? .system(.caption2, design: .monospaced).weight(.semibold)
|
||||
: .system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(isBinding ? .primary : .secondary)
|
||||
.frame(width: 32, alignment: .leading)
|
||||
|
||||
// Horizontal fill bar — wider than the old secondary thinBar so fine
|
||||
// gradations are visible. Remaining fraction fills from the left so a
|
||||
// full bar = healthy, an empty bar = exhausted.
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Color.primary.opacity(isBinding ? 0.14 : 0.09))
|
||||
Capsule()
|
||||
.fill(barColor)
|
||||
.frame(width: max(2, 110 * fill))
|
||||
}
|
||||
.frame(width: 110, height: isBinding ? 7 : 5)
|
||||
|
||||
Spacer(minLength: 5)
|
||||
|
||||
// Remaining percentage — monospaced so digits are column-stable.
|
||||
Text("\(Int(w.remainingPercent.rounded()))%")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(isBinding ? barColor : .secondary)
|
||||
.frame(width: 32, alignment: .trailing)
|
||||
|
||||
Spacer(minLength: 5)
|
||||
|
||||
// Compact reset chip — terse duration or calendar date.
|
||||
resetChip(for: w, isBinding: isBinding)
|
||||
|
||||
// At-risk warning: shown only on the binding window when pace says we
|
||||
// will exhaust before the reset. Kept compact (⚠ + duration) so the
|
||||
// row does not blow out to a second line.
|
||||
if isAtRisk, let pace = paceWarningText(for: w) {
|
||||
Text(pace)
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(theme.bandCoral)
|
||||
.lineLimit(1)
|
||||
.padding(.leading, 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Terse window label for the bar list, at most 4-5 chars:
|
||||
/// five_hour → "5h"
|
||||
/// seven_day → "wk"
|
||||
/// seven_day_opus → "Opus"
|
||||
/// seven_day_sonnet → "Son"
|
||||
///
|
||||
/// Fall back to the backend-supplied label truncated to 5 chars so unknown
|
||||
/// future keys still render acceptably.
|
||||
private func shortLabel(for w: QuotaWindowDetail) -> String {
|
||||
switch w.key {
|
||||
case "five_hour": return "5h"
|
||||
case "seven_day": return "wk"
|
||||
case "seven_day_opus": return "Opus"
|
||||
case "seven_day_sonnet": return "Son"
|
||||
default:
|
||||
let s = w.label
|
||||
return s.count <= 5 ? s : String(s.prefix(4)) + "…"
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact reset chip: muted small text showing how long until the window
|
||||
/// refreshes. Uses BarCardFormatting.shortReset which delegates to Core's
|
||||
/// compactDuration for <24h durations (days-tier included).
|
||||
private func resetChip(for w: QuotaWindowDetail, isBinding: Bool) -> some View {
|
||||
Group {
|
||||
if let t = BarCardFormatting.shortReset(iso: w.resetAt, now: now) {
|
||||
Text(t)
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(isBinding ? .secondary : .tertiary)
|
||||
}
|
||||
}
|
||||
.frame(width: 48, alignment: .trailing)
|
||||
}
|
||||
|
||||
/// Extract the "~Th Mm" part from paceClause for the at-risk inline warning.
|
||||
/// Returns nil when paceClause returns nil or the limit-reached path fires
|
||||
/// (those are handled by the bar color, not an extra label).
|
||||
private func paceWarningText(for w: QuotaWindowDetail) -> String? {
|
||||
guard let clause = BarQuotaGauge.paceClause(
|
||||
usedPercent: w.usedPercent,
|
||||
remainingPercent: w.remainingPercent,
|
||||
resetAt: w.resetAt,
|
||||
windowMinutes: w.windowMinutes,
|
||||
status: row.quotaStatus,
|
||||
now: now),
|
||||
clause.hasPrefix("~")
|
||||
else { return nil }
|
||||
// Strip "left at this pace" suffix to keep the inline chip terse.
|
||||
// "~2h 30m left at this pace" → "⚠ ~2h 30m"
|
||||
let core = clause
|
||||
.replacingOccurrences(of: " left at this pace", with: "")
|
||||
return "⚠ \(core)"
|
||||
}
|
||||
|
||||
// MARK: Stale footnote (Codex older-session data)
|
||||
|
||||
/// "as of HH:mm (older session)" caption when the Codex reading came from an
|
||||
/// older session. The bar still renders — the data is real, just not live.
|
||||
@ViewBuilder private var staleFootnote: some View {
|
||||
if let stale = row.staleAsOf, let clock = BarCardFormatting.clockTime(iso: stale) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text("as of \(clock), older session")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Empty / error state
|
||||
|
||||
/// No quota windows (reauth / error row): plain status text, no bar.
|
||||
private var emptyState: some View {
|
||||
Text(
|
||||
row.needsReauth
|
||||
? "reauth needed"
|
||||
: BarFormatting.quotaLabel(percentage: row.quotaPercentage, status: row.quotaStatus)
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
// MARK: Shared helpers
|
||||
|
||||
private var healthColor: Color {
|
||||
switch row.health {
|
||||
case "error": return theme.bandRed
|
||||
case "warning": return theme.bandAmber
|
||||
default: return theme.bandGreen
|
||||
}
|
||||
}
|
||||
|
||||
private func color(for band: BarQuotaGauge.Band) -> Color {
|
||||
switch band {
|
||||
case .green: return theme.bandGreen
|
||||
case .yellow: return theme.bandAmber
|
||||
case .orange: return theme.bandCoral
|
||||
case .red: return theme.bandRed
|
||||
case .none: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import CCSBarCore
|
||||
|
||||
/// Observable state for the menu bar. Holds the last-known rows for instant
|
||||
/// paint, reconnects to the CCS web-server via the discovery file, and fires a
|
||||
/// debounced force-refresh when the menu opens.
|
||||
@MainActor
|
||||
final class BarViewModel: ObservableObject {
|
||||
@Published var rows: [BarSummaryRow] = []
|
||||
@Published var analytics: BarAnalytics?
|
||||
@Published var offline = false
|
||||
@Published var lastError: String?
|
||||
@Published var isRefreshing = false
|
||||
@Published var iconStyle: BarIconStyle {
|
||||
didSet { MenuBarIcon.saveStyle(iconStyle) }
|
||||
}
|
||||
/// User-selected dropdown theme (System / Light / Dark). Global chrome, not an
|
||||
/// alert pref, so it persists on its own key and bypasses the draft/writeThrough
|
||||
/// path. Being @Published makes the MenuBarExtra re-render `ThemedRoot` the
|
||||
/// instant it changes, giving a live theme switch.
|
||||
@Published var appearance: BarAppearance {
|
||||
didSet { BarAppearanceStore.save(appearance) }
|
||||
}
|
||||
/// Which figure leads the always-on title. Persisted; a change re-derives
|
||||
/// `statusTitle` live because it is @Published.
|
||||
@Published var glanceMode: BarGlanceMode
|
||||
/// Render style for the spend sparkline (bars or line). Persisted via
|
||||
/// SpendChartStyleStore; didSet mirrors the BarAppearance/iconStyle pattern.
|
||||
@Published var spendChartStyle: SpendChartStyle {
|
||||
didSet { SpendChartStyleStore.save(spendChartStyle) }
|
||||
}
|
||||
/// The alerts the most recent evaluation wanted delivered, surfaced in the
|
||||
/// dropdown so users who deny notifications still see the conditions.
|
||||
@Published var activeAlerts: [BarNotification] = []
|
||||
|
||||
private let home: String
|
||||
private var client: CCSBarClient?
|
||||
private var debouncer = RefreshDebouncer(interval: 15)
|
||||
/// Periodic background refresh so the glance self-heals from a transient
|
||||
/// server gap (e.g. a momentary backend restart that dropped the native rows)
|
||||
/// without the user having to reopen the menu. Cheap + safe: it reads the
|
||||
/// local server's caches and never hammers providers (native quota is
|
||||
/// TTL-gated server-side).
|
||||
private var pollTask: Task<Void, Never>?
|
||||
private let prefs: BarPreferences
|
||||
private let notifier: NotificationDelivering
|
||||
|
||||
init(
|
||||
home: String = NSHomeDirectory(),
|
||||
prefs: BarPreferences = BarPreferences(),
|
||||
notifier: NotificationDelivering? = nil
|
||||
) {
|
||||
self.home = home
|
||||
self.prefs = prefs
|
||||
// Seed registration defaults before the first pref read. Idempotent, and the
|
||||
// App-level call is a redundant safety net for the @StateObject default-init
|
||||
// ordering (stored-property defaults run before the App.init body).
|
||||
prefs.registerDefaults()
|
||||
// Default to the real UN-backed notifier; tests inject a recording one.
|
||||
self.notifier = notifier ?? BarNotifier()
|
||||
self.iconStyle = MenuBarIcon.loadStyle()
|
||||
self.appearance = BarAppearanceStore.load()
|
||||
self.glanceMode = prefs.load().glanceMode
|
||||
self.spendChartStyle = SpendChartStyleStore.load()
|
||||
reconnect()
|
||||
startBackgroundPolling()
|
||||
}
|
||||
|
||||
/// Periodically re-poll for the app's lifetime so a transient empty/missing-row
|
||||
/// state recovers on its own within one interval — each tick reconnects if the
|
||||
/// client/discovery was lost, then loads (non-force, so it respects the
|
||||
/// server-side caches). This is what prevents the menu from getting stuck after
|
||||
/// the server momentarily restarts.
|
||||
private func startBackgroundPolling() {
|
||||
pollTask?.cancel()
|
||||
pollTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(60))
|
||||
guard let self else { return }
|
||||
await self.load(force: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-read prefs after the preferences sheet writes through, so the next poll
|
||||
/// (and the live title) reflects the change immediately.
|
||||
func reloadPrefs() {
|
||||
glanceMode = prefs.load().glanceMode
|
||||
}
|
||||
|
||||
/// Toggle the menu-bar icon between the color mark and the mono template.
|
||||
func toggleIconStyle() {
|
||||
iconStyle = (iconStyle == .color) ? .mono : .color
|
||||
}
|
||||
|
||||
/// Compact status-bar title, resolved through the user's chosen glance mode.
|
||||
var statusTitle: String {
|
||||
offline
|
||||
? "CCS offline"
|
||||
: BarFormatting.statusTitle(rows: rows, analytics: analytics, mode: glanceMode)
|
||||
}
|
||||
|
||||
/// Resolve the discovery file and (re)build the client. Marks offline when
|
||||
/// CCS hasn't been launched.
|
||||
func reconnect() {
|
||||
switch BarDiscovery.load(home: home) {
|
||||
case .success(let discovery):
|
||||
if let url = discovery.resolvedURL {
|
||||
client = CCSBarClient(baseURL: url)
|
||||
offline = false
|
||||
} else {
|
||||
client = nil
|
||||
offline = true
|
||||
}
|
||||
case .failure:
|
||||
client = nil
|
||||
offline = true
|
||||
}
|
||||
}
|
||||
|
||||
/// Menu opened: cached rows are already on screen; fire a debounced
|
||||
/// force-refresh so the glance reflects live provider data.
|
||||
func onOpen() {
|
||||
let force = debouncer.shouldRefresh(now: Date())
|
||||
Task { await load(force: force) }
|
||||
}
|
||||
|
||||
func load(force: Bool) async {
|
||||
if client == nil { reconnect() }
|
||||
guard let client else {
|
||||
offline = true
|
||||
return
|
||||
}
|
||||
if force { isRefreshing = true }
|
||||
defer { isRefreshing = false }
|
||||
do {
|
||||
rows = try await client.summary(refresh: force)
|
||||
offline = false
|
||||
lastError = nil
|
||||
} catch {
|
||||
lastError = describe(error)
|
||||
// Keep the last rows visible (instant cached paint); only flip to the
|
||||
// offline state when there is nothing to show.
|
||||
if rows.isEmpty { offline = true }
|
||||
}
|
||||
|
||||
// Analytics is a best-effort side-load: a failure here must never blank the
|
||||
// glance or flip us offline. Keep the last-known analytics on error.
|
||||
if let fresh = try? await client.analytics() {
|
||||
analytics = fresh
|
||||
}
|
||||
|
||||
evaluateAlerts()
|
||||
}
|
||||
|
||||
/// Run the pure rule engine once per poll against the freshly-loaded state,
|
||||
/// deliver any new notifications, and overwrite the persisted fired-key set
|
||||
/// verbatim. The engine's dedupe means repeated polls with unchanged state
|
||||
/// deliver nothing, so this never spams. Delivery is best-effort and never
|
||||
/// blocks the UI (the notifier hops to its own task).
|
||||
private func evaluateAlerts() {
|
||||
let current = prefs.load()
|
||||
let eval = BarAlertEngine.evaluate(
|
||||
rows: rows,
|
||||
analytics: analytics,
|
||||
prefs: current,
|
||||
priorFiredKeys: prefs.firedKeys,
|
||||
now: Date())
|
||||
for notification in eval.toDeliver {
|
||||
notifier.deliver(notification)
|
||||
}
|
||||
// Persist the COMPLETE next-state set verbatim (no merge) — this is what keeps
|
||||
// the stored set bounded and lets cleared conditions re-arm.
|
||||
prefs.firedKeys = eval.firedKeys
|
||||
activeAlerts = eval.toDeliver
|
||||
}
|
||||
|
||||
// MARK: Account actions
|
||||
|
||||
func pause(_ row: BarSummaryRow) {
|
||||
perform { try await $0.pause(provider: row.provider, accountId: row.accountId) }
|
||||
}
|
||||
func resume(_ row: BarSummaryRow) {
|
||||
perform { try await $0.resume(provider: row.provider, accountId: row.accountId) }
|
||||
}
|
||||
func solo(_ row: BarSummaryRow) {
|
||||
perform { try await $0.solo(provider: row.provider, accountId: row.accountId) }
|
||||
}
|
||||
func setDefault(_ row: BarSummaryRow) {
|
||||
// The server's /api/accounts/default parses CLIProxy accounts as the
|
||||
// composite "provider:accountId" key; row.id already has that shape.
|
||||
// Sending the bare accountId fails parseCliproxyKey and 500s / no-ops.
|
||||
perform { try await $0.setDefault(name: row.id) }
|
||||
}
|
||||
func tierLock(_ row: BarSummaryRow, tier: String?) {
|
||||
perform { try await $0.tierLock(provider: row.provider, tier: tier) }
|
||||
}
|
||||
|
||||
private func perform(_ op: @escaping (CCSBarClient) async throws -> Void) {
|
||||
guard let client else { return }
|
||||
Task {
|
||||
do {
|
||||
try await op(client)
|
||||
await load(force: true)
|
||||
} catch {
|
||||
lastError = describe(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func describe(_ error: Error) -> String {
|
||||
String(describing: error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import SwiftUI
|
||||
import CCSBarCore
|
||||
|
||||
/// CCS Bar entry point. A menu-bar-only app (no dock icon) whose title shows
|
||||
/// the leading account's quota and today's total cost, with a dropdown for
|
||||
/// per-account detail and control.
|
||||
@main
|
||||
struct CCSBarApp: App {
|
||||
@StateObject private var viewModel = BarViewModel()
|
||||
|
||||
init() {
|
||||
// Seed the registration domain BEFORE any pref read so absent Bool keys
|
||||
// resolve to their real defaults (true) instead of reading back as false,
|
||||
// which would silently disable every alert on a fresh install.
|
||||
BarPreferences().registerDefaults()
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra {
|
||||
// ThemedRoot forces the chosen scheme and injects the resolved tokens, so
|
||||
// the whole dropdown follows the user's appearance pick independently of
|
||||
// the macOS system appearance. The label (status item) stays OS-tinted.
|
||||
ThemedRoot(appearance: viewModel.appearance) {
|
||||
BarMenuView(viewModel: viewModel)
|
||||
}
|
||||
} label: {
|
||||
// The CCS mark + compact glance. The image re-renders when the style
|
||||
// preference changes because `iconStyle` is observed.
|
||||
Image(nsImage: MenuBarIcon.statusImage(viewModel.iconStyle))
|
||||
Text(viewModel.statusTitle)
|
||||
}
|
||||
.menuBarExtraStyle(.window)
|
||||
}
|
||||
}
|
||||
|
||||
/// Forces the chosen color scheme on the dropdown content at the boundary, then
|
||||
/// hands off to `ResolvedThemeHost` to read the now-forced scheme and inject
|
||||
/// tokens. The split is deliberate: `.preferredColorScheme` rewrites the
|
||||
/// environment for DESCENDANTS only, so a view cannot read its own forced scheme
|
||||
/// in the same scope. `ResolvedThemeHost` is a descendant and therefore sees it.
|
||||
struct ThemedRoot<Content: View>: View {
|
||||
let appearance: BarAppearance
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
var body: some View {
|
||||
// Order matters: .preferredColorScheme first updates the SwiftUI \.colorScheme
|
||||
// environment for descendants (so the token resolver + Color.primary/.secondary
|
||||
// pick up the chosen scheme), THEN the .background WindowAppearanceForcer sets
|
||||
// the actual host NSWindow.appearance so system materials + semantic-color
|
||||
// inversions flip at the AppKit layer too — not just the custom RGB tokens.
|
||||
// The two are complementary: env tokens + real window appearance. KEEP both.
|
||||
ResolvedThemeHost(content: content)
|
||||
.preferredColorScheme(appearance.forced)
|
||||
.background(WindowAppearanceForcer(appearance: appearance))
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the (already-forced) color scheme, resolves the matching `BarTheme`,
|
||||
/// paints the themed window plate behind the content, and injects the tokens.
|
||||
/// In dark the plate is `.clear` (native MenuBarExtra material shows through —
|
||||
/// zero regression); in light it is the explicit #F5F5F7 plate so the dropdown
|
||||
/// renders light even when macOS is in dark mode.
|
||||
struct ResolvedThemeHost<Content: View>: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let content: Content
|
||||
|
||||
var body: some View {
|
||||
let theme = BarTheme.resolve(colorScheme)
|
||||
content
|
||||
.background(theme.windowSurface)
|
||||
.environment(\.barTheme, theme)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import AppKit
|
||||
import CCSBarCore
|
||||
import Foundation
|
||||
|
||||
/// Opens the CCS dashboard, starting the local server when it isn't running.
|
||||
///
|
||||
/// The dashboard is just a page served by the CCS web-server, so it can't load
|
||||
/// if no server is up. When the discovered URL isn't reachable we launch
|
||||
/// `ccs config`, which boots the server AND opens the dashboard in the browser
|
||||
/// itself — so we must not also open it (that would double-open a tab).
|
||||
enum DashboardLauncher {
|
||||
/// A GUI app does not inherit the shell PATH, so probe the common install
|
||||
/// locations for the `ccs` binary explicitly. First executable match wins.
|
||||
private static var ccsCandidates: [String] {
|
||||
let home = NSHomeDirectory()
|
||||
return [
|
||||
"\(home)/.bun/bin/ccs",
|
||||
"/opt/homebrew/bin/ccs",
|
||||
"/usr/local/bin/ccs",
|
||||
"\(home)/.local/bin/ccs",
|
||||
"\(home)/.npm-global/bin/ccs",
|
||||
"/usr/bin/ccs",
|
||||
]
|
||||
}
|
||||
|
||||
private static func ccsBinary() -> String? {
|
||||
ccsCandidates.first { FileManager.default.isExecutableFile(atPath: $0) }
|
||||
}
|
||||
|
||||
private static func dashboardURL() -> URL? {
|
||||
if case .success(let discovery) = BarDiscovery.load() { return discovery.resolvedURL }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Quick reachability probe so a stale `bar.json` URL doesn't send the user to
|
||||
/// a dead page. Short timeout — the dashboard is local.
|
||||
private static func isReachable(_ url: URL) async -> Bool {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "HEAD"
|
||||
request.timeoutInterval = 1.5
|
||||
return (try? await URLSession.shared.data(for: request)) != nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func openOrStart() async {
|
||||
if let url = dashboardURL(), await isReachable(url) {
|
||||
NSWorkspace.shared.open(url)
|
||||
return
|
||||
}
|
||||
|
||||
// Server isn't up. Start it via `ccs config`, fully detached (nohup) so it
|
||||
// survives this app quitting; it opens the dashboard on its own.
|
||||
if let bin = ccsBinary() {
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/bin/sh")
|
||||
proc.arguments = ["-c", "nohup \"\(bin)\" config >/dev/null 2>&1 &"]
|
||||
try? proc.run()
|
||||
return
|
||||
}
|
||||
|
||||
// Can't find the ccs binary — best effort: open whatever URL we know so the
|
||||
// user at least lands on the right place (or sees the connection is refused).
|
||||
if let url = dashboardURL() {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import AppKit
|
||||
|
||||
/// Menu-bar icon style. `color` shows the full CCS mark; `mono` uses a template
|
||||
/// silhouette that macOS auto-tints black/white to match the menu bar.
|
||||
enum BarIconStyle: String, CaseIterable {
|
||||
case color
|
||||
case mono
|
||||
}
|
||||
|
||||
/// Loads the CCS icon assets bundled into the .app (Contents/Resources) and
|
||||
/// hands back correctly-sized NSImages. Falls back to an SF Symbol when running
|
||||
/// from `swift run` (no bundle), so the app is always usable in dev.
|
||||
enum MenuBarIcon {
|
||||
static let defaultsKey = "ccsbar.iconStyle"
|
||||
|
||||
static func loadStyle() -> BarIconStyle {
|
||||
let raw = UserDefaults.standard.string(forKey: defaultsKey) ?? BarIconStyle.color.rawValue
|
||||
return BarIconStyle(rawValue: raw) ?? .color
|
||||
}
|
||||
|
||||
static func saveStyle(_ style: BarIconStyle) {
|
||||
UserDefaults.standard.set(style.rawValue, forKey: defaultsKey)
|
||||
}
|
||||
|
||||
/// The status-bar label image at ~18pt for the given style.
|
||||
static func statusImage(_ style: BarIconStyle) -> NSImage {
|
||||
let asset = style == .mono ? "MenuBarTemplate" : "MenuBarColor"
|
||||
let image = bundleImage(asset) ?? sfSymbol("gauge.with.dots.needle.bottom.50percent")
|
||||
image.size = NSSize(width: 18, height: 18)
|
||||
image.isTemplate = (style == .mono)
|
||||
return image
|
||||
}
|
||||
|
||||
/// The color CCS mark for the dropdown header at ~24pt.
|
||||
static func headerImage() -> NSImage {
|
||||
let image = bundleImage("HeaderLogo") ?? sfSymbol("gauge.with.dots.needle.bottom.50percent")
|
||||
image.size = NSSize(width: 24, height: 24)
|
||||
image.isTemplate = false
|
||||
return image
|
||||
}
|
||||
|
||||
private static func bundleImage(_ name: String) -> NSImage? {
|
||||
guard
|
||||
let url = Bundle.main.url(forResource: name, withExtension: "png"),
|
||||
let image = NSImage(contentsOf: url)
|
||||
else { return nil }
|
||||
return image
|
||||
}
|
||||
|
||||
private static func sfSymbol(_ name: String) -> NSImage {
|
||||
NSImage(systemSymbolName: name, accessibilityDescription: "CCS")
|
||||
?? NSImage(systemSymbolName: "circle", accessibilityDescription: "CCS")
|
||||
?? NSImage()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
/// Zero-size bridge that walks up to the enclosing `NSScrollView` at runtime and
|
||||
/// hard-disables both scrollers.
|
||||
///
|
||||
/// Why this exists on top of `.scrollIndicators(.never)`: inside a
|
||||
/// `MenuBarExtra` popover the SwiftUI indicator preference is sometimes ignored,
|
||||
/// and AppKit still draws a vertical scroller whose track steals horizontal width
|
||||
/// and shoves content out of alignment. Reaching the real `NSScrollView` and
|
||||
/// setting `hasVerticalScroller = false` (plus overlay/autohide) guarantees no
|
||||
/// scroller chrome regardless of how the popover hosts the SwiftUI content.
|
||||
struct ScrollerHider: NSViewRepresentable {
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let probe = NSView(frame: .zero)
|
||||
// Defer the walk until the view is in the hierarchy; at make-time the
|
||||
// enclosing scroll view does not exist yet.
|
||||
DispatchQueue.main.async { hideScroller(from: probe) }
|
||||
return probe
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
// The scroll view can be rebuilt on content changes inside the popover, so
|
||||
// re-apply on each update to keep the scroller suppressed.
|
||||
DispatchQueue.main.async { hideScroller(from: nsView) }
|
||||
}
|
||||
|
||||
/// Walk superviews until the first `NSScrollView`, then disable its scrollers.
|
||||
private func hideScroller(from view: NSView) {
|
||||
var current: NSView? = view.superview
|
||||
while let v = current {
|
||||
if let scroll = v as? NSScrollView {
|
||||
scroll.hasVerticalScroller = false
|
||||
scroll.hasHorizontalScroller = false
|
||||
scroll.scrollerStyle = .overlay
|
||||
scroll.autohidesScrollers = true
|
||||
scroll.drawsBackground = false
|
||||
return
|
||||
}
|
||||
current = v.superview
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import CCSBarCore
|
||||
|
||||
/// Opens the CCS Bar settings as a standalone AppKit `NSWindow`.
|
||||
///
|
||||
/// MECHANISM: a singleton `NSWindowController`-style driver backed by a real
|
||||
/// `NSWindow` + `NSWindowDelegate`, NOT the SwiftUI `Window` scene. AppKit is the
|
||||
/// only path that gives deterministic control of the three things a
|
||||
/// MenuBarExtra-only (no dock icon) app needs:
|
||||
/// (a) forcing `window.appearance` so the theme flips at the AppKit layer,
|
||||
/// (b) restoring `.accessory` activation policy on close (drop the dock icon),
|
||||
/// (c) singleton reuse so a second Settings click focuses the existing window
|
||||
/// instead of spawning a duplicate.
|
||||
/// The SwiftUI `Window` scene leaves the app stuck in `.regular` with a lingering
|
||||
/// dock icon and version-dependent focus behavior, so it is deliberately avoided.
|
||||
///
|
||||
/// Crucially, opening this window does NOT touch the MenuBarExtra popover's own
|
||||
/// NSWindow (a separate AppKit window), so the popover stays open and responsive
|
||||
/// — the exact cross-window isolation `ScrollerHider` already proves works here.
|
||||
/// This is what fixes BUG 1: the old `.sheet` presented inside the `.window`
|
||||
/// popover stole focus and auto-dismissed the whole bar.
|
||||
@MainActor
|
||||
final class SettingsWindowController {
|
||||
/// One window, max. A second show() reuses it rather than spawning a duplicate.
|
||||
static let shared = SettingsWindowController()
|
||||
|
||||
private var window: NSWindow?
|
||||
/// Retained so the delegate isn't deallocated while the window lives.
|
||||
private var delegate: SettingsWindowDelegate?
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Show (or re-focus) the settings window, hosting the LIVE view model so the
|
||||
/// appearance picker drives both this window and the menu-bar popover.
|
||||
func show(viewModel: BarViewModel) {
|
||||
if let existing = window {
|
||||
// Reuse: bring the single window to front instead of opening another.
|
||||
existing.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
return
|
||||
}
|
||||
|
||||
// SwiftUI root: the SAME view model + the SAME ThemedRoot pipeline as the
|
||||
// popover, so the settings window themes identically and live-syncs.
|
||||
let root = SettingsWindowRoot(viewModel: viewModel)
|
||||
let hosting = NSHostingController(rootView: root)
|
||||
|
||||
let window = NSWindow(contentViewController: hosting)
|
||||
window.title = "CCS Bar Settings"
|
||||
// Titled + closable only: it's a settings dialog, not a document window, so
|
||||
// no miniaturize/zoom. Resizable (no .nonResizable) per the spec.
|
||||
window.styleMask = [.titled, .closable, .resizable]
|
||||
window.setContentSize(NSSize(width: 460, height: 600))
|
||||
window.minSize = NSSize(width: 420, height: 520)
|
||||
// Reuse the instance on reopen instead of tearing it down on close.
|
||||
window.isReleasedWhenClosed = false
|
||||
// Center on the screen that contains the mouse cursor (= the clicked screen)
|
||||
// rather than NSScreen.main (which is the screen with the key window and may
|
||||
// differ from the display where the menu bar icon was clicked). Falls back to
|
||||
// the primary screen via NSWindow.center() when no match is found.
|
||||
centerOnClickedScreen(window)
|
||||
|
||||
let delegate = SettingsWindowDelegate(onClose: { [weak self] in self?.handleClose() })
|
||||
window.delegate = delegate
|
||||
self.delegate = delegate
|
||||
self.window = window
|
||||
|
||||
// Accessory (menu-bar-only) apps can't take key focus; upgrade to .regular
|
||||
// so the window focuses and shows in the app switcher while it's open.
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
// Force front even when invoked from another frontmost app.
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
/// Close the window programmatically (e.g. the Done button). Routes through
|
||||
/// `performClose` so the delegate restores `.accessory` mode exactly like the
|
||||
/// title-bar close button — `@Environment(\.dismiss)` is a no-op in a plain
|
||||
/// NSHostingController window, so this is the reliable path.
|
||||
func close() { window?.performClose(nil) }
|
||||
|
||||
/// Delegate callback on window close: drop the dock icon back so we return to
|
||||
/// menu-bar-only mode. The popover is untouched throughout. The window itself
|
||||
/// is kept (isReleasedWhenClosed = false) for cheap reuse, but we clear our
|
||||
/// reference so the next show() rebuilds a fresh, correctly-centered window.
|
||||
private func handleClose() {
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
window = nil
|
||||
delegate = nil
|
||||
}
|
||||
|
||||
/// Centers `window` on the screen that contains the mouse cursor at call-time
|
||||
/// (i.e. the display where the user clicked the settings button). This ensures
|
||||
/// the settings window opens on the same display as the menu bar icon that was
|
||||
/// clicked, rather than defaulting to NSScreen.main (the primary / key-window
|
||||
/// screen, which may be a different monitor in a multi-display setup).
|
||||
///
|
||||
/// Falls back to `window.center()` (primary screen, macOS default) when no
|
||||
/// candidate screen can be determined.
|
||||
private func centerOnClickedScreen(_ window: NSWindow) {
|
||||
let mouseGlobal = NSEvent.mouseLocation
|
||||
let screens = NSScreen.screens
|
||||
guard let target = BarScreenPicker.screen(for: mouseGlobal, in: screens) else {
|
||||
window.center()
|
||||
return
|
||||
}
|
||||
let visibleFrame = target.visibleFrame
|
||||
let windowSize = window.frame.size
|
||||
let x = visibleFrame.midX - windowSize.width / 2
|
||||
let y = visibleFrame.midY - windowSize.height / 2
|
||||
window.setFrameOrigin(NSPoint(
|
||||
x: max(visibleFrame.minX, min(x, visibleFrame.maxX - windowSize.width)),
|
||||
y: max(visibleFrame.minY, min(y, visibleFrame.maxY - windowSize.height))
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridges `NSWindow` close back to the controller so it can restore the
|
||||
/// `.accessory` activation policy (menu-bar-only mode).
|
||||
final class SettingsWindowDelegate: NSObject, NSWindowDelegate {
|
||||
private let onClose: () -> Void
|
||||
init(onClose: @escaping () -> Void) {
|
||||
self.onClose = onClose
|
||||
}
|
||||
func windowWillClose(_ notification: Notification) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
/// SwiftUI root hosted inside the settings `NSWindow`. Wraps the reused
|
||||
/// `BarPreferencesView` in the SAME `ThemedRoot` token pipeline as the popover,
|
||||
/// so the settings window themes identically. Observing the shared
|
||||
/// `BarViewModel` means a theme pick here re-renders BOTH windows live.
|
||||
struct SettingsWindowRoot: View {
|
||||
@ObservedObject var viewModel: BarViewModel
|
||||
/// The prefs adapter the view edits; shares the standard suite with the view
|
||||
/// model so a write-through is visible on the next poll (same as the popover).
|
||||
private let prefs = BarPreferences()
|
||||
|
||||
var body: some View {
|
||||
// ThemedRoot applies .preferredColorScheme + injects tokens, and its
|
||||
// .background WindowAppearanceForcer forces THIS NSWindow's appearance so
|
||||
// system materials + semantic colors flip too — single source of truth with
|
||||
// the popover. Fill the window so the plate covers the full content area.
|
||||
ThemedRoot(appearance: viewModel.appearance) {
|
||||
BarPreferencesView(viewModel: viewModel, prefs: prefs)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import SwiftUI
|
||||
import CCSBarCore
|
||||
|
||||
/// A compact sparkline for daily values (e.g. cost per day over 30 days).
|
||||
///
|
||||
/// Two render styles controlled by `style`:
|
||||
/// - `.bars` (default): the original RoundedRectangle bar chart. Zero-value days
|
||||
/// render as faint placeholders so the cadence stays readable.
|
||||
/// - `.line`: a Path-based polyline through the normalized points, stroked ~1.5pt,
|
||||
/// with a subtle area fill (accent at ~0.15 opacity) under the curve. Better for
|
||||
/// reading trend direction over a long window. Falls back to a flat baseline when
|
||||
/// count < 2 or all values are zero.
|
||||
struct Sparkline: View {
|
||||
let values: [Double]
|
||||
// Default is the dark preset's accent: a default argument can't read the
|
||||
// environment, so this is the static fallback. Live callers pass the themed
|
||||
// `theme.accent` from the parent so the rendered bar follows the chosen theme.
|
||||
var accent: Color = BarTheme.dark.accent
|
||||
/// Render mode. Default `.bars` preserves the original look; `.line` draws a
|
||||
/// trend line instead.
|
||||
var style: SpendChartStyle = .bars
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
switch style {
|
||||
case .bars:
|
||||
barsBody(in: geo.size)
|
||||
case .line:
|
||||
lineBody(in: geo.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Bar render (original)
|
||||
|
||||
private func barsBody(in size: CGSize) -> some View {
|
||||
let peak = max(values.max() ?? 0, 0.0001)
|
||||
return HStack(alignment: .bottom, spacing: 3) {
|
||||
ForEach(Array(values.enumerated()), id: \.offset) { _, value in
|
||||
let height = CGFloat(value / peak) * size.height
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(value > 0 ? accent : Color.secondary.opacity(0.2))
|
||||
.frame(height: max(2, height))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
||||
}
|
||||
|
||||
// MARK: Line render
|
||||
|
||||
/// Polyline path connecting the normalized data points. x is evenly spaced
|
||||
/// across the width; y is inverted so a larger value is higher (closer to 0).
|
||||
@ViewBuilder private func lineBody(in size: CGSize) -> some View {
|
||||
let peak = max(values.max() ?? 0, 0.0001)
|
||||
let count = values.count
|
||||
let allZero = values.allSatisfy { $0 <= 0 }
|
||||
|
||||
if count < 2 || allZero {
|
||||
// Flat baseline — nothing to show; render a faint hairline so the area
|
||||
// is not invisible on an idle spend strip.
|
||||
Path { p in
|
||||
p.move(to: CGPoint(x: 0, y: size.height))
|
||||
p.addLine(to: CGPoint(x: size.width, y: size.height))
|
||||
}
|
||||
.stroke(accent.opacity(0.2), lineWidth: 1)
|
||||
} else {
|
||||
// Build points: x evenly spaced, y inverted (0 = top = max value).
|
||||
let pts: [CGPoint] = values.enumerated().map { i, v in
|
||||
let x = CGFloat(i) / CGFloat(count - 1) * size.width
|
||||
let y = size.height - CGFloat(v / peak) * size.height
|
||||
return CGPoint(x: x, y: y)
|
||||
}
|
||||
|
||||
// Area fill: close the path by dropping to the bottom edge.
|
||||
let fillPath = Path { p in
|
||||
p.move(to: CGPoint(x: pts[0].x, y: size.height))
|
||||
p.addLine(to: pts[0])
|
||||
for pt in pts.dropFirst() { p.addLine(to: pt) }
|
||||
p.addLine(to: CGPoint(x: pts[pts.count - 1].x, y: size.height))
|
||||
p.closeSubpath()
|
||||
}
|
||||
|
||||
// Stroke path.
|
||||
let strokePath = Path { p in
|
||||
p.move(to: pts[0])
|
||||
for pt in pts.dropFirst() { p.addLine(to: pt) }
|
||||
}
|
||||
|
||||
ZStack {
|
||||
fillPath.fill(accent.opacity(0.15))
|
||||
strokePath.stroke(accent, lineWidth: 1.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import CCSBarCore
|
||||
|
||||
/// Zero-size bridge that walks up to the host `NSWindow` and:
|
||||
/// 1. Forces its `appearance` to match the user's chosen `BarAppearance`.
|
||||
/// 2. Fixes `collectionBehavior` to `.moveToActiveSpace` so the panel is
|
||||
/// never visible on multiple Spaces / displays simultaneously (#1503).
|
||||
/// 3. Anchors the panel to the screen where the user clicked the status item
|
||||
/// by reading `NSEvent.mouseLocation` at open-time (#1502).
|
||||
///
|
||||
/// Why (1) exists on top of `.preferredColorScheme`: that modifier only
|
||||
/// rewrites the SwiftUI `\.colorScheme` environment for descendant views — it
|
||||
/// does NOT change the host `NSWindow.effectiveAppearance`. So AppKit-level
|
||||
/// surfaces keep reading the OS appearance and fight the chosen theme:
|
||||
/// - system materials (the MenuBarExtra popover's backing material)
|
||||
/// - semantic colors (`Color.primary` / `.secondary`, used by Chip text,
|
||||
/// health dots, captions) which invert off the window appearance.
|
||||
/// Setting `window.appearance` directly fixes the theme at the AppKit layer so
|
||||
/// the whole surface flips, not just the custom RGB tokens.
|
||||
///
|
||||
/// Why (2): the default `NSPanel` created by SwiftUI `MenuBarExtra(.window)`
|
||||
/// can carry `NSWindow.CollectionBehavior.canJoinAllSpaces`, which makes it
|
||||
/// appear on every Space and every display simultaneously. Switching to
|
||||
/// `.moveToActiveSpace` (the correct behavior for a transient panel) removes
|
||||
/// the multi-display ghost (#1503).
|
||||
///
|
||||
/// Why (3): with "Displays have separate Spaces" on, the menu bar icon appears
|
||||
/// on every display but the panel can open on the wrong screen when its initial
|
||||
/// frame is computed relative to `NSScreen.main` instead of the clicked screen.
|
||||
/// `NSEvent.mouseLocation` is captured synchronously at click time (before the
|
||||
/// async window-materialization hop) to avoid a placement race where a post-click
|
||||
/// cursor move would anchor the panel to the wrong display.
|
||||
/// If the panel landed on a different screen than the click we reposition it (#1502).
|
||||
///
|
||||
/// Modeled on the proven `ScrollerHider` pattern (which already reaches the host
|
||||
/// window inside this popover), proving cross-window AppKit access works here.
|
||||
struct WindowAppearanceForcer: NSViewRepresentable {
|
||||
let appearance: BarAppearance
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let probe = NSView(frame: .zero)
|
||||
// Capture the cursor position synchronously at click time (before any async
|
||||
// dispatch) so the anchoring logic always sees the screen that was clicked,
|
||||
// not wherever the cursor ended up by the time the async block executes.
|
||||
let clickLocation = NSEvent.mouseLocation
|
||||
// Defer window access until the view is in the hierarchy; at make-time
|
||||
// `view.window` is nil.
|
||||
DispatchQueue.main.async { apply(to: probe, clickLocation: clickLocation) }
|
||||
return probe
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
// Re-apply on every update: the popover's NSWindow can be rebuilt on content
|
||||
// changes, and the appearance pick itself changes mid-session. Screen
|
||||
// anchoring is skipped on updates (clickLocation: nil) to avoid fighting
|
||||
// SwiftUI's own layout passes once the panel is already shown.
|
||||
DispatchQueue.main.async { apply(to: nsView, clickLocation: nil) }
|
||||
}
|
||||
|
||||
/// Apply all window fixes: appearance, collectionBehavior, and (on first open)
|
||||
/// screen anchoring.
|
||||
///
|
||||
/// .system -> nil (follow the OS)
|
||||
/// .light -> aqua
|
||||
/// .dark -> darkAqua
|
||||
///
|
||||
/// `clickLocation`: the cursor position captured synchronously at click time.
|
||||
/// Non-nil only on the first open; nil on subsequent updates (anchoring skipped).
|
||||
private func apply(to view: NSView, clickLocation: NSPoint?) {
|
||||
guard let window = view.window else { return }
|
||||
|
||||
// (1) Appearance.
|
||||
switch appearance {
|
||||
case .system:
|
||||
window.appearance = nil
|
||||
case .light:
|
||||
window.appearance = NSAppearance(named: .aqua)
|
||||
case .dark:
|
||||
window.appearance = NSAppearance(named: .darkAqua)
|
||||
}
|
||||
|
||||
// (2) Collection behavior — fix #1503.
|
||||
// Remove canJoinAllSpaces so the panel does not ghost across all displays.
|
||||
// .moveToActiveSpace is the correct policy for a transient menu bar panel:
|
||||
// it stays on the Space where it was opened, moves with the user's active
|
||||
// Space on Space switches, and is never visible on multiple displays at once.
|
||||
var behavior = window.collectionBehavior
|
||||
behavior.remove(.canJoinAllSpaces)
|
||||
behavior.insert(.moveToActiveSpace)
|
||||
window.collectionBehavior = behavior
|
||||
|
||||
// (3) Screen anchoring on first open — fix #1502.
|
||||
// clickLocation is the cursor position captured synchronously at click time
|
||||
// (before the async hop). Passing it here avoids the placement race where
|
||||
// a late-read NSEvent.mouseLocation could reflect a post-click cursor move
|
||||
// and anchor the panel to the wrong display.
|
||||
// This is a no-op on a single-display setup or when the panel is already
|
||||
// on the correct screen.
|
||||
if let location = clickLocation {
|
||||
anchorToClickedScreen(window: window, clickLocation: location)
|
||||
}
|
||||
}
|
||||
|
||||
/// Repositions `window` to the screen that contains `clickLocation` (the
|
||||
/// cursor position captured synchronously at click time) if that screen
|
||||
/// differs from the screen the panel currently occupies.
|
||||
///
|
||||
/// The y-offset from the top of the screen is preserved so the panel still
|
||||
/// appears just below the menu bar on the correct display.
|
||||
private func anchorToClickedScreen(window: NSWindow, clickLocation: NSPoint) {
|
||||
let mouseGlobal = clickLocation
|
||||
let screens = NSScreen.screens
|
||||
guard
|
||||
let clickedScreen = BarScreenPicker.screen(for: mouseGlobal, in: screens),
|
||||
let panelScreen = window.screen,
|
||||
clickedScreen != panelScreen
|
||||
else { return }
|
||||
|
||||
// Compute the distance from the top of the panel's current screen to the
|
||||
// top of the panel frame. "Top" in macOS global coords = maxY (Y increases
|
||||
// upward). Preserve this offset when moving to the clicked screen.
|
||||
let currentMaxY = panelScreen.frame.maxY
|
||||
let panelFrame = window.frame
|
||||
let distanceFromTop = currentMaxY - panelFrame.maxY
|
||||
|
||||
// Place the panel at the same relative position from the top of the clicked
|
||||
// screen, horizontally centered on that screen.
|
||||
let targetMaxY = clickedScreen.frame.maxY - distanceFromTop
|
||||
let targetX = clickedScreen.frame.midX - panelFrame.width / 2
|
||||
let targetOrigin = NSPoint(x: targetX, y: targetMaxY - panelFrame.height)
|
||||
|
||||
window.setFrameOrigin(targetOrigin)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure helper: given a list of screen frames and a point in global coordinates,
|
||||
/// returns the screen whose frame contains the point. Falls back to the first
|
||||
/// screen if no screen contains the point (e.g. the cursor is between displays).
|
||||
///
|
||||
/// Extracted from `WindowAppearanceForcer` so the decision logic can be
|
||||
/// unit-tested headlessly in the `ccs-bar-check` assert harness without AppKit.
|
||||
enum BarScreenPicker {
|
||||
/// Returns the element of `screens` whose `frame` contains `point`, or `nil`
|
||||
/// if `screens` is empty. When no screen contains `point` exactly (gap between
|
||||
/// displays), returns the closest screen by distance to frame center.
|
||||
static func screen(for point: NSPoint, in screens: [NSScreen]) -> NSScreen? {
|
||||
guard !screens.isEmpty else { return nil }
|
||||
// Exact hit first.
|
||||
if let exact = screens.first(where: { $0.frame.contains(point) }) {
|
||||
return exact
|
||||
}
|
||||
// Fallback: closest by Euclidean distance from frame center (handles gaps).
|
||||
return screens.min(by: { a, b in
|
||||
distanceSquared(from: point, to: a.frame) < distanceSquared(from: point, to: b.frame)
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: - Internal helpers (fileprivate for tests via same module)
|
||||
|
||||
/// Squared Euclidean distance from a point to the nearest point on a rect.
|
||||
/// Zero when the point is inside the rect (handled by the caller first).
|
||||
static func distanceSquared(from point: NSPoint, to rect: NSRect) -> CGFloat {
|
||||
let dx = max(rect.minX - point.x, 0, point.x - rect.maxX)
|
||||
let dy = max(rect.minY - point.y, 0, point.y - rect.maxY)
|
||||
return dx * dx + dy * dy
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,296 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: Glance mode
|
||||
|
||||
/// Which figure leads the always-on menu-bar title. Persisted by raw value, so
|
||||
/// the cases are a stable contract with `BarFormatting.statusTitle(...:mode:)`.
|
||||
/// There is deliberately NO allTime/lifetime case — a lifetime dollar figure in
|
||||
/// the bar reads as live spend and creates false urgency.
|
||||
public enum BarGlanceMode: String, Sendable, Equatable, CaseIterable {
|
||||
case auto
|
||||
case todaySpend
|
||||
case monthSpend
|
||||
case lowestQuota
|
||||
case accountCount
|
||||
}
|
||||
|
||||
// MARK: Alert kinds
|
||||
|
||||
/// The five alert conditions the engine evaluates. The raw values are STABLE:
|
||||
/// they are embedded inside persisted fired-keys, so renaming a case orphans
|
||||
/// any live key and would re-fire or silence alerts incorrectly.
|
||||
public enum BarAlertKind: String, Sendable, Equatable, CaseIterable {
|
||||
case quotaRemainingBelow
|
||||
case dailySpendAbove
|
||||
case monthSpendAbove
|
||||
case reauthNeeded
|
||||
case accountCooldownOrPaused
|
||||
}
|
||||
|
||||
// MARK: Preferences (pure value type)
|
||||
|
||||
/// User alert preferences. A pure input to the engine — the App hydrates this
|
||||
/// from UserDefaults and passes it in; the engine never reads defaults itself.
|
||||
public struct BarAlertPrefs: Sendable, Equatable {
|
||||
public var quotaEnabled: Bool
|
||||
public var quotaLevels: [Int]
|
||||
public var dailySpendEnabled: Bool
|
||||
public var dailyCapUSD: Double
|
||||
public var monthSpendEnabled: Bool
|
||||
public var monthCapUSD: Double
|
||||
public var reauthEnabled: Bool
|
||||
public var cooldownPausedEnabled: Bool
|
||||
public var glanceMode: BarGlanceMode
|
||||
|
||||
public init(
|
||||
quotaEnabled: Bool = true,
|
||||
quotaLevels: [Int] = [20, 10, 0],
|
||||
dailySpendEnabled: Bool = true,
|
||||
dailyCapUSD: Double = 500,
|
||||
monthSpendEnabled: Bool = true,
|
||||
monthCapUSD: Double = 10000,
|
||||
reauthEnabled: Bool = true,
|
||||
cooldownPausedEnabled: Bool = true,
|
||||
glanceMode: BarGlanceMode = .auto
|
||||
) {
|
||||
self.quotaEnabled = quotaEnabled
|
||||
self.quotaLevels = quotaLevels
|
||||
self.dailySpendEnabled = dailySpendEnabled
|
||||
self.dailyCapUSD = dailyCapUSD
|
||||
self.monthSpendEnabled = monthSpendEnabled
|
||||
self.monthCapUSD = monthCapUSD
|
||||
self.reauthEnabled = reauthEnabled
|
||||
self.cooldownPausedEnabled = cooldownPausedEnabled
|
||||
self.glanceMode = glanceMode
|
||||
}
|
||||
|
||||
/// Quota levels defensively normalized: clamped to 0...100, de-duplicated, and
|
||||
/// sorted descending so the engine can pick the single most-severe crossed
|
||||
/// level deterministically regardless of how the prefs were entered.
|
||||
public var normalizedQuotaLevels: [Int] {
|
||||
Array(Set(quotaLevels.map { min(100, max(0, $0)) })).sorted(by: >)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Notification value + delivery protocol
|
||||
|
||||
/// A single notification the engine wants delivered this poll. `id` is the
|
||||
/// fired-key (also used as the UN request identifier so the OS de-dupes at the
|
||||
/// delivery layer too).
|
||||
public struct BarNotification: Sendable, Equatable, Identifiable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
public let body: String
|
||||
public let kind: BarAlertKind
|
||||
public init(id: String, title: String, body: String, kind: BarAlertKind) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.body = body
|
||||
self.kind = kind
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of one evaluation. `firedKeys` is the COMPLETE next-state set — the
|
||||
/// caller overwrites its stored set verbatim (no merge), which is what keeps the
|
||||
/// persisted set bounded and lets cleared conditions re-arm.
|
||||
public struct BarAlertEvaluation: Sendable, Equatable {
|
||||
public let toDeliver: [BarNotification]
|
||||
public let firedKeys: Set<String>
|
||||
public init(toDeliver: [BarNotification], firedKeys: Set<String>) {
|
||||
self.toDeliver = toDeliver
|
||||
self.firedKeys = firedKeys
|
||||
}
|
||||
}
|
||||
|
||||
/// Real notification delivery. Declared in Core so the assert harness can supply
|
||||
/// a recording implementation; the engine NEVER calls it — the App orchestrates
|
||||
/// delivery from the engine's `toDeliver` output.
|
||||
public protocol NotificationDelivering: Sendable {
|
||||
func deliver(_ notification: BarNotification)
|
||||
}
|
||||
|
||||
// MARK: The pure rule engine
|
||||
|
||||
/// Namespace for the pure, deterministic alert rule engine. `evaluate` is
|
||||
/// side-effect-free: no `Date()`, no IO, no UserDefaults. Everything time- or
|
||||
/// state-dependent (`now`, `prefs`, `priorFiredKeys`, `calendar`) is injected.
|
||||
public enum BarAlertEngine {
|
||||
/// Evaluate all rules against the current rows + analytics.
|
||||
///
|
||||
/// Deterministic: accounts are iterated in stable id order so output order is
|
||||
/// reproducible. Returns the notifications to deliver and the complete next
|
||||
/// fired-key set (caller overwrites stored set verbatim).
|
||||
///
|
||||
/// Key format: pipe-joined `kindRaw|scope|bucket[|suffix]`. Pipe (not colon)
|
||||
/// because `account.id` already contains a colon (`provider:accountId`).
|
||||
/// `scope` is the account id or literal "global". `bucket` is the re-arm token
|
||||
/// — when it rolls (new day/month/reset) the key changes and the alert re-fires.
|
||||
public static func evaluate(
|
||||
rows: [BarSummaryRow],
|
||||
analytics: BarAnalytics?,
|
||||
prefs: BarAlertPrefs,
|
||||
priorFiredKeys: Set<String>,
|
||||
now: Date,
|
||||
calendar: Calendar = .current
|
||||
) -> BarAlertEvaluation {
|
||||
var fired = priorFiredKeys
|
||||
var out: [BarNotification] = []
|
||||
|
||||
let sortedRows = rows.sorted { $0.id < $1.id }
|
||||
let dayBucket = localDayKey(now, calendar: calendar)
|
||||
let monthBucket = localMonthKey(now, calendar: calendar)
|
||||
let levels = prefs.normalizedQuotaLevels
|
||||
|
||||
// Per-account rules.
|
||||
for row in sortedRows {
|
||||
let scope = row.id
|
||||
let name = row.displayName ?? row.provider
|
||||
|
||||
// (1) quotaRemainingBelow — fire the SINGLE most-severe crossed level.
|
||||
// One alert per reset window (anti-spam): the fired-key embeds the reset
|
||||
// bucket (row.nextReset ?? "noreset" below), so once an account crosses a
|
||||
// level the alert is suppressed for the rest of that window even if quota
|
||||
// recovers and then drops again. It re-arms automatically when nextReset
|
||||
// rolls to a new window.
|
||||
if prefs.quotaEnabled, row.quotaStatus == "ok", let pct = row.quotaPercentage {
|
||||
let remaining = Int(pct.rounded())
|
||||
// levels sorted desc; the most-severe crossed level is the smallest L
|
||||
// with remaining <= L. One notif per account per poll, not one per level.
|
||||
if let level = levels.filter({ remaining <= $0 }).min() {
|
||||
let bucket = row.nextReset ?? "noreset"
|
||||
let key = "\(BarAlertKind.quotaRemainingBelow.rawValue)|\(scope)|\(bucket)|L\(level)"
|
||||
if !fired.contains(key) {
|
||||
let resets = BarQuotaGauge.resetCountdown(nextReset: row.nextReset, now: now)
|
||||
let resetSuffix = resets.map { " — \($0)" } ?? ""
|
||||
out.append(
|
||||
BarNotification(
|
||||
id: key,
|
||||
title: "Quota low",
|
||||
body: "\(name) quota at \(remaining)%\(resetSuffix)",
|
||||
kind: .quotaRemainingBelow))
|
||||
fired.insert(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (4) reauthNeeded — clears-then-recurs.
|
||||
let reauthKey = "\(BarAlertKind.reauthNeeded.rawValue)|\(scope)|on"
|
||||
if prefs.reauthEnabled, row.needsReauth {
|
||||
if !fired.contains(reauthKey) {
|
||||
out.append(
|
||||
BarNotification(
|
||||
id: reauthKey,
|
||||
title: "Re-authentication needed",
|
||||
body: "\(name) needs re-authentication",
|
||||
kind: .reauthNeeded))
|
||||
fired.insert(reauthKey)
|
||||
}
|
||||
} else {
|
||||
// Condition cleared (or rule disabled): drop the key so it re-fires next
|
||||
// time the condition becomes true again.
|
||||
fired.remove(reauthKey)
|
||||
}
|
||||
|
||||
// (5) accountCooldownOrPaused — clears-then-recurs on `paused`.
|
||||
let pausedKey = "\(BarAlertKind.accountCooldownOrPaused.rawValue)|\(scope)|on"
|
||||
if prefs.cooldownPausedEnabled, row.paused {
|
||||
if !fired.contains(pausedKey) {
|
||||
out.append(
|
||||
BarNotification(
|
||||
id: pausedKey,
|
||||
title: "Account paused",
|
||||
body: "\(name) is paused / cooling down",
|
||||
kind: .accountCooldownOrPaused))
|
||||
fired.insert(pausedKey)
|
||||
}
|
||||
} else {
|
||||
fired.remove(pausedKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Global rules.
|
||||
|
||||
// (2) dailySpendAbove — strict >, per calendar day.
|
||||
if prefs.dailySpendEnabled {
|
||||
let today = analytics?.today.cost ?? 0
|
||||
if today > prefs.dailyCapUSD {
|
||||
let key = "\(BarAlertKind.dailySpendAbove.rawValue)|global|\(dayBucket)"
|
||||
if !fired.contains(key) {
|
||||
out.append(
|
||||
BarNotification(
|
||||
id: key,
|
||||
title: "Daily spend cap",
|
||||
body:
|
||||
"Daily spend \(BarFormatting.money(today)) is over your "
|
||||
+ "\(BarFormatting.money(prefs.dailyCapUSD)) cap",
|
||||
kind: .dailySpendAbove))
|
||||
fired.insert(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (3) monthSpendAbove — strict >, driven by calendar MTD (NOT last30d/allTime).
|
||||
if prefs.monthSpendEnabled {
|
||||
let mtd = analytics?.monthToDate.cost ?? 0
|
||||
if mtd > prefs.monthCapUSD {
|
||||
let key = "\(BarAlertKind.monthSpendAbove.rawValue)|global|\(monthBucket)"
|
||||
if !fired.contains(key) {
|
||||
out.append(
|
||||
BarNotification(
|
||||
id: key,
|
||||
title: "Monthly spend cap",
|
||||
body:
|
||||
"This month's spend \(BarFormatting.money(mtd)) is over your "
|
||||
+ "\(BarFormatting.money(prefs.monthCapUSD)) cap",
|
||||
kind: .monthSpendAbove))
|
||||
fired.insert(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PRUNE — keep the fired set bounded so it can't grow without limit across
|
||||
// day/month/reset rollovers or account churn.
|
||||
let presentIds = Set(rows.map { $0.id })
|
||||
let presentResetBuckets: [String: String] = Dictionary(
|
||||
uniqueKeysWithValues: rows.map { ($0.id, $0.nextReset ?? "noreset") })
|
||||
fired = fired.filter { key in
|
||||
let parts = key.split(separator: "|", omittingEmptySubsequences: false).map(String.init)
|
||||
guard let kind = parts.first else { return false }
|
||||
switch kind {
|
||||
case BarAlertKind.dailySpendAbove.rawValue:
|
||||
// parts: [kind, "global", dayBucket]
|
||||
return parts.count >= 3 && parts[2] == dayBucket
|
||||
case BarAlertKind.monthSpendAbove.rawValue:
|
||||
return parts.count >= 3 && parts[2] == monthBucket
|
||||
case BarAlertKind.quotaRemainingBelow.rawValue:
|
||||
// parts: [kind, accountId, bucket, "L<level>"]; bucket must equal the
|
||||
// account's CURRENT nextReset and the account must still be present.
|
||||
guard parts.count >= 3, presentIds.contains(parts[1]) else { return false }
|
||||
return presentResetBuckets[parts[1]] == parts[2]
|
||||
case BarAlertKind.reauthNeeded.rawValue, BarAlertKind.accountCooldownOrPaused.rawValue:
|
||||
// parts: [kind, accountId, "on"]; keep only for still-present accounts.
|
||||
return parts.count >= 2 && presentIds.contains(parts[1])
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return BarAlertEvaluation(toDeliver: out, firedKeys: fired)
|
||||
}
|
||||
|
||||
// MARK: Bucket helpers (local calendar, injected)
|
||||
|
||||
/// Local calendar day key `yyyy-MM-dd` from injected `calendar`. Local (not
|
||||
/// UTC) to match the backend's local-day analytics semantics.
|
||||
static func localDayKey(_ date: Date, calendar: Calendar) -> String {
|
||||
let c = calendar.dateComponents([.year, .month, .day], from: date)
|
||||
return String(
|
||||
format: "%04d-%02d-%02d", c.year ?? 0, c.month ?? 0, c.day ?? 0)
|
||||
}
|
||||
|
||||
/// Local calendar month key `yyyy-MM`.
|
||||
static func localMonthKey(_ date: Date, calendar: Calendar) -> String {
|
||||
let c = calendar.dateComponents([.year, .month], from: date)
|
||||
return String(format: "%04d-%02d", c.year ?? 0, c.month ?? 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import Foundation
|
||||
|
||||
/// The persistence CONTRACT for alert preferences: the UserDefaults key strings,
|
||||
/// their defaults, and a pure dictionary <-> prefs codec. The App layer owns the
|
||||
/// live `UserDefaults` object; this type stays pure so the key/default/parse
|
||||
/// logic is unit-testable on any toolchain without touching real defaults.
|
||||
public enum BarAlertPrefsStore {
|
||||
/// UserDefaults keys. Stable strings — changing one silently drops a user's
|
||||
/// saved preference back to its default.
|
||||
public enum Key {
|
||||
public static let quotaEnabled = "ccsbar.alert.quota.enabled"
|
||||
public static let quotaLevels = "ccsbar.alert.quota.levels"
|
||||
public static let dailyEnabled = "ccsbar.alert.daily.enabled"
|
||||
public static let dailyCapUSD = "ccsbar.alert.daily.capUSD"
|
||||
public static let monthEnabled = "ccsbar.alert.month.enabled"
|
||||
public static let monthCapUSD = "ccsbar.alert.month.capUSD"
|
||||
public static let reauthEnabled = "ccsbar.alert.reauth.enabled"
|
||||
public static let cooldownPausedEnabled = "ccsbar.alert.cooldownPaused.enabled"
|
||||
public static let glanceMode = "ccsbar.glance.mode"
|
||||
/// Engine state, NOT user-editable.
|
||||
public static let firedKeys = "ccsbar.alert.firedKeys"
|
||||
}
|
||||
|
||||
/// Default values keyed by the UserDefaults key. Registered via
|
||||
/// `UserDefaults.register(defaults:)` at App launch so absent Bool keys don't
|
||||
/// read back as `false` (which would silently disable every alert on first run).
|
||||
public static var registrationDefaults: [String: Any] {
|
||||
[
|
||||
Key.quotaEnabled: true,
|
||||
Key.quotaLevels: "20,10,0",
|
||||
Key.dailyEnabled: true,
|
||||
Key.dailyCapUSD: 500.0,
|
||||
Key.monthEnabled: true,
|
||||
Key.monthCapUSD: 10000.0,
|
||||
Key.reauthEnabled: true,
|
||||
Key.cooldownPausedEnabled: true,
|
||||
Key.glanceMode: BarGlanceMode.auto.rawValue,
|
||||
]
|
||||
}
|
||||
|
||||
/// Parse the comma-joined quota levels string into normalized `[Int]`:
|
||||
/// clamped 0...100, de-duplicated, sorted descending. Bad tokens are dropped.
|
||||
public static func parseQuotaLevels(_ raw: String) -> [Int] {
|
||||
let parsed = raw.split(separator: ",")
|
||||
.compactMap { Int($0.trimmingCharacters(in: .whitespaces)) }
|
||||
.map { min(100, max(0, $0)) }
|
||||
return Array(Set(parsed)).sorted(by: >)
|
||||
}
|
||||
|
||||
/// Serialize quota levels back to the comma-joined storage form (sorted desc).
|
||||
public static func encodeQuotaLevels(_ levels: [Int]) -> String {
|
||||
levels.map { min(100, max(0, $0)) }.sorted(by: >).map(String.init).joined(separator: ",")
|
||||
}
|
||||
|
||||
/// Decode a `BarAlertPrefs` from a plain `[key: value]` dictionary. Mirrors how
|
||||
/// the App will read each key from `UserDefaults`, but pure so it is testable.
|
||||
/// Missing keys fall back to the matching `BarAlertPrefs` default.
|
||||
public static func decode(from dict: [String: Any]) -> BarAlertPrefs {
|
||||
let d = BarAlertPrefs() // carries the canonical defaults
|
||||
|
||||
func bool(_ key: String, _ fallback: Bool) -> Bool { (dict[key] as? Bool) ?? fallback }
|
||||
func double(_ key: String, _ fallback: Double) -> Double {
|
||||
if let v = dict[key] as? Double { return v }
|
||||
if let v = dict[key] as? Int { return Double(v) }
|
||||
return fallback
|
||||
}
|
||||
|
||||
let levels: [Int]
|
||||
if let raw = dict[Key.quotaLevels] as? String {
|
||||
let parsed = parseQuotaLevels(raw)
|
||||
levels = parsed.isEmpty ? d.quotaLevels : parsed
|
||||
} else {
|
||||
levels = d.quotaLevels
|
||||
}
|
||||
|
||||
let mode = (dict[Key.glanceMode] as? String).flatMap(BarGlanceMode.init(rawValue:)) ?? d.glanceMode
|
||||
|
||||
return BarAlertPrefs(
|
||||
quotaEnabled: bool(Key.quotaEnabled, d.quotaEnabled),
|
||||
quotaLevels: levels,
|
||||
dailySpendEnabled: bool(Key.dailyEnabled, d.dailySpendEnabled),
|
||||
dailyCapUSD: double(Key.dailyCapUSD, d.dailyCapUSD),
|
||||
monthSpendEnabled: bool(Key.monthEnabled, d.monthSpendEnabled),
|
||||
monthCapUSD: double(Key.monthCapUSD, d.monthCapUSD),
|
||||
reauthEnabled: bool(Key.reauthEnabled, d.reauthEnabled),
|
||||
cooldownPausedEnabled: bool(Key.cooldownPausedEnabled, d.cooldownPausedEnabled),
|
||||
glanceMode: mode)
|
||||
}
|
||||
|
||||
/// Encode a `BarAlertPrefs` to the dictionary form the App writes to
|
||||
/// `UserDefaults` (levels comma-joined, mode as raw value).
|
||||
public static func encode(_ prefs: BarAlertPrefs) -> [String: Any] {
|
||||
[
|
||||
Key.quotaEnabled: prefs.quotaEnabled,
|
||||
Key.quotaLevels: encodeQuotaLevels(prefs.quotaLevels),
|
||||
Key.dailyEnabled: prefs.dailySpendEnabled,
|
||||
Key.dailyCapUSD: prefs.dailyCapUSD,
|
||||
Key.monthEnabled: prefs.monthSpendEnabled,
|
||||
Key.monthCapUSD: prefs.monthCapUSD,
|
||||
Key.reauthEnabled: prefs.reauthEnabled,
|
||||
Key.cooldownPausedEnabled: prefs.cooldownPausedEnabled,
|
||||
Key.glanceMode: prefs.glanceMode.rawValue,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import Foundation
|
||||
|
||||
/// Usage analytics for the menu bar, mirroring `GET /api/bar/analytics`.
|
||||
///
|
||||
/// Rolled up server-side from the persisted CLIProxy usage snapshot. All cost
|
||||
/// values are USD.
|
||||
|
||||
/// Spend and request count for one usage surface (tool/origin), e.g. "Claude Code"
|
||||
/// or "Codex". The server sends these ordered descending by cost for the same window
|
||||
/// as topModels, so the array can be rendered as-is.
|
||||
public struct BarAnalyticsSurface: Codable, Sendable, Equatable, Identifiable {
|
||||
public let source: String
|
||||
public let surface: String
|
||||
public let cost: Double
|
||||
public let requests: Int
|
||||
/// Stable identity for SwiftUI lists: surface name is unique within a window.
|
||||
public var id: String { surface }
|
||||
public init(source: String, surface: String, cost: Double, requests: Int) {
|
||||
self.source = source
|
||||
self.surface = surface
|
||||
self.cost = cost
|
||||
self.requests = requests
|
||||
}
|
||||
}
|
||||
|
||||
public struct BarAnalytics: Codable, Sendable, Equatable {
|
||||
public struct Window: Codable, Sendable, Equatable {
|
||||
public let cost: Double
|
||||
public let requests: Int
|
||||
public init(cost: Double, requests: Int) {
|
||||
self.cost = cost
|
||||
self.requests = requests
|
||||
}
|
||||
}
|
||||
|
||||
public struct Day: Codable, Sendable, Equatable, Identifiable {
|
||||
public let date: String
|
||||
public let cost: Double
|
||||
public let requests: Int
|
||||
public var id: String { date }
|
||||
public init(date: String, cost: Double, requests: Int) {
|
||||
self.date = date
|
||||
self.cost = cost
|
||||
self.requests = requests
|
||||
}
|
||||
}
|
||||
|
||||
public struct Model: Codable, Sendable, Equatable, Identifiable {
|
||||
public let model: String
|
||||
public let cost: Double
|
||||
public let requests: Int
|
||||
public var id: String { model }
|
||||
public init(model: String, cost: Double, requests: Int) {
|
||||
self.model = model
|
||||
self.cost = cost
|
||||
self.requests = requests
|
||||
}
|
||||
}
|
||||
|
||||
public let today: Window
|
||||
public let last7d: Window
|
||||
public let last30d: Window
|
||||
/// Honest calendar month-to-date (1st of the current local month → now), NOT a
|
||||
/// rolling 30 days. A fresh month resets this toward ~0 even when `last30d`
|
||||
/// stays populated, so a monthly-cap alert measures the real billing month.
|
||||
public let monthToDate: Window
|
||||
public let allTime: Window
|
||||
/// Oldest → newest, exactly 30 zero-filled entries, for the sparkline.
|
||||
public let byDay: [Day]
|
||||
public let topModels: [Model]
|
||||
/// "30d" when recent data exists, else "all".
|
||||
public let topModelsWindow: String
|
||||
/// ISO timestamp of the most recent non-failed usage record, null if none.
|
||||
public let lastActivityAt: String?
|
||||
/// Whole local-days since `lastActivityAt`, null if no usable records.
|
||||
public let daysSinceLastActivity: Int?
|
||||
/// True when the trailing 30 days carry any spend or requests. The UI pivots
|
||||
/// its empty/stale presentation on this without re-deriving it.
|
||||
public let hasRecentData: Bool
|
||||
public let generatedAt: String
|
||||
/// Spend/requests per usage surface (e.g. "Claude Code", "Codex"), ordered
|
||||
/// descending by cost for the same window as topModels. Empty when the backend
|
||||
/// has no surface breakdown; the UI omits the section in that case.
|
||||
public let bySurface: [BarAnalyticsSurface]
|
||||
|
||||
public init(
|
||||
today: Window,
|
||||
last7d: Window,
|
||||
last30d: Window,
|
||||
monthToDate: Window = Window(cost: 0, requests: 0),
|
||||
allTime: Window,
|
||||
byDay: [Day],
|
||||
topModels: [Model],
|
||||
topModelsWindow: String,
|
||||
lastActivityAt: String? = nil,
|
||||
daysSinceLastActivity: Int? = nil,
|
||||
hasRecentData: Bool = false,
|
||||
generatedAt: String,
|
||||
bySurface: [BarAnalyticsSurface] = []
|
||||
) {
|
||||
self.today = today
|
||||
self.last7d = last7d
|
||||
self.last30d = last30d
|
||||
self.monthToDate = monthToDate
|
||||
self.allTime = allTime
|
||||
self.byDay = byDay
|
||||
self.topModels = topModels
|
||||
self.topModelsWindow = topModelsWindow
|
||||
self.lastActivityAt = lastActivityAt
|
||||
self.daysSinceLastActivity = daysSinceLastActivity
|
||||
self.hasRecentData = hasRecentData
|
||||
self.generatedAt = generatedAt
|
||||
self.bySurface = bySurface
|
||||
}
|
||||
|
||||
// Custom decoder: `bySurface` is a new field absent from older snapshots.
|
||||
// Defaulting to [] when the key is missing keeps the app backward-compatible
|
||||
// with any cached or older-backend analytics payload.
|
||||
public init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
today = try c.decode(Window.self, forKey: .today)
|
||||
last7d = try c.decode(Window.self, forKey: .last7d)
|
||||
last30d = try c.decode(Window.self, forKey: .last30d)
|
||||
// `monthToDate` is a newer field; older cached payloads omit it. Default to a
|
||||
// zero window so a stale cache decodes cleanly instead of throwing.
|
||||
monthToDate =
|
||||
(try c.decodeIfPresent(Window.self, forKey: .monthToDate)) ?? Window(cost: 0, requests: 0)
|
||||
allTime = try c.decode(Window.self, forKey: .allTime)
|
||||
byDay = try c.decode([Day].self, forKey: .byDay)
|
||||
topModels = try c.decode([Model].self, forKey: .topModels)
|
||||
topModelsWindow = try c.decode(String.self, forKey: .topModelsWindow)
|
||||
lastActivityAt = try c.decodeIfPresent(String.self, forKey: .lastActivityAt)
|
||||
daysSinceLastActivity = try c.decodeIfPresent(Int.self, forKey: .daysSinceLastActivity)
|
||||
hasRecentData = try c.decode(Bool.self, forKey: .hasRecentData)
|
||||
generatedAt = try c.decode(String.self, forKey: .generatedAt)
|
||||
bySurface = (try c.decodeIfPresent([BarAnalyticsSurface].self, forKey: .bySurface)) ?? []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - SpendChartStyle
|
||||
|
||||
/// User-selectable render style for the spend sparkline in the dropdown.
|
||||
///
|
||||
/// `.bars` (default) draws the existing RoundedRectangle bar chart.
|
||||
/// `.line` draws a Path-based line graph with a faint area fill, which is
|
||||
/// better for spotting trend direction across the 30-day window.
|
||||
///
|
||||
/// Sendable + CaseIterable so the harness can iterate all cases and the value
|
||||
/// can cross actor boundaries safely.
|
||||
public enum SpendChartStyle: String, CaseIterable, Sendable {
|
||||
case bars
|
||||
case line
|
||||
}
|
||||
|
||||
// MARK: - SpendChartStyleStore
|
||||
|
||||
/// Persists the chosen spend-chart style. Mirrors the BarAppearanceStore pattern:
|
||||
/// a UserDefaults key, a static load, and a static save. The `?? .bars` fallback
|
||||
/// on a nil/unrecognized raw value is the sole source of the default.
|
||||
public enum SpendChartStyleStore {
|
||||
public static let defaultsKey = "ccsbar.spendChartStyle"
|
||||
|
||||
public static func load() -> SpendChartStyle {
|
||||
let raw = UserDefaults.standard.string(forKey: defaultsKey)
|
||||
?? SpendChartStyle.bars.rawValue
|
||||
return SpendChartStyle(rawValue: raw) ?? .bars
|
||||
}
|
||||
|
||||
public static func save(_ style: SpendChartStyle) {
|
||||
UserDefaults.standard.set(style.rawValue, forKey: defaultsKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
|
||||
/// Connection handshake the app reads to find the running CCS web-server.
|
||||
///
|
||||
/// Written by `ccs bar launch` to `~/.ccs/bar.json`. v1 only supports
|
||||
/// `authMode == "loopback"` (dashboard auth disabled, localhost).
|
||||
public struct BarDiscovery: Codable, Sendable, Equatable {
|
||||
public let baseUrl: String
|
||||
public let port: Int
|
||||
public let authMode: String
|
||||
|
||||
public init(baseUrl: String, port: Int, authMode: String) {
|
||||
self.baseUrl = baseUrl
|
||||
self.port = port
|
||||
self.authMode = authMode
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case baseUrl
|
||||
case port
|
||||
case authMode
|
||||
}
|
||||
|
||||
/// Resolved base URL, falling back to a localhost URL built from `port`
|
||||
/// when `baseUrl` is empty or unparseable.
|
||||
public var resolvedURL: URL? {
|
||||
if let url = URL(string: baseUrl), url.scheme != nil { return url }
|
||||
return URL(string: "http://127.0.0.1:\(port)")
|
||||
}
|
||||
|
||||
public enum LoadError: Error, Equatable {
|
||||
case missing(path: String)
|
||||
case unreadable(path: String)
|
||||
case malformed
|
||||
}
|
||||
|
||||
/// Default discovery file path under the given home directory.
|
||||
public static func defaultPath(home: String = NSHomeDirectory()) -> String {
|
||||
URL(fileURLWithPath: home)
|
||||
.appendingPathComponent(".ccs")
|
||||
.appendingPathComponent("bar.json")
|
||||
.path
|
||||
}
|
||||
|
||||
/// Load discovery from `~/.ccs/bar.json`. Returns a typed error when the
|
||||
/// file is absent (CCS not launched) or malformed so the UI can show a
|
||||
/// clear "CCS offline" state instead of crashing.
|
||||
public static func load(home: String = NSHomeDirectory()) -> Result<BarDiscovery, LoadError> {
|
||||
let path = defaultPath(home: home)
|
||||
guard FileManager.default.fileExists(atPath: path) else {
|
||||
return .failure(.missing(path: path))
|
||||
}
|
||||
guard let data = FileManager.default.contents(atPath: path) else {
|
||||
return .failure(.unreadable(path: path))
|
||||
}
|
||||
guard let discovery = try? JSONDecoder().decode(BarDiscovery.self, from: data) else {
|
||||
return .failure(.malformed)
|
||||
}
|
||||
return .success(discovery)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure formatting helpers for the status-bar title and dropdown rows.
|
||||
/// No SwiftUI dependency so they are unit-testable on any toolchain.
|
||||
public enum BarFormatting {
|
||||
/// Tri-state quota label. Honest about WHY a percentage is missing instead of
|
||||
/// collapsing every case to a bare "--":
|
||||
/// status "ok" + pct → "NN%" (threshold-colored upstream)
|
||||
/// status "unsupported" → "no quota" (provider has no quota API)
|
||||
/// status "error" → "quota ?" (transient fetch failure)
|
||||
/// An "ok" status with a nil percentage (shouldn't happen, but be safe) also
|
||||
/// degrades to "quota ?" rather than "--".
|
||||
public static func quotaLabel(percentage pct: Double?, status: String) -> String {
|
||||
switch status {
|
||||
case "ok":
|
||||
guard let pct else { return "quota ?" }
|
||||
return "\(Int(pct.rounded()))%"
|
||||
case "unsupported":
|
||||
return "no quota"
|
||||
default:
|
||||
return "quota ?"
|
||||
}
|
||||
}
|
||||
|
||||
/// Quota title token: only an "ok" row with a real percentage yields a token
|
||||
/// (so "unsupported"/"error" rows can never produce "--" in the menu-bar
|
||||
/// title and the fallback chain falls through instead). Returns nil to skip.
|
||||
public static func quotaTitleToken(percentage pct: Double?, status: String) -> String? {
|
||||
guard status == "ok", let pct else { return nil }
|
||||
return "\(Int(pct.rounded()))%"
|
||||
}
|
||||
|
||||
/// Today cost label, e.g. "$3.20" or "" when unknown/zero-not-shown.
|
||||
public static func costLabel(_ cost: Double?) -> String {
|
||||
guard let cost, cost > 0 else { return "" }
|
||||
return String(format: "$%.2f", cost)
|
||||
}
|
||||
|
||||
/// Always-visible compact currency, e.g. "$0.00", "$12.34", "$2.6k", "$1.3M".
|
||||
/// Used for the analytics rollups where zero is meaningful (no spend yet).
|
||||
public static func money(_ v: Double) -> String {
|
||||
let n = max(0, v)
|
||||
if n >= 1_000_000 { return String(format: "$%.1fM", n / 1_000_000) }
|
||||
if n >= 1_000 { return String(format: "$%.1fk", n / 1_000) }
|
||||
return String(format: "$%.2f", n)
|
||||
}
|
||||
|
||||
/// Compact integer count, e.g. "5", "1.2k", "3.4M".
|
||||
public static func count(_ n: Int) -> String {
|
||||
if n >= 1_000_000 { return String(format: "%.1fM", Double(n) / 1_000_000) }
|
||||
if n >= 1_000 { return String(format: "%.1fk", Double(n) / 1_000) }
|
||||
return "\(n)"
|
||||
}
|
||||
|
||||
/// Compact, always-meaningful status-bar title. Evaluates an ordered fallback
|
||||
/// chain left to right; the first step that yields a non-empty token wins. A
|
||||
/// bare "--" is NEVER emitted — every step degrades to the next instead.
|
||||
///
|
||||
/// 1. QUOTA — lowest remaining quota among rows whose quotaStatus=="ok"
|
||||
/// with a real percentage → "<provider> NN%" (e.g. "agy 12%").
|
||||
/// "unsupported"/"error" rows are skipped so they can't show "--".
|
||||
/// 2. TODAY COST — else analytics.today.cost > 0 → "$<today>" (e.g. "$3.20").
|
||||
/// Uses the fresh aggregate from analytics, not per-row today_cost.
|
||||
/// 3. ATTENTION/COUNT — else rows needing reauth → "CCS <n>!"; else active
|
||||
/// (non-paused) count → "CCS <n>", fallback to total count.
|
||||
/// 4. "CCS" — only when there are no rows at all.
|
||||
///
|
||||
/// All-time spend is deliberately EXCLUDED from the title chain: a lifetime dollar
|
||||
/// figure (e.g. "$40.8k") always reads as live spend in the always-on menu bar,
|
||||
/// creating false urgency. It belongs only in the analytics section of the dropdown.
|
||||
public static func statusTitle(rows: [BarSummaryRow], analytics: BarAnalytics?) -> String {
|
||||
if rows.isEmpty { return "CCS" }
|
||||
|
||||
// (1) QUOTA — closest to exhaustion among quota-capable rows.
|
||||
let quotaRows = rows.filter { $0.quotaStatus == "ok" && $0.quotaPercentage != nil }
|
||||
if let lead = quotaRows.min(by: { ($0.quotaPercentage ?? 0) < ($1.quotaPercentage ?? 0) }),
|
||||
let token = quotaTitleToken(percentage: lead.quotaPercentage, status: lead.quotaStatus)
|
||||
{
|
||||
return "\(lead.provider) \(token)"
|
||||
}
|
||||
|
||||
// (2) TODAY COST — fresh aggregate from analytics (more accurate than summing
|
||||
// per-row today_cost, which may have nulls or stale snapshot values).
|
||||
if let todayCost = analytics?.today.cost, todayCost > 0 {
|
||||
return money(todayCost)
|
||||
}
|
||||
|
||||
// (3) ATTENTION / ACTIVE COUNT.
|
||||
let reauthCount = rows.filter { $0.needsReauth }.count
|
||||
if reauthCount > 0 {
|
||||
return "CCS \(reauthCount)!"
|
||||
}
|
||||
let activeCount = rows.filter { !$0.paused }.count
|
||||
return "CCS \(activeCount > 0 ? activeCount : rows.count)"
|
||||
}
|
||||
|
||||
/// Glance-mode title resolver. The user picks which figure leads the menu-bar
|
||||
/// title; every mode degrades to the `.auto` fallback chain rather than show a
|
||||
/// dead "$0.00" or a misleading lifetime dollar. A LIFETIME / allTime figure
|
||||
/// NEVER appears in any mode — that invariant is what keeps the always-on bar
|
||||
/// from reading like live spend.
|
||||
public static func statusTitle(
|
||||
rows: [BarSummaryRow], analytics: BarAnalytics?, mode: BarGlanceMode
|
||||
) -> String {
|
||||
switch mode {
|
||||
case .auto:
|
||||
return statusTitle(rows: rows, analytics: analytics)
|
||||
|
||||
case .todaySpend:
|
||||
// Avoid a dead "$0.00" sitting in the bar: only lead with today's spend
|
||||
// when there is some; otherwise fall through to the auto chain.
|
||||
if let c = analytics?.today.cost, c > 0 { return money(c) }
|
||||
return statusTitle(rows: rows, analytics: analytics)
|
||||
|
||||
case .monthSpend:
|
||||
// Calendar month-to-date (the new backend field), NOT last30d/allTime.
|
||||
if let c = analytics?.monthToDate.cost, c > 0 { return money(c) }
|
||||
return statusTitle(rows: rows, analytics: analytics)
|
||||
|
||||
case .lowestQuota:
|
||||
// Step (1) of the auto chain only: lowest remaining "ok" quota.
|
||||
let quotaRows = rows.filter { $0.quotaStatus == "ok" && $0.quotaPercentage != nil }
|
||||
if let lead = quotaRows.min(by: { ($0.quotaPercentage ?? 0) < ($1.quotaPercentage ?? 0) }),
|
||||
let token = quotaTitleToken(percentage: lead.quotaPercentage, status: lead.quotaStatus)
|
||||
{
|
||||
return "\(lead.provider) \(token)"
|
||||
}
|
||||
return statusTitle(rows: rows, analytics: analytics)
|
||||
|
||||
case .accountCount:
|
||||
// Non-paused count, falling back to total when every account is paused.
|
||||
// Never appends "!" — that attention marker is an .auto-only signal.
|
||||
if rows.isEmpty { return statusTitle(rows: rows, analytics: analytics) }
|
||||
let active = rows.filter { !$0.paused }.count
|
||||
return "CCS \(active > 0 ? active : rows.count)"
|
||||
}
|
||||
}
|
||||
|
||||
/// The "headline" account for the dropdown when no quota exists (which account
|
||||
/// name leads). Deterministic: prefer the default row, else the sole active
|
||||
/// row, else alphabetical by id — never `rows.first` (arbitrary order).
|
||||
public static func leadRow(_ rows: [BarSummaryRow]) -> BarSummaryRow? {
|
||||
if rows.isEmpty { return nil }
|
||||
if let def = rows.first(where: { $0.isDefault }) { return def }
|
||||
let active = rows.filter { !$0.paused }
|
||||
if active.count == 1 { return active[0] }
|
||||
return rows.min(by: { $0.id < $1.id })
|
||||
}
|
||||
|
||||
/// Human "Last active" caption from an ISO timestamp + a precomputed day-delta.
|
||||
/// "Last active today" / "yesterday" / "Apr 29" — never a raw ISO string.
|
||||
public static func lastActiveLabel(iso: String?, daysSince: Int?) -> String? {
|
||||
guard let iso, let date = isoDate(iso) else { return nil }
|
||||
if let d = daysSince {
|
||||
if d <= 0 { return "Last active today" }
|
||||
if d == 1 { return "Last active yesterday" }
|
||||
}
|
||||
let fmt = DateFormatter()
|
||||
fmt.locale = Locale(identifier: "en_US_POSIX")
|
||||
fmt.dateFormat = "MMM d"
|
||||
return "Last active \(fmt.string(from: date))"
|
||||
}
|
||||
|
||||
/// True when a row is a native first-party subscription (the user's own Claude
|
||||
/// Code or Codex plan) rather than a CLIProxy-managed OAuth pool account. Drives
|
||||
/// the "Subscriptions" grouping + badge so a user reads "this is MY plan quota",
|
||||
/// not one of the rotating pool credentials.
|
||||
public static func isNativeSubscription(provider: String) -> Bool {
|
||||
provider == "claude-code" || provider == "codex"
|
||||
}
|
||||
|
||||
/// Friendly product label for a provider key. Native subscription keys read as
|
||||
/// products ("Claude Code", "Codex"); any other provider passes through verbatim
|
||||
/// (so "agy"/"ghcp"/"kiro" keep their established short chip text).
|
||||
public static func providerLabel(_ provider: String) -> String {
|
||||
switch provider {
|
||||
case "claude-code": return "Claude Code"
|
||||
case "codex": return "Codex"
|
||||
default: return provider
|
||||
}
|
||||
}
|
||||
|
||||
/// Partition rows into (native subscriptions, CLIProxy pool accounts) while
|
||||
/// preserving the backend's order within each group. Used by the dropdown to
|
||||
/// render subscriptions above the pool. Pure so it is testable in Core.
|
||||
public static func partitionSubscriptions(
|
||||
_ rows: [BarSummaryRow]
|
||||
) -> (subscriptions: [BarSummaryRow], pool: [BarSummaryRow]) {
|
||||
var subs: [BarSummaryRow] = []
|
||||
var pool: [BarSummaryRow] = []
|
||||
for row in rows {
|
||||
if isNativeSubscription(provider: row.provider) {
|
||||
subs.append(row)
|
||||
} else {
|
||||
pool.append(row)
|
||||
}
|
||||
}
|
||||
return (subs, pool)
|
||||
}
|
||||
|
||||
/// Parse an ISO-8601 timestamp (with or without fractional seconds).
|
||||
static func isoDate(_ iso: String) -> Date? {
|
||||
let withFraction = ISO8601DateFormatter()
|
||||
withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let d = withFraction.date(from: iso) { return d }
|
||||
let plain = ISO8601DateFormatter()
|
||||
plain.formatOptions = [.withInternetDateTime]
|
||||
return plain.date(from: iso)
|
||||
}
|
||||
}
|
||||
|
||||
/// Force-refresh debounce. Arms the window at decision time so concurrent
|
||||
/// open-triggered refreshes do not both bypass it (matches the server-side
|
||||
/// 15s debounce on `/api/bar/summary?refresh=true`).
|
||||
public struct RefreshDebouncer {
|
||||
public let interval: TimeInterval
|
||||
private var lastArmed: Date?
|
||||
|
||||
public init(interval: TimeInterval = 15) {
|
||||
self.interval = interval
|
||||
}
|
||||
|
||||
/// Returns true and arms the window if a force-refresh should proceed at
|
||||
/// `now`; returns false when still inside the previous window.
|
||||
public mutating func shouldRefresh(now: Date) -> Bool {
|
||||
if let lastArmed, now.timeIntervalSince(lastArmed) < interval {
|
||||
return false
|
||||
}
|
||||
lastArmed = now
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure quota-gauge math: band selection, fill fraction, and reset-countdown
|
||||
/// formatting. No SwiftUI dependency and no implicit clock — `now` is injected
|
||||
/// for `resetCountdown` so the gauge is fully deterministic and testable. The
|
||||
/// App layer renders a ring/bar from these values; all branch/color/countdown
|
||||
/// logic lives here so the view stays a thin render.
|
||||
public enum BarQuotaGauge {
|
||||
/// Severity band for the remaining-quota percentage. `.none` means the row
|
||||
/// has no live quota (unsupported provider, fetch error, or a nil percentage)
|
||||
/// and the gauge should not be drawn at all.
|
||||
public enum Band: String, Sendable, Equatable, CaseIterable {
|
||||
case green
|
||||
case yellow
|
||||
case orange
|
||||
case red
|
||||
case none
|
||||
}
|
||||
|
||||
/// Map a remaining-quota percentage to a severity band. Only a status of "ok"
|
||||
/// with a real percentage yields a colored band; everything else is `.none`.
|
||||
/// Boundaries (remaining): >50 green, 21...50 yellow, 11...20 orange, <=10 red.
|
||||
public static func band(percentage pct: Double?, status: String) -> Band {
|
||||
guard status == "ok", let pct else { return .none }
|
||||
if pct > 50 { return .green }
|
||||
if pct > 20 { return .yellow }
|
||||
if pct > 10 { return .orange }
|
||||
return .red
|
||||
}
|
||||
|
||||
/// Fraction of the ring/bar to fill: remaining/100 clamped to 0...1. Returns
|
||||
/// nil when there is no live quota (so the view can fall back to a text label
|
||||
/// instead of drawing an empty gauge).
|
||||
public static func fillFraction(percentage pct: Double?, status: String) -> Double? {
|
||||
guard status == "ok", let pct else { return nil }
|
||||
return min(1, max(0, pct / 100))
|
||||
}
|
||||
|
||||
/// Human countdown to the next quota reset, e.g. "resets in 1d 21h",
|
||||
/// "resets in 3h 12m", "resets in 12m", or "resets soon" when the reset
|
||||
/// time is at/in the past. Returns nil for nil/unparseable timestamps.
|
||||
/// `now` is injected so the formatting is deterministic and unit-testable.
|
||||
public static func resetCountdown(nextReset: String?, now: Date) -> String? {
|
||||
guard let nextReset, let reset = BarFormatting.isoDate(nextReset) else { return nil }
|
||||
let secs = reset.timeIntervalSince(now)
|
||||
if secs <= 0 { return "resets soon" }
|
||||
let totalMinutes = Int(secs / 60)
|
||||
// Three-tier: days (>=24h) → hours+minutes (1h-24h) → minutes-only (<1h).
|
||||
return "resets in \(compactDuration(minutes: totalMinutes))"
|
||||
}
|
||||
|
||||
// MARK: Burn-rate projection (single-window, no history)
|
||||
|
||||
/// Project minutes-to-exhaustion for ONE quota window from a single snapshot.
|
||||
///
|
||||
/// Linear model, no smoothing, no cross-window inference: a window of length
|
||||
/// `windowMinutes` that resets at `resetAt` started at `resetAt - windowMinutes`
|
||||
/// and has been running `elapsed = windowMinutes - max(0, (resetAt - now)/60)`
|
||||
/// minutes. The window's OWN average burn rate is `usedPercent / elapsed`
|
||||
/// (%/min); minutes left to hit 100% is `(100 - usedPercent) / rate`, which
|
||||
/// simplifies to `(100 - usedPercent) * elapsed / usedPercent`.
|
||||
///
|
||||
/// Returns:
|
||||
/// - nil when any input is unknown (windowMinutes/resetAt nil, elapsed <= 0):
|
||||
/// the caller OMITS the pace clause rather than guessing.
|
||||
/// - nil when usage is near-zero (<= ~1%): burn is negligible, the caller
|
||||
/// renders "plenty at this pace" instead of an absurdly large projection.
|
||||
/// - 0 when already exhausted (usedPercent >= 100): "limit reached".
|
||||
/// - otherwise the projected whole minutes remaining at the current pace.
|
||||
public static func burnMinutesRemaining(
|
||||
usedPercent: Double, resetAt: Date?, windowMinutes: Int?, now: Date
|
||||
) -> Int? {
|
||||
guard let windowMinutes, let resetAt else { return nil }
|
||||
let minutesToReset = resetAt.timeIntervalSince(now) / 60
|
||||
let elapsed = Double(windowMinutes) - max(0, minutesToReset)
|
||||
guard elapsed > 0 else { return nil }
|
||||
if usedPercent >= 100 { return 0 }
|
||||
// Near-zero burn would project an effectively infinite runway; treat it as
|
||||
// "plenty" (nil) so the phrasing layer can say so honestly.
|
||||
guard usedPercent > 1.0 else { return nil }
|
||||
let remaining = (100 - usedPercent) * elapsed / usedPercent
|
||||
return Int(remaining)
|
||||
}
|
||||
|
||||
/// Pick the BINDING window: the one a subscription runs out of first, i.e. the
|
||||
/// lowest `remainingPercent` (closest to empty). Ties break to the shorter
|
||||
/// window first (5h before week), then by a stable key order so the choice is
|
||||
/// deterministic. Opus/Sonnet windows are eligible. Returns nil for empty
|
||||
/// input (error/reauth rows have no windows, so they get no hero gauge).
|
||||
public static func selectBindingWindow(_ windows: [QuotaWindowDetail]) -> QuotaWindowDetail? {
|
||||
guard !windows.isEmpty else { return nil }
|
||||
return windows.min { a, b in
|
||||
if a.remainingPercent != b.remainingPercent {
|
||||
return a.remainingPercent < b.remainingPercent
|
||||
}
|
||||
let am = a.windowMinutes ?? Int.max
|
||||
let bm = b.windowMinutes ?? Int.max
|
||||
if am != bm { return am < bm }
|
||||
return keyRank(a.key) < keyRank(b.key)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable ordering for window keys when remaining% and length tie.
|
||||
private static func keyRank(_ key: String) -> Int {
|
||||
switch key {
|
||||
case "five_hour": return 0
|
||||
case "seven_day": return 1
|
||||
case "seven_day_opus": return 2
|
||||
case "seven_day_sonnet": return 3
|
||||
default: return 4
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a window is GENUINELY at risk of exhaustion before it resets.
|
||||
///
|
||||
/// Returns true only when the projected exhaustion time (burn rate × remaining
|
||||
/// headroom) is LESS than the time remaining until the next reset — i.e. the
|
||||
/// user will hit the wall before the window refreshes. When the projection is
|
||||
/// larger than the reset countdown the warning is meaningless (the quota will
|
||||
/// reset before running out), so atRisk returns false and no scary number is
|
||||
/// shown. Inputs mirror `paceClause`; `now` is injected for testability.
|
||||
public static func atRisk(
|
||||
usedPercent: Double,
|
||||
remainingPercent: Double,
|
||||
resetAt: String?,
|
||||
windowMinutes: Int?,
|
||||
status: String = "ok",
|
||||
now: Date
|
||||
) -> Bool {
|
||||
guard status != "rejected", remainingPercent > 0 else { return false }
|
||||
guard let resetDateStr = resetAt,
|
||||
let resetDate = BarFormatting.isoDate(resetDateStr)
|
||||
else { return false }
|
||||
let minutesToReset = resetDate.timeIntervalSince(now) / 60
|
||||
guard minutesToReset > 0 else { return false }
|
||||
guard let burn = burnMinutesRemaining(
|
||||
usedPercent: usedPercent, resetAt: resetDate, windowMinutes: windowMinutes, now: now)
|
||||
else { return false }
|
||||
// burn == 0 means already exhausted — that is handled by the exhausted path, not atRisk.
|
||||
guard burn > 0 else { return false }
|
||||
// Only at-risk when we will exhaust BEFORE the window resets.
|
||||
return Double(burn) < minutesToReset
|
||||
}
|
||||
|
||||
/// Trailing pace clause for a window's hero/footer line, or nil to OMIT it.
|
||||
///
|
||||
/// Phrasing rules (in order):
|
||||
/// - exhausted (remaining <= 0) or status "rejected" → "limit reached,
|
||||
/// resets in <countdown>". This REPLACES the bare reset countdown.
|
||||
/// - lots of headroom (remaining >= 85) or near-zero usage (burn == nil) →
|
||||
/// "plenty at this pace".
|
||||
/// - at-risk (projected exhaustion BEFORE reset): a finite projection m >= 5
|
||||
/// → "~<Hh Mm> left at this pace". m < 5 → limit-reached path.
|
||||
/// - NOT at-risk (burn > minutesToReset): the projection is beyond the reset,
|
||||
/// so showing the number is misleading — return nil (omit entirely).
|
||||
/// - unknown window (windowMinutes/resetAt nil, elapsed <= 0) → nil (omit).
|
||||
/// - resetAt already in the past (clock skew / stale) → nil pace.
|
||||
public static func paceClause(
|
||||
usedPercent: Double,
|
||||
remainingPercent: Double,
|
||||
resetAt: String?,
|
||||
windowMinutes: Int?,
|
||||
status: String = "ok",
|
||||
now: Date
|
||||
) -> String? {
|
||||
let resetDate = resetAt.flatMap { BarFormatting.isoDate($0) }
|
||||
|
||||
if remainingPercent <= 0 || status == "rejected" {
|
||||
if let countdown = resetCountdown(nextReset: resetAt, now: now) {
|
||||
// resetCountdown returns "resets in 3h 12m"; reuse just the duration.
|
||||
let duration = countdown.replacingOccurrences(of: "resets in ", with: "")
|
||||
return "limit reached, resets in \(duration)"
|
||||
}
|
||||
return "limit reached"
|
||||
}
|
||||
|
||||
// A reset in the past means our window math is unreliable; omit the pace.
|
||||
if let resetDate, resetDate.timeIntervalSince(now) <= 0 { return nil }
|
||||
|
||||
let burn = burnMinutesRemaining(
|
||||
usedPercent: usedPercent, resetAt: resetDate, windowMinutes: windowMinutes, now: now)
|
||||
|
||||
if remainingPercent >= 85 || burn == nil {
|
||||
// nil burn here is either unknown window (handled below) or near-zero use.
|
||||
if windowMinutes == nil || resetDate == nil { return nil }
|
||||
return "plenty at this pace"
|
||||
}
|
||||
|
||||
guard let m = burn else { return nil }
|
||||
if m < 5 {
|
||||
// Floor: anything under 5 minutes is effectively spent; say so plainly.
|
||||
if let countdown = resetCountdown(nextReset: resetAt, now: now) {
|
||||
let duration = countdown.replacingOccurrences(of: "resets in ", with: "")
|
||||
return "limit reached, resets in \(duration)"
|
||||
}
|
||||
return "limit reached"
|
||||
}
|
||||
|
||||
// Core at-risk gate: only show the projection when exhaustion is BEFORE the
|
||||
// reset. If burn >= minutesToReset the quota will outlast the window and the
|
||||
// number would be larger than the reset countdown — meaningless and confusing.
|
||||
let minutesToReset = resetDate.map { $0.timeIntervalSince(now) / 60 } ?? 0
|
||||
guard Double(m) < minutesToReset else { return nil }
|
||||
|
||||
return "~\(compactDuration(minutes: m)) left at this pace"
|
||||
}
|
||||
|
||||
/// Compact terse duration with a three-tier scale:
|
||||
/// >= 24h → "Nd Nh" (e.g. 1590m → "1d 2h", 2678m → "1d 21h")
|
||||
/// 1h–24h → "Hh Mm" (e.g. 195m → "3h 15m")
|
||||
/// < 1h → "Mm" (e.g. 35m → "35m")
|
||||
///
|
||||
/// Named `compactDuration` and `public` so it is reusable from App-side
|
||||
/// formatting (BarCardFormatting) without duplicating the logic.
|
||||
public static func compactDuration(minutes: Int) -> String {
|
||||
let totalHours = minutes / 60
|
||||
let m = minutes % 60
|
||||
if totalHours >= 24 {
|
||||
let d = totalHours / 24
|
||||
let h = totalHours % 24
|
||||
return "\(d)d \(h)h"
|
||||
}
|
||||
if totalHours > 0 { return "\(totalHours)h \(m)m" }
|
||||
return "\(m)m"
|
||||
}
|
||||
|
||||
/// Header "most room" leader: among subscription rows that have a binding
|
||||
/// window, the one whose BINDING window has the HIGHEST remaining%. Rows with
|
||||
/// no binding window (error/reauth) are excluded. Tie-breaks alphabetically by
|
||||
/// display name (falling back to provider). Returns nil with fewer than two
|
||||
/// eligible subscriptions (the header is suppressed below that).
|
||||
public static func headroomLeader(_ rows: [BarSummaryRow]) -> (label: String, remainingPercent: Double)? {
|
||||
let eligible: [(label: String, remaining: Double)] = rows.compactMap { row in
|
||||
guard let binding = selectBindingWindow(row.quotaWindows ?? []) else { return nil }
|
||||
let label = row.displayName ?? row.provider
|
||||
return (label, binding.remainingPercent)
|
||||
}
|
||||
guard eligible.count >= 2 else { return nil }
|
||||
let leader = eligible.max { a, b in
|
||||
if a.remaining != b.remaining { return a.remaining < b.remaining }
|
||||
// Highest remaining wins; alphabetical tie-break (smaller name "wins" max
|
||||
// only when remaining is equal, so invert the name comparison).
|
||||
return a.label > b.label
|
||||
}
|
||||
guard let leader else { return nil }
|
||||
return (leader.label, leader.remaining)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import Foundation
|
||||
|
||||
/// One quota window for a native subscription row (Claude/Codex).
|
||||
///
|
||||
/// Carries BOTH `usedPercent` and `remainingPercent` verbatim from the backend
|
||||
/// so the bar never re-derives one from the other (a single source of truth
|
||||
/// avoids rounding drift between the collapsed glance value and the per-window
|
||||
/// pace math). `windowMinutes` is the window length (300 = 5h, 10080 = 7d) used
|
||||
/// by the burn-rate projection; nil when the backend could not determine it.
|
||||
///
|
||||
/// JSON: the parent field serializes to "quota_windows" (snake_case), but the
|
||||
/// inner keys stay camelCase to match the live serializer.
|
||||
public struct QuotaWindowDetail: Codable, Sendable, Equatable, Identifiable {
|
||||
public let key: String
|
||||
public let label: String
|
||||
public let usedPercent: Double
|
||||
public let remainingPercent: Double
|
||||
public let resetAt: String?
|
||||
public let windowMinutes: Int?
|
||||
|
||||
/// SwiftUI list identity: the window key is unique within a row.
|
||||
public var id: String { key }
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
label: String,
|
||||
usedPercent: Double,
|
||||
remainingPercent: Double,
|
||||
resetAt: String? = nil,
|
||||
windowMinutes: Int? = nil
|
||||
) {
|
||||
self.key = key
|
||||
self.label = label
|
||||
self.usedPercent = usedPercent
|
||||
self.remainingPercent = remainingPercent
|
||||
self.resetAt = resetAt
|
||||
self.windowMinutes = windowMinutes
|
||||
}
|
||||
}
|
||||
|
||||
/// One account row in the menu-bar glance.
|
||||
///
|
||||
/// Mirrors the `GET /api/bar/summary` payload exactly (mixed snake_case and
|
||||
/// camelCase keys, matching the CCS web-server response).
|
||||
public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
|
||||
public let accountId: String
|
||||
public let provider: String
|
||||
public let displayName: String?
|
||||
public let tier: String?
|
||||
public let paused: Bool
|
||||
public let quotaPercentage: Double?
|
||||
/// Tri-state quota availability: "ok" (provider has a quota API and the fetch
|
||||
/// succeeded), "unsupported" (provider has no quota API at all, e.g. ghcp/kiro),
|
||||
/// or "error" (should report quota but the fetch failed/timed out/needs reauth).
|
||||
/// Drives "no quota" (unsupported) vs "quota ?" (error) so a bare "--" never
|
||||
/// conflates the two.
|
||||
public let quotaStatus: String
|
||||
public let nextReset: String?
|
||||
/// True when this is the provider's default account; drives the active/default badge.
|
||||
public let isDefault: Bool
|
||||
/// ISO timestamp this account was last used, null if never/unknown.
|
||||
public let lastActivityAt: String?
|
||||
public let todayCost: Double?
|
||||
public let health: String
|
||||
public let cached: Bool
|
||||
public let fetchedAt: String?
|
||||
public let needsReauth: Bool
|
||||
/// Native-only per-window quota breakdown (Claude: 5h/week/opus/sonnet,
|
||||
/// Codex: 5h/week). nil for CLIProxy pool rows, which omit "quota_windows"
|
||||
/// entirely — so legacy payloads decode unchanged (backward compatible).
|
||||
public let quotaWindows: [QuotaWindowDetail]?
|
||||
/// Native-only ISO mtime of the source session that supplied a STALE Codex
|
||||
/// reading. Present only when the data is stale; nil otherwise. Drives the
|
||||
/// "as of HH:mm (older session)" footnote without faking a "live" badge.
|
||||
public let staleAsOf: String?
|
||||
|
||||
/// Stable identity for SwiftUI lists: provider-scoped account id.
|
||||
public var id: String { "\(provider):\(accountId)" }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accountId = "account_id"
|
||||
case provider
|
||||
case displayName
|
||||
case tier
|
||||
case paused
|
||||
case quotaPercentage = "quota_percentage"
|
||||
case quotaStatus
|
||||
case nextReset = "next_reset"
|
||||
case isDefault = "is_default"
|
||||
case lastActivityAt = "last_activity_at"
|
||||
case todayCost = "today_cost"
|
||||
case health
|
||||
case cached
|
||||
case fetchedAt
|
||||
case needsReauth
|
||||
case quotaWindows = "quota_windows"
|
||||
case staleAsOf = "stale_as_of"
|
||||
}
|
||||
|
||||
public init(
|
||||
accountId: String,
|
||||
provider: String,
|
||||
displayName: String? = nil,
|
||||
tier: String? = nil,
|
||||
paused: Bool = false,
|
||||
quotaPercentage: Double? = nil,
|
||||
quotaStatus: String = "ok",
|
||||
nextReset: String? = nil,
|
||||
isDefault: Bool = false,
|
||||
lastActivityAt: String? = nil,
|
||||
todayCost: Double? = nil,
|
||||
health: String = "ok",
|
||||
cached: Bool = false,
|
||||
fetchedAt: String? = nil,
|
||||
needsReauth: Bool = false,
|
||||
quotaWindows: [QuotaWindowDetail]? = nil,
|
||||
staleAsOf: String? = nil
|
||||
) {
|
||||
self.accountId = accountId
|
||||
self.provider = provider
|
||||
self.displayName = displayName
|
||||
self.tier = tier
|
||||
self.paused = paused
|
||||
self.quotaPercentage = quotaPercentage
|
||||
self.quotaStatus = quotaStatus
|
||||
self.nextReset = nextReset
|
||||
self.isDefault = isDefault
|
||||
self.lastActivityAt = lastActivityAt
|
||||
self.todayCost = todayCost
|
||||
self.health = health
|
||||
self.cached = cached
|
||||
self.fetchedAt = fetchedAt
|
||||
self.needsReauth = needsReauth
|
||||
self.quotaWindows = quotaWindows
|
||||
self.staleAsOf = staleAsOf
|
||||
}
|
||||
|
||||
/// Resilient decode: the two native-only keys are decoded with
|
||||
/// `decodeIfPresent` so a legacy payload (no "quota_windows"/"stale_as_of")
|
||||
/// yields nil rather than a decode failure. All other keys keep synthesized
|
||||
/// behavior.
|
||||
public init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
accountId = try c.decode(String.self, forKey: .accountId)
|
||||
provider = try c.decode(String.self, forKey: .provider)
|
||||
displayName = try c.decodeIfPresent(String.self, forKey: .displayName)
|
||||
tier = try c.decodeIfPresent(String.self, forKey: .tier)
|
||||
paused = try c.decode(Bool.self, forKey: .paused)
|
||||
quotaPercentage = try c.decodeIfPresent(Double.self, forKey: .quotaPercentage)
|
||||
quotaStatus = try c.decode(String.self, forKey: .quotaStatus)
|
||||
nextReset = try c.decodeIfPresent(String.self, forKey: .nextReset)
|
||||
isDefault = try c.decode(Bool.self, forKey: .isDefault)
|
||||
lastActivityAt = try c.decodeIfPresent(String.self, forKey: .lastActivityAt)
|
||||
todayCost = try c.decodeIfPresent(Double.self, forKey: .todayCost)
|
||||
health = try c.decode(String.self, forKey: .health)
|
||||
cached = try c.decode(Bool.self, forKey: .cached)
|
||||
fetchedAt = try c.decodeIfPresent(String.self, forKey: .fetchedAt)
|
||||
needsReauth = try c.decode(Bool.self, forKey: .needsReauth)
|
||||
quotaWindows = try c.decodeIfPresent([QuotaWindowDetail].self, forKey: .quotaWindows)
|
||||
staleAsOf = try c.decodeIfPresent(String.self, forKey: .staleAsOf)
|
||||
}
|
||||
}
|
||||
|
||||
extension BarSummaryRow {
|
||||
/// Health rendered as an ASCII-safe dot for the dropdown.
|
||||
public var healthDot: String {
|
||||
switch health {
|
||||
case "error": return "X"
|
||||
case "warning": return "!"
|
||||
default: return "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import SwiftUI
|
||||
|
||||
// Theme token model for the menu-bar dropdown.
|
||||
//
|
||||
// Lives in CCSBarCore (not the SwiftUI app target) so the assert harness can
|
||||
// import and verify the palette/enum/resolver without a full Xcode/XCTest
|
||||
// toolchain. This is the ONE file in CCSBarCore that imports SwiftUI — every
|
||||
// other Core file stays Foundation-only. SwiftUI's ColorScheme/Color compile
|
||||
// and run on the CommandLineTools toolchain, which is why this is safe here.
|
||||
|
||||
// MARK: - Raw palette (harness-assertable)
|
||||
|
||||
/// A plain RGB triple. We keep raw Doubles (not SwiftUI `Color`) as the source
|
||||
/// of truth because `Color` equality is unreliable for tests — two Colors built
|
||||
/// from identical components are not guaranteed `==`. Asserting on these Doubles
|
||||
/// is exact, so the dark-regression lock and light-value lock are byte-precise.
|
||||
public struct RGB: Equatable, Sendable {
|
||||
public let r: Double
|
||||
public let g: Double
|
||||
public let b: Double
|
||||
public init(_ r: Double, _ g: Double, _ b: Double) {
|
||||
self.r = r
|
||||
self.g = g
|
||||
self.b = b
|
||||
}
|
||||
}
|
||||
|
||||
/// Named color triples for one appearance. Pure data so it round-trips through
|
||||
/// the harness with exact equality.
|
||||
public struct BarPalette: Equatable, Sendable {
|
||||
public let accentRGB: RGB
|
||||
public let subscriptionRGB: RGB
|
||||
public let bandGreenRGB: RGB
|
||||
public let bandAmberRGB: RGB
|
||||
public let bandCoralRGB: RGB
|
||||
public let bandRedRGB: RGB
|
||||
/// Light-mode window plate. In dark mode the window defers to the native
|
||||
/// MenuBarExtra material, so this value is unused there (windowSurface == .clear).
|
||||
public let windowSurfaceRGB: RGB
|
||||
|
||||
public init(
|
||||
accentRGB: RGB, subscriptionRGB: RGB, bandGreenRGB: RGB, bandAmberRGB: RGB,
|
||||
bandCoralRGB: RGB, bandRedRGB: RGB, windowSurfaceRGB: RGB
|
||||
) {
|
||||
self.accentRGB = accentRGB
|
||||
self.subscriptionRGB = subscriptionRGB
|
||||
self.bandGreenRGB = bandGreenRGB
|
||||
self.bandAmberRGB = bandAmberRGB
|
||||
self.bandCoralRGB = bandCoralRGB
|
||||
self.bandRedRGB = bandRedRGB
|
||||
self.windowSurfaceRGB = windowSurfaceRGB
|
||||
}
|
||||
|
||||
/// DARK = today's exact values, lifted verbatim from the original Sparkline
|
||||
/// `BarTheme` enum. These are LOCKED: any drift fails the harness, guaranteeing
|
||||
/// byte-identical rendering on upgrade for users who stay on the default theme.
|
||||
public static let dark = BarPalette(
|
||||
accentRGB: RGB(0.886, 0.451, 0.137), // #E2732A CCS orange
|
||||
subscriptionRGB: RGB(0.357, 0.388, 0.851), // #5B63D9 indigo
|
||||
bandGreenRGB: RGB(0.36, 0.74, 0.56), // #5CBC8F emerald
|
||||
bandAmberRGB: RGB(0.86, 0.67, 0.31), // #DBAB4F gold
|
||||
bandCoralRGB: RGB(0.91, 0.46, 0.36), // #E8755C warning
|
||||
bandRedRGB: RGB(0.85, 0.34, 0.31), // #D9564F critical
|
||||
windowSurfaceRGB: RGB(0, 0, 0) // unused in dark (windowSurface == .clear)
|
||||
)
|
||||
|
||||
/// LIGHT = deepened/saturated variants tuned for legibility on a ~#F5F5F7
|
||||
/// white plate. The dark-tuned muted values read too pale on white, so each
|
||||
/// themed token is darkened with more saturation while preserving the
|
||||
/// green→amber→coral→red ramp ordering.
|
||||
public static let light = BarPalette(
|
||||
accentRGB: RGB(0.812, 0.357, 0.063), // #CF5B10 deeper orange
|
||||
subscriptionRGB: RGB(0.275, 0.302, 0.745), // #464DBE darker indigo
|
||||
bandGreenRGB: RGB(0.106, 0.580, 0.357), // #1B945B emerald
|
||||
bandAmberRGB: RGB(0.722, 0.490, 0.043), // #B87D0B ochre
|
||||
bandCoralRGB: RGB(0.831, 0.302, 0.157), // #D44D28 coral
|
||||
bandRedRGB: RGB(0.776, 0.157, 0.137), // #C62823 critical red
|
||||
windowSurfaceRGB: RGB(0.961, 0.961, 0.969) // #F5F5F7 light plate
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Appearance enum + forced-scheme mapping
|
||||
|
||||
/// User-selectable menu-bar theme. `.system` follows the real OS appearance;
|
||||
/// `.light`/`.dark` force a scheme regardless of OS.
|
||||
public enum BarAppearance: String, CaseIterable, Sendable {
|
||||
case system
|
||||
case light
|
||||
case dark
|
||||
|
||||
/// The scheme to force on the dropdown. `nil` => inherit the real OS
|
||||
/// appearance; `.light`/`.dark` => override it.
|
||||
public var forced: ColorScheme? {
|
||||
switch self {
|
||||
case .system: return nil
|
||||
case .light: return .light
|
||||
case .dark: return .dark
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Resolved token struct (views read this)
|
||||
|
||||
/// The resolved SwiftUI tokens consumed by the dropdown views. Built from a
|
||||
/// `BarPalette`, plus two derived `Color.primary.opacity(...)` surfaces that
|
||||
/// auto-invert with the forced scheme (primary is black on light, white on
|
||||
/// dark) and so are identical in both presets.
|
||||
public struct BarTheme: Sendable {
|
||||
/// The palette this theme resolved from — kept so the harness can verify the
|
||||
/// resolver picked the right set without relying on Color equality.
|
||||
public let palette: BarPalette
|
||||
|
||||
public let accent: Color
|
||||
public let subscription: Color
|
||||
public let bandGreen: Color
|
||||
public let bandAmber: Color
|
||||
public let bandCoral: Color
|
||||
public let bandRed: Color
|
||||
/// Faint elevated surface; derived, auto-inverts. Centralizes the inline
|
||||
/// `Color.primary.opacity(0.05)` references.
|
||||
public let cardSurface: Color
|
||||
/// Quota-bar track; derived, auto-inverts. Centralizes `Color.primary.opacity(0.12)`.
|
||||
public let barTrack: Color
|
||||
/// Window plate. Dark = `.clear` (defer to native material, zero regression);
|
||||
/// light = explicit #F5F5F7 so tokens never render on a leftover dark material.
|
||||
public let windowSurface: Color
|
||||
|
||||
public init(palette: BarPalette) {
|
||||
self.palette = palette
|
||||
self.accent = Color(rgb: palette.accentRGB)
|
||||
self.subscription = Color(rgb: palette.subscriptionRGB)
|
||||
self.bandGreen = Color(rgb: palette.bandGreenRGB)
|
||||
self.bandAmber = Color(rgb: palette.bandAmberRGB)
|
||||
self.bandCoral = Color(rgb: palette.bandCoralRGB)
|
||||
self.bandRed = Color(rgb: palette.bandRedRGB)
|
||||
self.cardSurface = Color.primary.opacity(0.05)
|
||||
self.barTrack = Color.primary.opacity(0.12)
|
||||
// Dark defers to the native MenuBarExtra material; only light owns a plate.
|
||||
self.windowSurface = (palette == .dark) ? .clear : Color(rgb: palette.windowSurfaceRGB)
|
||||
}
|
||||
|
||||
public static let dark = BarTheme(palette: .dark)
|
||||
public static let light = BarTheme(palette: .light)
|
||||
|
||||
/// Pure resolver: returns the token set for a given (already-forced) scheme.
|
||||
/// The root view applies `.preferredColorScheme(appearance.forced)` and then
|
||||
/// reads `\.colorScheme` on a descendant, so the scheme passed here always
|
||||
/// reflects exactly what the user sees (for `.system`, the real OS scheme).
|
||||
public static func resolve(_ scheme: ColorScheme) -> BarTheme {
|
||||
scheme == .dark ? .dark : .light
|
||||
}
|
||||
}
|
||||
|
||||
extension Color {
|
||||
/// Builds a Color from a raw RGB triple.
|
||||
fileprivate init(rgb: RGB) {
|
||||
self.init(red: rgb.r, green: rgb.g, blue: rgb.b)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Environment propagation
|
||||
|
||||
/// Injects the resolved theme down the view tree. The default is the tuned
|
||||
/// dark preset so any view rendered outside an injected subtree (SwiftUI
|
||||
/// previews, a stray child) gets the exact current look — no crash, no
|
||||
/// regression.
|
||||
public struct BarThemeKey: EnvironmentKey {
|
||||
public static let defaultValue = BarTheme.dark
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
public var barTheme: BarTheme {
|
||||
get { self[BarThemeKey.self] }
|
||||
set { self[BarThemeKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
/// Persists the chosen appearance. Structurally mirrors `MenuBarIcon` load/save.
|
||||
/// Appearance is global chrome (not an alert pref), so it is NOT registered in
|
||||
/// `registerDefaults()`; the `?? .dark` fallback on a nil string read is the
|
||||
/// source of the default, avoiding any registration-domain trap.
|
||||
public enum BarAppearanceStore {
|
||||
public static let defaultsKey = "ccsbar.appearance"
|
||||
|
||||
public static func load() -> BarAppearance {
|
||||
let raw =
|
||||
UserDefaults.standard.string(forKey: defaultsKey) ?? BarAppearance.dark.rawValue
|
||||
return BarAppearance(rawValue: raw) ?? .dark
|
||||
}
|
||||
|
||||
public static func save(_ appearance: BarAppearance) {
|
||||
UserDefaults.standard.set(appearance.rawValue, forKey: defaultsKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure helper for the version string shown in the bar panel header.
|
||||
///
|
||||
/// Kept tiny so it can be tested in CCSBarCheck without a bundle present:
|
||||
/// the display-string logic is pure-Foundation and has no AppKit dependency.
|
||||
public enum BarVersionDisplay {
|
||||
|
||||
/// Converts a raw version string to a "v{version}" display string.
|
||||
///
|
||||
/// - Returns: `"v\(raw)"` when `raw` is non-nil and non-empty; `nil` otherwise.
|
||||
///
|
||||
/// This is the logic under test. The actual `Bundle.main` lookup is in `string()`.
|
||||
public static func displayString(for raw: String?) -> String? {
|
||||
guard let v = raw, !v.isEmpty else { return nil }
|
||||
return "v\(v)"
|
||||
}
|
||||
|
||||
/// Returns the display string for the running app's bundle version, or `nil`
|
||||
/// when the key is absent (e.g. `swift run` outside a bundle).
|
||||
///
|
||||
/// Never produces a dangling "v" prefix: if `CFBundleShortVersionString` is
|
||||
/// missing or empty, returns `nil` so the caller can omit the label entirely.
|
||||
public static func string() -> String? {
|
||||
let raw = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
|
||||
return displayString(for: raw)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import Foundation
|
||||
|
||||
/// Injectable HTTP transport so the client is testable without a live server
|
||||
/// (the assert harness supplies a recording/mock transport).
|
||||
public protocol HTTPTransport: Sendable {
|
||||
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse)
|
||||
}
|
||||
|
||||
public struct URLSessionTransport: HTTPTransport {
|
||||
let session: URLSession
|
||||
public init(session: URLSession = .shared) { self.session = session }
|
||||
public func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw CCSBarClientError.nonHTTPResponse
|
||||
}
|
||||
return (data, http)
|
||||
}
|
||||
}
|
||||
|
||||
public enum CCSBarClientError: Error, Equatable {
|
||||
case nonHTTPResponse
|
||||
case httpStatus(Int)
|
||||
case badURL
|
||||
case decoding
|
||||
}
|
||||
|
||||
/// Thin client over the CCS local web-server. The app NEVER talks to a
|
||||
/// provider directly; every call goes to localhost and CCS performs any
|
||||
/// provider fetch server-side.
|
||||
public struct CCSBarClient {
|
||||
let baseURL: URL
|
||||
let transport: HTTPTransport
|
||||
|
||||
public init(baseURL: URL, transport: HTTPTransport = URLSessionTransport()) {
|
||||
self.baseURL = baseURL
|
||||
self.transport = transport
|
||||
}
|
||||
|
||||
/// GET /api/bar/summary[?refresh=true]. Cached by default; `refresh: true`
|
||||
/// asks CCS to pull live from providers server-side.
|
||||
public func summary(refresh: Bool = false) async throws -> [BarSummaryRow] {
|
||||
guard
|
||||
var comps = URLComponents(
|
||||
url: baseURL.appendingPathComponent("api/bar/summary"),
|
||||
resolvingAgainstBaseURL: false
|
||||
)
|
||||
else { throw CCSBarClientError.badURL }
|
||||
if refresh { comps.queryItems = [URLQueryItem(name: "refresh", value: "true")] }
|
||||
guard let url = comps.url else { throw CCSBarClientError.badURL }
|
||||
|
||||
let (data, http) = try await transport.send(URLRequest(url: url))
|
||||
guard http.statusCode == 200 else { throw CCSBarClientError.httpStatus(http.statusCode) }
|
||||
do {
|
||||
return try JSONDecoder().decode([BarSummaryRow].self, from: data)
|
||||
} catch {
|
||||
throw CCSBarClientError.decoding
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/bar/analytics. Server-side rollup of the usage snapshot
|
||||
/// (today / 7d / 30d / all-time spend, sparkline, top models).
|
||||
public func analytics() async throws -> BarAnalytics {
|
||||
let url = baseURL.appendingPathComponent("api/bar/analytics")
|
||||
let (data, http) = try await transport.send(URLRequest(url: url))
|
||||
guard http.statusCode == 200 else { throw CCSBarClientError.httpStatus(http.statusCode) }
|
||||
do {
|
||||
return try JSONDecoder().decode(BarAnalytics.self, from: data)
|
||||
} catch {
|
||||
throw CCSBarClientError.decoding
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Account control (reuses existing CCS endpoints)
|
||||
|
||||
public func pause(provider: String, accountId: String) async throws {
|
||||
try await post("api/accounts/bulk-pause", body: ["provider": provider, "accountIds": [accountId]])
|
||||
}
|
||||
|
||||
public func resume(provider: String, accountId: String) async throws {
|
||||
try await post("api/accounts/bulk-resume", body: ["provider": provider, "accountIds": [accountId]])
|
||||
}
|
||||
|
||||
public func setDefault(name: String) async throws {
|
||||
try await post("api/accounts/default", body: ["name": name])
|
||||
}
|
||||
|
||||
public func solo(provider: String, accountId: String) async throws {
|
||||
try await post("api/accounts/solo", body: ["provider": provider, "accountId": accountId])
|
||||
}
|
||||
|
||||
/// Lock a provider's account selection to a tier, or pass `nil` to clear.
|
||||
public func tierLock(provider: String, tier: String?) async throws {
|
||||
try await post("api/accounts/tier-lock", body: ["provider": provider, "tier": tier ?? NSNull()])
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func post(_ path: String, body: [String: Any]) async throws -> Data {
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent(path))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
let (data, http) = try await transport.send(request)
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw CCSBarClientError.httpStatus(http.statusCode)
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "8.2.0",
|
||||
"version": "8.2.0-dev.11",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
+183
-17
@@ -44,8 +44,15 @@ const slowTests = [
|
||||
// CommonJS-heavy JS suites stay slow by default because many of them mutate
|
||||
// module cache or process state. Opt them into `test:fast` only after they are
|
||||
// proven stable in the mixed fast bucket.
|
||||
const fastJsTests = new Set([
|
||||
'tests/unit/flag-parsing-simple.test.js',
|
||||
const fastJsTests = new Set(['tests/unit/flag-parsing-simple.test.js']);
|
||||
|
||||
const isolatedTests = new Set([
|
||||
'tests/unit/targets/codex-adapter-exec.test.ts',
|
||||
'tests/unit/targets/codex-adapter.test.ts',
|
||||
'tests/unit/targets/droid-adapter.test.ts',
|
||||
'tests/unit/targets/target-registry.test.ts',
|
||||
'tests/unit/utils/fetch-proxy-setup.test.ts',
|
||||
'tests/unit/web-server/usage/account-attribution.test.ts',
|
||||
]);
|
||||
|
||||
const filePattern = /(\.test\.(c|m)?[jt]s|\.spec\.(c|m)?[jt]s|-test\.(c|m)?[jt]s)$/;
|
||||
@@ -89,6 +96,11 @@ function shouldForceSlow(file) {
|
||||
return readsBuiltDist(file);
|
||||
}
|
||||
|
||||
function usesBunTestRunner(relativePath) {
|
||||
const source = fs.readFileSync(path.join(rootDir, relativePath), 'utf8');
|
||||
return source.includes('bun:test') || /(^|[^\w.])(?:describe|it)\s*\(/m.test(source);
|
||||
}
|
||||
|
||||
function getSlowSet() {
|
||||
const discovered = getDiscoveredTests();
|
||||
const forceSlow = discovered.filter((file) => shouldForceSlow(file));
|
||||
@@ -99,9 +111,101 @@ function selectBucket(name) {
|
||||
const discovered = getDiscoveredTests();
|
||||
const slowSet = getSlowSet();
|
||||
|
||||
return name === 'slow'
|
||||
? [...slowSet].sort()
|
||||
: discovered.filter((file) => !slowSet.has(file));
|
||||
return name === 'slow' ? [...slowSet].sort() : discovered.filter((file) => !slowSet.has(file));
|
||||
}
|
||||
|
||||
function toBunTestPath(relativePath) {
|
||||
if (
|
||||
relativePath.startsWith('./') ||
|
||||
relativePath.startsWith('../') ||
|
||||
path.isAbsolute(relativePath)
|
||||
) {
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
return `./${relativePath}`;
|
||||
}
|
||||
|
||||
function getBunArgs(name, selected = selectBucket(name)) {
|
||||
const testPaths = selected.map(toBunTestPath);
|
||||
|
||||
// Slow bucket forces sequential execution because it spawns subprocesses,
|
||||
// binds ports, and touches shared state — parallelism causes flakes.
|
||||
// Fast bucket keeps bun's default parallelism for speed.
|
||||
return name === 'slow' ? ['test', '--max-concurrency=1', ...testPaths] : ['test', ...testPaths];
|
||||
}
|
||||
|
||||
function shouldRunIsolated(file) {
|
||||
return file.startsWith('src/') || isolatedTests.has(file) || !usesBunTestRunner(file);
|
||||
}
|
||||
|
||||
function getBunRuns(name, selected = selectBucket(name)) {
|
||||
const shared = selected.filter((file) => !shouldRunIsolated(file));
|
||||
const isolated = selected.filter(shouldRunIsolated);
|
||||
const runs = [];
|
||||
|
||||
if (shared.length > 0) {
|
||||
runs.push({
|
||||
label: 'shared',
|
||||
selected: shared,
|
||||
bunArgs: getBunArgs(name, shared),
|
||||
quietOnPass: false,
|
||||
});
|
||||
}
|
||||
|
||||
for (const file of isolated) {
|
||||
runs.push({
|
||||
label: file,
|
||||
selected: [file],
|
||||
bunArgs: getBunArgs(name, [file]),
|
||||
quietOnPass: true,
|
||||
});
|
||||
}
|
||||
|
||||
return runs;
|
||||
}
|
||||
|
||||
function stripAnsi(value) {
|
||||
return value.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
function parseBunFileCount(output) {
|
||||
const match = stripAnsi(output).match(/Ran\s+\d+\s+tests?\s+across\s+(\d+)\s+files?/i);
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
function verifyReportedFileCount(selectedCount, output) {
|
||||
const reportedCount = parseBunFileCount(output);
|
||||
|
||||
if (reportedCount === null) {
|
||||
return {
|
||||
ok: false,
|
||||
message: '[X] Could not find Bun test file count in output.',
|
||||
reportedCount,
|
||||
selectedCount,
|
||||
};
|
||||
}
|
||||
|
||||
if (reportedCount !== selectedCount) {
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
`[X] Bun ran ${reportedCount} files, but the bucket selected ${selectedCount} files. ` +
|
||||
'Check test path arguments for filter/path ambiguity.',
|
||||
reportedCount,
|
||||
selectedCount,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
reportedCount,
|
||||
selectedCount,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldVerifyRunFileCount(run) {
|
||||
return run.selected.every((file) => usesBunTestRunner(file));
|
||||
}
|
||||
|
||||
function ensureBuildForSlowBucket() {
|
||||
@@ -118,6 +222,54 @@ function ensureBuildForSlowBucket() {
|
||||
return build.status ?? 1;
|
||||
}
|
||||
|
||||
function runBunTest(run) {
|
||||
const result = spawnSync('bun', run.bunArgs, {
|
||||
cwd: rootDir,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
|
||||
const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
||||
const writeOutput = () => {
|
||||
if (result.stdout) {
|
||||
process.stdout.write(result.stdout);
|
||||
}
|
||||
if (result.stderr) {
|
||||
process.stderr.write(result.stderr);
|
||||
}
|
||||
};
|
||||
|
||||
if (result.error) {
|
||||
writeOutput();
|
||||
console.error(`[X] Failed to run bun test: ${result.error.message}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const exitCode = result.status ?? 1;
|
||||
if (exitCode !== 0) {
|
||||
writeOutput();
|
||||
return exitCode;
|
||||
}
|
||||
|
||||
if (shouldVerifyRunFileCount(run)) {
|
||||
const countCheck = verifyReportedFileCount(run.selected.length, output);
|
||||
if (!countCheck.ok) {
|
||||
writeOutput();
|
||||
console.error(countCheck.message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (run.quietOnPass) {
|
||||
console.log(`[OK] ${run.label}`);
|
||||
} else {
|
||||
writeOutput();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function runBucket(name) {
|
||||
const selected = selectBucket(name);
|
||||
|
||||
@@ -133,20 +285,25 @@ function runBucket(name) {
|
||||
}
|
||||
}
|
||||
|
||||
// Slow bucket forces sequential execution because it spawns subprocesses,
|
||||
// binds ports, and touches shared state — parallelism causes flakes.
|
||||
// Fast bucket keeps bun's default parallelism for speed.
|
||||
const bunArgs = name === 'slow'
|
||||
? ['test', '--max-concurrency=1', ...selected]
|
||||
: ['test', ...selected];
|
||||
const runs = getBunRuns(name, selected);
|
||||
const isolatedCount = runs.filter((run) => run.quietOnPass).length;
|
||||
if (isolatedCount > 0) {
|
||||
console.log(`[i] Running ${isolatedCount} test file(s) in isolated Bun processes.`);
|
||||
}
|
||||
|
||||
const result = spawnSync('bun', bunArgs, {
|
||||
cwd: rootDir,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
let exitCode = 0;
|
||||
for (const run of runs) {
|
||||
const status = runBunTest(run);
|
||||
if (status !== 0) {
|
||||
exitCode = status;
|
||||
}
|
||||
}
|
||||
|
||||
return result.status ?? 1;
|
||||
if (exitCode === 0) {
|
||||
console.log(`[OK] Bucket '${name}' ran ${selected.length} selected test files.`);
|
||||
}
|
||||
|
||||
return exitCode;
|
||||
}
|
||||
|
||||
function main(args = process.argv.slice(2)) {
|
||||
@@ -180,10 +337,19 @@ if (require.main === module) {
|
||||
module.exports = {
|
||||
slowTests,
|
||||
fastJsTests,
|
||||
isolatedTests,
|
||||
readsBuiltDist,
|
||||
shouldForceSlow,
|
||||
getDiscoveredTests,
|
||||
getSlowSet,
|
||||
selectBucket,
|
||||
toBunTestPath,
|
||||
getBunArgs,
|
||||
usesBunTestRunner,
|
||||
shouldRunIsolated,
|
||||
getBunRuns,
|
||||
parseBunFileCount,
|
||||
verifyReportedFileCount,
|
||||
shouldVerifyRunFileCount,
|
||||
main,
|
||||
};
|
||||
@@ -91,6 +91,12 @@ export function suggestCliproxyBridgeName(provider: CLIProxyProvider): string {
|
||||
}
|
||||
|
||||
function resolveBridgeModelMapping(provider: CLIProxyProvider): ModelMapping {
|
||||
// claude is model-neutral: model keys are absent from its base config so that
|
||||
// Claude Code's own /model selection is respected end-to-end. Return empty
|
||||
// strings here; createSettingsFile skips writing empty model entries.
|
||||
if (provider === 'claude') {
|
||||
return { default: '', opus: '', sonnet: '', haiku: '' };
|
||||
}
|
||||
const mapping = getModelMappingFromConfig(provider);
|
||||
return {
|
||||
default: mapping.defaultModel,
|
||||
|
||||
@@ -109,6 +109,15 @@ function createSettingsFile(
|
||||
});
|
||||
|
||||
const isNative = isAnthropicDirect(baseUrl, apiKey);
|
||||
// Model-neutral providers (e.g. claude built-in) pass empty strings to signal
|
||||
// "omit this key". Filter them out so the written settings file does not
|
||||
// contain ANTHROPIC_MODEL:'' which could be treated as an unintended override.
|
||||
const modelEnv = {
|
||||
...(models.default.trim() ? { ANTHROPIC_MODEL: models.default } : {}),
|
||||
...(models.opus.trim() ? { ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus } : {}),
|
||||
...(models.sonnet.trim() ? { ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet } : {}),
|
||||
...(models.haiku.trim() ? { ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku } : {}),
|
||||
};
|
||||
const settings = {
|
||||
env: {
|
||||
// Native mode: ANTHROPIC_API_KEY only, no BASE_URL/AUTH_TOKEN
|
||||
@@ -120,10 +129,7 @@ function createSettingsFile(
|
||||
ANTHROPIC_AUTH_TOKEN: apiKey,
|
||||
...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }),
|
||||
}),
|
||||
ANTHROPIC_MODEL: models.default,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
|
||||
...modelEnv,
|
||||
...(extraModels && extraModels.length > 0
|
||||
? { ANTHROPIC_EXTRA_MODELS: extraModels.join(',') }
|
||||
: {}),
|
||||
@@ -202,6 +208,13 @@ function createApiProfileUnified(
|
||||
});
|
||||
|
||||
const isNative = isAnthropicDirect(baseUrl, apiKey);
|
||||
// Model-neutral providers pass empty strings; omit those keys.
|
||||
const modelEnvUnified = {
|
||||
...(models.default.trim() ? { ANTHROPIC_MODEL: models.default } : {}),
|
||||
...(models.opus.trim() ? { ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus } : {}),
|
||||
...(models.sonnet.trim() ? { ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet } : {}),
|
||||
...(models.haiku.trim() ? { ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku } : {}),
|
||||
};
|
||||
const settings = {
|
||||
env: {
|
||||
...(isNative
|
||||
@@ -211,10 +224,7 @@ function createApiProfileUnified(
|
||||
ANTHROPIC_AUTH_TOKEN: apiKey,
|
||||
...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }),
|
||||
}),
|
||||
ANTHROPIC_MODEL: models.default,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
|
||||
...modelEnvUnified,
|
||||
...(extraModels && extraModels.length > 0
|
||||
? { ANTHROPIC_EXTRA_MODELS: extraModels.join(',') }
|
||||
: {}),
|
||||
|
||||
@@ -29,7 +29,11 @@ import { exitWithError } from '../../errors';
|
||||
import { ExitCode } from '../../errors/exit-codes';
|
||||
import { CommandContext, parseArgs, rejectUnsupportedAuthOptions } from './types';
|
||||
import { stripAmbientProviderCredentials } from './create-command-env';
|
||||
import { isUnifiedMode } from '../../config/config-loader-facade';
|
||||
import { isUnifiedMode, hasUnifiedConfig } from '../../config/config-loader-facade';
|
||||
import {
|
||||
maybeShowPoolOnboardingHint,
|
||||
countNativeClaudeProfiles,
|
||||
} from '../../cliproxy/routing/pool-onboarding-hint';
|
||||
|
||||
function sanitizeProfileNameForInstance(name: string): string {
|
||||
return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
|
||||
@@ -310,6 +314,16 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
||||
)
|
||||
);
|
||||
console.log('');
|
||||
// Pool suggestion: shown only AFTER the profile creation fully
|
||||
// succeeded (a pre-create hint would burn the once-per-install
|
||||
// dismissal even when creation fails or rolls back). Print-only,
|
||||
// TTY-gated, never blocks. Gated on hasUnifiedConfig() so legacy
|
||||
// profiles.json-only installs receive the hint from ccs doctor only
|
||||
// (where dismissal semantics are preserved). The profile now exists,
|
||||
// so the registry count is already post-create (no +1 needed).
|
||||
if (!profileExistedBeforeCreate && hasUnifiedConfig()) {
|
||||
maybeShowPoolOnboardingHint(countNativeClaudeProfiles());
|
||||
}
|
||||
process.exit(0);
|
||||
} else {
|
||||
await rollbackFailedCreate();
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Tests for account-safety ban copy parameterization (Gap 3).
|
||||
*
|
||||
* Verifies that handleBanDetection uses provider-appropriate copy:
|
||||
* - "Anthropic" for the claude provider
|
||||
* - "Google" for gemini / agy / codex
|
||||
* and that isBanResponse matches both shared and Anthropic-specific patterns.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { describe, expect, it, spyOn, afterEach, beforeEach } from 'bun:test';
|
||||
import { isBanResponse, handleBanDetection } from '../accounts/account-safety';
|
||||
|
||||
// ── isBanResponse ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('isBanResponse', () => {
|
||||
it.each([
|
||||
'disabled in this account',
|
||||
'violation of terms of service',
|
||||
'account has been disabled',
|
||||
'account is disabled',
|
||||
'account has been suspended',
|
||||
'account has been banned',
|
||||
])('matches shared ban pattern for all providers: %s', (pattern) => {
|
||||
expect(isBanResponse(pattern)).toBe(true);
|
||||
expect(isBanResponse(pattern.toUpperCase())).toBe(true);
|
||||
// Also matches when provider is specified
|
||||
expect(isBanResponse(pattern, 'gemini')).toBe(true);
|
||||
expect(isBanResponse(pattern, 'claude')).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['your account has been blocked', 'account is blocked'])(
|
||||
'matches Anthropic-specific ban pattern for claude provider: %s',
|
||||
(pattern) => {
|
||||
expect(isBanResponse(pattern, 'claude')).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['your account has been blocked', 'account is blocked'])(
|
||||
'does NOT match Anthropic-specific ban pattern for non-claude providers: %s',
|
||||
(pattern) => {
|
||||
expect(isBanResponse(pattern, 'gemini')).toBe(false);
|
||||
expect(isBanResponse(pattern, 'agy')).toBe(false);
|
||||
expect(isBanResponse(pattern, 'codex')).toBe(false);
|
||||
// Without provider argument also should not match
|
||||
expect(isBanResponse(pattern)).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('does not match bare "usage policy" substring for any provider (avoids false positives)', () => {
|
||||
// A rate-limit message mentioning "usage policy" should not trigger ban
|
||||
const msg = 'Request exceeds usage policy limits for your plan';
|
||||
expect(isBanResponse(msg)).toBe(false);
|
||||
expect(isBanResponse(msg, 'gemini')).toBe(false);
|
||||
expect(isBanResponse(msg, 'claude')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a benign error message', () => {
|
||||
expect(isBanResponse('rate limit exceeded')).toBe(false);
|
||||
expect(isBanResponse('network timeout')).toBe(false);
|
||||
expect(isBanResponse('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── handleBanDetection ban copy ────────────────────────────────────────────────
|
||||
|
||||
describe('handleBanDetection ban copy', () => {
|
||||
const stderrLines: string[] = [];
|
||||
let writeSpy: ReturnType<typeof spyOn>;
|
||||
let consoleSpy: ReturnType<typeof spyOn>;
|
||||
let tempHome: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-ban-copy-'));
|
||||
process.env.CCS_HOME = tempHome;
|
||||
});
|
||||
|
||||
const setup = () => {
|
||||
stderrLines.length = 0;
|
||||
writeSpy = spyOn(process.stderr, 'write').mockImplementation(
|
||||
(chunk: string | Uint8Array): boolean => {
|
||||
stderrLines.push(typeof chunk === 'string' ? chunk : '');
|
||||
return true;
|
||||
}
|
||||
);
|
||||
// Also capture console.error calls
|
||||
consoleSpy = spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
|
||||
stderrLines.push(String(args[0] ?? ''));
|
||||
});
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
writeSpy?.mockRestore();
|
||||
consoleSpy?.mockRestore();
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('uses "Anthropic" actor copy for the claude provider', () => {
|
||||
setup();
|
||||
handleBanDetection('claude', 'test@example.com', 'account has been disabled');
|
||||
const output = stderrLines.join(' ');
|
||||
expect(output).toContain('Anthropic');
|
||||
expect(output).not.toContain('Google');
|
||||
});
|
||||
|
||||
it('uses "Google" actor copy for the gemini provider', () => {
|
||||
setup();
|
||||
handleBanDetection('gemini', 'test@example.com', 'account has been disabled');
|
||||
const output = stderrLines.join(' ');
|
||||
expect(output).toContain('Google');
|
||||
expect(output).not.toContain('Anthropic');
|
||||
});
|
||||
|
||||
it('uses "Google" actor copy for the agy provider', () => {
|
||||
setup();
|
||||
handleBanDetection('agy', 'test@example.com', 'account has been banned');
|
||||
const output = stderrLines.join(' ');
|
||||
expect(output).toContain('Google');
|
||||
});
|
||||
|
||||
it('returns false for a non-ban error (no actor copy emitted)', () => {
|
||||
setup();
|
||||
const result = handleBanDetection('claude', 'test@example.com', 'rate limit exceeded');
|
||||
expect(result).toBe(false);
|
||||
// No ban message written
|
||||
expect(stderrLines.join(' ')).not.toContain('Anthropic');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Tests for claude-shadow-warning.ts
|
||||
*
|
||||
* Gap 2: shadow warning — emitted once when a user profile named 'claude' or
|
||||
* 'anthropic' is present; not repeated; not emitted without a collision.
|
||||
* Gap 4: routing notice — emitted once on first claude provider launch.
|
||||
*
|
||||
* These tests use a temp CCS_HOME and a non-TTY stderr mock to confirm
|
||||
* write behaviour without relying on an interactive terminal.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
|
||||
|
||||
// Module under test — imported after CCS_HOME is set up in beforeEach via dynamic re-import.
|
||||
// Because Bun caches modules, we test the exported functions in isolation by
|
||||
// controlling the file-system state they depend on.
|
||||
import { maybeWarnClaudeShadow, maybeShowClaudeRoutingNotice } from '../claude-shadow-warning';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function writeLegacyConfig(ccsDir: string, profiles: Record<string, string>): void {
|
||||
const configPath = path.join(ccsDir, 'config.json');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({ profiles }), 'utf-8');
|
||||
}
|
||||
|
||||
function markerDir(ccsDir: string): string {
|
||||
return path.join(ccsDir, 'cliproxy');
|
||||
}
|
||||
|
||||
function shadowMarker(ccsDir: string): string {
|
||||
return path.join(markerDir(ccsDir), '.claude-shadow-warned');
|
||||
}
|
||||
|
||||
function routingMarker(ccsDir: string): string {
|
||||
return path.join(markerDir(ccsDir), '.claude-routing-noticed');
|
||||
}
|
||||
|
||||
// ── Test setup ────────────────────────────────────────────────────────────────
|
||||
|
||||
let tempHome: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
let stderrLines: string[];
|
||||
let stderrSpy: ReturnType<typeof spyOn>;
|
||||
let originalIsTTYDescriptor: PropertyDescriptor | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shadow-warn-'));
|
||||
process.env.CCS_HOME = tempHome;
|
||||
|
||||
// Collect stderr writes without printing them
|
||||
stderrLines = [];
|
||||
stderrSpy = spyOn(process.stderr, 'write').mockImplementation(
|
||||
(chunk: string | Uint8Array): boolean => {
|
||||
stderrLines.push(typeof chunk === 'string' ? chunk : '');
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
// Save exact isTTY property descriptor so afterEach can restore it precisely
|
||||
originalIsTTYDescriptor = Object.getOwnPropertyDescriptor(process.stderr, 'isTTY');
|
||||
|
||||
// Simulate a TTY so the TTY guard does not short-circuit
|
||||
Object.defineProperty(process.stderr, 'isTTY', { value: true, configurable: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
stderrSpy.mockRestore();
|
||||
// Restore isTTY to exactly what it was before the test
|
||||
if (originalIsTTYDescriptor !== undefined) {
|
||||
Object.defineProperty(process.stderr, 'isTTY', originalIsTTYDescriptor);
|
||||
} else {
|
||||
delete (process.stderr as { isTTY?: boolean }).isTTY;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Shadow warning (Gap 2) ────────────────────────────────────────────────────
|
||||
|
||||
describe('maybeWarnClaudeShadow', () => {
|
||||
it('does not emit a warning when no shadowed profiles exist', () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
writeLegacyConfig(ccsDir, { myprofile: '/some/path' });
|
||||
|
||||
maybeWarnClaudeShadow();
|
||||
|
||||
expect(stderrLines.join('')).not.toContain('shadowed');
|
||||
expect(stderrLines.join('')).not.toContain('claude');
|
||||
});
|
||||
|
||||
it('emits a warning when a profile named "claude" exists', () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
writeLegacyConfig(ccsDir, { claude: '/path/to/claude.settings.json' });
|
||||
|
||||
maybeWarnClaudeShadow();
|
||||
|
||||
const output = stderrLines.join('');
|
||||
expect(output).toContain('claude');
|
||||
expect(output).toContain('shadowed');
|
||||
// Ensure warn() prefix is not doubled ([!] [!] ...)
|
||||
expect(output).not.toMatch(/\[!\]\s*\[!\]/);
|
||||
});
|
||||
|
||||
it('emits a warning when a profile named "anthropic" exists', () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
writeLegacyConfig(ccsDir, { anthropic: '/path/to/anthropic.settings.json' });
|
||||
|
||||
maybeWarnClaudeShadow();
|
||||
|
||||
const output = stderrLines.join('');
|
||||
expect(output).toContain('anthropic');
|
||||
expect(output).toContain('shadowed');
|
||||
// Ensure warn() prefix is not doubled
|
||||
expect(output).not.toMatch(/\[!\]\s*\[!\]/);
|
||||
});
|
||||
|
||||
it('creates the dismissal marker after warning', () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
writeLegacyConfig(ccsDir, { claude: '/path/to/settings.json' });
|
||||
|
||||
maybeWarnClaudeShadow();
|
||||
|
||||
expect(fs.existsSync(shadowMarker(ccsDir))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not repeat the warning when the marker already exists', () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
writeLegacyConfig(ccsDir, { claude: '/path/to/settings.json' });
|
||||
// Pre-create marker
|
||||
const mDir = markerDir(ccsDir);
|
||||
fs.mkdirSync(mDir, { recursive: true });
|
||||
fs.writeFileSync(shadowMarker(ccsDir), 'already-shown');
|
||||
|
||||
maybeWarnClaudeShadow();
|
||||
|
||||
const output = stderrLines.join('');
|
||||
expect(output).not.toContain('shadowed');
|
||||
});
|
||||
|
||||
it('does not warn when stderr is not a TTY', () => {
|
||||
Object.defineProperty(process.stderr, 'isTTY', { value: false, configurable: true });
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
writeLegacyConfig(ccsDir, { claude: '/path/to/settings.json' });
|
||||
|
||||
maybeWarnClaudeShadow();
|
||||
|
||||
expect(stderrLines.join('')).not.toContain('shadowed');
|
||||
});
|
||||
|
||||
it('emits a warning when an account profile named "claude" exists in profiles.json (legacy mode)', () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
// Write an empty settings config (no settings profiles named claude)
|
||||
writeLegacyConfig(ccsDir, { myprofile: '/some/path' });
|
||||
// Write profiles.json with an account profile named 'claude'
|
||||
const profilesData = {
|
||||
version: '2.0.0',
|
||||
profiles: { claude: { type: 'account', created: new Date().toISOString(), last_used: null } },
|
||||
default: null,
|
||||
};
|
||||
fs.writeFileSync(path.join(ccsDir, 'profiles.json'), JSON.stringify(profilesData), 'utf-8');
|
||||
|
||||
maybeWarnClaudeShadow();
|
||||
|
||||
const output = stderrLines.join('');
|
||||
expect(output).toContain('claude');
|
||||
expect(output).toContain('shadowed');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Routing notice (Gap 4) ────────────────────────────────────────────────────
|
||||
|
||||
describe('maybeShowClaudeRoutingNotice', () => {
|
||||
it('emits routing notice on first call', () => {
|
||||
maybeShowClaudeRoutingNotice();
|
||||
|
||||
const output = stderrLines.join('');
|
||||
expect(output).toContain('CLIProxy');
|
||||
// Ensure info() prefix is not doubled ([i] [i] ...)
|
||||
expect(output).not.toMatch(/\[i\]\s*\[i\]/);
|
||||
});
|
||||
|
||||
it('creates the routing notice marker after first call', () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
maybeShowClaudeRoutingNotice();
|
||||
|
||||
expect(fs.existsSync(routingMarker(ccsDir))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not repeat the routing notice when the marker already exists', () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
const mDir = markerDir(ccsDir);
|
||||
fs.mkdirSync(mDir, { recursive: true });
|
||||
fs.writeFileSync(routingMarker(ccsDir), 'already-shown');
|
||||
|
||||
maybeShowClaudeRoutingNotice();
|
||||
|
||||
// No output at all
|
||||
expect(stderrLines.join('')).not.toContain('CLIProxy');
|
||||
});
|
||||
|
||||
it('does not emit when stderr is not a TTY', () => {
|
||||
Object.defineProperty(process.stderr, 'isTTY', { value: false, configurable: true });
|
||||
|
||||
maybeShowClaudeRoutingNotice();
|
||||
|
||||
expect(stderrLines.join('')).not.toContain('CLIProxy');
|
||||
});
|
||||
});
|
||||
@@ -168,6 +168,19 @@ describe('Model Catalog', () => {
|
||||
assert.strictEqual(opus48.extendedContext, true);
|
||||
});
|
||||
|
||||
it('includes Claude Fable 5 with adaptive levels and extended context', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const fable5 = MODEL_CATALOG.claude.models.find((m) => m.id === 'claude-fable-5');
|
||||
assert(fable5, 'Should include Claude Fable 5');
|
||||
assert.strictEqual(fable5.name, 'Claude Fable 5');
|
||||
// New top tier above Opus; same adaptive thinking surface as Opus 4.8:
|
||||
// Anthropic only accepts effort levels, budget_tokens is rejected with 400.
|
||||
assert.strictEqual(fable5.thinking.type, 'levels');
|
||||
assert.deepStrictEqual(fable5.thinking.levels, ['low', 'medium', 'high', 'xhigh', 'max']);
|
||||
assert.strictEqual(fable5.thinking.maxLevel, 'max');
|
||||
assert.strictEqual(fable5.extendedContext, true);
|
||||
});
|
||||
|
||||
it('retains previous 4.5 snapshot models for explicit selection', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const ids = MODEL_CATALOG.claude.models.map((m) => m.id);
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
/**
|
||||
* Phase 5: Pool Onboarding Hint - Test Suite
|
||||
*
|
||||
* Covers:
|
||||
* 1. Non-TTY: hint is silent
|
||||
* 2. Pool already enabled: hint is silent
|
||||
* 3. Dismissed flag: hint is silent
|
||||
* 4. Fewer than 2 profiles: hint is silent
|
||||
* 5. Exactly 2 profiles, TTY, not dismissed, pool off: hint prints (single [i] marker)
|
||||
* 6. After dismiss, second call is silent
|
||||
* 7. Pre-computed count avoids double registry read
|
||||
* 8. isOnboardingHintDismissed / dismissOnboardingHint roundtrip
|
||||
* 9. countNativeClaudeProfiles returns only account-type profiles
|
||||
* 10. Doctor site (simulation): maybeShowPoolOnboardingHint callable after report phase
|
||||
* 10b. Doctor wiring (static): doctor.ts imports and calls the hint symbol
|
||||
* 11. Create-command suggestion: hint fires with count=2 when 1 existing profile present
|
||||
* 12. Legacy-only install: dismissOnboardingHint does not create config.yaml
|
||||
* 13. Hint copy includes 'ccs claude' entry point and quota trade-off language
|
||||
* 14. Legacy-only install: hasUnifiedConfig() returns false, enforcing call-site gate
|
||||
* 15. Malformed config (with count): hint swallows the error, never throws, prints nothing
|
||||
* 16. Malformed config (no count, doctor path): hint never throws
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||
|
||||
// Non-cache-busted facade import so mutateConfig targets the SHARED singleton
|
||||
import { invalidateConfigCache as invalidateSharedConfigCache } from '../../config/config-loader-facade';
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function createTestHome(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-pool-onboarding-test-'));
|
||||
const ccsDir = path.join(dir, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'version: 1\n', 'utf8');
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Create a legacy-only home: has profiles.json but NO config.yaml */
|
||||
function createLegacyTestHome(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-pool-onboarding-legacy-'));
|
||||
const ccsDir = path.join(dir, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
// Intentionally no config.yaml - legacy install
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Write a minimal profiles.json with N account-type entries */
|
||||
function writeProfiles(ccsDir: string, names: string[]): void {
|
||||
const profiles: Record<string, unknown> = {};
|
||||
for (const n of names) {
|
||||
profiles[n] = { type: 'account', created: new Date().toISOString(), last_used: null };
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'profiles.json'),
|
||||
JSON.stringify({ version: '2.0.0', profiles, default: null }, null, 2),
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
/** Write a minimal profiles.json with mixed types */
|
||||
function writeProfilesMixed(ccsDir: string): void {
|
||||
const profiles: Record<string, unknown> = {
|
||||
acct1: { type: 'account', created: new Date().toISOString(), last_used: null },
|
||||
settings1: { type: 'settings', created: new Date().toISOString(), last_used: null },
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'profiles.json'),
|
||||
JSON.stringify({ version: '2.0.0', profiles, default: null }, null, 2),
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Phase 5: Pool Onboarding Hint', () => {
|
||||
let tempHome: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalIsTTY: boolean | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = createTestHome();
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
// Default: treat stdout as TTY for hint to fire
|
||||
originalIsTTY = process.stdout.isTTY;
|
||||
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
Object.defineProperty(process.stdout, 'isTTY', {
|
||||
value: originalIsTTY,
|
||||
configurable: true,
|
||||
});
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
invalidateSharedConfigCache();
|
||||
});
|
||||
|
||||
// ── 1. Non-TTY ──────────────────────────────────────────────────────────
|
||||
it('returns skipReason non-tty when stdout.isTTY is false', async () => {
|
||||
Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true });
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
writeProfiles(ccsDir, ['work', 'personal']);
|
||||
const { maybeShowPoolOnboardingHint } = await import(
|
||||
`../routing/pool-onboarding-hint?p5nontty=${Date.now()}`
|
||||
);
|
||||
const result = maybeShowPoolOnboardingHint();
|
||||
expect(result.printed).toBe(false);
|
||||
expect(result.skipReason).toBe('non-tty');
|
||||
});
|
||||
|
||||
// ── 2. Pool already enabled ─────────────────────────────────────────────
|
||||
it('returns skipReason pool-already-enabled when pool routing is on', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
writeProfiles(ccsDir, ['work', 'personal']);
|
||||
// Enable pool routing first
|
||||
const { mutateConfig } = await import(`../../config/config-loader-facade?p5pool=${Date.now()}`);
|
||||
mutateConfig((cfg: { cliproxy?: { pool_routing?: Record<string, unknown> } }) => {
|
||||
if (!cfg.cliproxy) return;
|
||||
cfg.cliproxy.pool_routing = { ...(cfg.cliproxy.pool_routing ?? {}), enabled: true };
|
||||
});
|
||||
invalidateSharedConfigCache();
|
||||
|
||||
const { maybeShowPoolOnboardingHint } = await import(
|
||||
`../routing/pool-onboarding-hint?p5pool2=${Date.now()}`
|
||||
);
|
||||
const result = maybeShowPoolOnboardingHint();
|
||||
expect(result.printed).toBe(false);
|
||||
expect(result.skipReason).toBe('pool-already-enabled');
|
||||
});
|
||||
|
||||
// ── 3. Dismissed ────────────────────────────────────────────────────────
|
||||
it('returns skipReason dismissed when onboarding_hint_dismissed is true', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
writeProfiles(ccsDir, ['work', 'personal']);
|
||||
|
||||
const { mutateConfig } = await import(`../../config/config-loader-facade?p5dis=${Date.now()}`);
|
||||
mutateConfig((cfg: { cliproxy?: { pool_routing?: Record<string, unknown> } }) => {
|
||||
if (!cfg.cliproxy) return;
|
||||
cfg.cliproxy.pool_routing = {
|
||||
...(cfg.cliproxy.pool_routing ?? {}),
|
||||
onboarding_hint_dismissed: true,
|
||||
};
|
||||
});
|
||||
invalidateSharedConfigCache();
|
||||
|
||||
const { maybeShowPoolOnboardingHint } = await import(
|
||||
`../routing/pool-onboarding-hint?p5dis2=${Date.now()}`
|
||||
);
|
||||
const result = maybeShowPoolOnboardingHint();
|
||||
expect(result.printed).toBe(false);
|
||||
expect(result.skipReason).toBe('dismissed');
|
||||
});
|
||||
|
||||
// ── 4. Fewer than 2 profiles ────────────────────────────────────────────
|
||||
it('returns skipReason fewer-than-2-profiles when only 1 profile exists', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
writeProfiles(ccsDir, ['work']);
|
||||
const { maybeShowPoolOnboardingHint } = await import(
|
||||
`../routing/pool-onboarding-hint?p5few=${Date.now()}`
|
||||
);
|
||||
const result = maybeShowPoolOnboardingHint();
|
||||
expect(result.printed).toBe(false);
|
||||
expect(result.skipReason).toBe('fewer-than-2-profiles');
|
||||
});
|
||||
|
||||
it('returns skipReason fewer-than-2-profiles when no profiles exist', async () => {
|
||||
const { maybeShowPoolOnboardingHint } = await import(
|
||||
`../routing/pool-onboarding-hint?p5zero=${Date.now()}`
|
||||
);
|
||||
const result = maybeShowPoolOnboardingHint();
|
||||
expect(result.printed).toBe(false);
|
||||
expect(result.skipReason).toBe('fewer-than-2-profiles');
|
||||
});
|
||||
|
||||
// ── 5. Hint prints when 2 profiles, TTY, not dismissed, pool off ────────
|
||||
it('prints hint and returns printed=true when conditions are met', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
writeProfiles(ccsDir, ['work', 'personal']);
|
||||
|
||||
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||
try {
|
||||
const { maybeShowPoolOnboardingHint } = await import(
|
||||
`../routing/pool-onboarding-hint?p5print=${Date.now()}`
|
||||
);
|
||||
const result = maybeShowPoolOnboardingHint();
|
||||
expect(result.printed).toBe(true);
|
||||
expect(result.skipReason).toBeUndefined();
|
||||
// Verify output: exactly one [i] marker (no double-prefix from info() + literal)
|
||||
expect(consoleSpy.mock.calls.length).toBeGreaterThan(0);
|
||||
const allOutput = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
||||
expect(allOutput).toContain('2 Claude profiles');
|
||||
const iMarkerCount = (allOutput.match(/\[i\]/g) ?? []).length;
|
||||
expect(iMarkerCount).toBe(1);
|
||||
} finally {
|
||||
consoleSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// ── 6. After dismiss, second call is silent ─────────────────────────────
|
||||
it('is silent on the second call because hint auto-dismisses after first print', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
writeProfiles(ccsDir, ['work', 'personal']);
|
||||
|
||||
// Use the same module instance for both calls so the dismissed flag persists
|
||||
const { maybeShowPoolOnboardingHint } = await import(
|
||||
`../routing/pool-onboarding-hint?p5once=${Date.now()}`
|
||||
);
|
||||
|
||||
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||
try {
|
||||
const first = maybeShowPoolOnboardingHint();
|
||||
expect(first.printed).toBe(true);
|
||||
|
||||
invalidateSharedConfigCache();
|
||||
|
||||
const second = maybeShowPoolOnboardingHint();
|
||||
expect(second.printed).toBe(false);
|
||||
expect(second.skipReason).toBe('dismissed');
|
||||
} finally {
|
||||
consoleSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// ── 7. Pre-computed count skips internal registry read ─────────────────
|
||||
it('uses the pre-computed count when provided', async () => {
|
||||
// No profiles.json — but we pass count=3 directly so it should still print
|
||||
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||
try {
|
||||
const { maybeShowPoolOnboardingHint } = await import(
|
||||
`../routing/pool-onboarding-hint?p5precount=${Date.now()}`
|
||||
);
|
||||
const result = maybeShowPoolOnboardingHint(3);
|
||||
expect(result.printed).toBe(true);
|
||||
} finally {
|
||||
consoleSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// ── 8. isOnboardingHintDismissed / dismissOnboardingHint roundtrip ─────
|
||||
it('dismissOnboardingHint persists and isOnboardingHintDismissed reads it', async () => {
|
||||
const { isOnboardingHintDismissed, dismissOnboardingHint } = await import(
|
||||
`../routing/pool-onboarding-hint?p5dismiss=${Date.now()}`
|
||||
);
|
||||
expect(isOnboardingHintDismissed()).toBe(false);
|
||||
dismissOnboardingHint();
|
||||
invalidateSharedConfigCache();
|
||||
expect(isOnboardingHintDismissed()).toBe(true);
|
||||
});
|
||||
|
||||
// ── 9. countNativeClaudeProfiles counts only account-type profiles ───────
|
||||
it('countNativeClaudeProfiles ignores non-account type profiles', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
writeProfilesMixed(ccsDir); // 1 account + 1 settings
|
||||
const { countNativeClaudeProfiles } = await import(
|
||||
`../routing/pool-onboarding-hint?p5count=${Date.now()}`
|
||||
);
|
||||
expect(countNativeClaudeProfiles()).toBe(1);
|
||||
});
|
||||
|
||||
it('countNativeClaudeProfiles returns 0 when no profiles.json exists', async () => {
|
||||
const { countNativeClaudeProfiles } = await import(
|
||||
`../routing/pool-onboarding-hint?p5count2=${Date.now()}`
|
||||
);
|
||||
expect(countNativeClaudeProfiles()).toBe(0);
|
||||
});
|
||||
|
||||
// ── 10. Doctor site: maybeShowPoolOnboardingHint is exported and callable ─
|
||||
it('maybeShowPoolOnboardingHint is callable after the doctor report phase', async () => {
|
||||
// NOTE: This is a SIMULATION of the doctor post-checks call site, not the
|
||||
// real Doctor pipeline. The hint fires from Doctor's private displayResults()
|
||||
// method, which has no cheap standalone entry point - exercising it directly
|
||||
// would require constructing Doctor and running the full async check pipeline
|
||||
// (system / OAuth / CLIProxy / Docker), which is heavy and environment-fragile.
|
||||
// Instead we call the exact exported symbol the doctor imports
|
||||
// (maybeShowPoolOnboardingHint, see doctor.ts import) under the same
|
||||
// preconditions: 2 profiles, TTY, not dismissed. The wiring itself - that
|
||||
// doctor.ts imports this symbol - is asserted in the companion test below.
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
writeProfiles(ccsDir, ['work', 'personal']);
|
||||
|
||||
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||
try {
|
||||
const { maybeShowPoolOnboardingHint } = await import(
|
||||
`../routing/pool-onboarding-hint?p5doc=${Date.now()}`
|
||||
);
|
||||
// doctor calls maybeShowPoolOnboardingHint() with no pre-computed count
|
||||
const result = maybeShowPoolOnboardingHint();
|
||||
expect(result.printed).toBe(true);
|
||||
const allOutput = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
||||
expect(allOutput).toContain('2 Claude profiles');
|
||||
} finally {
|
||||
consoleSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// ── 10b. Doctor wiring: doctor.ts imports and calls the hint symbol ───────
|
||||
it('doctor.ts wires maybeShowPoolOnboardingHint as the doctor hint site', () => {
|
||||
// Cheap static wiring assertion: confirms the real doctor call site imports
|
||||
// and invokes the same symbol the simulation above exercises, without
|
||||
// executing the heavy Doctor pipeline. If the doctor stops importing or
|
||||
// calling the hint, this fails - catching a broken hint site that a pure
|
||||
// simulation test could not.
|
||||
const doctorSrc = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'management', 'doctor.ts'),
|
||||
'utf8'
|
||||
);
|
||||
expect(doctorSrc).toContain(
|
||||
"import { maybeShowPoolOnboardingHint } from '../cliproxy/routing/pool-onboarding-hint'"
|
||||
);
|
||||
expect(doctorSrc).toContain('maybeShowPoolOnboardingHint();');
|
||||
});
|
||||
|
||||
// ── 11. Create-command suggestion: hint fires with post-create count ──────
|
||||
it('hint fires with count 2 after a successful 2nd-profile create (post-create call site)', async () => {
|
||||
// NOTE: SIMULATION of the create-command call site, not the real command.
|
||||
// create-command shows the hint only AFTER profile creation succeeds (a
|
||||
// pre-create hint would burn the once-per-install dismissal on a failed
|
||||
// create). At that point the registry already contains the new profile,
|
||||
// so the call is maybeShowPoolOnboardingHint(countNativeClaudeProfiles()).
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
writeProfiles(ccsDir, ['work', 'personal']); // post-create state: 2 profiles
|
||||
|
||||
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||
try {
|
||||
const { maybeShowPoolOnboardingHint, countNativeClaudeProfiles } = await import(
|
||||
`../routing/pool-onboarding-hint?p5create=${Date.now()}`
|
||||
);
|
||||
const count = countNativeClaudeProfiles();
|
||||
expect(count).toBe(2);
|
||||
const result = maybeShowPoolOnboardingHint(count);
|
||||
expect(result.printed).toBe(true);
|
||||
const allOutput = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
||||
expect(allOutput).toContain('2 Claude profiles');
|
||||
} finally {
|
||||
consoleSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// ── 12. Legacy-only install: dismissal does not create config.yaml ────────
|
||||
it('dismissOnboardingHint does not create config.yaml for legacy profiles.json-only installs', async () => {
|
||||
// Override tempHome with a legacy-only home (no config.yaml)
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
const legacyHome = createLegacyTestHome();
|
||||
process.env.CCS_HOME = legacyHome;
|
||||
invalidateSharedConfigCache();
|
||||
|
||||
try {
|
||||
const ccsDir = path.join(legacyHome, '.ccs');
|
||||
writeProfiles(ccsDir, ['work', 'personal']);
|
||||
const configYamlPath = path.join(ccsDir, 'config.yaml');
|
||||
|
||||
const { dismissOnboardingHint, isOnboardingHintDismissed } = await import(
|
||||
`../routing/pool-onboarding-hint?p5legacy=${Date.now()}`
|
||||
);
|
||||
|
||||
// Sanity: config.yaml does not exist yet
|
||||
expect(fs.existsSync(configYamlPath)).toBe(false);
|
||||
|
||||
// isOnboardingHintDismissed returns false (reads empty config, no persist)
|
||||
expect(isOnboardingHintDismissed()).toBe(false);
|
||||
|
||||
// dismissOnboardingHint must NOT create config.yaml
|
||||
dismissOnboardingHint();
|
||||
expect(fs.existsSync(configYamlPath)).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(legacyHome, { recursive: true, force: true });
|
||||
invalidateSharedConfigCache();
|
||||
}
|
||||
});
|
||||
|
||||
// ── 13. Hint copy is opt-in framed and names the enable command ──────────
|
||||
it("hint copy mentions 'ccs claude' and the opt-in enable command", async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
writeProfiles(ccsDir, ['work', 'personal']);
|
||||
|
||||
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||
try {
|
||||
const { maybeShowPoolOnboardingHint } = await import(
|
||||
`../routing/pool-onboarding-hint?p5copy=${Date.now()}`
|
||||
);
|
||||
const result = maybeShowPoolOnboardingHint();
|
||||
expect(result.printed).toBe(true);
|
||||
const allOutput = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
||||
expect(allOutput).toContain('ccs claude');
|
||||
// Copy must be opt-in (not declarative "already pools") and name the
|
||||
// actual enable command so the user has an actionable next step.
|
||||
expect(allOutput).toContain('ccs cliproxy pool --enable');
|
||||
expect(allOutput.toLowerCase()).toContain('can auto-continue');
|
||||
// Must NOT read as already-active behavior.
|
||||
expect(allOutput).not.toContain('pool auto-continues');
|
||||
} finally {
|
||||
consoleSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// ── 14. Legacy-only install: hasUnifiedConfig() is false at account-flow / create-command sites ──
|
||||
it('hasUnifiedConfig returns false for legacy profiles.json-only installs (call-site gate)', async () => {
|
||||
// Verifies that the guard used in account-flow.ts and create-command.ts
|
||||
// (hasUnifiedConfig()) correctly reports false for a profiles.json-only home,
|
||||
// meaning those call sites silently skip the hint and legacy users receive
|
||||
// the hint exclusively from ccs doctor.
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
const legacyHome = createLegacyTestHome();
|
||||
process.env.CCS_HOME = legacyHome;
|
||||
invalidateSharedConfigCache();
|
||||
|
||||
try {
|
||||
const ccsDir = path.join(legacyHome, '.ccs');
|
||||
writeProfiles(ccsDir, ['work', 'personal']);
|
||||
|
||||
const { hasUnifiedConfig } = await import(
|
||||
`../../config/config-loader-facade?p5gate=${Date.now()}`
|
||||
);
|
||||
|
||||
// The call-site guard must return false - legacy install has no config.yaml
|
||||
expect(hasUnifiedConfig()).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(legacyHome, { recursive: true, force: true });
|
||||
invalidateSharedConfigCache();
|
||||
}
|
||||
});
|
||||
|
||||
// ── 15. Malformed config: hint never throws and prints nothing ────────────
|
||||
it('does not throw or print when config.yaml is malformed (hint must not break launch)', async () => {
|
||||
// A pre-computed profileCount (passed by the launch / create call sites)
|
||||
// clears the cheap gates, so the decision reaches the single config load.
|
||||
// A malformed config.yaml makes loadOrCreateUnifiedConfig throw; the
|
||||
// try/catch in maybeShowPoolOnboardingHint must swallow it - returning
|
||||
// printed=false with skipReason 'error' and emitting no hint - so an account
|
||||
// launch is never broken by a bad config.
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
// Overwrite the valid config.yaml from createTestHome() with invalid YAML.
|
||||
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'cliproxy: [unclosed\n', 'utf8');
|
||||
invalidateSharedConfigCache();
|
||||
|
||||
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||
try {
|
||||
const { maybeShowPoolOnboardingHint } = await import(
|
||||
`../routing/pool-onboarding-hint?p5malformed=${Date.now()}`
|
||||
);
|
||||
let result: { printed: boolean; skipReason?: string } | undefined;
|
||||
// Must not throw. Pass count=2 to bypass the registry (which itself loads
|
||||
// config) and drive the decision into the explicit config load + try/catch.
|
||||
expect(() => {
|
||||
result = maybeShowPoolOnboardingHint(2);
|
||||
}).not.toThrow();
|
||||
expect(result?.printed).toBe(false);
|
||||
expect(result?.skipReason).toBe('error');
|
||||
// No hint line printed.
|
||||
const hintLines = consoleSpy.mock.calls
|
||||
.map((c) => String(c[0]))
|
||||
.filter((s) => s.includes('Claude profiles'));
|
||||
expect(hintLines.length).toBe(0);
|
||||
} finally {
|
||||
consoleSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// ── 16. Malformed config via doctor path (no count): still no throw ───────
|
||||
it('does not throw when config is malformed and no profileCount is passed (doctor path)', async () => {
|
||||
// The doctor site calls maybeShowPoolOnboardingHint() with no count. With a
|
||||
// malformed config, countNativeClaudeProfiles() swallows the registry error
|
||||
// and returns 0, short-circuiting at fewer-than-2-profiles. Either way the
|
||||
// call must never throw - this guards the doctor call site specifically.
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
writeProfiles(ccsDir, ['work', 'personal']);
|
||||
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'cliproxy: [unclosed\n', 'utf8');
|
||||
invalidateSharedConfigCache();
|
||||
|
||||
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||
try {
|
||||
const { maybeShowPoolOnboardingHint } = await import(
|
||||
`../routing/pool-onboarding-hint?p5malformed2=${Date.now()}`
|
||||
);
|
||||
let result: { printed: boolean } | undefined;
|
||||
expect(() => {
|
||||
result = maybeShowPoolOnboardingHint();
|
||||
}).not.toThrow();
|
||||
expect(result?.printed).toBe(false);
|
||||
const hintLines = consoleSpy.mock.calls
|
||||
.map((c) => String(c[0]))
|
||||
.filter((s) => s.includes('Claude profiles'));
|
||||
expect(hintLines.length).toBe(0);
|
||||
} finally {
|
||||
consoleSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -60,6 +60,15 @@ describe('Thinking Validator', () => {
|
||||
expect(result.warning).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should treat max as a distinct top tier on Claude Fable 5', () => {
|
||||
// Fable 5 shares Opus 4.8's adaptive thinking surface; max must remain
|
||||
// distinct from xhigh.
|
||||
const result = validateThinking('claude', 'claude-fable-5', 'max');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.value).toBe('max');
|
||||
expect(result.warning).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should still alias max -> xhigh for models without a max level (backcompat)', () => {
|
||||
// Codex catalog uses ['low','medium','high','xhigh'] with maxLevel 'xhigh'.
|
||||
// User input "max" should map down to xhigh rather than be rejected.
|
||||
|
||||
@@ -0,0 +1,935 @@
|
||||
/**
|
||||
* Tests for drain order priority computation, write path, and attribution stability.
|
||||
*/
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { runWithScopedCcsHome } from '../../../utils/config-manager';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function withIsolatedHome<T>(fn: (homeDir: string) => Promise<T> | T): Promise<T> {
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-drain-order-'));
|
||||
try {
|
||||
return await runWithScopedCcsHome(homeDir, () => fn(homeDir));
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a minimal auth JSON file in the auth dir. */
|
||||
function writeAuthFile(
|
||||
authDir: string,
|
||||
fileName: string,
|
||||
fields: Record<string, unknown> = {}
|
||||
): void {
|
||||
fs.mkdirSync(authDir, { recursive: true, mode: 0o700 });
|
||||
const content = JSON.stringify({ type: 'antigravity', email: fileName, ...fields }, null, 2);
|
||||
fs.writeFileSync(path.join(authDir, fileName), content, { mode: 0o600 });
|
||||
}
|
||||
|
||||
function readAuthFile(authDir: string, fileName: string): Record<string, unknown> {
|
||||
return JSON.parse(fs.readFileSync(path.join(authDir, fileName), 'utf-8')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import helpers (avoid module-level imports that capture paths at load time)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function loadDrainOrder() {
|
||||
return import(`../drain-order?drain-order=${Date.now()}`);
|
||||
}
|
||||
|
||||
async function loadRegistry() {
|
||||
return import(`../registry?drain-order-registry=${Date.now()}`);
|
||||
}
|
||||
|
||||
async function loadStatsTransformer() {
|
||||
return import(`../../../cliproxy/services/stats-fetcher?drain-order-stats-fetcher=${Date.now()}`);
|
||||
}
|
||||
|
||||
async function loadOrderSubcommand() {
|
||||
return import(
|
||||
`../../../commands/cliproxy/order-subcommand?drain-order-order-subcommand=${Date.now()}`
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Priority computation tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('rankToPriority', () => {
|
||||
it('maps rank 0 (highest) to total priority', async () => {
|
||||
const { rankToPriority } = await loadDrainOrder();
|
||||
expect(rankToPriority(0, 3)).toBe(3);
|
||||
});
|
||||
|
||||
it('maps last rank to MIN_PRIORITY (1)', async () => {
|
||||
const { rankToPriority, MIN_PRIORITY } = await loadDrainOrder();
|
||||
expect(rankToPriority(2, 3)).toBe(MIN_PRIORITY);
|
||||
expect(rankToPriority(2, 3)).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('never returns 0', async () => {
|
||||
const { rankToPriority } = await loadDrainOrder();
|
||||
for (let total = 1; total <= 5; total++) {
|
||||
for (let rank = 0; rank < total; rank++) {
|
||||
expect(rankToPriority(rank, total)).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('single account gets priority 1', async () => {
|
||||
const { rankToPriority } = await loadDrainOrder();
|
||||
expect(rankToPriority(0, 1)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeManualDrainOrder', () => {
|
||||
it('assigns descending priorities to specified accounts', async () => {
|
||||
const { computeManualDrainOrder } = await loadDrainOrder();
|
||||
const accounts = [
|
||||
{ accountId: 'a@x.com', tokenFile: 'a.json' },
|
||||
{ accountId: 'b@x.com', tokenFile: 'b.json' },
|
||||
{ accountId: 'c@x.com', tokenFile: 'c.json' },
|
||||
];
|
||||
const entries = computeManualDrainOrder(['a@x.com', 'b@x.com', 'c@x.com'], accounts);
|
||||
const byId = new Map(
|
||||
entries.map((e: { accountId: string; priority: number }) => [e.accountId, e.priority])
|
||||
);
|
||||
expect(byId.get('a@x.com')).toBeGreaterThan(byId.get('b@x.com'));
|
||||
expect(byId.get('b@x.com')).toBeGreaterThan(byId.get('c@x.com'));
|
||||
expect(byId.get('c@x.com')).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('throws for unknown account ID', async () => {
|
||||
const { computeManualDrainOrder } = await loadDrainOrder();
|
||||
const accounts = [{ accountId: 'a@x.com', tokenFile: 'a.json' }];
|
||||
expect(() => computeManualDrainOrder(['z@x.com'], accounts)).toThrow();
|
||||
});
|
||||
|
||||
it('throws for duplicate account ID in the order list', async () => {
|
||||
const { computeManualDrainOrder } = await loadDrainOrder();
|
||||
const accounts = [
|
||||
{ accountId: 'a@x.com', tokenFile: 'a.json' },
|
||||
{ accountId: 'b@x.com', tokenFile: 'b.json' },
|
||||
];
|
||||
expect(() => computeManualDrainOrder(['a@x.com', 'b@x.com', 'a@x.com'], accounts)).toThrow(
|
||||
/[Dd]uplicate/
|
||||
);
|
||||
});
|
||||
|
||||
it('unspecified accounts get MIN_PRIORITY', async () => {
|
||||
const { computeManualDrainOrder, MIN_PRIORITY } = await loadDrainOrder();
|
||||
const accounts = [
|
||||
{ accountId: 'a@x.com', tokenFile: 'a.json' },
|
||||
{ accountId: 'b@x.com', tokenFile: 'b.json' },
|
||||
];
|
||||
const entries = computeManualDrainOrder(['a@x.com'], accounts);
|
||||
const bEntry = entries.find((e: { accountId: string }) => e.accountId === 'b@x.com');
|
||||
expect(bEntry?.priority).toBe(MIN_PRIORITY);
|
||||
});
|
||||
|
||||
it('is idempotent - same inputs produce same priorities', async () => {
|
||||
const { computeManualDrainOrder } = await loadDrainOrder();
|
||||
const accounts = [
|
||||
{ accountId: 'a@x.com', tokenFile: 'a.json' },
|
||||
{ accountId: 'b@x.com', tokenFile: 'b.json' },
|
||||
];
|
||||
const entries1 = computeManualDrainOrder(['a@x.com', 'b@x.com'], accounts);
|
||||
const entries2 = computeManualDrainOrder(['a@x.com', 'b@x.com'], accounts);
|
||||
expect(JSON.stringify(entries1)).toBe(JSON.stringify(entries2));
|
||||
});
|
||||
|
||||
it('partial --set: every specified account has strictly higher priority than every unspecified account', async () => {
|
||||
// Regression for the tie at priority 1 when last specified == MIN_PRIORITY == unspecified.
|
||||
// With a pool of 5, specifying 2 should give the last specified priority > 1.
|
||||
const { computeManualDrainOrder, MIN_PRIORITY } = await loadDrainOrder();
|
||||
const accounts = [
|
||||
{ accountId: 'a@x.com', tokenFile: 'a.json' },
|
||||
{ accountId: 'b@x.com', tokenFile: 'b.json' },
|
||||
{ accountId: 'c@x.com', tokenFile: 'c.json' },
|
||||
{ accountId: 'd@x.com', tokenFile: 'd.json' },
|
||||
{ accountId: 'e@x.com', tokenFile: 'e.json' },
|
||||
];
|
||||
const entries = computeManualDrainOrder(['a@x.com', 'b@x.com'], accounts);
|
||||
const byId = new Map(
|
||||
entries.map((e: { accountId: string; priority: number }) => [e.accountId, e.priority])
|
||||
);
|
||||
const specifiedPriorities = ['a@x.com', 'b@x.com'].map((id) => byId.get(id) as number);
|
||||
const unspecifiedPriorities = ['c@x.com', 'd@x.com', 'e@x.com'].map(
|
||||
(id) => byId.get(id) as number
|
||||
);
|
||||
const minSpecified = Math.min(...specifiedPriorities);
|
||||
const maxUnspecified = Math.max(...unspecifiedPriorities);
|
||||
expect(minSpecified).toBeGreaterThan(maxUnspecified);
|
||||
// Unspecified accounts still get MIN_PRIORITY
|
||||
for (const p of unspecifiedPriorities) {
|
||||
expect(p).toBe(MIN_PRIORITY);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeTierDrainOrder', () => {
|
||||
it('ultra > pro > free priority ordering', async () => {
|
||||
const { computeTierDrainOrder } = await loadDrainOrder();
|
||||
const accounts = [
|
||||
{ accountId: 'free@x.com', tokenFile: 'free.json', tier: 'free' as const },
|
||||
{ accountId: 'ultra@x.com', tokenFile: 'ultra.json', tier: 'ultra' as const },
|
||||
{ accountId: 'pro@x.com', tokenFile: 'pro.json', tier: 'pro' as const },
|
||||
];
|
||||
const entries = computeTierDrainOrder(accounts);
|
||||
const byId = new Map(
|
||||
entries.map((e: { accountId: string; priority: number }) => [e.accountId, e.priority])
|
||||
);
|
||||
expect(byId.get('ultra@x.com')).toBeGreaterThan(byId.get('pro@x.com'));
|
||||
expect(byId.get('pro@x.com')).toBeGreaterThan(byId.get('free@x.com'));
|
||||
expect(byId.get('free@x.com')).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('unknown tier gets lowest priority (MIN_PRIORITY)', async () => {
|
||||
const { computeTierDrainOrder, MIN_PRIORITY } = await loadDrainOrder();
|
||||
const accounts = [
|
||||
{ accountId: 'ultra@x.com', tokenFile: 'ultra.json', tier: 'ultra' as const },
|
||||
{ accountId: 'unknown@x.com', tokenFile: 'unknown.json', tier: 'unknown' as const },
|
||||
];
|
||||
const entries = computeTierDrainOrder(accounts);
|
||||
const unknownEntry = entries.find(
|
||||
(e: { accountId: string }) => e.accountId === 'unknown@x.com'
|
||||
);
|
||||
expect(unknownEntry?.priority).toBe(MIN_PRIORITY);
|
||||
});
|
||||
|
||||
it('accounts with same tier get equal priority', async () => {
|
||||
const { computeTierDrainOrder } = await loadDrainOrder();
|
||||
const accounts = [
|
||||
{ accountId: 'a@x.com', tokenFile: 'a.json', tier: 'pro' as const },
|
||||
{ accountId: 'b@x.com', tokenFile: 'b.json', tier: 'pro' as const },
|
||||
];
|
||||
const entries = computeTierDrainOrder(accounts);
|
||||
const [a, b] = entries as Array<{ accountId: string; priority: number }>;
|
||||
expect(a.priority).toBe(b.priority);
|
||||
});
|
||||
|
||||
it('tie-break by tokenFile (plain byte order, not locale) matches selector contract', async () => {
|
||||
// The upstream Go selector breaks priority ties by Auth.ID asc (lowercased file path).
|
||||
// For the agy prefix-named fleet, file names differ from emails, so verify we sort
|
||||
// by tokenFile not accountId.
|
||||
const { computeTierDrainOrder } = await loadDrainOrder();
|
||||
const accounts = [
|
||||
{ accountId: 'z@x.com', tokenFile: 'antigravity-aaa.json', tier: 'pro' as const },
|
||||
{ accountId: 'a@x.com', tokenFile: 'antigravity-zzz.json', tier: 'pro' as const },
|
||||
];
|
||||
// Both same tier -> same priority; sort by tokenFile should put 'aaa' before 'zzz'
|
||||
const entries = computeTierDrainOrder(accounts) as Array<{
|
||||
accountId: string;
|
||||
tokenFile: string;
|
||||
priority: number;
|
||||
}>;
|
||||
// Confirm equal priorities
|
||||
expect(entries[0].priority).toBe(entries[1].priority);
|
||||
// Sort as the display layer does: priority desc, tokenFile lowercase asc
|
||||
const sorted = entries
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.priority - a.priority ||
|
||||
(a.tokenFile.toLowerCase() < b.tokenFile.toLowerCase() ? -1 : 1)
|
||||
);
|
||||
// antigravity-aaa.json sorts before antigravity-zzz.json (byte/file order)
|
||||
expect(sorted[0].tokenFile).toBe('antigravity-aaa.json');
|
||||
expect(sorted[1].tokenFile).toBe('antigravity-zzz.json');
|
||||
// accountId order is the reverse, confirming file order != email order for this fleet
|
||||
expect(sorted[0].accountId).toBe('z@x.com');
|
||||
expect(sorted[1].accountId).toBe('a@x.com');
|
||||
});
|
||||
|
||||
it('tierDerived is true only for accounts with a real tier rank', async () => {
|
||||
const { computeTierDrainOrder } = await loadDrainOrder();
|
||||
const accounts = [
|
||||
{ accountId: 'ultra@x.com', tokenFile: 'ultra.json', tier: 'ultra' as const },
|
||||
{ accountId: 'unknown@x.com', tokenFile: 'unknown.json', tier: 'unknown' as const },
|
||||
];
|
||||
const entries = computeTierDrainOrder(accounts) as Array<{
|
||||
accountId: string;
|
||||
tierDerived: boolean;
|
||||
}>;
|
||||
const ultraEntry = entries.find((e) => e.accountId === 'ultra@x.com');
|
||||
const unknownEntry = entries.find((e) => e.accountId === 'unknown@x.com');
|
||||
expect(ultraEntry?.tierDerived).toBe(true);
|
||||
expect(unknownEntry?.tierDerived).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tie-break key (platform-conditional, mirrors upstream synthesizer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('tieBreakKey', () => {
|
||||
it('preserves case on non-Windows (byte order, upper before lower)', async () => {
|
||||
const { tieBreakKey } = await loadOrderSubcommand();
|
||||
// On non-Windows the selector compares raw Auth.ID bytes: 'B' (0x42) < 'a' (0x61).
|
||||
const files = ['antigravity-aaa.json', 'antigravity-Bbb.json'];
|
||||
const sorted = files
|
||||
.slice()
|
||||
.sort((a, b) => (tieBreakKey(a, 'linux') < tieBreakKey(b, 'linux') ? -1 : 1));
|
||||
expect(sorted).toEqual(['antigravity-Bbb.json', 'antigravity-aaa.json']);
|
||||
});
|
||||
|
||||
it('lowercases on Windows so mixed case sorts case-insensitively', async () => {
|
||||
const { tieBreakKey } = await loadOrderSubcommand();
|
||||
const files = ['antigravity-aaa.json', 'antigravity-Bbb.json'];
|
||||
const sorted = files
|
||||
.slice()
|
||||
.sort((a, b) => (tieBreakKey(a, 'win32') < tieBreakKey(b, 'win32') ? -1 : 1));
|
||||
expect(sorted).toEqual(['antigravity-aaa.json', 'antigravity-Bbb.json']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Direct file write tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('writeAuthFilePriorityDirect', () => {
|
||||
it('writes priority field to auth JSON', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
writeAuthFile(authDir, 'test.json', { email: 'test@x.com' });
|
||||
|
||||
const { writeAuthFilePriorityDirect } = await loadDrainOrder();
|
||||
writeAuthFilePriorityDirect('test.json', 3);
|
||||
|
||||
const data = readAuthFile(authDir, 'test.json');
|
||||
expect(data.priority).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves all other fields', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
writeAuthFile(authDir, 'test.json', { email: 'test@x.com', token: 'abc123' });
|
||||
|
||||
const { writeAuthFilePriorityDirect } = await loadDrainOrder();
|
||||
writeAuthFilePriorityDirect('test.json', 2);
|
||||
|
||||
const data = readAuthFile(authDir, 'test.json');
|
||||
expect(data.email).toBe('test@x.com');
|
||||
expect(data.token).toBe('abc123');
|
||||
expect(data.priority).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects priority 0 (management layer treats 0 as delete)', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
writeAuthFile(authDir, 'test.json');
|
||||
|
||||
const { writeAuthFilePriorityDirect } = await loadDrainOrder();
|
||||
expect(() => writeAuthFilePriorityDirect('test.json', 0)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects negative priority', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
writeAuthFile(authDir, 'test.json');
|
||||
|
||||
const { writeAuthFilePriorityDirect } = await loadDrainOrder();
|
||||
expect(() => writeAuthFilePriorityDirect('test.json', -1)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when file does not exist', async () => {
|
||||
await withIsolatedHome(async (_homeDir) => {
|
||||
const { writeAuthFilePriorityDirect } = await loadDrainOrder();
|
||||
expect(() => writeAuthFilePriorityDirect('nonexistent.json', 1)).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('readAuthFilePriority', () => {
|
||||
it('returns the priority from a file that has one', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
writeAuthFile(authDir, 'test.json', { priority: 5 });
|
||||
|
||||
const { readAuthFilePriority } = await loadDrainOrder();
|
||||
expect(readAuthFilePriority('test.json')).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined for file with no priority', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
writeAuthFile(authDir, 'test.json', { email: 'x@y.com' });
|
||||
|
||||
const { readAuthFilePriority } = await loadDrainOrder();
|
||||
expect(readAuthFilePriority('test.json')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined for missing file', async () => {
|
||||
await withIsolatedHome(async (_homeDir) => {
|
||||
const { readAuthFilePriority } = await loadDrainOrder();
|
||||
expect(readAuthFilePriority('ghost.json')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined for priority 0 (invalid sentinel)', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
writeAuthFile(authDir, 'test.json', { priority: 0 });
|
||||
|
||||
const { readAuthFilePriority } = await loadDrainOrder();
|
||||
expect(readAuthFilePriority('test.json')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applyDrainOrder (direct write path, proxy stopped)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('applyDrainOrder - direct write path (proxy stopped)', () => {
|
||||
it('writes priorities to all files when proxy stopped', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
writeAuthFile(authDir, 'a.json', { email: 'a@x.com' });
|
||||
writeAuthFile(authDir, 'b.json', { email: 'b@x.com' });
|
||||
|
||||
const { applyDrainOrder } = await loadDrainOrder();
|
||||
const entries = [
|
||||
{
|
||||
accountId: 'a@x.com',
|
||||
tokenFile: 'a.json',
|
||||
priority: 2,
|
||||
tierDerived: false,
|
||||
currentPriority: undefined,
|
||||
},
|
||||
{
|
||||
accountId: 'b@x.com',
|
||||
tokenFile: 'b.json',
|
||||
priority: 1,
|
||||
tierDerived: false,
|
||||
currentPriority: undefined,
|
||||
},
|
||||
];
|
||||
const result = await applyDrainOrder(entries, false);
|
||||
|
||||
expect(result.usedManagementApi).toBe(false);
|
||||
expect(result.written).toHaveLength(2);
|
||||
expect(result.failed).toHaveLength(0);
|
||||
|
||||
const a = readAuthFile(authDir, 'a.json');
|
||||
const b = readAuthFile(authDir, 'b.json');
|
||||
expect(a.priority).toBe(2);
|
||||
expect(b.priority).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('skips entries where priority already matches (idempotency)', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
writeAuthFile(authDir, 'a.json', { email: 'a@x.com', priority: 3 });
|
||||
|
||||
const { applyDrainOrder } = await loadDrainOrder();
|
||||
const entries = [
|
||||
{
|
||||
accountId: 'a@x.com',
|
||||
tokenFile: 'a.json',
|
||||
priority: 3,
|
||||
tierDerived: false,
|
||||
currentPriority: undefined,
|
||||
},
|
||||
];
|
||||
const result = await applyDrainOrder(entries, false);
|
||||
|
||||
expect(result.skipped).toHaveLength(1);
|
||||
expect(result.written).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('records failures for missing files', async () => {
|
||||
await withIsolatedHome(async (_homeDir) => {
|
||||
const { applyDrainOrder } = await loadDrainOrder();
|
||||
const entries = [
|
||||
{
|
||||
accountId: 'ghost@x.com',
|
||||
tokenFile: 'ghost.json',
|
||||
priority: 2,
|
||||
tierDerived: false,
|
||||
currentPriority: undefined,
|
||||
},
|
||||
];
|
||||
const result = await applyDrainOrder(entries, false);
|
||||
expect(result.failed).toHaveLength(1);
|
||||
expect(result.written).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registry drain order persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('registry drain order persistence', () => {
|
||||
it('saveDrainOrderConfig persists and loadDrainOrderConfig retrieves', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
writeAuthFile(authDir, 'antigravity-a.json', { email: 'a@x.com', type: 'antigravity' });
|
||||
|
||||
const { registerAccount } = await loadRegistry();
|
||||
registerAccount('agy', 'antigravity-a.json', 'a@x.com');
|
||||
|
||||
const { saveDrainOrderConfig, loadDrainOrderConfig } = await loadRegistry();
|
||||
const config = { mode: 'manual' as const, orderedIds: ['a@x.com'] };
|
||||
const saved = saveDrainOrderConfig('agy', config);
|
||||
expect(saved).toBe(true);
|
||||
|
||||
const loaded = loadDrainOrderConfig('agy');
|
||||
expect(loaded?.mode).toBe('manual');
|
||||
expect(loaded?.orderedIds).toEqual(['a@x.com']);
|
||||
});
|
||||
});
|
||||
|
||||
it('clearDrainOrderConfig removes persisted config', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
writeAuthFile(authDir, 'antigravity-a.json', { email: 'a@x.com', type: 'antigravity' });
|
||||
|
||||
const { registerAccount } = await loadRegistry();
|
||||
registerAccount('agy', 'antigravity-a.json', 'a@x.com');
|
||||
|
||||
const { saveDrainOrderConfig, loadDrainOrderConfig, clearDrainOrderConfig } =
|
||||
await loadRegistry();
|
||||
saveDrainOrderConfig('agy', { mode: 'tier' });
|
||||
const cleared = clearDrainOrderConfig('agy');
|
||||
expect(cleared).toBe(true);
|
||||
|
||||
expect(loadDrainOrderConfig('agy')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns false for saveDrainOrderConfig when provider not in registry', async () => {
|
||||
await withIsolatedHome(async (_homeDir) => {
|
||||
const { saveDrainOrderConfig } = await loadRegistry();
|
||||
// No accounts registered for 'gemini' -> returns false
|
||||
const result = saveDrainOrderConfig('gemini', { mode: 'tier' });
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Attribution stability: auth_index ordering survives priority rewrite
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('attribution stability - auth_index unaffected by priority rewrite', () => {
|
||||
it('buildAuthIndexToAccountMap produces stable output regardless of priority values', async () => {
|
||||
// Simulate the auth files list returned by /v0/management/auth-files with various priorities.
|
||||
// auth_index is assigned by CLIProxy at load time based on file discovery order,
|
||||
// NOT by priority. So priority rewrites must not change auth_index values.
|
||||
const { buildAuthIndexToAccountMap } = await loadStatsTransformer();
|
||||
|
||||
const authFilesBeforeRewrite = [
|
||||
{ auth_index: 0, email: 'a@x.com', provider: 'antigravity' },
|
||||
{ auth_index: 1, email: 'b@x.com', provider: 'antigravity' },
|
||||
{ auth_index: 2, email: 'c@x.com', provider: 'antigravity' },
|
||||
];
|
||||
|
||||
const authFilesAfterRewrite = [
|
||||
// Priority changed but auth_index values are assigned by CLIProxy independently
|
||||
{ auth_index: 0, email: 'a@x.com', provider: 'antigravity', priority: 3 },
|
||||
{ auth_index: 1, email: 'b@x.com', provider: 'antigravity', priority: 2 },
|
||||
{ auth_index: 2, email: 'c@x.com', provider: 'antigravity', priority: 1 },
|
||||
];
|
||||
|
||||
const mapBefore = buildAuthIndexToAccountMap(authFilesBeforeRewrite);
|
||||
const mapAfter = buildAuthIndexToAccountMap(authFilesAfterRewrite);
|
||||
|
||||
// auth_index -> email mapping must be identical after priority rewrite
|
||||
expect(mapBefore.get('0')).toBe('a@x.com');
|
||||
expect(mapBefore.get('1')).toBe('b@x.com');
|
||||
expect(mapBefore.get('2')).toBe('c@x.com');
|
||||
|
||||
expect(mapAfter.get('0')).toBe(mapBefore.get('0'));
|
||||
expect(mapAfter.get('1')).toBe(mapBefore.get('1'));
|
||||
expect(mapAfter.get('2')).toBe(mapBefore.get('2'));
|
||||
});
|
||||
|
||||
it('buildAuthIndexToAccountMap skips entries without auth_index', async () => {
|
||||
const { buildAuthIndexToAccountMap } = await loadStatsTransformer();
|
||||
|
||||
const authFiles = [
|
||||
{ email: 'no-index@x.com', provider: 'antigravity' }, // no auth_index
|
||||
{ auth_index: 5, email: 'has-index@x.com', provider: 'antigravity' },
|
||||
];
|
||||
|
||||
const map = buildAuthIndexToAccountMap(authFiles);
|
||||
expect(map.size).toBe(1);
|
||||
expect(map.get('5')).toBe('has-index@x.com');
|
||||
expect(map.has('undefined')).toBe(false);
|
||||
});
|
||||
|
||||
it('buildAuthIndexToAccountMap handles both numeric and string auth_index keys', async () => {
|
||||
const { buildAuthIndexToAccountMap } = await loadStatsTransformer();
|
||||
|
||||
const authFiles = [
|
||||
{ auth_index: 0, email: 'numeric@x.com', provider: 'antigravity' },
|
||||
{ auth_index: '1', email: 'string@x.com', provider: 'antigravity' },
|
||||
];
|
||||
|
||||
const map = buildAuthIndexToAccountMap(authFiles);
|
||||
// Both stored as String(auth_index)
|
||||
expect(map.get('0')).toBe('numeric@x.com');
|
||||
expect(map.get('1')).toBe('string@x.com');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveEffectiveDrainOrder - shared effective-order resolver
|
||||
// (consumed by `accounts order` show AND the quota pool section)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resolveEffectiveDrainOrder', () => {
|
||||
it('returns empty file-order result for no active accounts', async () => {
|
||||
await withIsolatedHome(async () => {
|
||||
const { resolveEffectiveDrainOrder } = await loadDrainOrder();
|
||||
const result = resolveEffectiveDrainOrder('claude', []);
|
||||
expect(result.mode).toBe('file');
|
||||
expect(result.entries).toEqual([]);
|
||||
expect(result.hasDrift).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses stable file order (tieBreakKey) and reports no drift when no config stored', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
writeAuthFile(authDir, 'claude-b.json', { email: 'b@x.com' });
|
||||
writeAuthFile(authDir, 'claude-a.json', { email: 'a@x.com' });
|
||||
|
||||
const { resolveEffectiveDrainOrder } = await loadDrainOrder();
|
||||
const result = resolveEffectiveDrainOrder('claude', [
|
||||
{ accountId: 'b@x.com', tokenFile: 'claude-b.json' },
|
||||
{ accountId: 'a@x.com', tokenFile: 'claude-a.json' },
|
||||
]);
|
||||
expect(result.mode).toBe('file');
|
||||
expect(result.entries.map((e: { accountId: string }) => e.accountId)).toEqual([
|
||||
'a@x.com',
|
||||
'b@x.com',
|
||||
]);
|
||||
expect(result.hasDrift).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('file-order: residual on-disk priorities reorder display to match the selector and flag drift', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
// No drain order config stored -> file mode. But residual priorities are
|
||||
// left on disk (e.g. after `order --reset` did not strip the attribute).
|
||||
// The selector follows the file, so b (priority 5) must sort before a
|
||||
// (priority 1) even though tieBreakKey alone would put a first, and drift
|
||||
// is flagged so the user knows the file disagrees with "plain file order".
|
||||
writeAuthFile(authDir, 'claude-a.json', { email: 'a@x.com', priority: 1 });
|
||||
writeAuthFile(authDir, 'claude-b.json', { email: 'b@x.com', priority: 5 });
|
||||
|
||||
const { resolveEffectiveDrainOrder } = await loadDrainOrder();
|
||||
const result = resolveEffectiveDrainOrder('claude', [
|
||||
{ accountId: 'a@x.com', tokenFile: 'claude-a.json' },
|
||||
{ accountId: 'b@x.com', tokenFile: 'claude-b.json' },
|
||||
]);
|
||||
expect(result.mode).toBe('file');
|
||||
expect(result.entries.map((e: { accountId: string }) => e.accountId)).toEqual([
|
||||
'b@x.com',
|
||||
'a@x.com',
|
||||
]);
|
||||
expect(result.hasDrift).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('sorts manual order by ON-DISK priority, not computed config, and flags drift', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
// Two registered accounts; manual config says a then b (a > b computed).
|
||||
writeAuthFile(authDir, 'antigravity-a.json', { email: 'a@x.com', type: 'antigravity' });
|
||||
writeAuthFile(authDir, 'antigravity-b.json', { email: 'b@x.com', type: 'antigravity' });
|
||||
|
||||
const { registerAccount, saveDrainOrderConfig } = await loadRegistry();
|
||||
registerAccount('agy', 'antigravity-a.json', 'a@x.com');
|
||||
registerAccount('agy', 'antigravity-b.json', 'b@x.com');
|
||||
saveDrainOrderConfig('agy', { mode: 'manual', orderedIds: ['a@x.com', 'b@x.com'] });
|
||||
|
||||
// On disk, re-auth/--reset left b with a HIGHER priority than a. The
|
||||
// selector follows the file, so b should come first and drift is flagged.
|
||||
const { writeAuthFilePriorityDirect, resolveEffectiveDrainOrder } = await loadDrainOrder();
|
||||
writeAuthFilePriorityDirect('antigravity-a.json', 1);
|
||||
writeAuthFilePriorityDirect('antigravity-b.json', 5);
|
||||
|
||||
const result = resolveEffectiveDrainOrder('agy', [
|
||||
{ accountId: 'a@x.com', tokenFile: 'antigravity-a.json' },
|
||||
{ accountId: 'b@x.com', tokenFile: 'antigravity-b.json' },
|
||||
]);
|
||||
expect(result.mode).toBe('manual');
|
||||
// b@x.com first because its ON-DISK priority (5) beats a@x.com (1).
|
||||
expect(result.entries.map((e: { accountId: string }) => e.accountId)).toEqual([
|
||||
'b@x.com',
|
||||
'a@x.com',
|
||||
]);
|
||||
expect(result.hasDrift).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports no drift when on-disk priorities match the computed manual config', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
writeAuthFile(authDir, 'antigravity-a.json', { email: 'a@x.com', type: 'antigravity' });
|
||||
writeAuthFile(authDir, 'antigravity-b.json', { email: 'b@x.com', type: 'antigravity' });
|
||||
|
||||
const { registerAccount, saveDrainOrderConfig } = await loadRegistry();
|
||||
registerAccount('agy', 'antigravity-a.json', 'a@x.com');
|
||||
registerAccount('agy', 'antigravity-b.json', 'b@x.com');
|
||||
saveDrainOrderConfig('agy', { mode: 'manual', orderedIds: ['a@x.com', 'b@x.com'] });
|
||||
|
||||
// Apply the computed config to disk first, then resolve: no drift.
|
||||
const { computeManualDrainOrder, applyDrainOrder, resolveEffectiveDrainOrder } =
|
||||
await loadDrainOrder();
|
||||
const entries = computeManualDrainOrder(
|
||||
['a@x.com', 'b@x.com'],
|
||||
[
|
||||
{ accountId: 'a@x.com', tokenFile: 'antigravity-a.json' },
|
||||
{ accountId: 'b@x.com', tokenFile: 'antigravity-b.json' },
|
||||
]
|
||||
);
|
||||
await applyDrainOrder(entries, false);
|
||||
|
||||
const result = resolveEffectiveDrainOrder('agy', [
|
||||
{ accountId: 'a@x.com', tokenFile: 'antigravity-a.json' },
|
||||
{ accountId: 'b@x.com', tokenFile: 'antigravity-b.json' },
|
||||
]);
|
||||
expect(result.mode).toBe('manual');
|
||||
expect(result.entries.map((e: { accountId: string }) => e.accountId)).toEqual([
|
||||
'a@x.com',
|
||||
'b@x.com',
|
||||
]);
|
||||
expect(result.hasDrift).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// clearAuthFilePriorityDirect + clearDrainOrderPriorities
|
||||
// (the missing half of `order --reset`: actually strip residual priorities)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('clearAuthFilePriorityDirect', () => {
|
||||
it('removes the priority field and preserves all other fields', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
writeAuthFile(authDir, 'a.json', { email: 'a@x.com', priority: 4, keep: 'me' });
|
||||
|
||||
const { clearAuthFilePriorityDirect } = await loadDrainOrder();
|
||||
const removed = clearAuthFilePriorityDirect('a.json');
|
||||
|
||||
expect(removed).toBe(true);
|
||||
const data = readAuthFile(authDir, 'a.json');
|
||||
expect('priority' in data).toBe(false);
|
||||
expect(data.keep).toBe('me');
|
||||
expect(data.email).toBe('a@x.com');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns false (no-op) when the file has no priority field', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
writeAuthFile(authDir, 'a.json', { email: 'a@x.com' });
|
||||
|
||||
const { clearAuthFilePriorityDirect } = await loadDrainOrder();
|
||||
expect(clearAuthFilePriorityDirect('a.json')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when the file does not exist', async () => {
|
||||
await withIsolatedHome(async () => {
|
||||
const { clearAuthFilePriorityDirect } = await loadDrainOrder();
|
||||
expect(() => clearAuthFilePriorityDirect('ghost.json')).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearDrainOrderPriorities - direct path (proxy stopped)', () => {
|
||||
it('clears residual priorities from all files via direct write', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
writeAuthFile(authDir, 'a.json', { email: 'a@x.com', priority: 3 });
|
||||
writeAuthFile(authDir, 'b.json', { email: 'b@x.com' }); // already clear
|
||||
|
||||
const { clearDrainOrderPriorities } = await loadDrainOrder();
|
||||
const result = await clearDrainOrderPriorities(['a.json', 'b.json'], false);
|
||||
|
||||
expect(result.usedManagementApi).toBe(false);
|
||||
expect(result.cleared).toEqual(['a.json']);
|
||||
expect(result.alreadyClear).toEqual(['b.json']);
|
||||
expect(result.failed).toHaveLength(0);
|
||||
expect('priority' in readAuthFile(authDir, 'a.json')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('records a failure for a missing file', async () => {
|
||||
await withIsolatedHome(async () => {
|
||||
const { clearDrainOrderPriorities } = await loadDrainOrder();
|
||||
const result = await clearDrainOrderPriorities(['ghost.json'], false);
|
||||
expect(result.failed).toHaveLength(1);
|
||||
expect(result.cleared).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearDrainOrderPriorities - management API path (proxy running)', () => {
|
||||
it('PATCHes priority:0 (delete) for files with a residual priority and skips clear ones', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
writeAuthFile(authDir, 'a.json', { email: 'a@x.com', priority: 3 });
|
||||
writeAuthFile(authDir, 'b.json', { email: 'b@x.com' }); // already clear -> no API call
|
||||
|
||||
const calls: Array<{ url: string; body: unknown }> = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url =
|
||||
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
calls.push({ url, body: JSON.parse(String(init?.body ?? '{}')) });
|
||||
return new Response('{}', { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const { clearDrainOrderPriorities } = await loadDrainOrder();
|
||||
const result = await clearDrainOrderPriorities(['a.json', 'b.json'], true);
|
||||
|
||||
expect(result.usedManagementApi).toBe(true);
|
||||
expect(result.cleared).toEqual(['a.json']);
|
||||
expect(result.alreadyClear).toEqual(['b.json']);
|
||||
expect(result.failed).toHaveLength(0);
|
||||
|
||||
// Only a.json (which had a residual priority) triggered an API call,
|
||||
// and it patched priority:0 (the delete sentinel).
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].url).toContain('/v0/management/auth-files/fields');
|
||||
expect(calls[0].body).toEqual({ name: 'a.json', priority: 0 });
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('records a failure when the PATCH returns a non-ok status', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
writeAuthFile(authDir, 'a.json', { email: 'a@x.com', priority: 3 });
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () => new Response('nope', { status: 500 })) as typeof fetch;
|
||||
|
||||
try {
|
||||
const { clearDrainOrderPriorities } = await loadDrainOrder();
|
||||
const result = await clearDrainOrderPriorities(['a.json'], true);
|
||||
expect(result.cleared).toHaveLength(0);
|
||||
expect(result.failed).toHaveLength(1);
|
||||
expect(result.failed[0].tokenFile).toBe('a.json');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fetchProxyAuthCooldowns - live in-proxy 429 cooldowns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('fetchProxyAuthCooldowns', () => {
|
||||
it('classifies unavailable entries as cooldowns and parses next_retry_after', async () => {
|
||||
await withIsolatedHome(async () => {
|
||||
const next = '2026-06-11T12:00:00.000Z';
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
files: [
|
||||
{ name: 'cooling.json', unavailable: true, next_retry_after: next },
|
||||
{ name: 'no-retry.json', unavailable: true },
|
||||
{ name: 'healthy.json', unavailable: false },
|
||||
],
|
||||
}),
|
||||
{ status: 200 }
|
||||
)) as typeof fetch;
|
||||
|
||||
try {
|
||||
const { fetchProxyAuthCooldowns } = await loadDrainOrder();
|
||||
const cooldowns = await fetchProxyAuthCooldowns();
|
||||
|
||||
// Only the two unavailable entries are returned; healthy.json is excluded.
|
||||
expect(cooldowns.map((c: { tokenFile: string }) => c.tokenFile).sort()).toEqual([
|
||||
'cooling.json',
|
||||
'no-retry.json',
|
||||
]);
|
||||
const cooling = cooldowns.find(
|
||||
(c: { tokenFile: string }) => c.tokenFile === 'cooling.json'
|
||||
);
|
||||
expect(cooling?.cooldownUntil).toBe(Date.parse(next));
|
||||
const noRetry = cooldowns.find(
|
||||
(c: { tokenFile: string }) => c.tokenFile === 'no-retry.json'
|
||||
);
|
||||
expect(noRetry?.cooldownUntil).toBeUndefined();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('degrades silently to [] on a non-ok response', async () => {
|
||||
await withIsolatedHome(async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () => new Response('err', { status: 404 })) as typeof fetch;
|
||||
try {
|
||||
const { fetchProxyAuthCooldowns } = await loadDrainOrder();
|
||||
expect(await fetchProxyAuthCooldowns()).toEqual([]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('degrades silently to [] on a network error', async () => {
|
||||
await withIsolatedHome(async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () => {
|
||||
throw new TypeError('network down');
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const { fetchProxyAuthCooldowns } = await loadDrainOrder();
|
||||
expect(await fetchProxyAuthCooldowns()).toEqual([]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('degrades silently to [] on an unexpected response shape', async () => {
|
||||
await withIsolatedHome(async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ unexpected: 'shape' }), { status: 200 })) as typeof fetch;
|
||||
try {
|
||||
const { fetchProxyAuthCooldowns } = await loadDrainOrder();
|
||||
expect(await fetchProxyAuthCooldowns()).toEqual([]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* Tests for the pool state resolver: per-account state classification
|
||||
* (available / cooling / paused), drain order modes, and the cooling-vs-paused
|
||||
* distinction driven by the persisted quota-cooldown store.
|
||||
*/
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { runWithScopedCcsHome } from '../../../utils/config-manager';
|
||||
import type { AccountInfo } from '../types';
|
||||
import type { PoolRoutingSettings } from '../pool-state';
|
||||
|
||||
const SETTINGS_OFF: PoolRoutingSettings = {
|
||||
poolEnabled: false,
|
||||
strategy: 'round-robin',
|
||||
sessionAffinityEnabled: false,
|
||||
sessionAffinityTtl: '1h',
|
||||
};
|
||||
|
||||
function account(partial: Partial<AccountInfo> & { id: string }): AccountInfo {
|
||||
return {
|
||||
provider: 'claude',
|
||||
isDefault: false,
|
||||
tokenFile: `${partial.id}.json`,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
...partial,
|
||||
} as AccountInfo;
|
||||
}
|
||||
|
||||
async function withIsolatedHome<T>(fn: (homeDir: string) => Promise<T> | T): Promise<T> {
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-pool-state-'));
|
||||
try {
|
||||
return await runWithScopedCcsHome(homeDir, () => fn(homeDir));
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const SETTINGS_ON: PoolRoutingSettings = {
|
||||
poolEnabled: true,
|
||||
strategy: 'fill-first',
|
||||
sessionAffinityEnabled: true,
|
||||
sessionAffinityTtl: '1h',
|
||||
};
|
||||
|
||||
async function loadPoolState() {
|
||||
return import(`../pool-state?pool-state=${Date.now()}`);
|
||||
}
|
||||
|
||||
describe('resolvePoolState - account state classification', () => {
|
||||
it('labels an unpaused, non-cooling account as available', async () => {
|
||||
await withIsolatedHome(async () => {
|
||||
const { resolvePoolState } = await loadPoolState();
|
||||
const pool = resolvePoolState({
|
||||
provider: 'claude',
|
||||
settings: SETTINGS_OFF,
|
||||
accounts: [account({ id: 'a@x.com' })],
|
||||
now: 1_000_000,
|
||||
});
|
||||
expect(pool.states[0].state).toBe('available');
|
||||
});
|
||||
});
|
||||
|
||||
it('labels a manually paused account (no cooldown record) as paused', async () => {
|
||||
await withIsolatedHome(async () => {
|
||||
const { resolvePoolState } = await loadPoolState();
|
||||
const pool = resolvePoolState({
|
||||
provider: 'claude',
|
||||
settings: SETTINGS_OFF,
|
||||
accounts: [account({ id: 'a@x.com', paused: true, pausedAt: '2026-06-01T00:00:00.000Z' })],
|
||||
now: 1_000_000,
|
||||
});
|
||||
expect(pool.states[0].state).toBe('paused');
|
||||
expect(pool.states[0].pausedAt).toBe('2026-06-01T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
it('labels a paused account with a matching quota-cooldown record as cooling', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const pausedAt = '2026-06-10T12:00:00.000Z';
|
||||
const until = 2_000_000;
|
||||
const quotaPausedPath = path.join(homeDir, '.ccs', 'cliproxy', 'quota-paused.json');
|
||||
fs.mkdirSync(path.dirname(quotaPausedPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
quotaPausedPath,
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
provider: 'claude',
|
||||
accountId: 'a@x.com',
|
||||
pausedAt,
|
||||
until,
|
||||
reason: 'quota_exhausted',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const { resolvePoolState } = await loadPoolState();
|
||||
const pool = resolvePoolState({
|
||||
provider: 'claude',
|
||||
settings: SETTINGS_OFF,
|
||||
accounts: [account({ id: 'a@x.com', paused: true, pausedAt })],
|
||||
now: 1_000_000,
|
||||
});
|
||||
expect(pool.states[0].state).toBe('cooling');
|
||||
expect(pool.states[0].cooldownUntil).toBe(until);
|
||||
expect(pool.states[0].cooldownSource).toBe('persisted');
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a manual pause layered over a stale cooldown record as paused (pausedAt mismatch)', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const quotaPausedPath = path.join(homeDir, '.ccs', 'cliproxy', 'quota-paused.json');
|
||||
fs.mkdirSync(path.dirname(quotaPausedPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
quotaPausedPath,
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
provider: 'claude',
|
||||
accountId: 'a@x.com',
|
||||
pausedAt: '2026-06-10T12:00:00.000Z',
|
||||
until: 2_000_000,
|
||||
reason: 'quota_exhausted',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const { resolvePoolState } = await loadPoolState();
|
||||
const pool = resolvePoolState({
|
||||
provider: 'claude',
|
||||
settings: SETTINGS_OFF,
|
||||
// Different pausedAt -> not the same pause the cooldown record describes.
|
||||
accounts: [account({ id: 'a@x.com', paused: true, pausedAt: '2026-06-11T09:00:00.000Z' })],
|
||||
now: 1_000_000,
|
||||
});
|
||||
expect(pool.states[0].state).toBe('paused');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not treat an expired persisted cooldown as cooling', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const quotaPausedPath = path.join(homeDir, '.ccs', 'cliproxy', 'quota-paused.json');
|
||||
fs.mkdirSync(path.dirname(quotaPausedPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
quotaPausedPath,
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
provider: 'claude',
|
||||
accountId: 'a@x.com',
|
||||
pausedAt: '2026-06-10T12:00:00.000Z',
|
||||
until: 500_000, // already past relative to now below
|
||||
reason: 'quota_exhausted',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const { resolvePoolState } = await loadPoolState();
|
||||
const pool = resolvePoolState({
|
||||
provider: 'claude',
|
||||
settings: SETTINGS_OFF,
|
||||
accounts: [account({ id: 'a@x.com' })],
|
||||
now: 1_000_000,
|
||||
});
|
||||
expect(pool.states[0].state).toBe('available');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePoolState - drain order', () => {
|
||||
it('uses stable file order when no drain config is stored', async () => {
|
||||
await withIsolatedHome(async () => {
|
||||
const { resolvePoolState } = await loadPoolState();
|
||||
const pool = resolvePoolState({
|
||||
provider: 'claude',
|
||||
settings: SETTINGS_OFF,
|
||||
accounts: [
|
||||
account({ id: 'b@x.com', tokenFile: 'claude-b.json' }),
|
||||
account({ id: 'a@x.com', tokenFile: 'claude-a.json' }),
|
||||
],
|
||||
now: 1_000_000,
|
||||
});
|
||||
expect(pool.drainOrder.mode).toBe('file');
|
||||
// Token-file byte order: claude-a.json before claude-b.json.
|
||||
expect(pool.drainOrder.order).toEqual(['a@x.com', 'b@x.com']);
|
||||
});
|
||||
});
|
||||
|
||||
it('excludes paused accounts from the drain order but keeps them in states', async () => {
|
||||
await withIsolatedHome(async () => {
|
||||
const { resolvePoolState } = await loadPoolState();
|
||||
const pool = resolvePoolState({
|
||||
provider: 'claude',
|
||||
settings: SETTINGS_OFF,
|
||||
accounts: [
|
||||
account({ id: 'a@x.com', tokenFile: 'claude-a.json' }),
|
||||
account({
|
||||
id: 'b@x.com',
|
||||
tokenFile: 'claude-b.json',
|
||||
paused: true,
|
||||
pausedAt: '2026-06-01T00:00:00.000Z',
|
||||
}),
|
||||
],
|
||||
now: 1_000_000,
|
||||
});
|
||||
expect(pool.drainOrder.order).toEqual(['a@x.com']);
|
||||
expect(pool.states.map((s) => s.accountId).sort()).toEqual(['a@x.com', 'b@x.com']);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports no drift in plain file-order mode', async () => {
|
||||
await withIsolatedHome(async () => {
|
||||
const { resolvePoolState } = await loadPoolState();
|
||||
const pool = resolvePoolState({
|
||||
provider: 'claude',
|
||||
settings: SETTINGS_OFF,
|
||||
accounts: [
|
||||
account({ id: 'a@x.com', tokenFile: 'claude-a.json' }),
|
||||
account({ id: 'b@x.com', tokenFile: 'claude-b.json' }),
|
||||
],
|
||||
now: 1_000_000,
|
||||
});
|
||||
expect(pool.drainOrder.hasDrift).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('follows on-disk priority and surfaces drift when the stored order diverges', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
fs.mkdirSync(authDir, { recursive: true, mode: 0o700 });
|
||||
// Auth files on disk with priorities that contradict the stored manual order:
|
||||
// config says a then b, but disk gives b the higher priority (re-auth/--reset trap).
|
||||
fs.writeFileSync(
|
||||
path.join(authDir, 'antigravity-a.json'),
|
||||
JSON.stringify({ type: 'antigravity', email: 'a@x.com', priority: 1 })
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(authDir, 'antigravity-b.json'),
|
||||
JSON.stringify({ type: 'antigravity', email: 'b@x.com', priority: 5 })
|
||||
);
|
||||
|
||||
const { registerAccount, saveDrainOrderConfig } = await import(
|
||||
`../registry?pool-state-drift=${Date.now()}`
|
||||
);
|
||||
registerAccount('agy', 'antigravity-a.json', 'a@x.com');
|
||||
registerAccount('agy', 'antigravity-b.json', 'b@x.com');
|
||||
saveDrainOrderConfig('agy', { mode: 'manual', orderedIds: ['a@x.com', 'b@x.com'] });
|
||||
|
||||
const { resolvePoolState } = await loadPoolState();
|
||||
const pool = resolvePoolState({
|
||||
provider: 'agy',
|
||||
settings: SETTINGS_OFF,
|
||||
accounts: [
|
||||
account({ provider: 'agy', id: 'a@x.com', tokenFile: 'antigravity-a.json' }),
|
||||
account({ provider: 'agy', id: 'b@x.com', tokenFile: 'antigravity-b.json' }),
|
||||
],
|
||||
now: 1_000_000,
|
||||
});
|
||||
// Selector follows the on-disk priority: b (5) before a (1).
|
||||
expect(pool.drainOrder.order).toEqual(['b@x.com', 'a@x.com']);
|
||||
expect(pool.drainOrder.hasDrift).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePoolState - in-proxy cooldown classification', () => {
|
||||
it('classifies an injected proxy cooldown as cooling with source "proxy"', async () => {
|
||||
await withIsolatedHome(async () => {
|
||||
const { resolvePoolState } = await loadPoolState();
|
||||
const until = 2_000_000;
|
||||
const pool = resolvePoolState({
|
||||
provider: 'claude',
|
||||
settings: SETTINGS_ON,
|
||||
accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })],
|
||||
proxyCooldowns: [{ tokenFile: 'claude-a.json', cooldownUntil: until }],
|
||||
now: 1_000_000,
|
||||
});
|
||||
expect(pool.states[0].state).toBe('cooling');
|
||||
expect(pool.states[0].cooldownUntil).toBe(until);
|
||||
expect(pool.states[0].cooldownSource).toBe('proxy');
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a proxy cooldown with no next_retry_after as cooling (no reset time)', async () => {
|
||||
await withIsolatedHome(async () => {
|
||||
const { resolvePoolState } = await loadPoolState();
|
||||
const pool = resolvePoolState({
|
||||
provider: 'claude',
|
||||
settings: SETTINGS_ON,
|
||||
accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })],
|
||||
proxyCooldowns: [{ tokenFile: 'claude-a.json' }],
|
||||
now: 1_000_000,
|
||||
});
|
||||
expect(pool.states[0].state).toBe('cooling');
|
||||
expect(pool.states[0].cooldownUntil).toBeUndefined();
|
||||
expect(pool.states[0].cooldownSource).toBe('proxy');
|
||||
});
|
||||
});
|
||||
|
||||
it('proxy cooldown wins over a persisted quota cooldown for the same account', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const quotaPausedPath = path.join(homeDir, '.ccs', 'cliproxy', 'quota-paused.json');
|
||||
fs.mkdirSync(path.dirname(quotaPausedPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
quotaPausedPath,
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
provider: 'claude',
|
||||
accountId: 'a@x.com',
|
||||
pausedAt: '2026-06-10T12:00:00.000Z',
|
||||
until: 1_500_000,
|
||||
reason: 'quota_exhausted',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const { resolvePoolState } = await loadPoolState();
|
||||
const pool = resolvePoolState({
|
||||
provider: 'claude',
|
||||
settings: SETTINGS_ON,
|
||||
accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })],
|
||||
proxyCooldowns: [{ tokenFile: 'claude-a.json', cooldownUntil: 9_000_000 }],
|
||||
now: 1_000_000,
|
||||
});
|
||||
// Proxy (live routing truth) wins: source 'proxy', its reset time, not the
|
||||
// persisted quota-paused.json one.
|
||||
expect(pool.states[0].cooldownSource).toBe('proxy');
|
||||
expect(pool.states[0].cooldownUntil).toBe(9_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves accounts available when no proxy cooldown is provided (graceful degradation)', async () => {
|
||||
await withIsolatedHome(async () => {
|
||||
const { resolvePoolState, hasProxySourcedCooling } = await loadPoolState();
|
||||
const pool = resolvePoolState({
|
||||
provider: 'claude',
|
||||
settings: SETTINGS_ON,
|
||||
accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })],
|
||||
// proxyCooldowns omitted -> CCS-side view only.
|
||||
now: 1_000_000,
|
||||
});
|
||||
expect(pool.states[0].state).toBe('available');
|
||||
expect(hasProxySourcedCooling(pool)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('hasProxySourcedCooling reports true only when a proxy-sourced cooling exists', async () => {
|
||||
await withIsolatedHome(async () => {
|
||||
const { resolvePoolState, hasProxySourcedCooling } = await loadPoolState();
|
||||
const pool = resolvePoolState({
|
||||
provider: 'claude',
|
||||
settings: SETTINGS_ON,
|
||||
accounts: [
|
||||
account({ id: 'a@x.com', tokenFile: 'claude-a.json' }),
|
||||
account({ id: 'b@x.com', tokenFile: 'claude-b.json' }),
|
||||
],
|
||||
proxyCooldowns: [{ tokenFile: 'claude-b.json', cooldownUntil: 2_000_000 }],
|
||||
now: 1_000_000,
|
||||
});
|
||||
expect(hasProxySourcedCooling(pool)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePoolStateWithProxyCooldowns - fetch + degradation', () => {
|
||||
it('skips the proxy fetch and stays available when pool routing is OFF', async () => {
|
||||
await withIsolatedHome(async () => {
|
||||
const { resolvePoolStateWithProxyCooldowns } = await loadPoolState();
|
||||
let fetched = false;
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () => {
|
||||
fetched = true;
|
||||
return new Response('{}', { status: 200 });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const pool = await resolvePoolStateWithProxyCooldowns({
|
||||
provider: 'claude',
|
||||
settings: SETTINGS_OFF,
|
||||
accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })],
|
||||
now: 1_000_000,
|
||||
});
|
||||
expect(pool.states[0].state).toBe('available');
|
||||
// Pool OFF -> no management API call at all.
|
||||
expect(fetched).toBe(false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('honours injected proxyCooldowns without fetching, even when pool is ON', async () => {
|
||||
await withIsolatedHome(async () => {
|
||||
const { resolvePoolStateWithProxyCooldowns } = await loadPoolState();
|
||||
let fetched = false;
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () => {
|
||||
fetched = true;
|
||||
return new Response('{}', { status: 200 });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const pool = await resolvePoolStateWithProxyCooldowns({
|
||||
provider: 'claude',
|
||||
settings: SETTINGS_ON,
|
||||
accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })],
|
||||
proxyCooldowns: [{ tokenFile: 'claude-a.json', cooldownUntil: 2_000_000 }],
|
||||
now: 1_000_000,
|
||||
});
|
||||
expect(pool.states[0].cooldownSource).toBe('proxy');
|
||||
// Injected cooldowns short-circuit the fetch.
|
||||
expect(fetched).toBe(false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Cross-lane email overlap guard
|
||||
*
|
||||
* The documented ban vector: one Google/Anthropic account active in BOTH a
|
||||
* CLIProxy OAuth lane AND a native Claude Code profile lane simultaneously.
|
||||
* CLIProxy refreshes tokens server-side while the native profile may be logged
|
||||
* in via the same account, creating concurrent token usage patterns that
|
||||
* Google/Anthropic treat as suspicious.
|
||||
*
|
||||
* Scope of check: compare the newly registered CLIProxy account email against
|
||||
* the email of every native Claude Code lane the user has:
|
||||
* 1. The ambient ~/.claude login (via `claude auth status`).
|
||||
* 2. Each isolated CCS account profile lane (per-profile CLAUDE_CONFIG_DIR
|
||||
* instance dir). These are the exact multi-account population the guard
|
||||
* exists to protect, so they MUST be inspected, not just the default lane.
|
||||
*
|
||||
* Lane emails are read directly from each lane's `.claude.json`
|
||||
* (`oauthAccount.emailAddress`) — cheap file reads, no CLI spawn. The
|
||||
* profiles.json v3.0 schema removed the email field, so the per-lane config is
|
||||
* the source of truth for an account profile's logged-in email.
|
||||
*
|
||||
* This guard is advisory only: it warns on stderr but does not block the add.
|
||||
* The user may intentionally separate accounts; a false positive is less harmful
|
||||
* than silently allowing a true overlap. Missing or unreadable lane files are
|
||||
* silently skipped — a corrupt profile must never block or crash auth.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { warn } from '../../utils/ui';
|
||||
import { getClaudeAuthStatus } from '../../utils/claude-detector';
|
||||
import { maskEmail } from './account-safety';
|
||||
import InstanceManager from '../../management/instance-manager';
|
||||
import { ProfileRegistry } from '../../auth/profile-registry';
|
||||
import type { CLIProxyProvider } from '../types';
|
||||
|
||||
/** Providers where CLIProxy OAuth could create a cross-lane conflict with native Claude */
|
||||
const CROSS_LANE_RISK_PROVIDERS: CLIProxyProvider[] = ['claude', 'agy', 'gemini', 'codex'];
|
||||
|
||||
/** A native Claude lane that overlaps with the new CLIProxy account email. */
|
||||
interface LaneOverlap {
|
||||
/** The lane's masked email (for display). */
|
||||
maskedEmail: string;
|
||||
/** Human label for the lane, e.g. "native Claude Code login" or `profile "work"`. */
|
||||
laneLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the OAuth email stored in a native Claude lane's `.claude.json`.
|
||||
*
|
||||
* Returns the lowercased/trimmed email, or null when the file is missing,
|
||||
* unreadable, malformed, or has no logged-in account. Never throws — a corrupt
|
||||
* profile is silently skipped (the guard is advisory).
|
||||
*/
|
||||
function readLaneEmail(claudeConfigDir: string): string | null {
|
||||
try {
|
||||
const claudeJsonPath = path.join(claudeConfigDir, '.claude.json');
|
||||
const raw = fs.readFileSync(claudeJsonPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const oauthAccount = (parsed as Record<string, unknown>).oauthAccount;
|
||||
if (!oauthAccount || typeof oauthAccount !== 'object') return null;
|
||||
const email = (oauthAccount as Record<string, unknown>).emailAddress;
|
||||
if (typeof email !== 'string' || email.trim().length === 0) return null;
|
||||
return email.toLowerCase().trim();
|
||||
} catch {
|
||||
// Missing / unreadable / malformed lane config — skip silently.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate CCS account profile lanes and collect any whose stored OAuth email
|
||||
* matches the newly added CLIProxy account email.
|
||||
*
|
||||
* Pure file reads (one `.claude.json` per profile) — no CLI spawn. Best-effort:
|
||||
* any failure to enumerate or read a lane is swallowed so the guard can never
|
||||
* block or crash the add.
|
||||
*/
|
||||
function findAccountProfileOverlaps(normalizedEmail: string): LaneOverlap[] {
|
||||
const overlaps: LaneOverlap[] = [];
|
||||
try {
|
||||
const registry = new ProfileRegistry();
|
||||
const profiles = registry.getAllProfilesMerged();
|
||||
const accountNames = Object.entries(profiles)
|
||||
.filter(([, meta]) => meta.type === 'account')
|
||||
.map(([name]) => name);
|
||||
if (accountNames.length === 0) return overlaps;
|
||||
|
||||
const instanceMgr = new InstanceManager();
|
||||
for (const name of accountNames) {
|
||||
const instancePath = instanceMgr.getInstancePath(name);
|
||||
const laneEmail = readLaneEmail(instancePath);
|
||||
if (laneEmail && laneEmail === normalizedEmail) {
|
||||
overlaps.push({ maskedEmail: maskEmail(laneEmail), laneLabel: `profile "${name}"` });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Registry / instance-manager construction failed — skip lane enumeration.
|
||||
}
|
||||
return overlaps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the newly added CLIProxy account email matches the email of any
|
||||
* native Claude Code lane: the ambient ~/.claude login or any isolated CCS
|
||||
* account profile lane.
|
||||
*
|
||||
* Emits a warning to stderr for each overlapping lane. Silent on errors
|
||||
* (CLI not found, not logged in, unreadable profile, etc.) — the check is
|
||||
* best-effort and never blocks the add.
|
||||
*
|
||||
* @param provider - The CLIProxy provider being added
|
||||
* @param email - Email address of the account that was just registered
|
||||
*/
|
||||
export function checkCrossLaneEmailOverlap(provider: CLIProxyProvider, email: string): void {
|
||||
if (!CROSS_LANE_RISK_PROVIDERS.includes(provider)) return;
|
||||
|
||||
try {
|
||||
const normalized = email.toLowerCase().trim();
|
||||
if (normalized.length === 0) return;
|
||||
|
||||
const overlaps: LaneOverlap[] = [];
|
||||
|
||||
// 1. Ambient ~/.claude login (default lane).
|
||||
const status = getClaudeAuthStatus();
|
||||
if (status?.loggedIn && status.email) {
|
||||
const ambientNormalized = status.email.toLowerCase().trim();
|
||||
if (ambientNormalized === normalized) {
|
||||
overlaps.push({
|
||||
maskedEmail: maskEmail(status.email),
|
||||
laneLabel: 'native Claude Code login',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Isolated CCS account profile lanes (per-profile CLAUDE_CONFIG_DIR).
|
||||
overlaps.push(...findAccountProfileOverlaps(normalized));
|
||||
|
||||
if (overlaps.length === 0) return;
|
||||
|
||||
const masked = maskEmail(email);
|
||||
|
||||
console.error('');
|
||||
console.error(warn(`Account safety: cross-lane email overlap detected for ${provider}`));
|
||||
console.error(` CLIProxy account: ${masked} (${provider})`);
|
||||
for (const overlap of overlaps) {
|
||||
console.error(` Native Claude Code lane: ${overlap.maskedEmail} (${overlap.laneLabel})`);
|
||||
}
|
||||
console.error(
|
||||
' Same account active in both CLIProxy and native Claude lanes is a known ban risk.'
|
||||
);
|
||||
console.error(
|
||||
' CLIProxy refreshes tokens server-side; native Claude may do the same concurrently.'
|
||||
);
|
||||
console.error(' If you want to keep access, use separate accounts for each lane.');
|
||||
console.error(
|
||||
' CCS is provided as-is and cannot take responsibility for access-loss decisions.'
|
||||
);
|
||||
console.error('');
|
||||
} catch {
|
||||
// Silent: CLI not installed, spawn failed, JSON parse error, etc.
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,37 @@ function loadQuotaPaused(): QuotaPausedFile {
|
||||
return { entries: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only view of a persisted quota-cooldown pause.
|
||||
* Exposed for visibility surfaces (e.g. `ccs cliproxy quota` pool section)
|
||||
* that must distinguish a quota cooldown (with a reset time) from a manual pause.
|
||||
*/
|
||||
export interface QuotaCooldownEntry {
|
||||
provider: CLIProxyProvider;
|
||||
accountId: string;
|
||||
/** ISO timestamp when the account was paused (matches AccountInfo.pausedAt) */
|
||||
pausedAt: string;
|
||||
/** Epoch ms when the cooldown is eligible to be lifted */
|
||||
until: number;
|
||||
reason: 'quota_exhausted';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the persisted quota-cooldown pauses recorded on disk.
|
||||
*
|
||||
* This is the cross-process source of truth for quota cooldowns: the in-memory
|
||||
* cooldown map in quota-manager is process-local, but quota-paused.json is
|
||||
* written by the long-lived proxy/monitor process and read by short-lived CLI
|
||||
* invocations. Callers use it to label an account as cooling (vs manually
|
||||
* paused) and to show the reset time.
|
||||
*
|
||||
* Entries are returned as-is (including expired ones); callers decide whether to
|
||||
* treat `until <= now` as already cooled down.
|
||||
*/
|
||||
export function readQuotaCooldownEntries(): QuotaCooldownEntry[] {
|
||||
return loadQuotaPaused().entries.map((entry) => ({ ...entry }));
|
||||
}
|
||||
|
||||
function saveQuotaPaused(data: QuotaPausedFile): void {
|
||||
const filePath = getQuotaPausedPath();
|
||||
if (data.entries.length === 0) {
|
||||
@@ -568,8 +599,9 @@ export function restoreAutoPausedAccounts(provider: CLIProxyProvider): void {
|
||||
saveAutoPaused(data);
|
||||
}
|
||||
|
||||
// Error patterns that indicate Google has disabled/banned an account
|
||||
const BAN_PATTERNS = [
|
||||
// Error patterns that indicate a provider has disabled/banned an account.
|
||||
// Shared patterns apply to all providers (Google and Anthropic OAuth flows).
|
||||
const SHARED_BAN_PATTERNS = [
|
||||
'disabled in this account',
|
||||
'violation of terms of service',
|
||||
'account has been disabled',
|
||||
@@ -578,12 +610,28 @@ const BAN_PATTERNS = [
|
||||
'account has been banned',
|
||||
];
|
||||
|
||||
// Anthropic-specific disable patterns. Only applied when provider === 'claude'
|
||||
// to avoid false-positive auto-pause on Google/Codex errors that may reference
|
||||
// "policy" in rate-limit or scope messages.
|
||||
const ANTHROPIC_BAN_PATTERNS = ['your account has been blocked', 'account is blocked'];
|
||||
|
||||
/**
|
||||
* Check if an error message indicates an account ban/disable.
|
||||
* Pass the provider so Anthropic-only patterns cannot trip Google providers.
|
||||
*/
|
||||
export function isBanResponse(errorMessage: string): boolean {
|
||||
export function isBanResponse(errorMessage: string, provider?: CLIProxyProvider): boolean {
|
||||
const lower = errorMessage.toLowerCase();
|
||||
return BAN_PATTERNS.some((pattern) => lower.includes(pattern));
|
||||
if (SHARED_BAN_PATTERNS.some((pattern) => lower.includes(pattern))) return true;
|
||||
if (provider === 'claude' && ANTHROPIC_BAN_PATTERNS.some((pattern) => lower.includes(pattern))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Return the actor name (Google, Anthropic, etc.) for ban copy. */
|
||||
function banActor(provider: CLIProxyProvider): string {
|
||||
if (provider === 'claude') return 'Anthropic';
|
||||
return 'Google';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -595,10 +643,11 @@ export function handleBanDetection(
|
||||
accountId: string,
|
||||
errorMessage: string
|
||||
): boolean {
|
||||
if (!isBanResponse(errorMessage)) return false;
|
||||
if (!isBanResponse(errorMessage, provider)) return false;
|
||||
|
||||
const actor = banActor(provider);
|
||||
console.error('');
|
||||
console.error(warn('Account safety: account appears disabled by Google'));
|
||||
console.error(warn(`Account safety: account appears disabled by ${actor}`));
|
||||
console.error(` Account "${maskEmail(accountId)}" (${provider}) returned:`);
|
||||
console.error(` "${truncate(errorMessage, 120)}"`);
|
||||
console.error('');
|
||||
|
||||
@@ -0,0 +1,714 @@
|
||||
/**
|
||||
* Drain Order Management
|
||||
*
|
||||
* Manages CLIProxy account drain order via top-level "priority" field in auth JSONs.
|
||||
*
|
||||
* Architecture:
|
||||
* - Priority >= 1 always (management layer treats 0 as delete-the-attribute)
|
||||
* - Write path is dual: management API when proxy running, direct file when stopped
|
||||
* - Tier-aware defaults only where AccountTier metadata exists (agy/gemini)
|
||||
* - Claude pools: tier is unknown -> stable file order + manual --set required
|
||||
* - Selector drain order: priority bucket desc, then Auth.ID asc
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getAuthDir } from '../config/config-generator';
|
||||
import {
|
||||
getProxyTarget,
|
||||
buildProxyUrl,
|
||||
buildManagementHeaders,
|
||||
} from '../proxy/proxy-target-resolver';
|
||||
import { loadDrainOrderConfig } from './registry';
|
||||
import type { AccountTier } from './types';
|
||||
import type { CLIProxyProvider } from '../types';
|
||||
|
||||
/** Minimum valid priority value. Management layer treats 0 as delete. */
|
||||
export const MIN_PRIORITY = 1;
|
||||
|
||||
/** Management API path for patching auth file fields */
|
||||
const AUTH_FILES_FIELDS_PATH = '/v0/management/auth-files/fields';
|
||||
|
||||
/** Management API path for listing auth files (and their runtime status). */
|
||||
const AUTH_FILES_PATH = '/v0/management/auth-files';
|
||||
|
||||
/** Timeout for management API calls in ms */
|
||||
const MGMT_TIMEOUT_MS = 5000;
|
||||
|
||||
/**
|
||||
* One in-proxy cooldown reading, keyed by auth file basename.
|
||||
*
|
||||
* Source of truth: GET /v0/management/auth-files. Verified field names from
|
||||
* CLIProxyAPIPlus internal/api/handlers/management/auth_files.go buildAuthFileEntry:
|
||||
* - "name" -> the token file basename
|
||||
* - "unavailable" -> bool, set when a credential is cooling after a 429
|
||||
* - "next_retry_after" -> RFC3339 timestamp, present only when non-zero
|
||||
* CLIProxyAPI (the base upstream) exposes the same fields via its own
|
||||
* auth-files handler (sdk/cliproxy/auth/types.go: Unavailable / NextRetryAfter).
|
||||
*/
|
||||
export interface ProxyAuthCooldown {
|
||||
/** Token file basename (matches AccountInfo.tokenFile). */
|
||||
tokenFile: string;
|
||||
/** Epoch ms when the in-proxy cooldown is eligible to lift, if known. */
|
||||
cooldownUntil?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tier rank for priority derivation.
|
||||
* Higher rank = higher priority = drained first.
|
||||
* ultra > pro > free, unknown = no rank (returns undefined).
|
||||
*/
|
||||
const TIER_RANK: Record<Exclude<AccountTier, 'unknown'>, number> = {
|
||||
ultra: 3,
|
||||
pro: 2,
|
||||
free: 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute 1-based priority from a 0-based rank among N accounts.
|
||||
* Rank 0 = highest priority = assigned priority N.
|
||||
* Rank N-1 = lowest priority = assigned priority 1.
|
||||
*
|
||||
* Never returns 0 (management layer treats 0 as delete).
|
||||
*/
|
||||
export function rankToPriority(rank: number, total: number): number {
|
||||
const raw = total - rank;
|
||||
return Math.max(MIN_PRIORITY, raw);
|
||||
}
|
||||
|
||||
/** Tier rank for a known tier, undefined for unknown. */
|
||||
export function tierRank(tier: AccountTier | undefined): number | undefined {
|
||||
if (!tier || tier === 'unknown') return undefined;
|
||||
return TIER_RANK[tier];
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of drain order computation for a single account.
|
||||
*/
|
||||
export interface DrainOrderEntry {
|
||||
/** Account ID */
|
||||
accountId: string;
|
||||
/** Token file name (without directory) */
|
||||
tokenFile: string;
|
||||
/** Computed priority (>= 1, higher = drained earlier) */
|
||||
priority: number;
|
||||
/** Tier if known */
|
||||
tier?: AccountTier;
|
||||
/** Whether tier was used to derive this priority */
|
||||
tierDerived: boolean;
|
||||
/** Current priority read from auth file (undefined = not yet set) */
|
||||
currentPriority: number | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input record for priority computation.
|
||||
*/
|
||||
export interface DrainOrderInput {
|
||||
accountId: string;
|
||||
tokenFile: string;
|
||||
tier?: AccountTier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute drain order priorities for a list of accounts using manual ordering.
|
||||
*
|
||||
* @param orderedIds Account IDs in desired drain order (first = highest priority)
|
||||
* @param allAccounts All accounts for the provider (some may not be in orderedIds)
|
||||
* @returns DrainOrderEntry array ordered by resulting priority desc
|
||||
*/
|
||||
export function computeManualDrainOrder(
|
||||
orderedIds: string[],
|
||||
allAccounts: DrainOrderInput[]
|
||||
): DrainOrderEntry[] {
|
||||
const accountMap = new Map(allAccounts.map((a) => [a.accountId, a]));
|
||||
const result: DrainOrderEntry[] = [];
|
||||
|
||||
// Reject duplicate IDs: a repeated account in --set is ambiguous and would
|
||||
// otherwise be assigned two different priorities.
|
||||
const seen = new Set<string>();
|
||||
for (const id of orderedIds) {
|
||||
if (seen.has(id)) {
|
||||
throw new Error(`Duplicate account ID in order: ${id}`);
|
||||
}
|
||||
seen.add(id);
|
||||
}
|
||||
|
||||
// Validate that all specified IDs exist
|
||||
for (const id of orderedIds) {
|
||||
if (!accountMap.has(id)) {
|
||||
throw new Error(`Account ID not found: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
const specifiedSet = new Set(orderedIds);
|
||||
// Use total + 1 so rank (N-1) maps to priority 2, keeping every specified
|
||||
// account strictly above the MIN_PRIORITY floor assigned to unspecified ones.
|
||||
const total = orderedIds.length + 1;
|
||||
|
||||
// Assign priorities to specified accounts (first = highest priority)
|
||||
for (let rank = 0; rank < orderedIds.length; rank++) {
|
||||
const id = orderedIds[rank];
|
||||
const account = accountMap.get(id);
|
||||
if (!account) continue;
|
||||
|
||||
result.push({
|
||||
accountId: id,
|
||||
tokenFile: account.tokenFile,
|
||||
priority: rankToPriority(rank, total),
|
||||
tier: account.tier,
|
||||
tierDerived: false,
|
||||
currentPriority: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Remaining accounts not in the specified list get priority 1 (lowest)
|
||||
for (const account of allAccounts) {
|
||||
if (!specifiedSet.has(account.accountId)) {
|
||||
result.push({
|
||||
accountId: account.accountId,
|
||||
tokenFile: account.tokenFile,
|
||||
priority: MIN_PRIORITY,
|
||||
tier: account.tier,
|
||||
tierDerived: false,
|
||||
currentPriority: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute drain order priorities for accounts using tier metadata.
|
||||
* Only valid where tier metadata exists (agy/gemini providers).
|
||||
*
|
||||
* Accounts with unknown tier are sorted last (priority MIN_PRIORITY).
|
||||
* Accounts within the same tier bucket are given equal priorities.
|
||||
*
|
||||
* @param accounts Accounts with optional tier metadata
|
||||
* @returns DrainOrderEntry array
|
||||
*/
|
||||
export function computeTierDrainOrder(accounts: DrainOrderInput[]): DrainOrderEntry[] {
|
||||
// Group by tier rank
|
||||
const withRank: Array<{ input: DrainOrderInput; rank: number }> = accounts.map((a) => ({
|
||||
input: a,
|
||||
rank: tierRank(a.tier) ?? 0,
|
||||
}));
|
||||
|
||||
// Get unique ranks descending
|
||||
const uniqueRanks = [...new Set(withRank.map((x) => x.rank))].sort((a, b) => b - a);
|
||||
const numRanks = uniqueRanks.length;
|
||||
|
||||
// Assign priority buckets: highest rank -> highest priority (numRanks), lowest -> MIN_PRIORITY
|
||||
const rankToPriorityMap = new Map<number, number>();
|
||||
uniqueRanks.forEach((rank, idx) => {
|
||||
// idx 0 = highest rank, gets highest priority
|
||||
const priority = Math.max(MIN_PRIORITY, numRanks - idx);
|
||||
rankToPriorityMap.set(rank, priority);
|
||||
});
|
||||
|
||||
return withRank.map(({ input, rank }) => ({
|
||||
accountId: input.accountId,
|
||||
tokenFile: input.tokenFile,
|
||||
priority: rankToPriorityMap.get(rank) ?? MIN_PRIORITY,
|
||||
tier: input.tier,
|
||||
tierDerived: rank > 0, // only true when tier contributed a real rank
|
||||
currentPriority: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current priority from an auth JSON file.
|
||||
* Returns undefined if file missing, unreadable, or has no priority field.
|
||||
*/
|
||||
export function readAuthFilePriority(tokenFile: string): number | undefined {
|
||||
try {
|
||||
const authDir = getAuthDir();
|
||||
const filePath = path.join(authDir, tokenFile);
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(content) as { priority?: unknown };
|
||||
if (typeof data.priority === 'number' && Number.isFinite(data.priority) && data.priority >= 1) {
|
||||
return data.priority;
|
||||
}
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write priority directly to an auth JSON file (proxy must be stopped).
|
||||
* Validates priority >= MIN_PRIORITY before writing.
|
||||
* Preserves all other fields; uses atomic temp-rename.
|
||||
*/
|
||||
export function writeAuthFilePriorityDirect(tokenFile: string, priority: number): void {
|
||||
if (priority < MIN_PRIORITY) {
|
||||
throw new Error(`Priority must be >= ${MIN_PRIORITY}, got ${priority}`);
|
||||
}
|
||||
|
||||
const authDir = getAuthDir();
|
||||
const filePath = path.join(authDir, tokenFile);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`Auth file not found: ${tokenFile}`);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(content) as Record<string, unknown>;
|
||||
|
||||
data.priority = priority;
|
||||
|
||||
const tempPath = `${filePath}.tmp.${process.pid}`;
|
||||
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
||||
fs.renameSync(tempPath, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write priority via CLIProxy management API (proxy must be running).
|
||||
* Uses PATCH /v0/management/auth-files/fields.
|
||||
*
|
||||
* @param tokenFile File name (basename, e.g. "antigravity-foo.json")
|
||||
* @param priority Priority value >= MIN_PRIORITY
|
||||
* @returns true on success, false on failure
|
||||
*/
|
||||
export async function writeAuthFilePriorityViaApi(
|
||||
tokenFile: string,
|
||||
priority: number
|
||||
): Promise<boolean> {
|
||||
if (priority < MIN_PRIORITY) {
|
||||
throw new Error(`Priority must be >= ${MIN_PRIORITY}, got ${priority}`);
|
||||
}
|
||||
|
||||
return patchAuthFilePriority(tokenFile, priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the priority attribute via CLIProxy management API (proxy running).
|
||||
* Sends PATCH /v0/management/auth-files/fields with priority:0, which the
|
||||
* management layer treats as "delete the attribute" (verified upstream:
|
||||
* CLIProxyAPIPlus syncAuthFilePriorityAttribute deletes the attribute when the
|
||||
* incoming priority is 0 or absent, which then persists the auth file without a
|
||||
* top-level priority field).
|
||||
*
|
||||
* Using the API path is mandatory while the proxy is running: a direct file
|
||||
* write would be clobbered by the proxy's whole-file MarkResult persist.
|
||||
*
|
||||
* @param tokenFile File name (basename, e.g. "antigravity-foo.json")
|
||||
* @returns true on success, false on failure
|
||||
*/
|
||||
export async function clearAuthFilePriorityViaApi(tokenFile: string): Promise<boolean> {
|
||||
return patchAuthFilePriority(tokenFile, 0);
|
||||
}
|
||||
|
||||
/** Shared PATCH-fields call for setting (>=1) or clearing (0) a file's priority. */
|
||||
async function patchAuthFilePriority(tokenFile: string, priority: number): Promise<boolean> {
|
||||
const target = getProxyTarget();
|
||||
const url = buildProxyUrl(target, AUTH_FILES_FIELDS_PATH);
|
||||
const headers = buildManagementHeaders(target, { 'Content-Type': 'application/json' });
|
||||
|
||||
const body = JSON.stringify({ name: tokenFile, priority });
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), MGMT_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the priority field directly from an auth JSON file (proxy stopped).
|
||||
* No-op (returns false) when the file has no priority field. Preserves all
|
||||
* other fields; uses the same atomic temp-rename as writeAuthFilePriorityDirect.
|
||||
*
|
||||
* @returns true if a priority field was present and removed, false otherwise
|
||||
*/
|
||||
export function clearAuthFilePriorityDirect(tokenFile: string): boolean {
|
||||
const authDir = getAuthDir();
|
||||
const filePath = path.join(authDir, tokenFile);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`Auth file not found: ${tokenFile}`);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(content) as Record<string, unknown>;
|
||||
|
||||
if (!('priority' in data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
delete data.priority;
|
||||
|
||||
const tempPath = `${filePath}.tmp.${process.pid}`;
|
||||
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
||||
fs.renameSync(tempPath, filePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Result of clearing residual priorities across a set of auth files. */
|
||||
export interface ClearDrainOrderPrioritiesResult {
|
||||
/** Token files where a priority field was present and removed. */
|
||||
cleared: string[];
|
||||
/** Token files that had no priority field (nothing to do). */
|
||||
alreadyClear: string[];
|
||||
/** Token files that failed, with the reason. */
|
||||
failed: Array<{ tokenFile: string; reason: string }>;
|
||||
/** Whether the management API path was used (proxy was running). */
|
||||
usedManagementApi: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear residual on-disk priority fields for the given auth files.
|
||||
*
|
||||
* Mirrors applyDrainOrder's dual write path:
|
||||
* - Proxy running: PATCH priority:0 via the management API (the only safe path;
|
||||
* a direct write is clobbered by the proxy's whole-file persist).
|
||||
* - Proxy stopped: delete the field directly with an atomic temp-rename.
|
||||
*
|
||||
* Idempotent: files with no priority field are reported as alreadyClear, not
|
||||
* failures. The running path always reports the file as cleared on a 200 (the
|
||||
* API treats a missing-attribute delete as a successful no-op), so this is safe
|
||||
* to run repeatedly.
|
||||
*
|
||||
* @param tokenFiles Auth file basenames to clear.
|
||||
* @param proxyRunning Whether the proxy is currently running.
|
||||
*/
|
||||
export async function clearDrainOrderPriorities(
|
||||
tokenFiles: string[],
|
||||
proxyRunning: boolean
|
||||
): Promise<ClearDrainOrderPrioritiesResult> {
|
||||
const result: ClearDrainOrderPrioritiesResult = {
|
||||
cleared: [],
|
||||
alreadyClear: [],
|
||||
failed: [],
|
||||
usedManagementApi: proxyRunning,
|
||||
};
|
||||
|
||||
for (const tokenFile of tokenFiles) {
|
||||
if (proxyRunning) {
|
||||
// Skip the API round-trip when the file already has no priority on disk.
|
||||
if (readAuthFilePriority(tokenFile) === undefined) {
|
||||
result.alreadyClear.push(tokenFile);
|
||||
continue;
|
||||
}
|
||||
const ok = await clearAuthFilePriorityViaApi(tokenFile);
|
||||
if (ok) {
|
||||
result.cleared.push(tokenFile);
|
||||
} else {
|
||||
result.failed.push({ tokenFile, reason: 'management API PATCH failed' });
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const removed = clearAuthFilePriorityDirect(tokenFile);
|
||||
if (removed) {
|
||||
result.cleared.push(tokenFile);
|
||||
} else {
|
||||
result.alreadyClear.push(tokenFile);
|
||||
}
|
||||
} catch (err) {
|
||||
result.failed.push({ tokenFile, reason: (err as Error).message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a drain order apply operation.
|
||||
*/
|
||||
export interface ApplyDrainOrderResult {
|
||||
/** Entries that were written successfully */
|
||||
written: DrainOrderEntry[];
|
||||
/** Entries that were skipped (priority unchanged) */
|
||||
skipped: DrainOrderEntry[];
|
||||
/** Entries that failed to write */
|
||||
failed: Array<{ entry: DrainOrderEntry; reason: string }>;
|
||||
/** Whether the proxy was running during the write (management API path) */
|
||||
usedManagementApi: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply drain order priorities to auth files.
|
||||
*
|
||||
* Write path selection:
|
||||
* - Proxy running (HTTP health check passes): use management API
|
||||
* - Proxy stopped: direct file write
|
||||
*
|
||||
* Idempotent: skips entries where priority already equals the target value.
|
||||
*
|
||||
* @param entries Drain order entries with target priorities
|
||||
* @param proxyRunning Whether the proxy is currently running
|
||||
* @returns Result summary
|
||||
*/
|
||||
export async function applyDrainOrder(
|
||||
entries: DrainOrderEntry[],
|
||||
proxyRunning: boolean
|
||||
): Promise<ApplyDrainOrderResult> {
|
||||
const result: ApplyDrainOrderResult = {
|
||||
written: [],
|
||||
skipped: [],
|
||||
failed: [],
|
||||
usedManagementApi: proxyRunning,
|
||||
};
|
||||
|
||||
for (const entry of entries) {
|
||||
// Idempotency: read current value and skip if already set
|
||||
const currentPriority = readAuthFilePriority(entry.tokenFile);
|
||||
if (currentPriority === entry.priority) {
|
||||
result.skipped.push({ ...entry, currentPriority });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (proxyRunning) {
|
||||
const ok = await writeAuthFilePriorityViaApi(entry.tokenFile, entry.priority);
|
||||
if (ok) {
|
||||
result.written.push({ ...entry, currentPriority });
|
||||
} else {
|
||||
result.failed.push({
|
||||
entry: { ...entry, currentPriority },
|
||||
reason: 'management API PATCH failed',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
writeAuthFilePriorityDirect(entry.tokenFile, entry.priority);
|
||||
result.written.push({ ...entry, currentPriority });
|
||||
} catch (err) {
|
||||
result.failed.push({
|
||||
entry: { ...entry, currentPriority },
|
||||
reason: (err as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read current drain order priorities for a set of accounts.
|
||||
* Annotates each DrainOrderEntry with its current priority from disk.
|
||||
*
|
||||
* @param entries Entries to annotate (modifies currentPriority in-place)
|
||||
*/
|
||||
export function annotateCurrentPriorities(entries: DrainOrderEntry[]): void {
|
||||
for (const entry of entries) {
|
||||
entry.currentPriority = readAuthFilePriority(entry.tokenFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tie-break key for a token file, matching the upstream Go selector.
|
||||
* The selector breaks priority ties by Auth.ID asc. Upstream (synthesizer
|
||||
* file.go:120-122) lowercases Auth.ID only on Windows; on other platforms it
|
||||
* compares by raw byte order. We mirror that platform-conditional behaviour so
|
||||
* the displayed order matches what CLIProxy actually drains.
|
||||
*
|
||||
* @param platform process.platform; defaults to the current platform. Exposed
|
||||
* for tests so non-Windows byte-order behaviour can be asserted deterministically.
|
||||
*/
|
||||
export function tieBreakKey(
|
||||
tokenFile: string,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): string {
|
||||
return platform === 'win32' ? tokenFile.toLowerCase() : tokenFile;
|
||||
}
|
||||
|
||||
/** How the effective drain order was derived. */
|
||||
export type EffectiveDrainOrderMode = 'manual' | 'tier' | 'file';
|
||||
|
||||
/**
|
||||
* Effective drain order as the selector actually sees it: computed from the
|
||||
* stored config mode, then sorted by the on-disk priority each auth file
|
||||
* carries (the selector's real input), not by the intended config priority.
|
||||
*/
|
||||
export interface EffectiveDrainOrder {
|
||||
/** How the stored config intended to derive order. */
|
||||
mode: EffectiveDrainOrderMode;
|
||||
/**
|
||||
* Entries in selector pick order (first = drained first), annotated with the
|
||||
* on-disk priority via annotateCurrentPriorities().
|
||||
*/
|
||||
entries: DrainOrderEntry[];
|
||||
/**
|
||||
* True when the computed config priority diverges from what is actually on
|
||||
* disk for any account. Drift happens because re-auth rewrites the auth JSON
|
||||
* and drops the priority attribute, and --reset leaves residual file
|
||||
* priorities; under drift the selector follows the file, not the config.
|
||||
*/
|
||||
hasDrift: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective drain order for a provider's active (non-paused)
|
||||
* accounts, mirroring what `ccs cliproxy accounts order` shows and what the
|
||||
* selector actually drains.
|
||||
*
|
||||
* Semantics (shared by the order subcommand and the quota pool section):
|
||||
* 1. Pick the config mode (manual when stored ids still resolve, else tier when
|
||||
* stored, else file order).
|
||||
* 2. annotateCurrentPriorities() to read the on-disk priority for each account.
|
||||
* 3. Sort by currentPriority desc (undefined treated as 0, matching the
|
||||
* selector), tie-break by tieBreakKey(tokenFile) asc.
|
||||
* 4. Flag drift when the computed config priority != on-disk priority anywhere.
|
||||
*
|
||||
* File mode never writes priorities, but it still honours any residual on-disk
|
||||
* priority the selector reads (e.g. left by `order --reset`): it sorts by that
|
||||
* priority desc then tieBreakKey asc, and flags drift when any residual is
|
||||
* non-zero. With no residuals all priorities are 0 and it falls back to plain
|
||||
* tieBreakKey order with no drift.
|
||||
*
|
||||
* @param activeAccounts Accounts in rotation (callers exclude paused accounts).
|
||||
*/
|
||||
export function resolveEffectiveDrainOrder(
|
||||
provider: CLIProxyProvider,
|
||||
activeAccounts: DrainOrderInput[]
|
||||
): EffectiveDrainOrder {
|
||||
if (activeAccounts.length === 0) {
|
||||
return { mode: 'file', entries: [], hasDrift: false };
|
||||
}
|
||||
|
||||
const drainCfg = loadDrainOrderConfig(provider);
|
||||
|
||||
// Manual mode is only usable when at least one stored ID still maps to a live
|
||||
// account. If every stored ID is stale, fall back to the file-order view.
|
||||
let manualValidIds: string[] | undefined;
|
||||
if (drainCfg?.mode === 'manual' && drainCfg.orderedIds && drainCfg.orderedIds.length > 0) {
|
||||
const existingIds = new Set(activeAccounts.map((a) => a.accountId));
|
||||
manualValidIds = drainCfg.orderedIds.filter((id) => existingIds.has(id));
|
||||
}
|
||||
|
||||
let entries: DrainOrderEntry[];
|
||||
let mode: EffectiveDrainOrderMode;
|
||||
|
||||
if (manualValidIds && manualValidIds.length > 0) {
|
||||
entries = computeManualDrainOrder(manualValidIds, activeAccounts);
|
||||
mode = 'manual';
|
||||
} else if (drainCfg?.mode === 'tier') {
|
||||
entries = computeTierDrainOrder(activeAccounts);
|
||||
mode = 'tier';
|
||||
} else {
|
||||
// File order: config never writes priorities, so entries carry no computed
|
||||
// priority. The selector still reads any residual on-disk priority (e.g.
|
||||
// left behind by `order --reset`), so annotate, sort by that priority like
|
||||
// the other modes, and flag drift when any residual is non-zero.
|
||||
entries = activeAccounts.map((a) => ({
|
||||
accountId: a.accountId,
|
||||
tokenFile: a.tokenFile,
|
||||
priority: MIN_PRIORITY,
|
||||
tier: a.tier,
|
||||
tierDerived: false,
|
||||
currentPriority: undefined,
|
||||
}));
|
||||
annotateCurrentPriorities(entries);
|
||||
const fileHasDrift = entries.some((e) => (e.currentPriority ?? 0) !== 0);
|
||||
entries.sort(
|
||||
(a, b) =>
|
||||
(b.currentPriority ?? 0) - (a.currentPriority ?? 0) ||
|
||||
(tieBreakKey(a.tokenFile) < tieBreakKey(b.tokenFile) ? -1 : 1)
|
||||
);
|
||||
return { mode: 'file', entries, hasDrift: fileHasDrift };
|
||||
}
|
||||
|
||||
// Annotate on-disk priorities (the selector's real input) before sorting.
|
||||
annotateCurrentPriorities(entries);
|
||||
|
||||
// Drift: computed config priority diverges from on-disk anywhere. Missing
|
||||
// file priority (undefined) is treated as 0, matching the selector.
|
||||
const hasDrift = entries.some((e) => (e.currentPriority ?? 0) !== e.priority);
|
||||
|
||||
// Sort by on-disk priority desc (undefined as 0), tie-break by tokenFile asc.
|
||||
entries.sort(
|
||||
(a, b) =>
|
||||
(b.currentPriority ?? 0) - (a.currentPriority ?? 0) ||
|
||||
(tieBreakKey(a.tokenFile) < tieBreakKey(b.tokenFile) ? -1 : 1)
|
||||
);
|
||||
|
||||
return { mode, entries, hasDrift };
|
||||
}
|
||||
|
||||
/** Raw auth-file entry shape we parse out of the management listing. */
|
||||
interface RawManagementAuthFile {
|
||||
name?: unknown;
|
||||
unavailable?: unknown;
|
||||
next_retry_after?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch in-proxy cooldowns from the running CLIProxy.
|
||||
*
|
||||
* Pool routing turns cooling ON, so when a credential hits a 429 the proxy
|
||||
* marks it Unavailable and rotates to a healthy one. That cooldown lives inside
|
||||
* the proxy process (not in CCS's quota-paused.json), so the only way the quota
|
||||
* Pool section can explain a session hop is to read it here.
|
||||
*
|
||||
* GET /v0/management/auth-files returns { files: [{ name, unavailable,
|
||||
* next_retry_after, ... }] }. We classify any entry with unavailable=true as a
|
||||
* cooling credential and parse next_retry_after (RFC3339) into epoch ms when
|
||||
* present.
|
||||
*
|
||||
* Degrades silently to an empty array on any failure (proxy not running,
|
||||
* endpoint missing on an older binary, malformed/unknown response shape,
|
||||
* timeout): the caller falls back to its CCS-side cooldown view with no error
|
||||
* spam in quota output. Latency is bounded by a single request and MGMT_TIMEOUT_MS.
|
||||
*
|
||||
* @returns One entry per auth file the proxy reports unavailable.
|
||||
*/
|
||||
export async function fetchProxyAuthCooldowns(): Promise<ProxyAuthCooldown[]> {
|
||||
const target = getProxyTarget();
|
||||
const url = buildProxyUrl(target, AUTH_FILES_PATH);
|
||||
const headers = buildManagementHeaders(target);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), MGMT_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { method: 'GET', headers, signal: controller.signal });
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { files?: unknown };
|
||||
if (!Array.isArray(data.files)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cooldowns: ProxyAuthCooldown[] = [];
|
||||
for (const raw of data.files as RawManagementAuthFile[]) {
|
||||
// Defensive parse: only entries the proxy explicitly flags unavailable.
|
||||
if (!raw || raw.unavailable !== true) {
|
||||
continue;
|
||||
}
|
||||
const name = typeof raw.name === 'string' ? raw.name : undefined;
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
let cooldownUntil: number | undefined;
|
||||
if (typeof raw.next_retry_after === 'string') {
|
||||
const parsed = Date.parse(raw.next_retry_after);
|
||||
if (Number.isFinite(parsed)) {
|
||||
cooldownUntil = parsed;
|
||||
}
|
||||
}
|
||||
cooldowns.push({ tokenFile: name, cooldownUntil });
|
||||
}
|
||||
return cooldowns;
|
||||
} catch {
|
||||
return [];
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Pool State Resolver
|
||||
*
|
||||
* Resolves the observable state of an account pool for a provider, combining:
|
||||
* - effective drain order (Phase 4 resolver: manual / tier / file order)
|
||||
* - per-account state: available / cooling (quota cooldown, with reset time) / paused
|
||||
* - routing settings (strategy, session affinity) and pool routing mode
|
||||
*
|
||||
* These three account states map to DIFFERENT client-visible failure modes:
|
||||
* - paused : the account is held out of rotation by the user or a safety guard
|
||||
* - cooling : the account hit a quota limit and is on a timed cooldown
|
||||
* - available: the account is eligible for selection
|
||||
*
|
||||
* They are named distinctly so visibility surfaces never conflate "I paused it"
|
||||
* with "it ran out of quota".
|
||||
*
|
||||
* Cooldown source precedence:
|
||||
* - The in-proxy cooldown (read from GET /v0/management/auth-files) is the real
|
||||
* hop mechanism when pool routing is ON: the proxy cools a credential on a 429
|
||||
* and rotates. It wins over everything else because it is the live routing
|
||||
* truth that actually caused the session to move.
|
||||
* - The proxy/monitor process writes quota-paused.json (cross-process truth).
|
||||
* When pool routing's cooling flip is OFF (stock disable-cooling: true) there
|
||||
* is simply no cooldown data to show; this resolver reports that honestly.
|
||||
* - The in-memory quota-manager cooldown is process-local; it only contributes
|
||||
* when this very process applied a cooldown. The persisted entry wins when
|
||||
* both exist, because that is the routing truth across processes.
|
||||
*/
|
||||
|
||||
import {
|
||||
resolveEffectiveDrainOrder,
|
||||
fetchProxyAuthCooldowns,
|
||||
type DrainOrderInput,
|
||||
type ProxyAuthCooldown,
|
||||
} from './drain-order';
|
||||
import { getProviderAccounts } from './query';
|
||||
import { readQuotaCooldownEntries } from './account-safety';
|
||||
import { getCooldownUntil } from '../quota/quota-manager';
|
||||
import { getProxyTarget } from '../proxy/proxy-target-resolver';
|
||||
import { detectRunningProxy } from '../proxy/proxy-detector';
|
||||
import { resolveLifecyclePort } from '../config/port-manager';
|
||||
import type { AccountInfo, AccountTier } from './types';
|
||||
import type { CLIProxyProvider } from '../types';
|
||||
|
||||
/** Providers where tier metadata is tracked and tier-derived order is meaningful. */
|
||||
export const POOL_TIER_AWARE_PROVIDERS = new Set<CLIProxyProvider>(['agy', 'gemini']);
|
||||
|
||||
/** Per-account pool state. */
|
||||
export type PoolAccountStateKind = 'available' | 'cooling' | 'paused';
|
||||
|
||||
/** How the effective drain order was derived. */
|
||||
export type PoolDrainOrderMode = 'manual' | 'tier' | 'file';
|
||||
|
||||
export interface PoolAccountState {
|
||||
accountId: string;
|
||||
tokenFile: string;
|
||||
tier?: AccountTier;
|
||||
/** Whether this account is the provider default. */
|
||||
isDefault: boolean;
|
||||
/** Resolved state. */
|
||||
state: PoolAccountStateKind;
|
||||
/**
|
||||
* For state 'cooling': epoch ms when the cooldown is eligible to lift.
|
||||
* Undefined for other states.
|
||||
*/
|
||||
cooldownUntil?: number;
|
||||
/**
|
||||
* For state 'cooling': where the cooldown reading came from.
|
||||
* 'proxy' = live in-proxy 429 cooldown (GET /v0/management/auth-files),
|
||||
* 'persisted' = quota-paused.json (cross-process),
|
||||
* 'memory' = in-process quota-manager map.
|
||||
*/
|
||||
cooldownSource?: 'proxy' | 'persisted' | 'memory';
|
||||
/** ISO timestamp the account was paused (manual pause), when state is 'paused'. */
|
||||
pausedAt?: string;
|
||||
}
|
||||
|
||||
export interface PoolDrainOrderState {
|
||||
mode: PoolDrainOrderMode;
|
||||
/**
|
||||
* Accounts in effective drain order (first = drained first), as the selector
|
||||
* actually sees it: sorted by on-disk priority, not the intended config.
|
||||
* Paused accounts are excluded from the drain order (they are not in rotation)
|
||||
* but still surface in `states`.
|
||||
*/
|
||||
order: string[];
|
||||
/**
|
||||
* True when the stored config order diverges from what is on disk (the
|
||||
* selector's real input). Surfaced so the quota pool section warns instead of
|
||||
* silently showing a "next account" that disagrees with the selector.
|
||||
*/
|
||||
hasDrift: boolean;
|
||||
}
|
||||
|
||||
export interface PoolRoutingSettings {
|
||||
/** Whether pool routing (fill-first + affinity + cooling) is enabled. */
|
||||
poolEnabled: boolean;
|
||||
strategy: string;
|
||||
sessionAffinityEnabled: boolean;
|
||||
sessionAffinityTtl: string;
|
||||
/** Max credentials tried per request before a 429, when pool routing is on. */
|
||||
maxRetryCredentials?: number;
|
||||
}
|
||||
|
||||
export interface PoolState {
|
||||
provider: CLIProxyProvider;
|
||||
/** All accounts for the provider with resolved per-account state. */
|
||||
states: PoolAccountState[];
|
||||
drainOrder: PoolDrainOrderState;
|
||||
settings: PoolRoutingSettings;
|
||||
}
|
||||
|
||||
export interface ResolvePoolStateInput {
|
||||
provider: CLIProxyProvider;
|
||||
settings: PoolRoutingSettings;
|
||||
/** Override for tests; defaults to getProviderAccounts(provider). */
|
||||
accounts?: AccountInfo[];
|
||||
/** Override for tests; defaults to Date.now(). */
|
||||
now?: number;
|
||||
/**
|
||||
* Live in-proxy cooldowns (GET /v0/management/auth-files), keyed nowhere -
|
||||
* passed as a flat list and indexed by tokenFile internally. When provided,
|
||||
* an entry takes precedence over CCS-side cooldown views for the matching
|
||||
* auth file. Defaults to none (CCS-side classification only).
|
||||
*/
|
||||
proxyCooldowns?: ProxyAuthCooldown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a single account's pool state.
|
||||
*
|
||||
* Precedence:
|
||||
* 1. A live in-proxy cooldown (proxy reports the credential unavailable after a
|
||||
* 429) -> 'cooling' source 'proxy'. This is the actual hop mechanism when
|
||||
* pool routing is ON, so it wins over the CCS-side views below.
|
||||
* 2. A persisted quota cooldown whose pausedAt matches the account's pausedAt is
|
||||
* a quota cooldown -> 'cooling' (cross-process truth wins).
|
||||
* 3. A paused account without a matching cooldown record is a manual/safety pause
|
||||
* -> 'paused'.
|
||||
* 4. An unpaused account with an in-memory cooldown still in effect -> 'cooling'.
|
||||
* 5. Otherwise -> 'available'.
|
||||
*/
|
||||
function classifyAccount(
|
||||
provider: CLIProxyProvider,
|
||||
account: AccountInfo,
|
||||
cooldownByAccount: Map<string, { until: number; pausedAt: string }>,
|
||||
proxyCooldownByTokenFile: Map<string, ProxyAuthCooldown>,
|
||||
now: number
|
||||
): PoolAccountState {
|
||||
const base: PoolAccountState = {
|
||||
accountId: account.id,
|
||||
tokenFile: account.tokenFile,
|
||||
tier: account.tier,
|
||||
isDefault: account.isDefault,
|
||||
state: 'available',
|
||||
};
|
||||
|
||||
// Live in-proxy cooldown wins: this is the credential the proxy actually
|
||||
// rotated out on a 429. A missing/zero next_retry_after still means cooling;
|
||||
// we just cannot show a reset time for it.
|
||||
const proxyCooldown = proxyCooldownByTokenFile.get(account.tokenFile);
|
||||
if (
|
||||
proxyCooldown &&
|
||||
(proxyCooldown.cooldownUntil === undefined || proxyCooldown.cooldownUntil > now)
|
||||
) {
|
||||
return {
|
||||
...base,
|
||||
state: 'cooling',
|
||||
cooldownUntil: proxyCooldown.cooldownUntil,
|
||||
cooldownSource: 'proxy',
|
||||
};
|
||||
}
|
||||
|
||||
const persisted = cooldownByAccount.get(account.id);
|
||||
|
||||
if (account.paused) {
|
||||
// A persisted quota cooldown that matches this pause is a cooldown, not a
|
||||
// manual pause. Match on pausedAt so a manual pause layered over a stale
|
||||
// cooldown record is not mislabelled.
|
||||
if (persisted && persisted.pausedAt === account.pausedAt && persisted.until > now) {
|
||||
return {
|
||||
...base,
|
||||
state: 'cooling',
|
||||
cooldownUntil: persisted.until,
|
||||
cooldownSource: 'persisted',
|
||||
};
|
||||
}
|
||||
return { ...base, state: 'paused', pausedAt: account.pausedAt };
|
||||
}
|
||||
|
||||
// Unpaused: a persisted cooldown still in effect is the routing truth.
|
||||
if (persisted && persisted.until > now) {
|
||||
return {
|
||||
...base,
|
||||
state: 'cooling',
|
||||
cooldownUntil: persisted.until,
|
||||
cooldownSource: 'persisted',
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to the process-local cooldown map.
|
||||
const memoryUntil = getCooldownUntil(provider, account.id);
|
||||
if (memoryUntil !== undefined && memoryUntil > now) {
|
||||
return {
|
||||
...base,
|
||||
state: 'cooling',
|
||||
cooldownUntil: memoryUntil,
|
||||
cooldownSource: 'memory',
|
||||
};
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the effective drain order for the active (non-paused) accounts,
|
||||
* mirroring the selector's pick order used by `ccs cliproxy accounts order`.
|
||||
*
|
||||
* Delegates to the shared resolveEffectiveDrainOrder() so the quota Pool
|
||||
* section, the `accounts order` show, and the selector all agree: the order is
|
||||
* sorted by ON-DISK priority (the selector's real input), and any drift between
|
||||
* the stored config and the auth files is surfaced rather than hidden.
|
||||
*/
|
||||
function resolveDrainOrder(
|
||||
provider: CLIProxyProvider,
|
||||
activeAccounts: DrainOrderInput[]
|
||||
): PoolDrainOrderState {
|
||||
const effective = resolveEffectiveDrainOrder(provider, activeAccounts);
|
||||
return {
|
||||
mode: effective.mode,
|
||||
order: effective.entries.map((e) => e.accountId),
|
||||
hasDrift: effective.hasDrift,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the full observable pool state for a provider.
|
||||
*
|
||||
* Synchronous: callers that want live in-proxy 429 cooldowns surfaced (the real
|
||||
* hop mechanism when pool routing is ON) pass them in via input.proxyCooldowns,
|
||||
* or use resolvePoolStateWithProxyCooldowns() which fetches them first.
|
||||
*/
|
||||
export function resolvePoolState(input: ResolvePoolStateInput): PoolState {
|
||||
const { provider, settings } = input;
|
||||
const now = input.now ?? Date.now();
|
||||
const accounts = input.accounts ?? getProviderAccounts(provider);
|
||||
|
||||
// Index persisted quota cooldowns for this provider by account id.
|
||||
const cooldownByAccount = new Map<string, { until: number; pausedAt: string }>();
|
||||
for (const entry of readQuotaCooldownEntries()) {
|
||||
if (entry.provider !== provider) continue;
|
||||
cooldownByAccount.set(entry.accountId, { until: entry.until, pausedAt: entry.pausedAt });
|
||||
}
|
||||
|
||||
// Index live in-proxy cooldowns by token file (the proxy reports by file name).
|
||||
const proxyCooldownByTokenFile = new Map<string, ProxyAuthCooldown>();
|
||||
for (const entry of input.proxyCooldowns ?? []) {
|
||||
proxyCooldownByTokenFile.set(entry.tokenFile, entry);
|
||||
}
|
||||
|
||||
const states = accounts.map((account) =>
|
||||
classifyAccount(provider, account, cooldownByAccount, proxyCooldownByTokenFile, now)
|
||||
);
|
||||
|
||||
// Drain order is computed over accounts that are in rotation (not paused).
|
||||
// Cooling accounts remain in the order: cooling is transient and the selector
|
||||
// still considers them in priority terms once the window lifts.
|
||||
const activeAccounts: DrainOrderInput[] = accounts
|
||||
.filter((a) => !a.paused)
|
||||
.map((a) => ({ accountId: a.id, tokenFile: a.tokenFile, tier: a.tier }));
|
||||
|
||||
const drainOrder = resolveDrainOrder(provider, activeAccounts);
|
||||
|
||||
return { provider, states, drainOrder, settings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any account was classified 'cooling' from the live in-proxy source.
|
||||
* Used by the renderer to decide whether the "in-proxy cooldowns not shown"
|
||||
* honesty note still applies.
|
||||
*/
|
||||
export function hasProxySourcedCooling(pool: PoolState): boolean {
|
||||
return pool.states.some((s) => s.state === 'cooling' && s.cooldownSource === 'proxy');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve pool state and, when pool routing is ON and the local proxy is
|
||||
* reachable, fold in live in-proxy 429 cooldowns (the actual session-hop
|
||||
* mechanism). Remote targets and a stopped/older proxy degrade silently to the
|
||||
* synchronous CCS-side view with no error output.
|
||||
*
|
||||
* Kept separate from resolvePoolState() so the pure, synchronous resolver stays
|
||||
* test-friendly and free of network/process detection.
|
||||
*/
|
||||
export async function resolvePoolStateWithProxyCooldowns(
|
||||
input: ResolvePoolStateInput
|
||||
): Promise<PoolState> {
|
||||
// Only the local proxy exposes a management API we can read here; remote
|
||||
// drain/cooldown management is out of scope (v1), so skip the fetch.
|
||||
const shouldFetch = input.settings.poolEnabled && !getProxyTarget().isRemote;
|
||||
|
||||
let proxyCooldowns: ProxyAuthCooldown[] | undefined = input.proxyCooldowns;
|
||||
if (proxyCooldowns === undefined && shouldFetch) {
|
||||
try {
|
||||
const proxyStatus = await detectRunningProxy(resolveLifecyclePort());
|
||||
if (proxyStatus.running && proxyStatus.verified) {
|
||||
proxyCooldowns = await fetchProxyAuthCooldowns();
|
||||
}
|
||||
} catch {
|
||||
// Degrade silently: keep the CCS-side cooldown view.
|
||||
proxyCooldowns = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return resolvePoolState({ ...input, proxyCooldowns });
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { CLIProxyProvider } from '../types';
|
||||
import { PROVIDER_CAPABILITIES } from '../provider-capabilities';
|
||||
import { PROVIDER_TYPE_VALUES } from '../auth/auth-types';
|
||||
import { getAuthDir, getCliproxyDir } from '../config/config-generator';
|
||||
import { AccountsRegistry, AccountInfo, PROVIDERS_WITHOUT_EMAIL } from './types';
|
||||
import { AccountsRegistry, AccountInfo, PROVIDERS_WITHOUT_EMAIL, DrainOrderConfig } from './types';
|
||||
import {
|
||||
getAccountsRegistryPath,
|
||||
getPausedDir,
|
||||
@@ -269,6 +269,10 @@ function populateRegistryFromTokenFiles(
|
||||
lastUsedAt:
|
||||
hydratedAccount?.lastUsedAt ||
|
||||
(token.stats.mtime || token.stats.birthtime || new Date()).toISOString(),
|
||||
// Preserve tier from the hydrated registry entry. Without this, populate
|
||||
// would silently strip tier metadata written by the quota command, breaking
|
||||
// --by-tier ordering for file-copied fleets that are re-hydrated on read.
|
||||
tier: hydratedAccount?.tier,
|
||||
};
|
||||
|
||||
if (token.paused) {
|
||||
@@ -820,3 +824,48 @@ export function discoverExistingAccounts(): void {
|
||||
populateRegistryFromTokenFiles(registry);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist drain order configuration for a provider.
|
||||
* The config survives re-auth and registry sync; priorities are NOT automatically
|
||||
* re-applied after sync. Re-run `ccs cliproxy accounts order <provider> --by-tier`
|
||||
* or `--set` after adding or re-authing accounts to re-apply.
|
||||
*/
|
||||
export function saveDrainOrderConfig(
|
||||
provider: CLIProxyProvider,
|
||||
config: DrainOrderConfig
|
||||
): boolean {
|
||||
return mutateAccountsRegistry((registry) => {
|
||||
const providerAccounts = registry.providers[provider];
|
||||
if (!providerAccounts) {
|
||||
return false;
|
||||
}
|
||||
providerAccounts.drainOrder = config;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load persisted drain order configuration for a provider.
|
||||
* Returns undefined if none stored (implies file order).
|
||||
*/
|
||||
export function loadDrainOrderConfig(provider: CLIProxyProvider): DrainOrderConfig | undefined {
|
||||
return withAccountsRegistryLock(() => {
|
||||
const registry = readAccountsRegistryFromDisk();
|
||||
return registry.providers[provider]?.drainOrder;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear drain order configuration for a provider (revert to file order).
|
||||
*/
|
||||
export function clearDrainOrderConfig(provider: CLIProxyProvider): boolean {
|
||||
return mutateAccountsRegistry((registry) => {
|
||||
const providerAccounts = registry.providers[provider];
|
||||
if (!providerAccounts) {
|
||||
return false;
|
||||
}
|
||||
delete providerAccounts.drainOrder;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -41,12 +41,34 @@ export interface AccountInfo {
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain order mode for a provider's account pool.
|
||||
* - "manual": user-specified ordered list of account IDs
|
||||
* - "tier": auto-derived from AccountTier (ultra > pro > free); unknown = last
|
||||
* - "file": stable file-system order (default, no priority writes)
|
||||
*/
|
||||
export type DrainOrderMode = 'manual' | 'tier' | 'file';
|
||||
|
||||
/** Persisted drain order configuration for a provider */
|
||||
export interface DrainOrderConfig {
|
||||
/** How priorities were last set */
|
||||
mode: DrainOrderMode;
|
||||
/**
|
||||
* Ordered account IDs for manual mode.
|
||||
* First = drained first (highest priority).
|
||||
* Stale IDs (accounts removed since last --set) are silently ignored on re-apply.
|
||||
*/
|
||||
orderedIds?: string[];
|
||||
}
|
||||
|
||||
/** Provider accounts configuration */
|
||||
export interface ProviderAccounts {
|
||||
/** Default account ID for this provider */
|
||||
default: string;
|
||||
/** Map of account ID to account metadata */
|
||||
accounts: Record<string, Omit<AccountInfo, 'id' | 'provider' | 'isDefault'>>;
|
||||
/** Persisted drain order configuration (optional; absent = file order) */
|
||||
drainOrder?: DrainOrderConfig;
|
||||
}
|
||||
|
||||
/** Accounts registry structure */
|
||||
|
||||
@@ -75,6 +75,8 @@ import {
|
||||
warnOAuthBanRisk,
|
||||
warnPossible403Ban,
|
||||
} from '../accounts/account-safety';
|
||||
import { maybeOfferPoolRouting } from '../routing/pool-opt-in-prompt';
|
||||
import { checkCrossLaneEmailOverlap } from '../accounts/account-safety-cross-lane';
|
||||
import { ensureCliAntigravityResponsibility } from '../auth/antigravity-responsibility';
|
||||
import { InteractivePrompt } from '../../utils/prompt';
|
||||
import { getCcsDir } from '../../utils/config-manager';
|
||||
@@ -1133,6 +1135,8 @@ export async function triggerOAuth(
|
||||
|
||||
// Check for existing accounts
|
||||
const existingAccounts = getProviderAccounts(provider);
|
||||
// Capture count before registration for 1->2 transition detection
|
||||
const accountCountBeforeAdd = existingAccounts.length;
|
||||
const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null;
|
||||
const targetAccountId = options.expectedAccountId || existingNameMatch?.id;
|
||||
const nicknameError = !fromUI
|
||||
@@ -1363,6 +1367,38 @@ export async function triggerOAuth(
|
||||
}
|
||||
|
||||
if (account) {
|
||||
// Cross-lane overlap guard: warn if this account's email is also active
|
||||
// in native Claude profiles (same account in two lanes is the documented ban vector).
|
||||
if (account.email) {
|
||||
checkCrossLaneEmailOverlap(provider, account.email);
|
||||
}
|
||||
|
||||
// Pool routing opt-in: offer at the 1->2 account-add transition for verified providers.
|
||||
// Only runs for local CLI sessions — skip when fromUI is true because the dashboard
|
||||
// calls triggerOAuth from an HTTP request handler; the server may be running in a
|
||||
// foreground terminal (ccs api / ccs dashboard) where process.stdin.isTTY is true,
|
||||
// so reaching InteractivePrompt.confirm would block the HTTP request on the server's
|
||||
// stdin and show the consent prompt to the wrong audience.
|
||||
// Dashboard parity for the opt-in belongs to Phase 6.
|
||||
if (!fromUI) {
|
||||
try {
|
||||
await maybeOfferPoolRouting(provider, accountCountBeforeAdd);
|
||||
} catch (promptErr) {
|
||||
// A regenerateConfig or prompt failure must not fail triggerOAuth after a
|
||||
// successful account registration — the account is already registered.
|
||||
logger.stage(
|
||||
'auth',
|
||||
'cliproxy.pool-prompt.error',
|
||||
'Pool routing prompt failed (non-fatal)',
|
||||
{ provider },
|
||||
{ level: 'warn' }
|
||||
);
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error('[!] Pool routing prompt error (non-fatal):', promptErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.stage(
|
||||
'auth',
|
||||
'cliproxy.oauth.success',
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Claude built-in provider — shadow warning and first-run routing notice
|
||||
*
|
||||
* Shadow warning
|
||||
* --------------
|
||||
* The claude and anthropic provider names are reserved built-ins (priority 0.5).
|
||||
* A user who has a settings profile or account profile named 'claude' or 'anthropic'
|
||||
* will silently get the built-in rather than their own profile. This module emits
|
||||
* a one-line, TTY-only, once-per-install warning with a rename hint in that case.
|
||||
*
|
||||
* First-run routing notice
|
||||
* ------------------------
|
||||
* On the very first launch of `ccs claude` a one-line notice is printed to stderr
|
||||
* reminding the user that traffic routes through the local CLIProxy instance and
|
||||
* that bare `ccs` still launches native Claude Code.
|
||||
*
|
||||
* Persistence: marker files inside ~/.ccs/cliproxy/ are used for both notices.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { warn, info } from '../utils/ui';
|
||||
import {
|
||||
getCcsDir,
|
||||
loadOrCreateUnifiedConfig,
|
||||
isUnifiedMode,
|
||||
} from '../config/config-loader-facade';
|
||||
import { readConfig } from '../utils/config-manager';
|
||||
import { ProfileRegistry } from '../auth/profile-registry';
|
||||
|
||||
/** Marker file name inside ~/.ccs/cliproxy/ */
|
||||
const SHADOW_WARNED_MARKER = '.claude-shadow-warned';
|
||||
|
||||
/** Names that the claude built-in shadows */
|
||||
const SHADOWED_NAMES = new Set(['claude', 'anthropic']);
|
||||
|
||||
/** Get path to the once-per-install dismissal marker. */
|
||||
function getShadowWarnedMarkerPath(): string {
|
||||
return path.join(getCcsDir(), 'cliproxy', SHADOW_WARNED_MARKER);
|
||||
}
|
||||
|
||||
/** Return true if this install has already shown the shadow warning. */
|
||||
function shadowWarnAlreadyShown(): boolean {
|
||||
try {
|
||||
return fs.existsSync(getShadowWarnedMarkerPath());
|
||||
} catch {
|
||||
return true; // If we can't read, skip warning.
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the warning dismissal. */
|
||||
function markShadowWarnShown(): void {
|
||||
try {
|
||||
const dir = path.join(getCcsDir(), 'cliproxy');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(getShadowWarnedMarkerPath(), new Date().toISOString(), {
|
||||
encoding: 'utf8',
|
||||
flag: 'w',
|
||||
});
|
||||
} catch {
|
||||
// Best-effort — failure to persist is not fatal.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user has a settings or account profile named 'claude' or 'anthropic'
|
||||
* that is being shadowed by the built-in provider.
|
||||
*/
|
||||
function detectShadowedProfile(): string | null {
|
||||
try {
|
||||
if (isUnifiedMode()) {
|
||||
const unified = loadOrCreateUnifiedConfig();
|
||||
const profileNames = Object.keys(unified.profiles || {});
|
||||
const accountNames = Object.keys(unified.accounts || {});
|
||||
const variantNames = Object.keys(unified.cliproxy?.variants || {});
|
||||
for (const name of [...profileNames, ...accountNames, ...variantNames]) {
|
||||
if (SHADOWED_NAMES.has(name.toLowerCase())) return name;
|
||||
}
|
||||
} else {
|
||||
const config = readConfig();
|
||||
const profileNames = Object.keys(config.profiles || {});
|
||||
const cliproxyNames = Object.keys(config.cliproxy || {});
|
||||
// Also check account-based profiles from profiles.json (mirror unified accounts check).
|
||||
const registry = new ProfileRegistry();
|
||||
const accountProfileNames = registry.listProfiles();
|
||||
for (const name of [...profileNames, ...cliproxyNames, ...accountProfileNames]) {
|
||||
if (SHADOWED_NAMES.has(name.toLowerCase())) return name;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If config is unreadable, skip the check silently.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit the shadow warning if conditions are met.
|
||||
*
|
||||
* Conditions:
|
||||
* - Running in a TTY (no warning in pipes/CI)
|
||||
* - Warning not already shown for this install
|
||||
* - User has a profile named 'claude' or 'anthropic' that the built-in shadows
|
||||
*
|
||||
* Writes to stderr so it does not pollute stdout piped output.
|
||||
*/
|
||||
export function maybeWarnClaudeShadow(): void {
|
||||
// TTY guard — no warning in non-interactive or CI environments
|
||||
if (!process.stderr.isTTY) return;
|
||||
|
||||
if (shadowWarnAlreadyShown()) return;
|
||||
|
||||
const shadowedName = detectShadowedProfile();
|
||||
if (!shadowedName) return;
|
||||
|
||||
markShadowWarnShown();
|
||||
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(
|
||||
warn(
|
||||
`Profile '${shadowedName}' is shadowed by the built-in claude provider and cannot be reached via 'ccs ${shadowedName}'.`
|
||||
) + '\n'
|
||||
);
|
||||
process.stderr.write(
|
||||
` Rename it to continue using it: ccs config (or edit ~/.ccs/config.yaml / config.json)\n`
|
||||
);
|
||||
process.stderr.write('\n');
|
||||
}
|
||||
|
||||
// ── First-run routing notice ──────────────────────────────────────────────────
|
||||
|
||||
/** Marker file for the once-per-install routing notice. */
|
||||
const ROUTING_NOTICE_MARKER = '.claude-routing-noticed';
|
||||
|
||||
function getRoutingNoticeMarkerPath(): string {
|
||||
return path.join(getCcsDir(), 'cliproxy', ROUTING_NOTICE_MARKER);
|
||||
}
|
||||
|
||||
function routingNoticeAlreadyShown(): boolean {
|
||||
try {
|
||||
return fs.existsSync(getRoutingNoticeMarkerPath());
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function markRoutingNoticeShown(): void {
|
||||
try {
|
||||
const dir = path.join(getCcsDir(), 'cliproxy');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(getRoutingNoticeMarkerPath(), new Date().toISOString(), {
|
||||
encoding: 'utf8',
|
||||
flag: 'w',
|
||||
});
|
||||
} catch {
|
||||
// Best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a one-time routing notice when the claude built-in provider first launches.
|
||||
*
|
||||
* Informs the user that traffic is routed through the local CLIProxy instance
|
||||
* (not the Anthropic API directly) and that bare `ccs` still uses native Claude Code.
|
||||
*
|
||||
* TTY-only; written to stderr.
|
||||
*/
|
||||
export function maybeShowClaudeRoutingNotice(): void {
|
||||
if (!process.stderr.isTTY) return;
|
||||
if (routingNoticeAlreadyShown()) return;
|
||||
|
||||
markRoutingNoticeShown();
|
||||
|
||||
process.stderr.write(
|
||||
info('ccs claude: traffic routes through the local CLIProxy instance.') + '\n'
|
||||
);
|
||||
process.stderr.write(
|
||||
` Native Claude Code (direct Anthropic API) is still available via bare \`ccs\`.\n`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
/**
|
||||
* Gap 1: claude provider is model-neutral (no ANTHROPIC_MODEL pins in env output).
|
||||
* Snapshot test proving:
|
||||
* - getClaudeEnvVars('claude') emits no model env vars
|
||||
* - getClaudeEnvVars('gemini') still emits model env vars (no spillover)
|
||||
* - ensureProviderSettings does not write model pins for claude
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { getClaudeEnvVars, ensureProviderSettings, getRemoteEnvVars } from '../env-builder';
|
||||
import { clearConfigCache } from '../base-config-loader';
|
||||
|
||||
const MODEL_KEYS = [
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
] as const;
|
||||
|
||||
describe('claude provider model-neutral passthrough (Gap 1)', () => {
|
||||
let tempHome: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-claude-neutral-'));
|
||||
process.env.CCS_HOME = tempHome;
|
||||
clearConfigCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
clearConfigCache();
|
||||
});
|
||||
|
||||
it('emits no model env vars for the claude provider', () => {
|
||||
const env = getClaudeEnvVars('claude');
|
||||
|
||||
for (const key of MODEL_KEYS) {
|
||||
expect(env[key]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('still sets ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN for claude', () => {
|
||||
const env = getClaudeEnvVars('claude');
|
||||
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/claude');
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBeDefined();
|
||||
});
|
||||
|
||||
it('still emits model env vars for gemini provider (no spillover)', () => {
|
||||
const env = getClaudeEnvVars('gemini');
|
||||
|
||||
for (const key of MODEL_KEYS) {
|
||||
expect(typeof env[key]).toBe('string');
|
||||
expect((env[key] as string).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('ensureProviderSettings does not write model pins for claude', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
ensureProviderSettings('claude');
|
||||
|
||||
// Non-cursor providers use the legacy top-level path: ~/.ccs/claude.settings.json
|
||||
const settingsPath = path.join(ccsDir, 'claude.settings.json');
|
||||
expect(fs.existsSync(settingsPath)).toBe(true);
|
||||
|
||||
const written = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
for (const key of MODEL_KEYS) {
|
||||
// Model keys must not be present (or must be undefined/empty)
|
||||
const value = written.env[key];
|
||||
expect(!value || value.trim().length === 0).toBe(true);
|
||||
}
|
||||
|
||||
// Transport keys must be present
|
||||
expect(written.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/claude');
|
||||
expect(written.env.ANTHROPIC_AUTH_TOKEN).toBeDefined();
|
||||
});
|
||||
|
||||
// ── Upgrade-path: existing claude.settings.json with stale default model pins ──
|
||||
|
||||
it('strips stale default model pins from existing claude.settings.json on ensureProviderSettings', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
// Simulate a user who ran `ccs claude` before the model-neutral change.
|
||||
// These are the exact default values that were auto-written by older CCS.
|
||||
const stalePins = {
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-7',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
const settingsPath = path.join(ccsDir, 'claude.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
...stalePins,
|
||||
},
|
||||
}),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
ensureProviderSettings('claude');
|
||||
|
||||
const repaired = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
// All stale pins must be removed
|
||||
for (const key of MODEL_KEYS) {
|
||||
const value = repaired.env[key];
|
||||
expect(!value || value.trim().length === 0).toBe(true);
|
||||
}
|
||||
|
||||
// Transport keys must still be present
|
||||
expect(repaired.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/claude');
|
||||
expect(repaired.env.ANTHROPIC_AUTH_TOKEN).toBeDefined();
|
||||
});
|
||||
|
||||
it('preserves user-customised model pin that differs from the stale default', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
const settingsPath = path.join(ccsDir, 'claude.settings.json');
|
||||
// User has customised ANTHROPIC_MODEL to a non-default value
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'claude-opus-4-7', // customised — not the stale sonnet default
|
||||
},
|
||||
}),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
ensureProviderSettings('claude');
|
||||
|
||||
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
// Custom value must be preserved
|
||||
expect(result.env.ANTHROPIC_MODEL).toBe('claude-opus-4-7');
|
||||
});
|
||||
|
||||
// ── One-shot migration guard (launch N+1) ─────────────────────────────────────
|
||||
|
||||
it('stale-pin migration runs at most once (marker prevents re-strip on launch N+1)', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
// Pre-place the migration marker so migration is considered done.
|
||||
const markerDir = path.join(ccsDir, 'cliproxy');
|
||||
fs.mkdirSync(markerDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(markerDir, '.claude-model-migrated'), new Date().toISOString());
|
||||
|
||||
// Write a settings file that contains stale default pins.
|
||||
const stalePins = {
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-7',
|
||||
};
|
||||
const settingsPath = path.join(ccsDir, 'claude.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
...stalePins,
|
||||
},
|
||||
}),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
ensureProviderSettings('claude');
|
||||
|
||||
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
// Migration already marked done — stale pins must NOT be stripped again.
|
||||
// This proves a user re-pin that equals a stale default value survives on launch N+1.
|
||||
expect(result.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
|
||||
expect(result.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-7');
|
||||
});
|
||||
|
||||
it('settings file without ANTHROPIC_MODEL triggers no rewrite after migration marker set', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
// Pre-place the migration marker.
|
||||
const markerDir = path.join(ccsDir, 'cliproxy');
|
||||
fs.mkdirSync(markerDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(markerDir, '.claude-model-migrated'), new Date().toISOString());
|
||||
|
||||
const settingsPath = path.join(ccsDir, 'claude.settings.json');
|
||||
const originalContent = JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
fs.writeFileSync(settingsPath, originalContent + '\n', 'utf-8');
|
||||
const statBefore = fs.statSync(settingsPath);
|
||||
|
||||
ensureProviderSettings('claude');
|
||||
|
||||
const statAfter = fs.statSync(settingsPath);
|
||||
// mtime should not change — no rewrite triggered
|
||||
expect(statAfter.mtimeMs).toBe(statBefore.mtimeMs);
|
||||
|
||||
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string | undefined>;
|
||||
};
|
||||
// ANTHROPIC_MODEL must still be absent
|
||||
expect(result.env.ANTHROPIC_MODEL).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Historical-default set coverage ──────────────────────────────────────────
|
||||
|
||||
it('strips gen-2 pins (sonnet-4-5-20250929 / opus-4-5-20251101) on migration', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
const settingsPath = path.join(ccsDir, 'claude.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-20250929',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-20251101',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-20250929',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
},
|
||||
}),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
ensureProviderSettings('claude');
|
||||
|
||||
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
for (const key of MODEL_KEYS) {
|
||||
const value = result.env[key];
|
||||
expect(!value || value.trim().length === 0).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('strips gen-3 mixed pin (opus-4-6) on migration', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
const settingsPath = path.join(ccsDir, 'claude.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-3-5-20241022',
|
||||
},
|
||||
}),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
ensureProviderSettings('claude');
|
||||
|
||||
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
for (const key of MODEL_KEYS) {
|
||||
const value = result.env[key];
|
||||
expect(!value || value.trim().length === 0).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves an explicit user pin to a value not in any stale-defaults set (e.g. claude-opus-4-8)', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
const settingsPath = path.join(ccsDir, 'claude.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'claude-opus-4-8',
|
||||
},
|
||||
}),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
ensureProviderSettings('claude');
|
||||
|
||||
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
// claude-opus-4-8 is not in any stale-defaults set; must survive migration
|
||||
expect(result.env.ANTHROPIC_MODEL).toBe('claude-opus-4-8');
|
||||
});
|
||||
|
||||
// ── Fresh-file migration marker and tier-pin survival ─────────────────────────
|
||||
|
||||
it('marks migration done on fresh-file creation so tier pins written by ccs claude --config survive', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
const settingsPath = path.join(ccsDir, 'claude.settings.json');
|
||||
const markerPath = path.join(ccsDir, 'cliproxy', '.claude-model-migrated');
|
||||
|
||||
// Step 1: fresh ensureProviderSettings creates the file and the marker.
|
||||
expect(fs.existsSync(settingsPath)).toBe(false);
|
||||
ensureProviderSettings('claude');
|
||||
expect(fs.existsSync(settingsPath)).toBe(true);
|
||||
expect(fs.existsSync(markerPath)).toBe(true);
|
||||
|
||||
// Step 2: simulate 'ccs claude --config' pinning all four tier models.
|
||||
const tierPins = {
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-sonnet-4-6',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-sonnet-4-6',
|
||||
};
|
||||
const existing = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string>;
|
||||
};
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({ ...existing, env: { ...existing.env, ...tierPins } }, null, 2) + '\n',
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// Step 3: second ensureProviderSettings must NOT strip the user-written pins
|
||||
// because the migration marker already exists.
|
||||
ensureProviderSettings('claude');
|
||||
|
||||
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string | undefined>;
|
||||
};
|
||||
expect(result.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
|
||||
expect(result.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-sonnet-4-6');
|
||||
expect(result.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6');
|
||||
expect(result.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-6');
|
||||
});
|
||||
|
||||
// ── Upgrader --config ordering: migrate-first, then re-pin survives ───────────
|
||||
// Models the `ccs claude --config` flow on an UPGRADER: the executor runs
|
||||
// ensureProviderSettings BEFORE configureProviderModel writes the user's pick.
|
||||
// Step 1 (ensureProviderSettings) strips the old auto-pins and sets the marker;
|
||||
// Step 2 (the user's --config write) lands a fresh pin; Step 3 (next plain
|
||||
// launch) must NOT strip it because the marker is already set.
|
||||
it('upgrader --config: ensureProviderSettings runs first, so a re-pin equal to a stale default survives the next launch', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
const settingsPath = path.join(ccsDir, 'claude.settings.json');
|
||||
const markerPath = path.join(ccsDir, 'cliproxy', '.claude-model-migrated');
|
||||
|
||||
// Pre-existing upgrader file with OLD auto-written default pins, no marker.
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-20250929',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-20251101',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-20250929',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
},
|
||||
}),
|
||||
'utf-8'
|
||||
);
|
||||
expect(fs.existsSync(markerPath)).toBe(false);
|
||||
|
||||
// Step 1: executor runs ensureProviderSettings BEFORE the --config write.
|
||||
// This strips the old auto-pins and sets the migration marker.
|
||||
ensureProviderSettings('claude');
|
||||
expect(fs.existsSync(markerPath)).toBe(true);
|
||||
const afterMigrate = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string | undefined>;
|
||||
};
|
||||
for (const key of MODEL_KEYS) {
|
||||
const value = afterMigrate.env[key];
|
||||
expect(!value || value.trim().length === 0).toBe(true);
|
||||
}
|
||||
|
||||
// Step 2: configureProviderModel writes the user's deliberate pick — and the
|
||||
// pick happens to equal a current catalog default that is in the stale set.
|
||||
const userPin = {
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-sonnet-4-6',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-sonnet-4-6',
|
||||
};
|
||||
const current = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string>;
|
||||
};
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({ ...current, env: { ...current.env, ...userPin } }, null, 2) + '\n',
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// Step 3: next plain `ccs claude` launch must NOT strip the just-written pin
|
||||
// because the marker was already set in Step 1.
|
||||
ensureProviderSettings('claude');
|
||||
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string | undefined>;
|
||||
};
|
||||
expect(result.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
|
||||
expect(result.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-sonnet-4-6');
|
||||
expect(result.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6');
|
||||
expect(result.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-6');
|
||||
});
|
||||
|
||||
// ── Remote read path: stale-pin filter without file mutation ──────────────────
|
||||
|
||||
it('getRemoteEnvVars (claude): drops stale default model pins from the settings file at read level', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
const settingsPath = path.join(ccsDir, 'claude.settings.json');
|
||||
const originalContent =
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-7',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
},
|
||||
}) + '\n';
|
||||
fs.writeFileSync(settingsPath, originalContent, 'utf-8');
|
||||
|
||||
const env = getRemoteEnvVars('claude', {
|
||||
host: 'example.com',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
});
|
||||
|
||||
// Stale defaults must NOT leak into the remote env.
|
||||
for (const key of MODEL_KEYS) {
|
||||
expect(env[key]).toBeUndefined();
|
||||
}
|
||||
// Transport keys are always set from the remote config.
|
||||
expect(env.ANTHROPIC_BASE_URL).toContain('example.com');
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBeDefined();
|
||||
|
||||
// Read-level only: the settings file on disk is untouched.
|
||||
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe(originalContent);
|
||||
});
|
||||
|
||||
it('getRemoteEnvVars (claude): preserves a user-custom model pin not in any stale set', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
const settingsPath = path.join(ccsDir, 'claude.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'claude-opus-4-8', // custom — not a historical default
|
||||
},
|
||||
}),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const env = getRemoteEnvVars('claude', {
|
||||
host: 'example.com',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
});
|
||||
|
||||
// Custom pin survives the remote read path.
|
||||
expect(env.ANTHROPIC_MODEL).toBe('claude-opus-4-8');
|
||||
});
|
||||
|
||||
it('getRemoteEnvVars (claude): Priority 1 explicit custom settings path is NOT stale-filtered', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
// An explicitly passed settings file is the user's deliberate choice and
|
||||
// must be honored verbatim — even when it carries a value equal to a
|
||||
// historical default.
|
||||
const customPath = path.join(ccsDir, 'custom-claude.settings.json');
|
||||
fs.writeFileSync(
|
||||
customPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-6', // equals a stale default, but explicit
|
||||
},
|
||||
}),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const env = getRemoteEnvVars(
|
||||
'claude',
|
||||
{ host: 'example.com', port: 8317, protocol: 'http' },
|
||||
customPath
|
||||
);
|
||||
|
||||
// Priority 1 untouched: the explicit pin survives.
|
||||
expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
|
||||
});
|
||||
|
||||
it('getRemoteEnvVars (claude): migration marker set means pins are user-intentional and NOT filtered', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
// Marker present: the file was already cleaned once, so any pin that
|
||||
// exists now was put there deliberately (e.g. ccs claude --config picked
|
||||
// a value that happens to equal a historical default).
|
||||
const markerDir = path.join(ccsDir, 'cliproxy');
|
||||
fs.mkdirSync(markerDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(markerDir, '.claude-model-migrated'), new Date().toISOString());
|
||||
|
||||
const settingsPath = path.join(ccsDir, 'claude.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-6', // equals a stale default, but post-migration = explicit
|
||||
},
|
||||
}),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const env = getRemoteEnvVars('claude', {
|
||||
host: 'example.com',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
});
|
||||
|
||||
expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
|
||||
});
|
||||
});
|
||||
@@ -16,10 +16,10 @@ interface BaseSettings {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: string;
|
||||
ANTHROPIC_AUTH_TOKEN: string;
|
||||
ANTHROPIC_MODEL: string;
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: string;
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: string;
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: string;
|
||||
ANTHROPIC_MODEL?: string;
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL?: string;
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL?: string;
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,16 +66,20 @@ export function loadBaseConfig(provider: CLIProxyProvider): BaseSettings {
|
||||
throw new Error('Missing or invalid "env" object');
|
||||
}
|
||||
|
||||
const required = [
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
];
|
||||
// claude provider is model-neutral: it does not pin model env vars so that
|
||||
// the user's own Claude Code model selection is respected end-to-end.
|
||||
if (provider !== 'claude') {
|
||||
const required = [
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
];
|
||||
|
||||
for (const field of required) {
|
||||
if (!settings.env[field as keyof BaseSettings['env']]) {
|
||||
throw new Error(`Missing required field: env.${field}`);
|
||||
for (const field of required) {
|
||||
if (!settings.env[field as keyof BaseSettings['env']]) {
|
||||
throw new Error(`Missing required field: env.${field}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,14 +94,17 @@ export function loadBaseConfig(provider: CLIProxyProvider): BaseSettings {
|
||||
|
||||
/**
|
||||
* Get model mapping from base config
|
||||
* Extracts model names from env vars
|
||||
* Extracts model names from env vars.
|
||||
* Returns undefined model fields for the claude provider (model-neutral passthrough).
|
||||
*/
|
||||
export function getModelMappingFromConfig(provider: CLIProxyProvider): ProviderModelMapping {
|
||||
const config = loadBaseConfig(provider);
|
||||
|
||||
// claude is model-neutral: ANTHROPIC_MODEL is absent from its config; callers
|
||||
// that need model IDs (e.g. getClaudeEnvVars) guard on provider !== 'claude'.
|
||||
return {
|
||||
defaultModel: config.env.ANTHROPIC_MODEL,
|
||||
claudeModel: config.env.ANTHROPIC_MODEL,
|
||||
defaultModel: config.env.ANTHROPIC_MODEL ?? '',
|
||||
claudeModel: config.env.ANTHROPIC_MODEL ?? '',
|
||||
opusModel: config.env.ANTHROPIC_DEFAULT_OPUS_MODEL,
|
||||
sonnetModel: config.env.ANTHROPIC_DEFAULT_SONNET_MODEL,
|
||||
haikuModel: config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL,
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
normalizeIFlowLegacyModelAliases,
|
||||
normalizeModelIdForProvider,
|
||||
} from '../ai-providers/model-id-normalizer';
|
||||
import { getGlobalEnvConfig } from '../../config/config-loader-facade';
|
||||
import { getGlobalEnvConfig, getCcsDir } from '../../config/config-loader-facade';
|
||||
|
||||
/** Settings file structure for user overrides */
|
||||
interface ProviderSettings {
|
||||
@@ -52,6 +52,9 @@ const REQUIRED_PROVIDER_ENV_KEYS = [
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
] as const;
|
||||
|
||||
/** Minimum required env vars for the claude built-in provider (model-neutral). */
|
||||
const REQUIRED_CLAUDE_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const;
|
||||
const CURSOR_LEGACY_ENV_OVERRIDE_KEYS = new Set([
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
@@ -200,28 +203,18 @@ export function getModelMapping(provider: CLIProxyProvider): ProviderModelMappin
|
||||
* Get environment variables for Claude CLI (bundled defaults)
|
||||
* Uses provider-specific endpoint (e.g., /api/provider/gemini) for explicit routing.
|
||||
* This enables concurrent gemini/codex usage - each session routes to its provider via URL path.
|
||||
*
|
||||
* For the claude built-in provider the model env vars are intentionally omitted so that
|
||||
* the user's own Claude Code /model selection is honored end-to-end (model-neutral passthrough).
|
||||
*/
|
||||
export function getClaudeEnvVars(
|
||||
provider: CLIProxyProvider,
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
): NodeJS.ProcessEnv {
|
||||
const models = getModelMapping(provider);
|
||||
|
||||
// Base env vars from config file (includes ANTHROPIC_MAX_TOKENS, etc.)
|
||||
const baseEnvVars = getEnvVarsFromConfig(provider);
|
||||
|
||||
// Core env vars that we always set dynamically
|
||||
const coreEnvVars = {
|
||||
// Provider-specific endpoint - routes to correct provider via URL path
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}/api/provider/${provider}`,
|
||||
ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(),
|
||||
ANTHROPIC_MODEL: models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel,
|
||||
};
|
||||
|
||||
// Filter out core env vars from base config to avoid conflicts
|
||||
// Filter out model pins and URL/auth from base config (we set them dynamically)
|
||||
const {
|
||||
ANTHROPIC_BASE_URL: _baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: _authToken,
|
||||
@@ -232,6 +225,22 @@ export function getClaudeEnvVars(
|
||||
...additionalEnvVars
|
||||
} = baseEnvVars;
|
||||
|
||||
// Core transport env vars set dynamically for all providers
|
||||
const coreEnvVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}/api/provider/${provider}`,
|
||||
ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(),
|
||||
};
|
||||
|
||||
// Model pins: omitted for claude provider (model-neutral passthrough).
|
||||
// For all other providers, set model vars from the base config model mapping.
|
||||
if (provider !== 'claude') {
|
||||
const models = getModelMapping(provider);
|
||||
coreEnvVars.ANTHROPIC_MODEL = models.claudeModel;
|
||||
coreEnvVars.ANTHROPIC_DEFAULT_OPUS_MODEL = models.opusModel || models.claudeModel;
|
||||
coreEnvVars.ANTHROPIC_DEFAULT_SONNET_MODEL = models.sonnetModel || models.claudeModel;
|
||||
coreEnvVars.ANTHROPIC_DEFAULT_HAIKU_MODEL = models.haikuModel || models.claudeModel;
|
||||
}
|
||||
|
||||
// Merge core env vars with additional env vars from base config
|
||||
const mergedEnv = {
|
||||
...coreEnvVars,
|
||||
@@ -509,6 +518,98 @@ export function getEffectiveEnvVars(
|
||||
return { ...globalEnv, ...getClaudeEnvVars(provider, port) };
|
||||
}
|
||||
|
||||
/**
|
||||
* All historically-shipped default model pins that CCS auto-wrote into
|
||||
* claude.settings.json before the model-neutral passthrough change.
|
||||
* A key is removed only when the stored value exactly matches one of the values
|
||||
* in that key's set, so user-customised values are always preserved.
|
||||
*/
|
||||
const CLAUDE_STALE_MODEL_DEFAULTS: Record<string, Set<string>> = {
|
||||
ANTHROPIC_MODEL: new Set([
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-sonnet-4-5-20250929',
|
||||
'claude-sonnet-4-6',
|
||||
]),
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: new Set([
|
||||
'claude-opus-4-20250514',
|
||||
'claude-opus-4-5-20251101',
|
||||
'claude-opus-4-6',
|
||||
'claude-opus-4-7',
|
||||
]),
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: new Set([
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-sonnet-4-5-20250929',
|
||||
'claude-sonnet-4-6',
|
||||
]),
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: new Set([
|
||||
'claude-haiku-3-5-20241022',
|
||||
'claude-haiku-4-5-20251001',
|
||||
]),
|
||||
};
|
||||
|
||||
/** Marker file that records when the one-time stale-pin migration has run. */
|
||||
const CLAUDE_MODEL_MIGRATED_MARKER = '.claude-model-migrated';
|
||||
|
||||
/** Return true if the one-time stale-pin migration has already been applied. */
|
||||
function claudeModelMigrationDone(): boolean {
|
||||
try {
|
||||
return fs.existsSync(path.join(getCcsDir(), 'cliproxy', CLAUDE_MODEL_MIGRATED_MARKER));
|
||||
} catch {
|
||||
return true; // Cannot read — treat as done to avoid repeated rewrites.
|
||||
}
|
||||
}
|
||||
|
||||
/** Record that the one-time stale-pin migration has been applied. */
|
||||
function markClaudeModelMigrationDone(): void {
|
||||
try {
|
||||
const dir = path.join(getCcsDir(), 'cliproxy');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, CLAUDE_MODEL_MIGRATED_MARKER), new Date().toISOString(), {
|
||||
encoding: 'utf8',
|
||||
flag: 'w',
|
||||
});
|
||||
} catch {
|
||||
// Best-effort — failure to persist is not fatal.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove stale model pins from an existing claude.settings.json env block.
|
||||
* Only removes keys whose values appear in the set of historically-shipped
|
||||
* defaults for that key, preserving user-customised values.
|
||||
* Returns true when at least one key was removed (signals file needs rewriting).
|
||||
*/
|
||||
function migrateClaudeStaleModelPins(env: Record<string, string>): boolean {
|
||||
let mutated = false;
|
||||
for (const [key, staleValues] of Object.entries(CLAUDE_STALE_MODEL_DEFAULTS)) {
|
||||
if (staleValues.has(env[key])) {
|
||||
delete env[key];
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
return mutated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-level equivalent of the stale-pin migration for paths that must not
|
||||
* mutate the settings file (e.g. the remote env builder). Returns a copy of
|
||||
* the env with any value equal to a historical default dropped per-key, while
|
||||
* user-custom pins are preserved. Same per-key Sets as the local migration, so
|
||||
* local / --config / remote read paths agree on which pins are stale.
|
||||
*
|
||||
* No marker is consulted or written: this is purely a read-time filter, so it
|
||||
* is idempotent across launches without a one-shot guard.
|
||||
*/
|
||||
function filterClaudeStaleModelPins(env: Record<string, string>): Record<string, string> {
|
||||
const result = { ...env };
|
||||
for (const [key, staleValues] of Object.entries(CLAUDE_STALE_MODEL_DEFAULTS)) {
|
||||
if (staleValues.has(result[key])) {
|
||||
delete result[key];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy bundled settings template to user directory if not exists
|
||||
* Called during installation/first run
|
||||
@@ -525,8 +626,13 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void {
|
||||
};
|
||||
|
||||
// Create initial file when missing.
|
||||
// A freshly created file has no stale pins by construction, so mark migration
|
||||
// done immediately to prevent the one-time strip from running unnecessarily.
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
writeSettings({ env: defaultEnv });
|
||||
if (provider === 'claude') {
|
||||
markClaudeModelMigrationDone();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -564,11 +670,28 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void {
|
||||
: {};
|
||||
|
||||
let mutated = !(envCandidate && typeof envCandidate === 'object' && !Array.isArray(envCandidate));
|
||||
for (const key of REQUIRED_PROVIDER_ENV_KEYS) {
|
||||
|
||||
// One-time migration: strip stale model pins written by older CCS versions into
|
||||
// claude.settings.json. Guarded by a marker file so a user-re-pin that happens
|
||||
// to equal a stale default value is not silently stripped on every subsequent launch.
|
||||
if (provider === 'claude' && !claudeModelMigrationDone()) {
|
||||
if (migrateClaudeStaleModelPins(mergedEnv)) {
|
||||
mutated = true;
|
||||
}
|
||||
markClaudeModelMigrationDone();
|
||||
}
|
||||
|
||||
// claude is model-neutral: only transport keys (URL + auth) are required; model pins are omitted.
|
||||
const requiredKeys =
|
||||
provider === 'claude' ? REQUIRED_CLAUDE_ENV_KEYS : REQUIRED_PROVIDER_ENV_KEYS;
|
||||
for (const key of requiredKeys) {
|
||||
const current = mergedEnv[key];
|
||||
if (typeof current !== 'string' || current.trim().length === 0) {
|
||||
mergedEnv[key] = defaultEnv[key] || '';
|
||||
mutated = true;
|
||||
const fallback = defaultEnv[key];
|
||||
if (fallback) {
|
||||
mergedEnv[key] = fallback;
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -680,6 +803,21 @@ export function getRemoteEnvVars(
|
||||
migrateDeprecatedModelNames(settingsPath, provider, settings);
|
||||
migrateIFlowPlaceholderModel(settingsPath, provider, settings);
|
||||
userEnvVars = settings.env as Record<string, string>;
|
||||
// claude is model-neutral. The local launch path runs the one-time
|
||||
// stale-pin migration (ensureProviderSettings), but the remote path
|
||||
// never rewrites the file, so a remote-only user whose settings still
|
||||
// carry an old auto-written default would stay pinned. Filter stale
|
||||
// defaults at read level so values equal to a historical default are
|
||||
// dropped while user-custom pins survive. Once the migration marker
|
||||
// exists the file has already been cleaned, so any pin present after
|
||||
// that is user-intentional (e.g. an explicit `ccs claude --config`
|
||||
// pick that happens to equal a historical default) and must NOT be
|
||||
// filtered. Priority 1 (explicit custom settings path) is
|
||||
// intentionally left untouched: an explicitly passed settings file
|
||||
// is the user's deliberate choice.
|
||||
if (provider === 'claude' && !claudeModelMigrationDone()) {
|
||||
userEnvVars = filterClaudeStaleModelPins(userEnvVars);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON - fall through to base config
|
||||
@@ -689,7 +827,6 @@ export function getRemoteEnvVars(
|
||||
|
||||
// Priority 3: Base config defaults
|
||||
if (Object.keys(userEnvVars).length === 0) {
|
||||
const models = getModelMapping(provider);
|
||||
const baseEnvVars = getEnvVarsFromConfig(provider);
|
||||
// Filter out URL/auth from base config (we'll set those from remote config)
|
||||
const {
|
||||
@@ -697,13 +834,25 @@ export function getRemoteEnvVars(
|
||||
ANTHROPIC_AUTH_TOKEN: _authToken,
|
||||
...additionalEnvVars
|
||||
} = baseEnvVars;
|
||||
userEnvVars = {
|
||||
...additionalEnvVars,
|
||||
ANTHROPIC_MODEL: models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel,
|
||||
};
|
||||
// claude is model-neutral: omit model pins so Claude Code's own /model
|
||||
// selection is respected on remote launches too.
|
||||
if (provider === 'claude') {
|
||||
// Filter out undefined values coming from NodeJS.ProcessEnv spread.
|
||||
userEnvVars = Object.fromEntries(
|
||||
Object.entries(additionalEnvVars).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === 'string'
|
||||
)
|
||||
);
|
||||
} else {
|
||||
const models = getModelMapping(provider);
|
||||
userEnvVars = {
|
||||
...additionalEnvVars,
|
||||
ANTHROPIC_MODEL: models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Build final env: global + user settings + remote URL/auth override
|
||||
|
||||
@@ -44,8 +44,9 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs';
|
||||
* v17: Persist routing.strategy from CCS unified config
|
||||
* v18: Persist routing.session-affinity and routing.session-affinity-ttl from CCS unified config
|
||||
* v19: Persist backend-aware management panel repository from CCS unified config
|
||||
* v20: Pool-gated cooling/routing/retry-cap block; disable-cooling flips to false for pool users
|
||||
*/
|
||||
export const CLIPROXY_CONFIG_VERSION = 19;
|
||||
export const CLIPROXY_CONFIG_VERSION = 20;
|
||||
|
||||
export const ORIGINAL_MANAGEMENT_PANEL_REPOSITORY =
|
||||
'https://github.com/router-for-me/Cli-Proxy-API-Management-Center';
|
||||
@@ -138,6 +139,45 @@ function getLoggingSettings(): { loggingToFile: boolean; requestLog: boolean } {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether pool routing is enabled in the CCS unified config.
|
||||
*
|
||||
* Cooling archaeology (CCS v5, commit fb77d72a, Jan 26 2026):
|
||||
* disable-cooling: true was added for stability because single-account users
|
||||
* hit a blackout cliff when their only credential entered cooldown. Two upstream
|
||||
* bugs compounded the issue:
|
||||
* 1. Transient-error blackout (fixed upstream Jan 21 2026, commit 30a59168)
|
||||
* 2. 401/402/403/404 ignoring the disable-cooling flag entirely
|
||||
* (fixed upstream Apr 7 2026, commit 0ea76801)
|
||||
*
|
||||
* The concern no longer applies on current CLIProxy versions (fallback 6.9.45,
|
||||
* which postdates both fixes). For pool users (2+ accounts per provider)
|
||||
* cooling-ON is the correct behavior: a suspended credential rotates out and
|
||||
* a healthy one takes over, which is the pool-routing value proposition.
|
||||
* For single-account users the original stability concern still holds, so
|
||||
* disable-cooling: true is preserved when pool routing is disabled.
|
||||
*
|
||||
* Per-auth metadata override cannot be used to enable cooling — the upstream
|
||||
* override is "true-only" (types.go:384-404): a false per-auth value falls back
|
||||
* to the global flag. The flip must be at the top-level config key.
|
||||
*
|
||||
* Retry-cap note: max-retry-credentials is ONLY valid with cooling ON.
|
||||
* Without cooling, a just-exhausted credential is still "available" on the
|
||||
* next request; retry-cap would not prevent re-targeting it.
|
||||
*/
|
||||
function isPoolRoutingEnabled(): boolean {
|
||||
return loadOrCreateUnifiedConfig().cliproxy?.pool_routing?.enabled === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get max-retry-credentials for pool routing config.
|
||||
* Only consulted when pool routing is enabled.
|
||||
* Defaults to 3 (try up to 3 credentials before returning 429 to caller).
|
||||
*/
|
||||
function getPoolMaxRetryCredentials(): number {
|
||||
return loadOrCreateUnifiedConfig().cliproxy?.pool_routing?.max_retry_credentials ?? 3;
|
||||
}
|
||||
|
||||
function getRoutingStrategy(): 'round-robin' | 'fill-first' {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
return config.cliproxy?.routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin';
|
||||
@@ -615,6 +655,7 @@ function generateUnifiedConfigContent(
|
||||
|
||||
// Get logging settings from user config (disabled by default)
|
||||
const { loggingToFile, requestLog } = getLoggingSettings();
|
||||
const poolEnabled = isPoolRoutingEnabled();
|
||||
const routingStrategy = getRoutingStrategy();
|
||||
const sessionAffinityEnabled = getSessionAffinityEnabled();
|
||||
const sessionAffinityTtl = getSessionAffinityTtl();
|
||||
@@ -632,6 +673,29 @@ function generateUnifiedConfigContent(
|
||||
);
|
||||
const apiKeysYaml = allApiKeys.map((key) => ` - "${key}"`).join('\n');
|
||||
|
||||
// Pool routing block: emitted only when pool routing is enabled.
|
||||
// When pool routing is off, disable-cooling stays true (v5 stability default).
|
||||
// See isPoolRoutingEnabled() and the cooling archaeology comment above it.
|
||||
//
|
||||
// Hot-reload note: CLIProxy watches config for changes and hot-reloads it
|
||||
// (server.go calls auth.SetQuotaCooldownDisabled on config update).
|
||||
// Changing pool routing state writes a new config; CLIProxy will pick it up
|
||||
// live and evict all SessionAffinity pins on the next request (one recompute
|
||||
// per conversation). A restart is not required.
|
||||
const poolMaxRetry = poolEnabled ? getPoolMaxRetryCredentials() : null;
|
||||
const disableCoolingValue = poolEnabled ? 'false' : 'true';
|
||||
const coolingComment = poolEnabled
|
||||
? '# Pool routing enabled: cooling ON so exhausted accounts enter backoff and rotate out.\n# First 429 gets a 1s backoff (exponential to 30m cap); Retry-After header is honored.\n# Retry-cap below stops burn loops from retrying already-known-bad credentials.'
|
||||
: '# Disable quota cooldown scheduling for stability.\n# Pool routing is off: cooling stays disabled to prevent single-account blackouts.\n# Re-enabled automatically when pool routing is turned on (ccs cliproxy pool --enable).';
|
||||
const poolRoutingBlock = poolEnabled
|
||||
? `\n# Max credentials to try per request before returning 429 to caller.\n# ONLY valid with cooling on (above). Without cooling a just-exhausted credential\n# remains "available" and retry-cap would not prevent re-targeting it.\nmax-retry-credentials: ${poolMaxRetry}\n`
|
||||
: '';
|
||||
const routingBlock = `# Credential selection strategy when multiple matching accounts are available
|
||||
routing:
|
||||
strategy: ${poolEnabled ? 'fill-first' : routingStrategy}
|
||||
session-affinity: ${poolEnabled ? 'true' : sessionAffinityEnabled}
|
||||
session-affinity-ttl: "${poolEnabled ? '1h' : sessionAffinityTtl}"`;
|
||||
|
||||
// Unified config with enhanced CLIProxyAPI features
|
||||
const config = `# CLIProxyAPI config generated by CCS v${CLIPROXY_CONFIG_VERSION}
|
||||
# Supports: gemini, codex, agy, qwen, iflow (concurrent usage)
|
||||
@@ -683,24 +747,20 @@ remote-management:
|
||||
# Reliability & Quota Management
|
||||
# =============================================================================
|
||||
|
||||
# Disable quota cooldown scheduling for stability
|
||||
disable-cooling: true
|
||||
${coolingComment}
|
||||
disable-cooling: ${disableCoolingValue}
|
||||
|
||||
# Auto-retry on transient errors (403, 408, 500, 502, 503, 504)
|
||||
request-retry: 0
|
||||
max-retry-interval: 0
|
||||
|
||||
${poolRoutingBlock}
|
||||
# Auto-switch accounts on quota exceeded (429)
|
||||
# This enables seamless multi-account rotation when rate limited
|
||||
quota-exceeded:
|
||||
switch-project: true
|
||||
switch-preview-model: true
|
||||
|
||||
# Credential selection strategy when multiple matching accounts are available
|
||||
routing:
|
||||
strategy: ${routingStrategy}
|
||||
session-affinity: ${sessionAffinityEnabled}
|
||||
session-affinity-ttl: "${sessionAffinityTtl}"
|
||||
${routingBlock}
|
||||
|
||||
# =============================================================================
|
||||
# Authentication
|
||||
|
||||
@@ -63,6 +63,7 @@ function shouldShowWarnings(thinkingConfig: ThinkingConfig): boolean {
|
||||
*/
|
||||
export function detectTierFromModel(modelName: string): ModelTier {
|
||||
const lower = modelName.toLowerCase();
|
||||
if (lower.includes('fable')) return 'opus'; // Fable is the top tier; thinking defaults match Opus
|
||||
if (lower.includes('opus')) return 'opus';
|
||||
if (lower.includes('haiku')) return 'haiku';
|
||||
return 'sonnet'; // Default to sonnet (most common)
|
||||
|
||||
@@ -484,4 +484,17 @@ describe('ensureModelConfiguration', () => {
|
||||
await ensureModelConfiguration('codex', cfg, false);
|
||||
expect(mockReconcileCodexModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// claude is model-neutral passthrough — must never auto-prompt at launch
|
||||
it('claude non-composite → configureProviderModel NOT called (model-neutral)', async () => {
|
||||
const cfg = { isComposite: false, customSettingsPath: undefined } as ExecutorConfig;
|
||||
await ensureModelConfiguration('claude', cfg, false);
|
||||
expect(mockConfigureProviderModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('claude composite → configureProviderModel NOT called', async () => {
|
||||
const cfg = { isComposite: true } as ExecutorConfig;
|
||||
await ensureModelConfiguration('claude', cfg, false);
|
||||
expect(mockConfigureProviderModel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ function makeBrowserConfig(enabled = false, policy: 'auto' | 'always' | 'never'
|
||||
|
||||
describe('resolveBrowserLaunchFlags — no browser flags', () => {
|
||||
it('returns undefined override and passes args through unchanged', async () => {
|
||||
mock.module('../../utils/browser', () => ({
|
||||
mock.module('../../../utils/browser', () => ({
|
||||
appendBrowserToolArgs: (a: string[]) => a,
|
||||
resolveBrowserLaunchFlagResolution: (_args: string[]) => ({
|
||||
override: undefined,
|
||||
@@ -40,7 +40,7 @@ describe('resolveBrowserLaunchFlags — no browser flags', () => {
|
||||
resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: undefined }),
|
||||
syncBrowserMcpToConfigDir: () => true,
|
||||
}));
|
||||
mock.module('../../../config/unified-config-loader', () => ({
|
||||
mock.module('../../../config/config-loader-facade', () => ({
|
||||
getBrowserConfig: () => makeBrowserConfig(false),
|
||||
loadOrCreateUnifiedConfig: () => ({}),
|
||||
getThinkingConfig: () => ({}),
|
||||
@@ -58,7 +58,7 @@ describe('resolveBrowserLaunchFlags — no browser flags', () => {
|
||||
|
||||
describe('resolveBrowserLaunchFlags — with browser-launch override', () => {
|
||||
it('returns override and strips the browser flag from args', async () => {
|
||||
mock.module('../../utils/browser', () => ({
|
||||
mock.module('../../../utils/browser', () => ({
|
||||
appendBrowserToolArgs: (a: string[]) => a,
|
||||
resolveBrowserLaunchFlagResolution: (_args: string[]) => ({
|
||||
override: 'force-enable' as const,
|
||||
@@ -71,7 +71,7 @@ describe('resolveBrowserLaunchFlags — with browser-launch override', () => {
|
||||
resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: undefined }),
|
||||
syncBrowserMcpToConfigDir: () => true,
|
||||
}));
|
||||
mock.module('../../../config/unified-config-loader', () => ({
|
||||
mock.module('../../../config/config-loader-facade', () => ({
|
||||
getBrowserConfig: () => makeBrowserConfig(true, 'auto'),
|
||||
loadOrCreateUnifiedConfig: () => ({}),
|
||||
getThinkingConfig: () => ({}),
|
||||
@@ -98,7 +98,7 @@ describe('resolveBrowserLaunchFlags — blocked override warning', () => {
|
||||
});
|
||||
|
||||
it('emits warn() when getBlockedBrowserOverrideWarning returns a message', async () => {
|
||||
mock.module('../../utils/browser', () => ({
|
||||
mock.module('../../../utils/browser', () => ({
|
||||
appendBrowserToolArgs: (a: string[]) => a,
|
||||
resolveBrowserLaunchFlagResolution: (args: string[]) => ({
|
||||
override: 'force-enable' as const,
|
||||
@@ -111,7 +111,7 @@ describe('resolveBrowserLaunchFlags — blocked override warning', () => {
|
||||
resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: undefined }),
|
||||
syncBrowserMcpToConfigDir: () => true,
|
||||
}));
|
||||
mock.module('../../../config/unified-config-loader', () => ({
|
||||
mock.module('../../../config/config-loader-facade', () => ({
|
||||
getBrowserConfig: () => makeBrowserConfig(false, 'never'),
|
||||
loadOrCreateUnifiedConfig: () => ({}),
|
||||
getThinkingConfig: () => ({}),
|
||||
@@ -128,7 +128,7 @@ describe('resolveBrowserLaunchFlags — blocked override warning', () => {
|
||||
|
||||
describe('resolveBrowserRuntime — attach disabled', () => {
|
||||
it('returns undefined browserRuntimeEnv when browser attach is disabled', async () => {
|
||||
mock.module('../../utils/browser', () => ({
|
||||
mock.module('../../../utils/browser', () => ({
|
||||
appendBrowserToolArgs: (a: string[]) => a,
|
||||
resolveBrowserLaunchFlagResolution: (a: string[]) => ({
|
||||
override: undefined,
|
||||
@@ -141,7 +141,7 @@ describe('resolveBrowserRuntime — attach disabled', () => {
|
||||
resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: undefined }),
|
||||
syncBrowserMcpToConfigDir: () => true,
|
||||
}));
|
||||
mock.module('../../../config/unified-config-loader', () => ({
|
||||
mock.module('../../../config/config-loader-facade', () => ({
|
||||
getBrowserConfig: () => makeBrowserConfig(false),
|
||||
loadOrCreateUnifiedConfig: () => ({}),
|
||||
getThinkingConfig: () => ({}),
|
||||
@@ -158,7 +158,7 @@ describe('resolveBrowserRuntime — attach disabled', () => {
|
||||
describe('resolveBrowserRuntime — active runtime env', () => {
|
||||
it('returns runtimeEnv when browser attach resolves successfully', async () => {
|
||||
const fakeRuntimeEnv = { CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1:9222/json' };
|
||||
mock.module('../../utils/browser', () => ({
|
||||
mock.module('../../../utils/browser', () => ({
|
||||
appendBrowserToolArgs: (a: string[]) => a,
|
||||
resolveBrowserLaunchFlagResolution: (a: string[]) => ({
|
||||
override: 'force-enable' as const,
|
||||
@@ -171,7 +171,7 @@ describe('resolveBrowserRuntime — active runtime env', () => {
|
||||
resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: fakeRuntimeEnv }),
|
||||
syncBrowserMcpToConfigDir: () => true,
|
||||
}));
|
||||
mock.module('../../../config/unified-config-loader', () => ({
|
||||
mock.module('../../../config/config-loader-facade', () => ({
|
||||
getBrowserConfig: () => makeBrowserConfig(true, 'always'),
|
||||
loadOrCreateUnifiedConfig: () => ({}),
|
||||
getThinkingConfig: () => ({}),
|
||||
|
||||
@@ -34,7 +34,7 @@ mock.module('child_process', () => ({
|
||||
const mockEscapeShellArg = jest.fn((s: string) => `"${s}"`);
|
||||
const mockGetWindowsEscapedCommandShell = jest.fn().mockReturnValue('cmd.exe');
|
||||
|
||||
mock.module('../../utils/shell-executor', () => ({
|
||||
mock.module('../../../utils/shell-executor', () => ({
|
||||
escapeShellArg: mockEscapeShellArg,
|
||||
getWindowsEscapedCommandShell: mockGetWindowsEscapedCommandShell,
|
||||
}));
|
||||
@@ -46,7 +46,7 @@ mock.module('../../config/config-generator', () => ({
|
||||
getProviderConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
mock.module('../../utils/websearch-manager', () => ({
|
||||
mock.module('../../../utils/websearch-manager', () => ({
|
||||
appendThirdPartyWebSearchToolArgs: (args: string[]) => args,
|
||||
createWebSearchTraceContext: jest.fn().mockReturnValue({}),
|
||||
ensureWebSearchMcpOrThrow: jest.fn(),
|
||||
@@ -54,17 +54,17 @@ mock.module('../../utils/websearch-manager', () => ({
|
||||
getWebSearchHookEnv: jest.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
mock.module('../../utils/image-analysis', () => ({
|
||||
mock.module('../../../utils/image-analysis', () => ({
|
||||
appendThirdPartyImageAnalysisToolArgs: (args: string[]) => [...args, '--mcp-image-analysis'],
|
||||
syncImageAnalysisMcpToConfigDir: jest.fn(),
|
||||
ensureImageAnalysisMcpOrThrow: jest.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
mock.module('../../utils/browser', () => ({
|
||||
mock.module('../../../utils/browser', () => ({
|
||||
appendBrowserToolArgs: (args: string[]) => [...args, '--browser'],
|
||||
}));
|
||||
|
||||
mock.module('../accounts/account-manager', () => ({
|
||||
mock.module('../../accounts/account-manager', () => ({
|
||||
getDefaultAccount: jest.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
@@ -82,7 +82,7 @@ mock.module('../account-resolution', () => ({
|
||||
}));
|
||||
|
||||
// Dynamic import for quota-manager
|
||||
mock.module('../quota/quota-manager', () => ({
|
||||
mock.module('../../quota/quota-manager', () => ({
|
||||
startQuotaMonitor: jest.fn(),
|
||||
stopQuotaMonitor: jest.fn(),
|
||||
}));
|
||||
@@ -202,7 +202,7 @@ describe('launchClaude', () => {
|
||||
|
||||
it('uses shell mode for .cmd executables on Windows', async () => {
|
||||
await launchClaude(baseContext({ claudeCli: 'C:\\tools\\claude.cmd' }));
|
||||
const spawnOpts = mockSpawn.mock.calls[0][2] as { shell: string | boolean };
|
||||
const spawnOpts = mockSpawn.mock.calls[0][1] as { shell: string | boolean };
|
||||
// shell property should be set (cmd.exe from mock)
|
||||
expect(spawnOpts.shell).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -65,6 +65,13 @@ describe('detectTierFromModel', () => {
|
||||
expect(result).toBe('sonnet');
|
||||
});
|
||||
|
||||
it('should map Claude Fable 5 to the opus tier', () => {
|
||||
// Fable is the top tier above Opus; auto-mode thinking defaults should
|
||||
// match the opus tier (effort high), not the sonnet fallback.
|
||||
const result = detectTierFromModel('claude-fable-5');
|
||||
expect(result).toBe('opus');
|
||||
});
|
||||
|
||||
it('should detect haiku from model name containing "haiku"', () => {
|
||||
const result = detectTierFromModel('claude-haiku-4-5-20251001');
|
||||
expect(result).toBe('haiku');
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* silent for healthy ones, covering both simple and composite providers.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test';
|
||||
import { afterEach, beforeEach, describe, expect, it, jest, mock } from 'bun:test';
|
||||
|
||||
// ── Stubs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -23,26 +23,21 @@ const mockGetSuggestedReplacementModel = jest
|
||||
.fn<string | undefined, [string, string]>()
|
||||
.mockReturnValue(undefined);
|
||||
|
||||
jest.mock('../model-warnings', () => {
|
||||
// We test the real implementation — only mock its dependencies
|
||||
return jest.requireActual('../model-warnings');
|
||||
});
|
||||
|
||||
jest.mock('../../config/model-config', () => ({
|
||||
mock.module('../../config/model-config', () => ({
|
||||
getCurrentModel: mockGetCurrentModel,
|
||||
}));
|
||||
|
||||
jest.mock('../../cliproxy/model-catalog', () => ({
|
||||
mock.module('../../model-catalog', () => ({
|
||||
isModelBroken: mockIsModelBroken,
|
||||
getModelIssueUrl: mockGetModelIssueUrl,
|
||||
findModel: mockFindModel,
|
||||
getSuggestedReplacementModel: mockGetSuggestedReplacementModel,
|
||||
}));
|
||||
|
||||
// We import from real path after jest.mock so actual module is used
|
||||
import { warnBrokenModels } from '../model-warnings';
|
||||
import type { ExecutorConfig } from '../../types';
|
||||
|
||||
const { warnBrokenModels } = await import('../model-warnings');
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeSimpleCfg(overrides: Partial<ExecutorConfig> = {}): ExecutorConfig {
|
||||
|
||||
@@ -150,6 +150,7 @@ export async function resolveAccounts(
|
||||
}
|
||||
}
|
||||
process.exit(1);
|
||||
return { earlyExit: true };
|
||||
}
|
||||
setDefaultAccount(provider, account.id);
|
||||
touchAccount(provider, account.id);
|
||||
@@ -166,6 +167,7 @@ export async function resolveAccounts(
|
||||
console.error(fail(`No account found for ${providerConfig.displayName}`));
|
||||
console.error(` Run "ccs ${provider} --auth" to add an account first`);
|
||||
process.exit(1);
|
||||
return { earlyExit: true };
|
||||
}
|
||||
try {
|
||||
const success = renameAccount(provider, defaultAccount.id, setNickname);
|
||||
@@ -174,12 +176,15 @@ export async function resolveAccounts(
|
||||
} else {
|
||||
console.error(fail('Failed to rename account'));
|
||||
process.exit(1);
|
||||
return { earlyExit: true };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(fail(err instanceof Error ? err.message : 'Failed to rename account'));
|
||||
process.exit(1);
|
||||
return { earlyExit: true };
|
||||
}
|
||||
process.exit(0);
|
||||
return { earlyExit: true };
|
||||
}
|
||||
|
||||
return { earlyExit: false };
|
||||
|
||||
@@ -369,13 +369,17 @@ export function runAccountSafetyGuards(
|
||||
* Ensure provider model is configured on first run.
|
||||
* Skipped for composite variants and remote proxy mode.
|
||||
* Also reconciles Codex model for active plan.
|
||||
*
|
||||
* claude is model-neutral passthrough: the user's own /model selection inside
|
||||
* Claude Code governs which model is used, so no auto-prompt at launch.
|
||||
* Use `ccs claude --config` for an explicit pin opt-in.
|
||||
*/
|
||||
export async function ensureModelConfiguration(
|
||||
provider: CLIProxyProvider,
|
||||
cfg: ExecutorConfig,
|
||||
verbose: boolean
|
||||
): Promise<void> {
|
||||
if (!cfg.isComposite && supportsModelConfig(provider)) {
|
||||
if (!cfg.isComposite && provider !== 'claude' && supportsModelConfig(provider)) {
|
||||
await configureProviderModel(provider, false, cfg.customSettingsPath);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ import { resolveExecutorProxy, resolveExecutorProxyConfig } from './proxy-resolv
|
||||
import { buildProxyChain } from './proxy-chain-builder';
|
||||
import { warnBrokenModels } from './model-warnings';
|
||||
import { launchClaude } from './claude-launcher';
|
||||
import { maybeWarnClaudeShadow, maybeShowClaudeRoutingNotice } from '../claude-shadow-warning';
|
||||
|
||||
/** Local alias so internal call sites need no change */
|
||||
const resolveRuntimeQuotaMonitorProviders = _resolveRuntimeQuotaMonitorProviders;
|
||||
@@ -161,6 +162,11 @@ export async function execClaudeWithCLIProxy(
|
||||
const providerConfig = getProviderConfig(provider);
|
||||
log(`Provider: ${providerConfig.displayName}`);
|
||||
|
||||
// claude built-in: warn once if a user profile is being shadowed
|
||||
if (provider === 'claude') {
|
||||
maybeWarnClaudeShadow();
|
||||
}
|
||||
|
||||
// Variables for local proxy mode
|
||||
let sessionId: string | undefined;
|
||||
|
||||
@@ -224,6 +230,17 @@ export async function execClaudeWithCLIProxy(
|
||||
console.error(` Use "ccs cliproxy edit ${variantName}" to modify composite variants`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
// Run the one-time stale-pin migration on the pre-existing settings file
|
||||
// BEFORE writing the user's chosen pin. ensureProviderSettingsFile sets the
|
||||
// migration marker, so the explicit --config pick survives the next launch
|
||||
// even when it equals a historical default. Without this, the --config flow
|
||||
// exits before the only ensureProviderSettingsFile call site (later in this
|
||||
// function), so a later plain launch would strip the just-written pin.
|
||||
// Skipped for custom-settings variants: those have no claude.settings
|
||||
// migration and are written verbatim.
|
||||
if (!cfg.customSettingsPath) {
|
||||
ensureProviderSettingsFile(provider);
|
||||
}
|
||||
await configureProviderModel(provider, true, cfg.customSettingsPath);
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -279,6 +296,11 @@ export async function execClaudeWithCLIProxy(
|
||||
// 5. Check for broken models (multi-tier for composite)
|
||||
warnBrokenModels({ provider, cfg, compositeProviders, skipLocalAuth });
|
||||
|
||||
// 5a. claude built-in: one-time routing notice (first launch only)
|
||||
if (provider === 'claude') {
|
||||
maybeShowClaudeRoutingNotice();
|
||||
}
|
||||
|
||||
// 6. Ensure user settings file exists
|
||||
ensureProviderSettingsFile(provider);
|
||||
|
||||
|
||||
@@ -369,6 +369,21 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
displayName: 'Claude (Anthropic)',
|
||||
defaultModel: 'claude-sonnet-4-6',
|
||||
models: [
|
||||
{
|
||||
id: 'claude-fable-5',
|
||||
name: 'Claude Fable 5',
|
||||
description: 'Most powerful model',
|
||||
nativeImageInput: true,
|
||||
// New tier above Opus. Same adaptive-thinking surface as Opus 4.8:
|
||||
// Anthropic accepts only effort levels; manual budget_tokens is rejected with 400.
|
||||
thinking: {
|
||||
type: 'levels',
|
||||
levels: ['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
maxLevel: 'max',
|
||||
dynamicAllowed: true,
|
||||
},
|
||||
extendedContext: true,
|
||||
},
|
||||
{
|
||||
id: 'claude-opus-4-8',
|
||||
name: 'Claude Opus 4.8',
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Unit tests for attachUpstreamResponseTimeout
|
||||
*
|
||||
* The stubs mirror Node's IncomingMessage.setTimeout semantics, which delegate
|
||||
* to this.socket.setTimeout() without a null guard (node:_http_incoming). The
|
||||
* published CLI runs under Node, where keep-alive agents detach the socket from
|
||||
* the response before user 'end' listeners run — Bun keeps the socket attached,
|
||||
* so a real-socket integration test cannot reproduce the Node-only crash.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import * as http from 'http';
|
||||
import {
|
||||
attachUpstreamResponseTimeout,
|
||||
UPSTREAM_RESPONSE_TIMEOUT_MESSAGE,
|
||||
} from '../upstream-response-timeout';
|
||||
|
||||
interface UpstreamResStub {
|
||||
socket: { setTimeout: (ms: number) => void } | null;
|
||||
headers: http.IncomingHttpHeaders;
|
||||
destroyedWith: Error | undefined;
|
||||
setTimeout: (ms: number, callback?: () => void) => UpstreamResStub;
|
||||
destroy: (error?: Error) => void;
|
||||
fireTimeout: () => void;
|
||||
socketTimeoutCalls: number[];
|
||||
}
|
||||
|
||||
function createUpstreamResStub(): UpstreamResStub {
|
||||
const socketTimeoutCalls: number[] = [];
|
||||
let timeoutCallback: (() => void) | undefined;
|
||||
|
||||
const stub: UpstreamResStub = {
|
||||
socket: {
|
||||
setTimeout(ms: number) {
|
||||
socketTimeoutCalls.push(ms);
|
||||
},
|
||||
},
|
||||
headers: {},
|
||||
destroyedWith: undefined,
|
||||
setTimeout(ms: number, callback?: () => void) {
|
||||
if (callback) timeoutCallback = callback;
|
||||
// Intentionally unguarded, matching node:_http_incoming.
|
||||
stub.socket!.setTimeout(ms);
|
||||
return stub;
|
||||
},
|
||||
destroy(error?: Error) {
|
||||
stub.destroyedWith = error;
|
||||
},
|
||||
fireTimeout() {
|
||||
timeoutCallback?.();
|
||||
},
|
||||
socketTimeoutCalls,
|
||||
};
|
||||
|
||||
return stub;
|
||||
}
|
||||
|
||||
function createUpstreamReqStub() {
|
||||
return {
|
||||
destroyedWith: undefined as Error | undefined,
|
||||
destroy(error?: Error) {
|
||||
this.destroyedWith = error;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createClientResStub() {
|
||||
return {
|
||||
headersSent: false,
|
||||
destroyed: false,
|
||||
writableEnded: false,
|
||||
statusCode: undefined as number | undefined,
|
||||
body: '',
|
||||
writeHead(statusCode: number) {
|
||||
this.statusCode = statusCode;
|
||||
this.headersSent = true;
|
||||
return this;
|
||||
},
|
||||
write(chunk: string) {
|
||||
this.body += chunk;
|
||||
return true;
|
||||
},
|
||||
end() {
|
||||
this.writableEnded = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function attach(
|
||||
upstreamRes: UpstreamResStub,
|
||||
upstreamReq = createUpstreamReqStub(),
|
||||
clientRes = createClientResStub()
|
||||
) {
|
||||
const clear = attachUpstreamResponseTimeout({
|
||||
upstreamReq: upstreamReq as unknown as http.ClientRequest,
|
||||
upstreamRes: upstreamRes as unknown as http.IncomingMessage,
|
||||
clientRes: clientRes as unknown as http.ServerResponse,
|
||||
timeoutMs: 1_000,
|
||||
});
|
||||
return { clear, upstreamReq, clientRes };
|
||||
}
|
||||
|
||||
describe('attachUpstreamResponseTimeout', () => {
|
||||
it('arms the socket timeout on attach and clears it while the socket is attached', () => {
|
||||
const upstreamRes = createUpstreamResStub();
|
||||
const { clear } = attach(upstreamRes);
|
||||
|
||||
expect(upstreamRes.socketTimeoutCalls).toEqual([1_000]);
|
||||
|
||||
clear();
|
||||
|
||||
expect(upstreamRes.socketTimeoutCalls).toEqual([1_000, 0]);
|
||||
});
|
||||
|
||||
it('does not throw when a keep-alive agent detached the socket before cleanup', () => {
|
||||
const upstreamRes = createUpstreamResStub();
|
||||
const { clear } = attach(upstreamRes);
|
||||
|
||||
// Node nulls res.socket when the agent reclaims the connection after 'end'.
|
||||
upstreamRes.socket = null;
|
||||
|
||||
expect(() => clear()).not.toThrow();
|
||||
});
|
||||
|
||||
it('writes a timeout response and destroys both sides when the timeout fires', () => {
|
||||
const upstreamRes = createUpstreamResStub();
|
||||
const { upstreamReq, clientRes } = attach(upstreamRes);
|
||||
|
||||
upstreamRes.fireTimeout();
|
||||
|
||||
expect(clientRes.statusCode).toBe(504);
|
||||
expect(clientRes.body).toContain(UPSTREAM_RESPONSE_TIMEOUT_MESSAGE);
|
||||
expect(upstreamRes.destroyedWith?.message).toBe(UPSTREAM_RESPONSE_TIMEOUT_MESSAGE);
|
||||
expect(upstreamReq.destroyedWith?.message).toBe(UPSTREAM_RESPONSE_TIMEOUT_MESSAGE);
|
||||
});
|
||||
|
||||
it('ignores a late timeout after clear() settled the response', () => {
|
||||
const upstreamRes = createUpstreamResStub();
|
||||
const { clear, upstreamReq, clientRes } = attach(upstreamRes);
|
||||
|
||||
clear();
|
||||
upstreamRes.fireTimeout();
|
||||
|
||||
expect(clientRes.statusCode).toBeUndefined();
|
||||
expect(clientRes.body).toBe('');
|
||||
expect(upstreamRes.destroyedWith).toBeUndefined();
|
||||
expect(upstreamReq.destroyedWith).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -70,7 +70,14 @@ export function attachUpstreamResponseTimeout(options: {
|
||||
|
||||
const clear = () => {
|
||||
settled = true;
|
||||
upstreamRes.setTimeout(0);
|
||||
// Under Node, keep-alive agents detach the socket from the IncomingMessage
|
||||
// before user 'end' listeners run, so upstreamRes.socket is null here and
|
||||
// Node's unguarded IncomingMessage.setTimeout() would throw an uncaught
|
||||
// TypeError that kills the whole proxy process. The detached socket has no
|
||||
// pending timer to clear (Agent#keepSocketAlive resets it), so skipping is safe.
|
||||
if (upstreamRes.socket) {
|
||||
upstreamRes.setTimeout(0);
|
||||
}
|
||||
};
|
||||
|
||||
upstreamRes.setTimeout(timeoutMs, () => {
|
||||
|
||||
@@ -622,6 +622,34 @@ describe('Claude Quota Fetcher', () => {
|
||||
expect(result.coreUsage?.fiveHour?.remainingPercent).toBe(60);
|
||||
});
|
||||
|
||||
it('does NOT inner-retry on 429; returns retryable single-attempt result honoring Retry-After', async () => {
|
||||
// Safety intent: 429 must NOT trigger an immediate, delay-free inner retry.
|
||||
// The outer 10-min cache + circuit breaker honor Retry-After and bound total
|
||||
// volume, so a single attempt is made and the retryable signal is surfaced.
|
||||
createClaudeAccount('claude-429@example.com', {
|
||||
access_token: 'rate-limited-token',
|
||||
expired: '2099-01-01T00:00:00.000Z',
|
||||
type: 'claude',
|
||||
});
|
||||
|
||||
let attempt = 0;
|
||||
global.fetch = mock(() => {
|
||||
attempt += 1;
|
||||
return Promise.resolve(
|
||||
new Response('', { status: 429, headers: { 'Retry-After': '120' } })
|
||||
);
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await fetchClaudeQuota('claude-429@example.com');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
// Single attempt — no inner retry burned on the 429.
|
||||
expect(attempt).toBe(1);
|
||||
expect(result.httpStatus).toBe(429);
|
||||
expect(result.retryable).toBe(true);
|
||||
expect(result.errorDetail).toBe('retry-after:120');
|
||||
});
|
||||
|
||||
it('clears the request timeout before retrying a retryable HTTP error', async () => {
|
||||
createClaudeAccount('claude-retry-timeout@example.com', {
|
||||
access_token: 'retry-timeout-token',
|
||||
|
||||
@@ -218,25 +218,19 @@ function buildEmptyResult(
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch quota for a single Claude account.
|
||||
* Run the Anthropic OAuth usage fetch loop for a known-good access token.
|
||||
*
|
||||
* This is the single Anthropic-call surface shared by both the CLIProxy-managed
|
||||
* path (fetchClaudeQuota) and the native-login path (fetchClaudeQuotaWithToken).
|
||||
* It owns the 401/403/404/429/5xx branch logic, the bounded retry loop, and the
|
||||
* window normalization so neither caller re-implements the hostile-endpoint
|
||||
* handling. The callers differ only in WHERE the token comes from.
|
||||
*/
|
||||
export async function fetchClaudeQuota(
|
||||
async function runClaudeUsageFetch(
|
||||
accessToken: string,
|
||||
accountId: string,
|
||||
verbose = false
|
||||
verbose: boolean
|
||||
): Promise<ClaudeQuotaResult> {
|
||||
const authData = await readClaudeAuthData(accountId);
|
||||
if (!authData) {
|
||||
return buildEmptyResult('Auth file not found for Claude account', accountId);
|
||||
}
|
||||
|
||||
if (authData.isExpired) {
|
||||
return buildEmptyResult(
|
||||
'Token expired - re-authenticate with ccs cliproxy auth claude',
|
||||
accountId,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
let lastError = 'Unknown error';
|
||||
|
||||
for (let attempt = 1; attempt <= CLAUDE_QUOTA_MAX_ATTEMPTS; attempt++) {
|
||||
@@ -248,7 +242,7 @@ export async function fetchClaudeQuota(
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Authorization: `Bearer ${authData.accessToken}`,
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-beta': CLAUDE_OAUTH_BETA_HEADER,
|
||||
@@ -283,15 +277,23 @@ export async function fetchClaudeQuota(
|
||||
lastError =
|
||||
(await readResponseErrorMessage(response)) ||
|
||||
`Claude OAuth usage API error: ${response.status}`;
|
||||
if (
|
||||
attempt < CLAUDE_QUOTA_MAX_ATTEMPTS &&
|
||||
(response.status === 429 || response.status >= 500)
|
||||
) {
|
||||
// Surface the upstream status + Retry-After so an outer caller (the
|
||||
// native collector's circuit-breaker) can honor backoff guidance.
|
||||
const retryAfter = response.headers.get('retry-after');
|
||||
// Do not inner-retry 429 with no delay; the outer cache + circuit breaker
|
||||
// honor Retry-After and bound total volume. Inner retry stays for
|
||||
// transient 5xx only.
|
||||
if (attempt < CLAUDE_QUOTA_MAX_ATTEMPTS && response.status >= 500) {
|
||||
clearTimeout(timeoutId);
|
||||
continue;
|
||||
}
|
||||
clearTimeout(timeoutId);
|
||||
return buildEmptyResult(lastError, accountId);
|
||||
return {
|
||||
...buildEmptyResult(lastError, accountId),
|
||||
httpStatus: response.status,
|
||||
retryable: response.status === 429 || response.status >= 500,
|
||||
...(retryAfter ? { errorDetail: `retry-after:${retryAfter}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
@@ -338,12 +340,54 @@ export async function fetchClaudeQuota(
|
||||
|
||||
if (attempt >= CLAUDE_QUOTA_MAX_ATTEMPTS) {
|
||||
clearTimeout(timeoutId);
|
||||
return buildEmptyResult(lastError, accountId);
|
||||
return { ...buildEmptyResult(lastError, accountId), retryable: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buildEmptyResult(lastError, accountId);
|
||||
return { ...buildEmptyResult(lastError, accountId), retryable: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch quota using a directly-supplied native OAuth access token.
|
||||
*
|
||||
* Reuses the exact Anthropic call + normalization as fetchClaudeQuota; the only
|
||||
* difference is the token source (the logged-in Claude Code credential rather
|
||||
* than a CLIProxy-managed auth file). Lives in this file so it can share the
|
||||
* file-private beta header, timeout, attempt count, and branch logic.
|
||||
*/
|
||||
export async function fetchClaudeQuotaWithToken(
|
||||
accessToken: string,
|
||||
accountId = 'claude-code',
|
||||
verbose = false
|
||||
): Promise<ClaudeQuotaResult> {
|
||||
if (!accessToken || accessToken.trim().length === 0) {
|
||||
return buildEmptyResult('Missing native Claude access token', accountId, true);
|
||||
}
|
||||
return runClaudeUsageFetch(accessToken.trim(), accountId, verbose);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch quota for a single Claude account.
|
||||
*/
|
||||
export async function fetchClaudeQuota(
|
||||
accountId: string,
|
||||
verbose = false
|
||||
): Promise<ClaudeQuotaResult> {
|
||||
const authData = await readClaudeAuthData(accountId);
|
||||
if (!authData) {
|
||||
return buildEmptyResult('Auth file not found for Claude account', accountId);
|
||||
}
|
||||
|
||||
if (authData.isExpired) {
|
||||
return buildEmptyResult(
|
||||
'Token expired - re-authenticate with ccs cliproxy auth claude',
|
||||
accountId,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
return runClaudeUsageFetch(authData.accessToken, accountId, verbose);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -835,11 +835,15 @@ export async function fetchAccountQuota(
|
||||
if (provider !== 'agy') {
|
||||
const error = `Quota not supported for provider: ${provider}`;
|
||||
if (verbose) console.error(`[!] Error: ${error}`);
|
||||
// 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',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
|
||||
import type { RuntimeMonitorConfig } from '../../config/unified-config-types';
|
||||
import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade';
|
||||
import { getTierLockForProvider } from '../../config/schemas/quota';
|
||||
|
||||
export type ManagedQuotaProvider = 'agy' | 'claude' | 'codex' | 'gemini' | 'ghcp';
|
||||
type ManagedQuotaResult =
|
||||
@@ -261,6 +262,26 @@ export function isOnCooldown(provider: CLIProxyProvider, accountId: string): boo
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the epoch-ms timestamp when an account's in-memory cooldown expires.
|
||||
* Returns undefined when the account is not on cooldown (or the cooldown has
|
||||
* already lapsed). This is the process-local cooldown; for the cross-process
|
||||
* source of truth see readQuotaCooldownEntries() in account-safety.
|
||||
*/
|
||||
export function getCooldownUntil(
|
||||
provider: CLIProxyProvider,
|
||||
accountId: string
|
||||
): number | undefined {
|
||||
const key = getCacheKey(provider, accountId);
|
||||
const entry = cooldownMap.get(key);
|
||||
if (!entry) return undefined;
|
||||
if (Date.now() > entry.until) {
|
||||
cooldownMap.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return entry.until;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply cooldown to an exhausted account
|
||||
*/
|
||||
@@ -428,10 +449,20 @@ export async function findHealthyAccount(
|
||||
|
||||
const accounts = getProviderAccounts(provider);
|
||||
|
||||
// When a tier is locked for this specific provider, restrict candidates to
|
||||
// that tier only. Locks are per-provider so locking "agy" to "ultra" does
|
||||
// NOT affect failover for "claude", "codex", "gemini", or "ghcp".
|
||||
// This is intentionally strict: no cross-tier fallback while a lock is active,
|
||||
// so the user's explicit tier choice is always honored.
|
||||
const tierLock = getTierLockForProvider(config.quota_management?.manual, provider);
|
||||
|
||||
// Filter available accounts
|
||||
const available = accounts.filter(
|
||||
(a) =>
|
||||
!exclude.includes(a.id) && !isAccountPaused(provider, a.id) && !isOnCooldown(provider, a.id)
|
||||
!exclude.includes(a.id) &&
|
||||
!isAccountPaused(provider, a.id) &&
|
||||
!isOnCooldown(provider, a.id) &&
|
||||
(tierLock === null || (a.tier || 'unknown') === tierLock)
|
||||
);
|
||||
|
||||
if (available.length === 0) return null;
|
||||
@@ -623,6 +654,28 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise<Prefli
|
||||
}
|
||||
}
|
||||
|
||||
// When a tier is locked for this specific provider, the default account must
|
||||
// match the locked tier. If it doesn't, route to a healthy account in the
|
||||
// locked tier instead. Locks are per-provider: locking "agy" to "ultra"
|
||||
// does NOT constrain "claude", "codex", "gemini", or "ghcp".
|
||||
// Graceful degradation: if no locked-tier account is available, fall through
|
||||
// to the default (don't block the request entirely).
|
||||
const tierLock = getTierLockForProvider(quotaConfig.manual, provider);
|
||||
if (tierLock !== null && (defaultAccount.tier || 'unknown') !== tierLock) {
|
||||
const lockedTierAccount = await findHealthyAccount(provider, []);
|
||||
if (lockedTierAccount) {
|
||||
setDefaultAccount(provider, lockedTierAccount.id);
|
||||
touchAccount(provider, lockedTierAccount.id);
|
||||
return {
|
||||
proceed: true,
|
||||
accountId: lockedTierAccount.id,
|
||||
switchedFrom: defaultAccount.id,
|
||||
reason: `Tier lock: selected ${tierLock} account`,
|
||||
};
|
||||
}
|
||||
// No locked-tier account available — fall through and use default
|
||||
}
|
||||
|
||||
// Check if default is paused
|
||||
if (isAccountPaused(provider, defaultAccount.id)) {
|
||||
return await findAndSwitch(provider, defaultAccount.id, 'Default account is paused');
|
||||
|
||||
@@ -285,4 +285,84 @@ describe('cliproxy routing strategy service', () => {
|
||||
expect(state.message).toContain('not reachable');
|
||||
});
|
||||
});
|
||||
|
||||
// PR #1514 fix index 2: for a remote target, readCliproxyRoutingState must NOT
|
||||
// present the local pool flag as if it described the remote proxy. It surfaces
|
||||
// manageable:false + a message, mirroring the session-affinity remote handling.
|
||||
it('marks remote pool routing as not manageable (local flag does not describe the remote proxy)', async () => {
|
||||
await withScopedConfig(async () => {
|
||||
routingTarget = {
|
||||
host: 'remote.example.com',
|
||||
port: 8080,
|
||||
protocol: 'http',
|
||||
isRemote: true,
|
||||
};
|
||||
responseFactory = async () =>
|
||||
new Response(JSON.stringify({ strategy: 'round-robin' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
// Enable the LOCAL pool flag — the remote proxy must still report not-manageable.
|
||||
const { mutateUnifiedConfig } = await import('../../../config/unified-config-loader');
|
||||
mutateUnifiedConfig((config) => {
|
||||
if (config.cliproxy) {
|
||||
config.cliproxy.pool_routing = { enabled: true, max_retry_credentials: 3 };
|
||||
}
|
||||
});
|
||||
|
||||
const mod = await loadRoutingModule();
|
||||
const state = await mod.readCliproxyRoutingState();
|
||||
|
||||
expect(state.target).toBe('remote');
|
||||
expect(state.poolRouting?.manageable).toBe(false);
|
||||
expect(state.poolRouting?.message).toContain('remote proxy');
|
||||
});
|
||||
});
|
||||
|
||||
// PR #1514 fix index 14 (backend): when local pool routing is enabled, the apply
|
||||
// result message must carry the pool-override note so API/dashboard consumers see
|
||||
// the same caveat the CLI prints.
|
||||
it('appends a pool-active override note to local strategy apply when pool routing is on', async () => {
|
||||
await withScopedConfig(async () => {
|
||||
const { mutateUnifiedConfig } = await import('../../../config/unified-config-loader');
|
||||
mutateUnifiedConfig((config) => {
|
||||
if (config.cliproxy) {
|
||||
config.cliproxy.pool_routing = { enabled: true, max_retry_credentials: 3 };
|
||||
}
|
||||
});
|
||||
|
||||
const mod = await loadRoutingModule();
|
||||
const result = await mod.applyCliproxyRoutingStrategy('round-robin');
|
||||
|
||||
expect(result.message).toContain('Pool routing is active');
|
||||
expect(result.message).toContain('ccs cliproxy pool --disable');
|
||||
});
|
||||
});
|
||||
|
||||
it('appends a pool-active override note to local affinity apply when pool routing is on', async () => {
|
||||
await withScopedConfig(async () => {
|
||||
const { mutateUnifiedConfig } = await import('../../../config/unified-config-loader');
|
||||
mutateUnifiedConfig((config) => {
|
||||
if (config.cliproxy) {
|
||||
config.cliproxy.pool_routing = { enabled: true, max_retry_credentials: 3 };
|
||||
}
|
||||
});
|
||||
|
||||
const mod = await loadRoutingModule();
|
||||
const result = await mod.applyCliproxySessionAffinitySettings({ enabled: true, ttl: '1h' });
|
||||
|
||||
expect(result.message).toContain('Pool routing is active');
|
||||
expect(result.message).toContain('ccs cliproxy pool --disable');
|
||||
});
|
||||
});
|
||||
|
||||
// Without pool routing, the apply message must NOT carry the override note.
|
||||
it('does not append the pool-override note when pool routing is off', async () => {
|
||||
await withScopedConfig(async () => {
|
||||
const mod = await loadRoutingModule();
|
||||
const result = await mod.applyCliproxyRoutingStrategy('fill-first');
|
||||
expect(result.message).not.toContain('Pool routing is active');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Pool onboarding hint
|
||||
*
|
||||
* Fires once (per install, TTY only) when a user has >= 2 native Claude account
|
||||
* profiles and has not yet enabled pool routing. Covers the #1464 use case:
|
||||
* existing users with multiple native profiles who have never touched CLIProxy
|
||||
* and would miss the 1->2 account-add opt-in prompt.
|
||||
*
|
||||
* Three hint sites call this module:
|
||||
* 1. ccs doctor (post-checks summary, fires for ALL install types)
|
||||
* 2. account-flow launch (print-only, pre-spawn) - unified config only
|
||||
* 3. ccs auth create (before spawning Claude for a 2nd+ profile) - unified config only
|
||||
*
|
||||
* Sites 2 and 3 are gated on hasUnifiedConfig() so that legacy profiles.json-only
|
||||
* installs receive the hint exclusively from ccs doctor (site 1). This ensures
|
||||
* once-per-install dismissal semantics for every install type: unified installs
|
||||
* dismiss via config.yaml on first show; legacy installs see the hint from
|
||||
* doctor only (which is the natural discovery surface for that population).
|
||||
*
|
||||
* Dismissal: stored in pool_routing.onboarding_hint_dismissed in config.yaml.
|
||||
* Reuses the Phase 3 pool_routing schema family - one schema home, no duplicate
|
||||
* plumbing. Legacy installs cannot persist a dismissal without config.yaml;
|
||||
* this is intentional - dismissal for legacy users happens via ccs migrate or
|
||||
* pool enable (both create config.yaml).
|
||||
*
|
||||
* TTY contract: non-TTY sessions never print the hint (single line only).
|
||||
* The hint is informational only - it never blocks the calling flow.
|
||||
*
|
||||
* History note: pooled sessions live in the CLIProxy lane, not the native
|
||||
* profile's CLAUDE_CONFIG_DIR lane. Transcripts and --continue inventory do
|
||||
* NOT follow. This is stated on the docs page linked in the hint copy.
|
||||
*/
|
||||
|
||||
import { info } from '../../utils/ui';
|
||||
import {
|
||||
loadOrCreateUnifiedConfig,
|
||||
mutateConfig,
|
||||
hasUnifiedConfig,
|
||||
} from '../../config/config-loader-facade';
|
||||
import type { UnifiedConfig } from '../../config/unified-config-types';
|
||||
import { ProfileRegistry } from '../../auth/profile-registry';
|
||||
|
||||
// Docs anchor - Phase 7 acceptance item: verify curl returns HTTP 200 before release.
|
||||
// Pattern matches canonical docs domain used in error-codes.ts and version-command.ts.
|
||||
export const POOL_DOCS_LINK = 'https://docs.ccs.kaitran.ca/features/pool';
|
||||
|
||||
/**
|
||||
* Whether the one-time pool onboarding hint has already been dismissed.
|
||||
*
|
||||
* @param config - Optional pre-loaded config to avoid a redundant disk read +
|
||||
* YAML parse. When omitted the config is loaded here.
|
||||
*/
|
||||
export function isOnboardingHintDismissed(config?: UnifiedConfig): boolean {
|
||||
const cfg = config ?? loadOrCreateUnifiedConfig();
|
||||
return cfg.cliproxy?.pool_routing?.onboarding_hint_dismissed === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the pool onboarding hint as permanently dismissed.
|
||||
* Written after first display so subsequent invocations are silent.
|
||||
*
|
||||
* Guard: skipped when config.yaml does not yet exist (legacy profiles.json-only
|
||||
* installs). Writing here would create config.yaml, flipping isUnifiedMode()
|
||||
* and silently mode-migrating the install outside the deliberate ccs migrate
|
||||
* flow. For those users the hint may re-appear across process restarts; that
|
||||
* is acceptable -- the hint is informational and non-blocking, and the user
|
||||
* can silence it permanently via ccs migrate.
|
||||
*/
|
||||
export function dismissOnboardingHint(): void {
|
||||
if (!hasUnifiedConfig()) {
|
||||
// Legacy-only install: skip persist to avoid implicit config.yaml creation.
|
||||
return;
|
||||
}
|
||||
mutateConfig((cfg) => {
|
||||
if (!cfg.cliproxy) return;
|
||||
cfg.cliproxy.pool_routing = {
|
||||
...cfg.cliproxy.pool_routing,
|
||||
onboarding_hint_dismissed: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether pool routing is currently enabled.
|
||||
*
|
||||
* @param config - Optional pre-loaded config to avoid a redundant disk read +
|
||||
* YAML parse. When omitted the config is loaded here.
|
||||
*/
|
||||
function isPoolEnabled(config?: UnifiedConfig): boolean {
|
||||
const cfg = config ?? loadOrCreateUnifiedConfig();
|
||||
return cfg.cliproxy?.pool_routing?.enabled === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count native Claude account profiles (type === 'account').
|
||||
*
|
||||
* Uses ProfileRegistry so the count respects both legacy profiles.json and
|
||||
* unified config accounts - same source of truth as the rest of the codebase.
|
||||
*
|
||||
* type === 'account' implies Claude: per the profile-registry schema, every
|
||||
* account profile is an isolated Claude instance (CLAUDE_CONFIG_DIR lane) with
|
||||
* no CLI discriminator. Gemini/Codex multi-account lives in CLIProxy auth
|
||||
* files, never in profiles.json, so this filter cannot over-count.
|
||||
*/
|
||||
export function countNativeClaudeProfiles(): number {
|
||||
try {
|
||||
const registry = new ProfileRegistry();
|
||||
const profiles = registry.getAllProfilesMerged();
|
||||
return Object.values(profiles).filter((p) => p.type === 'account').length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export interface OnboardingHintResult {
|
||||
/** Whether the hint was printed */
|
||||
printed: boolean;
|
||||
/** Why the hint was skipped (only set when printed === false) */
|
||||
skipReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maybe print the one-time pool onboarding hint.
|
||||
*
|
||||
* Skips silently when any of the following are true:
|
||||
* - Not a TTY (piped / CI / non-interactive)
|
||||
* - Fewer than 2 native Claude account profiles exist
|
||||
* - Pool routing is already enabled
|
||||
* - The hint was already dismissed
|
||||
* - Any error occurs while deciding (e.g. malformed config.yaml)
|
||||
*
|
||||
* Checks run cheapest-first: TTY and profile count are evaluated before any
|
||||
* config read, and the config is loaded exactly once for the two
|
||||
* config-derived checks. The entire decision is wrapped in try/catch so a
|
||||
* hint failure can never break an account launch.
|
||||
*
|
||||
* When the hint is printed for the first time it is also dismissed so it
|
||||
* never appears again (it is informational, not an interactive prompt).
|
||||
*
|
||||
* @param profileCount - Pre-computed profile count (pass undefined to compute
|
||||
* it here). The caller may pass a count it already has to avoid a second
|
||||
* registry read.
|
||||
*/
|
||||
export function maybeShowPoolOnboardingHint(profileCount?: number): OnboardingHintResult {
|
||||
// A hint must never break a launch. Any error in the decision path
|
||||
// (malformed config.yaml, registry read failure, etc.) silently skips the
|
||||
// hint rather than rethrowing into the caller's account-launch flow.
|
||||
try {
|
||||
// Cheapest checks first, no disk read required.
|
||||
// Non-TTY: stay silent (piped / CI / non-interactive).
|
||||
if (!process.stdout.isTTY) {
|
||||
return { printed: false, skipReason: 'non-tty' };
|
||||
}
|
||||
|
||||
const count = profileCount ?? countNativeClaudeProfiles();
|
||||
if (count < 2) {
|
||||
return { printed: false, skipReason: 'fewer-than-2-profiles' };
|
||||
}
|
||||
|
||||
// Load config once and reuse it for both config-derived checks below,
|
||||
// avoiding two disk reads + YAML parses per call.
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
|
||||
if (isPoolEnabled(config)) {
|
||||
return { printed: false, skipReason: 'pool-already-enabled' };
|
||||
}
|
||||
|
||||
if (isOnboardingHintDismissed(config)) {
|
||||
return { printed: false, skipReason: 'dismissed' };
|
||||
}
|
||||
|
||||
console.log(
|
||||
info(
|
||||
`You have ${count} Claude profiles. Pool routing can auto-continue 'ccs claude' when one account hits its limit. Enable: ccs cliproxy pool --enable Docs: ${POOL_DOCS_LINK}`
|
||||
)
|
||||
);
|
||||
|
||||
// Dismiss so subsequent invocations (other sites or next run) are silent.
|
||||
try {
|
||||
dismissOnboardingHint();
|
||||
} catch {
|
||||
// Best-effort: a config write failure must not surface to the user here.
|
||||
}
|
||||
|
||||
return { printed: true };
|
||||
} catch {
|
||||
// Decision failed (e.g. malformed config) - skip the hint, never throw.
|
||||
return { printed: false, skipReason: 'error' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Pool routing opt-in prompt
|
||||
*
|
||||
* Fires at the 1->2 account-add transition for verified providers (claude, agy).
|
||||
* Informed-consent copy: discloses instance-global effect and lists ALL providers
|
||||
* with >=2 accounts that will be affected.
|
||||
*
|
||||
* Codex/gemini get no prompt until failover behavior is verified for pool routing
|
||||
* (spike Test D pending). They continue with implicit round-robin.
|
||||
*
|
||||
* Remote/Docker targets: prompt replaced by manual-config hint because session
|
||||
* affinity is not remotely toggleable from CCS (PR #1117 precedent).
|
||||
*/
|
||||
|
||||
import { info, warn } from '../../utils/ui';
|
||||
import { InteractivePrompt } from '../../utils/prompt';
|
||||
import { getProxyTarget } from '../proxy/proxy-target-resolver';
|
||||
import {
|
||||
enablePoolRouting,
|
||||
POOL_ROUTING_VERIFIED_PROVIDERS,
|
||||
POOL_MAX_RETRY_CREDENTIALS,
|
||||
} from './routing-strategy';
|
||||
import {
|
||||
loadOrCreateUnifiedConfig,
|
||||
mutateConfig,
|
||||
hasUnifiedConfig,
|
||||
} from '../../config/config-loader-facade';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../config/port-manager';
|
||||
import { getConfigPathForPort } from '../config/path-resolver';
|
||||
import { getAuthDir } from '../config/path-resolver';
|
||||
import type { CLIProxyProvider } from '../types';
|
||||
import { getProviderAccounts } from '../accounts/account-manager';
|
||||
import { loadAccountsRegistry } from '../accounts/registry';
|
||||
|
||||
/**
|
||||
* Collect names of all providers that currently have >= 2 accounts registered.
|
||||
* Derived from the registry's actual provider keys so this list can never drift
|
||||
* from the CLIProxyProvider type union (spec requirement: disclose ALL affected
|
||||
* providers, not just a hardcoded subset).
|
||||
*/
|
||||
function getMultiAccountProviders(): CLIProxyProvider[] {
|
||||
const result: CLIProxyProvider[] = [];
|
||||
try {
|
||||
const registry = loadAccountsRegistry();
|
||||
for (const p of Object.keys(registry.providers) as CLIProxyProvider[]) {
|
||||
try {
|
||||
if (getProviderAccounts(p).length >= 2) result.push(p);
|
||||
} catch {
|
||||
// Provider registry entry present but accounts unreadable — skip
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Registry unreadable (first-run, corrupt) — return empty; caller falls back to provider param
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the pool routing opt-in prompt has been permanently dismissed.
|
||||
* Dismissal is recorded per-provider in pool_routing.prompt_dismissed.
|
||||
*/
|
||||
export function isPoolPromptDismissed(): boolean {
|
||||
return loadOrCreateUnifiedConfig().cliproxy?.pool_routing?.prompt_dismissed === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the pool routing prompt as permanently dismissed (user said no explicitly).
|
||||
*/
|
||||
export function dismissPoolPrompt(): void {
|
||||
// Defense in depth: never write the unified config on a legacy install — that
|
||||
// would create config.yaml and silently flip isUnifiedMode(). maybeOfferPoolRouting
|
||||
// already gates the only caller, but this keeps the exported helper safe too.
|
||||
if (!hasUnifiedConfig()) return;
|
||||
mutateConfig((cfg) => {
|
||||
if (!cfg.cliproxy) return;
|
||||
cfg.cliproxy.pool_routing = {
|
||||
...cfg.cliproxy.pool_routing,
|
||||
prompt_dismissed: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the remote/Docker hint instead of an interactive prompt.
|
||||
* Session affinity is not remotely toggleable from CCS (fail-closed
|
||||
* per PR #1117 precedent) so we emit guidance only.
|
||||
*/
|
||||
function printRemoteHint(provider: CLIProxyProvider): void {
|
||||
console.log('');
|
||||
console.log(
|
||||
info(`[i] Pool routing hint: you now have 2+ ${provider} accounts on a remote/Docker CLIProxy.`)
|
||||
);
|
||||
console.log(
|
||||
' CCS cannot toggle session affinity on remote targets yet (management API limitation).'
|
||||
);
|
||||
console.log(' To enable pool routing manually, add to your CLIProxy config.yaml:');
|
||||
console.log(' disable-cooling: false');
|
||||
console.log(` max-retry-credentials: ${POOL_MAX_RETRY_CREDENTIALS}`);
|
||||
console.log(' routing:');
|
||||
console.log(' strategy: fill-first');
|
||||
console.log(' session-affinity: true');
|
||||
console.log(' session-affinity-ttl: "1h"');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
export interface PoolOptInResult {
|
||||
/** Whether the prompt was shown */
|
||||
prompted: boolean;
|
||||
/** Whether pool routing was enabled */
|
||||
enabled: boolean;
|
||||
/** Whether the prompt was skipped (remote, dismissed, not verified, already enabled) */
|
||||
skipped: boolean;
|
||||
skipReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer pool routing opt-in when the account count crosses 1->2 for a verified provider.
|
||||
*
|
||||
* Call this immediately after a successful account registration when the provider
|
||||
* transitions from 1 to 2 accounts. The function is a no-op when:
|
||||
* - Pool routing is already enabled
|
||||
* - Provider is not in the verified pool list (codex, gemini, etc.)
|
||||
* - The prompt was previously dismissed
|
||||
* - Target is non-TTY (piped input / CI)
|
||||
*
|
||||
* Remote/Docker targets get a manual-config hint instead of an interactive prompt.
|
||||
*
|
||||
* @param provider - The provider that just reached 2 accounts
|
||||
* @param accountCountBefore - Number of accounts before this add (should be 1 for transition)
|
||||
* @param port - CLIProxy port (default: 8317)
|
||||
*/
|
||||
export async function maybeOfferPoolRouting(
|
||||
provider: CLIProxyProvider,
|
||||
accountCountBefore: number,
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
): Promise<PoolOptInResult> {
|
||||
// Fast path: only fire at the 1->2 transition (accountCountBefore check only).
|
||||
// The actual post-add count is verified below, after cheap early-exit guards pass,
|
||||
// so that provider/dismissed/remote guards still return their expected skipReason
|
||||
// regardless of whether the registry has been populated in the test environment.
|
||||
if (accountCountBefore !== 1) {
|
||||
return { prompted: false, enabled: false, skipped: true, skipReason: 'not-at-transition' };
|
||||
}
|
||||
|
||||
// Legacy install guard: on a profiles.json/config.json-only install there is no
|
||||
// unified config.yaml. Both accept (enablePoolRouting) and decline
|
||||
// (dismissPoolPrompt) write the unified config, which would create config.yaml
|
||||
// and silently flip isUnifiedMode() outside the deliberate `ccs migrate` flow,
|
||||
// orphaning the user's config.json profiles. Skip entirely before any prompt
|
||||
// or dismissal persistence. Mirrors the guards in create-command.ts and
|
||||
// account-flow.ts and the hazard documented in pool-onboarding-hint.ts.
|
||||
// Silent by design: legacy installs receive pool guidance exclusively from
|
||||
// ccs doctor (pool-onboarding-hint site 1); printing here would nag on every
|
||||
// account add with no dismissal available.
|
||||
if (!hasUnifiedConfig()) {
|
||||
return { prompted: false, enabled: false, skipped: true, skipReason: 'legacy-config' };
|
||||
}
|
||||
|
||||
// Only for providers where pool routing is verified
|
||||
if (!POOL_ROUTING_VERIFIED_PROVIDERS.has(provider)) {
|
||||
return {
|
||||
prompted: false,
|
||||
enabled: false,
|
||||
skipped: true,
|
||||
skipReason: `provider-${provider}-unverified`,
|
||||
};
|
||||
}
|
||||
|
||||
// No-op if already enabled
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
if (config.cliproxy?.pool_routing?.enabled === true) {
|
||||
return { prompted: false, enabled: true, skipped: true, skipReason: 'already-enabled' };
|
||||
}
|
||||
|
||||
// No-op if user already dismissed
|
||||
if (isPoolPromptDismissed()) {
|
||||
return { prompted: false, enabled: false, skipped: true, skipReason: 'dismissed' };
|
||||
}
|
||||
|
||||
// Remote / Docker target: print hint, do not prompt.
|
||||
// Check before account count so remote targets with locally-unknown registries
|
||||
// still get the hint (the remote CLIProxy holds the authoritative account list).
|
||||
const target = getProxyTarget();
|
||||
if (target.isRemote) {
|
||||
printRemoteHint(provider);
|
||||
return { prompted: false, enabled: false, skipped: true, skipReason: 'remote-target' };
|
||||
}
|
||||
|
||||
// Verify the actual post-add count is >= 2. registerAccount deduplicates by
|
||||
// email/token-file so re-authenticating the single existing account keeps the
|
||||
// count at 1 — not a real 1->2 transition. Checking here (before TTY) ensures
|
||||
// re-auth dedup always returns not-at-transition even in non-TTY/CI sessions.
|
||||
const accountCountAfter = getProviderAccounts(provider).length;
|
||||
if (accountCountAfter < 2) {
|
||||
return { prompted: false, enabled: false, skipped: true, skipReason: 'not-at-transition' };
|
||||
}
|
||||
|
||||
// Non-TTY (piped input / CI): skip silently
|
||||
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
||||
return { prompted: false, enabled: false, skipped: true, skipReason: 'non-tty' };
|
||||
}
|
||||
|
||||
// Automation bypass: InteractivePrompt.confirm auto-returns true when --yes/-y
|
||||
// is in argv or CCS_YES=1 is set, regardless of the default:false. This is an
|
||||
// INSTANCE-GLOBAL routing/cooling consent decision; a generic automation flag
|
||||
// must never grant it. Skip WITHOUT printing the prompt and WITHOUT persisting
|
||||
// dismissal, so a future interactive run still offers the opt-in.
|
||||
if (
|
||||
process.env.CCS_YES === '1' ||
|
||||
process.argv.includes('--yes') ||
|
||||
process.argv.includes('-y')
|
||||
) {
|
||||
return { prompted: false, enabled: false, skipped: true, skipReason: 'automation-bypass' };
|
||||
}
|
||||
|
||||
// Gather all providers with 2+ accounts for disclosure
|
||||
const multiAccountProviders = getMultiAccountProviders();
|
||||
const providerList =
|
||||
multiAccountProviders.length > 0 ? multiAccountProviders.join(', ') : provider;
|
||||
|
||||
console.log('');
|
||||
console.log(warn('Pool routing: you now have 2+ accounts for ' + provider));
|
||||
console.log('');
|
||||
console.log(' CCS can enable pool routing (fill-first + session affinity + 429 cooldown).');
|
||||
console.log(' This is an INSTANCE-GLOBAL change: it affects account selection for ALL');
|
||||
console.log(` CLIProxy providers on this machine: ${providerList}`);
|
||||
console.log('');
|
||||
console.log(' What changes:');
|
||||
console.log(' - disable-cooling: false (cooldown is required for retry-cap to work)');
|
||||
console.log(' A 429 suspends a credential briefly (1s -> 30m exp backoff).');
|
||||
console.log(' A 401/403 suspends it for 30 minutes (correct: broken auth = no traffic).');
|
||||
console.log(' - routing: fill-first (drain one account before switching)');
|
||||
console.log(' - session-affinity: true (TTL 1h, pinned per conversation)');
|
||||
console.log(
|
||||
` - max-retry-credentials: ${POOL_MAX_RETRY_CREDENTIALS} (stop after ${POOL_MAX_RETRY_CREDENTIALS} attempts per request)`
|
||||
);
|
||||
console.log('');
|
||||
console.log(' You can roll back at any time: ccs cliproxy pool --disable');
|
||||
console.log(' (Or re-enable later: ccs cliproxy pool --enable)');
|
||||
console.log('');
|
||||
|
||||
const yes = await InteractivePrompt.confirm(
|
||||
' Enable pool routing for all CLIProxy providers?',
|
||||
{ default: false }
|
||||
);
|
||||
|
||||
if (!yes) {
|
||||
// Persist decline so we don't re-ask on every subsequent add
|
||||
dismissPoolPrompt();
|
||||
console.log(
|
||||
info(
|
||||
" Declined. Pool routing stays off. Run 'ccs cliproxy pool --enable' to opt in later."
|
||||
)
|
||||
);
|
||||
console.log('');
|
||||
return { prompted: true, enabled: false, skipped: false };
|
||||
}
|
||||
|
||||
const configPath = getConfigPathForPort(port);
|
||||
const authDir = getAuthDir();
|
||||
const result = enablePoolRouting(port, { configPath, authDir });
|
||||
|
||||
console.log('');
|
||||
if (result.failed) {
|
||||
// Config regeneration failed and the flag was rolled back: pool routing is
|
||||
// NOT active. Surface the recovery copy and report enabled:false so callers
|
||||
// (and quota/dashboard) do not claim pool ON.
|
||||
console.log(warn(result.message));
|
||||
console.log('');
|
||||
return { prompted: true, enabled: false, skipped: false };
|
||||
}
|
||||
if (result.changed) {
|
||||
console.log(info(result.message));
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// User accepted and enablePoolRouting completed: pool routing is enabled
|
||||
// even when this call was an idempotent no-op (changed=false).
|
||||
return { prompted: true, enabled: true, skipped: false };
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import * as fs from 'fs';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { regenerateConfig } from '../config/generator';
|
||||
import { getAuthDir, getConfigPathForPort } from '../config/path-resolver';
|
||||
import {
|
||||
@@ -7,20 +9,356 @@ import {
|
||||
} from './routing-strategy-http';
|
||||
import type { CliproxyRoutingStrategy } from '../types';
|
||||
import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loader-facade';
|
||||
import { getInstalledCliproxyVersion } from '../binary-manager';
|
||||
import { compareVersions } from '../../utils/update-checker';
|
||||
import { getConfigYamlPath } from '../../config/loader/io-locks';
|
||||
|
||||
export const DEFAULT_CLIPROXY_ROUTING_STRATEGY: CliproxyRoutingStrategy = 'round-robin';
|
||||
export const DEFAULT_CLIPROXY_SESSION_AFFINITY_ENABLED = false;
|
||||
export const DEFAULT_CLIPROXY_SESSION_AFFINITY_TTL = '1h';
|
||||
|
||||
/**
|
||||
* Pool routing defaults written to config when pool routing is enabled.
|
||||
* fill-first + session affinity drains one account before using another,
|
||||
* maximising per-account context depth while honouring cooldown windows.
|
||||
*/
|
||||
export const POOL_ROUTING_STRATEGY: CliproxyRoutingStrategy = 'fill-first';
|
||||
export const POOL_SESSION_AFFINITY_ENABLED = true;
|
||||
export const POOL_SESSION_AFFINITY_TTL = '1h';
|
||||
export const POOL_MAX_RETRY_CREDENTIALS = 3;
|
||||
|
||||
/**
|
||||
* Providers for which pool routing is available and the opt-in prompt
|
||||
* shows the full cooling/routing disclosure. Others (codex, gemini)
|
||||
* have failover behaviour that is unverified for pool routing; they get
|
||||
* a softened prompt variant or no prompt until spike Test D confirms.
|
||||
*/
|
||||
export const POOL_ROUTING_VERIFIED_PROVIDERS = new Set(['claude', 'agy']);
|
||||
|
||||
/**
|
||||
* Minimum CLIProxy version that supports pool routing keys:
|
||||
* max-retry-credentials and the cooling flip.
|
||||
* Older binaries silently ignore unknown keys — pool rails would appear active
|
||||
* but have no effect. Warn the user at enable time if below this version.
|
||||
*
|
||||
* NOTE: Update this constant when upstream first ships these keys.
|
||||
* Current best estimate based on spec; adjust after spike Test D confirms.
|
||||
*/
|
||||
export const POOL_ROUTING_MIN_VERSION = '6.9.45';
|
||||
|
||||
/**
|
||||
* Pool-active override warning text. When pool routing is enabled the generator
|
||||
* forces fill-first/affinity/cooling and ignores the stored strategy/affinity, so
|
||||
* an apply via API or dashboard will not take effect. The CLI prints this same
|
||||
* text before applying; appending it to the apply result message lets dashboard
|
||||
* and API consumers surface the same caveat (the CLI warns, the dashboard did not).
|
||||
*/
|
||||
function poolActiveOverrideNote(kind: 'strategy' | 'affinity'): string {
|
||||
const what = kind === 'strategy' ? 'stored strategy' : 'stored affinity setting';
|
||||
return (
|
||||
`[!] Pool routing is active. The ${what} will not take effect\n` +
|
||||
` until pool routing is disabled: ccs cliproxy pool --disable`
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether pool routing is enabled in the local unified config. */
|
||||
function isLocalPoolRoutingEnabled(): boolean {
|
||||
return loadOrCreateUnifiedConfig().cliproxy?.pool_routing?.enabled === true;
|
||||
}
|
||||
|
||||
export interface EnablePoolRoutingResult {
|
||||
/** Whether the pool routing state actually changed */
|
||||
changed: boolean;
|
||||
/** Whether an existing explicit user routing setting was preserved */
|
||||
preservedExplicitSetting: boolean;
|
||||
/**
|
||||
* True when the config could not be regenerated and the pool flag was rolled
|
||||
* back. Pool routing is NOT active; the message carries recovery guidance.
|
||||
*/
|
||||
failed?: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface DisablePoolRoutingResult {
|
||||
changed: boolean;
|
||||
/** True when the config could not be regenerated and the flag was rolled back. */
|
||||
failed?: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the raw (pre-defaults-merger) CCS config YAML.
|
||||
* The loaded config always injects `strategy: round-robin`, `session_affinity: false`,
|
||||
* `session_affinity_ttl: 1h` as defaults — so we cannot use the merged config to
|
||||
* detect whether the user actually wrote these keys. This helper reads the raw YAML
|
||||
* and returns the partial routing block as-written on disk.
|
||||
*
|
||||
* Returns null if the config file does not exist or cannot be parsed.
|
||||
*/
|
||||
function readRawRoutingConfig(): {
|
||||
strategy?: unknown;
|
||||
session_affinity?: unknown;
|
||||
session_affinity_ttl?: unknown;
|
||||
} | null {
|
||||
try {
|
||||
const yamlPath = getConfigYamlPath();
|
||||
if (!fs.existsSync(yamlPath)) return null;
|
||||
const raw = yaml.load(fs.readFileSync(yamlPath, 'utf8')) as Record<string, unknown> | null;
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const cliproxy = raw['cliproxy'] as Record<string, unknown> | undefined;
|
||||
if (!cliproxy || typeof cliproxy !== 'object') return null;
|
||||
const routing = cliproxy['routing'] as Record<string, unknown> | undefined;
|
||||
if (!routing || typeof routing !== 'object') return null;
|
||||
return routing as {
|
||||
strategy?: unknown;
|
||||
session_affinity?: unknown;
|
||||
session_affinity_ttl?: unknown;
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether the user has set a routing strategy that differs from the
|
||||
* injected default (round-robin).
|
||||
*
|
||||
* Background: loadOrCreateUnifiedConfig persists injected defaults to disk on
|
||||
* first load, so `strategy: round-robin` may appear in the raw YAML even on a
|
||||
* pristine config — it was injected by CCS, not written by the user. A stored
|
||||
* value EQUAL to the default is therefore treated as NOT explicit so that
|
||||
* enablePoolRouting does not falsely claim to be "preserving a custom strategy".
|
||||
*
|
||||
* A value that DIFFERS from the default (e.g. fill-first) is treated as
|
||||
* user-managed: preserve it and warn.
|
||||
*/
|
||||
export function hasExplicitRoutingStrategy(): boolean {
|
||||
const rawRouting = readRawRoutingConfig();
|
||||
if (rawRouting?.strategy === undefined) return false;
|
||||
// Equal to the injected default -> not explicitly customised by the user
|
||||
return rawRouting.strategy !== DEFAULT_CLIPROXY_ROUTING_STRATEGY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether the user has set session-affinity to a value that differs
|
||||
* from the injected default (false).
|
||||
*
|
||||
* Same rationale as hasExplicitRoutingStrategy: loadOrCreateUnifiedConfig may
|
||||
* persist `session_affinity: false` as a default, so presence alone is not
|
||||
* sufficient — only a value that differs from the default counts as explicit.
|
||||
*/
|
||||
export function hasExplicitSessionAffinity(): boolean {
|
||||
const rawRouting = readRawRoutingConfig();
|
||||
if (rawRouting?.session_affinity === undefined) return false;
|
||||
// Equal to the injected default -> not explicitly customised by the user
|
||||
return rawRouting.session_affinity !== DEFAULT_CLIPROXY_SESSION_AFFINITY_ENABLED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable pool routing: write pool_routing.enabled = true and the canonical
|
||||
* pool defaults (fill-first, session affinity 1h, max-retry-credentials: 3)
|
||||
* to the CCS unified config. Regenerates the CLIProxy config.yaml so the
|
||||
* cooling flip and routing block take effect immediately (CLIProxy hot-reloads).
|
||||
*
|
||||
* Explicit user routing settings are preserved and a warning is emitted.
|
||||
* The pool flag is written regardless — the generator uses fill-first/affinity
|
||||
* from the pool defaults when pool is enabled, bypassing any stored routing.
|
||||
*
|
||||
* Idempotent: calling when already enabled is a no-op.
|
||||
*/
|
||||
export function enablePoolRouting(
|
||||
port: number,
|
||||
options: { configPath?: string; authDir?: string } = {}
|
||||
): EnablePoolRoutingResult {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const already = config.cliproxy?.pool_routing?.enabled === true;
|
||||
const configPath = options.configPath ?? getConfigPathForPort(port);
|
||||
const authDir = options.authDir ?? getAuthDir();
|
||||
|
||||
// Already-enabled repair path: the flag may have been persisted by a prior call
|
||||
// whose regenerateConfig threw (leaving config.yaml non-pool while the flag says
|
||||
// enabled). Re-run regenerateConfig so `pool --enable` is an idempotent repair
|
||||
// command rather than a no-op that can never fix a half-applied state.
|
||||
if (already) {
|
||||
try {
|
||||
regenerateConfig(port, { configPath, authDir });
|
||||
} catch (err) {
|
||||
return {
|
||||
changed: false,
|
||||
preservedExplicitSetting: false,
|
||||
failed: true,
|
||||
message: `[X] Could not write CLIProxy config: ${(err as Error).message}.\n Pool routing is flagged enabled but the config was not regenerated.\n Fix the file permission and re-run: ccs cliproxy pool --enable`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
changed: false,
|
||||
preservedExplicitSetting: false,
|
||||
message: 'Pool routing is already enabled.',
|
||||
};
|
||||
}
|
||||
|
||||
const preservedExplicitSetting = hasExplicitRoutingStrategy() || hasExplicitSessionAffinity();
|
||||
|
||||
// Spec step 3 / architecture: assert minimum CLIProxy version at enable time.
|
||||
// Stale binaries silently ignore max-retry-credentials and the cooling flip,
|
||||
// so pool rails would appear active but have no effect. Warn and proceed.
|
||||
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'.`
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Binary not installed yet (first setup) — skip the version check silently
|
||||
}
|
||||
|
||||
mutateConfig((cfg) => {
|
||||
if (!cfg.cliproxy) return;
|
||||
// Write only the pool flag and retry-cap. User's routing values (strategy,
|
||||
// session_affinity, session_affinity_ttl) are intentionally left untouched so
|
||||
// disablePoolRouting can restore them without needing a separate backup.
|
||||
// The generator uses pool constants (fill-first, affinity 1h) when pool is
|
||||
// enabled, bypassing whatever is stored in cfg.cliproxy.routing.
|
||||
cfg.cliproxy.pool_routing = {
|
||||
...cfg.cliproxy.pool_routing,
|
||||
enabled: true,
|
||||
max_retry_credentials: POOL_MAX_RETRY_CREDENTIALS,
|
||||
};
|
||||
});
|
||||
|
||||
// The flag is persisted before regenerateConfig. If regeneration throws, roll
|
||||
// the flag back so status surfaces (pool --enable, quota, dashboard) do not
|
||||
// report pool ON while config.yaml still runs non-pool routing.
|
||||
try {
|
||||
regenerateConfig(port, { configPath, authDir });
|
||||
} catch (err) {
|
||||
mutateConfig((cfg) => {
|
||||
if (!cfg.cliproxy) return;
|
||||
cfg.cliproxy.pool_routing = {
|
||||
...cfg.cliproxy.pool_routing,
|
||||
enabled: false,
|
||||
};
|
||||
});
|
||||
return {
|
||||
changed: false,
|
||||
preservedExplicitSetting,
|
||||
failed: true,
|
||||
message: `[X] Could not write CLIProxy config: ${(err as Error).message}.\n Pool routing was not enabled; fix the file permission and re-run: ccs cliproxy pool --enable`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
preservedExplicitSetting,
|
||||
message: preservedExplicitSetting
|
||||
? '[!] Pool routing enabled. Your existing routing setting is preserved in config.\n The generator uses pool defaults (fill-first, affinity 1h) while pool is active.\n To restore your setting, disable pool routing first: ccs cliproxy pool --disable'
|
||||
: '[OK] Pool routing enabled. CLIProxy config regenerated with cooling ON,\n fill-first strategy, session affinity 1h, max-retry-credentials 3.\n CLIProxy will hot-reload the change; live session pins will re-pin on\n next request.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable pool routing: clear pool_routing.enabled and restore the non-pool
|
||||
* config defaults (disable-cooling: true, round-robin, no affinity).
|
||||
* Regenerates the CLIProxy config.yaml.
|
||||
*
|
||||
* IMPORTANT: disablePoolRouting MUST explicitly restore routing to round-robin
|
||||
* and session_affinity to false. Simply clearing pool_routing.enabled is not
|
||||
* sufficient because the upstream CLIProxy default for disable-cooling is false
|
||||
* (cooling ON) when the key is absent. Leaving cooling ON for a user who has
|
||||
* disabled pool routing would reintroduce the single-account blackout that v5
|
||||
* (commit fb77d72a) fixed.
|
||||
*
|
||||
* Idempotent: calling when already disabled is a no-op.
|
||||
*/
|
||||
export function disablePoolRouting(
|
||||
port: number,
|
||||
options: { configPath?: string; authDir?: string } = {}
|
||||
): DisablePoolRoutingResult {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const wasEnabled = config.cliproxy?.pool_routing?.enabled === true;
|
||||
const configPath = options.configPath ?? getConfigPathForPort(port);
|
||||
const authDir = options.authDir ?? getAuthDir();
|
||||
if (!wasEnabled) {
|
||||
return {
|
||||
changed: false,
|
||||
message: 'Pool routing is not enabled.',
|
||||
};
|
||||
}
|
||||
|
||||
mutateConfig((cfg) => {
|
||||
if (!cfg.cliproxy) return;
|
||||
// Only clear the pool flag — user's routing values (strategy, session_affinity,
|
||||
// session_affinity_ttl) were never overwritten on enable, so they are naturally
|
||||
// restored here. The generator emits disable-cooling: true when pool is off.
|
||||
cfg.cliproxy.pool_routing = {
|
||||
...cfg.cliproxy.pool_routing,
|
||||
enabled: false,
|
||||
};
|
||||
});
|
||||
|
||||
// The flag is already cleared (matching user intent). If regeneration throws we
|
||||
// keep enabled:false rather than rolling back to true — re-asserting pool ON
|
||||
// would reintroduce the single-account blackout the disable path exists to
|
||||
// prevent. Surface the failure so the user can fix permissions and re-run.
|
||||
try {
|
||||
regenerateConfig(port, { configPath, authDir });
|
||||
} catch (err) {
|
||||
return {
|
||||
changed: true,
|
||||
failed: true,
|
||||
message: `[X] Could not write CLIProxy config: ${(err as Error).message}.\n Pool routing is flagged disabled but the config was not regenerated.\n Fix the file permission and re-run: ccs cliproxy pool --disable`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
message:
|
||||
'[OK] Pool routing disabled. CLIProxy config regenerated with cooling disabled (stability mode).\n' +
|
||||
' Your original routing settings are restored.\n' +
|
||||
' If you have multiple accounts and want fair distribution, round-robin is active.\n' +
|
||||
' To avoid cache-burn with large multi-account fleets, consider reducing to 1 account\n' +
|
||||
' or re-enabling pool routing: ccs cliproxy pool --enable',
|
||||
};
|
||||
}
|
||||
|
||||
const GO_DURATION_SEGMENT = String.raw`(?:\d+(?:\.\d+)?(?:ns|us|µs|μs|ms|s|m|h))`;
|
||||
const GO_DURATION_PATTERN = new RegExp(`^${GO_DURATION_SEGMENT}+$`);
|
||||
|
||||
/**
|
||||
* Pool routing mode summary surfaced alongside routing state for the dashboard.
|
||||
* When pool routing is enabled the generator forces fill-first + session affinity
|
||||
* + cooling, regardless of stored routing values.
|
||||
*/
|
||||
export interface CliproxyPoolRoutingState {
|
||||
enabled: boolean;
|
||||
maxRetryCredentials?: number;
|
||||
/**
|
||||
* Whether CCS can manage pool routing for this target. enablePoolRouting only
|
||||
* writes LOCAL config files, so a remote proxy is never affected by the local
|
||||
* pool flag. For remote targets this is false and `enabled` reflects the local
|
||||
* config only (not the remote proxy's behaviour). Omitted (treated as true)
|
||||
* for local targets. Mirrors CliproxySessionAffinityState.manageable.
|
||||
*/
|
||||
manageable?: boolean;
|
||||
/** Explanation surfaced when manageable is false (remote target). */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface CliproxyRoutingState {
|
||||
strategy: CliproxyRoutingStrategy;
|
||||
source: 'live' | 'config';
|
||||
target: 'local' | 'remote';
|
||||
reachable: boolean;
|
||||
message?: string;
|
||||
/**
|
||||
* Pool routing mode. For local targets `enabled` reflects the active proxy
|
||||
* config. For remote targets `manageable` is false and `enabled` reflects only
|
||||
* the local config (the remote proxy is not affected by it).
|
||||
*/
|
||||
poolRouting?: CliproxyPoolRoutingState;
|
||||
}
|
||||
|
||||
export interface CliproxyRoutingApplyResult extends CliproxyRoutingState {
|
||||
@@ -142,15 +480,48 @@ export async function fetchLiveCliproxyRoutingStrategy(): Promise<CliproxyRoutin
|
||||
return strategy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the pool routing mode from the unified config.
|
||||
* Pool routing is proxy-wide opt-in; this reflects the persisted flag, not a
|
||||
* live selector probe.
|
||||
*/
|
||||
export function getCliproxyPoolRoutingState(): CliproxyPoolRoutingState {
|
||||
const pool = loadOrCreateUnifiedConfig().cliproxy?.pool_routing;
|
||||
const enabled = pool?.enabled === true;
|
||||
return {
|
||||
enabled,
|
||||
// When pool routing is enabled the generator applies the POOL_MAX_RETRY_CREDENTIALS
|
||||
// default, so surface the same effective value here to match the generated
|
||||
// config. When disabled, max-retry does not apply; leave it unset.
|
||||
maxRetryCredentials: enabled
|
||||
? (pool?.max_retry_credentials ?? POOL_MAX_RETRY_CREDENTIALS)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readCliproxyRoutingState(): Promise<CliproxyRoutingState> {
|
||||
const target = getCliproxyRoutingTarget();
|
||||
const poolRouting = getCliproxyPoolRoutingState();
|
||||
|
||||
if (target.isRemote) {
|
||||
// Do NOT attach the local pool flag as if it described the remote proxy.
|
||||
// enablePoolRouting only writes local config files; the remote proxy keeps
|
||||
// its own routing/cooling. Surface manageable:false + a message so the
|
||||
// dashboard renders "local only / not applied" instead of claiming the
|
||||
// remote proxy is pool-managed. Mirrors readCliproxySessionAffinityState.
|
||||
return {
|
||||
strategy: await fetchLiveCliproxyRoutingStrategy(),
|
||||
source: 'live',
|
||||
target: 'remote',
|
||||
reachable: true,
|
||||
poolRouting: {
|
||||
enabled: poolRouting.enabled,
|
||||
maxRetryCredentials: poolRouting.maxRetryCredentials,
|
||||
manageable: false,
|
||||
message:
|
||||
'Pool routing is managed from the local config only and does not affect this remote proxy. ' +
|
||||
'Configure cooling and routing on the host running CLIProxy instead.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -160,6 +531,7 @@ export async function readCliproxyRoutingState(): Promise<CliproxyRoutingState>
|
||||
source: 'live',
|
||||
target: 'local',
|
||||
reachable: true,
|
||||
poolRouting,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
@@ -168,6 +540,7 @@ export async function readCliproxyRoutingState(): Promise<CliproxyRoutingState>
|
||||
target: 'local',
|
||||
reachable: false,
|
||||
message: 'Local CLIProxy is not reachable. Showing the saved startup default.',
|
||||
poolRouting,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -223,6 +596,11 @@ export async function applyCliproxyRoutingStrategy(
|
||||
};
|
||||
}
|
||||
|
||||
// Pool routing overrides the stored strategy at config-generation time, so the
|
||||
// apply will not take effect until pool routing is disabled. Append the same
|
||||
// note the CLI prints so dashboard/API consumers see the override too.
|
||||
const poolNote = isLocalPoolRoutingEnabled() ? `\n\n${poolActiveOverrideNote('strategy')}` : '';
|
||||
|
||||
mutateConfig((config) => {
|
||||
if (config.cliproxy) {
|
||||
config.cliproxy.routing = { ...config.cliproxy.routing, strategy };
|
||||
@@ -238,7 +616,7 @@ export async function applyCliproxyRoutingStrategy(
|
||||
target: 'local',
|
||||
reachable: true,
|
||||
applied: 'live-and-config',
|
||||
message: 'Updated the running proxy and saved the local startup default.',
|
||||
message: 'Updated the running proxy and saved the local startup default.' + poolNote,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
@@ -247,7 +625,8 @@ export async function applyCliproxyRoutingStrategy(
|
||||
target: 'local',
|
||||
reachable: false,
|
||||
applied: 'config-only',
|
||||
message: 'Saved the local startup default. It will apply the next time CLIProxy starts.',
|
||||
message:
|
||||
'Saved the local startup default. It will apply the next time CLIProxy starts.' + poolNote,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -278,6 +657,10 @@ export async function applyCliproxySessionAffinitySettings(
|
||||
current.ttl ??
|
||||
DEFAULT_CLIPROXY_SESSION_AFFINITY_TTL;
|
||||
|
||||
// Pool routing overrides the stored session-affinity at config-generation time;
|
||||
// append the same override note the CLI prints so dashboard/API consumers see it.
|
||||
const poolNote = isLocalPoolRoutingEnabled() ? `\n\n${poolActiveOverrideNote('affinity')}` : '';
|
||||
|
||||
mutateConfig((config) => {
|
||||
if (config.cliproxy) {
|
||||
config.cliproxy.routing = {
|
||||
@@ -298,9 +681,11 @@ export async function applyCliproxySessionAffinitySettings(
|
||||
reachable,
|
||||
manageable: true,
|
||||
applied: 'config-only',
|
||||
message: reachable
|
||||
? 'Saved the local startup default. Running local CLIProxy may hot-reload the session-affinity setting, but CCS does not verify live selector state yet.'
|
||||
: 'Saved the local startup default. It will apply the next time local CLIProxy starts.',
|
||||
message:
|
||||
(reachable
|
||||
? 'Saved the local startup default. Running local CLIProxy may hot-reload the session-affinity setting, but CCS does not verify live selector state yet.'
|
||||
: 'Saved the local startup default. It will apply the next time local CLIProxy starts.') +
|
||||
poolNote,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -344,7 +344,9 @@ async function fetchManagementJson<T>(
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCliproxyAuthFiles(port?: number): Promise<CliproxyManagementAuthFile[] | null> {
|
||||
export async function fetchCliproxyAuthFiles(
|
||||
port?: number
|
||||
): Promise<CliproxyManagementAuthFile[] | null> {
|
||||
try {
|
||||
const result = await fetchManagementJson<{ files?: CliproxyManagementAuthFile[] }>(
|
||||
'/v0/management/auth-files',
|
||||
@@ -367,6 +369,29 @@ export const __testExports = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Build an auth_index → account email/id map from CLIProxy auth file metadata.
|
||||
*
|
||||
* Keys are stored as strings (String(auth_index)) so both numeric and string
|
||||
* auth_index values resolve consistently via `map.get(String(auth_index))`.
|
||||
*
|
||||
* Entries missing either `auth_index` or `email` are silently skipped.
|
||||
*
|
||||
* @param authFiles Auth file records from /v0/management/auth-files
|
||||
* @returns Map from String(auth_index) → email
|
||||
*/
|
||||
export function buildAuthIndexToAccountMap(
|
||||
authFiles: CliproxyManagementAuthFile[]
|
||||
): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
for (const file of authFiles) {
|
||||
if (file.auth_index === undefined || file.auth_index === null) continue;
|
||||
if (!file.email || file.email.trim().length === 0) continue;
|
||||
map.set(String(file.auth_index), file.email.trim());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** OpenAI-compatible model object from /v1/models endpoint */
|
||||
export interface CliproxyModel {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { color, dim, header, initUI, subheader } from '../../utils/ui';
|
||||
|
||||
export async function showHelp(): Promise<void> {
|
||||
await initUI();
|
||||
console.log('');
|
||||
console.log(header('CCS Bar (macOS Menu Bar App)'));
|
||||
console.log('');
|
||||
console.log(subheader('Usage:'));
|
||||
console.log(` ${color('ccs bar', 'command')} [command] [options]`);
|
||||
console.log('');
|
||||
|
||||
const sections: [string, [string, string][]][] = [
|
||||
[
|
||||
'Commands:',
|
||||
[
|
||||
['launch', 'Start the web-server, write ~/.ccs/bar.json, and open the app (default)'],
|
||||
['install', 'Download CCS Bar from the ccs-bar-latest GitHub release into ~/Applications'],
|
||||
['uninstall', 'Remove the app and version pin'],
|
||||
['version', 'Show CLI and installed app versions'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'Options:',
|
||||
[
|
||||
['--help, -h', 'Show this help message'],
|
||||
['--version', 'Show CLI and installed app versions'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'Install options:',
|
||||
[
|
||||
['--launch', 'Launch CCS Bar immediately after install without prompting'],
|
||||
['--no-launch', 'Skip the launch prompt after install'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'Examples:',
|
||||
[
|
||||
['ccs bar', 'Start the web-server and open CCS Bar (default launch)'],
|
||||
['ccs bar install', 'Download and install CCS Bar, then prompt to launch'],
|
||||
['ccs bar install --launch', 'Install and launch immediately (no prompt)'],
|
||||
['ccs bar install --no-launch', 'Install without launching'],
|
||||
['ccs bar version', 'Show CLI and installed app versions'],
|
||||
['ccs bar uninstall', 'Remove CCS Bar and its version pin'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
for (const [title, rows] of sections) {
|
||||
console.log(subheader(title));
|
||||
const width = Math.max(...rows.map(([command]) => command.length));
|
||||
for (const [command, description] of rows) {
|
||||
console.log(` ${color(command.padEnd(width + 2), 'command')} ${description}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log(dim(' macOS only. The app communicates with the CCS web-server on localhost only.'));
|
||||
console.log(
|
||||
dim(
|
||||
' Gatekeeper quarantine is cleared automatically after install. If the app is still blocked,'
|
||||
)
|
||||
);
|
||||
console.log(dim(' right-click > Open or run `xattr -dr com.apple.quarantine` manually.'));
|
||||
console.log('');
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* `ccs bar` command dispatcher
|
||||
*
|
||||
* Mirrors the pattern in src/commands/docker/index.ts.
|
||||
* Subcommands: launch (default), install, uninstall, version / --version, help / --help / -h.
|
||||
*/
|
||||
|
||||
import { hasAnyFlag } from '../arg-extractor';
|
||||
|
||||
export async function handleBarCommand(args: string[]): Promise<void> {
|
||||
const subcommand = args[0];
|
||||
|
||||
// --help / -h anywhere in args (e.g. `ccs bar install --help`) → show help.
|
||||
// Mirrors docker/index.ts: hasAnyFlag(normalizedArgs, ['--help', '-h']).
|
||||
// Also dispatch bare `help` subcommand for symmetry.
|
||||
if (hasAnyFlag(args, ['--help', '-h']) || subcommand === 'help') {
|
||||
const { showHelp } = await import('./help-subcommand');
|
||||
await showHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
// --version / version are aliases for the version subcommand
|
||||
if (subcommand === '--version' || subcommand === 'version') {
|
||||
const { handleBarVersion } = await import('./version-subcommand');
|
||||
await handleBarVersion();
|
||||
return;
|
||||
}
|
||||
|
||||
const commandHandlers: Record<string, (subArgs: string[]) => Promise<void>> = {
|
||||
launch: async (subArgs) => {
|
||||
const { handleBarLaunch } = await import('./launch-subcommand');
|
||||
await handleBarLaunch(subArgs);
|
||||
},
|
||||
install: async (subArgs) => {
|
||||
const { handleBarInstall } = await import('./install-subcommand');
|
||||
await handleBarInstall(subArgs);
|
||||
},
|
||||
uninstall: async (subArgs) => {
|
||||
const { handleBarUninstall } = await import('./uninstall-subcommand');
|
||||
await handleBarUninstall(subArgs);
|
||||
},
|
||||
};
|
||||
|
||||
// Bare `ccs bar` → launch
|
||||
if (!subcommand || subcommand === 'launch') {
|
||||
await commandHandlers.launch(subcommand ? args.slice(1) : []);
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = commandHandlers[subcommand];
|
||||
if (!handler) {
|
||||
console.error(`[X] Unknown bar subcommand: ${subcommand}`);
|
||||
console.error('[i] Usage: ccs bar [launch|install|uninstall|version|--help]');
|
||||
return;
|
||||
}
|
||||
|
||||
await handler(args.slice(1));
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
/**
|
||||
* `ccs bar install` — download the CCS Bar app from the floating
|
||||
* `ccs-bar-latest` GitHub release tag and install to ~/Applications.
|
||||
*
|
||||
* Intentionally uses a FLOATING tag (not the exact CLI version) so the
|
||||
* Swift app can be rebuilt and published independently. After extraction,
|
||||
* the real app version is read from the bundle's Contents/Info.plist
|
||||
* (CFBundleShortVersionString) and pinned to ~/.ccs/bar/.version.
|
||||
*
|
||||
* Post-install compat check: single GET {baseUrl}/api/bar/summary.
|
||||
* 200 = server serves the bar API (compatible).
|
||||
* 404 = server too old (actionable warning).
|
||||
* Other / unreachable = soft-warn, never hard-fail install.
|
||||
*
|
||||
* Mirrors the download/version-pin pattern in src/cliproxy/binary-manager.ts.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir } from '../../config/config-loader-facade';
|
||||
import { hasAnyFlag } from '../arg-extractor';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Floating release tag — never pin to exact CLI semver. */
|
||||
const BAR_RELEASE_TAG = 'ccs-bar-latest';
|
||||
const BAR_APP_NAME = 'CCS Bar.app';
|
||||
const BAR_ASSET_NAME = 'CCS-Bar.app.zip';
|
||||
const BAR_GITHUB_REPO = 'kaitranntt/ccs';
|
||||
|
||||
/**
|
||||
* Allowlist of hostnames from which we will accept asset downloads.
|
||||
* GitHub releases redirect from github.com to objects.githubusercontent.com.
|
||||
*
|
||||
* TODO(checksum-v2): once release assets ship a checksums.txt/.sha256 file,
|
||||
* wire SHA-256 verification here. The download URL is already validated for
|
||||
* host+HTTPS as a v1 minimum guard. The verifier hook below is the intended
|
||||
* extension point.
|
||||
*/
|
||||
const DOWNLOAD_HOST_ALLOWLIST: ReadonlyArray<string> = [
|
||||
'github.com',
|
||||
'objects.githubusercontent.com',
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ReleaseAssetResult {
|
||||
downloadUrl: string;
|
||||
}
|
||||
|
||||
export interface CompatResult {
|
||||
compatible: boolean;
|
||||
reason: 'ok' | 'no-bar-api' | 'unreachable';
|
||||
}
|
||||
|
||||
export interface InstallDeps {
|
||||
/**
|
||||
* Resolve the asset download URL from a GitHub release tag.
|
||||
* Production: calls GitHub API releases/tags/{tag}.
|
||||
* Test: mock that returns a fake URL.
|
||||
*/
|
||||
fetchReleaseAsset: (tag: string, asset: string) => Promise<ReleaseAssetResult>;
|
||||
/**
|
||||
* Download the zip archive and extract the .app bundle into dest/.
|
||||
* Production: uses undici to stream + extract (with redirect + status check).
|
||||
*/
|
||||
downloadAndExtract: (url: string, dest: string) => Promise<void>;
|
||||
/**
|
||||
* GET {baseUrl}/api/bar/summary — capability handshake.
|
||||
* 200 → compatible; 404 → no-bar-api; else/unreachable → unreachable.
|
||||
* Never hard-fails install.
|
||||
*/
|
||||
verifyCompat: (baseUrl: string) => Promise<CompatResult>;
|
||||
/**
|
||||
* Read CFBundleShortVersionString from {appPath}/Contents/Info.plist.
|
||||
* Returns null if the file is absent or unreadable.
|
||||
*/
|
||||
readAppBundleVersion: (appPath: string) => string | null;
|
||||
/** Returns path to ~/.ccs (respects CCS_HOME). */
|
||||
getCcsDir: () => string;
|
||||
/** Destination directory for the .app bundle (~/Applications by default). */
|
||||
getAppsDir: () => string;
|
||||
/**
|
||||
* Clear the macOS Gatekeeper quarantine attribute from the installed app.
|
||||
* Run via `/usr/bin/xattr -dr com.apple.quarantine <appPath>` (execFile, not shell-string).
|
||||
* Returns true on success, false if xattr is unavailable or the call fails (non-fatal).
|
||||
* Injectable for tests — avoids touching the real system.
|
||||
*/
|
||||
clearQuarantine: (appPath: string) => Promise<boolean>;
|
||||
/**
|
||||
* Invoke handleBarLaunch after a successful install when the user consents.
|
||||
* Injectable so tests can assert invocation without starting a real server.
|
||||
*/
|
||||
launchBar: (args: string[]) => Promise<void>;
|
||||
/**
|
||||
* Ask the user whether to launch CCS Bar now (stdin-TTY-only).
|
||||
* Returns true on yes/default, false on no or non-TTY stdin.
|
||||
* Injectable for tests.
|
||||
*/
|
||||
promptLaunch: () => Promise<boolean>;
|
||||
/**
|
||||
* Detect whether CCS Bar is already running.
|
||||
* Production: uses /usr/bin/pgrep -x CCSBar (exit 0 = running, exit 1 = not found).
|
||||
* Returns false on any error (treat pgrep failure as not running).
|
||||
* Injectable for tests — avoids touching the real process table.
|
||||
*/
|
||||
isBarRunning: () => Promise<boolean>;
|
||||
/**
|
||||
* Remove an existing app bundle before extraction so a malformed archive
|
||||
* cannot silently leave the old version in place.
|
||||
* Production: fs.rmSync(appPath, { recursive: true, force: true }).
|
||||
* Injectable for tests — avoids real filesystem side-effects.
|
||||
* Throws on failure; caller catches and aborts install.
|
||||
*/
|
||||
removeExistingApp: (appPath: string) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host allowlist validation (Finding #9)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validate that a download URL is https and its hostname is in the allowlist
|
||||
* (exact match or *.githubusercontent.com wildcard).
|
||||
* Throws a descriptive Error if validation fails.
|
||||
*/
|
||||
export function validateDownloadUrl(url: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new Error(`Download URL is not a valid URL: ${url}`);
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'https:') {
|
||||
throw new Error(`Download URL must use HTTPS, got: ${parsed.protocol} for ${url}`);
|
||||
}
|
||||
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
const allowed =
|
||||
(DOWNLOAD_HOST_ALLOWLIST as string[]).includes(host) || host.endsWith('.githubusercontent.com');
|
||||
|
||||
if (!allowed) {
|
||||
throw new Error(
|
||||
`Download URL hostname "${host}" is not in the trusted allowlist ` +
|
||||
`(${DOWNLOAD_HOST_ALLOWLIST.join(', ')}, *.githubusercontent.com). ` +
|
||||
`Refusing to download from untrusted host.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Production implementation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function defaultFetchReleaseAsset(tag: string, asset: string): Promise<ReleaseAssetResult> {
|
||||
const { request } = await import('undici');
|
||||
const apiUrl = `https://api.github.com/repos/${BAR_GITHUB_REPO}/releases/tags/${tag}`;
|
||||
const { statusCode, body } = await request(apiUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'ccs-cli',
|
||||
Accept: 'application/vnd.github+json',
|
||||
},
|
||||
});
|
||||
|
||||
if (statusCode !== 200) {
|
||||
throw new Error(`GitHub API returned ${statusCode} for tag ${tag}`);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const release = (await body.json()) as any;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const found = (release.assets as any[]).find((a: { name: string }) => a.name === asset);
|
||||
if (!found) {
|
||||
throw new Error(`Asset "${asset}" not found in release ${tag}`);
|
||||
}
|
||||
|
||||
return { downloadUrl: found.browser_download_url as string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a zip from `url` (following up to 5 GitHub 302 redirects, re-validating
|
||||
* each hop's Location hostname) and extract its contents into `dest`.
|
||||
*
|
||||
* Fixes applied:
|
||||
* - Fix #6: maxRedirections:0 + manual redirect following so every hop's Location
|
||||
* header is passed through validateDownloadUrl before following it.
|
||||
* The previous maxRedirections:5 let undici follow redirects to ANY host unchecked.
|
||||
* - Fix #14: list zip entries with `unzip -l` before extracting; reject the archive
|
||||
* if any entry path contains ".." or starts with "/" (zip-slip guard).
|
||||
* - Finding #11: check `statusCode` and throw a descriptive error before streaming.
|
||||
* - Finding #9: validate host+HTTPS before making the first request.
|
||||
*/
|
||||
async function defaultDownloadAndExtract(url: string, dest: string): Promise<void> {
|
||||
const { request } = await import('undici');
|
||||
const { createWriteStream, mkdirSync } = fs;
|
||||
const { promisify } = await import('util');
|
||||
const { pipeline } = await import('stream');
|
||||
const streamPipeline = promisify(pipeline);
|
||||
const { execFile } = await import('child_process');
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Validate initial URL (Finding #9)
|
||||
validateDownloadUrl(url);
|
||||
|
||||
mkdirSync(dest, { recursive: true });
|
||||
|
||||
// Fix #6: follow redirects manually so each hop is re-validated.
|
||||
const MAX_REDIRECTS = 5;
|
||||
let currentUrl = url;
|
||||
let redirectsFollowed = 0;
|
||||
|
||||
while (true) {
|
||||
const { statusCode, headers, body } = await request(currentUrl, {
|
||||
maxRedirections: 0, // disable undici's auto-follow; we follow manually
|
||||
});
|
||||
|
||||
if (statusCode >= 300 && statusCode < 400) {
|
||||
const location = Array.isArray(headers['location'])
|
||||
? headers['location'][0]
|
||||
: headers['location'];
|
||||
|
||||
if (!location) {
|
||||
throw new Error(`Redirect (HTTP ${statusCode}) from ${currentUrl} has no Location header`);
|
||||
}
|
||||
|
||||
// Resolve relative redirects against the current URL
|
||||
const resolved = new URL(location, currentUrl).toString();
|
||||
|
||||
// Re-validate the redirect target — this is the key fix for #6
|
||||
validateDownloadUrl(resolved);
|
||||
|
||||
if (redirectsFollowed >= MAX_REDIRECTS) {
|
||||
throw new Error(`Too many redirects (>${MAX_REDIRECTS}) while downloading ${url}`);
|
||||
}
|
||||
|
||||
// Drain the body to free the socket before following the redirect
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (body as any).dump?.();
|
||||
currentUrl = resolved;
|
||||
redirectsFollowed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (statusCode !== 200) {
|
||||
throw new Error(`Download failed: HTTP ${statusCode} for ${currentUrl}`);
|
||||
}
|
||||
|
||||
const tmpZip = path.join(os.tmpdir(), `ccs-bar-${Date.now()}.zip`);
|
||||
|
||||
// Stream body to tmpZip
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await streamPipeline(body as any, createWriteStream(tmpZip));
|
||||
|
||||
// Fix #14: zip-slip guard — inspect entries before extraction.
|
||||
// `unzip -l` lists entries in a machine-readable format; we scan for ".." or
|
||||
// absolute paths that would escape the destination directory.
|
||||
try {
|
||||
const { stdout: listing } = await execFileAsync('unzip', ['-l', tmpZip]);
|
||||
const lines = listing.split('\n');
|
||||
for (const line of lines) {
|
||||
// Entry lines look like: " <size> <date> <time> <path>"
|
||||
// We extract the path from the last whitespace-delimited field.
|
||||
const match = /^\s+\d+\s+[\d-]+\s+[\d:]+\s+(.+)$/.exec(line);
|
||||
if (!match || !match[1]) continue;
|
||||
const entryPath = match[1].trim();
|
||||
if (!entryPath || entryPath.endsWith('/')) continue; // skip directory entries
|
||||
|
||||
// Reject absolute paths and paths with traversal components
|
||||
if (path.isAbsolute(entryPath) || entryPath.includes('..')) {
|
||||
try {
|
||||
fs.unlinkSync(tmpZip);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(
|
||||
`Zip-slip detected: archive entry "${entryPath}" contains a path traversal ` +
|
||||
`component. Refusing to extract.`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// If the guard itself throws (e.g. zip-slip detected above), propagate it.
|
||||
// If it's a system error (unzip not available), let extraction proceed and
|
||||
// surface the issue then.
|
||||
if ((err as Error).message?.includes('Zip-slip')) throw err;
|
||||
// Warn but continue — extraction will likely also fail if unzip is missing
|
||||
console.error(
|
||||
`[!] Zip entry scan failed (will attempt extraction): ${(err as Error).message}`
|
||||
);
|
||||
}
|
||||
|
||||
// Extract the zip into dest
|
||||
await execFileAsync('unzip', ['-o', tmpZip, '-d', dest]);
|
||||
|
||||
// Clean up the temp archive
|
||||
try {
|
||||
fs.unlinkSync(tmpZip);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-request capability handshake: GET {baseUrl}/api/bar/summary.
|
||||
* 200 → server serves the bar API (compatible).
|
||||
* 404 → server is too old and does not serve the bar API.
|
||||
* Any other status or network error → soft-warn (unreachable).
|
||||
*
|
||||
* The route is loopback-gated server-side; install-time baseUrl is always
|
||||
* loopback (bar.json baseUrl or http://127.0.0.1:3000), so the gate passes.
|
||||
*/
|
||||
async function defaultVerifyCompat(baseUrl: string): Promise<CompatResult> {
|
||||
try {
|
||||
const { request } = await import('undici');
|
||||
const { statusCode, body } = await request(`${baseUrl}/api/bar/summary`, {
|
||||
headers: { 'User-Agent': 'ccs-cli' },
|
||||
});
|
||||
|
||||
// Drain the body regardless of status to free the socket.
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (body as any).dump?.();
|
||||
} catch {
|
||||
/* ignore drain errors */
|
||||
}
|
||||
|
||||
if (statusCode === 200) {
|
||||
return { compatible: true, reason: 'ok' };
|
||||
}
|
||||
if (statusCode === 404) {
|
||||
return { compatible: false, reason: 'no-bar-api' };
|
||||
}
|
||||
return { compatible: false, reason: 'unreachable' };
|
||||
} catch {
|
||||
// Network error or server not running.
|
||||
return { compatible: false, reason: 'unreachable' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read CFBundleShortVersionString from {appPath}/Contents/Info.plist.
|
||||
* The plist is XML text post-codesign (verified on the installed app).
|
||||
* Returns null on any error (missing file, binary plist, parse failure).
|
||||
*/
|
||||
function defaultReadAppBundleVersion(appPath: string): string | null {
|
||||
try {
|
||||
const plistPath = path.join(appPath, 'Contents', 'Info.plist');
|
||||
const contents = fs.readFileSync(plistPath, 'utf8');
|
||||
const match = /<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/.exec(
|
||||
contents
|
||||
);
|
||||
if (!match || !match[1]) return null;
|
||||
const v = match[1].trim();
|
||||
return v || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultGetCcsDir(): string {
|
||||
return getCcsDir();
|
||||
}
|
||||
|
||||
function defaultGetAppsDir(): string {
|
||||
return path.join(os.homedir(), 'Applications');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the macOS Gatekeeper quarantine attribute from the installed app.
|
||||
* Uses absolute path `/usr/bin/xattr` to avoid PATH hijacking.
|
||||
* Runs `/usr/bin/xattr -dr com.apple.quarantine <appPath>` via execFile (not shell-string)
|
||||
* so the path is passed as an argument, not interpolated into a shell command.
|
||||
* Returns true on success, false on any error (non-fatal — install already succeeded).
|
||||
*/
|
||||
async function defaultClearQuarantine(appPath: string): Promise<boolean> {
|
||||
try {
|
||||
const { execFile } = await import('child_process');
|
||||
const { promisify } = await import('util');
|
||||
const execFileAsync = promisify(execFile);
|
||||
await execFileAsync('/usr/bin/xattr', ['-dr', 'com.apple.quarantine', appPath]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultRemoveExistingApp(appPath: string): void {
|
||||
fs.rmSync(appPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function defaultLaunchBar(args: string[]): Promise<void> {
|
||||
const { handleBarLaunch } = await import('./launch-subcommand');
|
||||
await handleBarLaunch(args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether CCS Bar is already running using `/usr/bin/pgrep -x CCSBar`.
|
||||
* pgrep exits 0 when a match is found (running), 1 when no match (not running).
|
||||
* execFile surfaces exit code 1 as an error, so we catch and return false.
|
||||
* Any other error (pgrep unavailable, permission denied) is also treated as not running.
|
||||
*/
|
||||
async function defaultIsBarRunning(): Promise<boolean> {
|
||||
try {
|
||||
const { execFile } = await import('child_process');
|
||||
const { promisify } = await import('util');
|
||||
const execFileAsync = promisify(execFile);
|
||||
await execFileAsync('/usr/bin/pgrep', ['-x', 'CCSBar']);
|
||||
return true;
|
||||
} catch {
|
||||
// pgrep exits 1 when no match found; execFile throws for non-zero exit codes.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the user whether to launch CCS Bar now.
|
||||
* Only prompts when stdin is a TTY — stdin is what the prompt actually reads.
|
||||
* Non-TTY stdin: return false (caller prints guidance).
|
||||
* Default answer is yes (Enter = launch).
|
||||
*/
|
||||
async function defaultPromptLaunch(): Promise<boolean> {
|
||||
if (!process.stdin.isTTY) {
|
||||
return false;
|
||||
}
|
||||
const readline = await import('readline');
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: true,
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
rl.question('Launch CCS Bar now? [Y/n] ', (answer: string) => {
|
||||
rl.close();
|
||||
const normalized = answer.trim().toLowerCase();
|
||||
// Empty (Enter) or 'y'/'yes' → launch
|
||||
resolve(normalized === '' || normalized === 'y' || normalized === 'yes');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Version pin helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getBarVersionFilePath(ccsDir: string): string {
|
||||
return path.join(ccsDir, 'bar', '.version');
|
||||
}
|
||||
|
||||
function pinBarVersion(ccsDir: string, version: string): void {
|
||||
const versionFile = getBarVersionFilePath(ccsDir);
|
||||
fs.mkdirSync(path.dirname(versionFile), { recursive: true });
|
||||
fs.writeFileSync(versionFile, version);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function handleBarInstall(
|
||||
args: string[],
|
||||
deps: Partial<InstallDeps> = {}
|
||||
): Promise<void> {
|
||||
// Parse --launch / --no-launch flags before delegating to deps.
|
||||
const forceLaunch = hasAnyFlag(args, ['--launch']);
|
||||
const noLaunch = hasAnyFlag(args, ['--no-launch']);
|
||||
|
||||
const fetchReleaseAsset = deps.fetchReleaseAsset ?? defaultFetchReleaseAsset;
|
||||
const downloadAndExtract = deps.downloadAndExtract ?? defaultDownloadAndExtract;
|
||||
const verifyCompat = deps.verifyCompat ?? defaultVerifyCompat;
|
||||
const readAppBundleVersion = deps.readAppBundleVersion ?? defaultReadAppBundleVersion;
|
||||
const clearQuarantine = deps.clearQuarantine ?? defaultClearQuarantine;
|
||||
const launchBar = deps.launchBar ?? defaultLaunchBar;
|
||||
const promptLaunch = deps.promptLaunch ?? defaultPromptLaunch;
|
||||
const isBarRunning = deps.isBarRunning ?? defaultIsBarRunning;
|
||||
const removeExistingApp = deps.removeExistingApp ?? defaultRemoveExistingApp;
|
||||
const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)();
|
||||
const appsDir = (deps.getAppsDir ?? defaultGetAppsDir)();
|
||||
|
||||
// 0. Already-installed detection: show the current version before proceeding.
|
||||
const appPath = path.join(appsDir, BAR_APP_NAME);
|
||||
if (fs.existsSync(appPath)) {
|
||||
const existingVersion = readAppBundleVersion(appPath);
|
||||
if (existingVersion !== null) {
|
||||
console.log(`[i] CCS Bar v${existingVersion} is already installed. Reinstalling...`);
|
||||
} else {
|
||||
console.log('[i] CCS Bar is already installed. Reinstalling...');
|
||||
}
|
||||
} else {
|
||||
console.log('[i] Fetching CCS Bar release info...');
|
||||
}
|
||||
|
||||
// 1. Resolve the floating tag → download URL.
|
||||
let releaseInfo: ReleaseAssetResult;
|
||||
try {
|
||||
releaseInfo = await fetchReleaseAsset(BAR_RELEASE_TAG, BAR_ASSET_NAME);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Failed to fetch release asset: ${msg}`);
|
||||
console.error('[i] Check your network connection and try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { downloadUrl } = releaseInfo;
|
||||
console.log(`[i] Installing CCS Bar from ${BAR_RELEASE_TAG}...`);
|
||||
|
||||
// 1b. Stage-then-swap: extract into a hidden staging dir on the same filesystem
|
||||
// so the rename to appPath is atomic-ish. The old bundle is only removed
|
||||
// AFTER the new one is verified in staging — a transient download/extraction
|
||||
// failure during reinstall therefore leaves the previous working install intact.
|
||||
fs.mkdirSync(appsDir, { recursive: true });
|
||||
let stagingDir: string;
|
||||
try {
|
||||
stagingDir = fs.mkdtempSync(path.join(appsDir, '.ccs-bar-staging-'));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Could not create staging directory in ${appsDir}: ${msg}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Helper: remove staging dir, ignoring errors (best-effort cleanup).
|
||||
function cleanupStaging(): void {
|
||||
try {
|
||||
fs.rmSync(stagingDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Download and extract into staging, NOT appsDir.
|
||||
try {
|
||||
await downloadAndExtract(downloadUrl, stagingDir);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Download or extraction failed: ${msg}`);
|
||||
cleanupStaging();
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Finding #12: assert that the expected .app bundle was actually extracted
|
||||
// into the staging dir before touching the old install.
|
||||
const stagedApp = path.join(stagingDir, BAR_APP_NAME);
|
||||
if (!fs.existsSync(stagedApp)) {
|
||||
// Report what was actually extracted to help diagnose archive issues.
|
||||
let extracted: string[] = [];
|
||||
try {
|
||||
extracted = fs.readdirSync(stagingDir);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const found = extracted.length > 0 ? extracted.join(', ') : '(none)';
|
||||
console.error(`[X] Extraction succeeded but "${BAR_APP_NAME}" was not found in staging.`);
|
||||
console.error(`[i] Files found in staging: ${found}`);
|
||||
cleanupStaging();
|
||||
return;
|
||||
}
|
||||
|
||||
// 3b. New bundle verified in staging — now safe to remove the old install.
|
||||
if (fs.existsSync(appPath)) {
|
||||
try {
|
||||
removeExistingApp(appPath);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Could not remove the existing CCS Bar.app: ${msg}`);
|
||||
console.error('[i] Close any running instance and try again.');
|
||||
cleanupStaging();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3c. Atomic-ish rename: move staged bundle into the final location.
|
||||
try {
|
||||
fs.renameSync(stagedApp, appPath);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Could not move staged app into place: ${msg}`);
|
||||
cleanupStaging();
|
||||
return;
|
||||
}
|
||||
|
||||
// Staging dir is now empty (bundle was moved out); remove it.
|
||||
cleanupStaging();
|
||||
|
||||
// 4. Read the real version from the extracted bundle's Info.plist.
|
||||
const installedVersion = readAppBundleVersion(appPath);
|
||||
|
||||
if (installedVersion !== null) {
|
||||
// Pin the real version string.
|
||||
try {
|
||||
pinBarVersion(ccsDir, installedVersion);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[!] Could not write version pin: ${msg}`);
|
||||
// Non-fatal — continue.
|
||||
}
|
||||
console.log(`[OK] CCS Bar v${installedVersion} installed to ${appsDir}/${BAR_APP_NAME}`);
|
||||
} else {
|
||||
// Info.plist unreadable — best-effort remove any stale version pin from a previous install,
|
||||
// so `ccs bar version` does not show an outdated version string.
|
||||
try {
|
||||
fs.rmSync(path.join(ccsDir, 'bar', '.version'), { force: true });
|
||||
} catch {
|
||||
/* non-fatal — ignore */
|
||||
}
|
||||
// No pin written; ASCII notice; no crash.
|
||||
console.log(`[OK] CCS Bar installed to ${appsDir}/${BAR_APP_NAME}`);
|
||||
console.log('[!] Could not read app version from Info.plist.');
|
||||
}
|
||||
|
||||
// 5. Capability handshake via GET /api/bar/summary.
|
||||
// Runs BEFORE the quarantine-clear + launch handoff because it is a server-side
|
||||
// check unrelated to Gatekeeper. A failed quarantine clear must not prevent the
|
||||
// compat result from being shown to the user.
|
||||
// Read bar.json for baseUrl if present; otherwise fall back to localhost:3000.
|
||||
const barJsonPath = path.join(ccsDir, 'bar.json');
|
||||
let baseUrl = 'http://127.0.0.1:3000';
|
||||
try {
|
||||
const raw = fs.readFileSync(barJsonPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as { baseUrl?: string };
|
||||
if (parsed.baseUrl) baseUrl = parsed.baseUrl;
|
||||
} catch {
|
||||
/* bar.json absent — use fallback */
|
||||
}
|
||||
|
||||
try {
|
||||
const compat = await verifyCompat(baseUrl);
|
||||
if (compat.reason === 'ok') {
|
||||
console.log('[OK] Server bar API reachable.');
|
||||
} else if (compat.reason === 'no-bar-api') {
|
||||
console.log(
|
||||
'[!] CCS server does not serve the bar API (/api/bar/summary returned 404).' +
|
||||
' Update CCS, then restart `ccs bar`.'
|
||||
);
|
||||
} else {
|
||||
// unreachable — soft-warn
|
||||
console.log('[!] Could not verify server compatibility (server may not be running).');
|
||||
console.log('[i] Run `ccs bar` to start the server and recheck.');
|
||||
}
|
||||
} catch {
|
||||
console.log('[!] Could not verify server compatibility (server may not be running).');
|
||||
console.log('[i] Run `ccs bar` to start the server and recheck.');
|
||||
}
|
||||
|
||||
// 6. Quarantine handling: run `xattr -dr com.apple.quarantine` automatically.
|
||||
// This clears the Gatekeeper quarantine flag that ad-hoc builds receive on download.
|
||||
// On success: print [OK] confirmation. On failure: fall back to printed guidance and
|
||||
// SKIP the launch handoff entirely — launching a still-quarantined app hits the
|
||||
// Gatekeeper block, so the user must clear quarantine manually first.
|
||||
const quarantineCleared = await clearQuarantine(appPath);
|
||||
if (quarantineCleared) {
|
||||
console.log('[OK] Cleared Gatekeeper quarantine.');
|
||||
} else {
|
||||
console.log('[i] Gatekeeper note (ad-hoc build):');
|
||||
console.log(' If macOS says the app is "damaged" or "unverified", run:');
|
||||
console.log(` xattr -dr com.apple.quarantine "${appPath}"`);
|
||||
console.log(' Or right-click the app and select Open.');
|
||||
console.log('[i] After clearing quarantine, run `ccs bar` to launch.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 7. Launch handoff.
|
||||
// Already-running check: if CCS Bar is running after a (re)install, skip the
|
||||
// prompt entirely and print a restart hint (the user needs to quit and reopen
|
||||
// to pick up the new version). --launch with the app already running still
|
||||
// proceeds — the launch flow opens/activates the app, which is harmless.
|
||||
//
|
||||
// --launch: skip prompt and launch immediately.
|
||||
// --no-launch: skip prompt, print guidance.
|
||||
// stdin-TTY: ask interactively (default yes).
|
||||
// Non-TTY stdin: print guidance, no prompt.
|
||||
const barIsRunning = await isBarRunning();
|
||||
|
||||
if (barIsRunning && !forceLaunch) {
|
||||
console.log(
|
||||
'[!] CCS Bar is currently running an older build. Quit it from the menu bar, then run `ccs bar` to relaunch the updated app.'
|
||||
);
|
||||
} else if (forceLaunch) {
|
||||
await launchBar([]);
|
||||
} else if (noLaunch) {
|
||||
console.log('[i] Run `ccs bar` to launch.');
|
||||
} else {
|
||||
const shouldLaunch = await promptLaunch();
|
||||
if (shouldLaunch) {
|
||||
await launchBar([]);
|
||||
} else {
|
||||
// User declined or non-TTY stdin — always print guidance so the user
|
||||
// knows how to start the app after install.
|
||||
console.log('[i] Run `ccs bar` to launch later.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* `ccs bar launch` — ensure web-server is up, write ~/.ccs/bar.json, open the app.
|
||||
*
|
||||
* bar.json shape (v1):
|
||||
* { baseUrl: string, port: number, authMode: "loopback" }
|
||||
*
|
||||
* authMode is always "loopback" for v1 (auth-enabled unsupported until v1.1).
|
||||
*
|
||||
* Reuse-first behavior: before starting a new server, launch probes the candidate
|
||||
* ports (bar.json port first, then 3000, 3001, 3002, 8000, 8080) for a live CCS
|
||||
* server at GET /api/bar/summary. A 200 response means the server is already
|
||||
* running; launch reuses it without attempting to bind a new one.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir } from '../../config/config-loader-facade';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BarDiscoveryJson {
|
||||
baseUrl: string;
|
||||
port: number;
|
||||
authMode: 'loopback';
|
||||
}
|
||||
|
||||
export interface DashboardInfo {
|
||||
port: number;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export interface LaunchDeps {
|
||||
/**
|
||||
* Probe candidate ports for a running CCS server.
|
||||
* Returns { port, baseUrl } of the first live server found, or null if none.
|
||||
* Never throws — any error is treated as "not found".
|
||||
*/
|
||||
findRunningServer: () => Promise<DashboardInfo | null>;
|
||||
/**
|
||||
* Ensure the CCS web-server / dashboard is running.
|
||||
* Returns { port, baseUrl } of the running server.
|
||||
* Throws if the server cannot be started (degraded path).
|
||||
*/
|
||||
ensureDashboard: () => Promise<DashboardInfo>;
|
||||
/** Open the installed .app bundle. Throws if the app is not found. */
|
||||
openApp: (appPath: string) => Promise<void>;
|
||||
/** Returns path to ~/.ccs (respects CCS_HOME for test isolation). */
|
||||
getCcsDir: () => string;
|
||||
/** Full path where the .app should be installed, e.g. ~/Applications/CCS Bar.app */
|
||||
appInstallPath: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Port discovery helpers (exported for unit tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Read the port recorded in an existing bar.json.
|
||||
* Returns null when the file is absent or malformed.
|
||||
*/
|
||||
export function resolveBarPort(ccsDir: string): number | null {
|
||||
const barJsonPath = path.join(ccsDir, 'bar.json');
|
||||
try {
|
||||
const raw = fs.readFileSync(barJsonPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as Partial<BarDiscoveryJson>;
|
||||
return typeof parsed.port === 'number' ? parsed.port : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default production dependencies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Probe candidate ports for a running CCS server.
|
||||
*
|
||||
* Both IPv4 (127.0.0.1) and IPv6 (::1) loopback addresses are probed for
|
||||
* each port. This is necessary because `ccs config` starts the server with
|
||||
* host 'localhost', which macOS resolves to ::1 — an IPv4-only probe would
|
||||
* miss that server and start a redundant second instance.
|
||||
*
|
||||
* All probes are fired concurrently (cheap loopback requests) so worst-case
|
||||
* latency is ~1.5 s (one timeout) rather than N × 1.5 s sequentially.
|
||||
* Priority selection is still applied after all results are in: the bar.json
|
||||
* port is preferred over the defaults (3000, 3001, 3002, 8000, 8080), and
|
||||
* within a port 127.0.0.1 is preferred over [::1].
|
||||
*/
|
||||
export async function defaultFindRunningServer(ccsDir: string): Promise<DashboardInfo | null> {
|
||||
const { request } = await import('undici');
|
||||
|
||||
/**
|
||||
* Probe a single URL. Resolves to { ok: true } on HTTP 200, { ok: false }
|
||||
* on any other status or error (connection refused, timeout, etc.).
|
||||
* Never rejects — all errors are absorbed so Promise.all never short-circuits.
|
||||
*/
|
||||
async function probe(url: string): Promise<{ ok: boolean }> {
|
||||
try {
|
||||
const { statusCode, body } = await request(url, {
|
||||
method: 'GET',
|
||||
headersTimeout: 1500,
|
||||
bodyTimeout: 1500,
|
||||
});
|
||||
// Drain the body to avoid hanging sockets regardless of status.
|
||||
// body.dump() is not available in Bun's undici shim; body.text() works
|
||||
// cross-runtime and fully consumes the response stream.
|
||||
await body.text();
|
||||
return { ok: statusCode === 200 };
|
||||
} catch {
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
const barJsonPort = resolveBarPort(ccsDir);
|
||||
const base = [3000, 3001, 3002, 8000, 8080];
|
||||
const candidates: number[] =
|
||||
barJsonPort !== null ? [barJsonPort, ...base.filter((p) => p !== barJsonPort)] : base;
|
||||
|
||||
// Build the ordered list of (port, host, baseUrl) tuples. Within each port,
|
||||
// IPv4 comes before IPv6 to preserve the existing priority semantics.
|
||||
const probeTargets = candidates.flatMap((port) => [
|
||||
{ port, baseUrl: `http://127.0.0.1:${port}`, url: `http://127.0.0.1:${port}/api/bar/summary` },
|
||||
{ port, baseUrl: `http://[::1]:${port}`, url: `http://[::1]:${port}/api/bar/summary` },
|
||||
]);
|
||||
|
||||
// Fire all probes concurrently. Each probe resolves (never rejects), so
|
||||
// Promise.all collects all results without short-circuiting on failure.
|
||||
const results = await Promise.all(probeTargets.map((t) => probe(t.url)));
|
||||
|
||||
// Walk the results in priority order and return the first successful hit.
|
||||
for (let i = 0; i < probeTargets.length; i++) {
|
||||
if (results[i].ok) {
|
||||
const { port, baseUrl } = probeTargets[i];
|
||||
return { port, baseUrl };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function defaultEnsureDashboard(): Promise<DashboardInfo> {
|
||||
// Reuse the same startup path as `ccs config`:
|
||||
// find a free port then start the web-server via startServer().
|
||||
const getPort = (await import('get-port')).default;
|
||||
const { startServer } = await import('../../web-server');
|
||||
|
||||
// Pass host: '127.0.0.1' so getPort probes the same address that startServer
|
||||
// will bind. On macOS, a wildcard bind and a specific 127.0.0.1 bind are
|
||||
// independent: getPort without a host can return 3000 "free" while a live
|
||||
// CCS server already holds 127.0.0.1:3000, causing EADDRINUSE on startServer.
|
||||
const port = await getPort({ port: [3000, 3001, 3002, 8000, 8080], host: '127.0.0.1' });
|
||||
// Bind IPv4 loopback explicitly. Without a host, startServer defaults to
|
||||
// 'localhost', which on macOS resolves to ::1 (IPv6) — but bar.json's baseUrl
|
||||
// (and the Swift app) use 127.0.0.1, so the app could not reach its own server.
|
||||
const { server } = await startServer({ port, host: '127.0.0.1' });
|
||||
|
||||
const addr = server.address();
|
||||
const resolvedPort = addr && typeof addr === 'object' ? addr.port : port;
|
||||
const baseUrl = `http://127.0.0.1:${resolvedPort}`;
|
||||
return { port: resolvedPort, baseUrl };
|
||||
}
|
||||
|
||||
async function defaultOpenApp(appPath: string): Promise<void> {
|
||||
const { execFile } = await import('child_process');
|
||||
const { promisify } = await import('util');
|
||||
const execFileAsync = promisify(execFile);
|
||||
await execFileAsync('open', ['-a', appPath]);
|
||||
}
|
||||
|
||||
function defaultGetCcsDir(): string {
|
||||
return getCcsDir();
|
||||
}
|
||||
|
||||
// Fix #5: use os.homedir() to match install-subcommand.ts and uninstall-subcommand.ts.
|
||||
// process.env.HOME may be unset in restricted environments, and CCS_HOME is the CCS
|
||||
// data directory (~/.ccs), not the user's home — neither is a safe fallback here.
|
||||
const DEFAULT_APP_INSTALL_PATH = path.join(os.homedir(), 'Applications', 'CCS Bar.app');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function handleBarLaunch(
|
||||
_args: string[],
|
||||
deps: Partial<LaunchDeps> = {}
|
||||
): Promise<void> {
|
||||
const ensureDashboard = deps.ensureDashboard ?? defaultEnsureDashboard;
|
||||
const openApp = deps.openApp ?? defaultOpenApp;
|
||||
const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)();
|
||||
const appInstallPath = deps.appInstallPath ?? DEFAULT_APP_INSTALL_PATH;
|
||||
|
||||
// Wire findRunningServer after ccsDir is resolved so the default impl can
|
||||
// read bar.json from the correct directory.
|
||||
const findRunningServer = deps.findRunningServer ?? (() => defaultFindRunningServer(ccsDir));
|
||||
|
||||
// 1. Try to reuse an already-running CCS server; fall back to starting one.
|
||||
// Probe errors are treated as "not found" — they never abort the launch flow.
|
||||
let running: DashboardInfo | null = null;
|
||||
try {
|
||||
running = await findRunningServer();
|
||||
} catch {
|
||||
// Any probe error counts as null; ensureDashboard() is the fallback.
|
||||
}
|
||||
|
||||
let dashboardInfo: DashboardInfo;
|
||||
let reused = false;
|
||||
if (running !== null) {
|
||||
dashboardInfo = running;
|
||||
reused = true;
|
||||
} else {
|
||||
try {
|
||||
dashboardInfo = await ensureDashboard();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Could not start CCS web-server: ${msg}`);
|
||||
console.error('[i] Run `ccs config` to start the dashboard manually.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Write bar.json — this is the single source of discovery for the Swift app.
|
||||
const barJson: BarDiscoveryJson = {
|
||||
baseUrl: dashboardInfo.baseUrl,
|
||||
port: dashboardInfo.port,
|
||||
authMode: 'loopback',
|
||||
};
|
||||
|
||||
try {
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(ccsDir, 'bar.json'), JSON.stringify(barJson, null, 2));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Failed to write bar.json: ${msg}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (reused) {
|
||||
console.log(`[OK] Reusing running CCS web-server at ${dashboardInfo.baseUrl}`);
|
||||
} else {
|
||||
console.log(`[OK] CCS web-server running at ${dashboardInfo.baseUrl}`);
|
||||
}
|
||||
console.log(`[i] Discovery file written: ${path.join(ccsDir, 'bar.json')}`);
|
||||
|
||||
// 3. Open the app.
|
||||
try {
|
||||
await openApp(appInstallPath);
|
||||
console.log('[OK] CCS Bar launched.');
|
||||
} catch {
|
||||
// Degraded path: app not installed or open failed.
|
||||
if (!fs.existsSync(appInstallPath)) {
|
||||
console.log('[!] CCS Bar app is not installed.');
|
||||
console.log('[i] Run `ccs bar install` to install it.');
|
||||
} else {
|
||||
console.log('[!] Could not open CCS Bar. Try right-clicking and selecting Open.');
|
||||
console.log('[i] If Gatekeeper blocks the app, run:');
|
||||
console.log(` xattr -dr com.apple.quarantine "${appInstallPath}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* `ccs bar uninstall` — remove CCS Bar.app from ~/Applications
|
||||
* and clear the version pin at ~/.ccs/bar/.version.
|
||||
*
|
||||
* No-op (and no error) when neither the app nor the pin exists.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir } from '../../config/config-loader-facade';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UninstallDeps {
|
||||
getCcsDir: () => string;
|
||||
getAppsDir: () => string;
|
||||
appName: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function handleBarUninstall(
|
||||
_args: string[],
|
||||
deps: Partial<UninstallDeps> = {}
|
||||
): Promise<void> {
|
||||
const ccsDir = (deps.getCcsDir ?? (() => getCcsDir()))();
|
||||
const appsDir = (deps.getAppsDir ?? (() => path.join(os.homedir(), 'Applications')))();
|
||||
const appName = deps.appName ?? 'CCS Bar.app';
|
||||
|
||||
const appPath = path.join(appsDir, appName);
|
||||
const versionPin = path.join(ccsDir, 'bar', '.version');
|
||||
|
||||
let removed = false;
|
||||
|
||||
// Remove the .app bundle (a directory on macOS).
|
||||
if (fs.existsSync(appPath)) {
|
||||
try {
|
||||
fs.rmSync(appPath, { recursive: true, force: true });
|
||||
console.log(`[OK] Removed ${appPath}`);
|
||||
removed = true;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Failed to remove ${appPath}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the version pin.
|
||||
if (fs.existsSync(versionPin)) {
|
||||
try {
|
||||
fs.unlinkSync(versionPin);
|
||||
removed = true;
|
||||
} catch {
|
||||
// Non-fatal — pin may already be gone.
|
||||
}
|
||||
}
|
||||
|
||||
if (!removed) {
|
||||
console.log('[i] CCS Bar is not installed — nothing to remove.');
|
||||
} else {
|
||||
console.log('[OK] CCS Bar uninstalled.');
|
||||
console.log('[i] Run `ccs bar install` to reinstall.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* `ccs bar --version` / `ccs bar version`
|
||||
*
|
||||
* Prints the CCS CLI version alongside the installed CCS Bar app version
|
||||
* (read from ~/.ccs/bar/.version, if present).
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getVersion } from '../../utils/version';
|
||||
import { getCcsDir } from '../../config/config-loader-facade';
|
||||
|
||||
function readInstalledBarVersion(ccsDir: string): string | null {
|
||||
const versionFile = path.join(ccsDir, 'bar', '.version');
|
||||
try {
|
||||
const content = fs.readFileSync(versionFile, 'utf8').trim();
|
||||
return content || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleBarVersion(): Promise<void> {
|
||||
const cliVersion = getVersion();
|
||||
const ccsDir = getCcsDir();
|
||||
const barVersion = readInstalledBarVersion(ccsDir);
|
||||
|
||||
// Finding #13: label each line unambiguously — CLI version vs installed Bar app version.
|
||||
console.log(`[i] CCS CLI v${cliVersion}`);
|
||||
if (barVersion) {
|
||||
console.log(`[i] CCS Bar app: v${barVersion}`);
|
||||
} else {
|
||||
console.log('[i] CCS Bar app: not installed (run `ccs bar install`)');
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Tests for handleOrderSubcommand presentation + reset behavior.
|
||||
*
|
||||
* Why these matter:
|
||||
* - File-mode SHOW must render from the shared resolver (selector pick order +
|
||||
* drift), not an alphabetical re-sort. Otherwise the displayed order is the
|
||||
* inverse of what CLIProxy actually drains whenever residual on-disk
|
||||
* priorities exist (e.g. left by a prior managed order), and the drift
|
||||
* warning the manual/tier branch shows is silently dropped.
|
||||
* - `--reset` must actually strip residual priorities from the auth files, not
|
||||
* just delete the stored config. With the proxy STOPPED the field is removed
|
||||
* by a direct atomic write; the running-proxy PATCH path is covered at the
|
||||
* clearDrainOrderPriorities unit level (drain-order.test.ts).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
mock.module('../../../cliproxy/proxy/proxy-detector', () => ({
|
||||
detectRunningProxy: async () => ({ running: false, verified: false }),
|
||||
}));
|
||||
|
||||
describe('handleOrderSubcommand', () => {
|
||||
let tempHome: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
let logSpy: ReturnType<typeof spyOn>;
|
||||
let lines: string[];
|
||||
|
||||
function authDir(): string {
|
||||
return path.join(tempHome, '.ccs', 'cliproxy', 'auth');
|
||||
}
|
||||
|
||||
function writeAuthFile(fileName: string, fields: Record<string, unknown> = {}): void {
|
||||
fs.mkdirSync(authDir(), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(
|
||||
path.join(authDir(), fileName),
|
||||
JSON.stringify({ type: 'claude', ...fields }, null, 2),
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
}
|
||||
|
||||
function readAuthFile(fileName: string): Record<string, unknown> {
|
||||
return JSON.parse(fs.readFileSync(path.join(authDir(), fileName), 'utf-8')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
|
||||
async function registerClaude(): Promise<{
|
||||
registerAccount: (provider: string, tokenFile: string, email: string) => unknown;
|
||||
saveDrainOrderConfig: (provider: string, config: unknown) => boolean;
|
||||
}> {
|
||||
return import(`../../../cliproxy/accounts/registry?order-subcommand=${Date.now()}`);
|
||||
}
|
||||
|
||||
async function runOrderSubcommand(args: string[]): Promise<void> {
|
||||
const { handleOrderSubcommand } = await import(
|
||||
`../order-subcommand?order-subcommand=${Date.now()}`
|
||||
);
|
||||
await handleOrderSubcommand(args);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-order-subcommand-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
process.exitCode = 0;
|
||||
lines = [];
|
||||
logSpy = spyOn(console, 'log').mockImplementation((msg?: unknown) => {
|
||||
if (typeof msg === 'string') lines.push(msg);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore();
|
||||
process.exitCode = 0;
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('file-mode show with residual priorities', () => {
|
||||
it('renders selector pick order (priority desc) and flags drift instead of alphabetical order', async () => {
|
||||
// Residual on-disk priorities, no stored config -> file mode + drift.
|
||||
// claude-a sorts first alphabetically, but b has the higher priority, so
|
||||
// the selector drains b first. The display must follow the selector.
|
||||
writeAuthFile('claude-a.json', { email: 'a@x.com', priority: 1 });
|
||||
writeAuthFile('claude-b.json', { email: 'b@x.com', priority: 5 });
|
||||
|
||||
const { registerAccount } = await registerClaude();
|
||||
registerAccount('claude', 'claude-a.json', 'a@x.com');
|
||||
registerAccount('claude', 'claude-b.json', 'b@x.com');
|
||||
|
||||
await runOrderSubcommand(['claude']);
|
||||
|
||||
const output = lines.join('\n');
|
||||
// b@x.com (priority 5) must appear before a@x.com (priority 1).
|
||||
const idxB = output.indexOf('b@x.com');
|
||||
const idxA = output.indexOf('a@x.com');
|
||||
expect(idxB).toBeGreaterThanOrEqual(0);
|
||||
expect(idxA).toBeGreaterThan(idxB);
|
||||
|
||||
// Drift surfaced (same as the manual/tier branch), and the mode label no
|
||||
// longer falsely claims "no priority set" under residual priorities.
|
||||
expect(output).toContain('Drift detected');
|
||||
expect(output).toContain('residual priorities present');
|
||||
expect(output).not.toContain('no priority set');
|
||||
// [priority: N] annotations preserved.
|
||||
expect(output).toContain('[priority: 5]');
|
||||
expect(output).toContain('[priority: 1]');
|
||||
});
|
||||
|
||||
it('keeps the plain "no priority set" label and no drift when there are no residuals', async () => {
|
||||
writeAuthFile('claude-a.json', { email: 'a@x.com' });
|
||||
writeAuthFile('claude-b.json', { email: 'b@x.com' });
|
||||
|
||||
const { registerAccount } = await registerClaude();
|
||||
registerAccount('claude', 'claude-a.json', 'a@x.com');
|
||||
registerAccount('claude', 'claude-b.json', 'b@x.com');
|
||||
|
||||
await runOrderSubcommand(['claude']);
|
||||
|
||||
const output = lines.join('\n');
|
||||
expect(output).toContain('no priority set');
|
||||
expect(output).not.toContain('Drift detected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('--reset clears residual priorities (proxy stopped -> direct write)', () => {
|
||||
it('removes the priority field from auth files and reports per-file results', async () => {
|
||||
writeAuthFile('claude-a.json', { email: 'a@x.com', priority: 4 });
|
||||
writeAuthFile('claude-b.json', { email: 'b@x.com' }); // already clear
|
||||
|
||||
const { registerAccount, saveDrainOrderConfig } = await registerClaude();
|
||||
registerAccount('claude', 'claude-a.json', 'a@x.com');
|
||||
registerAccount('claude', 'claude-b.json', 'b@x.com');
|
||||
saveDrainOrderConfig('claude', { mode: 'manual', orderedIds: ['a@x.com', 'b@x.com'] });
|
||||
|
||||
await runOrderSubcommand(['claude', '--reset']);
|
||||
|
||||
// Residual priority is actually gone from disk (not just config deleted).
|
||||
expect('priority' in readAuthFile('claude-a.json')).toBe(false);
|
||||
|
||||
const output = lines.join('\n');
|
||||
expect(output).toContain('reset to file order');
|
||||
// Honest per-file reporting, and it no longer claims residuals remain.
|
||||
expect(output).toContain('Cleared residual priority from 1 auth file');
|
||||
expect(output).not.toContain('CLIProxy will continue using them');
|
||||
});
|
||||
|
||||
it('reports already-clear files and still resets when no priorities exist', async () => {
|
||||
writeAuthFile('claude-a.json', { email: 'a@x.com' });
|
||||
|
||||
const { registerAccount } = await registerClaude();
|
||||
registerAccount('claude', 'claude-a.json', 'a@x.com');
|
||||
|
||||
await runOrderSubcommand(['claude', '--reset']);
|
||||
|
||||
const output = lines.join('\n');
|
||||
expect(output).toContain('reset to file order');
|
||||
expect(output).toContain('no priority set');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Tests for the CLI pool-state renderer helpers: state labels (the three
|
||||
* account states must be NAMED differently because they map to different client
|
||||
* failure modes), reset formatting, and drain-order mode labels.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { describeAccountState, formatCooldownReset, modeLabel } from '../pool-state-renderer';
|
||||
import type { PoolAccountState } from '../../../cliproxy/accounts/pool-state';
|
||||
|
||||
function state(
|
||||
partial: Partial<PoolAccountState> & { state: PoolAccountState['state'] }
|
||||
): PoolAccountState {
|
||||
return {
|
||||
accountId: 'a@x.com',
|
||||
tokenFile: 'a.json',
|
||||
isDefault: false,
|
||||
...partial,
|
||||
} as PoolAccountState;
|
||||
}
|
||||
|
||||
describe('describeAccountState', () => {
|
||||
it('names available, cooling, and paused states distinctly', () => {
|
||||
const now = 1_000_000;
|
||||
const available = describeAccountState(state({ state: 'available' }), now);
|
||||
const paused = describeAccountState(state({ state: 'paused' }), now);
|
||||
const cooling = describeAccountState(
|
||||
state({ state: 'cooling', cooldownUntil: now + 5 * 60 * 1000, cooldownSource: 'persisted' }),
|
||||
now
|
||||
);
|
||||
|
||||
expect(available.label).toBe('available');
|
||||
expect(available.tone).toBe('available');
|
||||
// The pause label must NOT claim "manual" - a pause can be automatic (ban
|
||||
// detection, cross-provider isolation, expired-but-unrestored cooldown).
|
||||
expect(paused.label).toBe('paused (manual or safety)');
|
||||
expect(paused.label).not.toBe('paused (manual)');
|
||||
expect(paused.tone).toBe('paused');
|
||||
expect(cooling.label).toContain('cooling until');
|
||||
expect(cooling.tone).toBe('cooling');
|
||||
|
||||
// The three labels must be mutually distinct (no conflation).
|
||||
const labels = new Set([available.label, paused.label, cooling.label]);
|
||||
expect(labels.size).toBe(3);
|
||||
});
|
||||
|
||||
it('annotates an in-process (memory) cooldown source', () => {
|
||||
const now = 1_000_000;
|
||||
const cooling = describeAccountState(
|
||||
state({ state: 'cooling', cooldownUntil: now + 60_000, cooldownSource: 'memory' }),
|
||||
now
|
||||
);
|
||||
expect(cooling.label).toContain('[in-process]');
|
||||
});
|
||||
|
||||
it('shows unknown when a cooling account has no reset time', () => {
|
||||
const cooling = describeAccountState(state({ state: 'cooling' }), 1_000_000);
|
||||
expect(cooling.label).toContain('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCooldownReset', () => {
|
||||
it('returns now for a non-positive delta', () => {
|
||||
expect(formatCooldownReset(1_000_000, 1_000_000)).toContain('now');
|
||||
});
|
||||
|
||||
it('uses minute granularity within the hour', () => {
|
||||
const now = 1_000_000;
|
||||
expect(formatCooldownReset(now + 5 * 60 * 1000, now)).toContain('5m');
|
||||
});
|
||||
|
||||
it('uses hour granularity within the day', () => {
|
||||
const now = 1_000_000;
|
||||
expect(formatCooldownReset(now + 3 * 3600 * 1000, now)).toContain('3h');
|
||||
});
|
||||
});
|
||||
|
||||
describe('modeLabel', () => {
|
||||
it('maps each drain order mode to a stable label', () => {
|
||||
expect(modeLabel('manual')).toBe('manual (--set)');
|
||||
expect(modeLabel('tier')).toBe('tier-derived (--by-tier)');
|
||||
expect(modeLabel('file')).toBe('file order');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// renderProviderPoolSection - integration (resume hint, file-mode drift copy,
|
||||
// pool-on cooling honesty note). Uses an isolated CCS_HOME and captures console.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('renderProviderPoolSection', () => {
|
||||
let tempHome: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
let logSpy: ReturnType<typeof spyOn>;
|
||||
let lines: string[];
|
||||
|
||||
function writeAuthFile(fileName: string, fields: Record<string, unknown> = {}): void {
|
||||
const authDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
|
||||
fs.mkdirSync(authDir, { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(
|
||||
path.join(authDir, fileName),
|
||||
JSON.stringify({ type: 'claude', ...fields }, null, 2),
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
}
|
||||
|
||||
const SETTINGS_OFF = {
|
||||
poolEnabled: false,
|
||||
strategy: 'round-robin',
|
||||
sessionAffinityEnabled: false,
|
||||
sessionAffinityTtl: '1h',
|
||||
} as const;
|
||||
|
||||
const SETTINGS_ON = {
|
||||
poolEnabled: true,
|
||||
strategy: 'fill-first',
|
||||
sessionAffinityEnabled: true,
|
||||
sessionAffinityTtl: '1h',
|
||||
} as const;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-pool-renderer-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
process.exitCode = 0;
|
||||
lines = [];
|
||||
logSpy = spyOn(console, 'log').mockImplementation((msg?: unknown) => {
|
||||
if (typeof msg === 'string') lines.push(msg);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore();
|
||||
process.exitCode = 0;
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('emits a resume hint pointing at the real command when an account is paused', async () => {
|
||||
writeAuthFile('claude-a.json', { email: 'a@x.com' });
|
||||
writeAuthFile('claude-b.json', { email: 'b@x.com' });
|
||||
|
||||
const { registerAccount, pauseAccount } = await import(
|
||||
`../../../cliproxy/accounts/registry?renderer-resume=${Date.now()}`
|
||||
);
|
||||
registerAccount('claude', 'claude-a.json', 'a@x.com');
|
||||
registerAccount('claude', 'claude-b.json', 'b@x.com');
|
||||
pauseAccount('claude', 'a@x.com');
|
||||
|
||||
const { renderProviderPoolSection } = await import(
|
||||
`../pool-state-renderer?renderer-resume=${Date.now()}`
|
||||
);
|
||||
await renderProviderPoolSection('claude', SETTINGS_OFF, 1_000_000);
|
||||
|
||||
const output = lines.join('\n');
|
||||
// The hint names the actual resume subcommand (ccs cliproxy resume <account>).
|
||||
expect(output).toContain('Resume:');
|
||||
expect(output).toContain('ccs cliproxy resume <account>');
|
||||
});
|
||||
|
||||
it('does NOT emit a resume hint when no account is paused', async () => {
|
||||
writeAuthFile('claude-a.json', { email: 'a@x.com' });
|
||||
const { registerAccount } = await import(
|
||||
`../../../cliproxy/accounts/registry?renderer-no-resume=${Date.now()}`
|
||||
);
|
||||
registerAccount('claude', 'claude-a.json', 'a@x.com');
|
||||
|
||||
const { renderProviderPoolSection } = await import(
|
||||
`../pool-state-renderer?renderer-no-resume=${Date.now()}`
|
||||
);
|
||||
await renderProviderPoolSection('claude', SETTINGS_OFF, 1_000_000);
|
||||
|
||||
expect(lines.join('\n')).not.toContain('Resume:');
|
||||
});
|
||||
|
||||
it('uses honest file-mode drift copy (no "stored order") when residual priorities exist', async () => {
|
||||
// Residual on-disk priorities, but no stored drain config -> file mode drift.
|
||||
writeAuthFile('claude-a.json', { email: 'a@x.com', priority: 1 });
|
||||
writeAuthFile('claude-b.json', { email: 'b@x.com', priority: 5 });
|
||||
|
||||
const { registerAccount } = await import(
|
||||
`../../../cliproxy/accounts/registry?renderer-drift=${Date.now()}`
|
||||
);
|
||||
registerAccount('claude', 'claude-a.json', 'a@x.com');
|
||||
registerAccount('claude', 'claude-b.json', 'b@x.com');
|
||||
|
||||
const { renderProviderPoolSection } = await import(
|
||||
`../pool-state-renderer?renderer-drift=${Date.now()}`
|
||||
);
|
||||
await renderProviderPoolSection('claude', SETTINGS_OFF, 1_000_000);
|
||||
|
||||
const output = lines.join('\n');
|
||||
expect(output).toContain('Drift:');
|
||||
// File mode has no stored order, so the copy must not claim one nor tell the
|
||||
// user to "re-apply with --set/--by-tier" (which re-adopts managed ordering).
|
||||
expect(output).toContain('residual priorities');
|
||||
expect(output).toContain('--reset');
|
||||
expect(output).not.toContain('stored order does not match');
|
||||
expect(output).not.toContain('re-apply with --set/--by-tier');
|
||||
});
|
||||
|
||||
it('prints the in-proxy-cooldown honesty note when pool is ON but no proxy data is available', async () => {
|
||||
writeAuthFile('claude-a.json', { email: 'a@x.com' });
|
||||
const { registerAccount } = await import(
|
||||
`../../../cliproxy/accounts/registry?renderer-note=${Date.now()}`
|
||||
);
|
||||
registerAccount('claude', 'claude-a.json', 'a@x.com');
|
||||
|
||||
const { renderProviderPoolSection } = await import(
|
||||
`../pool-state-renderer?renderer-note=${Date.now()}`
|
||||
);
|
||||
// No proxy is running in the test home, so the fetch degrades to no data and
|
||||
// the honesty note must be printed instead of implying every account is fine.
|
||||
await renderProviderPoolSection('claude', SETTINGS_ON, 1_000_000);
|
||||
|
||||
expect(lines.join('\n')).toContain('live in-proxy 429 cooldowns are not shown here');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Tests for handlePoolSubcommand: remote-target refusal and local lifecycle-port
|
||||
* resolution.
|
||||
*
|
||||
* Why these matter:
|
||||
* - Remote refusal: enable/disable only mutate LOCAL config files. On a remote
|
||||
* target the running proxy is never touched, so flipping the local flag and
|
||||
* printing a hot-reload success message would lie. The command must refuse
|
||||
* (exitCode 1) and leave the local pool flag untouched.
|
||||
* - Port resolution: a user on a custom local.port (e.g. 9000) must have
|
||||
* config-9000.yaml regenerated (the file the running proxy reads), not the
|
||||
* default config.yaml.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
|
||||
|
||||
import { handlePoolSubcommand } from '../pool-subcommand';
|
||||
import {
|
||||
invalidateConfigCache as invalidateSharedConfigCache,
|
||||
mutateConfig,
|
||||
} from '../../../config/config-loader-facade';
|
||||
|
||||
function createTestHome(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-pool-subcommand-test-'));
|
||||
const ccsDir = path.join(dir, '.ccs');
|
||||
fs.mkdirSync(path.join(ccsDir, 'cliproxy', 'auth'), { recursive: true });
|
||||
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'version: 1\n', 'utf8');
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('handlePoolSubcommand', () => {
|
||||
let tempHome: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
let logSpy: ReturnType<typeof spyOn>;
|
||||
let lines: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = createTestHome();
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
invalidateSharedConfigCache();
|
||||
process.exitCode = 0;
|
||||
lines = [];
|
||||
logSpy = spyOn(console, 'log').mockImplementation((msg?: unknown) => {
|
||||
if (typeof msg === 'string') lines.push(msg);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore();
|
||||
process.exitCode = 0;
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('refuses --enable on a remote target, sets exitCode 1, and does not flip the local flag', async () => {
|
||||
mutateConfig((cfg) => {
|
||||
cfg.cliproxy_server = {
|
||||
remote: { enabled: true, host: '192.168.1.50', protocol: 'http' },
|
||||
};
|
||||
});
|
||||
invalidateSharedConfigCache();
|
||||
|
||||
await handlePoolSubcommand(['--enable']);
|
||||
|
||||
const output = lines.join('\n');
|
||||
expect(output).toContain('Remote proxy target detected');
|
||||
// Manual-config guidance is shown for the enable path
|
||||
expect(output).toContain('disable-cooling: false');
|
||||
expect(output).toContain('strategy: fill-first');
|
||||
expect(process.exitCode).toBe(1);
|
||||
|
||||
// Local pool flag must remain untouched (not enabled).
|
||||
invalidateSharedConfigCache();
|
||||
const { loadOrCreateUnifiedConfig } = await import(
|
||||
`../../../config/config-loader-facade?poolremote=${Date.now()}`
|
||||
);
|
||||
const cfg = loadOrCreateUnifiedConfig();
|
||||
expect(cfg.cliproxy?.pool_routing?.enabled).not.toBe(true);
|
||||
|
||||
// No local config.yaml should have been regenerated by the refused command.
|
||||
const cliproxyConfig = path.join(tempHome, '.ccs', 'cliproxy', 'config.yaml');
|
||||
expect(fs.existsSync(cliproxyConfig)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses --disable on a remote target without the enable-only manual snippet', async () => {
|
||||
mutateConfig((cfg) => {
|
||||
cfg.cliproxy_server = {
|
||||
remote: { enabled: true, host: '192.168.1.50', protocol: 'http' },
|
||||
};
|
||||
});
|
||||
invalidateSharedConfigCache();
|
||||
|
||||
await handlePoolSubcommand(['--disable']);
|
||||
|
||||
const output = lines.join('\n');
|
||||
expect(output).toContain('Remote proxy target detected');
|
||||
// The disable refusal must NOT print the enable manual snippet
|
||||
expect(output).not.toContain('disable-cooling: false');
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('--enable on a local custom port regenerates config-<port>.yaml, not config.yaml', async () => {
|
||||
// Configure a custom local lifecycle port. resolveLifecyclePort() must pick
|
||||
// it up so the regenerated file matches what the running proxy reads.
|
||||
mutateConfig((cfg) => {
|
||||
cfg.cliproxy_server = { local: { port: 9000 } };
|
||||
});
|
||||
invalidateSharedConfigCache();
|
||||
|
||||
await handlePoolSubcommand(['--enable']);
|
||||
|
||||
const cliproxyDir = path.join(tempHome, '.ccs', 'cliproxy');
|
||||
const customConfig = path.join(cliproxyDir, 'config-9000.yaml');
|
||||
const defaultConfig = path.join(cliproxyDir, 'config.yaml');
|
||||
|
||||
// The custom-port config file must be the one regenerated.
|
||||
expect(fs.existsSync(customConfig)).toBe(true);
|
||||
// The default-port config.yaml must NOT be written by a custom-port enable.
|
||||
expect(fs.existsSync(defaultConfig)).toBe(false);
|
||||
|
||||
// And the regenerated file must carry the pool-on rails.
|
||||
const content = fs.readFileSync(customConfig, 'utf-8');
|
||||
expect(content).toContain('disable-cooling: false');
|
||||
expect(content).toContain('strategy: fill-first');
|
||||
// Success path must not flag a failure exit code.
|
||||
expect(process.exitCode).not.toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -56,13 +56,32 @@ export async function showHelp(): Promise<void> {
|
||||
['default <account>', 'Set default account for rotation'],
|
||||
['pause <account>', 'Pause account (skip in rotation)'],
|
||||
['resume <account>', 'Resume paused account'],
|
||||
['quota', 'Show quota status for all providers (Codex/Claude include 5h + weekly reset)'],
|
||||
[
|
||||
'quota',
|
||||
'Show quota status + pool context (drain order, per-account available/cooling/paused)',
|
||||
],
|
||||
['quota --provider <name>', `Filter by provider (${QUOTA_PROVIDER_HELP_TEXT})`],
|
||||
['routing', 'Show current routing strategy and manual guidance'],
|
||||
['routing explain', 'Explain strategy vs session-affinity and how sessions are recognized'],
|
||||
['routing set <mode>', 'Explicitly set round-robin or fill-first'],
|
||||
['routing affinity', 'Show local session-affinity status and TTL'],
|
||||
['routing affinity <on|off> [--ttl <duration>]', 'Toggle local session-affinity settings'],
|
||||
['pool', 'Show pool routing status (fill-first + affinity + 429 cooldown)'],
|
||||
['pool --enable', 'Enable pool routing (writes cooling/affinity/retry-cap to config)'],
|
||||
['pool --disable', 'Disable pool routing and restore non-pool config'],
|
||||
[
|
||||
'accounts order <provider>',
|
||||
'Show effective drain order (priority bucket desc, then ID asc)',
|
||||
],
|
||||
[
|
||||
'accounts order <provider> --by-tier',
|
||||
'Set drain order from tier metadata (ultra > pro > free)',
|
||||
],
|
||||
[
|
||||
'accounts order <provider> --set a,b,c',
|
||||
'Set manual drain order (comma-separated account IDs)',
|
||||
],
|
||||
['accounts order <provider> --reset', 'Revert drain order to stable file order'],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -48,6 +48,8 @@ import {
|
||||
handleCatalogReset,
|
||||
handleCatalogJson,
|
||||
} from './catalog-subcommand';
|
||||
import { handlePoolSubcommand } from './pool-subcommand';
|
||||
import { handleOrderSubcommand } from './order-subcommand';
|
||||
|
||||
/**
|
||||
* Parse --backend flag from args
|
||||
@@ -186,6 +188,26 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'pool') {
|
||||
await handlePoolSubcommand(remainingArgs.slice(1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'accounts') {
|
||||
const subcommand = remainingArgs[1];
|
||||
if (subcommand === 'order') {
|
||||
await handleOrderSubcommand(remainingArgs.slice(2));
|
||||
return;
|
||||
}
|
||||
// Unknown (or missing) accounts subcommand: report and show help.
|
||||
// 'order' is currently the only accounts subcommand.
|
||||
console.error(`[X] Unknown accounts subcommand: ${subcommand ?? '(none)'}`);
|
||||
console.error(' Usage: ccs cliproxy accounts order <provider>');
|
||||
process.exitCode = 1;
|
||||
await showHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'routing') {
|
||||
const subcommand = remainingArgs[1];
|
||||
if (subcommand === 'set') {
|
||||
|
||||
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user