Merge pull request #1521 from kaitranntt/dev

feat(release): promote dev to main
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-06-14 12:13:44 -04:00
commit 0abcda17c5
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

+224
View File
@@ -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>
+4
View File
@@ -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
+12
View File
@@ -116,6 +116,17 @@ Need the full setup path instead of the short version?
## See CCS In Action
### CCS Bar For macOS
![CCS Bar](assets/screenshots/ccs-bar-panel.webp)
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
![Analytics Dashboard](assets/screenshots/analytics.webp)
@@ -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 -5
View File
@@ -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"
}
}
+98
View File
@@ -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
```
+5
View File
@@ -0,0 +1,5 @@
.build/
dist/
*.zip
DerivedData/
*.xcodeproj
+23
View File
@@ -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"]),
]
)
+46
View File
@@ -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

+26
View File
@@ -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>
+74
View File
@@ -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"
}
}
}
+196
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+18 -8
View File
@@ -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(',') }
: {}),
+15 -1
View File
@@ -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.
}
}
+55 -6
View File
@@ -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('');
+714
View File
@@ -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);
}
}
+316
View File
@@ -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 });
}
+50 -1
View File
@@ -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;
});
}
+22
View File
@@ -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 */
+36
View File
@@ -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',
+179
View File
@@ -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');
});
});
+23 -16
View File
@@ -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,
+175 -26
View File
@@ -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
+69 -9
View File
@@ -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
+1
View File
@@ -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 };
+5 -1
View File
@@ -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);
}
+22
View File
@@ -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);
+15
View File
@@ -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',
+68 -24
View File
@@ -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);
}
/**
+4
View File
@@ -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',
};
}
+54 -1
View File
@@ -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' };
}
}
+280
View File
@@ -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 };
}
+390 -5
View File
@@ -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,
};
}
+26 -1
View File
@@ -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;
+66
View File
@@ -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('');
}
+58
View File
@@ -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));
}
+700
View File
@@ -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.');
}
}
}
+262
View File
@@ -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}"`);
}
}
}
+68
View File
@@ -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.');
}
}
+37
View File
@@ -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);
});
});
+20 -1
View File
@@ -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'],
],
],
[
+22
View File
@@ -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