mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-18 08:24:33 +00:00
Merge pull request #1493 from kaitranntt/kai/feat/ccs-bar
feat(bar): CCS Bar — native macOS menu-bar app for live quota & usage
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# 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.
|
||||
|
||||
### Gatekeeper note
|
||||
|
||||
The v1 builds use ad-hoc signing, so the first launch may be blocked by Gatekeeper. Either right-click the app and choose Open, or clear the quarantine attribute:
|
||||
|
||||
```bash
|
||||
xattr -dr com.apple.quarantine "$HOME/Applications/CCS Bar.app"
|
||||
```
|
||||
|
||||
## Launch
|
||||
|
||||
```bash
|
||||
ccs bar # alias: ccs bar launch
|
||||
```
|
||||
|
||||
This makes sure the web-server is up, writes the discovery file `~/.ccs/bar.json`, and opens the app. 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
|
||||
|
||||
- Server failed to start: usually a port conflict. Free the port or re-run `ccs bar` to pick a fresh one.
|
||||
- App won't open (Gatekeeper): right-click and Open, or clear quarantine with the `xattr` command above.
|
||||
- Blank app: the web-server is not running. Re-run `ccs bar` and confirm `~/.ccs/bar.json` exists.
|
||||
- Quota not updating: re-open the menu to force a refresh, or confirm the server is still reachable on loopback.
|
||||
|
||||
## Development
|
||||
|
||||
The source lives in `macos-bar/`. Contributors can build and run the logic checks with a Swift 5.9+ toolchain (CommandLineTools is enough, full Xcode not required):
|
||||
|
||||
```bash
|
||||
swift build # build all targets, including the app
|
||||
swift run ccs-bar-check # run the logic tests
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
.build/
|
||||
dist/
|
||||
*.zip
|
||||
DerivedData/
|
||||
*.xcodeproj
|
||||
@@ -0,0 +1,23 @@
|
||||
// swift-tools-version:5.9
|
||||
import PackageDescription
|
||||
|
||||
// CCS Bar - native macOS menu bar client for CCS.
|
||||
//
|
||||
// Build/test note: full Xcode (and therefore XCTest) is not required. The
|
||||
// testable logic lives in the pure-Foundation `CCSBarCore` target and is
|
||||
// exercised by the `ccs-bar-check` executable (an assert harness) so it runs
|
||||
// on a CommandLineTools-only toolchain. The SwiftUI app target is added once
|
||||
// the core is verified.
|
||||
let package = Package(
|
||||
name: "CCSBar",
|
||||
platforms: [.macOS(.v13)],
|
||||
products: [
|
||||
.executable(name: "CCSBar", targets: ["CCSBarApp"]),
|
||||
.executable(name: "ccs-bar-check", targets: ["CCSBarCheck"]),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "CCSBarCore"),
|
||||
.executableTarget(name: "CCSBarApp", dependencies: ["CCSBarCore"]),
|
||||
.executableTarget(name: "CCSBarCheck", dependencies: ["CCSBarCore"]),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
# CCS Bar (macOS)
|
||||
|
||||
Native SwiftUI menu bar app for CCS. A thin client of the CCS local web-server:
|
||||
it glances per-account quota, cost and tier, and performs account control
|
||||
(pause/resume, set default, solo, tier-lock) from the menu bar.
|
||||
|
||||
The app never talks to a provider directly. Every call goes to `localhost`, and
|
||||
CCS performs any provider fetch server-side. Opening the menu fires a debounced
|
||||
force-refresh so the glance reflects live data without blocking the UI.
|
||||
|
||||
## Layout
|
||||
|
||||
- `Sources/CCSBarCore` — pure Foundation logic (no SwiftUI): API client,
|
||||
discovery handshake, models, formatting, refresh debounce. Fully unit-tested.
|
||||
- `Sources/CCSBarApp` — SwiftUI `MenuBarExtra` app: view-model + views.
|
||||
- `Sources/CCSBarCheck` — runnable assert harness used in place of XCTest
|
||||
(XCTest ships with full Xcode; this builds on a CommandLineTools toolchain).
|
||||
|
||||
## Build and test
|
||||
|
||||
Requires a Swift 5.9+ toolchain (CommandLineTools is enough; full Xcode not
|
||||
required for build/test).
|
||||
|
||||
```bash
|
||||
swift build # build all targets, including the app
|
||||
swift run ccs-bar-check # run the logic tests (exits non-zero on failure)
|
||||
```
|
||||
|
||||
## Discovery
|
||||
|
||||
The app reads `~/.ccs/bar.json` (written by `ccs bar launch`):
|
||||
|
||||
```json
|
||||
{ "baseUrl": "http://127.0.0.1:3000", "port": 3000, "authMode": "loopback" }
|
||||
```
|
||||
|
||||
v1 supports `authMode: "loopback"` only (dashboard auth disabled, localhost).
|
||||
|
||||
## Packaging
|
||||
|
||||
`Scripts/package_app.sh` assembles `CCS Bar.app` from a release build and signs
|
||||
it. v1 uses ad-hoc signing (`CCS_BAR_SIGNING=adhoc`, the default); users open it
|
||||
the first time via right-click then Open, or clear quarantine with
|
||||
`xattr -dr com.apple.quarantine "/Applications/CCS Bar.app"`. Developer ID
|
||||
signing + notarization (`CCS_BAR_SIGNING=developer-id`) is the public-launch
|
||||
path and is not required for ad-hoc distribution.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>CCSBar</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>ca.kaitran.ccs.bar</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>CCS Bar</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>CCS Bar</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>__VERSION__</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>__VERSION__</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>13.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>CCS Bar</string>
|
||||
</dict>
|
||||
</plist>
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# Assemble and sign "CCS Bar.app" from a release build.
|
||||
#
|
||||
# Signing mode (CCS_BAR_SIGNING):
|
||||
# adhoc (default) ad-hoc sign with `codesign -s -`. Free, no Apple
|
||||
# Developer account. Users open via right-click > Open or clear
|
||||
# quarantine with `xattr -dr com.apple.quarantine`.
|
||||
# developer-id Sign with a Developer ID Application identity (set
|
||||
# CCS_BAR_SIGN_IDENTITY) for the notarized public-launch path.
|
||||
#
|
||||
# Usage:
|
||||
# ./Scripts/package_app.sh [version]
|
||||
# CCS_BAR_SIGNING=developer-id CCS_BAR_SIGN_IDENTITY="Developer ID Application: ..." ./Scripts/package_app.sh 0.1.0
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-0.0.0}"
|
||||
SIGNING="${CCS_BAR_SIGNING:-adhoc}"
|
||||
APP_NAME="CCS Bar"
|
||||
EXEC_NAME="CCSBar"
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
DIST="$ROOT/dist"
|
||||
APP="$DIST/$APP_NAME.app"
|
||||
|
||||
echo "[i] Building release binary..."
|
||||
( cd "$ROOT" && swift build -c release )
|
||||
BIN="$ROOT/.build/release/$EXEC_NAME"
|
||||
if [[ ! -x "$BIN" ]]; then
|
||||
echo "[X] Release binary not found at $BIN" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[i] Assembling $APP_NAME.app (version $VERSION)..."
|
||||
rm -rf "$APP"
|
||||
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
||||
cp "$BIN" "$APP/Contents/MacOS/$EXEC_NAME"
|
||||
sed "s/__VERSION__/$VERSION/g" "$ROOT/Resources/Info.plist" > "$APP/Contents/Info.plist"
|
||||
|
||||
# Bundle the CCS icon assets (menu-bar color/template + header logo) so
|
||||
# Bundle.main can resolve them at runtime.
|
||||
if [[ -d "$ROOT/Resources/Assets" ]]; then
|
||||
cp "$ROOT/Resources/Assets/"*.png "$APP/Contents/Resources/" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "[i] Signing ($SIGNING)..."
|
||||
case "$SIGNING" in
|
||||
adhoc)
|
||||
codesign --force --deep --sign - "$APP"
|
||||
;;
|
||||
developer-id)
|
||||
: "${CCS_BAR_SIGN_IDENTITY:?Set CCS_BAR_SIGN_IDENTITY for developer-id signing}"
|
||||
codesign --force --deep --options runtime --timestamp \
|
||||
--sign "$CCS_BAR_SIGN_IDENTITY" "$APP"
|
||||
echo "[i] Signed with Developer ID. Notarize before public distribution:"
|
||||
echo " xcrun notarytool submit <zip> --keychain-profile <profile> --wait"
|
||||
;;
|
||||
*)
|
||||
echo "[X] Unknown CCS_BAR_SIGNING: $SIGNING (expected adhoc|developer-id)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
ZIP="$DIST/CCS-Bar.app.zip"
|
||||
echo "[i] Zipping -> $ZIP"
|
||||
rm -f "$ZIP"
|
||||
( cd "$DIST" && ditto -c -k --keepParent "$APP_NAME.app" "CCS-Bar.app.zip" )
|
||||
|
||||
echo "[OK] Packaged: $APP"
|
||||
echo "[OK] Asset: $ZIP"
|
||||
if [[ "$SIGNING" == "adhoc" ]]; then
|
||||
echo "[!] Ad-hoc build: first launch needs right-click > Open, or"
|
||||
echo " xattr -dr com.apple.quarantine \"/Applications/$APP_NAME.app\""
|
||||
fi
|
||||
echo "[i] To publish: gh release upload ccs-bar-latest \"$ZIP\" --clobber"
|
||||
@@ -0,0 +1,209 @@
|
||||
import SwiftUI
|
||||
import CCSBarCore
|
||||
|
||||
/// Demoted spend strip + surface/model breakdown.
|
||||
///
|
||||
/// Spend is informational pool context, NEVER the headline — so the loud 2×2
|
||||
/// StatCell grid + 28pt sparkline that used to dominate the dropdown collapses
|
||||
/// into one muted caption line ("today $NN · 7d $N.Nk") with an optional thin
|
||||
/// inline sparkline. The By-surface / Top-models breakdowns stay, tightened and
|
||||
/// subordinate, below the pool accounts.
|
||||
///
|
||||
/// When the trailing 30 days carry no spend the strip reads the honest idle line
|
||||
/// ("No usage in N days") instead of three dead "$0.00" cells.
|
||||
struct BarAnalyticsView: View {
|
||||
@Environment(\.barTheme) private var theme
|
||||
let analytics: BarAnalytics
|
||||
/// Which slot of the dropdown this instance renders. `.spend` is the thin strip
|
||||
/// placed below the subscriptions cockpit; `.breakdown` is the by-surface /
|
||||
/// top-models detail placed below the pool accounts.
|
||||
enum Section { case spend, breakdown }
|
||||
var section: Section = .spend
|
||||
/// Controls whether the spend sparkline renders as bars or a line graph.
|
||||
/// Passed in by BarMenuView so it reflects the user's persisted choice live.
|
||||
var spendChartStyle: SpendChartStyle = .bars
|
||||
/// Inline flip for the spend chart style, surfaced as a small toggle in the
|
||||
/// Spend header (using the otherwise-blank space) so the user switches
|
||||
/// bars/line in place rather than digging into Settings. nil for `.breakdown`.
|
||||
var onToggleSpendStyle: (() -> Void)? = nil
|
||||
|
||||
private var lastActive: String? {
|
||||
BarFormatting.lastActiveLabel(
|
||||
iso: analytics.lastActivityAt, daysSince: analytics.daysSinceLastActivity)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
switch section {
|
||||
case .spend:
|
||||
spendStrip
|
||||
case .breakdown:
|
||||
breakdown
|
||||
}
|
||||
}
|
||||
|
||||
/// By-surface + top-models detail, tightened and subordinate.
|
||||
@ViewBuilder private var breakdown: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Surface breakdown: "how much Claude Code vs Codex" — only shown when
|
||||
// the backend supplies at least one surface entry. Top 5 keeps it compact.
|
||||
if !analytics.bySurface.isEmpty {
|
||||
SectionLabel("By surface")
|
||||
let peakSurface = analytics.bySurface.map(\.cost).max() ?? 1
|
||||
ForEach(analytics.bySurface.prefix(5)) { surface in
|
||||
SurfaceBar(surface: surface, peak: peakSurface)
|
||||
}
|
||||
}
|
||||
|
||||
// Top models.
|
||||
if !analytics.topModels.isEmpty {
|
||||
let scope = analytics.topModelsWindow == "30d" ? "30d" : "all-time"
|
||||
SectionLabel("Top models · \(scope)")
|
||||
let peak = analytics.topModels.map(\.cost).max() ?? 1
|
||||
ForEach(analytics.topModels.prefix(4)) { model in
|
||||
ModelBar(model: model, peak: peak)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True when there's any surface/model detail worth a divider + section.
|
||||
var hasBreakdown: Bool {
|
||||
!analytics.bySurface.isEmpty || !analytics.topModels.isEmpty
|
||||
}
|
||||
|
||||
/// The collapsed informational spend strip: a "SPEND" label, one muted caption
|
||||
/// line, and a thin inline 30-day sparkline when there is real spend.
|
||||
private var spendStrip: some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(spacing: 6) {
|
||||
SectionLabel("Spend")
|
||||
Spacer()
|
||||
// Inline bars/line toggle in the header's blank space — no Settings trip.
|
||||
if let toggle = onToggleSpendStyle, analytics.hasRecentData, !sparklineIsEmpty {
|
||||
Button(action: toggle) {
|
||||
Image(
|
||||
systemName: spendChartStyle == .bars
|
||||
? "chart.line.uptrend.xyaxis" : "chart.bar.fill"
|
||||
)
|
||||
.font(.system(size: 10))
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.foregroundStyle(.tertiary)
|
||||
.help("Spend graph: switch to \(spendChartStyle == .bars ? "line" : "bars")")
|
||||
}
|
||||
}
|
||||
if analytics.hasRecentData {
|
||||
Text(spendCaption)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
if !sparklineIsEmpty {
|
||||
// height: 30 (up from 18) so daily spend gradations are clearly readable.
|
||||
Sparkline(values: analytics.byDay.map(\.cost), accent: theme.accent,
|
||||
style: spendChartStyle)
|
||||
.frame(height: 30)
|
||||
}
|
||||
} else {
|
||||
Text(idleCaption)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One-line rollup: "today $NN · 7d $N.Nk · 30d $N.Nk".
|
||||
private var spendCaption: String {
|
||||
"today \(BarFormatting.money(analytics.today.cost))"
|
||||
+ " · 7d \(BarFormatting.money(analytics.last7d.cost))"
|
||||
+ " · 30d \(BarFormatting.money(analytics.last30d.cost))"
|
||||
}
|
||||
|
||||
/// Honest idle caption when there's no recent spend, folding in last-active.
|
||||
private var idleCaption: String {
|
||||
let headline =
|
||||
analytics.daysSinceLastActivity.map { "No usage in \($0) days" } ?? "No usage in 30 days"
|
||||
if let lastActive { return "\(headline) · \(lastActive.lowercased())" }
|
||||
return headline
|
||||
}
|
||||
|
||||
private var sparklineIsEmpty: Bool {
|
||||
analytics.byDay.allSatisfy { $0.cost <= 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// One usage-surface row: surface name + proportional accent bar + cost and
|
||||
/// request count. Mirrors ModelBar visually so the two sections feel cohesive.
|
||||
private struct SurfaceBar: View {
|
||||
@Environment(\.barTheme) private var theme
|
||||
let surface: BarAnalyticsSurface
|
||||
let peak: Double
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let fraction = peak > 0 ? CGFloat(surface.cost / peak) : 0
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(theme.accent.opacity(0.16))
|
||||
.frame(width: max(8, geo.size.width * fraction))
|
||||
HStack {
|
||||
Text(surface.surface)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
HStack(spacing: 4) {
|
||||
Text(BarFormatting.count(surface.requests))
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(BarFormatting.money(surface.cost))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
}
|
||||
.frame(height: 26)
|
||||
}
|
||||
}
|
||||
|
||||
/// One top-model row: name + spend with a proportional accent bar behind.
|
||||
private struct ModelBar: View {
|
||||
@Environment(\.barTheme) private var theme
|
||||
let model: BarAnalytics.Model
|
||||
let peak: Double
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let fraction = peak > 0 ? CGFloat(model.cost / peak) : 0
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(theme.accent.opacity(0.16))
|
||||
.frame(width: max(8, geo.size.width * fraction))
|
||||
HStack {
|
||||
Text(model.model)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
Text(BarFormatting.money(model.cost))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
}
|
||||
.frame(height: 26)
|
||||
}
|
||||
}
|
||||
|
||||
/// Uppercase section divider label.
|
||||
struct SectionLabel: View {
|
||||
let text: String
|
||||
init(_ text: String) { self.text = text }
|
||||
var body: some View {
|
||||
Text(text.uppercased())
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
import CCSBarCore
|
||||
|
||||
/// App-side date phrasing for the subscription card. Kept out of Core so the
|
||||
/// shared formatting contract there stays untouched. Parses ISO-8601 the same
|
||||
/// way Core does (with and without fractional seconds) so timestamp handling
|
||||
/// matches the rest of the bar.
|
||||
enum BarCardFormatting {
|
||||
/// Parse an ISO-8601 timestamp, tolerating an optional fractional-seconds
|
||||
/// component. Mirrors Core's parser, which is module-internal there.
|
||||
private static func isoDate(_ iso: String) -> Date? {
|
||||
let withFraction = ISO8601DateFormatter()
|
||||
withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let d = withFraction.date(from: iso) { return d }
|
||||
let plain = ISO8601DateFormatter()
|
||||
plain.formatOptions = [.withInternetDateTime]
|
||||
return plain.date(from: iso)
|
||||
}
|
||||
|
||||
/// Compact reset form for the per-window bar chips and split lines:
|
||||
/// <24h → compact duration via BarQuotaGauge.compactDuration (e.g. "3h 15m", "22m")
|
||||
/// <7d → weekday abbreviation (e.g. "Fri")
|
||||
/// >=7d → calendar date (e.g. "Jun 14")
|
||||
/// Returns nil for a missing/unparseable timestamp (caller omits the clause).
|
||||
static func shortReset(iso: String?, now: Date) -> String? {
|
||||
guard let iso, let date = isoDate(iso) else { return nil }
|
||||
let secs = date.timeIntervalSince(now)
|
||||
if secs <= 0 { return "due" }
|
||||
if secs < 24 * 3600 {
|
||||
// Delegate to Core's authoritative compactDuration so both layers are
|
||||
// consistent and the days-tier is automatically handled if ever needed.
|
||||
let totalMinutes = Int(secs / 60)
|
||||
return BarQuotaGauge.compactDuration(minutes: totalMinutes)
|
||||
}
|
||||
let fmt = DateFormatter()
|
||||
fmt.locale = Locale(identifier: "en_US_POSIX")
|
||||
if secs < 7 * 24 * 3600 {
|
||||
fmt.dateFormat = "EEE" // weekday, e.g. "Fri"
|
||||
} else {
|
||||
fmt.dateFormat = "MMM d" // e.g. "Jun 14"
|
||||
}
|
||||
return fmt.string(from: date)
|
||||
}
|
||||
|
||||
/// Local wall-clock "HH:mm" for the Codex stale footnote, e.g. "13:42".
|
||||
/// Returns nil for a missing/unparseable timestamp.
|
||||
static func clockTime(iso: String?) -> String? {
|
||||
guard let iso, let date = isoDate(iso) else { return nil }
|
||||
let fmt = DateFormatter()
|
||||
fmt.locale = Locale(identifier: "en_US_POSIX")
|
||||
fmt.dateFormat = "HH:mm"
|
||||
return fmt.string(from: date)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
.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,122 @@
|
||||
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
|
||||
window.center()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,51 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import CCSBarCore
|
||||
|
||||
/// Zero-size bridge that walks up to the host `NSWindow` and forces its
|
||||
/// `appearance` to match the user's chosen `BarAppearance`.
|
||||
///
|
||||
/// Why this 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.
|
||||
///
|
||||
/// 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)
|
||||
// Defer until the view is in the hierarchy; at make-time `view.window` is nil.
|
||||
DispatchQueue.main.async { apply(to: probe) }
|
||||
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.
|
||||
DispatchQueue.main.async { apply(to: nsView) }
|
||||
}
|
||||
|
||||
/// Force the host window's appearance from the chosen theme.
|
||||
/// .system -> nil (follow the OS)
|
||||
/// .light -> aqua
|
||||
/// .dark -> darkAqua
|
||||
private func apply(to view: NSView) {
|
||||
guard let window = view.window else { return }
|
||||
switch appearance {
|
||||
case .system:
|
||||
window.appearance = nil
|
||||
case .light:
|
||||
window.appearance = NSAppearance(named: .aqua)
|
||||
case .dark:
|
||||
window.appearance = NSAppearance(named: .darkAqua)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,296 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: Glance mode
|
||||
|
||||
/// Which figure leads the always-on menu-bar title. Persisted by raw value, so
|
||||
/// the cases are a stable contract with `BarFormatting.statusTitle(...:mode:)`.
|
||||
/// There is deliberately NO allTime/lifetime case — a lifetime dollar figure in
|
||||
/// the bar reads as live spend and creates false urgency.
|
||||
public enum BarGlanceMode: String, Sendable, Equatable, CaseIterable {
|
||||
case auto
|
||||
case todaySpend
|
||||
case monthSpend
|
||||
case lowestQuota
|
||||
case accountCount
|
||||
}
|
||||
|
||||
// MARK: Alert kinds
|
||||
|
||||
/// The five alert conditions the engine evaluates. The raw values are STABLE:
|
||||
/// they are embedded inside persisted fired-keys, so renaming a case orphans
|
||||
/// any live key and would re-fire or silence alerts incorrectly.
|
||||
public enum BarAlertKind: String, Sendable, Equatable, CaseIterable {
|
||||
case quotaRemainingBelow
|
||||
case dailySpendAbove
|
||||
case monthSpendAbove
|
||||
case reauthNeeded
|
||||
case accountCooldownOrPaused
|
||||
}
|
||||
|
||||
// MARK: Preferences (pure value type)
|
||||
|
||||
/// User alert preferences. A pure input to the engine — the App hydrates this
|
||||
/// from UserDefaults and passes it in; the engine never reads defaults itself.
|
||||
public struct BarAlertPrefs: Sendable, Equatable {
|
||||
public var quotaEnabled: Bool
|
||||
public var quotaLevels: [Int]
|
||||
public var dailySpendEnabled: Bool
|
||||
public var dailyCapUSD: Double
|
||||
public var monthSpendEnabled: Bool
|
||||
public var monthCapUSD: Double
|
||||
public var reauthEnabled: Bool
|
||||
public var cooldownPausedEnabled: Bool
|
||||
public var glanceMode: BarGlanceMode
|
||||
|
||||
public init(
|
||||
quotaEnabled: Bool = true,
|
||||
quotaLevels: [Int] = [20, 10, 0],
|
||||
dailySpendEnabled: Bool = true,
|
||||
dailyCapUSD: Double = 500,
|
||||
monthSpendEnabled: Bool = true,
|
||||
monthCapUSD: Double = 10000,
|
||||
reauthEnabled: Bool = true,
|
||||
cooldownPausedEnabled: Bool = true,
|
||||
glanceMode: BarGlanceMode = .auto
|
||||
) {
|
||||
self.quotaEnabled = quotaEnabled
|
||||
self.quotaLevels = quotaLevels
|
||||
self.dailySpendEnabled = dailySpendEnabled
|
||||
self.dailyCapUSD = dailyCapUSD
|
||||
self.monthSpendEnabled = monthSpendEnabled
|
||||
self.monthCapUSD = monthCapUSD
|
||||
self.reauthEnabled = reauthEnabled
|
||||
self.cooldownPausedEnabled = cooldownPausedEnabled
|
||||
self.glanceMode = glanceMode
|
||||
}
|
||||
|
||||
/// Quota levels defensively normalized: clamped to 0...100, de-duplicated, and
|
||||
/// sorted descending so the engine can pick the single most-severe crossed
|
||||
/// level deterministically regardless of how the prefs were entered.
|
||||
public var normalizedQuotaLevels: [Int] {
|
||||
Array(Set(quotaLevels.map { min(100, max(0, $0)) })).sorted(by: >)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Notification value + delivery protocol
|
||||
|
||||
/// A single notification the engine wants delivered this poll. `id` is the
|
||||
/// fired-key (also used as the UN request identifier so the OS de-dupes at the
|
||||
/// delivery layer too).
|
||||
public struct BarNotification: Sendable, Equatable, Identifiable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
public let body: String
|
||||
public let kind: BarAlertKind
|
||||
public init(id: String, title: String, body: String, kind: BarAlertKind) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.body = body
|
||||
self.kind = kind
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of one evaluation. `firedKeys` is the COMPLETE next-state set — the
|
||||
/// caller overwrites its stored set verbatim (no merge), which is what keeps the
|
||||
/// persisted set bounded and lets cleared conditions re-arm.
|
||||
public struct BarAlertEvaluation: Sendable, Equatable {
|
||||
public let toDeliver: [BarNotification]
|
||||
public let firedKeys: Set<String>
|
||||
public init(toDeliver: [BarNotification], firedKeys: Set<String>) {
|
||||
self.toDeliver = toDeliver
|
||||
self.firedKeys = firedKeys
|
||||
}
|
||||
}
|
||||
|
||||
/// Real notification delivery. Declared in Core so the assert harness can supply
|
||||
/// a recording implementation; the engine NEVER calls it — the App orchestrates
|
||||
/// delivery from the engine's `toDeliver` output.
|
||||
public protocol NotificationDelivering: Sendable {
|
||||
func deliver(_ notification: BarNotification)
|
||||
}
|
||||
|
||||
// MARK: The pure rule engine
|
||||
|
||||
/// Namespace for the pure, deterministic alert rule engine. `evaluate` is
|
||||
/// side-effect-free: no `Date()`, no IO, no UserDefaults. Everything time- or
|
||||
/// state-dependent (`now`, `prefs`, `priorFiredKeys`, `calendar`) is injected.
|
||||
public enum BarAlertEngine {
|
||||
/// Evaluate all rules against the current rows + analytics.
|
||||
///
|
||||
/// Deterministic: accounts are iterated in stable id order so output order is
|
||||
/// reproducible. Returns the notifications to deliver and the complete next
|
||||
/// fired-key set (caller overwrites stored set verbatim).
|
||||
///
|
||||
/// Key format: pipe-joined `kindRaw|scope|bucket[|suffix]`. Pipe (not colon)
|
||||
/// because `account.id` already contains a colon (`provider:accountId`).
|
||||
/// `scope` is the account id or literal "global". `bucket` is the re-arm token
|
||||
/// — when it rolls (new day/month/reset) the key changes and the alert re-fires.
|
||||
public static func evaluate(
|
||||
rows: [BarSummaryRow],
|
||||
analytics: BarAnalytics?,
|
||||
prefs: BarAlertPrefs,
|
||||
priorFiredKeys: Set<String>,
|
||||
now: Date,
|
||||
calendar: Calendar = .current
|
||||
) -> BarAlertEvaluation {
|
||||
var fired = priorFiredKeys
|
||||
var out: [BarNotification] = []
|
||||
|
||||
let sortedRows = rows.sorted { $0.id < $1.id }
|
||||
let dayBucket = localDayKey(now, calendar: calendar)
|
||||
let monthBucket = localMonthKey(now, calendar: calendar)
|
||||
let levels = prefs.normalizedQuotaLevels
|
||||
|
||||
// Per-account rules.
|
||||
for row in sortedRows {
|
||||
let scope = row.id
|
||||
let name = row.displayName ?? row.provider
|
||||
|
||||
// (1) quotaRemainingBelow — fire the SINGLE most-severe crossed level.
|
||||
// One alert per reset window (anti-spam): the fired-key embeds the reset
|
||||
// bucket (row.nextReset ?? "noreset" below), so once an account crosses a
|
||||
// level the alert is suppressed for the rest of that window even if quota
|
||||
// recovers and then drops again. It re-arms automatically when nextReset
|
||||
// rolls to a new window.
|
||||
if prefs.quotaEnabled, row.quotaStatus == "ok", let pct = row.quotaPercentage {
|
||||
let remaining = Int(pct.rounded())
|
||||
// levels sorted desc; the most-severe crossed level is the smallest L
|
||||
// with remaining <= L. One notif per account per poll, not one per level.
|
||||
if let level = levels.filter({ remaining <= $0 }).min() {
|
||||
let bucket = row.nextReset ?? "noreset"
|
||||
let key = "\(BarAlertKind.quotaRemainingBelow.rawValue)|\(scope)|\(bucket)|L\(level)"
|
||||
if !fired.contains(key) {
|
||||
let resets = BarQuotaGauge.resetCountdown(nextReset: row.nextReset, now: now)
|
||||
let resetSuffix = resets.map { " — \($0)" } ?? ""
|
||||
out.append(
|
||||
BarNotification(
|
||||
id: key,
|
||||
title: "Quota low",
|
||||
body: "\(name) quota at \(remaining)%\(resetSuffix)",
|
||||
kind: .quotaRemainingBelow))
|
||||
fired.insert(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (4) reauthNeeded — clears-then-recurs.
|
||||
let reauthKey = "\(BarAlertKind.reauthNeeded.rawValue)|\(scope)|on"
|
||||
if prefs.reauthEnabled, row.needsReauth {
|
||||
if !fired.contains(reauthKey) {
|
||||
out.append(
|
||||
BarNotification(
|
||||
id: reauthKey,
|
||||
title: "Re-authentication needed",
|
||||
body: "\(name) needs re-authentication",
|
||||
kind: .reauthNeeded))
|
||||
fired.insert(reauthKey)
|
||||
}
|
||||
} else {
|
||||
// Condition cleared (or rule disabled): drop the key so it re-fires next
|
||||
// time the condition becomes true again.
|
||||
fired.remove(reauthKey)
|
||||
}
|
||||
|
||||
// (5) accountCooldownOrPaused — clears-then-recurs on `paused`.
|
||||
let pausedKey = "\(BarAlertKind.accountCooldownOrPaused.rawValue)|\(scope)|on"
|
||||
if prefs.cooldownPausedEnabled, row.paused {
|
||||
if !fired.contains(pausedKey) {
|
||||
out.append(
|
||||
BarNotification(
|
||||
id: pausedKey,
|
||||
title: "Account paused",
|
||||
body: "\(name) is paused / cooling down",
|
||||
kind: .accountCooldownOrPaused))
|
||||
fired.insert(pausedKey)
|
||||
}
|
||||
} else {
|
||||
fired.remove(pausedKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Global rules.
|
||||
|
||||
// (2) dailySpendAbove — strict >, per calendar day.
|
||||
if prefs.dailySpendEnabled {
|
||||
let today = analytics?.today.cost ?? 0
|
||||
if today > prefs.dailyCapUSD {
|
||||
let key = "\(BarAlertKind.dailySpendAbove.rawValue)|global|\(dayBucket)"
|
||||
if !fired.contains(key) {
|
||||
out.append(
|
||||
BarNotification(
|
||||
id: key,
|
||||
title: "Daily spend cap",
|
||||
body:
|
||||
"Daily spend \(BarFormatting.money(today)) is over your "
|
||||
+ "\(BarFormatting.money(prefs.dailyCapUSD)) cap",
|
||||
kind: .dailySpendAbove))
|
||||
fired.insert(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (3) monthSpendAbove — strict >, driven by calendar MTD (NOT last30d/allTime).
|
||||
if prefs.monthSpendEnabled {
|
||||
let mtd = analytics?.monthToDate.cost ?? 0
|
||||
if mtd > prefs.monthCapUSD {
|
||||
let key = "\(BarAlertKind.monthSpendAbove.rawValue)|global|\(monthBucket)"
|
||||
if !fired.contains(key) {
|
||||
out.append(
|
||||
BarNotification(
|
||||
id: key,
|
||||
title: "Monthly spend cap",
|
||||
body:
|
||||
"This month's spend \(BarFormatting.money(mtd)) is over your "
|
||||
+ "\(BarFormatting.money(prefs.monthCapUSD)) cap",
|
||||
kind: .monthSpendAbove))
|
||||
fired.insert(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PRUNE — keep the fired set bounded so it can't grow without limit across
|
||||
// day/month/reset rollovers or account churn.
|
||||
let presentIds = Set(rows.map { $0.id })
|
||||
let presentResetBuckets: [String: String] = Dictionary(
|
||||
uniqueKeysWithValues: rows.map { ($0.id, $0.nextReset ?? "noreset") })
|
||||
fired = fired.filter { key in
|
||||
let parts = key.split(separator: "|", omittingEmptySubsequences: false).map(String.init)
|
||||
guard let kind = parts.first else { return false }
|
||||
switch kind {
|
||||
case BarAlertKind.dailySpendAbove.rawValue:
|
||||
// parts: [kind, "global", dayBucket]
|
||||
return parts.count >= 3 && parts[2] == dayBucket
|
||||
case BarAlertKind.monthSpendAbove.rawValue:
|
||||
return parts.count >= 3 && parts[2] == monthBucket
|
||||
case BarAlertKind.quotaRemainingBelow.rawValue:
|
||||
// parts: [kind, accountId, bucket, "L<level>"]; bucket must equal the
|
||||
// account's CURRENT nextReset and the account must still be present.
|
||||
guard parts.count >= 3, presentIds.contains(parts[1]) else { return false }
|
||||
return presentResetBuckets[parts[1]] == parts[2]
|
||||
case BarAlertKind.reauthNeeded.rawValue, BarAlertKind.accountCooldownOrPaused.rawValue:
|
||||
// parts: [kind, accountId, "on"]; keep only for still-present accounts.
|
||||
return parts.count >= 2 && presentIds.contains(parts[1])
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return BarAlertEvaluation(toDeliver: out, firedKeys: fired)
|
||||
}
|
||||
|
||||
// MARK: Bucket helpers (local calendar, injected)
|
||||
|
||||
/// Local calendar day key `yyyy-MM-dd` from injected `calendar`. Local (not
|
||||
/// UTC) to match the backend's local-day analytics semantics.
|
||||
static func localDayKey(_ date: Date, calendar: Calendar) -> String {
|
||||
let c = calendar.dateComponents([.year, .month, .day], from: date)
|
||||
return String(
|
||||
format: "%04d-%02d-%02d", c.year ?? 0, c.month ?? 0, c.day ?? 0)
|
||||
}
|
||||
|
||||
/// Local calendar month key `yyyy-MM`.
|
||||
static func localMonthKey(_ date: Date, calendar: Calendar) -> String {
|
||||
let c = calendar.dateComponents([.year, .month], from: date)
|
||||
return String(format: "%04d-%02d", c.year ?? 0, c.month ?? 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import Foundation
|
||||
|
||||
/// The persistence CONTRACT for alert preferences: the UserDefaults key strings,
|
||||
/// their defaults, and a pure dictionary <-> prefs codec. The App layer owns the
|
||||
/// live `UserDefaults` object; this type stays pure so the key/default/parse
|
||||
/// logic is unit-testable on any toolchain without touching real defaults.
|
||||
public enum BarAlertPrefsStore {
|
||||
/// UserDefaults keys. Stable strings — changing one silently drops a user's
|
||||
/// saved preference back to its default.
|
||||
public enum Key {
|
||||
public static let quotaEnabled = "ccsbar.alert.quota.enabled"
|
||||
public static let quotaLevels = "ccsbar.alert.quota.levels"
|
||||
public static let dailyEnabled = "ccsbar.alert.daily.enabled"
|
||||
public static let dailyCapUSD = "ccsbar.alert.daily.capUSD"
|
||||
public static let monthEnabled = "ccsbar.alert.month.enabled"
|
||||
public static let monthCapUSD = "ccsbar.alert.month.capUSD"
|
||||
public static let reauthEnabled = "ccsbar.alert.reauth.enabled"
|
||||
public static let cooldownPausedEnabled = "ccsbar.alert.cooldownPaused.enabled"
|
||||
public static let glanceMode = "ccsbar.glance.mode"
|
||||
/// Engine state, NOT user-editable.
|
||||
public static let firedKeys = "ccsbar.alert.firedKeys"
|
||||
}
|
||||
|
||||
/// Default values keyed by the UserDefaults key. Registered via
|
||||
/// `UserDefaults.register(defaults:)` at App launch so absent Bool keys don't
|
||||
/// read back as `false` (which would silently disable every alert on first run).
|
||||
public static var registrationDefaults: [String: Any] {
|
||||
[
|
||||
Key.quotaEnabled: true,
|
||||
Key.quotaLevels: "20,10,0",
|
||||
Key.dailyEnabled: true,
|
||||
Key.dailyCapUSD: 500.0,
|
||||
Key.monthEnabled: true,
|
||||
Key.monthCapUSD: 10000.0,
|
||||
Key.reauthEnabled: true,
|
||||
Key.cooldownPausedEnabled: true,
|
||||
Key.glanceMode: BarGlanceMode.auto.rawValue,
|
||||
]
|
||||
}
|
||||
|
||||
/// Parse the comma-joined quota levels string into normalized `[Int]`:
|
||||
/// clamped 0...100, de-duplicated, sorted descending. Bad tokens are dropped.
|
||||
public static func parseQuotaLevels(_ raw: String) -> [Int] {
|
||||
let parsed = raw.split(separator: ",")
|
||||
.compactMap { Int($0.trimmingCharacters(in: .whitespaces)) }
|
||||
.map { min(100, max(0, $0)) }
|
||||
return Array(Set(parsed)).sorted(by: >)
|
||||
}
|
||||
|
||||
/// Serialize quota levels back to the comma-joined storage form (sorted desc).
|
||||
public static func encodeQuotaLevels(_ levels: [Int]) -> String {
|
||||
levels.map { min(100, max(0, $0)) }.sorted(by: >).map(String.init).joined(separator: ",")
|
||||
}
|
||||
|
||||
/// Decode a `BarAlertPrefs` from a plain `[key: value]` dictionary. Mirrors how
|
||||
/// the App will read each key from `UserDefaults`, but pure so it is testable.
|
||||
/// Missing keys fall back to the matching `BarAlertPrefs` default.
|
||||
public static func decode(from dict: [String: Any]) -> BarAlertPrefs {
|
||||
let d = BarAlertPrefs() // carries the canonical defaults
|
||||
|
||||
func bool(_ key: String, _ fallback: Bool) -> Bool { (dict[key] as? Bool) ?? fallback }
|
||||
func double(_ key: String, _ fallback: Double) -> Double {
|
||||
if let v = dict[key] as? Double { return v }
|
||||
if let v = dict[key] as? Int { return Double(v) }
|
||||
return fallback
|
||||
}
|
||||
|
||||
let levels: [Int]
|
||||
if let raw = dict[Key.quotaLevels] as? String {
|
||||
let parsed = parseQuotaLevels(raw)
|
||||
levels = parsed.isEmpty ? d.quotaLevels : parsed
|
||||
} else {
|
||||
levels = d.quotaLevels
|
||||
}
|
||||
|
||||
let mode = (dict[Key.glanceMode] as? String).flatMap(BarGlanceMode.init(rawValue:)) ?? d.glanceMode
|
||||
|
||||
return BarAlertPrefs(
|
||||
quotaEnabled: bool(Key.quotaEnabled, d.quotaEnabled),
|
||||
quotaLevels: levels,
|
||||
dailySpendEnabled: bool(Key.dailyEnabled, d.dailySpendEnabled),
|
||||
dailyCapUSD: double(Key.dailyCapUSD, d.dailyCapUSD),
|
||||
monthSpendEnabled: bool(Key.monthEnabled, d.monthSpendEnabled),
|
||||
monthCapUSD: double(Key.monthCapUSD, d.monthCapUSD),
|
||||
reauthEnabled: bool(Key.reauthEnabled, d.reauthEnabled),
|
||||
cooldownPausedEnabled: bool(Key.cooldownPausedEnabled, d.cooldownPausedEnabled),
|
||||
glanceMode: mode)
|
||||
}
|
||||
|
||||
/// Encode a `BarAlertPrefs` to the dictionary form the App writes to
|
||||
/// `UserDefaults` (levels comma-joined, mode as raw value).
|
||||
public static func encode(_ prefs: BarAlertPrefs) -> [String: Any] {
|
||||
[
|
||||
Key.quotaEnabled: prefs.quotaEnabled,
|
||||
Key.quotaLevels: encodeQuotaLevels(prefs.quotaLevels),
|
||||
Key.dailyEnabled: prefs.dailySpendEnabled,
|
||||
Key.dailyCapUSD: prefs.dailyCapUSD,
|
||||
Key.monthEnabled: prefs.monthSpendEnabled,
|
||||
Key.monthCapUSD: prefs.monthCapUSD,
|
||||
Key.reauthEnabled: prefs.reauthEnabled,
|
||||
Key.cooldownPausedEnabled: prefs.cooldownPausedEnabled,
|
||||
Key.glanceMode: prefs.glanceMode.rawValue,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import Foundation
|
||||
|
||||
/// Usage analytics for the menu bar, mirroring `GET /api/bar/analytics`.
|
||||
///
|
||||
/// Rolled up server-side from the persisted CLIProxy usage snapshot. All cost
|
||||
/// values are USD.
|
||||
|
||||
/// Spend and request count for one usage surface (tool/origin), e.g. "Claude Code"
|
||||
/// or "Codex". The server sends these ordered descending by cost for the same window
|
||||
/// as topModels, so the array can be rendered as-is.
|
||||
public struct BarAnalyticsSurface: Codable, Sendable, Equatable, Identifiable {
|
||||
public let source: String
|
||||
public let surface: String
|
||||
public let cost: Double
|
||||
public let requests: Int
|
||||
/// Stable identity for SwiftUI lists: surface name is unique within a window.
|
||||
public var id: String { surface }
|
||||
public init(source: String, surface: String, cost: Double, requests: Int) {
|
||||
self.source = source
|
||||
self.surface = surface
|
||||
self.cost = cost
|
||||
self.requests = requests
|
||||
}
|
||||
}
|
||||
|
||||
public struct BarAnalytics: Codable, Sendable, Equatable {
|
||||
public struct Window: Codable, Sendable, Equatable {
|
||||
public let cost: Double
|
||||
public let requests: Int
|
||||
public init(cost: Double, requests: Int) {
|
||||
self.cost = cost
|
||||
self.requests = requests
|
||||
}
|
||||
}
|
||||
|
||||
public struct Day: Codable, Sendable, Equatable, Identifiable {
|
||||
public let date: String
|
||||
public let cost: Double
|
||||
public let requests: Int
|
||||
public var id: String { date }
|
||||
public init(date: String, cost: Double, requests: Int) {
|
||||
self.date = date
|
||||
self.cost = cost
|
||||
self.requests = requests
|
||||
}
|
||||
}
|
||||
|
||||
public struct Model: Codable, Sendable, Equatable, Identifiable {
|
||||
public let model: String
|
||||
public let cost: Double
|
||||
public let requests: Int
|
||||
public var id: String { model }
|
||||
public init(model: String, cost: Double, requests: Int) {
|
||||
self.model = model
|
||||
self.cost = cost
|
||||
self.requests = requests
|
||||
}
|
||||
}
|
||||
|
||||
public let today: Window
|
||||
public let last7d: Window
|
||||
public let last30d: Window
|
||||
/// Honest calendar month-to-date (1st of the current local month → now), NOT a
|
||||
/// rolling 30 days. A fresh month resets this toward ~0 even when `last30d`
|
||||
/// stays populated, so a monthly-cap alert measures the real billing month.
|
||||
public let monthToDate: Window
|
||||
public let allTime: Window
|
||||
/// Oldest → newest, exactly 30 zero-filled entries, for the sparkline.
|
||||
public let byDay: [Day]
|
||||
public let topModels: [Model]
|
||||
/// "30d" when recent data exists, else "all".
|
||||
public let topModelsWindow: String
|
||||
/// ISO timestamp of the most recent non-failed usage record, null if none.
|
||||
public let lastActivityAt: String?
|
||||
/// Whole local-days since `lastActivityAt`, null if no usable records.
|
||||
public let daysSinceLastActivity: Int?
|
||||
/// True when the trailing 30 days carry any spend or requests. The UI pivots
|
||||
/// its empty/stale presentation on this without re-deriving it.
|
||||
public let hasRecentData: Bool
|
||||
public let generatedAt: String
|
||||
/// Spend/requests per usage surface (e.g. "Claude Code", "Codex"), ordered
|
||||
/// descending by cost for the same window as topModels. Empty when the backend
|
||||
/// has no surface breakdown; the UI omits the section in that case.
|
||||
public let bySurface: [BarAnalyticsSurface]
|
||||
|
||||
public init(
|
||||
today: Window,
|
||||
last7d: Window,
|
||||
last30d: Window,
|
||||
monthToDate: Window = Window(cost: 0, requests: 0),
|
||||
allTime: Window,
|
||||
byDay: [Day],
|
||||
topModels: [Model],
|
||||
topModelsWindow: String,
|
||||
lastActivityAt: String? = nil,
|
||||
daysSinceLastActivity: Int? = nil,
|
||||
hasRecentData: Bool = false,
|
||||
generatedAt: String,
|
||||
bySurface: [BarAnalyticsSurface] = []
|
||||
) {
|
||||
self.today = today
|
||||
self.last7d = last7d
|
||||
self.last30d = last30d
|
||||
self.monthToDate = monthToDate
|
||||
self.allTime = allTime
|
||||
self.byDay = byDay
|
||||
self.topModels = topModels
|
||||
self.topModelsWindow = topModelsWindow
|
||||
self.lastActivityAt = lastActivityAt
|
||||
self.daysSinceLastActivity = daysSinceLastActivity
|
||||
self.hasRecentData = hasRecentData
|
||||
self.generatedAt = generatedAt
|
||||
self.bySurface = bySurface
|
||||
}
|
||||
|
||||
// Custom decoder: `bySurface` is a new field absent from older snapshots.
|
||||
// Defaulting to [] when the key is missing keeps the app backward-compatible
|
||||
// with any cached or older-backend analytics payload.
|
||||
public init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
today = try c.decode(Window.self, forKey: .today)
|
||||
last7d = try c.decode(Window.self, forKey: .last7d)
|
||||
last30d = try c.decode(Window.self, forKey: .last30d)
|
||||
// `monthToDate` is a newer field; older cached payloads omit it. Default to a
|
||||
// zero window so a stale cache decodes cleanly instead of throwing.
|
||||
monthToDate =
|
||||
(try c.decodeIfPresent(Window.self, forKey: .monthToDate)) ?? Window(cost: 0, requests: 0)
|
||||
allTime = try c.decode(Window.self, forKey: .allTime)
|
||||
byDay = try c.decode([Day].self, forKey: .byDay)
|
||||
topModels = try c.decode([Model].self, forKey: .topModels)
|
||||
topModelsWindow = try c.decode(String.self, forKey: .topModelsWindow)
|
||||
lastActivityAt = try c.decodeIfPresent(String.self, forKey: .lastActivityAt)
|
||||
daysSinceLastActivity = try c.decodeIfPresent(Int.self, forKey: .daysSinceLastActivity)
|
||||
hasRecentData = try c.decode(Bool.self, forKey: .hasRecentData)
|
||||
generatedAt = try c.decode(String.self, forKey: .generatedAt)
|
||||
bySurface = (try c.decodeIfPresent([BarAnalyticsSurface].self, forKey: .bySurface)) ?? []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - SpendChartStyle
|
||||
|
||||
/// User-selectable render style for the spend sparkline in the dropdown.
|
||||
///
|
||||
/// `.bars` (default) draws the existing RoundedRectangle bar chart.
|
||||
/// `.line` draws a Path-based line graph with a faint area fill, which is
|
||||
/// better for spotting trend direction across the 30-day window.
|
||||
///
|
||||
/// Sendable + CaseIterable so the harness can iterate all cases and the value
|
||||
/// can cross actor boundaries safely.
|
||||
public enum SpendChartStyle: String, CaseIterable, Sendable {
|
||||
case bars
|
||||
case line
|
||||
}
|
||||
|
||||
// MARK: - SpendChartStyleStore
|
||||
|
||||
/// Persists the chosen spend-chart style. Mirrors the BarAppearanceStore pattern:
|
||||
/// a UserDefaults key, a static load, and a static save. The `?? .bars` fallback
|
||||
/// on a nil/unrecognized raw value is the sole source of the default.
|
||||
public enum SpendChartStyleStore {
|
||||
public static let defaultsKey = "ccsbar.spendChartStyle"
|
||||
|
||||
public static func load() -> SpendChartStyle {
|
||||
let raw = UserDefaults.standard.string(forKey: defaultsKey)
|
||||
?? SpendChartStyle.bars.rawValue
|
||||
return SpendChartStyle(rawValue: raw) ?? .bars
|
||||
}
|
||||
|
||||
public static func save(_ style: SpendChartStyle) {
|
||||
UserDefaults.standard.set(style.rawValue, forKey: defaultsKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
|
||||
/// Connection handshake the app reads to find the running CCS web-server.
|
||||
///
|
||||
/// Written by `ccs bar launch` to `~/.ccs/bar.json`. v1 only supports
|
||||
/// `authMode == "loopback"` (dashboard auth disabled, localhost).
|
||||
public struct BarDiscovery: Codable, Sendable, Equatable {
|
||||
public let baseUrl: String
|
||||
public let port: Int
|
||||
public let authMode: String
|
||||
|
||||
public init(baseUrl: String, port: Int, authMode: String) {
|
||||
self.baseUrl = baseUrl
|
||||
self.port = port
|
||||
self.authMode = authMode
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case baseUrl
|
||||
case port
|
||||
case authMode
|
||||
}
|
||||
|
||||
/// Resolved base URL, falling back to a localhost URL built from `port`
|
||||
/// when `baseUrl` is empty or unparseable.
|
||||
public var resolvedURL: URL? {
|
||||
if let url = URL(string: baseUrl), url.scheme != nil { return url }
|
||||
return URL(string: "http://127.0.0.1:\(port)")
|
||||
}
|
||||
|
||||
public enum LoadError: Error, Equatable {
|
||||
case missing(path: String)
|
||||
case unreadable(path: String)
|
||||
case malformed
|
||||
}
|
||||
|
||||
/// Default discovery file path under the given home directory.
|
||||
public static func defaultPath(home: String = NSHomeDirectory()) -> String {
|
||||
URL(fileURLWithPath: home)
|
||||
.appendingPathComponent(".ccs")
|
||||
.appendingPathComponent("bar.json")
|
||||
.path
|
||||
}
|
||||
|
||||
/// Load discovery from `~/.ccs/bar.json`. Returns a typed error when the
|
||||
/// file is absent (CCS not launched) or malformed so the UI can show a
|
||||
/// clear "CCS offline" state instead of crashing.
|
||||
public static func load(home: String = NSHomeDirectory()) -> Result<BarDiscovery, LoadError> {
|
||||
let path = defaultPath(home: home)
|
||||
guard FileManager.default.fileExists(atPath: path) else {
|
||||
return .failure(.missing(path: path))
|
||||
}
|
||||
guard let data = FileManager.default.contents(atPath: path) else {
|
||||
return .failure(.unreadable(path: path))
|
||||
}
|
||||
guard let discovery = try? JSONDecoder().decode(BarDiscovery.self, from: data) else {
|
||||
return .failure(.malformed)
|
||||
}
|
||||
return .success(discovery)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure formatting helpers for the status-bar title and dropdown rows.
|
||||
/// No SwiftUI dependency so they are unit-testable on any toolchain.
|
||||
public enum BarFormatting {
|
||||
/// Tri-state quota label. Honest about WHY a percentage is missing instead of
|
||||
/// collapsing every case to a bare "--":
|
||||
/// status "ok" + pct → "NN%" (threshold-colored upstream)
|
||||
/// status "unsupported" → "no quota" (provider has no quota API)
|
||||
/// status "error" → "quota ?" (transient fetch failure)
|
||||
/// An "ok" status with a nil percentage (shouldn't happen, but be safe) also
|
||||
/// degrades to "quota ?" rather than "--".
|
||||
public static func quotaLabel(percentage pct: Double?, status: String) -> String {
|
||||
switch status {
|
||||
case "ok":
|
||||
guard let pct else { return "quota ?" }
|
||||
return "\(Int(pct.rounded()))%"
|
||||
case "unsupported":
|
||||
return "no quota"
|
||||
default:
|
||||
return "quota ?"
|
||||
}
|
||||
}
|
||||
|
||||
/// Quota title token: only an "ok" row with a real percentage yields a token
|
||||
/// (so "unsupported"/"error" rows can never produce "--" in the menu-bar
|
||||
/// title and the fallback chain falls through instead). Returns nil to skip.
|
||||
public static func quotaTitleToken(percentage pct: Double?, status: String) -> String? {
|
||||
guard status == "ok", let pct else { return nil }
|
||||
return "\(Int(pct.rounded()))%"
|
||||
}
|
||||
|
||||
/// Today cost label, e.g. "$3.20" or "" when unknown/zero-not-shown.
|
||||
public static func costLabel(_ cost: Double?) -> String {
|
||||
guard let cost, cost > 0 else { return "" }
|
||||
return String(format: "$%.2f", cost)
|
||||
}
|
||||
|
||||
/// Always-visible compact currency, e.g. "$0.00", "$12.34", "$2.6k", "$1.3M".
|
||||
/// Used for the analytics rollups where zero is meaningful (no spend yet).
|
||||
public static func money(_ v: Double) -> String {
|
||||
let n = max(0, v)
|
||||
if n >= 1_000_000 { return String(format: "$%.1fM", n / 1_000_000) }
|
||||
if n >= 1_000 { return String(format: "$%.1fk", n / 1_000) }
|
||||
return String(format: "$%.2f", n)
|
||||
}
|
||||
|
||||
/// Compact integer count, e.g. "5", "1.2k", "3.4M".
|
||||
public static func count(_ n: Int) -> String {
|
||||
if n >= 1_000_000 { return String(format: "%.1fM", Double(n) / 1_000_000) }
|
||||
if n >= 1_000 { return String(format: "%.1fk", Double(n) / 1_000) }
|
||||
return "\(n)"
|
||||
}
|
||||
|
||||
/// Compact, always-meaningful status-bar title. Evaluates an ordered fallback
|
||||
/// chain left to right; the first step that yields a non-empty token wins. A
|
||||
/// bare "--" is NEVER emitted — every step degrades to the next instead.
|
||||
///
|
||||
/// 1. QUOTA — lowest remaining quota among rows whose quotaStatus=="ok"
|
||||
/// with a real percentage → "<provider> NN%" (e.g. "agy 12%").
|
||||
/// "unsupported"/"error" rows are skipped so they can't show "--".
|
||||
/// 2. TODAY COST — else analytics.today.cost > 0 → "$<today>" (e.g. "$3.20").
|
||||
/// Uses the fresh aggregate from analytics, not per-row today_cost.
|
||||
/// 3. ATTENTION/COUNT — else rows needing reauth → "CCS <n>!"; else active
|
||||
/// (non-paused) count → "CCS <n>", fallback to total count.
|
||||
/// 4. "CCS" — only when there are no rows at all.
|
||||
///
|
||||
/// All-time spend is deliberately EXCLUDED from the title chain: a lifetime dollar
|
||||
/// figure (e.g. "$40.8k") always reads as live spend in the always-on menu bar,
|
||||
/// creating false urgency. It belongs only in the analytics section of the dropdown.
|
||||
public static func statusTitle(rows: [BarSummaryRow], analytics: BarAnalytics?) -> String {
|
||||
if rows.isEmpty { return "CCS" }
|
||||
|
||||
// (1) QUOTA — closest to exhaustion among quota-capable rows.
|
||||
let quotaRows = rows.filter { $0.quotaStatus == "ok" && $0.quotaPercentage != nil }
|
||||
if let lead = quotaRows.min(by: { ($0.quotaPercentage ?? 0) < ($1.quotaPercentage ?? 0) }),
|
||||
let token = quotaTitleToken(percentage: lead.quotaPercentage, status: lead.quotaStatus)
|
||||
{
|
||||
return "\(lead.provider) \(token)"
|
||||
}
|
||||
|
||||
// (2) TODAY COST — fresh aggregate from analytics (more accurate than summing
|
||||
// per-row today_cost, which may have nulls or stale snapshot values).
|
||||
if let todayCost = analytics?.today.cost, todayCost > 0 {
|
||||
return money(todayCost)
|
||||
}
|
||||
|
||||
// (3) ATTENTION / ACTIVE COUNT.
|
||||
let reauthCount = rows.filter { $0.needsReauth }.count
|
||||
if reauthCount > 0 {
|
||||
return "CCS \(reauthCount)!"
|
||||
}
|
||||
let activeCount = rows.filter { !$0.paused }.count
|
||||
return "CCS \(activeCount > 0 ? activeCount : rows.count)"
|
||||
}
|
||||
|
||||
/// Glance-mode title resolver. The user picks which figure leads the menu-bar
|
||||
/// title; every mode degrades to the `.auto` fallback chain rather than show a
|
||||
/// dead "$0.00" or a misleading lifetime dollar. A LIFETIME / allTime figure
|
||||
/// NEVER appears in any mode — that invariant is what keeps the always-on bar
|
||||
/// from reading like live spend.
|
||||
public static func statusTitle(
|
||||
rows: [BarSummaryRow], analytics: BarAnalytics?, mode: BarGlanceMode
|
||||
) -> String {
|
||||
switch mode {
|
||||
case .auto:
|
||||
return statusTitle(rows: rows, analytics: analytics)
|
||||
|
||||
case .todaySpend:
|
||||
// Avoid a dead "$0.00" sitting in the bar: only lead with today's spend
|
||||
// when there is some; otherwise fall through to the auto chain.
|
||||
if let c = analytics?.today.cost, c > 0 { return money(c) }
|
||||
return statusTitle(rows: rows, analytics: analytics)
|
||||
|
||||
case .monthSpend:
|
||||
// Calendar month-to-date (the new backend field), NOT last30d/allTime.
|
||||
if let c = analytics?.monthToDate.cost, c > 0 { return money(c) }
|
||||
return statusTitle(rows: rows, analytics: analytics)
|
||||
|
||||
case .lowestQuota:
|
||||
// Step (1) of the auto chain only: lowest remaining "ok" quota.
|
||||
let quotaRows = rows.filter { $0.quotaStatus == "ok" && $0.quotaPercentage != nil }
|
||||
if let lead = quotaRows.min(by: { ($0.quotaPercentage ?? 0) < ($1.quotaPercentage ?? 0) }),
|
||||
let token = quotaTitleToken(percentage: lead.quotaPercentage, status: lead.quotaStatus)
|
||||
{
|
||||
return "\(lead.provider) \(token)"
|
||||
}
|
||||
return statusTitle(rows: rows, analytics: analytics)
|
||||
|
||||
case .accountCount:
|
||||
// Non-paused count, falling back to total when every account is paused.
|
||||
// Never appends "!" — that attention marker is an .auto-only signal.
|
||||
if rows.isEmpty { return statusTitle(rows: rows, analytics: analytics) }
|
||||
let active = rows.filter { !$0.paused }.count
|
||||
return "CCS \(active > 0 ? active : rows.count)"
|
||||
}
|
||||
}
|
||||
|
||||
/// The "headline" account for the dropdown when no quota exists (which account
|
||||
/// name leads). Deterministic: prefer the default row, else the sole active
|
||||
/// row, else alphabetical by id — never `rows.first` (arbitrary order).
|
||||
public static func leadRow(_ rows: [BarSummaryRow]) -> BarSummaryRow? {
|
||||
if rows.isEmpty { return nil }
|
||||
if let def = rows.first(where: { $0.isDefault }) { return def }
|
||||
let active = rows.filter { !$0.paused }
|
||||
if active.count == 1 { return active[0] }
|
||||
return rows.min(by: { $0.id < $1.id })
|
||||
}
|
||||
|
||||
/// Human "Last active" caption from an ISO timestamp + a precomputed day-delta.
|
||||
/// "Last active today" / "yesterday" / "Apr 29" — never a raw ISO string.
|
||||
public static func lastActiveLabel(iso: String?, daysSince: Int?) -> String? {
|
||||
guard let iso, let date = isoDate(iso) else { return nil }
|
||||
if let d = daysSince {
|
||||
if d <= 0 { return "Last active today" }
|
||||
if d == 1 { return "Last active yesterday" }
|
||||
}
|
||||
let fmt = DateFormatter()
|
||||
fmt.locale = Locale(identifier: "en_US_POSIX")
|
||||
fmt.dateFormat = "MMM d"
|
||||
return "Last active \(fmt.string(from: date))"
|
||||
}
|
||||
|
||||
/// True when a row is a native first-party subscription (the user's own Claude
|
||||
/// Code or Codex plan) rather than a CLIProxy-managed OAuth pool account. Drives
|
||||
/// the "Subscriptions" grouping + badge so a user reads "this is MY plan quota",
|
||||
/// not one of the rotating pool credentials.
|
||||
public static func isNativeSubscription(provider: String) -> Bool {
|
||||
provider == "claude-code" || provider == "codex"
|
||||
}
|
||||
|
||||
/// Friendly product label for a provider key. Native subscription keys read as
|
||||
/// products ("Claude Code", "Codex"); any other provider passes through verbatim
|
||||
/// (so "agy"/"ghcp"/"kiro" keep their established short chip text).
|
||||
public static func providerLabel(_ provider: String) -> String {
|
||||
switch provider {
|
||||
case "claude-code": return "Claude Code"
|
||||
case "codex": return "Codex"
|
||||
default: return provider
|
||||
}
|
||||
}
|
||||
|
||||
/// Partition rows into (native subscriptions, CLIProxy pool accounts) while
|
||||
/// preserving the backend's order within each group. Used by the dropdown to
|
||||
/// render subscriptions above the pool. Pure so it is testable in Core.
|
||||
public static func partitionSubscriptions(
|
||||
_ rows: [BarSummaryRow]
|
||||
) -> (subscriptions: [BarSummaryRow], pool: [BarSummaryRow]) {
|
||||
var subs: [BarSummaryRow] = []
|
||||
var pool: [BarSummaryRow] = []
|
||||
for row in rows {
|
||||
if isNativeSubscription(provider: row.provider) {
|
||||
subs.append(row)
|
||||
} else {
|
||||
pool.append(row)
|
||||
}
|
||||
}
|
||||
return (subs, pool)
|
||||
}
|
||||
|
||||
/// Parse an ISO-8601 timestamp (with or without fractional seconds).
|
||||
static func isoDate(_ iso: String) -> Date? {
|
||||
let withFraction = ISO8601DateFormatter()
|
||||
withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let d = withFraction.date(from: iso) { return d }
|
||||
let plain = ISO8601DateFormatter()
|
||||
plain.formatOptions = [.withInternetDateTime]
|
||||
return plain.date(from: iso)
|
||||
}
|
||||
}
|
||||
|
||||
/// Force-refresh debounce. Arms the window at decision time so concurrent
|
||||
/// open-triggered refreshes do not both bypass it (matches the server-side
|
||||
/// 15s debounce on `/api/bar/summary?refresh=true`).
|
||||
public struct RefreshDebouncer {
|
||||
public let interval: TimeInterval
|
||||
private var lastArmed: Date?
|
||||
|
||||
public init(interval: TimeInterval = 15) {
|
||||
self.interval = interval
|
||||
}
|
||||
|
||||
/// Returns true and arms the window if a force-refresh should proceed at
|
||||
/// `now`; returns false when still inside the previous window.
|
||||
public mutating func shouldRefresh(now: Date) -> Bool {
|
||||
if let lastArmed, now.timeIntervalSince(lastArmed) < interval {
|
||||
return false
|
||||
}
|
||||
lastArmed = now
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure quota-gauge math: band selection, fill fraction, and reset-countdown
|
||||
/// formatting. No SwiftUI dependency and no implicit clock — `now` is injected
|
||||
/// for `resetCountdown` so the gauge is fully deterministic and testable. The
|
||||
/// App layer renders a ring/bar from these values; all branch/color/countdown
|
||||
/// logic lives here so the view stays a thin render.
|
||||
public enum BarQuotaGauge {
|
||||
/// Severity band for the remaining-quota percentage. `.none` means the row
|
||||
/// has no live quota (unsupported provider, fetch error, or a nil percentage)
|
||||
/// and the gauge should not be drawn at all.
|
||||
public enum Band: String, Sendable, Equatable, CaseIterable {
|
||||
case green
|
||||
case yellow
|
||||
case orange
|
||||
case red
|
||||
case none
|
||||
}
|
||||
|
||||
/// Map a remaining-quota percentage to a severity band. Only a status of "ok"
|
||||
/// with a real percentage yields a colored band; everything else is `.none`.
|
||||
/// Boundaries (remaining): >50 green, 21...50 yellow, 11...20 orange, <=10 red.
|
||||
public static func band(percentage pct: Double?, status: String) -> Band {
|
||||
guard status == "ok", let pct else { return .none }
|
||||
if pct > 50 { return .green }
|
||||
if pct > 20 { return .yellow }
|
||||
if pct > 10 { return .orange }
|
||||
return .red
|
||||
}
|
||||
|
||||
/// Fraction of the ring/bar to fill: remaining/100 clamped to 0...1. Returns
|
||||
/// nil when there is no live quota (so the view can fall back to a text label
|
||||
/// instead of drawing an empty gauge).
|
||||
public static func fillFraction(percentage pct: Double?, status: String) -> Double? {
|
||||
guard status == "ok", let pct else { return nil }
|
||||
return min(1, max(0, pct / 100))
|
||||
}
|
||||
|
||||
/// Human countdown to the next quota reset, e.g. "resets in 1d 21h",
|
||||
/// "resets in 3h 12m", "resets in 12m", or "resets soon" when the reset
|
||||
/// time is at/in the past. Returns nil for nil/unparseable timestamps.
|
||||
/// `now` is injected so the formatting is deterministic and unit-testable.
|
||||
public static func resetCountdown(nextReset: String?, now: Date) -> String? {
|
||||
guard let nextReset, let reset = BarFormatting.isoDate(nextReset) else { return nil }
|
||||
let secs = reset.timeIntervalSince(now)
|
||||
if secs <= 0 { return "resets soon" }
|
||||
let totalMinutes = Int(secs / 60)
|
||||
// Three-tier: days (>=24h) → hours+minutes (1h-24h) → minutes-only (<1h).
|
||||
return "resets in \(compactDuration(minutes: totalMinutes))"
|
||||
}
|
||||
|
||||
// MARK: Burn-rate projection (single-window, no history)
|
||||
|
||||
/// Project minutes-to-exhaustion for ONE quota window from a single snapshot.
|
||||
///
|
||||
/// Linear model, no smoothing, no cross-window inference: a window of length
|
||||
/// `windowMinutes` that resets at `resetAt` started at `resetAt - windowMinutes`
|
||||
/// and has been running `elapsed = windowMinutes - max(0, (resetAt - now)/60)`
|
||||
/// minutes. The window's OWN average burn rate is `usedPercent / elapsed`
|
||||
/// (%/min); minutes left to hit 100% is `(100 - usedPercent) / rate`, which
|
||||
/// simplifies to `(100 - usedPercent) * elapsed / usedPercent`.
|
||||
///
|
||||
/// Returns:
|
||||
/// - nil when any input is unknown (windowMinutes/resetAt nil, elapsed <= 0):
|
||||
/// the caller OMITS the pace clause rather than guessing.
|
||||
/// - nil when usage is near-zero (<= ~1%): burn is negligible, the caller
|
||||
/// renders "plenty at this pace" instead of an absurdly large projection.
|
||||
/// - 0 when already exhausted (usedPercent >= 100): "limit reached".
|
||||
/// - otherwise the projected whole minutes remaining at the current pace.
|
||||
public static func burnMinutesRemaining(
|
||||
usedPercent: Double, resetAt: Date?, windowMinutes: Int?, now: Date
|
||||
) -> Int? {
|
||||
guard let windowMinutes, let resetAt else { return nil }
|
||||
let minutesToReset = resetAt.timeIntervalSince(now) / 60
|
||||
let elapsed = Double(windowMinutes) - max(0, minutesToReset)
|
||||
guard elapsed > 0 else { return nil }
|
||||
if usedPercent >= 100 { return 0 }
|
||||
// Near-zero burn would project an effectively infinite runway; treat it as
|
||||
// "plenty" (nil) so the phrasing layer can say so honestly.
|
||||
guard usedPercent > 1.0 else { return nil }
|
||||
let remaining = (100 - usedPercent) * elapsed / usedPercent
|
||||
return Int(remaining)
|
||||
}
|
||||
|
||||
/// Pick the BINDING window: the one a subscription runs out of first, i.e. the
|
||||
/// lowest `remainingPercent` (closest to empty). Ties break to the shorter
|
||||
/// window first (5h before week), then by a stable key order so the choice is
|
||||
/// deterministic. Opus/Sonnet windows are eligible. Returns nil for empty
|
||||
/// input (error/reauth rows have no windows, so they get no hero gauge).
|
||||
public static func selectBindingWindow(_ windows: [QuotaWindowDetail]) -> QuotaWindowDetail? {
|
||||
guard !windows.isEmpty else { return nil }
|
||||
return windows.min { a, b in
|
||||
if a.remainingPercent != b.remainingPercent {
|
||||
return a.remainingPercent < b.remainingPercent
|
||||
}
|
||||
let am = a.windowMinutes ?? Int.max
|
||||
let bm = b.windowMinutes ?? Int.max
|
||||
if am != bm { return am < bm }
|
||||
return keyRank(a.key) < keyRank(b.key)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable ordering for window keys when remaining% and length tie.
|
||||
private static func keyRank(_ key: String) -> Int {
|
||||
switch key {
|
||||
case "five_hour": return 0
|
||||
case "seven_day": return 1
|
||||
case "seven_day_opus": return 2
|
||||
case "seven_day_sonnet": return 3
|
||||
default: return 4
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a window is GENUINELY at risk of exhaustion before it resets.
|
||||
///
|
||||
/// Returns true only when the projected exhaustion time (burn rate × remaining
|
||||
/// headroom) is LESS than the time remaining until the next reset — i.e. the
|
||||
/// user will hit the wall before the window refreshes. When the projection is
|
||||
/// larger than the reset countdown the warning is meaningless (the quota will
|
||||
/// reset before running out), so atRisk returns false and no scary number is
|
||||
/// shown. Inputs mirror `paceClause`; `now` is injected for testability.
|
||||
public static func atRisk(
|
||||
usedPercent: Double,
|
||||
remainingPercent: Double,
|
||||
resetAt: String?,
|
||||
windowMinutes: Int?,
|
||||
status: String = "ok",
|
||||
now: Date
|
||||
) -> Bool {
|
||||
guard status != "rejected", remainingPercent > 0 else { return false }
|
||||
guard let resetDateStr = resetAt,
|
||||
let resetDate = BarFormatting.isoDate(resetDateStr)
|
||||
else { return false }
|
||||
let minutesToReset = resetDate.timeIntervalSince(now) / 60
|
||||
guard minutesToReset > 0 else { return false }
|
||||
guard let burn = burnMinutesRemaining(
|
||||
usedPercent: usedPercent, resetAt: resetDate, windowMinutes: windowMinutes, now: now)
|
||||
else { return false }
|
||||
// burn == 0 means already exhausted — that is handled by the exhausted path, not atRisk.
|
||||
guard burn > 0 else { return false }
|
||||
// Only at-risk when we will exhaust BEFORE the window resets.
|
||||
return Double(burn) < minutesToReset
|
||||
}
|
||||
|
||||
/// Trailing pace clause for a window's hero/footer line, or nil to OMIT it.
|
||||
///
|
||||
/// Phrasing rules (in order):
|
||||
/// - exhausted (remaining <= 0) or status "rejected" → "limit reached,
|
||||
/// resets in <countdown>". This REPLACES the bare reset countdown.
|
||||
/// - lots of headroom (remaining >= 85) or near-zero usage (burn == nil) →
|
||||
/// "plenty at this pace".
|
||||
/// - at-risk (projected exhaustion BEFORE reset): a finite projection m >= 5
|
||||
/// → "~<Hh Mm> left at this pace". m < 5 → limit-reached path.
|
||||
/// - NOT at-risk (burn > minutesToReset): the projection is beyond the reset,
|
||||
/// so showing the number is misleading — return nil (omit entirely).
|
||||
/// - unknown window (windowMinutes/resetAt nil, elapsed <= 0) → nil (omit).
|
||||
/// - resetAt already in the past (clock skew / stale) → nil pace.
|
||||
public static func paceClause(
|
||||
usedPercent: Double,
|
||||
remainingPercent: Double,
|
||||
resetAt: String?,
|
||||
windowMinutes: Int?,
|
||||
status: String = "ok",
|
||||
now: Date
|
||||
) -> String? {
|
||||
let resetDate = resetAt.flatMap { BarFormatting.isoDate($0) }
|
||||
|
||||
if remainingPercent <= 0 || status == "rejected" {
|
||||
if let countdown = resetCountdown(nextReset: resetAt, now: now) {
|
||||
// resetCountdown returns "resets in 3h 12m"; reuse just the duration.
|
||||
let duration = countdown.replacingOccurrences(of: "resets in ", with: "")
|
||||
return "limit reached, resets in \(duration)"
|
||||
}
|
||||
return "limit reached"
|
||||
}
|
||||
|
||||
// A reset in the past means our window math is unreliable; omit the pace.
|
||||
if let resetDate, resetDate.timeIntervalSince(now) <= 0 { return nil }
|
||||
|
||||
let burn = burnMinutesRemaining(
|
||||
usedPercent: usedPercent, resetAt: resetDate, windowMinutes: windowMinutes, now: now)
|
||||
|
||||
if remainingPercent >= 85 || burn == nil {
|
||||
// nil burn here is either unknown window (handled below) or near-zero use.
|
||||
if windowMinutes == nil || resetDate == nil { return nil }
|
||||
return "plenty at this pace"
|
||||
}
|
||||
|
||||
guard let m = burn else { return nil }
|
||||
if m < 5 {
|
||||
// Floor: anything under 5 minutes is effectively spent; say so plainly.
|
||||
if let countdown = resetCountdown(nextReset: resetAt, now: now) {
|
||||
let duration = countdown.replacingOccurrences(of: "resets in ", with: "")
|
||||
return "limit reached, resets in \(duration)"
|
||||
}
|
||||
return "limit reached"
|
||||
}
|
||||
|
||||
// Core at-risk gate: only show the projection when exhaustion is BEFORE the
|
||||
// reset. If burn >= minutesToReset the quota will outlast the window and the
|
||||
// number would be larger than the reset countdown — meaningless and confusing.
|
||||
let minutesToReset = resetDate.map { $0.timeIntervalSince(now) / 60 } ?? 0
|
||||
guard Double(m) < minutesToReset else { return nil }
|
||||
|
||||
return "~\(compactDuration(minutes: m)) left at this pace"
|
||||
}
|
||||
|
||||
/// Compact terse duration with a three-tier scale:
|
||||
/// >= 24h → "Nd Nh" (e.g. 1590m → "1d 2h", 2678m → "1d 21h")
|
||||
/// 1h–24h → "Hh Mm" (e.g. 195m → "3h 15m")
|
||||
/// < 1h → "Mm" (e.g. 35m → "35m")
|
||||
///
|
||||
/// Named `compactDuration` and `public` so it is reusable from App-side
|
||||
/// formatting (BarCardFormatting) without duplicating the logic.
|
||||
public static func compactDuration(minutes: Int) -> String {
|
||||
let totalHours = minutes / 60
|
||||
let m = minutes % 60
|
||||
if totalHours >= 24 {
|
||||
let d = totalHours / 24
|
||||
let h = totalHours % 24
|
||||
return "\(d)d \(h)h"
|
||||
}
|
||||
if totalHours > 0 { return "\(totalHours)h \(m)m" }
|
||||
return "\(m)m"
|
||||
}
|
||||
|
||||
/// Header "most room" leader: among subscription rows that have a binding
|
||||
/// window, the one whose BINDING window has the HIGHEST remaining%. Rows with
|
||||
/// no binding window (error/reauth) are excluded. Tie-breaks alphabetically by
|
||||
/// display name (falling back to provider). Returns nil with fewer than two
|
||||
/// eligible subscriptions (the header is suppressed below that).
|
||||
public static func headroomLeader(_ rows: [BarSummaryRow]) -> (label: String, remainingPercent: Double)? {
|
||||
let eligible: [(label: String, remaining: Double)] = rows.compactMap { row in
|
||||
guard let binding = selectBindingWindow(row.quotaWindows ?? []) else { return nil }
|
||||
let label = row.displayName ?? row.provider
|
||||
return (label, binding.remainingPercent)
|
||||
}
|
||||
guard eligible.count >= 2 else { return nil }
|
||||
let leader = eligible.max { a, b in
|
||||
if a.remaining != b.remaining { return a.remaining < b.remaining }
|
||||
// Highest remaining wins; alphabetical tie-break (smaller name "wins" max
|
||||
// only when remaining is equal, so invert the name comparison).
|
||||
return a.label > b.label
|
||||
}
|
||||
guard let leader else { return nil }
|
||||
return (leader.label, leader.remaining)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import Foundation
|
||||
|
||||
/// One quota window for a native subscription row (Claude/Codex).
|
||||
///
|
||||
/// Carries BOTH `usedPercent` and `remainingPercent` verbatim from the backend
|
||||
/// so the bar never re-derives one from the other (a single source of truth
|
||||
/// avoids rounding drift between the collapsed glance value and the per-window
|
||||
/// pace math). `windowMinutes` is the window length (300 = 5h, 10080 = 7d) used
|
||||
/// by the burn-rate projection; nil when the backend could not determine it.
|
||||
///
|
||||
/// JSON: the parent field serializes to "quota_windows" (snake_case), but the
|
||||
/// inner keys stay camelCase to match the live serializer.
|
||||
public struct QuotaWindowDetail: Codable, Sendable, Equatable, Identifiable {
|
||||
public let key: String
|
||||
public let label: String
|
||||
public let usedPercent: Double
|
||||
public let remainingPercent: Double
|
||||
public let resetAt: String?
|
||||
public let windowMinutes: Int?
|
||||
|
||||
/// SwiftUI list identity: the window key is unique within a row.
|
||||
public var id: String { key }
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
label: String,
|
||||
usedPercent: Double,
|
||||
remainingPercent: Double,
|
||||
resetAt: String? = nil,
|
||||
windowMinutes: Int? = nil
|
||||
) {
|
||||
self.key = key
|
||||
self.label = label
|
||||
self.usedPercent = usedPercent
|
||||
self.remainingPercent = remainingPercent
|
||||
self.resetAt = resetAt
|
||||
self.windowMinutes = windowMinutes
|
||||
}
|
||||
}
|
||||
|
||||
/// One account row in the menu-bar glance.
|
||||
///
|
||||
/// Mirrors the `GET /api/bar/summary` payload exactly (mixed snake_case and
|
||||
/// camelCase keys, matching the CCS web-server response).
|
||||
public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
|
||||
public let accountId: String
|
||||
public let provider: String
|
||||
public let displayName: String?
|
||||
public let tier: String?
|
||||
public let paused: Bool
|
||||
public let quotaPercentage: Double?
|
||||
/// Tri-state quota availability: "ok" (provider has a quota API and the fetch
|
||||
/// succeeded), "unsupported" (provider has no quota API at all, e.g. ghcp/kiro),
|
||||
/// or "error" (should report quota but the fetch failed/timed out/needs reauth).
|
||||
/// Drives "no quota" (unsupported) vs "quota ?" (error) so a bare "--" never
|
||||
/// conflates the two.
|
||||
public let quotaStatus: String
|
||||
public let nextReset: String?
|
||||
/// True when this is the provider's default account; drives the active/default badge.
|
||||
public let isDefault: Bool
|
||||
/// ISO timestamp this account was last used, null if never/unknown.
|
||||
public let lastActivityAt: String?
|
||||
public let todayCost: Double?
|
||||
public let health: String
|
||||
public let cached: Bool
|
||||
public let fetchedAt: String?
|
||||
public let needsReauth: Bool
|
||||
/// Native-only per-window quota breakdown (Claude: 5h/week/opus/sonnet,
|
||||
/// Codex: 5h/week). nil for CLIProxy pool rows, which omit "quota_windows"
|
||||
/// entirely — so legacy payloads decode unchanged (backward compatible).
|
||||
public let quotaWindows: [QuotaWindowDetail]?
|
||||
/// Native-only ISO mtime of the source session that supplied a STALE Codex
|
||||
/// reading. Present only when the data is stale; nil otherwise. Drives the
|
||||
/// "as of HH:mm (older session)" footnote without faking a "live" badge.
|
||||
public let staleAsOf: String?
|
||||
|
||||
/// Stable identity for SwiftUI lists: provider-scoped account id.
|
||||
public var id: String { "\(provider):\(accountId)" }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accountId = "account_id"
|
||||
case provider
|
||||
case displayName
|
||||
case tier
|
||||
case paused
|
||||
case quotaPercentage = "quota_percentage"
|
||||
case quotaStatus
|
||||
case nextReset = "next_reset"
|
||||
case isDefault = "is_default"
|
||||
case lastActivityAt = "last_activity_at"
|
||||
case todayCost = "today_cost"
|
||||
case health
|
||||
case cached
|
||||
case fetchedAt
|
||||
case needsReauth
|
||||
case quotaWindows = "quota_windows"
|
||||
case staleAsOf = "stale_as_of"
|
||||
}
|
||||
|
||||
public init(
|
||||
accountId: String,
|
||||
provider: String,
|
||||
displayName: String? = nil,
|
||||
tier: String? = nil,
|
||||
paused: Bool = false,
|
||||
quotaPercentage: Double? = nil,
|
||||
quotaStatus: String = "ok",
|
||||
nextReset: String? = nil,
|
||||
isDefault: Bool = false,
|
||||
lastActivityAt: String? = nil,
|
||||
todayCost: Double? = nil,
|
||||
health: String = "ok",
|
||||
cached: Bool = false,
|
||||
fetchedAt: String? = nil,
|
||||
needsReauth: Bool = false,
|
||||
quotaWindows: [QuotaWindowDetail]? = nil,
|
||||
staleAsOf: String? = nil
|
||||
) {
|
||||
self.accountId = accountId
|
||||
self.provider = provider
|
||||
self.displayName = displayName
|
||||
self.tier = tier
|
||||
self.paused = paused
|
||||
self.quotaPercentage = quotaPercentage
|
||||
self.quotaStatus = quotaStatus
|
||||
self.nextReset = nextReset
|
||||
self.isDefault = isDefault
|
||||
self.lastActivityAt = lastActivityAt
|
||||
self.todayCost = todayCost
|
||||
self.health = health
|
||||
self.cached = cached
|
||||
self.fetchedAt = fetchedAt
|
||||
self.needsReauth = needsReauth
|
||||
self.quotaWindows = quotaWindows
|
||||
self.staleAsOf = staleAsOf
|
||||
}
|
||||
|
||||
/// Resilient decode: the two native-only keys are decoded with
|
||||
/// `decodeIfPresent` so a legacy payload (no "quota_windows"/"stale_as_of")
|
||||
/// yields nil rather than a decode failure. All other keys keep synthesized
|
||||
/// behavior.
|
||||
public init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
accountId = try c.decode(String.self, forKey: .accountId)
|
||||
provider = try c.decode(String.self, forKey: .provider)
|
||||
displayName = try c.decodeIfPresent(String.self, forKey: .displayName)
|
||||
tier = try c.decodeIfPresent(String.self, forKey: .tier)
|
||||
paused = try c.decode(Bool.self, forKey: .paused)
|
||||
quotaPercentage = try c.decodeIfPresent(Double.self, forKey: .quotaPercentage)
|
||||
quotaStatus = try c.decode(String.self, forKey: .quotaStatus)
|
||||
nextReset = try c.decodeIfPresent(String.self, forKey: .nextReset)
|
||||
isDefault = try c.decode(Bool.self, forKey: .isDefault)
|
||||
lastActivityAt = try c.decodeIfPresent(String.self, forKey: .lastActivityAt)
|
||||
todayCost = try c.decodeIfPresent(Double.self, forKey: .todayCost)
|
||||
health = try c.decode(String.self, forKey: .health)
|
||||
cached = try c.decode(Bool.self, forKey: .cached)
|
||||
fetchedAt = try c.decodeIfPresent(String.self, forKey: .fetchedAt)
|
||||
needsReauth = try c.decode(Bool.self, forKey: .needsReauth)
|
||||
quotaWindows = try c.decodeIfPresent([QuotaWindowDetail].self, forKey: .quotaWindows)
|
||||
staleAsOf = try c.decodeIfPresent(String.self, forKey: .staleAsOf)
|
||||
}
|
||||
}
|
||||
|
||||
extension BarSummaryRow {
|
||||
/// Health rendered as an ASCII-safe dot for the dropdown.
|
||||
public var healthDot: String {
|
||||
switch health {
|
||||
case "error": return "X"
|
||||
case "warning": return "!"
|
||||
default: return "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import SwiftUI
|
||||
|
||||
// Theme token model for the menu-bar dropdown.
|
||||
//
|
||||
// Lives in CCSBarCore (not the SwiftUI app target) so the assert harness can
|
||||
// import and verify the palette/enum/resolver without a full Xcode/XCTest
|
||||
// toolchain. This is the ONE file in CCSBarCore that imports SwiftUI — every
|
||||
// other Core file stays Foundation-only. SwiftUI's ColorScheme/Color compile
|
||||
// and run on the CommandLineTools toolchain, which is why this is safe here.
|
||||
|
||||
// MARK: - Raw palette (harness-assertable)
|
||||
|
||||
/// A plain RGB triple. We keep raw Doubles (not SwiftUI `Color`) as the source
|
||||
/// of truth because `Color` equality is unreliable for tests — two Colors built
|
||||
/// from identical components are not guaranteed `==`. Asserting on these Doubles
|
||||
/// is exact, so the dark-regression lock and light-value lock are byte-precise.
|
||||
public struct RGB: Equatable, Sendable {
|
||||
public let r: Double
|
||||
public let g: Double
|
||||
public let b: Double
|
||||
public init(_ r: Double, _ g: Double, _ b: Double) {
|
||||
self.r = r
|
||||
self.g = g
|
||||
self.b = b
|
||||
}
|
||||
}
|
||||
|
||||
/// Named color triples for one appearance. Pure data so it round-trips through
|
||||
/// the harness with exact equality.
|
||||
public struct BarPalette: Equatable, Sendable {
|
||||
public let accentRGB: RGB
|
||||
public let subscriptionRGB: RGB
|
||||
public let bandGreenRGB: RGB
|
||||
public let bandAmberRGB: RGB
|
||||
public let bandCoralRGB: RGB
|
||||
public let bandRedRGB: RGB
|
||||
/// Light-mode window plate. In dark mode the window defers to the native
|
||||
/// MenuBarExtra material, so this value is unused there (windowSurface == .clear).
|
||||
public let windowSurfaceRGB: RGB
|
||||
|
||||
public init(
|
||||
accentRGB: RGB, subscriptionRGB: RGB, bandGreenRGB: RGB, bandAmberRGB: RGB,
|
||||
bandCoralRGB: RGB, bandRedRGB: RGB, windowSurfaceRGB: RGB
|
||||
) {
|
||||
self.accentRGB = accentRGB
|
||||
self.subscriptionRGB = subscriptionRGB
|
||||
self.bandGreenRGB = bandGreenRGB
|
||||
self.bandAmberRGB = bandAmberRGB
|
||||
self.bandCoralRGB = bandCoralRGB
|
||||
self.bandRedRGB = bandRedRGB
|
||||
self.windowSurfaceRGB = windowSurfaceRGB
|
||||
}
|
||||
|
||||
/// DARK = today's exact values, lifted verbatim from the original Sparkline
|
||||
/// `BarTheme` enum. These are LOCKED: any drift fails the harness, guaranteeing
|
||||
/// byte-identical rendering on upgrade for users who stay on the default theme.
|
||||
public static let dark = BarPalette(
|
||||
accentRGB: RGB(0.886, 0.451, 0.137), // #E2732A CCS orange
|
||||
subscriptionRGB: RGB(0.357, 0.388, 0.851), // #5B63D9 indigo
|
||||
bandGreenRGB: RGB(0.36, 0.74, 0.56), // #5CBC8F emerald
|
||||
bandAmberRGB: RGB(0.86, 0.67, 0.31), // #DBAB4F gold
|
||||
bandCoralRGB: RGB(0.91, 0.46, 0.36), // #E8755C warning
|
||||
bandRedRGB: RGB(0.85, 0.34, 0.31), // #D9564F critical
|
||||
windowSurfaceRGB: RGB(0, 0, 0) // unused in dark (windowSurface == .clear)
|
||||
)
|
||||
|
||||
/// LIGHT = deepened/saturated variants tuned for legibility on a ~#F5F5F7
|
||||
/// white plate. The dark-tuned muted values read too pale on white, so each
|
||||
/// themed token is darkened with more saturation while preserving the
|
||||
/// green→amber→coral→red ramp ordering.
|
||||
public static let light = BarPalette(
|
||||
accentRGB: RGB(0.812, 0.357, 0.063), // #CF5B10 deeper orange
|
||||
subscriptionRGB: RGB(0.275, 0.302, 0.745), // #464DBE darker indigo
|
||||
bandGreenRGB: RGB(0.106, 0.580, 0.357), // #1B945B emerald
|
||||
bandAmberRGB: RGB(0.722, 0.490, 0.043), // #B87D0B ochre
|
||||
bandCoralRGB: RGB(0.831, 0.302, 0.157), // #D44D28 coral
|
||||
bandRedRGB: RGB(0.776, 0.157, 0.137), // #C62823 critical red
|
||||
windowSurfaceRGB: RGB(0.961, 0.961, 0.969) // #F5F5F7 light plate
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Appearance enum + forced-scheme mapping
|
||||
|
||||
/// User-selectable menu-bar theme. `.system` follows the real OS appearance;
|
||||
/// `.light`/`.dark` force a scheme regardless of OS.
|
||||
public enum BarAppearance: String, CaseIterable, Sendable {
|
||||
case system
|
||||
case light
|
||||
case dark
|
||||
|
||||
/// The scheme to force on the dropdown. `nil` => inherit the real OS
|
||||
/// appearance; `.light`/`.dark` => override it.
|
||||
public var forced: ColorScheme? {
|
||||
switch self {
|
||||
case .system: return nil
|
||||
case .light: return .light
|
||||
case .dark: return .dark
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Resolved token struct (views read this)
|
||||
|
||||
/// The resolved SwiftUI tokens consumed by the dropdown views. Built from a
|
||||
/// `BarPalette`, plus two derived `Color.primary.opacity(...)` surfaces that
|
||||
/// auto-invert with the forced scheme (primary is black on light, white on
|
||||
/// dark) and so are identical in both presets.
|
||||
public struct BarTheme: Sendable {
|
||||
/// The palette this theme resolved from — kept so the harness can verify the
|
||||
/// resolver picked the right set without relying on Color equality.
|
||||
public let palette: BarPalette
|
||||
|
||||
public let accent: Color
|
||||
public let subscription: Color
|
||||
public let bandGreen: Color
|
||||
public let bandAmber: Color
|
||||
public let bandCoral: Color
|
||||
public let bandRed: Color
|
||||
/// Faint elevated surface; derived, auto-inverts. Centralizes the inline
|
||||
/// `Color.primary.opacity(0.05)` references.
|
||||
public let cardSurface: Color
|
||||
/// Quota-bar track; derived, auto-inverts. Centralizes `Color.primary.opacity(0.12)`.
|
||||
public let barTrack: Color
|
||||
/// Window plate. Dark = `.clear` (defer to native material, zero regression);
|
||||
/// light = explicit #F5F5F7 so tokens never render on a leftover dark material.
|
||||
public let windowSurface: Color
|
||||
|
||||
public init(palette: BarPalette) {
|
||||
self.palette = palette
|
||||
self.accent = Color(rgb: palette.accentRGB)
|
||||
self.subscription = Color(rgb: palette.subscriptionRGB)
|
||||
self.bandGreen = Color(rgb: palette.bandGreenRGB)
|
||||
self.bandAmber = Color(rgb: palette.bandAmberRGB)
|
||||
self.bandCoral = Color(rgb: palette.bandCoralRGB)
|
||||
self.bandRed = Color(rgb: palette.bandRedRGB)
|
||||
self.cardSurface = Color.primary.opacity(0.05)
|
||||
self.barTrack = Color.primary.opacity(0.12)
|
||||
// Dark defers to the native MenuBarExtra material; only light owns a plate.
|
||||
self.windowSurface = (palette == .dark) ? .clear : Color(rgb: palette.windowSurfaceRGB)
|
||||
}
|
||||
|
||||
public static let dark = BarTheme(palette: .dark)
|
||||
public static let light = BarTheme(palette: .light)
|
||||
|
||||
/// Pure resolver: returns the token set for a given (already-forced) scheme.
|
||||
/// The root view applies `.preferredColorScheme(appearance.forced)` and then
|
||||
/// reads `\.colorScheme` on a descendant, so the scheme passed here always
|
||||
/// reflects exactly what the user sees (for `.system`, the real OS scheme).
|
||||
public static func resolve(_ scheme: ColorScheme) -> BarTheme {
|
||||
scheme == .dark ? .dark : .light
|
||||
}
|
||||
}
|
||||
|
||||
extension Color {
|
||||
/// Builds a Color from a raw RGB triple.
|
||||
fileprivate init(rgb: RGB) {
|
||||
self.init(red: rgb.r, green: rgb.g, blue: rgb.b)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Environment propagation
|
||||
|
||||
/// Injects the resolved theme down the view tree. The default is the tuned
|
||||
/// dark preset so any view rendered outside an injected subtree (SwiftUI
|
||||
/// previews, a stray child) gets the exact current look — no crash, no
|
||||
/// regression.
|
||||
public struct BarThemeKey: EnvironmentKey {
|
||||
public static let defaultValue = BarTheme.dark
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
public var barTheme: BarTheme {
|
||||
get { self[BarThemeKey.self] }
|
||||
set { self[BarThemeKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
/// Persists the chosen appearance. Structurally mirrors `MenuBarIcon` load/save.
|
||||
/// Appearance is global chrome (not an alert pref), so it is NOT registered in
|
||||
/// `registerDefaults()`; the `?? .dark` fallback on a nil string read is the
|
||||
/// source of the default, avoiding any registration-domain trap.
|
||||
public enum BarAppearanceStore {
|
||||
public static let defaultsKey = "ccsbar.appearance"
|
||||
|
||||
public static func load() -> BarAppearance {
|
||||
let raw =
|
||||
UserDefaults.standard.string(forKey: defaultsKey) ?? BarAppearance.dark.rawValue
|
||||
return BarAppearance(rawValue: raw) ?? .dark
|
||||
}
|
||||
|
||||
public static func save(_ appearance: BarAppearance) {
|
||||
UserDefaults.standard.set(appearance.rawValue, forKey: defaultsKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
@@ -622,6 +622,34 @@ describe('Claude Quota Fetcher', () => {
|
||||
expect(result.coreUsage?.fiveHour?.remainingPercent).toBe(60);
|
||||
});
|
||||
|
||||
it('does NOT inner-retry on 429; returns retryable single-attempt result honoring Retry-After', async () => {
|
||||
// Safety intent: 429 must NOT trigger an immediate, delay-free inner retry.
|
||||
// The outer 10-min cache + circuit breaker honor Retry-After and bound total
|
||||
// volume, so a single attempt is made and the retryable signal is surfaced.
|
||||
createClaudeAccount('claude-429@example.com', {
|
||||
access_token: 'rate-limited-token',
|
||||
expired: '2099-01-01T00:00:00.000Z',
|
||||
type: 'claude',
|
||||
});
|
||||
|
||||
let attempt = 0;
|
||||
global.fetch = mock(() => {
|
||||
attempt += 1;
|
||||
return Promise.resolve(
|
||||
new Response('', { status: 429, headers: { 'Retry-After': '120' } })
|
||||
);
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await fetchClaudeQuota('claude-429@example.com');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
// Single attempt — no inner retry burned on the 429.
|
||||
expect(attempt).toBe(1);
|
||||
expect(result.httpStatus).toBe(429);
|
||||
expect(result.retryable).toBe(true);
|
||||
expect(result.errorDetail).toBe('retry-after:120');
|
||||
});
|
||||
|
||||
it('clears the request timeout before retrying a retryable HTTP error', async () => {
|
||||
createClaudeAccount('claude-retry-timeout@example.com', {
|
||||
access_token: 'retry-timeout-token',
|
||||
|
||||
@@ -218,25 +218,19 @@ function buildEmptyResult(
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch quota for a single Claude account.
|
||||
* Run the Anthropic OAuth usage fetch loop for a known-good access token.
|
||||
*
|
||||
* This is the single Anthropic-call surface shared by both the CLIProxy-managed
|
||||
* path (fetchClaudeQuota) and the native-login path (fetchClaudeQuotaWithToken).
|
||||
* It owns the 401/403/404/429/5xx branch logic, the bounded retry loop, and the
|
||||
* window normalization so neither caller re-implements the hostile-endpoint
|
||||
* handling. The callers differ only in WHERE the token comes from.
|
||||
*/
|
||||
export async function fetchClaudeQuota(
|
||||
async function runClaudeUsageFetch(
|
||||
accessToken: string,
|
||||
accountId: string,
|
||||
verbose = false
|
||||
verbose: boolean
|
||||
): Promise<ClaudeQuotaResult> {
|
||||
const authData = await readClaudeAuthData(accountId);
|
||||
if (!authData) {
|
||||
return buildEmptyResult('Auth file not found for Claude account', accountId);
|
||||
}
|
||||
|
||||
if (authData.isExpired) {
|
||||
return buildEmptyResult(
|
||||
'Token expired - re-authenticate with ccs cliproxy auth claude',
|
||||
accountId,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
let lastError = 'Unknown error';
|
||||
|
||||
for (let attempt = 1; attempt <= CLAUDE_QUOTA_MAX_ATTEMPTS; attempt++) {
|
||||
@@ -248,7 +242,7 @@ export async function fetchClaudeQuota(
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Authorization: `Bearer ${authData.accessToken}`,
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-beta': CLAUDE_OAUTH_BETA_HEADER,
|
||||
@@ -283,15 +277,23 @@ export async function fetchClaudeQuota(
|
||||
lastError =
|
||||
(await readResponseErrorMessage(response)) ||
|
||||
`Claude OAuth usage API error: ${response.status}`;
|
||||
if (
|
||||
attempt < CLAUDE_QUOTA_MAX_ATTEMPTS &&
|
||||
(response.status === 429 || response.status >= 500)
|
||||
) {
|
||||
// Surface the upstream status + Retry-After so an outer caller (the
|
||||
// native collector's circuit-breaker) can honor backoff guidance.
|
||||
const retryAfter = response.headers.get('retry-after');
|
||||
// Do not inner-retry 429 with no delay; the outer cache + circuit breaker
|
||||
// honor Retry-After and bound total volume. Inner retry stays for
|
||||
// transient 5xx only.
|
||||
if (attempt < CLAUDE_QUOTA_MAX_ATTEMPTS && response.status >= 500) {
|
||||
clearTimeout(timeoutId);
|
||||
continue;
|
||||
}
|
||||
clearTimeout(timeoutId);
|
||||
return buildEmptyResult(lastError, accountId);
|
||||
return {
|
||||
...buildEmptyResult(lastError, accountId),
|
||||
httpStatus: response.status,
|
||||
retryable: response.status === 429 || response.status >= 500,
|
||||
...(retryAfter ? { errorDetail: `retry-after:${retryAfter}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
@@ -338,12 +340,54 @@ export async function fetchClaudeQuota(
|
||||
|
||||
if (attempt >= CLAUDE_QUOTA_MAX_ATTEMPTS) {
|
||||
clearTimeout(timeoutId);
|
||||
return buildEmptyResult(lastError, accountId);
|
||||
return { ...buildEmptyResult(lastError, accountId), retryable: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buildEmptyResult(lastError, accountId);
|
||||
return { ...buildEmptyResult(lastError, accountId), retryable: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch quota using a directly-supplied native OAuth access token.
|
||||
*
|
||||
* Reuses the exact Anthropic call + normalization as fetchClaudeQuota; the only
|
||||
* difference is the token source (the logged-in Claude Code credential rather
|
||||
* than a CLIProxy-managed auth file). Lives in this file so it can share the
|
||||
* file-private beta header, timeout, attempt count, and branch logic.
|
||||
*/
|
||||
export async function fetchClaudeQuotaWithToken(
|
||||
accessToken: string,
|
||||
accountId = 'claude-code',
|
||||
verbose = false
|
||||
): Promise<ClaudeQuotaResult> {
|
||||
if (!accessToken || accessToken.trim().length === 0) {
|
||||
return buildEmptyResult('Missing native Claude access token', accountId, true);
|
||||
}
|
||||
return runClaudeUsageFetch(accessToken.trim(), accountId, verbose);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch quota for a single Claude account.
|
||||
*/
|
||||
export async function fetchClaudeQuota(
|
||||
accountId: string,
|
||||
verbose = false
|
||||
): Promise<ClaudeQuotaResult> {
|
||||
const authData = await readClaudeAuthData(accountId);
|
||||
if (!authData) {
|
||||
return buildEmptyResult('Auth file not found for Claude account', accountId);
|
||||
}
|
||||
|
||||
if (authData.isExpired) {
|
||||
return buildEmptyResult(
|
||||
'Token expired - re-authenticate with ccs cliproxy auth claude',
|
||||
accountId,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
return runClaudeUsageFetch(authData.accessToken, accountId, verbose);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -835,11 +835,15 @@ export async function fetchAccountQuota(
|
||||
if (provider !== 'agy') {
|
||||
const error = `Quota not supported for provider: ${provider}`;
|
||||
if (verbose) console.error(`[!] Error: ${error}`);
|
||||
// Stable machine code so callers branch on a code, not the human string.
|
||||
// This is "no quota API for this provider", which is healthy — distinct
|
||||
// from a transient fetch failure or an expired token.
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
lastUpdated: Date.now(),
|
||||
error,
|
||||
errorCode: 'quota_not_supported',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
|
||||
import type { RuntimeMonitorConfig } from '../../config/unified-config-types';
|
||||
import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade';
|
||||
import { getTierLockForProvider } from '../../config/schemas/quota';
|
||||
|
||||
export type ManagedQuotaProvider = 'agy' | 'claude' | 'codex' | 'gemini' | 'ghcp';
|
||||
type ManagedQuotaResult =
|
||||
@@ -428,10 +429,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 +634,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');
|
||||
|
||||
@@ -344,7 +344,9 @@ async function fetchManagementJson<T>(
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCliproxyAuthFiles(port?: number): Promise<CliproxyManagementAuthFile[] | null> {
|
||||
export async function fetchCliproxyAuthFiles(
|
||||
port?: number
|
||||
): Promise<CliproxyManagementAuthFile[] | null> {
|
||||
try {
|
||||
const result = await fetchManagementJson<{ files?: CliproxyManagementAuthFile[] }>(
|
||||
'/v0/management/auth-files',
|
||||
@@ -367,6 +369,29 @@ export const __testExports = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Build an auth_index → account email/id map from CLIProxy auth file metadata.
|
||||
*
|
||||
* Keys are stored as strings (String(auth_index)) so both numeric and string
|
||||
* auth_index values resolve consistently via `map.get(String(auth_index))`.
|
||||
*
|
||||
* Entries missing either `auth_index` or `email` are silently skipped.
|
||||
*
|
||||
* @param authFiles Auth file records from /v0/management/auth-files
|
||||
* @returns Map from String(auth_index) → email
|
||||
*/
|
||||
export function buildAuthIndexToAccountMap(
|
||||
authFiles: CliproxyManagementAuthFile[]
|
||||
): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
for (const file of authFiles) {
|
||||
if (file.auth_index === undefined || file.auth_index === null) continue;
|
||||
if (!file.email || file.email.trim().length === 0) continue;
|
||||
map.set(String(file.auth_index), file.email.trim());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** OpenAI-compatible model object from /v1/models endpoint */
|
||||
export interface CliproxyModel {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* `ccs bar` command dispatcher
|
||||
*
|
||||
* Mirrors the pattern in src/commands/docker/index.ts.
|
||||
* Subcommands: launch (default), install, uninstall, version / --version.
|
||||
*/
|
||||
|
||||
export async function handleBarCommand(args: string[]): Promise<void> {
|
||||
const subcommand = args[0];
|
||||
|
||||
// --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]');
|
||||
return;
|
||||
}
|
||||
|
||||
await handler(args.slice(1));
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
/**
|
||||
* `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 install the
|
||||
* handler calls GET /api/overview to verify version compatibility and warns
|
||||
* (but does not hard-fail) on mismatch.
|
||||
*
|
||||
* 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';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface CompatResult {
|
||||
version: string;
|
||||
compatible: boolean;
|
||||
}
|
||||
|
||||
export interface InstallDeps {
|
||||
/**
|
||||
* Resolve the asset download URL + version string from a GitHub release tag.
|
||||
* Production: calls GitHub API releases/tags/{tag}.
|
||||
* Test: mock that returns a fake URL + version.
|
||||
*/
|
||||
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>;
|
||||
/**
|
||||
* Call GET {baseUrl}/api/overview and return { version, compatible }.
|
||||
* compatible = server version major === installed app version major.
|
||||
* Returns compatible:false (never true) when versions cannot be compared.
|
||||
*/
|
||||
verifyCompat: (baseUrl: string, installedVersion: string) => Promise<CompatResult>;
|
||||
/** Returns path to ~/.ccs (respects CCS_HOME). */
|
||||
getCcsDir: () => string;
|
||||
/** Destination directory for the .app bundle (~/Applications by default). */
|
||||
getAppsDir: () => string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
const tagName: string = release.tag_name ?? tag;
|
||||
// Strip leading 'v' for the version pin file.
|
||||
const version = tagName.replace(/^v/, '');
|
||||
|
||||
// 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, version };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call GET {baseUrl}/api/overview and compare server version against the
|
||||
* installed bar version. Returns compat=false whenever the comparison cannot
|
||||
* be made (server unreachable, version strings unparseable, etc.).
|
||||
*
|
||||
* Fix for Finding #10: replaces the phantom check that always returned true.
|
||||
* Compatible is defined as same semver major (0 vs 0, 1 vs 1, etc.).
|
||||
*/
|
||||
async function defaultVerifyCompat(
|
||||
baseUrl: string,
|
||||
installedVersion: string
|
||||
): Promise<CompatResult> {
|
||||
try {
|
||||
const { request } = await import('undici');
|
||||
const { statusCode, body } = await request(`${baseUrl}/api/overview`, {
|
||||
headers: { 'User-Agent': 'ccs-cli' },
|
||||
});
|
||||
if (statusCode !== 200) {
|
||||
console.log(
|
||||
`[!] Version compatibility check failed: /api/overview returned HTTP ${statusCode}.`
|
||||
);
|
||||
return { version: 'unknown', compatible: false };
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = (await body.json()) as any;
|
||||
const serverVersion: string = (data.version as string) ?? 'unknown';
|
||||
|
||||
if (serverVersion === 'unknown') {
|
||||
console.log('[!] Version compatibility: server did not report a version.');
|
||||
return { version: serverVersion, compatible: false };
|
||||
}
|
||||
|
||||
// Parse installed version major from the pinned version string.
|
||||
const installedMajorRaw = installedVersion.split('.')[0];
|
||||
const serverMajorRaw = serverVersion.split('.')[0];
|
||||
const installedMajor = parseInt(installedMajorRaw ?? '', 10);
|
||||
const serverMajor = parseInt(serverMajorRaw ?? '', 10);
|
||||
|
||||
if (isNaN(installedMajor) || isNaN(serverMajor)) {
|
||||
console.log(
|
||||
`[!] Version compatibility: cannot parse major versions ` +
|
||||
`(installed="${installedVersion}", server="${serverVersion}").`
|
||||
);
|
||||
return { version: serverVersion, compatible: false };
|
||||
}
|
||||
|
||||
const compatible = installedMajor === serverMajor;
|
||||
return { version: serverVersion, compatible };
|
||||
} catch {
|
||||
// Server unreachable — warn but do not claim compatible.
|
||||
return { version: 'unknown', compatible: false };
|
||||
}
|
||||
}
|
||||
|
||||
function defaultGetCcsDir(): string {
|
||||
return getCcsDir();
|
||||
}
|
||||
|
||||
function defaultGetAppsDir(): string {
|
||||
return path.join(os.homedir(), 'Applications');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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> {
|
||||
const fetchReleaseAsset = deps.fetchReleaseAsset ?? defaultFetchReleaseAsset;
|
||||
const downloadAndExtract = deps.downloadAndExtract ?? defaultDownloadAndExtract;
|
||||
const verifyCompat = deps.verifyCompat ?? defaultVerifyCompat;
|
||||
const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)();
|
||||
const appsDir = (deps.getAppsDir ?? defaultGetAppsDir)();
|
||||
|
||||
console.log('[i] Fetching CCS Bar release info...');
|
||||
|
||||
// 1. Resolve the floating tag → download URL + version.
|
||||
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, version } = releaseInfo;
|
||||
console.log(`[i] Installing CCS Bar v${version} from ${BAR_RELEASE_TAG}...`);
|
||||
|
||||
// 2. Download and extract into ~/Applications.
|
||||
try {
|
||||
await downloadAndExtract(downloadUrl, appsDir);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Download or extraction failed: ${msg}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Finding #12: assert that the expected .app bundle was actually extracted
|
||||
// before reporting success.
|
||||
const appPath = path.join(appsDir, BAR_APP_NAME);
|
||||
if (!fs.existsSync(appPath)) {
|
||||
// Report what was actually extracted to help diagnose archive issues.
|
||||
let extracted: string[] = [];
|
||||
try {
|
||||
extracted = fs.readdirSync(appsDir);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const found = extracted.length > 0 ? extracted.join(', ') : '(none)';
|
||||
console.error(`[X] Extraction succeeded but "${BAR_APP_NAME}" was not found in ${appsDir}.`);
|
||||
console.error(`[i] Files found in ${appsDir}: ${found}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Pin the installed version to ~/.ccs/bar/.version.
|
||||
try {
|
||||
pinBarVersion(ccsDir, version);
|
||||
} 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${version} installed to ${appsDir}/${BAR_APP_NAME}`);
|
||||
|
||||
// 5. Version-compat handshake via /api/overview.
|
||||
// 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 {
|
||||
// Pass the pinned version so verifyCompat can do a real major-version comparison.
|
||||
const compat = await verifyCompat(baseUrl, version);
|
||||
if (!compat.compatible) {
|
||||
console.log(
|
||||
`[!] Version mismatch: CCS server reports v${compat.version}, ` +
|
||||
`app is v${version}. Some features may not work until you restart ` +
|
||||
'`ccs bar` or update CCS.'
|
||||
);
|
||||
} else {
|
||||
console.log(`[OK] Version compatibility confirmed (server: v${compat.version}).`);
|
||||
}
|
||||
} catch {
|
||||
console.log('[!] Could not verify version compatibility (server may not be running).');
|
||||
console.log('[i] Run `ccs bar` to start the server and recheck.');
|
||||
}
|
||||
|
||||
// 6. Print Gatekeeper guidance for ad-hoc/unsigned builds.
|
||||
console.log('');
|
||||
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 "${path.join(appsDir, BAR_APP_NAME)}"`);
|
||||
console.log(' Or right-click the app and select Open.');
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* `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).
|
||||
*/
|
||||
|
||||
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 {
|
||||
/**
|
||||
* 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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');
|
||||
|
||||
const port = await getPort({ port: [3000, 3001, 3002, 8000, 8080] });
|
||||
const { server } = await startServer({ port });
|
||||
|
||||
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;
|
||||
|
||||
// 1. Ensure the web-server/dashboard is running.
|
||||
let dashboardInfo: DashboardInfo;
|
||||
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;
|
||||
}
|
||||
|
||||
console.log(`[OK] CCS web-server running at ${dashboardInfo.baseUrl}`);
|
||||
console.log(`[i] Discovery file written: ${path.join(ccsDir, 'bar.json')}`);
|
||||
|
||||
// 3. Open the app.
|
||||
try {
|
||||
await openApp(appInstallPath);
|
||||
console.log('[OK] CCS Bar launched.');
|
||||
} catch {
|
||||
// Degraded path: app not installed or open failed.
|
||||
if (!fs.existsSync(appInstallPath)) {
|
||||
console.log('[!] CCS Bar app is not installed.');
|
||||
console.log('[i] Run `ccs bar install` to install it.');
|
||||
} else {
|
||||
console.log('[!] Could not open CCS Bar. Try right-clicking and selecting Open.');
|
||||
console.log('[i] If Gatekeeper blocks the app, run:');
|
||||
console.log(` xattr -dr com.apple.quarantine "${appInstallPath}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* `ccs bar uninstall` — remove CCS Bar.app from ~/Applications
|
||||
* and clear the version pin at ~/.ccs/bar/.version.
|
||||
*
|
||||
* No-op (and no error) when neither the app nor the pin exists.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir } from '../../config/config-loader-facade';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UninstallDeps {
|
||||
getCcsDir: () => string;
|
||||
getAppsDir: () => string;
|
||||
appName: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function handleBarUninstall(
|
||||
_args: string[],
|
||||
deps: Partial<UninstallDeps> = {}
|
||||
): Promise<void> {
|
||||
const ccsDir = (deps.getCcsDir ?? (() => getCcsDir()))();
|
||||
const appsDir = (deps.getAppsDir ?? (() => path.join(os.homedir(), 'Applications')))();
|
||||
const appName = deps.appName ?? 'CCS Bar.app';
|
||||
|
||||
const appPath = path.join(appsDir, appName);
|
||||
const versionPin = path.join(ccsDir, 'bar', '.version');
|
||||
|
||||
let removed = false;
|
||||
|
||||
// Remove the .app bundle (a directory on macOS).
|
||||
if (fs.existsSync(appPath)) {
|
||||
try {
|
||||
fs.rmSync(appPath, { recursive: true, force: true });
|
||||
console.log(`[OK] Removed ${appPath}`);
|
||||
removed = true;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Failed to remove ${appPath}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the version pin.
|
||||
if (fs.existsSync(versionPin)) {
|
||||
try {
|
||||
fs.unlinkSync(versionPin);
|
||||
removed = true;
|
||||
} catch {
|
||||
// Non-fatal — pin may already be gone.
|
||||
}
|
||||
}
|
||||
|
||||
if (!removed) {
|
||||
console.log('[i] CCS Bar is not installed — nothing to remove.');
|
||||
} else {
|
||||
console.log('[OK] CCS Bar uninstalled.');
|
||||
console.log('[i] Run `ccs bar install` to reinstall.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* `ccs bar --version` / `ccs bar version`
|
||||
*
|
||||
* Prints the CCS CLI version alongside the installed CCS Bar app version
|
||||
* (read from ~/.ccs/bar/.version, if present).
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getVersion } from '../../utils/version';
|
||||
import { getCcsDir } from '../../config/config-loader-facade';
|
||||
|
||||
function readInstalledBarVersion(ccsDir: string): string | null {
|
||||
const versionFile = path.join(ccsDir, 'bar', '.version');
|
||||
try {
|
||||
const content = fs.readFileSync(versionFile, 'utf8').trim();
|
||||
return content || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleBarVersion(): Promise<void> {
|
||||
const cliVersion = getVersion();
|
||||
const ccsDir = getCcsDir();
|
||||
const barVersion = readInstalledBarVersion(ccsDir);
|
||||
|
||||
// Finding #13: label each line unambiguously — CLI version vs installed Bar app version.
|
||||
console.log(`[i] CCS CLI v${cliVersion}`);
|
||||
if (barVersion) {
|
||||
console.log(`[i] CCS Bar app: v${barVersion}`);
|
||||
} else {
|
||||
console.log('[i] CCS Bar app: not installed (run `ccs bar install`)');
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -139,6 +139,12 @@ export const ROOT_COMMAND_CATALOG: readonly RootCommandEntry[] = [
|
||||
group: 'operations',
|
||||
visibility: 'public',
|
||||
},
|
||||
{
|
||||
name: 'bar',
|
||||
summary: 'Install and launch the CCS macOS menu bar app',
|
||||
group: 'operations',
|
||||
visibility: 'public',
|
||||
},
|
||||
{
|
||||
name: 'sync',
|
||||
summary: 'Sync delegation commands and skills',
|
||||
|
||||
@@ -193,6 +193,13 @@ export const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [
|
||||
await handleSetupCommand(args);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'bar',
|
||||
handle: async (args) => {
|
||||
const { handleBarCommand } = await import('./bar');
|
||||
await handleBarCommand(args);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function tryHandleRootCommand(args: string[]): Promise<boolean> {
|
||||
|
||||
@@ -351,5 +351,24 @@ export function generateYamlWithComments(config: UnifiedConfig): string {
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Quota management section (hybrid auto+manual account selection)
|
||||
if (config.quota_management) {
|
||||
lines.push('# ----------------------------------------------------------------------------');
|
||||
lines.push('# Quota Management: Hybrid auto+manual account selection for multi-account setups');
|
||||
lines.push('# mode: auto | manual | hybrid (default: hybrid)');
|
||||
lines.push('# manual.tier_lock: per-provider tier lock map (e.g. { agy: "ultra" })');
|
||||
lines.push('# Configure via: POST /api/accounts/tier-lock');
|
||||
lines.push('# ----------------------------------------------------------------------------');
|
||||
lines.push(
|
||||
yaml
|
||||
.dump(
|
||||
{ quota_management: config.quota_management },
|
||||
{ indent: 2, lineWidth: -1, quotingType: '"' }
|
||||
)
|
||||
.trim()
|
||||
);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -52,8 +52,20 @@ export interface ManualQuotaConfig {
|
||||
paused_accounts: string[];
|
||||
/** Force use of specific account (overrides auto-selection) */
|
||||
forced_default: string | null;
|
||||
/** Lock to specific tier only */
|
||||
tier_lock: string | null;
|
||||
/**
|
||||
* Per-provider tier lock map.
|
||||
*
|
||||
* Keys are provider IDs (e.g. "agy", "claude"). Values are the tier name to
|
||||
* lock that provider to (e.g. "ultra", "pro"), or null to clear the lock for
|
||||
* that provider.
|
||||
*
|
||||
* Only providers present in the map are affected; other providers retain
|
||||
* normal tier-priority failover. A null/absent map means no locks are active.
|
||||
*
|
||||
* Legacy shape (bare string | null): treated as no lock. The old global
|
||||
* string value predates per-provider locking and is ignored on read.
|
||||
*/
|
||||
tier_lock: Record<string, string | null> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,6 +110,27 @@ export const DEFAULT_MANUAL_QUOTA_CONFIG: ManualQuotaConfig = {
|
||||
tier_lock: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the tier lock for a specific provider from a ManualQuotaConfig.
|
||||
*
|
||||
* Handles three cases:
|
||||
* 1. tier_lock is null/undefined → no lock active for any provider
|
||||
* 2. tier_lock is a Record (new shape) → return the value for this provider
|
||||
* 3. tier_lock is a bare string (legacy shape, pre-per-provider) → treated as
|
||||
* no lock to avoid silently applying an old global lock to every provider
|
||||
*
|
||||
* @returns The tier string to lock to, or null (no lock).
|
||||
*/
|
||||
export function getTierLockForProvider(
|
||||
manual: ManualQuotaConfig | undefined | null,
|
||||
provider: string
|
||||
): string | null {
|
||||
const tierLock = manual?.tier_lock;
|
||||
if (!tierLock || typeof tierLock !== 'object') return null;
|
||||
const value = (tierLock as Record<string, string | null>)[provider];
|
||||
return value ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default runtime monitor configuration.
|
||||
*/
|
||||
|
||||
@@ -38,7 +38,24 @@ import {
|
||||
} from './account-route-helpers';
|
||||
import type { AccountConfig } from '../../config/unified-config-types';
|
||||
import { resolveConfiguredPlainCcsResumeLane } from '../../auth/resume-lane-diagnostics';
|
||||
import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../../config/config-loader-facade';
|
||||
import {
|
||||
isUnifiedMode,
|
||||
loadOrCreateUnifiedConfig,
|
||||
mutateConfig,
|
||||
} from '../../config/config-loader-facade';
|
||||
import type { AccountTier } from '../../cliproxy/accounts/types';
|
||||
import {
|
||||
isManagedQuotaProvider,
|
||||
MANAGED_QUOTA_PROVIDERS,
|
||||
} from '../../cliproxy/quota/quota-manager';
|
||||
|
||||
/** Valid account tier values for tier-lock validation */
|
||||
const VALID_ACCOUNT_TIERS: ReadonlySet<string> = new Set<AccountTier>([
|
||||
'free',
|
||||
'pro',
|
||||
'ultra',
|
||||
'unknown',
|
||||
]);
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -633,4 +650,119 @@ router.post('/solo', async (req: Request, res: Response): Promise<void> => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/accounts/tier-lock - Set or clear the tier_lock for a provider
|
||||
*
|
||||
* Body: { provider: string, tier: string | null }
|
||||
* - tier: the tier name to lock to (e.g. "ultra", "pro"), or null to clear.
|
||||
*
|
||||
* Persists via the existing config write path (mutateConfig → quota_management.manual.tier_lock).
|
||||
* The quota-manager reads this on every preflight/findHealthyAccount call.
|
||||
*/
|
||||
router.post('/tier-lock', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { provider, tier } = req.body as { provider?: unknown; tier?: unknown };
|
||||
|
||||
if (!provider || typeof provider !== 'string') {
|
||||
res.status(400).json({ error: 'Missing required field: provider (string)' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isCLIProxyProvider(provider)) {
|
||||
res.status(400).json({ error: `Invalid provider: ${provider}` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Fix #8: tier_lock is only enforced by quota-manager for managed-quota providers.
|
||||
// Locking a non-managed provider persists a silently-unenforced entry.
|
||||
// Reject with 400 to avoid misleading the caller.
|
||||
if (!isManagedQuotaProvider(provider)) {
|
||||
res.status(400).json({
|
||||
error:
|
||||
`Provider "${provider}" does not support tier-lock. ` +
|
||||
`Only managed-quota providers support it: ${[...MANAGED_QUOTA_PROVIDERS].join(', ')}.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// tier must be explicitly present in body (key exists), and must be string or null
|
||||
if (!('tier' in req.body)) {
|
||||
res.status(400).json({ error: 'Missing required field: tier (string | null)' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (tier !== null && typeof tier !== 'string') {
|
||||
res.status(400).json({ error: 'Invalid tier: must be a string or null' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate tier against known AccountTier values (null means clear the lock)
|
||||
if (tier !== null && !VALID_ACCOUNT_TIERS.has(tier)) {
|
||||
res.status(400).json({
|
||||
error: `Invalid tier: "${tier}". Must be one of: ${[...VALID_ACCOUNT_TIERS].join(', ')}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist via existing config write path.
|
||||
// tier_lock is a per-provider map: { [provider]: tier | null }
|
||||
// Setting a provider's entry to null clears the lock for that provider only.
|
||||
mutateConfig((config) => {
|
||||
if (!config.quota_management) {
|
||||
config.quota_management = {
|
||||
mode: 'hybrid',
|
||||
auto: {
|
||||
preflight_check: true,
|
||||
exhaustion_threshold: 5,
|
||||
tier_priority: ['ultra', 'pro', 'free'],
|
||||
cooldown_minutes: 5,
|
||||
},
|
||||
manual: {
|
||||
paused_accounts: [],
|
||||
forced_default: null,
|
||||
tier_lock: { [provider]: tier ?? null },
|
||||
},
|
||||
runtime_monitor: {
|
||||
enabled: true,
|
||||
normal_interval_seconds: 300,
|
||||
critical_interval_seconds: 60,
|
||||
warn_threshold: 20,
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 5,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
if (!config.quota_management.manual) {
|
||||
config.quota_management.manual = {
|
||||
paused_accounts: [],
|
||||
forced_default: null,
|
||||
tier_lock: { [provider]: tier ?? null },
|
||||
};
|
||||
} else {
|
||||
// Ensure config.quota_management.manual is an owned object, not a shared
|
||||
// reference from DEFAULT_MANUAL_QUOTA_CONFIG (which createEmptyUnifiedConfig
|
||||
// sets via shallow spread). Replacing the reference prevents mutation of
|
||||
// the module-level default constant.
|
||||
config.quota_management.manual = { ...config.quota_management.manual };
|
||||
|
||||
// Ensure tier_lock is a map (guard against legacy string shape or null)
|
||||
const existing = config.quota_management.manual.tier_lock;
|
||||
if (!existing || typeof existing !== 'object') {
|
||||
config.quota_management.manual.tier_lock = { [provider]: tier ?? null };
|
||||
} else {
|
||||
// Spread to own the map too before mutating it
|
||||
const ownedMap = { ...(existing as Record<string, string | null>) };
|
||||
ownedMap[provider] = tier ?? null;
|
||||
config.quota_management.manual.tier_lock = ownedMap;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ provider, tier_lock: tier ?? null });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,616 @@
|
||||
/**
|
||||
* Bar Routes — /api/bar/summary aggregator
|
||||
*
|
||||
* One GET returns the full glance array for CCS Bar (macOS MenuBarExtra).
|
||||
* Supports cached (instant) and ?refresh=true (live provider pull) modes.
|
||||
*
|
||||
* Design:
|
||||
* - Calls data sources DIRECTLY (not via HTTP routes) so rate-limiters are irrelevant.
|
||||
* - Force-fresh = invalidate quota-response-cache then call the fetcher server-side.
|
||||
* - Debounce: if a fresh pull happened < 15s ago, serve cache even when refresh=true.
|
||||
* - Per-account failure degrades THAT row (null fields + needsReauth/health:error);
|
||||
* other rows are unaffected — the payload always returns HTTP 200.
|
||||
* - today_cost sourced from getTodayCostByAccount() (Phase 1A output).
|
||||
* - health derived from runHealthChecks() summary (overall, not per-account for v1).
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
import type { AccountInfo } from '../../cliproxy/accounts/types';
|
||||
import type { QuotaResult } from '../../cliproxy/quota/quota-fetcher';
|
||||
import type { HealthReport } from '../health-service';
|
||||
import type { CliproxyUsageHistoryDetail } from '../usage/cliproxy-usage-transformer';
|
||||
import { computeBarAnalyticsFromDaily } from '../usage/bar-analytics';
|
||||
import type { DailyUsage, HourlyUsage } from '../usage/types';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Per-window quota detail for native subscription rows (Claude/Codex).
|
||||
*
|
||||
* Carries BOTH used and remaining percent so the macOS bar never re-derives
|
||||
* them. CLIProxy rows omit the parent `quotaWindows` field entirely, keeping
|
||||
* the payload backward compatible.
|
||||
*
|
||||
* JSON shape (decode test pins these exact keys): the inner object keys stay
|
||||
* camelCase (usedPercent/remainingPercent/resetAt/windowMinutes); only the
|
||||
* parent field name serializes to snake_case ("quota_windows").
|
||||
*/
|
||||
export interface QuotaWindowDetail {
|
||||
/** Stable key: "five_hour" | "seven_day" | "seven_day_opus" | "seven_day_sonnet". */
|
||||
key: string;
|
||||
/** Display label: "5h" | "week" | "Opus · week" | "Sonnet · week". */
|
||||
label: string;
|
||||
/** Used percentage (0-100). */
|
||||
usedPercent: number;
|
||||
/** Remaining percentage (0-100). Carried explicitly, never derived in Swift. */
|
||||
remainingPercent: number;
|
||||
/** ISO timestamp when this window resets, null if unknown. */
|
||||
resetAt: string | null;
|
||||
/** Window length in minutes (300 | 10080), null if unknown. */
|
||||
windowMinutes: number | null;
|
||||
}
|
||||
|
||||
/** Single account glance row returned by /api/bar/summary */
|
||||
export interface BarSummaryRow {
|
||||
/** Account identifier (email or custom name) */
|
||||
account_id: string;
|
||||
/** CLIProxy provider: agy | codex | gemini | claude | ghcp | … */
|
||||
provider: string;
|
||||
/** Nickname or fallback to account_id */
|
||||
displayName: string | null;
|
||||
/** Account tier: free | pro | ultra | unknown | null on error */
|
||||
tier: string | null;
|
||||
/** Whether account is user-paused */
|
||||
paused: boolean;
|
||||
/** Best-guess quota remaining percentage (0-100), null on error */
|
||||
quota_percentage: number | null;
|
||||
/**
|
||||
* Tri-state quota availability for this account:
|
||||
* 'ok' — provider has a quota API and the fetch succeeded
|
||||
* 'unsupported' — provider has no quota API at all (e.g. ghcp, kiro)
|
||||
* 'error' — provider should report quota but the fetch failed/timed out/needs reauth
|
||||
* The UI uses this to render "no quota" (unsupported) vs "quota ?" (error)
|
||||
* instead of a bare "--" that conflates the two.
|
||||
*/
|
||||
quotaStatus: 'ok' | 'unsupported' | 'error';
|
||||
/** ISO timestamp of next quota reset, null if unknown */
|
||||
next_reset: string | null;
|
||||
/** Whether this is the provider's default account (drives the active/default badge) */
|
||||
is_default: boolean;
|
||||
/** ISO timestamp this account was last used, null if never/unknown */
|
||||
last_activity_at: string | null;
|
||||
/** Today's attributed cost in USD, null if unavailable */
|
||||
today_cost: number | null;
|
||||
/** Health status derived from overall system health */
|
||||
health: 'ok' | 'warning' | 'error';
|
||||
/** True when value came from cache; false when freshly fetched */
|
||||
cached: boolean;
|
||||
/** ISO timestamp of when this data was fetched/cached */
|
||||
fetchedAt: string;
|
||||
/** True if account token is expired and needs re-authentication */
|
||||
needsReauth: boolean;
|
||||
/**
|
||||
* Native-only per-window quota breakdown (Claude: 5h/week/opus/sonnet,
|
||||
* Codex: 5h/week). CLIProxy rows OMIT this field so existing decode/encode
|
||||
* tests and the Swift legacy path stay unaffected. Serialized as
|
||||
* "quota_windows".
|
||||
*/
|
||||
quotaWindows?: QuotaWindowDetail[];
|
||||
/**
|
||||
* Native-only ISO mtime of the source session that supplied a stale Codex
|
||||
* reading. Present only when stale; serialized as "stale_as_of".
|
||||
*/
|
||||
staleAsOf?: string | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Dependency injection interface
|
||||
// ============================================================================
|
||||
|
||||
/** All external dependencies are injectable for testability */
|
||||
export interface BarRouterDeps {
|
||||
/** Get all CLIProxy accounts across providers */
|
||||
getAllAccountsSummary: () => Record<string, AccountInfo[]>;
|
||||
/** Check the quota cache for a specific account */
|
||||
|
||||
getCachedQuota: <T>(provider: CLIProxyProvider | string, accountId: string) => T | null;
|
||||
/** Store a value in the quota cache */
|
||||
|
||||
setCachedQuota: <T>(provider: CLIProxyProvider | string, accountId: string, data: T) => void;
|
||||
/** Invalidate cache entry for a specific account */
|
||||
invalidateQuotaCache: (provider: CLIProxyProvider | string, accountId: string) => void;
|
||||
/** Fetch live quota from provider for one account */
|
||||
fetchAccountQuota: (provider: CLIProxyProvider, accountId: string) => Promise<QuotaResult>;
|
||||
/** Compute per-account today cost from history details */
|
||||
getTodayCostByAccount: (details: CliproxyUsageHistoryDetail[]) => Record<string, number>;
|
||||
/** Load persisted CLIProxy usage details (from snapshot cache) */
|
||||
loadCliproxyDetails: () => Promise<CliproxyUsageHistoryDetail[]>;
|
||||
/**
|
||||
* Load merged, multi-source daily usage (Claude Code, Codex, Droid, CLIProxy).
|
||||
* Fresh, stale-while-revalidate; carries cost + per-model + per-surface spend.
|
||||
*/
|
||||
loadDailyUsage: () => Promise<DailyUsage[]>;
|
||||
/** Load merged hourly usage — the source of request counts (daily lacks them). */
|
||||
loadHourlyUsage: () => Promise<HourlyUsage[]>;
|
||||
/**
|
||||
* Optional, retained only for test back-compat. NOT used by the request path:
|
||||
* the bar derives per-account health from each quota result. The real system
|
||||
* audit shells out via a synchronous execSync that must never run here.
|
||||
*/
|
||||
runHealthChecks?: () => Promise<HealthReport>;
|
||||
/**
|
||||
* Native subscription quota rows (Claude Code + Codex). Defaults to an empty
|
||||
* async so older tests that build deps without it keep passing. The native
|
||||
* collector owns its own long-TTL cache + safety controls, so this is cheap
|
||||
* to call per request.
|
||||
*/
|
||||
getNativeAccountRows?: () => Promise<BarSummaryRow[]>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Timing budgets (module-level; the bar must NEVER block on a slow provider)
|
||||
// ============================================================================
|
||||
|
||||
/** Debounce window: skip force-fresh if last fresh pull was < 15s ago */
|
||||
const FORCE_FRESH_DEBOUNCE_MS = 15_000;
|
||||
|
||||
/** Hard ceiling for the whole /summary response. Past this we paint from cache. */
|
||||
const REQUEST_DEADLINE_MS = 2_500;
|
||||
|
||||
/** Per-account synchronous wait before falling back to stale cache (bg fetch continues). */
|
||||
const PER_ACCOUNT_TIMEOUT_MS = 5_000;
|
||||
|
||||
/** Bound for the cost side-load so a slow snapshot read can't dominate the response. */
|
||||
const SIDELOAD_TIMEOUT_MS = 1_500;
|
||||
|
||||
/**
|
||||
* Bound for the native-subscription side-load. A slow or failed native fetch
|
||||
* resolves to [] so the response paints CLIProxy rows only — never errors, never
|
||||
* blocks. Native rows have their own 10-min cache, so the common path is instant.
|
||||
*/
|
||||
const NATIVE_SIDELOAD_TIMEOUT_MS = 1_500;
|
||||
|
||||
/** Timestamp of the last successful force-fresh pull (epoch ms, 0 = never) */
|
||||
let lastForceFreshAt = 0;
|
||||
|
||||
/** Reset module state — called in tests to prevent cross-test pollution */
|
||||
export function resetForceFreshDebounce(): void {
|
||||
lastForceFreshAt = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a promise to its value, or to null if it doesn't settle within `ms`.
|
||||
* The underlying promise keeps running (used to let a slow fetch warm the cache
|
||||
* for the next open while the current response degrades gracefully).
|
||||
*/
|
||||
function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | null> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(null);
|
||||
}
|
||||
}, ms);
|
||||
p.then(
|
||||
(v) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(v);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a row to its wire shape. The native-only additions use snake_case parent
|
||||
* keys ("quota_windows" / "stale_as_of") to match the existing payload's mixed
|
||||
* casing; inner QuotaWindowDetail keys stay camelCase and pass through as-is.
|
||||
* Rows without the native fields serialize byte-identically to before, so
|
||||
* CLIProxy decode/encode tests are unaffected.
|
||||
*/
|
||||
function serializeBarRow(row: BarSummaryRow): Record<string, unknown> {
|
||||
const { quotaWindows, staleAsOf, ...rest } = row;
|
||||
const wire: Record<string, unknown> = { ...rest };
|
||||
if (quotaWindows !== undefined) wire.quota_windows = quotaWindows;
|
||||
if (staleAsOf !== undefined && staleAsOf !== null) wire.stale_as_of = staleAsOf;
|
||||
return wire;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Per-account health derivation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Tri-state quota availability derived from the QuotaResult.
|
||||
*
|
||||
* We branch ONLY on the result's stable errorCode, never on a provider
|
||||
* registry: ghcp is listed in MANAGED_QUOTA_PROVIDERS, yet fetchAccountQuota
|
||||
* returns unsupported for every provider !== 'agy'. The result's
|
||||
* 'quota_not_supported' code is the only honest signal that a provider has no
|
||||
* quota API, so we use it here.
|
||||
*
|
||||
* success → 'ok'
|
||||
* errorCode 'quota_not_supported' → 'unsupported' (no quota API; healthy)
|
||||
* anything else (null/timeout/reauth/other failure) → 'error'
|
||||
*/
|
||||
function deriveQuotaStatus(quota: QuotaResult | null): 'ok' | 'unsupported' | 'error' {
|
||||
if (quota?.success === true) return 'ok';
|
||||
if (quota && quota.errorCode === 'quota_not_supported') return 'unsupported';
|
||||
return 'error';
|
||||
}
|
||||
|
||||
/**
|
||||
* Health for a single account, derived from its own quota-fetch result.
|
||||
*
|
||||
* The menu bar is a per-account glance, so health is per-row — NOT the global
|
||||
* system audit. (The system audit is also unsafe here: it shells out via a
|
||||
* synchronous execSync that would block the event loop on the request path.)
|
||||
*
|
||||
* A provider with no quota API (quotaStatus 'unsupported', e.g. ghcp/kiro) is
|
||||
* healthy — it must not show a permanent warning dot. 'warning' is reserved for
|
||||
* genuine transient fetch failures, 'error' for accounts needing reauth.
|
||||
*
|
||||
* needsReauth → 'error' (token expired; user action required)
|
||||
* quota unsupported → 'ok' (no quota API is not a fault)
|
||||
* fetch failed → 'warning' (transient/unknown; row degrades but isn't fatal)
|
||||
* success → 'ok'
|
||||
*/
|
||||
function deriveHealth(
|
||||
quota: QuotaResult | null,
|
||||
quotaStatus: 'ok' | 'unsupported' | 'error'
|
||||
): 'ok' | 'warning' | 'error' {
|
||||
if (quota?.needsReauth) return 'error';
|
||||
if (quotaStatus === 'unsupported') return 'ok';
|
||||
if (!quota || !quota.success) return 'warning';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Quota → bar row mapping
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Extract the primary quota percentage from a QuotaResult.
|
||||
* For Antigravity accounts: use the first model's percentage.
|
||||
* Returns null on failure or missing data.
|
||||
*/
|
||||
function extractQuotaPercentage(quota: QuotaResult): number | null {
|
||||
if (!quota.success || quota.models.length === 0) return null;
|
||||
// Use the first model (highest weight) as the representative percentage
|
||||
return quota.models[0].percentage ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the next reset timestamp from a QuotaResult.
|
||||
* Returns null if not available.
|
||||
*/
|
||||
function extractNextReset(quota: QuotaResult): string | null {
|
||||
if (!quota.success || quota.models.length === 0) return null;
|
||||
return quota.models[0].resetTime ?? null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Per-account fetch with error isolation
|
||||
// ============================================================================
|
||||
|
||||
interface AccountFetchResult {
|
||||
quota: QuotaResult | null;
|
||||
cached: boolean;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
async function fetchAccountData(
|
||||
account: AccountInfo,
|
||||
forceRefresh: boolean,
|
||||
deps: BarRouterDeps
|
||||
): Promise<AccountFetchResult> {
|
||||
const provider = account.provider;
|
||||
const accountId = account.id;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Read any prior cache up front so it survives as a stale fallback even when a
|
||||
// refresh fetch is slow or fails (stale-while-revalidate).
|
||||
const cached = deps.getCachedQuota<QuotaResult>(provider, accountId);
|
||||
|
||||
// Paused accounts: serve cache if present, otherwise degrade.
|
||||
// Never trigger a live fetch for a user-paused account.
|
||||
if (account.paused === true) {
|
||||
return { quota: cached ?? null, cached: cached !== null, fetchedAt: now };
|
||||
}
|
||||
|
||||
// Default mode serves a present cache instantly (no provider call).
|
||||
if (!forceRefresh && cached) {
|
||||
return { quota: cached, cached: true, fetchedAt: now };
|
||||
}
|
||||
|
||||
// Force-fresh busts the route cache first (the stale value captured above
|
||||
// still backs the fallback below).
|
||||
if (forceRefresh) {
|
||||
deps.invalidateQuotaCache(provider, accountId);
|
||||
}
|
||||
|
||||
// Live fetch (force-refresh, or default-mode cache miss). It overwrites the
|
||||
// cache on success and is bounded by PER_ACCOUNT_TIMEOUT_MS; if it overruns,
|
||||
// the fetch keeps running (warming the cache for the next open) while this row
|
||||
// degrades to the stale value so the payload never blocks.
|
||||
const live = deps.fetchAccountQuota(provider as CLIProxyProvider, accountId).then((quota) => {
|
||||
deps.setCachedQuota(provider, accountId, quota);
|
||||
return quota;
|
||||
});
|
||||
const fresh = await withTimeout(live, PER_ACCOUNT_TIMEOUT_MS);
|
||||
if (fresh) return { quota: fresh, cached: false, fetchedAt: now };
|
||||
return { quota: cached ?? null, cached: cached !== null, fetchedAt: now };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Row builder
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Resolve the cost-lookup key for an account.
|
||||
*
|
||||
* The attribution pipeline (buildAuthIndexToAccountMap) stores email as the
|
||||
* map value, so costByAccount keys are emails. For providers where
|
||||
* account.id == email (agy, gemini, anthropic, etc.) this is a no-op.
|
||||
* For duplicate-email providers like codex, account.id may be "email#variant",
|
||||
* so we prefer account.email for the lookup to ensure the keys match.
|
||||
* Falls back to account.id when email is absent (e.g. kiro/ghcp).
|
||||
*/
|
||||
function resolveCostKey(account: AccountInfo): string {
|
||||
return account.email ?? account.id;
|
||||
}
|
||||
|
||||
function buildRow(
|
||||
account: AccountInfo,
|
||||
fetchResult: AccountFetchResult,
|
||||
costByAccount: Record<string, number>,
|
||||
/** Set of cost-keys that are shared by more than one account. Cost is unknowable for these. */
|
||||
sharedCostKeys: ReadonlySet<string>
|
||||
): BarSummaryRow {
|
||||
const { quota, cached, fetchedAt } = fetchResult;
|
||||
const costKey = resolveCostKey(account);
|
||||
const quotaStatus = deriveQuotaStatus(quota);
|
||||
const health = deriveHealth(quota, quotaStatus);
|
||||
const isDefault = account.isDefault ?? false;
|
||||
const lastActivityAt = account.lastUsedAt ?? null;
|
||||
|
||||
// When multiple accounts share the same cost-key (e.g. two codex accounts with
|
||||
// the same email), we cannot attribute the combined cost to either individual
|
||||
// account, so it is null=unknowable. A missing key on a single-owner account is
|
||||
// ALSO null=unknown (no usage record on a possibly-stale snapshot), distinct from
|
||||
// a genuine 0 spend — the UI renders "no data" vs "$0.00" honestly.
|
||||
const todayCost = sharedCostKeys.has(costKey) ? null : (costByAccount[costKey] ?? null);
|
||||
|
||||
if (!quota || !quota.success) {
|
||||
// Degraded row: preserve identity fields, null out quota data
|
||||
return {
|
||||
account_id: account.id,
|
||||
provider: account.provider,
|
||||
displayName: account.nickname ?? account.id,
|
||||
tier: account.tier ?? null,
|
||||
paused: account.paused ?? false,
|
||||
quota_percentage: null,
|
||||
quotaStatus,
|
||||
next_reset: null,
|
||||
is_default: isDefault,
|
||||
last_activity_at: lastActivityAt,
|
||||
today_cost: todayCost,
|
||||
health,
|
||||
cached,
|
||||
fetchedAt,
|
||||
needsReauth: quota?.needsReauth ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
account_id: account.id,
|
||||
provider: account.provider,
|
||||
displayName: account.nickname ?? account.id,
|
||||
tier: quota.tier ?? account.tier ?? null,
|
||||
paused: account.paused ?? false,
|
||||
quota_percentage: extractQuotaPercentage(quota),
|
||||
quotaStatus,
|
||||
next_reset: extractNextReset(quota),
|
||||
is_default: isDefault,
|
||||
last_activity_at: lastActivityAt,
|
||||
today_cost: todayCost,
|
||||
health,
|
||||
cached,
|
||||
fetchedAt,
|
||||
needsReauth: quota.needsReauth ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Router factory
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create the bar router with injected dependencies.
|
||||
*
|
||||
* Production usage: call without arguments (defaults resolve from real modules).
|
||||
* Test usage: pass mock implementations for each dep.
|
||||
*/
|
||||
export function createBarRouter(deps: BarRouterDeps): Router {
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /summary[?refresh=true]
|
||||
*
|
||||
* Returns the menu-bar glance array for all CLIProxy accounts.
|
||||
*
|
||||
* Query params:
|
||||
* refresh=true — force-fresh from provider (debounced to once per 15s)
|
||||
*/
|
||||
router.get('/summary', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const wantsRefresh = req.query['refresh'] === 'true';
|
||||
|
||||
// Determine effective refresh mode after applying debounce.
|
||||
// IMPORTANT: set lastForceFreshAt at decision time (before awaiting any
|
||||
// fetches) to prevent a read-modify-write race where two concurrent
|
||||
// refresh=true requests both pass the debounce check before either
|
||||
// records the timestamp.
|
||||
let doForceRefresh = false;
|
||||
if (wantsRefresh) {
|
||||
const sinceLastFresh = Date.now() - lastForceFreshAt;
|
||||
if (sinceLastFresh >= FORCE_FRESH_DEBOUNCE_MS) {
|
||||
doForceRefresh = true;
|
||||
lastForceFreshAt = Date.now(); // claim the window before any async work
|
||||
}
|
||||
// else: debounce active — fall through to cache path
|
||||
}
|
||||
|
||||
// Cost side-load is bounded so a slow usage-snapshot read can't stall the
|
||||
// glance. (Health is per-account, derived from each quota result below —
|
||||
// no blocking system audit on the request path.)
|
||||
const details = await withTimeout(deps.loadCliproxyDetails(), SIDELOAD_TIMEOUT_MS);
|
||||
const costByAccount: Record<string, number> = details
|
||||
? deps.getTodayCostByAccount(details)
|
||||
: {};
|
||||
|
||||
// Flatten all accounts across providers
|
||||
const summary = deps.getAllAccountsSummary();
|
||||
const allAccounts: AccountInfo[] = Object.values(summary).flat();
|
||||
|
||||
// Fix #11: compute which cost-keys are shared by >1 account so buildRow can
|
||||
// report null (unknowable) rather than the combined total for those rows.
|
||||
const costKeyCount = new Map<string, number>();
|
||||
for (const account of allAccounts) {
|
||||
const key = resolveCostKey(account);
|
||||
costKeyCount.set(key, (costKeyCount.get(key) ?? 0) + 1);
|
||||
}
|
||||
const sharedCostKeys = new Set<string>(
|
||||
Array.from(costKeyCount.entries())
|
||||
.filter(([, count]) => count > 1)
|
||||
.map(([key]) => key)
|
||||
);
|
||||
|
||||
// Build every row synchronously from whatever is in cache right now. This
|
||||
// is the instant-paint fallback and the source of truth when the deadline
|
||||
// fires before live fetches finish.
|
||||
const cacheRows = (): BarSummaryRow[] => {
|
||||
const at = new Date().toISOString();
|
||||
return allAccounts.map((account) => {
|
||||
const cached = deps.getCachedQuota<QuotaResult>(account.provider, account.id);
|
||||
return buildRow(
|
||||
account,
|
||||
{ quota: cached ?? null, cached: cached !== null, fetchedAt: at },
|
||||
costByAccount,
|
||||
sharedCostKeys
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Fetch quota in parallel with per-account error isolation. Each row is
|
||||
// bounded inside fetchAccountData; the whole gather is additionally raced
|
||||
// against REQUEST_DEADLINE_MS so the response NEVER hangs on a slow
|
||||
// provider — past the deadline we paint from cache and let background
|
||||
// fetches warm the next open.
|
||||
const CONCURRENCY_CAP = 5;
|
||||
const gather = (async (): Promise<BarSummaryRow[]> => {
|
||||
const rows: BarSummaryRow[] = [];
|
||||
for (let i = 0; i < allAccounts.length; i += CONCURRENCY_CAP) {
|
||||
const batch = allAccounts.slice(i, i + CONCURRENCY_CAP);
|
||||
const batchRows = await Promise.all(
|
||||
batch.map(async (account): Promise<BarSummaryRow> => {
|
||||
const fetchResult = await fetchAccountData(account, doForceRefresh, deps);
|
||||
return buildRow(account, fetchResult, costByAccount, sharedCostKeys);
|
||||
})
|
||||
);
|
||||
rows.push(...batchRows);
|
||||
}
|
||||
return rows;
|
||||
})();
|
||||
|
||||
const deadline = new Promise<BarSummaryRow[]>((resolve) => {
|
||||
setTimeout(() => resolve(cacheRows()), REQUEST_DEADLINE_MS);
|
||||
});
|
||||
|
||||
const rows = await Promise.race([gather, deadline]);
|
||||
|
||||
// Native subscription rows (Claude Code + Codex) are side-loaded AFTER the
|
||||
// CLIProxy rows resolve, bounded so a slow/failed native fetch degrades to
|
||||
// [] rather than blocking or erroring the response.
|
||||
const getNative = deps.getNativeAccountRows ?? (async () => [] as BarSummaryRow[]);
|
||||
const nativeRows = (await withTimeout(getNative(), NATIVE_SIDELOAD_TIMEOUT_MS)) ?? [];
|
||||
|
||||
res.json([...rows, ...nativeRows].map(serializeBarRow));
|
||||
} catch (err) {
|
||||
console.error('[bar-routes] /summary error:', (err as Error).message);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /analytics
|
||||
*
|
||||
* Rolls up the merged, multi-source usage (Claude Code, Codex, Droid, CLIProxy)
|
||||
* into today / 7-day / 30-day spend, a 30-day sparkline, top models, and a
|
||||
* per-surface breakdown. Reads the dashboard's stale-while-revalidate caches so
|
||||
* recent activity shows even when the CLIProxy snapshot is frozen by a restart.
|
||||
* Both loads are bounded so a slow read can't stall the menu; on miss the
|
||||
* windows degrade to empty rather than failing the payload.
|
||||
*/
|
||||
router.get('/analytics', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const [daily, hourly] = await Promise.all([
|
||||
withTimeout(deps.loadDailyUsage(), SIDELOAD_TIMEOUT_MS).catch(() => [] as DailyUsage[]),
|
||||
withTimeout(deps.loadHourlyUsage(), SIDELOAD_TIMEOUT_MS).catch(() => [] as HourlyUsage[]),
|
||||
]);
|
||||
const analytics = computeBarAnalyticsFromDaily(daily ?? [], hourly ?? [], new Date());
|
||||
res.json(analytics);
|
||||
} catch (err) {
|
||||
console.error('[bar-routes] /analytics error:', (err as Error).message);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Default production router (sync imports — matches all other route modules)
|
||||
// ============================================================================
|
||||
|
||||
import { getAllAccountsSummary } from '../../cliproxy/accounts/query';
|
||||
import {
|
||||
getCachedQuota,
|
||||
setCachedQuota,
|
||||
invalidateQuotaCache,
|
||||
} from '../../cliproxy/quota/quota-response-cache';
|
||||
import { fetchAccountQuota } from '../../cliproxy/quota/quota-fetcher';
|
||||
import { getTodayCostByAccount } from '../usage/data-aggregator';
|
||||
import { loadCliproxySnapshotDetails } from '../usage/cliproxy-snapshot-reader';
|
||||
import { getCachedDailyData, getCachedHourlyData } from '../usage/aggregator';
|
||||
import { getNativeAccountRows } from '../usage/native-quota-collector';
|
||||
|
||||
/** Production bar router — wired to real dependencies */
|
||||
const barRouter: Router = createBarRouter({
|
||||
getAllAccountsSummary,
|
||||
getCachedQuota,
|
||||
setCachedQuota,
|
||||
invalidateQuotaCache,
|
||||
fetchAccountQuota,
|
||||
getTodayCostByAccount,
|
||||
loadCliproxyDetails: loadCliproxySnapshotDetails,
|
||||
loadDailyUsage: () => getCachedDailyData(),
|
||||
loadHourlyUsage: () => getCachedHourlyData(),
|
||||
getNativeAccountRows: () => getNativeAccountRows(),
|
||||
});
|
||||
|
||||
export default barRouter;
|
||||
@@ -36,6 +36,7 @@ import persistRoutes from './persist-routes';
|
||||
import catalogRoutes from './catalog-routes';
|
||||
import claudeExtensionRoutes from './claude-extension-routes';
|
||||
import logsRoutes from './logs-routes';
|
||||
import barRoutes from './bar-routes';
|
||||
|
||||
// Create the main API router
|
||||
export const apiRoutes = Router();
|
||||
@@ -43,6 +44,13 @@ export const apiRoutes = Router();
|
||||
const REMOTE_WRITE_ACCESS_ERROR =
|
||||
'Remote dashboard writes require localhost access when dashboard auth is disabled.';
|
||||
|
||||
// CCS Bar endpoints (/api/bar/*) expose the user's native quota, tier, and cost
|
||||
// snapshot. Unlike the rest of the read API these are sensitive even on GET, so
|
||||
// they are gated for ALL methods (not just mutations) by the same
|
||||
// localhost-when-auth-disabled choke point.
|
||||
const BAR_LOCAL_ACCESS_ERROR =
|
||||
'CCS Bar endpoints require localhost access when dashboard auth is disabled.';
|
||||
|
||||
function isMutationMethod(method: string): boolean {
|
||||
const normalized = method.toUpperCase();
|
||||
return (
|
||||
@@ -54,6 +62,17 @@ function isMutationMethod(method: string): boolean {
|
||||
}
|
||||
|
||||
apiRoutes.use((req, res, next) => {
|
||||
// /api/bar/* leaks native quota/tier/cost data; gate it for every method.
|
||||
// This middleware runs before the '/bar' mount below, so req.path still
|
||||
// carries the '/bar' prefix here.
|
||||
// Exact segment match so a future sibling like '/barbaz' isn't accidentally gated.
|
||||
if (req.path === '/bar' || req.path.startsWith('/bar/')) {
|
||||
if (requireLocalAccessWhenAuthDisabled(req, res, BAR_LOCAL_ACCESS_ERROR)) {
|
||||
next();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMutationMethod(req.method)) {
|
||||
next();
|
||||
return;
|
||||
@@ -117,6 +136,9 @@ apiRoutes.use('/codex', codexRoutes);
|
||||
// ==================== CLIProxy Server Settings ====================
|
||||
apiRoutes.use('/cliproxy-server', cliproxyServerRoutes);
|
||||
|
||||
// ==================== Bar (Menu Bar Glance) ====================
|
||||
apiRoutes.use('/bar', barRoutes);
|
||||
|
||||
// ==================== Misc (File API, Global Env) ====================
|
||||
apiRoutes.use('/', miscRoutes);
|
||||
apiRoutes.use('/logs', logsRoutes);
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* Bar Analytics Aggregator
|
||||
*
|
||||
* Pure functions that roll up the flat CliproxyUsageHistoryDetail array (the
|
||||
* same snapshot the bar already loads for per-account cost) into the small,
|
||||
* glanceable analytics the menu bar surfaces: today / 7-day / 30-day spend,
|
||||
* a 7-day cost sparkline, and the top models by spend.
|
||||
*
|
||||
* Kept dependency-free and deterministic (the reference "now" is injected) so
|
||||
* it is trivially unit-testable and cheap enough to run on every bar open.
|
||||
*/
|
||||
|
||||
import type { CliproxyUsageHistoryDetail } from './cliproxy-usage-transformer';
|
||||
import type { DailyUsage, HourlyUsage } from './types';
|
||||
|
||||
/** A single day's roll-up (local-day granularity). */
|
||||
export interface BarAnalyticsDay {
|
||||
/** Local calendar day, YYYY-MM-DD. */
|
||||
date: string;
|
||||
cost: number;
|
||||
requests: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One usage surface's contribution to spend over the active window.
|
||||
* A "surface" is the tool/origin a request came from (Claude Code, Codex, the
|
||||
* CLIProxy router, Droid, …) — the dimension the menu bar uses to answer
|
||||
* "where is my usage actually going".
|
||||
*/
|
||||
export interface BarAnalyticsSurface {
|
||||
/** Raw pipeline source key (custom-parser | codex-native | cliproxy | droid-native | …). */
|
||||
source: string;
|
||||
/** Human label shown in the bar (Claude Code, Codex, CLIProxy, Droid, …). */
|
||||
surface: string;
|
||||
cost: number;
|
||||
requests: number;
|
||||
}
|
||||
|
||||
/** Aggregate spend over a rolling window. */
|
||||
export interface BarAnalyticsWindow {
|
||||
cost: number;
|
||||
requests: number;
|
||||
}
|
||||
|
||||
/** One model's contribution to spend over the trailing 7 days. */
|
||||
export interface BarAnalyticsModel {
|
||||
model: string;
|
||||
cost: number;
|
||||
requests: number;
|
||||
}
|
||||
|
||||
/** The full analytics payload returned by GET /api/bar/analytics. */
|
||||
export interface BarAnalytics {
|
||||
today: BarAnalyticsWindow;
|
||||
last7d: BarAnalyticsWindow;
|
||||
last30d: BarAnalyticsWindow;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
monthToDate: BarAnalyticsWindow;
|
||||
/** Lifetime totals across every record in the snapshot. */
|
||||
allTime: BarAnalyticsWindow;
|
||||
/** Oldest → newest, exactly 30 entries (zero-filled), for the sparkline. */
|
||||
byDay: BarAnalyticsDay[];
|
||||
/** Highest-spend models (descending, capped) for the window in `topModelsWindow`. */
|
||||
topModels: BarAnalyticsModel[];
|
||||
/** Which window `topModels` covers — the most recent one that has data. */
|
||||
topModelsWindow: '30d' | 'all';
|
||||
/**
|
||||
* Spend/requests broken down by usage surface (tool/origin), for the same
|
||||
* window as `topModels`. Descending by cost. Empty when no source is known
|
||||
* (e.g. the legacy snapshot-only path that carries no surface dimension).
|
||||
*/
|
||||
bySurface: BarAnalyticsSurface[];
|
||||
/** ISO timestamp of the most recent non-failed usage record, null if none. */
|
||||
lastActivityAt: string | null;
|
||||
/** Whole local-days since `lastActivityAt`, null if no usable records. */
|
||||
daysSinceLastActivity: number | null;
|
||||
/**
|
||||
* True when the trailing 30 days carry any spend or requests. The UI pivots
|
||||
* its empty/stale presentation on this without re-deriving it.
|
||||
*/
|
||||
hasRecentData: boolean;
|
||||
/** ISO timestamp the payload was generated. */
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
// 30-day trailing window: gives a non-empty sparkline shape even when the last
|
||||
// 7 days are zero, so a stale-but-real history doesn't read as a broken chart.
|
||||
const SPARKLINE_DAYS = 30;
|
||||
const TOP_MODELS_LIMIT = 5;
|
||||
|
||||
/** Local-time YYYY-MM-DD key for a Date (matches the user's calendar day). */
|
||||
export function localDayKey(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local-time YYYY-MM key for a Date. Local (not a UTC ISO slice) so it matches
|
||||
* the local-day semantics of `dayDelta`/`localDayKey` — a record near midnight
|
||||
* lands in the same month the user sees on their calendar.
|
||||
*/
|
||||
function localMonthKey(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
return `${y}-${m}`;
|
||||
}
|
||||
|
||||
/** Whole-day difference (a - b) in local days, via midnight-anchored dates. */
|
||||
function dayDelta(a: Date, b: Date): number {
|
||||
const da = new Date(a.getFullYear(), a.getMonth(), a.getDate());
|
||||
const db = new Date(b.getFullYear(), b.getMonth(), b.getDate());
|
||||
return Math.round((da.getTime() - db.getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll the raw details into the bar analytics payload, relative to `now`.
|
||||
* Failed requests are excluded from spend (they carry no real cost).
|
||||
*/
|
||||
export function computeBarAnalytics(
|
||||
details: CliproxyUsageHistoryDetail[],
|
||||
now: Date
|
||||
): BarAnalytics {
|
||||
const today: BarAnalyticsWindow = { cost: 0, requests: 0 };
|
||||
const last7d: BarAnalyticsWindow = { cost: 0, requests: 0 };
|
||||
const last30d: BarAnalyticsWindow = { cost: 0, requests: 0 };
|
||||
const monthToDate: BarAnalyticsWindow = { cost: 0, requests: 0 };
|
||||
const allTime: BarAnalyticsWindow = { cost: 0, requests: 0 };
|
||||
|
||||
// Current local calendar month — records keyed to it feed month-to-date.
|
||||
const currentMonth = localMonthKey(now);
|
||||
|
||||
// Seed the sparkline with the trailing 7 local days (zero-filled, ordered).
|
||||
const dayBuckets = new Map<string, BarAnalyticsDay>();
|
||||
for (let i = SPARKLINE_DAYS - 1; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i);
|
||||
dayBuckets.set(localDayKey(d), { date: localDayKey(d), cost: 0, requests: 0 });
|
||||
}
|
||||
|
||||
// Track per-model spend over both the trailing 30 days and all-time so we can
|
||||
// show recent leaders when fresh, and lifetime leaders when the proxy has
|
||||
// simply been idle lately.
|
||||
const model30d = new Map<string, BarAnalyticsModel>();
|
||||
const modelAll = new Map<string, BarAnalyticsModel>();
|
||||
const bump = (
|
||||
map: Map<string, BarAnalyticsModel>,
|
||||
model: string,
|
||||
cost: number,
|
||||
requests: number
|
||||
): void => {
|
||||
const existing = map.get(model);
|
||||
if (existing) {
|
||||
existing.cost += cost;
|
||||
existing.requests += requests;
|
||||
} else {
|
||||
map.set(model, { model, cost, requests });
|
||||
}
|
||||
};
|
||||
|
||||
// Epoch ms of the most recent non-failed record, tracked inside the single
|
||||
// loop the function already runs (O(1) extra per iteration, no new I/O).
|
||||
let lastActivityMs = -Infinity;
|
||||
|
||||
for (const detail of details) {
|
||||
if (detail.failed) continue;
|
||||
const ts = new Date(detail.timestamp);
|
||||
if (Number.isNaN(ts.getTime())) continue;
|
||||
|
||||
const delta = dayDelta(now, ts); // 0 = today, 1 = yesterday, …
|
||||
if (delta < 0) continue; // ignore future-dated noise
|
||||
|
||||
if (ts.getTime() > lastActivityMs) lastActivityMs = ts.getTime();
|
||||
|
||||
const cost = Number.isFinite(detail.cost) ? detail.cost : 0;
|
||||
const requests = Number.isFinite(detail.requestCount) ? detail.requestCount : 0;
|
||||
|
||||
allTime.cost += cost;
|
||||
allTime.requests += requests;
|
||||
bump(modelAll, detail.model, cost, requests);
|
||||
|
||||
if (localMonthKey(ts) === currentMonth) {
|
||||
monthToDate.cost += cost;
|
||||
monthToDate.requests += requests;
|
||||
}
|
||||
|
||||
if (delta === 0) {
|
||||
today.cost += cost;
|
||||
today.requests += requests;
|
||||
}
|
||||
// Window math stays 7-day; only the sparkline bucket fill widens to 30.
|
||||
if (delta < 7) {
|
||||
last7d.cost += cost;
|
||||
last7d.requests += requests;
|
||||
}
|
||||
if (delta < SPARKLINE_DAYS) {
|
||||
const bucket = dayBuckets.get(localDayKey(ts));
|
||||
if (bucket) {
|
||||
bucket.cost += cost;
|
||||
bucket.requests += requests;
|
||||
}
|
||||
}
|
||||
if (delta < 30) {
|
||||
last30d.cost += cost;
|
||||
last30d.requests += requests;
|
||||
bump(model30d, detail.model, cost, requests);
|
||||
}
|
||||
}
|
||||
|
||||
const lastActivityAt =
|
||||
lastActivityMs === -Infinity ? null : new Date(lastActivityMs).toISOString();
|
||||
const daysSinceLastActivity =
|
||||
lastActivityAt === null ? null : dayDelta(now, new Date(lastActivityAt));
|
||||
|
||||
// Prefer recent leaders; fall back to lifetime when the last 30 days are idle.
|
||||
const recentHasData = last30d.cost > 0 || last30d.requests > 0;
|
||||
const sourceMap = recentHasData ? model30d : modelAll;
|
||||
const topModels = Array.from(sourceMap.values())
|
||||
.filter((m) => m.cost > 0 || m.requests > 0)
|
||||
.sort((a, b) => b.cost - a.cost)
|
||||
.slice(0, TOP_MODELS_LIMIT);
|
||||
|
||||
return {
|
||||
today,
|
||||
last7d,
|
||||
last30d,
|
||||
monthToDate,
|
||||
allTime,
|
||||
byDay: Array.from(dayBuckets.values()),
|
||||
topModels,
|
||||
topModelsWindow: recentHasData ? '30d' : 'all',
|
||||
// The snapshot-detail path has no surface attribution; the daily path does.
|
||||
bySurface: [],
|
||||
lastActivityAt,
|
||||
daysSinceLastActivity,
|
||||
hasRecentData: recentHasData,
|
||||
generatedAt: now.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Maps a raw usage-pipeline `source` to the label shown in the bar. Unknown
|
||||
// sources fall through to their raw key so a new collector is never silently
|
||||
// dropped — it just shows un-prettified until added here.
|
||||
const SURFACE_LABELS: Record<string, string> = {
|
||||
'custom-parser': 'Claude Code',
|
||||
'codex-native': 'Codex',
|
||||
'droid-native': 'Droid',
|
||||
cliproxy: 'CLIProxy',
|
||||
};
|
||||
|
||||
/** Human-friendly surface name for a raw usage `source` key. */
|
||||
function surfaceLabel(source: string): string {
|
||||
if (SURFACE_LABELS[source]) return SURFACE_LABELS[source];
|
||||
return source || 'Other';
|
||||
}
|
||||
|
||||
/** Local-midnight Date from a YYYY-MM-DD day key (calendar-day anchored). */
|
||||
function dateFromDayKey(key: string): Date {
|
||||
const [y, m, d] = key.split('-').map((n) => parseInt(n, 10));
|
||||
return new Date(y, (m || 1) - 1, d || 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll the merged, multi-source usage aggregates into the bar analytics payload.
|
||||
*
|
||||
* Unlike `computeBarAnalytics` (which reads only the CLIProxy snapshot — frozen
|
||||
* whenever the proxy restarts), this consumes the same merged daily/hourly data
|
||||
* the dashboard uses, so recent activity from Claude Code, Codex, Droid, and the
|
||||
* CLIProxy router all show up. `daily` carries cost+models+source; `hourly`
|
||||
* carries the request counts (daily aggregates don't), so the two are combined:
|
||||
* cost/models/surface-cost from daily, request counts from hourly.
|
||||
*/
|
||||
export function computeBarAnalyticsFromDaily(
|
||||
daily: DailyUsage[],
|
||||
hourly: HourlyUsage[],
|
||||
now: Date
|
||||
): BarAnalytics {
|
||||
const today: BarAnalyticsWindow = { cost: 0, requests: 0 };
|
||||
const last7d: BarAnalyticsWindow = { cost: 0, requests: 0 };
|
||||
const last30d: BarAnalyticsWindow = { cost: 0, requests: 0 };
|
||||
const monthToDate: BarAnalyticsWindow = { cost: 0, requests: 0 };
|
||||
const allTime: BarAnalyticsWindow = { cost: 0, requests: 0 };
|
||||
|
||||
// Daily keys (YYYY-MM-DD) and hourly keys (YYYY-MM-DD HH:00) are already local,
|
||||
// so slice(0,7) yields the local YYYY-MM to compare against the current month.
|
||||
const currentMonth = localMonthKey(now);
|
||||
|
||||
const dayBuckets = new Map<string, BarAnalyticsDay>();
|
||||
for (let i = SPARKLINE_DAYS - 1; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i);
|
||||
dayBuckets.set(localDayKey(d), { date: localDayKey(d), cost: 0, requests: 0 });
|
||||
}
|
||||
|
||||
const model30d = new Map<string, BarAnalyticsModel>();
|
||||
const modelAll = new Map<string, BarAnalyticsModel>();
|
||||
const bumpModel = (map: Map<string, BarAnalyticsModel>, model: string, cost: number): void => {
|
||||
const existing = map.get(model);
|
||||
if (existing) existing.cost += cost;
|
||||
else map.set(model, { model, cost, requests: 0 });
|
||||
};
|
||||
|
||||
const surface30d = new Map<string, BarAnalyticsSurface>();
|
||||
const surfaceAll = new Map<string, BarAnalyticsSurface>();
|
||||
const bumpSurface = (
|
||||
map: Map<string, BarAnalyticsSurface>,
|
||||
source: string,
|
||||
cost: number,
|
||||
requests: number
|
||||
): void => {
|
||||
const existing = map.get(source);
|
||||
if (existing) {
|
||||
existing.cost += cost;
|
||||
existing.requests += requests;
|
||||
} else {
|
||||
map.set(source, { source, surface: surfaceLabel(source), cost, requests });
|
||||
}
|
||||
};
|
||||
|
||||
// Latest local-day with real activity (cost or requests), across both passes.
|
||||
let lastActivityKey: string | null = null;
|
||||
const touchActivity = (dayKey: string): void => {
|
||||
if (lastActivityKey === null || dayKey > lastActivityKey) lastActivityKey = dayKey;
|
||||
};
|
||||
|
||||
// Pass 1 — daily: cost, per-model spend, per-surface spend, sparkline cost.
|
||||
for (const d of daily) {
|
||||
if (!d || !d.date) continue;
|
||||
const delta = dayDelta(now, dateFromDayKey(d.date));
|
||||
if (delta < 0) continue; // ignore future-dated noise
|
||||
const cost = Number.isFinite(d.totalCost) ? d.totalCost : Number.isFinite(d.cost) ? d.cost : 0;
|
||||
const source = d.source || '';
|
||||
|
||||
allTime.cost += cost;
|
||||
bumpSurface(surfaceAll, source, cost, 0);
|
||||
for (const mb of d.modelBreakdowns || []) {
|
||||
bumpModel(modelAll, mb.modelName, Number.isFinite(mb.cost) ? mb.cost : 0);
|
||||
}
|
||||
if (cost > 0) touchActivity(d.date);
|
||||
|
||||
if (d.date.slice(0, 7) === currentMonth) monthToDate.cost += cost;
|
||||
|
||||
if (delta === 0) today.cost += cost;
|
||||
if (delta < 7) last7d.cost += cost;
|
||||
if (delta < 30) {
|
||||
last30d.cost += cost;
|
||||
bumpSurface(surface30d, source, cost, 0);
|
||||
for (const mb of d.modelBreakdowns || []) {
|
||||
bumpModel(model30d, mb.modelName, Number.isFinite(mb.cost) ? mb.cost : 0);
|
||||
}
|
||||
}
|
||||
if (delta < SPARKLINE_DAYS) {
|
||||
const bucket = dayBuckets.get(d.date);
|
||||
if (bucket) bucket.cost += cost;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2 — hourly: request counts (daily aggregates don't carry them).
|
||||
for (const h of hourly) {
|
||||
if (!h || !h.hour) continue;
|
||||
const dayKey = h.hour.slice(0, 10);
|
||||
const delta = dayDelta(now, dateFromDayKey(dayKey));
|
||||
if (delta < 0) continue;
|
||||
const requests = Number.isFinite(h.requestCount) ? (h.requestCount as number) : 0;
|
||||
if (requests <= 0) continue;
|
||||
const source = h.source || '';
|
||||
|
||||
allTime.requests += requests;
|
||||
bumpSurface(surfaceAll, source, 0, requests);
|
||||
touchActivity(dayKey);
|
||||
|
||||
if (h.hour.slice(0, 7) === currentMonth) monthToDate.requests += requests;
|
||||
|
||||
if (delta === 0) today.requests += requests;
|
||||
if (delta < 7) last7d.requests += requests;
|
||||
if (delta < 30) {
|
||||
last30d.requests += requests;
|
||||
bumpSurface(surface30d, source, 0, requests);
|
||||
}
|
||||
if (delta < SPARKLINE_DAYS) {
|
||||
const bucket = dayBuckets.get(dayKey);
|
||||
if (bucket) bucket.requests += requests;
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer recent leaders; fall back to lifetime when the last 30 days are idle.
|
||||
const recentHasData = last30d.cost > 0 || last30d.requests > 0;
|
||||
const topModels = Array.from((recentHasData ? model30d : modelAll).values())
|
||||
.filter((m) => m.cost > 0 || m.requests > 0)
|
||||
.sort((a, b) => b.cost - a.cost)
|
||||
.slice(0, TOP_MODELS_LIMIT);
|
||||
const bySurface = Array.from((recentHasData ? surface30d : surfaceAll).values())
|
||||
.filter((s) => s.cost > 0 || s.requests > 0)
|
||||
.sort((a, b) => b.cost - a.cost);
|
||||
|
||||
const lastActivityAt = lastActivityKey ? dateFromDayKey(lastActivityKey).toISOString() : null;
|
||||
const daysSinceLastActivity = lastActivityKey
|
||||
? dayDelta(now, dateFromDayKey(lastActivityKey))
|
||||
: null;
|
||||
|
||||
return {
|
||||
today,
|
||||
last7d,
|
||||
last30d,
|
||||
monthToDate,
|
||||
allTime,
|
||||
byDay: Array.from(dayBuckets.values()),
|
||||
topModels,
|
||||
topModelsWindow: recentHasData ? '30d' : 'all',
|
||||
bySurface,
|
||||
lastActivityAt,
|
||||
daysSinceLastActivity,
|
||||
hasRecentData: recentHasData,
|
||||
generatedAt: now.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Native Claude Code credential reader.
|
||||
*
|
||||
* Reads the LOGGED-IN Claude Code OAuth token so the bar can show the user's
|
||||
* own subscription quota (Max/Pro/Team) without going through CLIProxy-managed
|
||||
* auth files.
|
||||
*
|
||||
* File-first, Keychain-fallback: we read ~/.claude/.credentials.json directly
|
||||
* because hitting the macOS Keychain pops a permission dialog. The Keychain
|
||||
* fallback is load-bearing on machines where Claude Code stores the token there
|
||||
* instead of on disk, so it must be kept even though the file path is preferred.
|
||||
*
|
||||
* Only the user's own token is read here; the single read-only Anthropic usage
|
||||
* endpoint is the only thing that ever sees it (see native-quota-collector).
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
/** Shape of the relevant slice of ~/.claude/.credentials.json */
|
||||
export interface ClaudeNativeCredentials {
|
||||
claudeAiOauth?: {
|
||||
accessToken?: string;
|
||||
subscriptionType?: string;
|
||||
rateLimitTier?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Injectable seams so unit tests never touch the real fs/Keychain. */
|
||||
export interface CredentialReaderDeps {
|
||||
platform?: NodeJS.Platform;
|
||||
homedir?: string;
|
||||
existsSyncImpl?: (p: string) => boolean;
|
||||
readFileSyncImpl?: (p: string) => string;
|
||||
execSyncImpl?: (cmd: string, opts: Record<string, unknown>) => string | Buffer;
|
||||
}
|
||||
|
||||
const KEYCHAIN_SERVICE = 'Claude Code-credentials';
|
||||
const KEYCHAIN_TIMEOUT_MS = 5000;
|
||||
|
||||
/** Subscription types that mean "no real subscription" -> skip the fetch. */
|
||||
const UNSUPPORTED_SUBSCRIPTION_TYPES = new Set(['', 'free', 'none']);
|
||||
|
||||
/** rateLimitTier values that imply an entitled subscription. */
|
||||
const SUPPORTED_RATE_LIMIT_TIER = /claude|max|pro|team|enterprise/;
|
||||
|
||||
function parseCredentials(raw: string): ClaudeNativeCredentials | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as ClaudeNativeCredentials;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the native Claude Code credentials.
|
||||
*
|
||||
* Order: the on-disk credentials file first (no prompt), then the macOS
|
||||
* Keychain as a fallback. Returns null when neither source yields a parseable
|
||||
* object.
|
||||
*/
|
||||
export function readClaudeCredentials(
|
||||
deps: CredentialReaderDeps = {}
|
||||
): ClaudeNativeCredentials | null {
|
||||
const platform = deps.platform ?? os.platform();
|
||||
const homedir = deps.homedir ?? os.homedir();
|
||||
const existsImpl = deps.existsSyncImpl ?? existsSync;
|
||||
const readImpl = deps.readFileSyncImpl ?? ((p: string) => readFileSync(p, 'utf8'));
|
||||
const execImpl = deps.execSyncImpl ?? execSync;
|
||||
|
||||
const credentialsPath = path.join(homedir, '.claude', '.credentials.json');
|
||||
if (existsImpl(credentialsPath)) {
|
||||
try {
|
||||
const parsed = parseCredentials(readImpl(credentialsPath));
|
||||
if (parsed) return parsed;
|
||||
} catch {
|
||||
// fall through to Keychain
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
try {
|
||||
const out = execImpl(`security find-generic-password -s "${KEYCHAIN_SERVICE}" -w`, {
|
||||
timeout: KEYCHAIN_TIMEOUT_MS,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
});
|
||||
const raw = (typeof out === 'string' ? out : out.toString('utf8')).trim();
|
||||
if (raw) {
|
||||
const parsed = parseCredentials(raw);
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
} catch {
|
||||
// no Keychain entry / access denied -> null
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Pull the OAuth access token, or null when absent. */
|
||||
export function getAccessToken(creds: ClaudeNativeCredentials | null): string | null {
|
||||
const token = creds?.claudeAiOauth?.accessToken;
|
||||
return typeof token === 'string' && token.trim().length > 0 ? token : null;
|
||||
}
|
||||
|
||||
/** Pull the subscription tier (e.g. "max" / "pro"), or null. */
|
||||
export function getSubscriptionTier(creds: ClaudeNativeCredentials | null): string | null {
|
||||
const tier = creds?.claudeAiOauth?.subscriptionType;
|
||||
return typeof tier === 'string' && tier.trim().length > 0 ? tier.trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the credentials describe a real, entitled subscription.
|
||||
*
|
||||
* Gating on this BEFORE fetching means a free/logged-out user never spends a
|
||||
* token call against the hostile usage endpoint and never gets a phantom row.
|
||||
*/
|
||||
export function hasSupportedSubscription(creds: ClaudeNativeCredentials | null): boolean {
|
||||
const subscriptionType = String(creds?.claudeAiOauth?.subscriptionType ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (subscriptionType && !UNSUPPORTED_SUBSCRIPTION_TYPES.has(subscriptionType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const rateLimitTier = String(creds?.claudeAiOauth?.rateLimitTier ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return SUPPORTED_RATE_LIMIT_TIER.test(rateLimitTier);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* CLIProxy Snapshot Reader
|
||||
*
|
||||
* Lightweight reader that extracts the flat CliproxyUsageHistoryDetail array
|
||||
* from the persisted snapshot at ~/.ccs/cache/cliproxy-usage/latest.json.
|
||||
*
|
||||
* This is a read-only helper — it never writes or syncs. The syncer owns
|
||||
* the write path; this module owns the "give me the raw details" path
|
||||
* needed by the bar-routes aggregator for per-account cost mapping.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { getCcsDir } from '../../config/config-loader-facade';
|
||||
import {
|
||||
normalizeCliproxyUsageHistoryDetail,
|
||||
type CliproxyUsageHistoryDetail,
|
||||
} from './cliproxy-usage-transformer';
|
||||
|
||||
const SUPPORTED_SNAPSHOT_VERSION = 3;
|
||||
|
||||
function getLatestSnapshotPath(): string {
|
||||
return path.join(getCcsDir(), 'cache', 'cliproxy-usage', 'latest.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the persisted CLIProxy usage snapshot and return its raw detail records.
|
||||
*
|
||||
* Returns an empty array when:
|
||||
* - The snapshot file does not exist
|
||||
* - The file cannot be parsed as JSON
|
||||
* - The snapshot version is unrecognised
|
||||
* - The details array is absent or malformed
|
||||
*
|
||||
* Individual malformed detail records are silently dropped (normalizer returns null).
|
||||
*/
|
||||
export async function loadCliproxySnapshotDetails(): Promise<CliproxyUsageHistoryDetail[]> {
|
||||
const snapshotPath = getLatestSnapshotPath();
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(snapshotPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(snapshotPath, 'utf-8');
|
||||
const snapshot = JSON.parse(raw) as Record<string, unknown>;
|
||||
|
||||
if (snapshot.version !== SUPPORTED_SNAPSHOT_VERSION) {
|
||||
// Legacy / future snapshot — skip rather than mis-interpret
|
||||
return [];
|
||||
}
|
||||
|
||||
const details = snapshot.details;
|
||||
if (!Array.isArray(details)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return details
|
||||
.map((item) => normalizeCliproxyUsageHistoryDetail(item))
|
||||
.filter((item): item is CliproxyUsageHistoryDetail => item !== null);
|
||||
} catch {
|
||||
// IO / parse errors are non-fatal — bar glance degrades gracefully
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,11 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fetchCliproxyUsageRaw } from '../../cliproxy/services/stats-fetcher';
|
||||
import {
|
||||
fetchCliproxyUsageRaw,
|
||||
fetchCliproxyAuthFiles,
|
||||
buildAuthIndexToAccountMap,
|
||||
} from '../../cliproxy/services/stats-fetcher';
|
||||
import {
|
||||
buildCliproxyUsageHistoryAggregates,
|
||||
extractCliproxyUsageHistoryDetails,
|
||||
@@ -42,6 +46,7 @@ type LegacyCliproxyUsageSnapshot = {
|
||||
};
|
||||
|
||||
type FetchCliproxyUsageRaw = typeof fetchCliproxyUsageRaw;
|
||||
type FetchCliproxyAuthFiles = typeof fetchCliproxyAuthFiles;
|
||||
|
||||
const SNAPSHOT_VERSION = 3;
|
||||
const SNAPSHOT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
@@ -318,7 +323,8 @@ export async function loadCachedCliproxyData(): Promise<{
|
||||
}
|
||||
|
||||
export async function syncCliproxyUsage(
|
||||
fetchRaw: FetchCliproxyUsageRaw = fetchCliproxyUsageRaw
|
||||
fetchRaw: FetchCliproxyUsageRaw = fetchCliproxyUsageRaw,
|
||||
fetchAuthFiles: FetchCliproxyAuthFiles = fetchCliproxyAuthFiles
|
||||
): Promise<void> {
|
||||
const raw = await fetchRaw();
|
||||
|
||||
@@ -327,8 +333,20 @@ export async function syncCliproxyUsage(
|
||||
return;
|
||||
}
|
||||
|
||||
// Build auth_index → account email map for attribution.
|
||||
// Auth file fetch failure is non-fatal: fall back to undefined map (cost = 0).
|
||||
let accountMap: Map<string, string> | undefined;
|
||||
try {
|
||||
await writeSnapshotWithMerge(extractCliproxyUsageHistoryDetails(raw));
|
||||
const authFiles = await fetchAuthFiles();
|
||||
if (authFiles !== null) {
|
||||
accountMap = buildAuthIndexToAccountMap(authFiles);
|
||||
}
|
||||
} catch {
|
||||
// Auth files unavailable — proceed without account attribution
|
||||
}
|
||||
|
||||
try {
|
||||
await writeSnapshotWithMerge(extractCliproxyUsageHistoryDetails(raw, accountMap));
|
||||
} catch (err) {
|
||||
console.log(warn('Failed to write CLIProxy snapshot:') + ` ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ import { getModelsUsed, normalizeUsageProvider } from './model-identity';
|
||||
export interface CliproxyUsageHistoryDetail {
|
||||
model: string;
|
||||
provider?: string;
|
||||
/** CLIProxy account email/id derived from auth_index lookup. Populated when an accountMap is supplied. */
|
||||
accountId?: string;
|
||||
timestamp: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
@@ -59,16 +61,29 @@ function buildModelBreakdown(
|
||||
function createHistoryDetail(
|
||||
provider: string,
|
||||
model: string,
|
||||
detail: CliproxyRequestDetail
|
||||
detail: CliproxyRequestDetail,
|
||||
accountMap?: Map<string, string>
|
||||
): CliproxyUsageHistoryDetail {
|
||||
const pricingProvider = normalizeUsageProvider(provider) ?? provider.trim().toLowerCase();
|
||||
const inputTokens = detail.tokens?.input_tokens ?? 0;
|
||||
const outputTokens = detail.tokens?.output_tokens ?? 0;
|
||||
const cacheReadTokens = detail.tokens?.cached_tokens ?? 0;
|
||||
|
||||
// Resolve accountId from auth_index → account map.
|
||||
// buildAuthIndexToAccountMap stores only String(auth_index) keys, so the numeric-key
|
||||
// lookup is dead code and the detail.source fallback mis-attributes cost to a CLIProxy
|
||||
// source label rather than an email. Leave accountId undefined when the index is absent
|
||||
// so getTodayCostByAccount buckets it under 'unknown' and the bar excludes it.
|
||||
let accountId: string | undefined;
|
||||
if (accountMap !== undefined) {
|
||||
const key = String(detail.auth_index);
|
||||
accountId = accountMap.get(key);
|
||||
}
|
||||
|
||||
return {
|
||||
model,
|
||||
provider: pricingProvider,
|
||||
...(accountId !== undefined && { accountId }),
|
||||
timestamp: detail.timestamp,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
@@ -136,20 +151,30 @@ export function normalizeCliproxyUsageHistoryDetail(
|
||||
const outputTokens = normalizePersistedNumber(candidate.outputTokens);
|
||||
const cacheReadTokens = normalizePersistedNumber(candidate.cacheReadTokens);
|
||||
const requestCount = Math.max(1, normalizePersistedNumber(candidate.requestCount, 1));
|
||||
const cost = normalizePersistedNumber(
|
||||
candidate.cost,
|
||||
calculateHistoryDetailCost(
|
||||
candidate.model,
|
||||
provider,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens
|
||||
)
|
||||
);
|
||||
// Compute the cost fallback lazily. calculateHistoryDetailCost is ~6ms/call
|
||||
// (model-pricing lookup); passing it as an eager default argument ran it for
|
||||
// every record even when a persisted cost was already present, turning a few
|
||||
// thousand records into a multi-second event-loop stall.
|
||||
const cost =
|
||||
typeof candidate.cost === 'number' && Number.isFinite(candidate.cost)
|
||||
? candidate.cost
|
||||
: calculateHistoryDetailCost(
|
||||
candidate.model,
|
||||
provider,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens
|
||||
);
|
||||
|
||||
const accountId =
|
||||
typeof candidate.accountId === 'string' && candidate.accountId.length > 0
|
||||
? candidate.accountId
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
model: candidate.model,
|
||||
...(provider && { provider }),
|
||||
...(accountId !== undefined && { accountId }),
|
||||
timestamp: candidate.timestamp,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
@@ -177,9 +202,15 @@ function hasTrackedUsage(detail: CliproxyRequestDetail): boolean {
|
||||
* Flatten the nested response.usage.apis[provider].models[model].details[]
|
||||
* structure into normalized history details. Failed requests are retained only
|
||||
* when they still report tracked token usage that analytics can account for.
|
||||
*
|
||||
* @param accountMap Optional auth_index → account email/id map. When provided,
|
||||
* each detail's `accountId` is resolved from String(auth_index). When the index
|
||||
* is absent from the map, `accountId` is left undefined so getTodayCostByAccount
|
||||
* buckets the cost under 'unknown' rather than mis-attributing it.
|
||||
*/
|
||||
export function extractCliproxyUsageHistoryDetails(
|
||||
response: CliproxyUsageApiResponse
|
||||
response: CliproxyUsageApiResponse,
|
||||
accountMap?: Map<string, string>
|
||||
): CliproxyUsageHistoryDetail[] {
|
||||
const apis = response?.usage?.apis;
|
||||
if (!apis) return [];
|
||||
@@ -193,7 +224,7 @@ export function extractCliproxyUsageHistoryDetails(
|
||||
if (!details) continue;
|
||||
for (const detail of details) {
|
||||
if (detail.failed && !hasTrackedUsage(detail)) continue;
|
||||
results.push(createHistoryDetail(provider, model, detail));
|
||||
results.push(createHistoryDetail(provider, model, detail, accountMap));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,6 +235,7 @@ function sanitizeHistoryDetail(detail: CliproxyUsageHistoryDetail): CliproxyUsag
|
||||
return {
|
||||
model: detail.model,
|
||||
...(detail.provider && { provider: detail.provider }),
|
||||
...(detail.accountId !== undefined && { accountId: detail.accountId }),
|
||||
timestamp: detail.timestamp,
|
||||
inputTokens: detail.inputTokens,
|
||||
outputTokens: detail.outputTokens,
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Codex local quota collector (zero network).
|
||||
*
|
||||
* Codex writes a `rate_limits` object into its rollout session logs
|
||||
* (~/.codex/sessions/<y>/<m>/<d>/rollout-*.jsonl). We surface the user's Codex
|
||||
* subscription quota WITHOUT any network call — pure local file read.
|
||||
*
|
||||
* Exec-mode sessions often never emit rate_limits (the field stays null). The
|
||||
* NEWEST session is frequently exec-mode, so reading only it would emit no row
|
||||
* even though an older interactive session still carries real quota. We instead
|
||||
* scan recent sessions newest-first and use the first one that yields a usable
|
||||
* rate_limits, marking the result stale when that source file is old. We only
|
||||
* return null when no scanned session has any rate_limits — then the bar omits
|
||||
* the row rather than inventing a fake one.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { resolveCodexConfigPaths } from '../services/codex-dashboard-service';
|
||||
|
||||
/**
|
||||
* One Codex quota window normalized for the bar's per-window detail.
|
||||
* `key`/`label` map primary->five_hour/"5h", secondary->seven_day/"week".
|
||||
*/
|
||||
export interface CodexLocalQuotaWindow {
|
||||
/** Stable window key: "five_hour" | "seven_day". */
|
||||
key: string;
|
||||
/** Short display label: "5h" | "week". */
|
||||
label: string;
|
||||
/** Used percentage (0-100). */
|
||||
usedPercent: number;
|
||||
/** Remaining percentage (0-100) = 100 - usedPercent. */
|
||||
remainingPercent: number;
|
||||
/** ISO timestamp when this window resets, null if unknown. */
|
||||
resetAt: string | null;
|
||||
/** Window length in minutes (300 / 10080), null if absent in the raw log. */
|
||||
windowMinutes: number | null;
|
||||
}
|
||||
|
||||
/** Normalized Codex quota snapshot from a local session log. */
|
||||
export interface CodexLocalQuota {
|
||||
/** Remaining percentage (0-100): min across primary/secondary windows. */
|
||||
quotaPercentage: number;
|
||||
/** ISO timestamp of the soonest window reset, null if unknown. */
|
||||
nextReset: string | null;
|
||||
/** plan_type from the session (e.g. "pro"/"plus"), null if absent. */
|
||||
tier: string | null;
|
||||
/** True when the source file is older than the freshness window. */
|
||||
stale: boolean;
|
||||
/**
|
||||
* ISO mtime of the session file that SUPPLIED this data, present only when
|
||||
* stale. Lets the bar render an "as of HH:mm (older session)" footnote.
|
||||
*/
|
||||
staleAsOf: string | null;
|
||||
/** Per-window detail (primary -> five_hour, secondary -> seven_day). */
|
||||
windows: CodexLocalQuotaWindow[];
|
||||
}
|
||||
|
||||
/** Injectable seams for deterministic tests (no real fs / no tail subprocess). */
|
||||
export interface CodexLocalQuotaDeps {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homeDir?: string;
|
||||
existsSyncImpl?: (p: string) => boolean;
|
||||
readdirImpl?: (dir: string) => fs.Dirent[];
|
||||
statMtimeMsImpl?: (p: string) => number;
|
||||
/** Returns the last N lines of a file (default: Bun tail). */
|
||||
tailLinesImpl?: (file: string, lines: number) => Promise<string[]>;
|
||||
now?: number;
|
||||
}
|
||||
|
||||
/** Lines to scan from the tail; rate_limits sits near the end of a session. */
|
||||
const TAIL_LINES = 200;
|
||||
|
||||
/** A source older than this is reported stale (but still emitted). */
|
||||
const STALE_AFTER_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Cap on how many recent session files we scan newest-first before giving up.
|
||||
* Bounds the walk when Codex genuinely never reported (avoids touching the whole
|
||||
* history) while still reaching past several exec-mode sessions to a real one.
|
||||
*/
|
||||
const MAX_SESSIONS_SCANNED = 20;
|
||||
|
||||
interface CodexRateWindow {
|
||||
usedPercent: number;
|
||||
resetsAtSeconds: number | null;
|
||||
windowMinutes: number | null;
|
||||
}
|
||||
|
||||
interface CodexRateLimits {
|
||||
primary: CodexRateWindow | null;
|
||||
secondary: CodexRateWindow | null;
|
||||
planType: string | null;
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asFiniteNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function parseWindow(value: unknown): CodexRateWindow | null {
|
||||
const obj = asObject(value);
|
||||
if (!obj) return null;
|
||||
const usedPercent = asFiniteNumber(obj['used_percent']);
|
||||
if (usedPercent === null) return null;
|
||||
return {
|
||||
usedPercent,
|
||||
resetsAtSeconds: asFiniteNumber(obj['resets_at']),
|
||||
windowMinutes: asFiniteNumber(obj['window_minutes']),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a non-null rate_limits object from a parsed JSONL line.
|
||||
* rate_limits lives under `payload` in the token_count event.
|
||||
*/
|
||||
function extractRateLimits(line: string): CodexRateLimits | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const root = asObject(parsed);
|
||||
if (!root) return null;
|
||||
|
||||
const payload = asObject(root['payload']) ?? root;
|
||||
const rateLimits = asObject(payload['rate_limits']);
|
||||
if (!rateLimits) return null;
|
||||
|
||||
const primary = parseWindow(rateLimits['primary']);
|
||||
const secondary = parseWindow(rateLimits['secondary']);
|
||||
// A rate_limits object with neither window carries no usable signal.
|
||||
if (!primary && !secondary) return null;
|
||||
|
||||
const planType =
|
||||
typeof rateLimits['plan_type'] === 'string' && rateLimits['plan_type'].trim().length > 0
|
||||
? (rateLimits['plan_type'] as string).trim()
|
||||
: null;
|
||||
|
||||
return { primary, secondary, planType };
|
||||
}
|
||||
|
||||
/** Recursive rollout-*.jsonl walker, lexicographically sorted (ISO ts in name). */
|
||||
function collectRolloutFiles(
|
||||
dir: string,
|
||||
existsImpl: (p: string) => boolean,
|
||||
readdirImpl: (d: string) => fs.Dirent[]
|
||||
): string[] {
|
||||
if (!existsImpl(dir)) return [];
|
||||
const files: string[] = [];
|
||||
for (const entry of readdirImpl(dir)) {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...collectRolloutFiles(entryPath, existsImpl, readdirImpl));
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && entry.name.startsWith('rollout-') && entry.name.endsWith('.jsonl')) {
|
||||
files.push(entryPath);
|
||||
}
|
||||
}
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Default tail using Bun.spawn (macOS-safe: no tac, no GNU timeout).
|
||||
* Reads the last `lines` lines so we never load a huge session into memory.
|
||||
*/
|
||||
async function defaultTailLines(file: string, lines: number): Promise<string[]> {
|
||||
const proc = Bun.spawn(['tail', `-${lines}`, file], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'ignore',
|
||||
});
|
||||
const text = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
return text.split('\n').filter((l) => l.trim().length > 0);
|
||||
}
|
||||
|
||||
function computeQuotaPercentage(rate: CodexRateLimits): number {
|
||||
const remaining: number[] = [];
|
||||
if (rate.primary) remaining.push(100 - rate.primary.usedPercent);
|
||||
if (rate.secondary) remaining.push(100 - rate.secondary.usedPercent);
|
||||
// Clamp into [0,100] to guard against odd upstream values.
|
||||
return Math.max(0, Math.min(100, Math.min(...remaining)));
|
||||
}
|
||||
|
||||
function computeNextReset(rate: CodexRateLimits): string | null {
|
||||
const resets: number[] = [];
|
||||
if (rate.primary?.resetsAtSeconds !== null && rate.primary?.resetsAtSeconds !== undefined) {
|
||||
resets.push(rate.primary.resetsAtSeconds);
|
||||
}
|
||||
if (rate.secondary?.resetsAtSeconds !== null && rate.secondary?.resetsAtSeconds !== undefined) {
|
||||
resets.push(rate.secondary.resetsAtSeconds);
|
||||
}
|
||||
if (resets.length === 0) return null;
|
||||
return new Date(Math.min(...resets) * 1000).toISOString();
|
||||
}
|
||||
|
||||
function windowDetail(window: CodexRateWindow, key: string, label: string): CodexLocalQuotaWindow {
|
||||
const usedPercent = Math.max(0, Math.min(100, window.usedPercent));
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
usedPercent,
|
||||
remainingPercent: 100 - usedPercent,
|
||||
resetAt:
|
||||
window.resetsAtSeconds !== null
|
||||
? new Date(window.resetsAtSeconds * 1000).toISOString()
|
||||
: null,
|
||||
windowMinutes: window.windowMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build per-window detail (primary -> 5h, secondary -> week), omitting absent. */
|
||||
function buildWindows(rate: CodexRateLimits): CodexLocalQuotaWindow[] {
|
||||
const windows: CodexLocalQuotaWindow[] = [];
|
||||
if (rate.primary) windows.push(windowDetail(rate.primary, 'five_hour', '5h'));
|
||||
if (rate.secondary) windows.push(windowDetail(rate.secondary, 'seven_day', 'week'));
|
||||
return windows;
|
||||
}
|
||||
|
||||
/** Scan one session file's tail backward for the latest non-null rate_limits. */
|
||||
async function readRateLimitsFromFile(
|
||||
file: string,
|
||||
tailLinesImpl: (file: string, lines: number) => Promise<string[]>
|
||||
): Promise<CodexRateLimits | null> {
|
||||
let lines: string[];
|
||||
try {
|
||||
lines = await tailLinesImpl(file, TAIL_LINES);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const found = extractRateLimits(lines[i]);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan recent Codex sessions newest-first and normalize the first usable
|
||||
* rate_limits. Staleness is computed from the mtime of the file that SUPPLIED
|
||||
* the data (not the newest file), so a real-but-old session reads correctly as
|
||||
* stale. Returns null only when none of the scanned sessions carries quota.
|
||||
*/
|
||||
export async function getCodexLocalQuota(
|
||||
deps: CodexLocalQuotaDeps = {}
|
||||
): Promise<CodexLocalQuota | null> {
|
||||
const existsImpl = deps.existsSyncImpl ?? fs.existsSync;
|
||||
const readdirImpl =
|
||||
deps.readdirImpl ?? ((dir: string) => fs.readdirSync(dir, { withFileTypes: true }));
|
||||
const statMtimeMsImpl = deps.statMtimeMsImpl ?? ((p: string) => fs.statSync(p).mtimeMs);
|
||||
const tailLinesImpl = deps.tailLinesImpl ?? defaultTailLines;
|
||||
const now = deps.now ?? Date.now();
|
||||
|
||||
const { baseDir } = resolveCodexConfigPaths({ env: deps.env, homeDir: deps.homeDir });
|
||||
const sessionsDir = path.join(baseDir, 'sessions');
|
||||
|
||||
const rolloutFiles = collectRolloutFiles(sessionsDir, existsImpl, readdirImpl);
|
||||
if (rolloutFiles.length === 0) return null;
|
||||
|
||||
// Filenames carry an ISO timestamp, so the lexicographic order is chronological.
|
||||
// Walk newest-first, bounded, until a session yields a usable rate_limits.
|
||||
const newestFirst = rolloutFiles.slice().reverse().slice(0, MAX_SESSIONS_SCANNED);
|
||||
|
||||
let rate: CodexRateLimits | null = null;
|
||||
let sourceFile: string | null = null;
|
||||
for (const file of newestFirst) {
|
||||
const found = await readRateLimitsFromFile(file, tailLinesImpl);
|
||||
if (found) {
|
||||
rate = found;
|
||||
sourceFile = file;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!rate || !sourceFile) return null;
|
||||
|
||||
let stale = false;
|
||||
let staleAsOf: string | null = null;
|
||||
try {
|
||||
const mtimeMs = statMtimeMsImpl(sourceFile);
|
||||
stale = now - mtimeMs > STALE_AFTER_MS;
|
||||
if (stale) staleAsOf = new Date(mtimeMs).toISOString();
|
||||
} catch {
|
||||
// Unknown mtime -> treat as fresh; the data itself is still valid.
|
||||
stale = false;
|
||||
}
|
||||
|
||||
return {
|
||||
quotaPercentage: computeQuotaPercentage(rate),
|
||||
nextReset: computeNextReset(rate),
|
||||
tier: rate.planType,
|
||||
stale,
|
||||
staleAsOf,
|
||||
windows: buildWindows(rate),
|
||||
};
|
||||
}
|
||||
@@ -479,6 +479,45 @@ export function aggregateSessionUsage(
|
||||
return sessionUsage;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CLIPROXY ACCOUNT-LEVEL COST (Phase 1A: CCS Bar)
|
||||
// ============================================================================
|
||||
|
||||
import type { CliproxyUsageHistoryDetail } from './cliproxy-usage-transformer';
|
||||
import { localDayKey } from './bar-analytics';
|
||||
|
||||
/**
|
||||
* Compute per-account cost totals for a given calendar day.
|
||||
*
|
||||
* @param details Flat history details produced by extractCliproxyUsageHistoryDetails.
|
||||
* Details with `accountId` are grouped by that value; details without are grouped
|
||||
* under the key `'unknown'`.
|
||||
* @param today YYYY-MM-DD date string (defaults to local date if omitted).
|
||||
* @returns Record mapping accountId (or 'unknown') → total cost in USD for that day.
|
||||
*/
|
||||
export function getTodayCostByAccount(
|
||||
details: CliproxyUsageHistoryDetail[],
|
||||
today?: string
|
||||
): Record<string, number> {
|
||||
// Key on the LOCAL calendar day so a near-midnight record buckets into the
|
||||
// same day the analytics panel shows (bar-analytics also keys on localDayKey).
|
||||
const dateKey = today ?? localDayKey(new Date());
|
||||
const result: Record<string, number> = {};
|
||||
|
||||
for (const detail of details) {
|
||||
// Filter to the requested day only
|
||||
if (!detail.timestamp.startsWith(dateKey)) continue;
|
||||
|
||||
// Skip zero-cost records to avoid polluting result with no-op entries
|
||||
if (detail.cost <= 0) continue;
|
||||
|
||||
const accountKey = detail.accountId ?? 'unknown';
|
||||
result[accountKey] = (result[accountKey] ?? 0) + detail.cost;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN DATA LOADER (drop-in replacement for better-ccusage)
|
||||
// ============================================================================
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* Native subscription quota collector — the ONLY server-side fetch surface for
|
||||
* the user's own Claude Code + Codex subscription quota.
|
||||
*
|
||||
* The macOS bar reads localhost /api/bar/summary and NEVER calls Anthropic. All
|
||||
* Anthropic traffic originates here, under strict safety controls, because the
|
||||
* OAuth usage endpoint is undocumented and hostile to polling (persistent 429s,
|
||||
* no Retry-After, first-party-only policy). The controls below exist to protect
|
||||
* the user's account:
|
||||
*
|
||||
* - long TTL (10 min) on-demand cache, never a tight timer loop
|
||||
* - in-flight coalescing so concurrent /summary calls share one fetch
|
||||
* - Retry-After honored; exponential backoff + jitter on 429/5xx
|
||||
* - circuit breaker stops calling after repeated 429s for a cooldown
|
||||
* - serve-stale-on-failure; only omit a row when there is genuinely no data
|
||||
*
|
||||
* Codex is a pure local file read (no network), so it skips the network guards.
|
||||
*/
|
||||
|
||||
import {
|
||||
readClaudeCredentials,
|
||||
getAccessToken,
|
||||
getSubscriptionTier,
|
||||
hasSupportedSubscription,
|
||||
type ClaudeNativeCredentials,
|
||||
} from './claude-native-credentials';
|
||||
import { fetchClaudeQuotaWithToken } from '../../cliproxy/quota/quota-fetcher-claude';
|
||||
import { getCodexLocalQuota, type CodexLocalQuota } from './codex-local-quota-collector';
|
||||
import type { ClaudeQuotaResult } from '../../cliproxy/quota/quota-types';
|
||||
import type { BarSummaryRow, QuotaWindowDetail } from '../routes/bar-routes';
|
||||
|
||||
// ============================================================================
|
||||
// Safety constants (concrete, named, module-level)
|
||||
// ============================================================================
|
||||
|
||||
/** On-demand cache TTL. Floor is 5 min; we use 10 min because the bar polls
|
||||
* /summary far more often than a hook fires. */
|
||||
const NATIVE_QUOTA_TTL_MS = 600_000; // 10 minutes
|
||||
|
||||
/** Exponential backoff base; delay = min(base * 2^n, MAX) + jitter. */
|
||||
const RETRY_BASE_MS = 1_000;
|
||||
|
||||
/** Ceiling for any single backoff / Retry-After cooldown derived from one call. */
|
||||
const MAX_BACKOFF_MS = 60_000; // 1 minute
|
||||
|
||||
/** Jitter added to backoff to avoid synchronized retries. */
|
||||
const JITTER_MAX_MS = 500;
|
||||
|
||||
/** Consecutive 429s that trip the breaker open. */
|
||||
const CB_TRIP_THRESHOLD = 3;
|
||||
|
||||
/** How long the breaker stays open (zero network) once tripped. */
|
||||
const CB_COOLDOWN_MS = 900_000; // 15 minutes
|
||||
|
||||
const CLAUDE_PROVIDER = 'claude-code';
|
||||
const CODEX_PROVIDER = 'codex';
|
||||
|
||||
// ============================================================================
|
||||
// Injectable dependencies (tests inject mocks; never live Anthropic in CI)
|
||||
// ============================================================================
|
||||
|
||||
export interface NativeQuotaDeps {
|
||||
/** Read the native Claude Code credentials. */
|
||||
readCredentials?: () => ClaudeNativeCredentials | null;
|
||||
/** Fetch Claude quota with a directly-supplied native token. */
|
||||
fetchClaudeQuota?: (accessToken: string, accountId?: string) => Promise<ClaudeQuotaResult>;
|
||||
/** Read Codex quota from local session logs (zero network). */
|
||||
getCodexQuota?: () => Promise<CodexLocalQuota | null>;
|
||||
/** Clock seam for deterministic backoff/TTL/breaker tests. */
|
||||
now?: () => number;
|
||||
/** Sleep seam (no real delay in tests). */
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Per-provider mutable state (module-scoped; reset() for tests)
|
||||
// ============================================================================
|
||||
|
||||
interface ProviderState {
|
||||
/** Last successfully-built row, kept for stale-on-fail and TTL serving. */
|
||||
cachedRow: BarSummaryRow | null;
|
||||
/** Epoch ms when cachedRow was produced. */
|
||||
cachedAt: number;
|
||||
/** Shared in-flight promise; concurrent callers await this, not a new fetch. */
|
||||
pending: Promise<BarSummaryRow | null> | null;
|
||||
/** Consecutive 429 count toward the breaker threshold. */
|
||||
consecutive429: number;
|
||||
/** Epoch ms until which the breaker is open (no network). */
|
||||
breakerOpenUntil: number;
|
||||
/** Epoch ms until which a Retry-After / backoff cooldown holds. */
|
||||
cooldownUntil: number;
|
||||
/** Attempt counter feeding exponential backoff. */
|
||||
backoffAttempt: number;
|
||||
}
|
||||
|
||||
function freshProviderState(): ProviderState {
|
||||
return {
|
||||
cachedRow: null,
|
||||
cachedAt: 0,
|
||||
pending: null,
|
||||
consecutive429: 0,
|
||||
breakerOpenUntil: 0,
|
||||
cooldownUntil: 0,
|
||||
backoffAttempt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const claudeState = freshProviderState();
|
||||
|
||||
/** Reset all module state. Tests call this to avoid cross-test pollution. */
|
||||
export function resetNativeQuotaState(): void {
|
||||
Object.assign(claudeState, freshProviderState());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
function defaultSleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** Parse Retry-After (seconds or HTTP-date) into ms, capped at MAX_BACKOFF_MS. */
|
||||
function parseRetryAfterMs(detail: string | undefined, now: number): number | null {
|
||||
if (!detail) return null;
|
||||
const match = /retry-after:(.+)$/.exec(detail);
|
||||
if (!match) return null;
|
||||
const raw = match[1].trim();
|
||||
|
||||
const asSeconds = Number(raw);
|
||||
if (Number.isFinite(asSeconds)) {
|
||||
return Math.min(Math.max(0, asSeconds) * 1000, MAX_BACKOFF_MS);
|
||||
}
|
||||
|
||||
const asDate = Date.parse(raw);
|
||||
if (Number.isFinite(asDate)) {
|
||||
return Math.min(Math.max(0, asDate - now), MAX_BACKOFF_MS);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function computeBackoffMs(attempt: number): number {
|
||||
const exp = Math.min(RETRY_BASE_MS * 2 ** attempt, MAX_BACKOFF_MS);
|
||||
const jitter = Math.floor(Math.random() * JITTER_MAX_MS);
|
||||
return exp + jitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-guess remaining percentage across the 5h + weekly core windows, mirroring
|
||||
* the CLIProxy quota-manager derivation: min of remaining across non-overage
|
||||
* windows, falling back to all windows if core summary is absent.
|
||||
*/
|
||||
function deriveClaudeQuotaPercentage(quota: ClaudeQuotaResult): number | null {
|
||||
const coreWindows = [quota.coreUsage?.fiveHour, quota.coreUsage?.weekly].filter(
|
||||
(w): w is NonNullable<typeof w> => !!w
|
||||
);
|
||||
if (coreWindows.length > 0) {
|
||||
return Math.min(...coreWindows.map((w) => w.remainingPercent));
|
||||
}
|
||||
const usageWindows = quota.windows.filter((w) => w.rateLimitType !== 'overage');
|
||||
if (usageWindows.length > 0) {
|
||||
return Math.min(...usageWindows.map((w) => w.remainingPercent));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Soonest non-null reset ISO across the two core windows. */
|
||||
function deriveClaudeNextReset(quota: ClaudeQuotaResult): string | null {
|
||||
const resets = [quota.coreUsage?.fiveHour?.resetAt, quota.coreUsage?.weekly?.resetAt]
|
||||
.filter((r): r is string => typeof r === 'string')
|
||||
.map((r) => ({ iso: r, ms: new Date(r).getTime() }))
|
||||
.filter((r) => Number.isFinite(r.ms))
|
||||
.sort((a, b) => a.ms - b.ms);
|
||||
return resets.length > 0 ? resets[0].iso : null;
|
||||
}
|
||||
|
||||
/** Window length in minutes by Claude rate-limit family. */
|
||||
const FIVE_HOUR_MINUTES = 300;
|
||||
const SEVEN_DAY_MINUTES = 10080;
|
||||
|
||||
/**
|
||||
* Build the per-window detail for a Claude subscription row.
|
||||
*
|
||||
* 5h + weekly come from coreUsage (the canonical core summary). Opus/Sonnet
|
||||
* weekly splits come from quota.windows[] and only exist on Max plans, so they
|
||||
* are omitted entirely when absent. Each window carries BOTH used and remaining
|
||||
* percent so the bar never re-derives them.
|
||||
*/
|
||||
function buildClaudeQuotaWindows(quota: ClaudeQuotaResult): QuotaWindowDetail[] {
|
||||
const windows: QuotaWindowDetail[] = [];
|
||||
|
||||
const fiveHour = quota.coreUsage?.fiveHour;
|
||||
if (fiveHour) {
|
||||
windows.push({
|
||||
key: 'five_hour',
|
||||
label: '5h',
|
||||
usedPercent: 100 - fiveHour.remainingPercent,
|
||||
remainingPercent: fiveHour.remainingPercent,
|
||||
resetAt: fiveHour.resetAt,
|
||||
windowMinutes: FIVE_HOUR_MINUTES,
|
||||
});
|
||||
}
|
||||
|
||||
const weekly = quota.coreUsage?.weekly;
|
||||
if (weekly) {
|
||||
windows.push({
|
||||
key: 'seven_day',
|
||||
label: 'week',
|
||||
usedPercent: 100 - weekly.remainingPercent,
|
||||
remainingPercent: weekly.remainingPercent,
|
||||
resetAt: weekly.resetAt,
|
||||
windowMinutes: SEVEN_DAY_MINUTES,
|
||||
});
|
||||
}
|
||||
|
||||
// Opus/Sonnet weekly splits are Max-only; surface them when the API carries
|
||||
// them, otherwise omit so non-Max plans get exactly the two core windows.
|
||||
const splitLabels: Record<string, string> = {
|
||||
seven_day_opus: 'Opus · week',
|
||||
seven_day_sonnet: 'Sonnet · week',
|
||||
};
|
||||
for (const w of quota.windows) {
|
||||
const label = splitLabels[w.rateLimitType];
|
||||
if (!label) continue;
|
||||
windows.push({
|
||||
key: w.rateLimitType,
|
||||
label,
|
||||
usedPercent: w.usedPercent,
|
||||
remainingPercent: w.remainingPercent,
|
||||
resetAt: w.resetAt,
|
||||
windowMinutes: SEVEN_DAY_MINUTES,
|
||||
});
|
||||
}
|
||||
|
||||
return windows;
|
||||
}
|
||||
|
||||
function buildClaudeRow(quota: ClaudeQuotaResult, tier: string | null, now: number): BarSummaryRow {
|
||||
const quotaWindows = buildClaudeQuotaWindows(quota);
|
||||
return {
|
||||
account_id: CLAUDE_PROVIDER,
|
||||
provider: CLAUDE_PROVIDER,
|
||||
displayName: 'Claude Code',
|
||||
tier,
|
||||
paused: false,
|
||||
quota_percentage: deriveClaudeQuotaPercentage(quota),
|
||||
quotaStatus: 'ok',
|
||||
next_reset: deriveClaudeNextReset(quota),
|
||||
is_default: false,
|
||||
last_activity_at: null,
|
||||
today_cost: null,
|
||||
health: 'ok',
|
||||
cached: false,
|
||||
fetchedAt: new Date(now).toISOString(),
|
||||
needsReauth: false,
|
||||
// Omit the field entirely (rather than an empty array) when no windows
|
||||
// resolved, so the wire shape stays minimal.
|
||||
...(quotaWindows.length > 0 ? { quotaWindows } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Map the Codex local windows into the row's per-window detail shape. */
|
||||
function buildCodexQuotaWindows(quota: CodexLocalQuota): QuotaWindowDetail[] {
|
||||
return quota.windows.map((w) => ({
|
||||
key: w.key,
|
||||
label: w.label,
|
||||
usedPercent: w.usedPercent,
|
||||
remainingPercent: w.remainingPercent,
|
||||
resetAt: w.resetAt,
|
||||
windowMinutes: w.windowMinutes,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildCodexRow(quota: CodexLocalQuota, now: number): BarSummaryRow {
|
||||
const quotaWindows = buildCodexQuotaWindows(quota);
|
||||
return {
|
||||
account_id: CODEX_PROVIDER,
|
||||
provider: CODEX_PROVIDER,
|
||||
displayName: 'Codex',
|
||||
tier: quota.tier,
|
||||
paused: false,
|
||||
quota_percentage: quota.quotaPercentage,
|
||||
quotaStatus: 'ok',
|
||||
next_reset: quota.nextReset,
|
||||
is_default: false,
|
||||
last_activity_at: null,
|
||||
today_cost: null,
|
||||
// Codex is a local read; a stale source still reflects real usage so we keep
|
||||
// quotaStatus 'ok' but flag health 'warning' to hint freshness.
|
||||
health: quota.stale ? 'warning' : 'ok',
|
||||
cached: false,
|
||||
fetchedAt: new Date(now).toISOString(),
|
||||
needsReauth: false,
|
||||
...(quotaWindows.length > 0 ? { quotaWindows } : {}),
|
||||
// staleAsOf is only present (and serialized) when the source session is old.
|
||||
...(quota.staleAsOf ? { staleAsOf: quota.staleAsOf } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Return the cached row marked cached=true (used for TTL + stale serving). */
|
||||
function serveCached(state: ProviderState): BarSummaryRow | null {
|
||||
if (!state.cachedRow) return null;
|
||||
return { ...state.cachedRow, cached: true };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Claude path with full safety controls
|
||||
// ============================================================================
|
||||
|
||||
async function collectClaudeRow(deps: NativeQuotaDeps): Promise<BarSummaryRow | null> {
|
||||
const now = (deps.now ?? Date.now)();
|
||||
const state = claudeState;
|
||||
|
||||
// Serve from cache while within TTL — on-demand only, NO network.
|
||||
if (state.cachedRow && now - state.cachedAt < NATIVE_QUOTA_TTL_MS) {
|
||||
return serveCached(state);
|
||||
}
|
||||
|
||||
// Breaker open or cooldown active -> zero network, serve stale (may be null).
|
||||
if (now < state.breakerOpenUntil || now < state.cooldownUntil) {
|
||||
return serveCached(state);
|
||||
}
|
||||
|
||||
// Coalesce: concurrent callers past TTL share one in-flight fetch.
|
||||
if (state.pending) {
|
||||
return state.pending;
|
||||
}
|
||||
|
||||
const readCredentials = deps.readCredentials ?? readClaudeCredentials;
|
||||
const fetchQuota = deps.fetchClaudeQuota ?? fetchClaudeQuotaWithToken;
|
||||
const sleep = deps.sleep ?? defaultSleep;
|
||||
|
||||
state.pending = (async (): Promise<BarSummaryRow | null> => {
|
||||
try {
|
||||
const creds = readCredentials();
|
||||
// No token / unsupported subscription -> never spend a call, omit the row.
|
||||
if (!creds || !hasSupportedSubscription(creds)) {
|
||||
return serveCached(state);
|
||||
}
|
||||
const token = getAccessToken(creds);
|
||||
if (!token) {
|
||||
return serveCached(state);
|
||||
}
|
||||
const tier = getSubscriptionTier(creds);
|
||||
|
||||
const quota = await fetchQuota(token, CLAUDE_PROVIDER);
|
||||
|
||||
if (quota.success) {
|
||||
// Success closes the breaker and clears backoff.
|
||||
state.consecutive429 = 0;
|
||||
state.breakerOpenUntil = 0;
|
||||
state.cooldownUntil = 0;
|
||||
state.backoffAttempt = 0;
|
||||
const row = buildClaudeRow(quota, tier, now);
|
||||
state.cachedRow = row;
|
||||
state.cachedAt = now;
|
||||
return { ...row, cached: false };
|
||||
}
|
||||
|
||||
// 401 -> token expired. Emit a reauth row so the bar can prompt; this is
|
||||
// a real, actionable state distinct from a transient failure.
|
||||
if (quota.needsReauth) {
|
||||
const row: BarSummaryRow = {
|
||||
account_id: CLAUDE_PROVIDER,
|
||||
provider: CLAUDE_PROVIDER,
|
||||
displayName: 'Claude Code',
|
||||
tier,
|
||||
paused: false,
|
||||
quota_percentage: null,
|
||||
quotaStatus: 'error',
|
||||
next_reset: null,
|
||||
is_default: false,
|
||||
last_activity_at: null,
|
||||
today_cost: null,
|
||||
health: 'error',
|
||||
cached: false,
|
||||
fetchedAt: new Date(now).toISOString(),
|
||||
needsReauth: true,
|
||||
};
|
||||
// Do not cache the reauth row as a good value; it should re-evaluate
|
||||
// once the user re-auths. But return it now.
|
||||
return row;
|
||||
}
|
||||
|
||||
// 429 / 5xx / transient. Apply backoff + breaker, then serve stale.
|
||||
const is429 = quota.httpStatus === 429;
|
||||
if (is429) {
|
||||
state.consecutive429 += 1;
|
||||
if (state.consecutive429 >= CB_TRIP_THRESHOLD) {
|
||||
state.breakerOpenUntil = now + CB_COOLDOWN_MS;
|
||||
}
|
||||
const retryAfter = parseRetryAfterMs(quota.errorDetail, now);
|
||||
const backoff = retryAfter ?? computeBackoffMs(state.backoffAttempt);
|
||||
state.cooldownUntil = now + backoff;
|
||||
state.backoffAttempt += 1;
|
||||
// We do NOT sleep-then-retry inside the request path (that would burn
|
||||
// the request budget). The cooldown gates the NEXT call instead.
|
||||
void sleep; // retained as an injectable seam for future inline retry
|
||||
} else if (quota.retryable) {
|
||||
const backoff = computeBackoffMs(state.backoffAttempt);
|
||||
state.cooldownUntil = now + backoff;
|
||||
state.backoffAttempt += 1;
|
||||
}
|
||||
|
||||
// Serve last good row on failure; omit if we never succeeded.
|
||||
return serveCached(state);
|
||||
} catch {
|
||||
// Network/parse rejection -> treat as transient, serve stale.
|
||||
const backoff = computeBackoffMs(state.backoffAttempt);
|
||||
state.cooldownUntil = now + backoff;
|
||||
state.backoffAttempt += 1;
|
||||
return serveCached(state);
|
||||
} finally {
|
||||
state.pending = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return state.pending;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Codex path (local read, no network guards needed)
|
||||
// ============================================================================
|
||||
|
||||
async function collectCodexRow(deps: NativeQuotaDeps): Promise<BarSummaryRow | null> {
|
||||
const now = (deps.now ?? Date.now)();
|
||||
const getCodex = deps.getCodexQuota ?? getCodexLocalQuota;
|
||||
try {
|
||||
const quota = await getCodex();
|
||||
if (!quota) return null; // exec-mode / no rate_limits -> omit the row
|
||||
return buildCodexRow(quota, now);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Public entry point
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build the native subscription rows (Claude Code + Codex) for /summary.
|
||||
*
|
||||
* Each path is independently try/caught so one failing source never blocks the
|
||||
* other or the response. Returns only rows that represent real data.
|
||||
*/
|
||||
export async function getNativeAccountRows(deps: NativeQuotaDeps = {}): Promise<BarSummaryRow[]> {
|
||||
const [claude, codex] = await Promise.all([
|
||||
collectClaudeRow(deps).catch(() => null),
|
||||
collectCodexRow(deps).catch(() => null),
|
||||
]);
|
||||
|
||||
const rows: BarSummaryRow[] = [];
|
||||
if (claude) rows.push(claude);
|
||||
if (codex) rows.push(codex);
|
||||
return rows;
|
||||
}
|
||||
@@ -29,6 +29,8 @@ export interface DailyUsage {
|
||||
date: string;
|
||||
/** Stable CCS profile name when the source can be attributed to one. */
|
||||
profile?: string;
|
||||
/** Account email/id when the source can be attributed to a specific CLIProxy account. */
|
||||
accountId?: string;
|
||||
source: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* Phase 2: quota-manager tier_lock selection tests
|
||||
*
|
||||
* Load-bearing requirement: when manual.tier_lock is set in config,
|
||||
* findHealthyAccount must only return accounts matching that tier.
|
||||
* Clearing tier_lock (null) restores normal tier-priority selection.
|
||||
* Existing failover behavior must be unaffected.
|
||||
*
|
||||
* MEDIUM #1 coverage: tier_lock is per-provider (Record<provider, tier|null>).
|
||||
* Locking provider "agy" to "ultra" must NOT affect another provider's accounts.
|
||||
*
|
||||
* Strategy:
|
||||
* - _mockConfig is an in-memory object; each test mutates it to set the desired
|
||||
* tier_lock, then imports a fresh quota-manager instance via a cache-busting
|
||||
* query string. No disk writes, no process.env.CCS_HOME races.
|
||||
* - mock.module for account-manager and account-safety (already used by prior
|
||||
* tests in this file) cover the full transitive dependency surface.
|
||||
* - config-loader-facade is NOT mocked here — quota-manager reads config via
|
||||
* loadOrCreateUnifiedConfig which in turn reads CCS_HOME from env. We
|
||||
* write the minimal config.yaml once per test via writeMinimalConfig() to
|
||||
* a dedicated temp dir set to CCS_HOME before each test. This gives full
|
||||
* control without touching the facade mock surface.
|
||||
*
|
||||
* Note on isolation: Bun runs each test FILE in its own worker process, so
|
||||
* process.env.CCS_HOME is NOT shared across test files. The previous 2-test
|
||||
* failure ("clearing tier_lock (null)" and "no tier_lock") was caused by
|
||||
* within-file state leakage: an earlier test wrote {agy:'ultra',claude:'pro'}
|
||||
* to disk, and invalidateConfigCache() only cleared the facade's memoisation
|
||||
* cache — loadOrCreateUnifiedConfig still read the stale disk state. The fix
|
||||
* is to explicitly re-write the config file in every test that needs null-lock.
|
||||
*/
|
||||
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { invalidateConfigCache } from '../../../src/config/config-loader-facade';
|
||||
|
||||
// ============================================================================
|
||||
// Account fixtures
|
||||
// ============================================================================
|
||||
|
||||
const ULTRA_ACCOUNT = { id: 'ultra-acc-1', tier: 'ultra', email: 'ultra@example.com' };
|
||||
const PRO_ACCOUNT = { id: 'pro-acc-1', tier: 'pro', email: 'pro@example.com' };
|
||||
const PRO_ACCOUNT_2 = { id: 'pro-acc-2', tier: 'pro', email: 'pro2@example.com' };
|
||||
|
||||
/** Quota objects match calculateAgyQuotaPercent shape. */
|
||||
const HEALTHY_QUOTA = { success: true, models: [{ percentage: 80 }] };
|
||||
const EXHAUSTED_QUOTA = { success: true, models: [{ percentage: 2 }] };
|
||||
|
||||
// ============================================================================
|
||||
// Mutable mock state
|
||||
// ============================================================================
|
||||
|
||||
let _mockAccounts: (typeof ULTRA_ACCOUNT)[] = [];
|
||||
let _mockPausedIds: Set<string> = new Set();
|
||||
|
||||
// ============================================================================
|
||||
// Top-level mock.module registrations (file-load time)
|
||||
// ============================================================================
|
||||
|
||||
mock.module('../../../src/cliproxy/accounts/account-manager', () => ({
|
||||
PROVIDERS_WITHOUT_EMAIL: [],
|
||||
getAccountsRegistryPath: () => '',
|
||||
getPausedDir: () => '',
|
||||
getAccountTokenPath: () => '',
|
||||
extractAccountIdFromTokenFile: () => '',
|
||||
deriveNoEmailProviderAccountId: () => '',
|
||||
generateNickname: () => '',
|
||||
validateNickname: () => true,
|
||||
hasAccountNameConflict: () => false,
|
||||
findAccountNameMatch: () => null,
|
||||
tokenFileExists: () => false,
|
||||
loadAccountsRegistry: () => ({}),
|
||||
saveAccountsRegistry: () => undefined,
|
||||
syncRegistryWithTokenFiles: () => undefined,
|
||||
registerAccount: () => undefined,
|
||||
setDefaultAccount: () => undefined,
|
||||
pauseAccount: () => undefined,
|
||||
resumeAccount: () => undefined,
|
||||
removeAccount: () => undefined,
|
||||
renameAccount: () => undefined,
|
||||
touchAccount: () => undefined,
|
||||
setAccountTier: () => undefined,
|
||||
discoverExistingAccounts: () => [],
|
||||
getProviderAccounts: () => _mockAccounts,
|
||||
getDefaultAccount: () => (_mockAccounts.length > 0 ? _mockAccounts[0] : null),
|
||||
getAccount: (_p: string, id: string) => _mockAccounts.find((a) => a.id === id) ?? null,
|
||||
findAccountByQuery: () => null,
|
||||
getActiveAccounts: () => _mockAccounts,
|
||||
isAccountPaused: (_p: string, id: string) => _mockPausedIds.has(id),
|
||||
getAllAccountsSummary: () => [],
|
||||
bulkPauseAccounts: () => ({ succeeded: [], failed: [] }),
|
||||
bulkResumeAccounts: () => ({ succeeded: [], failed: [] }),
|
||||
soloAccount: async () => null,
|
||||
}));
|
||||
|
||||
mock.module('../../../src/cliproxy/accounts/account-safety', () => ({
|
||||
restoreExpiredQuotaPauses: () => undefined,
|
||||
pauseAccountForQuotaCooldown: () => false,
|
||||
}));
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Write a minimal config.yaml in tempHome/.ccs/ with the given tier_lock.
|
||||
*
|
||||
* tierLock: null means no locks active.
|
||||
* tierLock: Record means per-provider map, e.g. { agy: 'ultra' }.
|
||||
*
|
||||
* Always writes a fresh file — this is the canonical "truth" for each test.
|
||||
*/
|
||||
function writeMinimalConfig(
|
||||
tempHome: string,
|
||||
tierLock: null | Record<string, string | null>
|
||||
): void {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
let tierLockYaml: string;
|
||||
if (tierLock === null) {
|
||||
tierLockYaml = 'null';
|
||||
} else {
|
||||
const entries = Object.entries(tierLock)
|
||||
.map(([provider, tier]) => ` ${provider}: ${tier === null ? 'null' : `"${tier}"`}`)
|
||||
.join('\n');
|
||||
tierLockYaml = entries.length > 0 ? `\n${entries}` : '{}';
|
||||
}
|
||||
|
||||
const yaml = `
|
||||
version: 13
|
||||
setup_completed: true
|
||||
quota_management:
|
||||
mode: hybrid
|
||||
auto:
|
||||
preflight_check: true
|
||||
exhaustion_threshold: 5
|
||||
tier_priority:
|
||||
- ultra
|
||||
- pro
|
||||
- free
|
||||
cooldown_minutes: 5
|
||||
manual:
|
||||
paused_accounts: []
|
||||
forced_default: null
|
||||
tier_lock: ${tierLockYaml}
|
||||
runtime_monitor:
|
||||
enabled: true
|
||||
normal_interval_seconds: 300
|
||||
critical_interval_seconds: 60
|
||||
warn_threshold: 20
|
||||
exhaustion_threshold: 5
|
||||
cooldown_minutes: 5
|
||||
`.trim();
|
||||
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), yaml, 'utf-8');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('quota-manager findHealthyAccount — tier_lock', () => {
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalCcsUnified: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-tier-lock-qm-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalCcsUnified = process.env.CCS_UNIFIED_CONFIG;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
process.env.CCS_UNIFIED_CONFIG = '1';
|
||||
|
||||
// Invalidate the shared config-loader-facade memoisation cache so that
|
||||
// each test reads its own freshly-written config.yaml from disk.
|
||||
invalidateConfigCache();
|
||||
|
||||
// Reset mutable mock state.
|
||||
_mockAccounts = [ULTRA_ACCOUNT, PRO_ACCOUNT, PRO_ACCOUNT_2];
|
||||
_mockPausedIds = new Set();
|
||||
|
||||
// Re-register mocks in beforeEach to survive any mock.restore() calls
|
||||
// from other test suites running in this Bun process.
|
||||
mock.module('../../../src/cliproxy/accounts/account-manager', () => ({
|
||||
PROVIDERS_WITHOUT_EMAIL: [],
|
||||
getAccountsRegistryPath: () => '',
|
||||
getPausedDir: () => '',
|
||||
getAccountTokenPath: () => '',
|
||||
extractAccountIdFromTokenFile: () => '',
|
||||
deriveNoEmailProviderAccountId: () => '',
|
||||
generateNickname: () => '',
|
||||
validateNickname: () => true,
|
||||
hasAccountNameConflict: () => false,
|
||||
findAccountNameMatch: () => null,
|
||||
tokenFileExists: () => false,
|
||||
loadAccountsRegistry: () => ({}),
|
||||
saveAccountsRegistry: () => undefined,
|
||||
syncRegistryWithTokenFiles: () => undefined,
|
||||
registerAccount: () => undefined,
|
||||
setDefaultAccount: () => undefined,
|
||||
pauseAccount: () => undefined,
|
||||
resumeAccount: () => undefined,
|
||||
removeAccount: () => undefined,
|
||||
renameAccount: () => undefined,
|
||||
touchAccount: () => undefined,
|
||||
setAccountTier: () => undefined,
|
||||
discoverExistingAccounts: () => [],
|
||||
getProviderAccounts: () => _mockAccounts,
|
||||
getDefaultAccount: () => (_mockAccounts.length > 0 ? _mockAccounts[0] : null),
|
||||
getAccount: (_p: string, id: string) => _mockAccounts.find((a) => a.id === id) ?? null,
|
||||
findAccountByQuery: () => null,
|
||||
getActiveAccounts: () => _mockAccounts,
|
||||
isAccountPaused: (_p: string, id: string) => _mockPausedIds.has(id),
|
||||
getAllAccountsSummary: () => [],
|
||||
bulkPauseAccounts: () => ({ succeeded: [], failed: [] }),
|
||||
bulkResumeAccounts: () => ({ succeeded: [], failed: [] }),
|
||||
soloAccount: async () => null,
|
||||
}));
|
||||
|
||||
mock.module('../../../src/cliproxy/accounts/account-safety', () => ({
|
||||
restoreExpiredQuotaPauses: () => undefined,
|
||||
pauseAccountForQuotaCooldown: () => false,
|
||||
}));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
if (originalCcsUnified !== undefined) process.env.CCS_UNIFIED_CONFIG = originalCcsUnified;
|
||||
else delete process.env.CCS_UNIFIED_CONFIG;
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// tier_lock = { agy: "pro" } — ultra must be excluded
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('tier_lock="pro" — only pro accounts are candidates (ultra excluded)', async () => {
|
||||
writeMinimalConfig(tempHome, { agy: 'pro' });
|
||||
_mockAccounts = [ULTRA_ACCOUNT, PRO_ACCOUNT];
|
||||
|
||||
const uid = `lock-pro-${Date.now()}-${Math.random()}`;
|
||||
const { findHealthyAccount, setCachedQuota } = await import(
|
||||
`../../../src/cliproxy/quota/quota-manager?${uid}`
|
||||
);
|
||||
|
||||
setCachedQuota('agy', ULTRA_ACCOUNT.id, HEALTHY_QUOTA as never);
|
||||
setCachedQuota('agy', PRO_ACCOUNT.id, HEALTHY_QUOTA as never);
|
||||
|
||||
const result = await findHealthyAccount('agy', []);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.tier).toBe('pro');
|
||||
expect(result!.id).toBe(PRO_ACCOUNT.id);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// tier_lock specifies a tier with zero matching accounts
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('tier_lock="ultra" with only pro accounts → returns null (no cross-tier fallback)', async () => {
|
||||
writeMinimalConfig(tempHome, { agy: 'ultra' });
|
||||
_mockAccounts = [PRO_ACCOUNT, PRO_ACCOUNT_2];
|
||||
|
||||
const uid = `lock-ultra-no-ultra-${Date.now()}-${Math.random()}`;
|
||||
const { findHealthyAccount, setCachedQuota } = await import(
|
||||
`../../../src/cliproxy/quota/quota-manager?${uid}`
|
||||
);
|
||||
|
||||
setCachedQuota('agy', PRO_ACCOUNT.id, HEALTHY_QUOTA as never);
|
||||
setCachedQuota('agy', PRO_ACCOUNT_2.id, HEALTHY_QUOTA as never);
|
||||
|
||||
const result = await findHealthyAccount('agy', []);
|
||||
// tier_lock strictly enforced — no ultra accounts → null
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// tier_lock respects existing paused/exclude filters within locked tier
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('tier_lock="pro" — paused pro accounts are still excluded', async () => {
|
||||
writeMinimalConfig(tempHome, { agy: 'pro' });
|
||||
_mockAccounts = [PRO_ACCOUNT, PRO_ACCOUNT_2];
|
||||
_mockPausedIds = new Set([PRO_ACCOUNT.id]);
|
||||
|
||||
const uid = `lock-pro-paused-${Date.now()}-${Math.random()}`;
|
||||
const { findHealthyAccount, setCachedQuota } = await import(
|
||||
`../../../src/cliproxy/quota/quota-manager?${uid}`
|
||||
);
|
||||
|
||||
setCachedQuota('agy', PRO_ACCOUNT.id, HEALTHY_QUOTA as never);
|
||||
setCachedQuota('agy', PRO_ACCOUNT_2.id, HEALTHY_QUOTA as never);
|
||||
|
||||
const result = await findHealthyAccount('agy', []);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe(PRO_ACCOUNT_2.id);
|
||||
});
|
||||
|
||||
it('tier_lock="pro" — exclude list still removes accounts within the locked tier', async () => {
|
||||
writeMinimalConfig(tempHome, { agy: 'pro' });
|
||||
_mockAccounts = [PRO_ACCOUNT, PRO_ACCOUNT_2];
|
||||
|
||||
const uid = `lock-pro-exclude-${Date.now()}-${Math.random()}`;
|
||||
const { findHealthyAccount, setCachedQuota } = await import(
|
||||
`../../../src/cliproxy/quota/quota-manager?${uid}`
|
||||
);
|
||||
|
||||
setCachedQuota('agy', PRO_ACCOUNT.id, HEALTHY_QUOTA as never);
|
||||
setCachedQuota('agy', PRO_ACCOUNT_2.id, HEALTHY_QUOTA as never);
|
||||
|
||||
const result = await findHealthyAccount('agy', [PRO_ACCOUNT.id]);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe(PRO_ACCOUNT_2.id);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Clearing tier_lock restores prior behavior
|
||||
//
|
||||
// Key: writeMinimalConfig(tempHome, null) is called immediately before
|
||||
// findHealthyAccount, AFTER setting _mockAccounts. This ensures the disk
|
||||
// state is null even if a prior test in this file left stale content.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('clearing tier_lock (null) allows both tiers as candidates again', async () => {
|
||||
_mockAccounts = [PRO_ACCOUNT];
|
||||
// Write null-lock config as the very last disk op before the import so no
|
||||
// within-file test ordering can leave stale { agy: ... } on disk.
|
||||
writeMinimalConfig(tempHome, null);
|
||||
invalidateConfigCache();
|
||||
|
||||
const uid = `lock-cleared-${Date.now()}-${Math.random()}`;
|
||||
const { findHealthyAccount, setCachedQuota } = await import(
|
||||
`../../../src/cliproxy/quota/quota-manager?${uid}`
|
||||
);
|
||||
|
||||
setCachedQuota('agy', PRO_ACCOUNT.id, HEALTHY_QUOTA as never);
|
||||
|
||||
const result = await findHealthyAccount('agy', []);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe(PRO_ACCOUNT.id);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Exhausted locked-tier accounts: no cross-tier fallback
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('tier_lock="pro" — exhausted pro + healthy ultra → returns null (no cross-tier fallback)', async () => {
|
||||
writeMinimalConfig(tempHome, { agy: 'pro' });
|
||||
_mockAccounts = [ULTRA_ACCOUNT, PRO_ACCOUNT];
|
||||
|
||||
const uid = `lock-pro-exhausted-${Date.now()}-${Math.random()}`;
|
||||
const { findHealthyAccount, setCachedQuota } = await import(
|
||||
`../../../src/cliproxy/quota/quota-manager?${uid}`
|
||||
);
|
||||
|
||||
// Pro is exhausted (2%), ultra is healthy (80%) — tier_lock must block ultra
|
||||
setCachedQuota('agy', PRO_ACCOUNT.id, EXHAUSTED_QUOTA as never);
|
||||
setCachedQuota('agy', ULTRA_ACCOUNT.id, HEALTHY_QUOTA as never);
|
||||
|
||||
const result = await findHealthyAccount('agy', []);
|
||||
// No healthy pro accounts; must NOT fall back to ultra
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Baseline: no tier_lock — any available healthy account is returned.
|
||||
//
|
||||
// Same pattern: write null-lock and invalidate cache immediately before import.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('no tier_lock — at least one account is returned (tier filter is inactive)', async () => {
|
||||
_mockAccounts = [PRO_ACCOUNT];
|
||||
// Write null-lock as the very last disk op before the import.
|
||||
writeMinimalConfig(tempHome, null);
|
||||
invalidateConfigCache();
|
||||
|
||||
const uid = `no-lock-${Date.now()}-${Math.random()}`;
|
||||
const { findHealthyAccount, setCachedQuota } = await import(
|
||||
`../../../src/cliproxy/quota/quota-manager?${uid}`
|
||||
);
|
||||
|
||||
setCachedQuota('agy', PRO_ACCOUNT.id, HEALTHY_QUOTA as never);
|
||||
|
||||
const result = await findHealthyAccount('agy', []);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.tier).toBe('pro');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// MEDIUM #1: cross-provider isolation
|
||||
// Locking "agy" to "ultra" must NOT filter accounts for an unlocked provider.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('tier_lock on agy does NOT affect another provider — pro accounts are still selectable for unlocked provider', async () => {
|
||||
writeMinimalConfig(tempHome, { agy: 'ultra' });
|
||||
// Only pro accounts — agy locked to ultra means no result.
|
||||
_mockAccounts = [PRO_ACCOUNT, PRO_ACCOUNT_2];
|
||||
|
||||
const uid = `cross-provider-${Date.now()}-${Math.random()}`;
|
||||
const { findHealthyAccount, setCachedQuota } = await import(
|
||||
`../../../src/cliproxy/quota/quota-manager?${uid}`
|
||||
);
|
||||
|
||||
setCachedQuota('agy', PRO_ACCOUNT.id, HEALTHY_QUOTA as never);
|
||||
setCachedQuota('agy', PRO_ACCOUNT_2.id, HEALTHY_QUOTA as never);
|
||||
|
||||
// agy IS locked to ultra → no ultra accounts → null
|
||||
const agyLocked = await findHealthyAccount('agy', []);
|
||||
expect(agyLocked).toBeNull();
|
||||
|
||||
// Prove isolation: rewrite config with lock on a different key only.
|
||||
// getTierLockForProvider(config.manual, 'agy') returns null when 'agy' is
|
||||
// absent from the map — pro accounts become candidates again.
|
||||
writeMinimalConfig(tempHome, { claude: 'ultra' }); // only claude locked, not agy
|
||||
invalidateConfigCache();
|
||||
|
||||
// agy has no lock entry → pro accounts are candidates
|
||||
const agyUnlocked = await findHealthyAccount('agy', []);
|
||||
expect(agyUnlocked).not.toBeNull();
|
||||
expect(agyUnlocked!.tier).toBe('pro');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Security gate for /api/bar/* — these endpoints expose the user's native
|
||||
* quota, tier, and cost snapshot, so unlike the rest of the read API they must
|
||||
* be refused for non-loopback callers when dashboard auth is disabled.
|
||||
*
|
||||
* The gate lives in the top-level apiRoutes middleware (one choke point), so we
|
||||
* exercise it by mounting the real apiRoutes and toggling auth via env, mirroring
|
||||
* api-routes-remote-write-guard.test.ts.
|
||||
*/
|
||||
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import bcrypt from 'bcrypt';
|
||||
import express from 'express';
|
||||
import type { Server } from 'http';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { apiRoutes } from '../../../src/web-server/routes';
|
||||
import {
|
||||
authMiddleware,
|
||||
createSessionMiddleware,
|
||||
} from '../../../src/web-server/middleware/auth-middleware';
|
||||
|
||||
const BAR_LOCAL_ACCESS_ERROR =
|
||||
'CCS Bar endpoints require localhost access when dashboard auth is disabled.';
|
||||
|
||||
describe('api-routes /api/bar/* local-access guard', () => {
|
||||
let server: Server;
|
||||
let baseUrl = '';
|
||||
let forcedRemoteAddress = '127.0.0.1';
|
||||
let tempHome = '';
|
||||
let originalDashboardAuthEnabled: string | undefined;
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalCodexHome: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
Object.defineProperty(req.socket, 'remoteAddress', {
|
||||
value: forcedRemoteAddress,
|
||||
configurable: true,
|
||||
});
|
||||
next();
|
||||
});
|
||||
app.use('/api', apiRoutes);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server = app.listen(0, '127.0.0.1');
|
||||
server.once('error', reject);
|
||||
server.once('listening', () => resolve());
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Unable to resolve test server port');
|
||||
}
|
||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED;
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalCodexHome = process.env.CODEX_HOME;
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-api-routes-bar-guard-'));
|
||||
process.env.CCS_HOME = tempHome;
|
||||
process.env.CODEX_HOME = path.join(tempHome, '.codex');
|
||||
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false';
|
||||
forcedRemoteAddress = '192.168.2.50';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalDashboardAuthEnabled !== undefined) {
|
||||
process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled;
|
||||
} else {
|
||||
delete process.env.CCS_DASHBOARD_AUTH_ENABLED;
|
||||
}
|
||||
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
|
||||
if (originalCodexHome !== undefined) {
|
||||
process.env.CODEX_HOME = originalCodexHome;
|
||||
} else {
|
||||
delete process.env.CODEX_HOME;
|
||||
}
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
tempHome = '';
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a non-loopback GET /api/bar/summary when dashboard auth is disabled', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/bar/summary`);
|
||||
|
||||
// 403 from the gate means the bar handler never ran (no quota/cost data
|
||||
// loaded) — the body is the gate error, not a summary array.
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ error: BAR_LOCAL_ACCESS_ERROR });
|
||||
});
|
||||
|
||||
it('rejects a non-loopback GET /api/bar/analytics when dashboard auth is disabled', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/bar/analytics`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ error: BAR_LOCAL_ACCESS_ERROR });
|
||||
});
|
||||
|
||||
it('allows a loopback GET /api/bar/summary when dashboard auth is disabled', async () => {
|
||||
forcedRemoteAddress = '127.0.0.1';
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/bar/summary`, {
|
||||
headers: { Host: '127.0.0.1' },
|
||||
});
|
||||
|
||||
// Loopback passes the gate; the real handler degrades gracefully against an
|
||||
// empty temp CCS_HOME and returns a 200 array.
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(await response.json())).toBe(true);
|
||||
});
|
||||
|
||||
it('allows a non-loopback GET /api/bar/summary when dashboard auth is ENABLED', async () => {
|
||||
// With auth enabled the helper returns true regardless of peer address, so
|
||||
// an authenticated remote dashboard keeps working. We log in to get a session
|
||||
// cookie, then a remote (non-loopback) GET must pass.
|
||||
const password = 'testpassword123';
|
||||
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'true';
|
||||
process.env.CCS_DASHBOARD_USERNAME = 'admin';
|
||||
process.env.CCS_DASHBOARD_PASSWORD_HASH = await bcrypt.hash(password, 4);
|
||||
|
||||
const authApp = express();
|
||||
authApp.use(express.json());
|
||||
authApp.use((req, _res, next) => {
|
||||
Object.defineProperty(req.socket, 'remoteAddress', {
|
||||
value: '203.0.113.7',
|
||||
configurable: true,
|
||||
});
|
||||
next();
|
||||
});
|
||||
authApp.use(createSessionMiddleware());
|
||||
authApp.use(authMiddleware);
|
||||
authApp.use('/api', apiRoutes);
|
||||
|
||||
const authServer = await new Promise<Server>((resolve, reject) => {
|
||||
const instance = authApp.listen(0, '127.0.0.1');
|
||||
instance.once('error', reject);
|
||||
instance.once('listening', () => resolve(instance));
|
||||
});
|
||||
|
||||
try {
|
||||
const address = authServer.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Unable to resolve auth-enabled test server port');
|
||||
}
|
||||
const authBaseUrl = `http://127.0.0.1:${address.port}`;
|
||||
|
||||
const loginResponse = await fetch(`${authBaseUrl}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: 'admin', password }),
|
||||
});
|
||||
const cookie = loginResponse.headers.get('set-cookie');
|
||||
expect(loginResponse.status).toBe(200);
|
||||
expect(cookie).toBeTruthy();
|
||||
|
||||
const response = await fetch(`${authBaseUrl}/api/bar/summary`, {
|
||||
headers: { Cookie: cookie as string },
|
||||
});
|
||||
|
||||
// Not the gate's 403 — auth-enabled bypasses the localhost requirement.
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(await response.json())).toBe(true);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => authServer.close(() => resolve()));
|
||||
delete process.env.CCS_DASHBOARD_USERNAME;
|
||||
delete process.env.CCS_DASHBOARD_PASSWORD_HASH;
|
||||
}
|
||||
}, 15000);
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import {
|
||||
computeBarAnalytics,
|
||||
computeBarAnalyticsFromDaily,
|
||||
} from '../../../src/web-server/usage/bar-analytics';
|
||||
import type { CliproxyUsageHistoryDetail } from '../../../src/web-server/usage/cliproxy-usage-transformer';
|
||||
import type { DailyUsage, HourlyUsage } from '../../../src/web-server/usage/types';
|
||||
|
||||
const NOW = new Date('2026-06-08T12:00:00-04:00');
|
||||
|
||||
function detail(over: Partial<CliproxyUsageHistoryDetail>): CliproxyUsageHistoryDetail {
|
||||
return {
|
||||
model: 'gpt-5.5',
|
||||
timestamp: NOW.toISOString(),
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheReadTokens: 0,
|
||||
requestCount: 1,
|
||||
cost: 1,
|
||||
failed: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build an ISO timestamp `n` whole days before NOW (local). */
|
||||
function daysAgo(n: number): string {
|
||||
const d = new Date(NOW.getFullYear(), NOW.getMonth(), NOW.getDate() - n, 10, 0, 0);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
describe('computeBarAnalytics', () => {
|
||||
it('returns an empty/zeroed payload for no details', () => {
|
||||
const a = computeBarAnalytics([], NOW);
|
||||
expect(a.today.cost).toBe(0);
|
||||
expect(a.allTime.cost).toBe(0);
|
||||
expect(a.byDay).toHaveLength(30);
|
||||
expect(a.topModels).toHaveLength(0);
|
||||
expect(a.topModelsWindow).toBe('all');
|
||||
// No usable records → no last-activity signal, not stale-but-present.
|
||||
expect(a.lastActivityAt).toBeNull();
|
||||
expect(a.daysSinceLastActivity).toBeNull();
|
||||
expect(a.hasRecentData).toBe(false);
|
||||
});
|
||||
|
||||
it('rolls today / 7d / 30d / allTime into the right windows', () => {
|
||||
const a = computeBarAnalytics(
|
||||
[
|
||||
detail({ timestamp: daysAgo(0), cost: 2, requestCount: 1 }), // today
|
||||
detail({ timestamp: daysAgo(3), cost: 3, requestCount: 2 }), // 7d + 30d
|
||||
detail({ timestamp: daysAgo(20), cost: 5, requestCount: 1 }), // 30d only
|
||||
detail({ timestamp: daysAgo(90), cost: 10, requestCount: 4 }), // allTime only
|
||||
],
|
||||
NOW
|
||||
);
|
||||
expect(a.today.cost).toBe(2);
|
||||
expect(a.last7d.cost).toBe(5); // 2 + 3
|
||||
expect(a.last30d.cost).toBe(10); // 2 + 3 + 5
|
||||
expect(a.allTime.cost).toBe(20); // + 10
|
||||
expect(a.allTime.requests).toBe(8);
|
||||
});
|
||||
|
||||
it('excludes failed requests from spend', () => {
|
||||
const a = computeBarAnalytics(
|
||||
[detail({ cost: 9, failed: true }), detail({ cost: 1, failed: false })],
|
||||
NOW
|
||||
);
|
||||
expect(a.today.cost).toBe(1);
|
||||
expect(a.allTime.cost).toBe(1);
|
||||
});
|
||||
|
||||
it('zero-fills the 30-day sparkline in chronological order', () => {
|
||||
const a = computeBarAnalytics([detail({ timestamp: daysAgo(2), cost: 4 })], NOW);
|
||||
expect(a.byDay).toHaveLength(30);
|
||||
// oldest first, newest last
|
||||
expect(a.byDay[0].date < a.byDay[29].date).toBe(true);
|
||||
const hit = a.byDay.find((d) => d.cost > 0);
|
||||
expect(hit?.cost).toBe(4);
|
||||
});
|
||||
|
||||
it('populates sparkline days 8..30 from records older than the 7-day window', () => {
|
||||
// A record 20 days ago is outside last7d but inside the 30-day sparkline:
|
||||
// the bucket must fill so the chart isn't flat when only old data exists.
|
||||
const a = computeBarAnalytics([detail({ timestamp: daysAgo(20), cost: 6 })], NOW);
|
||||
expect(a.last7d.cost).toBe(0); // 7-day window math unchanged
|
||||
const hit = a.byDay.find((d) => d.cost > 0);
|
||||
expect(hit?.cost).toBe(6);
|
||||
});
|
||||
|
||||
it('reports last-activity and hasRecentData from the freshest non-failed record', () => {
|
||||
const recent = daysAgo(1);
|
||||
const a = computeBarAnalytics(
|
||||
[
|
||||
detail({ timestamp: daysAgo(5), cost: 1 }),
|
||||
detail({ timestamp: recent, cost: 2 }),
|
||||
// failed record must NOT count as activity even though it's newer
|
||||
detail({ timestamp: daysAgo(0), cost: 9, failed: true }),
|
||||
],
|
||||
NOW
|
||||
);
|
||||
expect(a.lastActivityAt).toBe(recent);
|
||||
expect(a.daysSinceLastActivity).toBe(1);
|
||||
expect(a.hasRecentData).toBe(true);
|
||||
});
|
||||
|
||||
it('reports hasRecentData false and last-activity from old data when the 30-day window is idle', () => {
|
||||
const old = daysAgo(45);
|
||||
const a = computeBarAnalytics([detail({ timestamp: old, cost: 5 })], NOW);
|
||||
expect(a.hasRecentData).toBe(false);
|
||||
expect(a.lastActivityAt).toBe(old);
|
||||
expect(a.daysSinceLastActivity).toBe(45);
|
||||
});
|
||||
|
||||
it('ranks top models by spend and labels the window 30d when recent data exists', () => {
|
||||
const a = computeBarAnalytics(
|
||||
[
|
||||
detail({ model: 'gpt-5.4', timestamp: daysAgo(1), cost: 5 }),
|
||||
detail({ model: 'gpt-5.5', timestamp: daysAgo(1), cost: 8 }),
|
||||
detail({ model: 'gpt-5.4', timestamp: daysAgo(2), cost: 2 }),
|
||||
],
|
||||
NOW
|
||||
);
|
||||
expect(a.topModelsWindow).toBe('30d');
|
||||
expect(a.topModels[0].model).toBe('gpt-5.5'); // 8
|
||||
expect(a.topModels[1].model).toBe('gpt-5.4'); // 7
|
||||
});
|
||||
|
||||
it('falls back to all-time top models when the last 30 days are idle', () => {
|
||||
const a = computeBarAnalytics(
|
||||
[
|
||||
detail({ model: 'gpt-5.4', timestamp: daysAgo(60), cost: 100 }),
|
||||
detail({ model: 'gpt-5.5', timestamp: daysAgo(45), cost: 40 }),
|
||||
],
|
||||
NOW
|
||||
);
|
||||
expect(a.last30d.cost).toBe(0);
|
||||
expect(a.topModelsWindow).toBe('all');
|
||||
expect(a.topModels[0].model).toBe('gpt-5.4');
|
||||
});
|
||||
|
||||
it('sums monthToDate from only current-calendar-month records, even when prior-month data is inside the rolling 30d', () => {
|
||||
// NOW is 2026-06-08. A 2026-05-25 record is 14 days ago: inside last30d but
|
||||
// in the PRIOR calendar month, so it must NOT count toward June MTD.
|
||||
const a = computeBarAnalytics(
|
||||
[
|
||||
detail({ timestamp: '2026-06-02T10:00:00-04:00', cost: 3, requestCount: 2 }), // June
|
||||
detail({ timestamp: '2026-06-08T09:00:00-04:00', cost: 4, requestCount: 1 }), // June (today)
|
||||
detail({ timestamp: '2026-05-25T10:00:00-04:00', cost: 5, requestCount: 9 }), // May, within 30d
|
||||
],
|
||||
NOW
|
||||
);
|
||||
expect(a.monthToDate.cost).toBe(7); // 3 + 4, May excluded
|
||||
expect(a.monthToDate.requests).toBe(3); // 2 + 1
|
||||
// last30d still includes the May record — proves MTD is a distinct window.
|
||||
expect(a.last30d.cost).toBe(12);
|
||||
});
|
||||
|
||||
it('returns zeroed monthToDate for no details', () => {
|
||||
const a = computeBarAnalytics([], NOW);
|
||||
expect(a.monthToDate).toEqual({ cost: 0, requests: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
function daily(over: Partial<DailyUsage>): DailyUsage {
|
||||
return {
|
||||
date: '2026-06-08',
|
||||
source: 'cliproxy',
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cost: 0,
|
||||
totalCost: 0,
|
||||
modelsUsed: [],
|
||||
modelBreakdowns: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function hourly(over: Partial<HourlyUsage>): HourlyUsage {
|
||||
return {
|
||||
hour: '2026-06-08 10:00',
|
||||
source: 'cliproxy',
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cost: 0,
|
||||
totalCost: 0,
|
||||
modelsUsed: [],
|
||||
modelBreakdowns: [],
|
||||
requestCount: 0,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('computeBarAnalyticsFromDaily — monthToDate', () => {
|
||||
it('sums monthToDate cost (daily) and requests (hourly) for only the current calendar month', () => {
|
||||
const a = computeBarAnalyticsFromDaily(
|
||||
[
|
||||
daily({ date: '2026-06-02', totalCost: 10 }), // June
|
||||
daily({ date: '2026-06-08', totalCost: 4 }), // June (today)
|
||||
daily({ date: '2026-05-25', totalCost: 7 }), // May, still within 30d
|
||||
],
|
||||
[
|
||||
hourly({ hour: '2026-06-02 10:00', requestCount: 5 }), // June
|
||||
hourly({ hour: '2026-06-08 09:00', requestCount: 3 }), // June
|
||||
hourly({ hour: '2026-05-25 10:00', requestCount: 99 }), // May
|
||||
],
|
||||
NOW
|
||||
);
|
||||
expect(a.monthToDate.cost).toBe(14); // 10 + 4, May excluded
|
||||
expect(a.monthToDate.requests).toBe(8); // 5 + 3, May excluded
|
||||
// Distinct from last30d, which still carries the prior-month May record.
|
||||
expect(a.last30d.cost).toBe(21);
|
||||
});
|
||||
|
||||
it('resets monthToDate toward 0 on a fresh-month boundary while last30d stays populated', () => {
|
||||
// Treat the 1st of the month as "now": all activity sits in the prior month,
|
||||
// so MTD must be ~0 even though those days remain inside the rolling 30d.
|
||||
const firstOfMonth = new Date('2026-06-01T08:00:00-04:00');
|
||||
const a = computeBarAnalyticsFromDaily(
|
||||
[daily({ date: '2026-05-20', totalCost: 12 }), daily({ date: '2026-05-31', totalCost: 8 })],
|
||||
[hourly({ hour: '2026-05-31 10:00', requestCount: 4 })],
|
||||
firstOfMonth
|
||||
);
|
||||
expect(a.monthToDate.cost).toBe(0);
|
||||
expect(a.monthToDate.requests).toBe(0);
|
||||
expect(a.last30d.cost).toBe(20); // rolling 30d still populated
|
||||
});
|
||||
|
||||
it('returns zeroed monthToDate for empty daily and hourly input', () => {
|
||||
const a = computeBarAnalyticsFromDaily([], [], NOW);
|
||||
expect(a.monthToDate).toEqual({ cost: 0, requests: 0 });
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Tests for the native Claude Code credential reader.
|
||||
*
|
||||
* All fs / Keychain access is injected, so these tests never touch the real
|
||||
* filesystem or pop a macOS Keychain prompt.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
readClaudeCredentials,
|
||||
getAccessToken,
|
||||
getSubscriptionTier,
|
||||
hasSupportedSubscription,
|
||||
type ClaudeNativeCredentials,
|
||||
} from '../../../src/web-server/usage/claude-native-credentials';
|
||||
|
||||
function makeCreds(overrides: Record<string, unknown> = {}): ClaudeNativeCredentials {
|
||||
return {
|
||||
claudeAiOauth: {
|
||||
accessToken: 'tok-abc',
|
||||
subscriptionType: 'max',
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('readClaudeCredentials', () => {
|
||||
it('parses the on-disk credentials file when present (file-first, no Keychain)', () => {
|
||||
let keychainCalled = false;
|
||||
const creds = readClaudeCredentials({
|
||||
platform: 'darwin',
|
||||
homedir: '/home/test',
|
||||
existsSyncImpl: () => true,
|
||||
readFileSyncImpl: () => JSON.stringify(makeCreds()),
|
||||
execSyncImpl: () => {
|
||||
keychainCalled = true;
|
||||
return '';
|
||||
},
|
||||
});
|
||||
expect(creds?.claudeAiOauth?.accessToken).toBe('tok-abc');
|
||||
// File present means the Keychain must NOT be consulted (avoids prompt).
|
||||
expect(keychainCalled).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the macOS Keychain when the file is absent', () => {
|
||||
const creds = readClaudeCredentials({
|
||||
platform: 'darwin',
|
||||
homedir: '/home/test',
|
||||
existsSyncImpl: () => false,
|
||||
readFileSyncImpl: () => {
|
||||
throw new Error('should not read file');
|
||||
},
|
||||
execSyncImpl: () => JSON.stringify(makeCreds({ subscriptionType: 'pro' })),
|
||||
});
|
||||
expect(creds?.claudeAiOauth?.subscriptionType).toBe('pro');
|
||||
});
|
||||
|
||||
it('returns null when both file and Keychain are absent', () => {
|
||||
const creds = readClaudeCredentials({
|
||||
platform: 'darwin',
|
||||
homedir: '/home/test',
|
||||
existsSyncImpl: () => false,
|
||||
readFileSyncImpl: () => {
|
||||
throw new Error('no file');
|
||||
},
|
||||
execSyncImpl: () => {
|
||||
throw new Error('no keychain entry');
|
||||
},
|
||||
});
|
||||
expect(creds).toBeNull();
|
||||
});
|
||||
|
||||
it('does not consult the Keychain on non-darwin platforms', () => {
|
||||
let keychainCalled = false;
|
||||
const creds = readClaudeCredentials({
|
||||
platform: 'linux',
|
||||
homedir: '/home/test',
|
||||
existsSyncImpl: () => false,
|
||||
readFileSyncImpl: () => {
|
||||
throw new Error('no file');
|
||||
},
|
||||
execSyncImpl: () => {
|
||||
keychainCalled = true;
|
||||
return '';
|
||||
},
|
||||
});
|
||||
expect(creds).toBeNull();
|
||||
expect(keychainCalled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasSupportedSubscription', () => {
|
||||
it.each(['', 'free', 'none'])('returns false for unsupported subscriptionType %p', (sub) => {
|
||||
expect(hasSupportedSubscription(makeCreds({ subscriptionType: sub }))).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['max', 'pro', 'team', 'enterprise'])(
|
||||
'returns true for supported subscriptionType %p',
|
||||
(sub) => {
|
||||
expect(hasSupportedSubscription(makeCreds({ subscriptionType: sub }))).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it('returns true via rateLimitTier regex when subscriptionType is empty', () => {
|
||||
const creds = makeCreds({ subscriptionType: '', rateLimitTier: 'claude_max_20x' });
|
||||
expect(hasSupportedSubscription(creds)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for null credentials', () => {
|
||||
expect(hasSupportedSubscription(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('token + tier extraction', () => {
|
||||
it('getAccessToken returns the token or null', () => {
|
||||
expect(getAccessToken(makeCreds())).toBe('tok-abc');
|
||||
expect(getAccessToken(makeCreds({ accessToken: '' }))).toBeNull();
|
||||
expect(getAccessToken(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('getSubscriptionTier returns the tier or null', () => {
|
||||
expect(getSubscriptionTier(makeCreds({ subscriptionType: 'max' }))).toBe('max');
|
||||
expect(getSubscriptionTier(makeCreds({ subscriptionType: '' }))).toBeNull();
|
||||
expect(getSubscriptionTier(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,10 @@ 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 type { CliproxyUsageApiResponse } from '../../../src/cliproxy/services/stats-fetcher';
|
||||
import type {
|
||||
CliproxyUsageApiResponse,
|
||||
CliproxyManagementAuthFile,
|
||||
} from '../../../src/cliproxy/services/stats-fetcher';
|
||||
import { runWithScopedConfigDir } from '../../../src/utils/config-manager';
|
||||
import {
|
||||
loadCachedCliproxyData,
|
||||
@@ -394,3 +397,108 @@ describe('cliproxy usage syncer', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Finding #3/#5: account attribution wired into syncer (accountMap passed to extractor)
|
||||
// ============================================================================
|
||||
|
||||
describe('syncCliproxyUsage — account attribution (finding #3/#5)', () => {
|
||||
let ccsDir2 = '';
|
||||
|
||||
beforeEach(() => {
|
||||
ccsDir2 = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-attr-'));
|
||||
stopCliproxySync();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stopCliproxySync();
|
||||
fs.rmSync(ccsDir2, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const TODAY = new Date().toISOString().slice(0, 10);
|
||||
|
||||
function buildResponseWithAuthIndex(authIndex: number): CliproxyUsageApiResponse {
|
||||
return {
|
||||
usage: {
|
||||
apis: {
|
||||
anthropic: {
|
||||
models: {
|
||||
'claude-sonnet-4-5': {
|
||||
details: [
|
||||
{
|
||||
timestamp: `${TODAY}T10:00:00.000Z`,
|
||||
source: 'raw-source',
|
||||
auth_index: authIndex,
|
||||
tokens: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
reasoning_tokens: 0,
|
||||
cached_tokens: 0,
|
||||
total_tokens: 1500,
|
||||
},
|
||||
failed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('persists accountId into snapshot when auth files are provided', async () => {
|
||||
const authFiles: CliproxyManagementAuthFile[] = [
|
||||
{ auth_index: 0, provider: 'anthropic', email: 'alice@example.com' },
|
||||
];
|
||||
|
||||
await runWithScopedConfigDir(ccsDir2, async () => {
|
||||
await syncCliproxyUsage(
|
||||
() => Promise.resolve(buildResponseWithAuthIndex(0)),
|
||||
() => Promise.resolve(authFiles)
|
||||
);
|
||||
});
|
||||
|
||||
const snapshotPath = path.join(ccsDir2, 'cache', 'cliproxy-usage', 'latest.json');
|
||||
const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8')) as {
|
||||
details: Array<{ accountId?: string }>;
|
||||
};
|
||||
|
||||
expect(snapshot.details[0].accountId).toBe('alice@example.com');
|
||||
});
|
||||
|
||||
it('falls back gracefully when auth-files fetch returns null (no throw, no accountId)', async () => {
|
||||
await runWithScopedConfigDir(ccsDir2, async () => {
|
||||
await syncCliproxyUsage(
|
||||
() => Promise.resolve(buildResponseWithAuthIndex(0)),
|
||||
() => Promise.resolve(null)
|
||||
);
|
||||
});
|
||||
|
||||
const snapshotPath = path.join(ccsDir2, 'cache', 'cliproxy-usage', 'latest.json');
|
||||
expect(fs.existsSync(snapshotPath)).toBe(true);
|
||||
|
||||
const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8')) as {
|
||||
details: Array<{ accountId?: string }>;
|
||||
};
|
||||
// accountId must be absent (not populated) when auth files unavailable
|
||||
expect(snapshot.details[0].accountId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back gracefully when auth-files fetch throws (no throw, no accountId)', async () => {
|
||||
await runWithScopedConfigDir(ccsDir2, async () => {
|
||||
await syncCliproxyUsage(
|
||||
() => Promise.resolve(buildResponseWithAuthIndex(0)),
|
||||
() => Promise.reject(new Error('network error'))
|
||||
);
|
||||
});
|
||||
|
||||
const snapshotPath = path.join(ccsDir2, 'cache', 'cliproxy-usage', 'latest.json');
|
||||
expect(fs.existsSync(snapshotPath)).toBe(true);
|
||||
|
||||
const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8')) as {
|
||||
details: Array<{ accountId?: string }>;
|
||||
};
|
||||
expect(snapshot.details[0].accountId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Tests for the Codex local quota collector (zero network).
|
||||
*
|
||||
* Uses a temp fixture rollout-*.jsonl read via the real Bun.spawn(['tail', ...])
|
||||
* default impl (macOS-safe) plus injected fs seams for the directory walk.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { getCodexLocalQuota } from '../../../src/web-server/usage/codex-local-quota-collector';
|
||||
|
||||
let tmpDir: string;
|
||||
let codexHome: string;
|
||||
let sessionsDir: string;
|
||||
|
||||
function tokenCountLine(rateLimits: unknown): string {
|
||||
return JSON.stringify({
|
||||
timestamp: '2026-06-09T14:36:48.896Z',
|
||||
type: 'event_msg',
|
||||
payload: { type: 'token_count', info: {}, rate_limits: rateLimits },
|
||||
});
|
||||
}
|
||||
|
||||
function writeRollout(sessions: string, name: string, lines: string[]): string {
|
||||
const day = path.join(sessions, '2026', '06', '09');
|
||||
fs.mkdirSync(day, { recursive: true });
|
||||
const file = path.join(day, name);
|
||||
fs.writeFileSync(file, lines.join('\n') + '\n');
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Each test gets its OWN codex home so the multi-session scan never bleeds a
|
||||
* fixture from another test (the scan walks ALL recent sessions, not just one).
|
||||
*/
|
||||
function freshHome(slug: string): { env: NodeJS.ProcessEnv; sessions: string } {
|
||||
const home = path.join(tmpDir, `.codex-${slug}`);
|
||||
const sessions = path.join(home, 'sessions');
|
||||
fs.mkdirSync(sessions, { recursive: true });
|
||||
return { env: { CODEX_HOME: home }, sessions };
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-local-quota-'));
|
||||
codexHome = path.join(tmpDir, '.codex');
|
||||
sessionsDir = path.join(codexHome, 'sessions');
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('getCodexLocalQuota', () => {
|
||||
it('parses the last non-null rate_limits into a normalized quota', async () => {
|
||||
const { env, sessions } = freshHome('parse');
|
||||
writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.jsonl', [
|
||||
tokenCountLine(null),
|
||||
tokenCountLine({
|
||||
primary: { used_percent: 0.0, window_minutes: 300, resets_at: 1781033803 },
|
||||
secondary: { used_percent: 48.0, window_minutes: 10080, resets_at: 1781192122 },
|
||||
plan_type: 'pro',
|
||||
}),
|
||||
]);
|
||||
|
||||
const quota = await getCodexLocalQuota({ env, now: Date.now() });
|
||||
expect(quota).not.toBeNull();
|
||||
// min(100-0, 100-48) = 52
|
||||
expect(quota?.quotaPercentage).toBe(52);
|
||||
expect(quota?.tier).toBe('pro');
|
||||
// soonest reset = min(1781033803, 1781192122) -> primary
|
||||
expect(quota?.nextReset).toBe(new Date(1781033803 * 1000).toISOString());
|
||||
expect(quota?.stale).toBe(false);
|
||||
expect(quota?.staleAsOf).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces per-window detail incl. window_minutes (300 / 10080)', async () => {
|
||||
const { env, sessions } = freshHome('windows');
|
||||
writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.jsonl', [
|
||||
tokenCountLine({
|
||||
primary: { used_percent: 19.0, window_minutes: 300, resets_at: 1781033803 },
|
||||
secondary: { used_percent: 30.0, window_minutes: 10080, resets_at: 1781192122 },
|
||||
plan_type: 'pro',
|
||||
}),
|
||||
]);
|
||||
|
||||
const quota = await getCodexLocalQuota({ env, now: Date.now() });
|
||||
expect(quota?.windows).toHaveLength(2);
|
||||
|
||||
const five = quota?.windows.find((w) => w.key === 'five_hour');
|
||||
expect(five?.label).toBe('5h');
|
||||
expect(five?.usedPercent).toBe(19);
|
||||
expect(five?.remainingPercent).toBe(81);
|
||||
expect(five?.windowMinutes).toBe(300);
|
||||
expect(five?.resetAt).toBe(new Date(1781033803 * 1000).toISOString());
|
||||
|
||||
const week = quota?.windows.find((w) => w.key === 'seven_day');
|
||||
expect(week?.label).toBe('week');
|
||||
expect(week?.usedPercent).toBe(30);
|
||||
expect(week?.remainingPercent).toBe(70);
|
||||
expect(week?.windowMinutes).toBe(10080);
|
||||
expect(week?.resetAt).toBe(new Date(1781192122 * 1000).toISOString());
|
||||
});
|
||||
|
||||
it('scans an OLDER session when the newest is exec-mode (rate_limits:null)', async () => {
|
||||
const { env, sessions } = freshHome('fallback');
|
||||
// Older interactive session carries real quota.
|
||||
writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.jsonl', [
|
||||
tokenCountLine({
|
||||
primary: { used_percent: 0.0, window_minutes: 300, resets_at: 1781033803 },
|
||||
secondary: { used_percent: 48.0, window_minutes: 10080, resets_at: 1781192122 },
|
||||
plan_type: 'pro',
|
||||
}),
|
||||
]);
|
||||
// Newest session is exec-mode: only null rate_limits.
|
||||
writeRollout(sessions, 'rollout-2026-06-09T11-00-00-bbbb.jsonl', [
|
||||
tokenCountLine(null),
|
||||
tokenCountLine(null),
|
||||
]);
|
||||
|
||||
const quota = await getCodexLocalQuota({ env, now: Date.now() });
|
||||
// Skips the null-newest file, reads the older file's rate_limits.
|
||||
expect(quota).not.toBeNull();
|
||||
expect(quota?.quotaPercentage).toBe(52);
|
||||
expect(quota?.tier).toBe('pro');
|
||||
});
|
||||
|
||||
it('returns null when NO scanned session carries rate_limits (no fake row)', async () => {
|
||||
const { env, sessions } = freshHome('all-null');
|
||||
writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.jsonl', [tokenCountLine(null)]);
|
||||
writeRollout(sessions, 'rollout-2026-06-09T11-00-00-bbbb.jsonl', [
|
||||
tokenCountLine(null),
|
||||
tokenCountLine(null),
|
||||
]);
|
||||
const quota = await getCodexLocalQuota({ env, now: Date.now() });
|
||||
expect(quota).toBeNull();
|
||||
});
|
||||
|
||||
it('flags stale from the SOURCE file mtime and sets staleAsOf', async () => {
|
||||
const { env, sessions } = freshHome('stale');
|
||||
// Newest is exec-mode (null); the data comes from the older file, so stale
|
||||
// must reflect the OLDER file's mtime, not the newest's.
|
||||
const sourceFile = writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.jsonl', [
|
||||
tokenCountLine({
|
||||
primary: { used_percent: 10, window_minutes: 300, resets_at: 1781033803 },
|
||||
secondary: { used_percent: 5, window_minutes: 10080, resets_at: 1781192122 },
|
||||
plan_type: 'plus',
|
||||
}),
|
||||
]);
|
||||
writeRollout(sessions, 'rollout-2026-06-09T11-00-00-bbbb.jsonl', [tokenCountLine(null)]);
|
||||
|
||||
const sourceMtime = fs.statSync(sourceFile).mtimeMs;
|
||||
const quota = await getCodexLocalQuota({ env, now: sourceMtime + 6 * 60 * 1000 });
|
||||
expect(quota?.stale).toBe(true);
|
||||
expect(quota?.staleAsOf).toBe(new Date(sourceMtime).toISOString());
|
||||
expect(quota?.tier).toBe('plus');
|
||||
});
|
||||
|
||||
it('is fresh (no staleAsOf) when the source file is recent', async () => {
|
||||
const { env, sessions } = freshHome('fresh');
|
||||
const file = writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.jsonl', [
|
||||
tokenCountLine({
|
||||
primary: { used_percent: 10, window_minutes: 300, resets_at: 1781033803 },
|
||||
secondary: { used_percent: 5, window_minutes: 10080, resets_at: 1781192122 },
|
||||
plan_type: 'plus',
|
||||
}),
|
||||
]);
|
||||
const mtime = fs.statSync(file).mtimeMs;
|
||||
const quota = await getCodexLocalQuota({ env, now: mtime + 60 * 1000 });
|
||||
expect(quota?.stale).toBe(false);
|
||||
expect(quota?.staleAsOf).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when there are no rollout files at all', async () => {
|
||||
const { env } = freshHome('empty');
|
||||
const quota = await getCodexLocalQuota({ env, now: Date.now() });
|
||||
expect(quota).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* Tests for the native subscription quota collector.
|
||||
*
|
||||
* The Anthropic fetch is ALWAYS mocked — these tests NEVER hit the live usage
|
||||
* endpoint. A controllable clock drives TTL / backoff / breaker assertions.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
getNativeAccountRows,
|
||||
resetNativeQuotaState,
|
||||
type NativeQuotaDeps,
|
||||
} from '../../../src/web-server/usage/native-quota-collector';
|
||||
import type { ClaudeQuotaResult } from '../../../src/cliproxy/quota/quota-types';
|
||||
import type { ClaudeNativeCredentials } from '../../../src/web-server/usage/claude-native-credentials';
|
||||
|
||||
// A jump comfortably past any single-call backoff cooldown (<= 60s), used so a
|
||||
// breaker-test fetch is not blocked by the prior 429's per-call cooldown.
|
||||
const MAX_COOLDOWN_JUMP = 61_000;
|
||||
|
||||
function maxCreds(): ClaudeNativeCredentials {
|
||||
return { claudeAiOauth: { accessToken: 'native-tok', subscriptionType: 'max' } };
|
||||
}
|
||||
|
||||
function successQuota(): ClaudeQuotaResult {
|
||||
return {
|
||||
success: true,
|
||||
windows: [],
|
||||
coreUsage: {
|
||||
fiveHour: {
|
||||
rateLimitType: 'five_hour',
|
||||
label: 'Session limit',
|
||||
remainingPercent: 42,
|
||||
resetAt: '2026-06-09T20:00:00.000Z',
|
||||
status: 'allowed',
|
||||
},
|
||||
weekly: {
|
||||
rateLimitType: 'seven_day',
|
||||
label: 'Weekly limit',
|
||||
remainingPercent: 70,
|
||||
resetAt: '2026-06-15T00:00:00.000Z',
|
||||
status: 'allowed',
|
||||
},
|
||||
},
|
||||
lastUpdated: Date.now(),
|
||||
accountId: 'claude-code',
|
||||
};
|
||||
}
|
||||
|
||||
/** A Max-plan quota carrying the Opus/Sonnet weekly splits in windows[]. */
|
||||
function maxQuotaWithSplits(): ClaudeQuotaResult {
|
||||
const base = successQuota();
|
||||
return {
|
||||
...base,
|
||||
windows: [
|
||||
{
|
||||
rateLimitType: 'seven_day_opus',
|
||||
label: 'Opus weekly',
|
||||
status: 'allowed',
|
||||
utilization: 0.25,
|
||||
usedPercent: 25,
|
||||
remainingPercent: 75,
|
||||
resetAt: '2026-06-15T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
rateLimitType: 'seven_day_sonnet',
|
||||
label: 'Sonnet weekly',
|
||||
status: 'allowed',
|
||||
utilization: 0.6,
|
||||
usedPercent: 60,
|
||||
remainingPercent: 40,
|
||||
resetAt: '2026-06-15T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function reauthQuota(): ClaudeQuotaResult {
|
||||
return {
|
||||
success: false,
|
||||
windows: [],
|
||||
coreUsage: { fiveHour: null, weekly: null },
|
||||
lastUpdated: Date.now(),
|
||||
accountId: 'claude-code',
|
||||
needsReauth: true,
|
||||
error: 'Authentication required',
|
||||
};
|
||||
}
|
||||
|
||||
function rateLimitedQuota(retryAfter?: string): ClaudeQuotaResult {
|
||||
return {
|
||||
success: false,
|
||||
windows: [],
|
||||
coreUsage: { fiveHour: null, weekly: null },
|
||||
lastUpdated: Date.now(),
|
||||
accountId: 'claude-code',
|
||||
httpStatus: 429,
|
||||
retryable: true,
|
||||
...(retryAfter ? { errorDetail: `retry-after:${retryAfter}` } : {}),
|
||||
error: 'rate limited',
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a deps object with a controllable clock + counted fetch. */
|
||||
function makeDeps(
|
||||
fetchImpl: (token: string) => Promise<ClaudeQuotaResult>,
|
||||
clock: { now: number },
|
||||
credsImpl: () => ClaudeNativeCredentials | null = maxCreds
|
||||
): NativeQuotaDeps & { fetchCount: () => number } {
|
||||
let count = 0;
|
||||
return {
|
||||
readCredentials: credsImpl,
|
||||
fetchClaudeQuota: async (token: string) => {
|
||||
count += 1;
|
||||
return fetchImpl(token);
|
||||
},
|
||||
getCodexQuota: async () => null,
|
||||
now: () => clock.now,
|
||||
sleep: async () => {},
|
||||
fetchCount: () => count,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetNativeQuotaState();
|
||||
});
|
||||
|
||||
describe('Claude native row mapping', () => {
|
||||
it('maps a successful fetch into a claude-code ok row with min remaining', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeDeps(async () => successQuota(), clock);
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
|
||||
const row = rows.find((r) => r.provider === 'claude-code');
|
||||
expect(row).toBeDefined();
|
||||
expect(row?.quotaStatus).toBe('ok');
|
||||
// min(42, 70)
|
||||
expect(row?.quota_percentage).toBe(42);
|
||||
expect(row?.next_reset).toBe('2026-06-09T20:00:00.000Z');
|
||||
expect(row?.tier).toBe('max');
|
||||
expect(row?.displayName).toBe('Claude Code');
|
||||
expect(row?.account_id).toBe('claude-code');
|
||||
expect(row?.needsReauth).toBe(false);
|
||||
});
|
||||
|
||||
it('emits quotaWindows with five_hour + seven_day from coreUsage (no opus/sonnet on non-Max)', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeDeps(async () => successQuota(), clock);
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
const row = rows.find((r) => r.provider === 'claude-code');
|
||||
|
||||
expect(row?.quotaWindows).toHaveLength(2);
|
||||
const five = row?.quotaWindows?.find((w) => w.key === 'five_hour');
|
||||
expect(five?.label).toBe('5h');
|
||||
expect(five?.remainingPercent).toBe(42);
|
||||
expect(five?.usedPercent).toBe(58); // 100 - 42
|
||||
expect(five?.windowMinutes).toBe(300);
|
||||
expect(five?.resetAt).toBe('2026-06-09T20:00:00.000Z');
|
||||
|
||||
const week = row?.quotaWindows?.find((w) => w.key === 'seven_day');
|
||||
expect(week?.label).toBe('week');
|
||||
expect(week?.remainingPercent).toBe(70);
|
||||
expect(week?.usedPercent).toBe(30);
|
||||
expect(week?.windowMinutes).toBe(10080);
|
||||
|
||||
// Max-only splits absent here.
|
||||
expect(row?.quotaWindows?.find((w) => w.key === 'seven_day_opus')).toBeUndefined();
|
||||
expect(row?.quotaWindows?.find((w) => w.key === 'seven_day_sonnet')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('adds seven_day_opus + seven_day_sonnet windows when present (Max plan)', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeDeps(async () => maxQuotaWithSplits(), clock);
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
const row = rows.find((r) => r.provider === 'claude-code');
|
||||
|
||||
// five_hour, seven_day, seven_day_opus, seven_day_sonnet
|
||||
expect(row?.quotaWindows).toHaveLength(4);
|
||||
const opus = row?.quotaWindows?.find((w) => w.key === 'seven_day_opus');
|
||||
expect(opus?.label).toBe('Opus · week');
|
||||
expect(opus?.usedPercent).toBe(25);
|
||||
expect(opus?.remainingPercent).toBe(75);
|
||||
expect(opus?.windowMinutes).toBe(10080);
|
||||
|
||||
const sonnet = row?.quotaWindows?.find((w) => w.key === 'seven_day_sonnet');
|
||||
expect(sonnet?.label).toBe('Sonnet · week');
|
||||
expect(sonnet?.usedPercent).toBe(60);
|
||||
expect(sonnet?.remainingPercent).toBe(40);
|
||||
});
|
||||
|
||||
it('emits a reauth error row on 401/needsReauth', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeDeps(async () => reauthQuota(), clock);
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
const row = rows.find((r) => r.provider === 'claude-code');
|
||||
expect(row?.quotaStatus).toBe('error');
|
||||
expect(row?.health).toBe('error');
|
||||
expect(row?.needsReauth).toBe(true);
|
||||
});
|
||||
|
||||
it('omits the claude row when there is no token', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeDeps(
|
||||
async () => successQuota(),
|
||||
clock,
|
||||
() => null
|
||||
);
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
expect(rows.find((r) => r.provider === 'claude-code')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits the claude row for an unsupported (free) subscription without spending a call', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeDeps(
|
||||
async () => successQuota(),
|
||||
clock,
|
||||
() => ({
|
||||
claudeAiOauth: { accessToken: 'x', subscriptionType: 'free' },
|
||||
})
|
||||
);
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
expect(rows.find((r) => r.provider === 'claude-code')).toBeUndefined();
|
||||
expect(deps.fetchCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache / TTL', () => {
|
||||
it('serves cache within TTL and does NOT re-fetch', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeDeps(async () => successQuota(), clock);
|
||||
|
||||
await getNativeAccountRows(deps);
|
||||
expect(deps.fetchCount()).toBe(1);
|
||||
|
||||
// Advance < 10 min: still cached.
|
||||
clock.now += 5 * 60 * 1000;
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
expect(deps.fetchCount()).toBe(1);
|
||||
expect(rows.find((r) => r.provider === 'claude-code')?.cached).toBe(true);
|
||||
});
|
||||
|
||||
it('re-fetches after the TTL expires', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeDeps(async () => successQuota(), clock);
|
||||
|
||||
await getNativeAccountRows(deps);
|
||||
clock.now += 11 * 60 * 1000; // past 10-min TTL
|
||||
await getNativeAccountRows(deps);
|
||||
expect(deps.fetchCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('in-flight coalescing', () => {
|
||||
it('shares ONE fetch across concurrent callers past TTL', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
let resolveFetch: (q: ClaudeQuotaResult) => void = () => {};
|
||||
const gate = new Promise<ClaudeQuotaResult>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
});
|
||||
const deps = makeDeps(async () => gate, clock);
|
||||
|
||||
const p1 = getNativeAccountRows(deps);
|
||||
const p2 = getNativeAccountRows(deps);
|
||||
resolveFetch(successQuota());
|
||||
await Promise.all([p1, p2]);
|
||||
|
||||
expect(deps.fetchCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Retry-After + backoff + circuit breaker', () => {
|
||||
it('honors Retry-After: no fetch until the cooldown elapses', async () => {
|
||||
const t0 = 1_000_000;
|
||||
const clock = { now: t0 };
|
||||
const deps = makeDeps(async () => rateLimitedQuota('30'), clock);
|
||||
|
||||
await getNativeAccountRows(deps); // fetch #1 -> 429, cooldown = t0 + 30s
|
||||
expect(deps.fetchCount()).toBe(1);
|
||||
|
||||
// Within the 30s Retry-After cooldown -> zero network even though there is
|
||||
// no cached row yet.
|
||||
clock.now = t0 + 10_000;
|
||||
await getNativeAccountRows(deps);
|
||||
expect(deps.fetchCount()).toBe(1);
|
||||
|
||||
// Past the 30s cooldown -> a fetch is allowed again.
|
||||
clock.now = t0 + 31_000;
|
||||
await getNativeAccountRows(deps);
|
||||
expect(deps.fetchCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('trips the breaker after 3 consecutive 429s, then a success closes it', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
let mode: 'fail' | 'ok' = 'fail';
|
||||
const deps = makeDeps(
|
||||
async () => (mode === 'fail' ? rateLimitedQuota() : successQuota()),
|
||||
clock
|
||||
);
|
||||
|
||||
// Three 429s; each separated past the per-call cooldown so they actually fetch.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await getNativeAccountRows(deps);
|
||||
// jump past TTL and any backoff cooldown
|
||||
clock.now += 11 * 60 * 1000 + MAX_COOLDOWN_JUMP;
|
||||
}
|
||||
expect(deps.fetchCount()).toBe(3);
|
||||
|
||||
// Breaker is open now -> zero network even past TTL.
|
||||
await getNativeAccountRows(deps);
|
||||
expect(deps.fetchCount()).toBe(3);
|
||||
|
||||
// Advance past the 15-min breaker cooldown; allow a success which closes it.
|
||||
clock.now += 16 * 60 * 1000;
|
||||
mode = 'ok';
|
||||
await getNativeAccountRows(deps);
|
||||
expect(deps.fetchCount()).toBe(4);
|
||||
|
||||
// After success, breaker closed: another fetch past TTL proceeds.
|
||||
clock.now += 11 * 60 * 1000;
|
||||
await getNativeAccountRows(deps);
|
||||
expect(deps.fetchCount()).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stale-on-fail', () => {
|
||||
it('returns the last good row when a subsequent fetch rejects', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
let mode: 'ok' | 'throw' = 'ok';
|
||||
const deps = makeDeps(async () => {
|
||||
if (mode === 'throw') throw new Error('network down');
|
||||
return successQuota();
|
||||
}, clock);
|
||||
|
||||
await getNativeAccountRows(deps); // good row cached
|
||||
mode = 'throw';
|
||||
clock.now += 11 * 60 * 1000; // force re-fetch
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
const row = rows.find((r) => r.provider === 'claude-code');
|
||||
expect(row).toBeDefined();
|
||||
expect(row?.quotaStatus).toBe('ok');
|
||||
expect(row?.cached).toBe(true);
|
||||
});
|
||||
|
||||
it('omits the row when the first-ever fetch fails (no prior cache)', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeDeps(async () => {
|
||||
throw new Error('network down');
|
||||
}, clock);
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
expect(rows.find((r) => r.provider === 'claude-code')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Codex path', () => {
|
||||
it('maps a local Codex quota into a codex ok row', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps: NativeQuotaDeps = {
|
||||
readCredentials: () => null, // no claude row
|
||||
getCodexQuota: async () => ({
|
||||
quotaPercentage: 52,
|
||||
nextReset: '2026-06-09T19:00:00.000Z',
|
||||
tier: 'pro',
|
||||
stale: false,
|
||||
staleAsOf: null,
|
||||
windows: [
|
||||
{
|
||||
key: 'five_hour',
|
||||
label: '5h',
|
||||
usedPercent: 19,
|
||||
remainingPercent: 81,
|
||||
resetAt: '2026-06-09T19:00:00.000Z',
|
||||
windowMinutes: 300,
|
||||
},
|
||||
{
|
||||
key: 'seven_day',
|
||||
label: 'week',
|
||||
usedPercent: 48,
|
||||
remainingPercent: 52,
|
||||
resetAt: '2026-06-14T00:00:00.000Z',
|
||||
windowMinutes: 10080,
|
||||
},
|
||||
],
|
||||
}),
|
||||
now: () => clock.now,
|
||||
};
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
const row = rows.find((r) => r.provider === 'codex');
|
||||
expect(row?.quotaStatus).toBe('ok');
|
||||
expect(row?.quota_percentage).toBe(52);
|
||||
expect(row?.tier).toBe('pro');
|
||||
expect(row?.health).toBe('ok');
|
||||
expect(row?.quotaWindows).toHaveLength(2);
|
||||
expect(row?.quotaWindows?.[0].windowMinutes).toBe(300);
|
||||
expect(row?.staleAsOf).toBeUndefined();
|
||||
});
|
||||
|
||||
it('flags health warning when the Codex source is stale', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps: NativeQuotaDeps = {
|
||||
readCredentials: () => null,
|
||||
getCodexQuota: async () => ({
|
||||
quotaPercentage: 10,
|
||||
nextReset: null,
|
||||
tier: null,
|
||||
stale: true,
|
||||
staleAsOf: '2026-06-09T13:30:00.000Z',
|
||||
windows: [],
|
||||
}),
|
||||
now: () => clock.now,
|
||||
};
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
const row = rows.find((r) => r.provider === 'codex');
|
||||
expect(row?.health).toBe('warning');
|
||||
// staleAsOf flows through so the bar can render the freshness footnote.
|
||||
expect(row?.staleAsOf).toBe('2026-06-09T13:30:00.000Z');
|
||||
});
|
||||
|
||||
it('omits the codex row when there is no rate_limits (exec-mode)', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps: NativeQuotaDeps = {
|
||||
readCredentials: () => null,
|
||||
getCodexQuota: async () => null,
|
||||
now: () => clock.now,
|
||||
};
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
expect(rows.find((r) => r.provider === 'codex')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Phase 2: tier-lock endpoint tests
|
||||
*
|
||||
* POST /api/accounts/tier-lock
|
||||
* body: { tier: string|null, provider?: string }
|
||||
*
|
||||
* Tests:
|
||||
* - sets tier_lock in config and returns it
|
||||
* - clears tier_lock when tier is null
|
||||
* - rejects missing provider
|
||||
* - rejects invalid provider
|
||||
* - rejects unknown tier strings (typos must 400, not silently persist)
|
||||
* - persists across config reads (config write path) as per-provider map
|
||||
* - locking one provider does NOT affect another provider's lock entry
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import express from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { Server } from 'http';
|
||||
|
||||
async function postJson(baseUrl: string, routePath: string, body: unknown): Promise<Response> {
|
||||
return fetch(`${baseUrl}${routePath}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe('POST /api/accounts/tier-lock', () => {
|
||||
let server: Server;
|
||||
let baseUrl = '';
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalCcsUnified: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-tier-lock-routes-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalCcsUnified = process.env.CCS_UNIFIED_CONFIG;
|
||||
|
||||
process.env.CCS_HOME = tempHome;
|
||||
process.env.CCS_UNIFIED_CONFIG = '1';
|
||||
|
||||
// No account-manager mock: the empty temp CCS_HOME yields zero CLIProxy
|
||||
// accounts naturally, and the tier-lock endpoint only validates the
|
||||
// provider id and writes config. Avoiding mock.module here is deliberate —
|
||||
// Bun's mock.restore() does NOT unwind mock.module, so a global account
|
||||
// manager mock would leak into later test files in the same process.
|
||||
const { default: accountRoutes } = await import(
|
||||
`../../../src/web-server/routes/account-routes?tier-lock-test=${Date.now()}-${Math.random()}`
|
||||
);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/accounts', accountRoutes);
|
||||
|
||||
server = await new Promise<Server>((resolve, reject) => {
|
||||
const instance = app.listen(0, '127.0.0.1');
|
||||
instance.once('error', reject);
|
||||
instance.once('listening', () => resolve(instance));
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Unable to resolve test server port');
|
||||
}
|
||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
if (originalCcsUnified !== undefined) process.env.CCS_UNIFIED_CONFIG = originalCcsUnified;
|
||||
else delete process.env.CCS_UNIFIED_CONFIG;
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('sets tier_lock to a named tier and returns it', async () => {
|
||||
const res = await postJson(baseUrl, '/api/accounts/tier-lock', {
|
||||
provider: 'agy',
|
||||
tier: 'ultra',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { provider: string; tier_lock: string | null };
|
||||
expect(body.provider).toBe('agy');
|
||||
expect(body.tier_lock).toBe('ultra');
|
||||
});
|
||||
|
||||
it('clears tier_lock when tier is null', async () => {
|
||||
// Set first
|
||||
await postJson(baseUrl, '/api/accounts/tier-lock', { provider: 'agy', tier: 'pro' });
|
||||
|
||||
// Then clear
|
||||
const res = await postJson(baseUrl, '/api/accounts/tier-lock', {
|
||||
provider: 'agy',
|
||||
tier: null,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { provider: string; tier_lock: string | null };
|
||||
expect(body.provider).toBe('agy');
|
||||
expect(body.tier_lock).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects missing provider with 400', async () => {
|
||||
const res = await postJson(baseUrl, '/api/accounts/tier-lock', { tier: 'pro' });
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/provider/i);
|
||||
});
|
||||
|
||||
it('rejects invalid provider with 400', async () => {
|
||||
const res = await postJson(baseUrl, '/api/accounts/tier-lock', {
|
||||
provider: 'notreal',
|
||||
tier: 'pro',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/provider/i);
|
||||
});
|
||||
|
||||
it('rejects missing tier field (no tier key at all) with 400', async () => {
|
||||
const res = await postJson(baseUrl, '/api/accounts/tier-lock', { provider: 'agy' });
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/tier/i);
|
||||
});
|
||||
|
||||
it('rejects non-string non-null tier with 400', async () => {
|
||||
const res = await postJson(baseUrl, '/api/accounts/tier-lock', {
|
||||
provider: 'agy',
|
||||
tier: 42,
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/tier/i);
|
||||
});
|
||||
|
||||
it('rejects unknown tier string (typo) with 400', async () => {
|
||||
// "Ultra" (capital U) is not a valid AccountTier — must 400, not silently persist
|
||||
const res = await postJson(baseUrl, '/api/accounts/tier-lock', {
|
||||
provider: 'agy',
|
||||
tier: 'Ultra',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/tier/i);
|
||||
});
|
||||
|
||||
it('rejects "premium" (unknown tier) with 400', async () => {
|
||||
const res = await postJson(baseUrl, '/api/accounts/tier-lock', {
|
||||
provider: 'agy',
|
||||
tier: 'premium',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/tier/i);
|
||||
});
|
||||
|
||||
it('rejects a non-managed provider with 400 (fix #8)', async () => {
|
||||
// Providers like 'kiro' or unknown CLIProxy providers accept isCLIProxyProvider()
|
||||
// but quota-manager does not enforce tier_lock for them. Persisting a lock entry
|
||||
// would silently have no effect, misleading the caller. Must 400.
|
||||
// Note: 'kiro' passes isCLIProxyProvider but is NOT in MANAGED_QUOTA_PROVIDERS.
|
||||
// We test with a provider that is valid (passes CLIProxy check) but not managed.
|
||||
// In practice this means any provider added to CLIProxy that is not in
|
||||
// MANAGED_QUOTA_PROVIDERS = ['agy', 'claude', 'codex', 'gemini', 'ghcp'].
|
||||
// We use a string that is a known CLIProxy provider but not managed.
|
||||
// Since the set of CLIProxy providers is dynamic we test the error message content.
|
||||
const res = await postJson(baseUrl, '/api/accounts/tier-lock', {
|
||||
provider: 'kiro',
|
||||
tier: 'pro',
|
||||
});
|
||||
// If kiro is a CLIProxy provider but not managed: expect 400
|
||||
// If kiro is not a CLIProxy provider at all: also 400 (from isCLIProxyProvider check)
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/provider/i);
|
||||
});
|
||||
|
||||
it('accepts all five managed-quota providers (agy, claude, codex, gemini, ghcp)', async () => {
|
||||
const managedProviders = ['agy', 'claude', 'codex', 'gemini', 'ghcp'];
|
||||
for (const provider of managedProviders) {
|
||||
const res = await postJson(baseUrl, '/api/accounts/tier-lock', {
|
||||
provider,
|
||||
tier: 'pro',
|
||||
});
|
||||
// All managed providers should succeed (200)
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { provider: string; tier_lock: string | null };
|
||||
expect(body.provider).toBe(provider);
|
||||
}
|
||||
});
|
||||
|
||||
it('persists tier_lock as per-provider map in the config', async () => {
|
||||
await postJson(baseUrl, '/api/accounts/tier-lock', { provider: 'agy', tier: 'pro' });
|
||||
|
||||
// Read the config directly to confirm per-provider persistence
|
||||
const { loadOrCreateUnifiedConfig } = await import(
|
||||
`../../../src/config/config-loader-facade?persist-check=${Date.now()}`
|
||||
);
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const tierLock = config.quota_management?.manual?.tier_lock;
|
||||
// Must be a map, not a bare string
|
||||
expect(typeof tierLock).toBe('object');
|
||||
expect((tierLock as Record<string, string | null>)['agy']).toBe('pro');
|
||||
});
|
||||
|
||||
it('tier_lock is persisted as a per-provider map entry (not a global string)', async () => {
|
||||
// Lock agy to ultra — verify the map structure has only agy set
|
||||
await postJson(baseUrl, '/api/accounts/tier-lock', { provider: 'agy', tier: 'ultra' });
|
||||
|
||||
const { loadOrCreateUnifiedConfig } = await import(
|
||||
`../../../src/config/config-loader-facade?per-provider-map-check=${Date.now()}`
|
||||
);
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const tierLock = config.quota_management?.manual?.tier_lock;
|
||||
|
||||
// Must be a map, not a bare string
|
||||
expect(typeof tierLock).toBe('object');
|
||||
expect(tierLock).not.toBeNull();
|
||||
// The agy entry must be set
|
||||
expect((tierLock as Record<string, string | null>)['agy']).toBe('ultra');
|
||||
// Providers not explicitly locked must not appear in the map
|
||||
expect((tierLock as Record<string, string | null>)['codex'] ?? null).toBeNull();
|
||||
expect((tierLock as Record<string, string | null>)['gemini'] ?? null).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,462 @@
|
||||
/**
|
||||
* Phase 1A: Account Attribution Tests
|
||||
*
|
||||
* TDD tests for auth_index → accountId mapping through the usage pipeline.
|
||||
* Covers:
|
||||
* - auth_index maps to account email via accountMap in transformer
|
||||
* - extractCliproxyUsageHistoryDetails carries accountId
|
||||
* - getTodayCostByAccount returns correct per-account totals
|
||||
* - Profile-based aggregation unaffected (backward compat)
|
||||
*/
|
||||
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'bun:test';
|
||||
import type {
|
||||
CliproxyUsageApiResponse,
|
||||
CliproxyManagementAuthFile,
|
||||
} from '../../../../src/cliproxy/services/stats-fetcher';
|
||||
|
||||
// ============================================================================
|
||||
// HELPERS & FIXTURES
|
||||
// ============================================================================
|
||||
|
||||
// Local calendar day (matches production getTodayCostByAccount, which keys on
|
||||
// localDayKey — not a UTC ISO slice). Fixture timestamps below use the SAME
|
||||
// local day with no trailing Z so they bucket consistently with production.
|
||||
function localDay(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
const TODAY = localDay(new Date()); // YYYY-MM-DD, local
|
||||
|
||||
function makeResponse(
|
||||
entries: Array<{
|
||||
provider: string;
|
||||
model: string;
|
||||
auth_index: number;
|
||||
source: string;
|
||||
timestamp: string;
|
||||
input: number;
|
||||
output: number;
|
||||
failed?: boolean;
|
||||
}>
|
||||
): CliproxyUsageApiResponse {
|
||||
const apis: CliproxyUsageApiResponse['usage'] = { apis: {} };
|
||||
for (const e of entries) {
|
||||
if (!apis.apis![e.provider]) {
|
||||
apis.apis![e.provider] = { models: {} };
|
||||
}
|
||||
const models = apis.apis![e.provider].models!;
|
||||
if (!models[e.model]) {
|
||||
models[e.model] = { details: [] };
|
||||
}
|
||||
models[e.model].details!.push({
|
||||
timestamp: e.timestamp,
|
||||
source: e.source,
|
||||
auth_index: e.auth_index,
|
||||
tokens: {
|
||||
input_tokens: e.input,
|
||||
output_tokens: e.output,
|
||||
reasoning_tokens: 0,
|
||||
cached_tokens: 0,
|
||||
total_tokens: e.input + e.output,
|
||||
},
|
||||
failed: e.failed ?? false,
|
||||
});
|
||||
}
|
||||
return { usage: apis };
|
||||
}
|
||||
|
||||
const twoAccountResponse = makeResponse([
|
||||
{
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-5',
|
||||
auth_index: 0,
|
||||
source: 'old-source-a',
|
||||
timestamp: `${TODAY}T10:00:00.000Z`,
|
||||
input: 1000,
|
||||
output: 500,
|
||||
},
|
||||
{
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-5',
|
||||
auth_index: 1,
|
||||
source: 'old-source-b',
|
||||
timestamp: `${TODAY}T11:00:00.000Z`,
|
||||
input: 2000,
|
||||
output: 800,
|
||||
},
|
||||
// auth_index 0 again — same account, second request.
|
||||
// output=201 (not 200) ensures alice's two-request total ($0.018025) strictly
|
||||
// exceeds bob's single-request total ($0.018), avoiding a floating-point tie.
|
||||
{
|
||||
provider: 'anthropic',
|
||||
model: 'claude-opus-4-5',
|
||||
auth_index: 0,
|
||||
source: 'old-source-a',
|
||||
timestamp: `${TODAY}T12:00:00.000Z`,
|
||||
input: 500,
|
||||
output: 201,
|
||||
},
|
||||
]);
|
||||
|
||||
// Fix #7/#13/#15: buildAuthIndexToAccountMap stores String(auth_index) keys only.
|
||||
// The map must use string keys so accountMap.get(String(detail.auth_index)) resolves correctly.
|
||||
const authFileMap: Map<number | string, string> = new Map([
|
||||
['0', 'alice@example.com'],
|
||||
['1', 'bob@example.com'],
|
||||
]);
|
||||
|
||||
// ============================================================================
|
||||
// TRANSFORMER: extractCliproxyUsageHistoryDetails with accountMap
|
||||
// ============================================================================
|
||||
|
||||
describe('extractCliproxyUsageHistoryDetails with accountMap', () => {
|
||||
it('populates accountId from accountMap when auth_index is present', async () => {
|
||||
const { extractCliproxyUsageHistoryDetails } = await import(
|
||||
'../../../../src/web-server/usage/cliproxy-usage-transformer'
|
||||
);
|
||||
|
||||
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse, authFileMap);
|
||||
|
||||
const aliceDetails = details.filter((d) => d.accountId === 'alice@example.com');
|
||||
const bobDetails = details.filter((d) => d.accountId === 'bob@example.com');
|
||||
|
||||
expect(aliceDetails).toHaveLength(2); // auth_index 0 appears twice
|
||||
expect(bobDetails).toHaveLength(1); // auth_index 1 appears once
|
||||
});
|
||||
|
||||
it('leaves accountId undefined when auth_index is not in accountMap (no source fallback)', async () => {
|
||||
// Fix #7/#13/#15: detail.source is a CLIProxy source label, not an email.
|
||||
// Using it as a cost key caused mis-attribution. When auth_index is absent from the
|
||||
// map, accountId must be undefined so getTodayCostByAccount buckets under 'unknown'.
|
||||
const { extractCliproxyUsageHistoryDetails } = await import(
|
||||
'../../../../src/web-server/usage/cliproxy-usage-transformer'
|
||||
);
|
||||
|
||||
// Use string key matching buildAuthIndexToAccountMap's String(auth_index) output
|
||||
const partialMap: Map<number | string, string> = new Map([['0', 'alice@example.com']]);
|
||||
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse, partialMap);
|
||||
|
||||
// auth_index 1 (bob) is not in the partial map — must be undefined, not 'old-source-b'
|
||||
const bobDetail = details.find((d) => d.accountId === undefined && !d.accountId);
|
||||
// There should be exactly one detail with no accountId (bob's request)
|
||||
const unmappedDetails = details.filter((d) => d.accountId === undefined);
|
||||
expect(unmappedDetails).toHaveLength(1);
|
||||
// Confirm it is NOT keyed under the source string
|
||||
const sourceFallback = details.find((d) => d.accountId === 'old-source-b');
|
||||
expect(sourceFallback).toBeUndefined();
|
||||
void bobDetail; // suppress lint
|
||||
});
|
||||
|
||||
it('does not include accountId when no accountMap is provided (backward compat)', async () => {
|
||||
const { extractCliproxyUsageHistoryDetails } = await import(
|
||||
'../../../../src/web-server/usage/cliproxy-usage-transformer'
|
||||
);
|
||||
|
||||
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse);
|
||||
|
||||
for (const detail of details) {
|
||||
expect(detail.accountId).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not expose source or auth_index on returned history details', async () => {
|
||||
const { extractCliproxyUsageHistoryDetails } = await import(
|
||||
'../../../../src/web-server/usage/cliproxy-usage-transformer'
|
||||
);
|
||||
|
||||
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse, authFileMap);
|
||||
|
||||
for (const detail of details) {
|
||||
expect((detail as Record<string, unknown>).source).toBeUndefined();
|
||||
expect((detail as Record<string, unknown>).auth_index).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// TRANSFORMER: CliproxyUsageHistoryDetail type has optional accountId
|
||||
// ============================================================================
|
||||
|
||||
describe('CliproxyUsageHistoryDetail type', () => {
|
||||
it('allows accountId as optional string field', async () => {
|
||||
const { normalizeCliproxyUsageHistoryDetail } = await import(
|
||||
'../../../../src/web-server/usage/cliproxy-usage-transformer'
|
||||
);
|
||||
|
||||
const withAccount = normalizeCliproxyUsageHistoryDetail({
|
||||
model: 'claude-sonnet-4-5',
|
||||
timestamp: `${TODAY}T10:00:00.000Z`,
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheReadTokens: 0,
|
||||
requestCount: 1,
|
||||
cost: 0.01,
|
||||
failed: false,
|
||||
accountId: 'alice@example.com',
|
||||
});
|
||||
|
||||
expect(withAccount).not.toBeNull();
|
||||
expect(withAccount?.accountId).toBe('alice@example.com');
|
||||
});
|
||||
|
||||
it('normalizes detail without accountId (remains undefined)', async () => {
|
||||
const { normalizeCliproxyUsageHistoryDetail } = await import(
|
||||
'../../../../src/web-server/usage/cliproxy-usage-transformer'
|
||||
);
|
||||
|
||||
const noAccount = normalizeCliproxyUsageHistoryDetail({
|
||||
model: 'claude-sonnet-4-5',
|
||||
timestamp: `${TODAY}T10:00:00.000Z`,
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheReadTokens: 0,
|
||||
requestCount: 1,
|
||||
cost: 0.01,
|
||||
failed: false,
|
||||
});
|
||||
|
||||
expect(noAccount).not.toBeNull();
|
||||
expect(noAccount?.accountId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// DATA-AGGREGATOR: getTodayCostByAccount
|
||||
// ============================================================================
|
||||
|
||||
describe('getTodayCostByAccount', () => {
|
||||
it('returns per-account cost totals for today', async () => {
|
||||
const { getTodayCostByAccount } = await import(
|
||||
'../../../../src/web-server/usage/data-aggregator'
|
||||
);
|
||||
|
||||
const details = [];
|
||||
|
||||
// Simulate alice's two requests today
|
||||
const { extractCliproxyUsageHistoryDetails } = await import(
|
||||
'../../../../src/web-server/usage/cliproxy-usage-transformer'
|
||||
);
|
||||
const todayDetails = extractCliproxyUsageHistoryDetails(twoAccountResponse, authFileMap);
|
||||
|
||||
const result = getTodayCostByAccount(todayDetails, TODAY);
|
||||
|
||||
// Alice has auth_index 0: two requests (claude-sonnet + claude-opus)
|
||||
// Bob has auth_index 1: one request (claude-sonnet)
|
||||
expect(typeof result['alice@example.com']).toBe('number');
|
||||
expect(typeof result['bob@example.com']).toBe('number');
|
||||
expect(result['alice@example.com']).toBeGreaterThan(0);
|
||||
expect(result['bob@example.com']).toBeGreaterThan(0);
|
||||
// Alice has two requests across two models; Bob has one request with more tokens.
|
||||
// Alice's two-request total ($0.018025) exceeds Bob's single-request total ($0.018).
|
||||
expect(result['alice@example.com']).toBeGreaterThan(result['bob@example.com']);
|
||||
});
|
||||
|
||||
it('returns empty object when no details exist for today', async () => {
|
||||
const { getTodayCostByAccount } = await import(
|
||||
'../../../../src/web-server/usage/data-aggregator'
|
||||
);
|
||||
|
||||
const result = getTodayCostByAccount([], TODAY);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('filters out details from days other than today', async () => {
|
||||
const { getTodayCostByAccount } = await import(
|
||||
'../../../../src/web-server/usage/data-aggregator'
|
||||
);
|
||||
const { extractCliproxyUsageHistoryDetails } = await import(
|
||||
'../../../../src/web-server/usage/cliproxy-usage-transformer'
|
||||
);
|
||||
|
||||
const yesterdayResponse = makeResponse([
|
||||
{
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-5',
|
||||
auth_index: 0,
|
||||
source: 'old-source-a',
|
||||
timestamp: '2020-01-01T10:00:00.000Z', // definitely not today
|
||||
input: 9999,
|
||||
output: 9999,
|
||||
},
|
||||
]);
|
||||
const details = extractCliproxyUsageHistoryDetails(yesterdayResponse, authFileMap);
|
||||
const result = getTodayCostByAccount(details, TODAY);
|
||||
|
||||
expect(Object.keys(result)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accumulates costs across multiple details for the same account', async () => {
|
||||
const { getTodayCostByAccount } = await import(
|
||||
'../../../../src/web-server/usage/data-aggregator'
|
||||
);
|
||||
const { extractCliproxyUsageHistoryDetails } = await import(
|
||||
'../../../../src/web-server/usage/cliproxy-usage-transformer'
|
||||
);
|
||||
|
||||
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse, authFileMap);
|
||||
|
||||
// alice appears for two models — verify aggregated correctly
|
||||
const result = getTodayCostByAccount(details, TODAY);
|
||||
const aliceCostFromDetails = details
|
||||
.filter((d) => d.accountId === 'alice@example.com')
|
||||
.reduce((acc, d) => acc + d.cost, 0);
|
||||
|
||||
expect(result['alice@example.com']).toBeCloseTo(aliceCostFromDetails, 10);
|
||||
});
|
||||
|
||||
it('details without accountId are grouped under the "unknown" key', async () => {
|
||||
// Fix #7/#13/#15: when no accountMap is provided, accountId is undefined on all details.
|
||||
// getTodayCostByAccount buckets these under 'unknown' — not under detail.source.
|
||||
const { getTodayCostByAccount } = await import(
|
||||
'../../../../src/web-server/usage/data-aggregator'
|
||||
);
|
||||
const { extractCliproxyUsageHistoryDetails } = await import(
|
||||
'../../../../src/web-server/usage/cliproxy-usage-transformer'
|
||||
);
|
||||
|
||||
// no accountMap — accountId will be undefined on all details
|
||||
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse);
|
||||
const result = getTodayCostByAccount(details, TODAY);
|
||||
|
||||
// All costs should be accumulated under the literal key 'unknown'
|
||||
expect(typeof result['unknown']).toBe('number');
|
||||
expect(result['unknown']).toBeGreaterThan(0);
|
||||
// No source-string keys should appear in the result
|
||||
expect(result['old-source-a']).toBeUndefined();
|
||||
expect(result['old-source-b']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// BACKWARD COMPATIBILITY: existing profile aggregation still works
|
||||
// ============================================================================
|
||||
|
||||
describe('backward compatibility: profile-based aggregation unaffected', () => {
|
||||
it('transformCliproxyToDailyUsage works without accountMap', async () => {
|
||||
const { transformCliproxyToDailyUsage } = await import(
|
||||
'../../../../src/web-server/usage/cliproxy-usage-transformer'
|
||||
);
|
||||
|
||||
const daily = transformCliproxyToDailyUsage(twoAccountResponse);
|
||||
|
||||
expect(daily.length).toBeGreaterThan(0);
|
||||
expect(daily[0].source).toBe('cliproxy');
|
||||
expect(daily[0].modelBreakdowns.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('buildCliproxyUsageHistoryAggregates preserves existing shape', async () => {
|
||||
const { buildCliproxyUsageHistoryAggregates, extractCliproxyUsageHistoryDetails } =
|
||||
await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
|
||||
|
||||
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse);
|
||||
const { daily, hourly, monthly } = buildCliproxyUsageHistoryAggregates(details);
|
||||
|
||||
expect(Array.isArray(daily)).toBe(true);
|
||||
expect(Array.isArray(hourly)).toBe(true);
|
||||
expect(Array.isArray(monthly)).toBe(true);
|
||||
if (daily.length > 0) {
|
||||
expect(typeof daily[0].date).toBe('string');
|
||||
expect(typeof daily[0].totalCost).toBe('number');
|
||||
}
|
||||
});
|
||||
|
||||
it('DailyUsage shape includes optional accountId field', async () => {
|
||||
// Type-level test: verify DailyUsage can carry accountId without breaking shape
|
||||
type DailyUsageShape = { date: string; source: string; totalCost: number; accountId?: string };
|
||||
const sample: DailyUsageShape = {
|
||||
date: TODAY,
|
||||
source: 'cliproxy',
|
||||
totalCost: 1.5,
|
||||
accountId: 'alice@example.com',
|
||||
};
|
||||
const noAccount: DailyUsageShape = { date: TODAY, source: 'cliproxy', totalCost: 0.5 };
|
||||
expect(sample.accountId).toBe('alice@example.com');
|
||||
expect(noAccount.accountId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// STATS-FETCHER: buildAuthIndexToAccountMap
|
||||
// ============================================================================
|
||||
|
||||
describe('buildAuthIndexToAccountMap', () => {
|
||||
it('builds map from auth files with auth_index and email', async () => {
|
||||
const { buildAuthIndexToAccountMap } = await import(
|
||||
'../../../../src/cliproxy/services/stats-fetcher'
|
||||
);
|
||||
|
||||
const authFiles: CliproxyManagementAuthFile[] = [
|
||||
{ auth_index: 0, provider: 'anthropic', email: 'alice@example.com' },
|
||||
{ auth_index: 1, provider: 'anthropic', email: 'bob@example.com' },
|
||||
{ auth_index: 2, provider: 'gemini', email: 'carol@example.com' },
|
||||
];
|
||||
|
||||
const map = buildAuthIndexToAccountMap(authFiles);
|
||||
|
||||
expect(map.get('0')).toBe('alice@example.com');
|
||||
expect(map.get('1')).toBe('bob@example.com');
|
||||
expect(map.get('2')).toBe('carol@example.com');
|
||||
});
|
||||
|
||||
it('skips entries missing auth_index', async () => {
|
||||
const { buildAuthIndexToAccountMap } = await import(
|
||||
'../../../../src/cliproxy/services/stats-fetcher'
|
||||
);
|
||||
|
||||
const authFiles: CliproxyManagementAuthFile[] = [
|
||||
{ provider: 'anthropic', email: 'nobody@example.com' }, // no auth_index
|
||||
{ auth_index: 3, provider: 'anthropic', email: 'alice@example.com' },
|
||||
];
|
||||
|
||||
const map = buildAuthIndexToAccountMap(authFiles);
|
||||
|
||||
expect(map.size).toBe(1);
|
||||
expect(map.get('3')).toBe('alice@example.com');
|
||||
});
|
||||
|
||||
it('skips entries missing email', async () => {
|
||||
const { buildAuthIndexToAccountMap } = await import(
|
||||
'../../../../src/cliproxy/services/stats-fetcher'
|
||||
);
|
||||
|
||||
const authFiles: CliproxyManagementAuthFile[] = [
|
||||
{ auth_index: 4, provider: 'anthropic' }, // no email
|
||||
{ auth_index: 5, provider: 'anthropic', email: 'dave@example.com' },
|
||||
];
|
||||
|
||||
const map = buildAuthIndexToAccountMap(authFiles);
|
||||
|
||||
expect(map.size).toBe(1);
|
||||
expect(map.get('5')).toBe('dave@example.com');
|
||||
});
|
||||
|
||||
it('returns empty map for empty auth files array', async () => {
|
||||
const { buildAuthIndexToAccountMap } = await import(
|
||||
'../../../../src/cliproxy/services/stats-fetcher'
|
||||
);
|
||||
|
||||
const map = buildAuthIndexToAccountMap([]);
|
||||
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it('handles numeric and string auth_index keys consistently', async () => {
|
||||
const { buildAuthIndexToAccountMap } = await import(
|
||||
'../../../../src/cliproxy/services/stats-fetcher'
|
||||
);
|
||||
|
||||
const authFiles: CliproxyManagementAuthFile[] = [
|
||||
{ auth_index: 7, provider: 'anthropic', email: 'alice@example.com' },
|
||||
{ auth_index: '8', provider: 'anthropic', email: 'bob@example.com' },
|
||||
];
|
||||
|
||||
const map = buildAuthIndexToAccountMap(authFiles);
|
||||
|
||||
expect(map.get('7')).toBe('alice@example.com');
|
||||
expect(map.get('8')).toBe('bob@example.com');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* CCS Bar Feature Banner
|
||||
* Dismissible announcement banner promoting the native macOS menu-bar app.
|
||||
*
|
||||
* Rendered only on macOS: the CTA is an install action (`ccs bar install`) that
|
||||
* has no effect on other platforms, so showing it elsewhere would be misleading.
|
||||
*/
|
||||
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, MonitorDot, ExternalLink } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const BANNER_DISMISSED_KEY = 'ccs:ccs-bar-banner-dismissed';
|
||||
|
||||
// User-facing docs page for CCS Bar (flat Markdown in the ccs/cli docs tree).
|
||||
const CCS_BAR_DOCS_URL = 'https://github.com/kaitranntt/ccs/blob/main/docs/ccs-bar.md';
|
||||
|
||||
// Lightweight, dependency-free macOS detection. Kept inline because no other
|
||||
// component needs platform detection; a shared hook would be premature.
|
||||
const isMacOS =
|
||||
typeof navigator !== 'undefined' &&
|
||||
/Mac|iPhone|iPad/i.test(navigator.userAgent || navigator.platform || '');
|
||||
|
||||
interface CcsBarBannerProps {
|
||||
onInstallClick?: () => void;
|
||||
}
|
||||
|
||||
export function CcsBarBanner({ onInstallClick }: CcsBarBannerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [dismissed, setDismissed] = useState(true); // Start hidden to avoid flash
|
||||
|
||||
// Check localStorage on mount
|
||||
useEffect(() => {
|
||||
const isDismissed = localStorage.getItem(BANNER_DISMISSED_KEY) === 'true';
|
||||
setDismissed(isDismissed);
|
||||
}, []);
|
||||
|
||||
const handleDismiss = () => {
|
||||
localStorage.setItem(BANNER_DISMISSED_KEY, 'true');
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
if (!isMacOS) return null;
|
||||
if (dismissed) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-accent to-accent/90 text-white px-4 py-3 relative shrink-0">
|
||||
<div className="flex items-center justify-between gap-4 max-w-screen-xl mx-auto">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="p-1.5 bg-white/20 rounded-md shrink-0">
|
||||
<MonitorDot className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm">
|
||||
{t('ccsBarBanner.new')}: {t('ccsBarBanner.title')}
|
||||
</p>
|
||||
<p className="text-xs text-white/80 truncate">
|
||||
{t('ccsBarBanner.description')}{' '}
|
||||
<code className="bg-white/15 rounded px-1 py-0.5 font-mono text-[11px]">
|
||||
ccs bar install
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{onInstallClick && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={onInstallClick}
|
||||
className="bg-white text-accent hover:bg-white/90 h-8"
|
||||
>
|
||||
{t('ccsBarBanner.install')}
|
||||
</Button>
|
||||
)}
|
||||
<a
|
||||
href={CCS_BAR_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-white/80 hover:text-white hidden sm:flex items-center gap-1"
|
||||
>
|
||||
Learn more
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleDismiss}
|
||||
className="h-7 w-7 text-white/70 hover:text-white hover:bg-white/20"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<span className="sr-only">Dismiss</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* CCS Bar Promo Card
|
||||
* Permanent promotional card for the native macOS menu-bar app, shown in the
|
||||
* providers sidebar footer.
|
||||
*
|
||||
* Rendered only on macOS: the install CTA has no effect elsewhere.
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MonitorDot } from 'lucide-react';
|
||||
|
||||
// Lightweight, dependency-free macOS detection (see ccs-bar-banner.tsx).
|
||||
const isMacOS =
|
||||
typeof navigator !== 'undefined' &&
|
||||
/Mac|iPhone|iPad/i.test(navigator.userAgent || navigator.platform || '');
|
||||
|
||||
interface CcsBarPromoCardProps {
|
||||
onInstallClick: () => void;
|
||||
}
|
||||
|
||||
export function CcsBarPromoCard({ onInstallClick }: CcsBarPromoCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isMacOS) return null;
|
||||
|
||||
return (
|
||||
<div className="p-3 border-t bg-gradient-to-r from-accent/5 to-accent/10 dark:from-accent/10 dark:to-accent/15">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 bg-accent/10 dark:bg-accent/20 rounded shrink-0">
|
||||
<MonitorDot className="w-4 h-4 text-accent" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-accent dark:text-accent-foreground">
|
||||
{t('ccsBarPromo.title')}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground truncate">
|
||||
{t('ccsBarPromo.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onInstallClick}
|
||||
className="h-7 px-2 text-accent hover:text-accent hover:bg-accent/10 dark:hover:bg-accent/20"
|
||||
>
|
||||
<MonitorDot className="w-3 h-3 mr-1" />
|
||||
<span className="text-xs">{t('ccsBarPromo.install')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,5 +20,10 @@ export { OpenRouterModelPicker } from './openrouter-model-picker';
|
||||
export { OpenRouterPromoCard } from './openrouter-promo-card';
|
||||
export { OpenRouterQuickStart } from './openrouter-quick-start';
|
||||
export { AlibabaCodingPlanPromoCard } from './alibaba-coding-plan-promo-card';
|
||||
|
||||
// CCS Bar (native macOS menu-bar app) promo components
|
||||
export { CcsBarBanner } from './ccs-bar-banner';
|
||||
export { CcsBarPromoCard } from './ccs-bar-promo-card';
|
||||
|
||||
export { ModelTierMapping } from './model-tier-mapping';
|
||||
export type { TierMapping } from './model-tier-mapping';
|
||||
|
||||
@@ -2475,6 +2475,17 @@ const resources = {
|
||||
title: 'OpenRouter',
|
||||
description: 'Access hundreds of models from one API endpoint.',
|
||||
},
|
||||
ccsBarBanner: {
|
||||
new: 'NEW',
|
||||
title: 'CCS Bar for macOS',
|
||||
description: 'See live subscription quota and usage from your menu bar. Install with',
|
||||
install: 'Install',
|
||||
},
|
||||
ccsBarPromo: {
|
||||
title: 'CCS Bar (macOS)',
|
||||
description: 'Live quota and usage in your menu bar.',
|
||||
install: 'Install',
|
||||
},
|
||||
profileCard: {
|
||||
profile: 'Profile',
|
||||
openRouter: 'OpenRouter profile',
|
||||
|
||||
@@ -22,6 +22,8 @@ import { OpenRouterBanner } from '@/components/profiles/openrouter-banner';
|
||||
import { OpenRouterQuickStart } from '@/components/profiles/openrouter-quick-start';
|
||||
import { OpenRouterPromoCard } from '@/components/profiles/openrouter-promo-card';
|
||||
import { AlibabaCodingPlanPromoCard } from '@/components/profiles/alibaba-coding-plan-promo-card';
|
||||
import { CcsBarBanner } from '@/components/profiles/ccs-bar-banner';
|
||||
import { CcsBarPromoCard } from '@/components/profiles/ccs-bar-promo-card';
|
||||
import {
|
||||
useProfiles,
|
||||
useDeleteProfile,
|
||||
@@ -41,6 +43,15 @@ import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
// CCS Bar is installed via the `ccs bar install` CLI command, not from the
|
||||
// dashboard. The promo CTA therefore opens the user-facing docs page where the
|
||||
// install/launch steps live, rather than triggering an in-app action.
|
||||
const CCS_BAR_DOCS_URL = 'https://github.com/kaitranntt/ccs/blob/main/docs/ccs-bar.md';
|
||||
|
||||
function openCcsBarDocs() {
|
||||
window.open(CCS_BAR_DOCS_URL, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
export function ApiPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -213,6 +224,7 @@ export function ApiPage() {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||
<OpenRouterBanner onCreateClick={() => setCreateDialogOpen(true)} />
|
||||
<CcsBarBanner onInstallClick={() => openCcsBarDocs()} />
|
||||
<div className="flex-1 flex min-h-0 overflow-hidden">
|
||||
<div className="w-80 border-r flex flex-col bg-muted/30">
|
||||
<div className="p-4 border-b bg-background">
|
||||
@@ -363,6 +375,7 @@ export function ApiPage() {
|
||||
setCreateDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
<CcsBarPromoCard onInstallClick={() => openCcsBarDocs()} />
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col min-w-0 overflow-hidden">
|
||||
|
||||
Reference in New Issue
Block a user