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:
Kai (Tam Nhu) Tran
2026-06-09 18:49:29 -04:00
committed by GitHub
78 changed files with 13545 additions and 46 deletions
+4
View File
@@ -62,3 +62,7 @@ tests/mocks/fixtures/*.js
tests/mocks/fixtures/*.d.ts
tests/mocks/fixtures/*.js.map
tests/mocks/fixtures/*.d.ts.map
# Bar demo scaffolding (local dev only)
_serve*.ts
_serve*.log
+84
View File
@@ -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
```
+5
View File
@@ -0,0 +1,5 @@
.build/
dist/
*.zip
DerivedData/
*.xcodeproj
+23
View File
@@ -0,0 +1,23 @@
// swift-tools-version:5.9
import PackageDescription
// CCS Bar - native macOS menu bar client for CCS.
//
// Build/test note: full Xcode (and therefore XCTest) is not required. The
// testable logic lives in the pure-Foundation `CCSBarCore` target and is
// exercised by the `ccs-bar-check` executable (an assert harness) so it runs
// on a CommandLineTools-only toolchain. The SwiftUI app target is added once
// the core is verified.
let package = Package(
name: "CCSBar",
platforms: [.macOS(.v13)],
products: [
.executable(name: "CCSBar", targets: ["CCSBarApp"]),
.executable(name: "ccs-bar-check", targets: ["CCSBarCheck"]),
],
targets: [
.target(name: "CCSBarCore"),
.executableTarget(name: "CCSBarApp", dependencies: ["CCSBarCore"]),
.executableTarget(name: "CCSBarCheck", dependencies: ["CCSBarCore"]),
]
)
+46
View File
@@ -0,0 +1,46 @@
# CCS Bar (macOS)
Native SwiftUI menu bar app for CCS. A thin client of the CCS local web-server:
it glances per-account quota, cost and tier, and performs account control
(pause/resume, set default, solo, tier-lock) from the menu bar.
The app never talks to a provider directly. Every call goes to `localhost`, and
CCS performs any provider fetch server-side. Opening the menu fires a debounced
force-refresh so the glance reflects live data without blocking the UI.
## Layout
- `Sources/CCSBarCore` — pure Foundation logic (no SwiftUI): API client,
discovery handshake, models, formatting, refresh debounce. Fully unit-tested.
- `Sources/CCSBarApp` — SwiftUI `MenuBarExtra` app: view-model + views.
- `Sources/CCSBarCheck` — runnable assert harness used in place of XCTest
(XCTest ships with full Xcode; this builds on a CommandLineTools toolchain).
## Build and test
Requires a Swift 5.9+ toolchain (CommandLineTools is enough; full Xcode not
required for build/test).
```bash
swift build # build all targets, including the app
swift run ccs-bar-check # run the logic tests (exits non-zero on failure)
```
## Discovery
The app reads `~/.ccs/bar.json` (written by `ccs bar launch`):
```json
{ "baseUrl": "http://127.0.0.1:3000", "port": 3000, "authMode": "loopback" }
```
v1 supports `authMode: "loopback"` only (dashboard auth disabled, localhost).
## Packaging
`Scripts/package_app.sh` assembles `CCS Bar.app` from a release build and signs
it. v1 uses ad-hoc signing (`CCS_BAR_SIGNING=adhoc`, the default); users open it
the first time via right-click then Open, or clear quarantine with
`xattr -dr com.apple.quarantine "/Applications/CCS Bar.app"`. Developer ID
signing + notarization (`CCS_BAR_SIGNING=developer-id`) is the public-launch
path and is not required for ad-hoc distribution.
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>CCSBar</string>
<key>CFBundleIdentifier</key>
<string>ca.kaitran.ccs.bar</string>
<key>CFBundleName</key>
<string>CCS Bar</string>
<key>CFBundleDisplayName</key>
<string>CCS Bar</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>__VERSION__</string>
<key>CFBundleVersion</key>
<string>__VERSION__</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>CCS Bar</string>
</dict>
</plist>
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Assemble and sign "CCS Bar.app" from a release build.
#
# Signing mode (CCS_BAR_SIGNING):
# adhoc (default) ad-hoc sign with `codesign -s -`. Free, no Apple
# Developer account. Users open via right-click > Open or clear
# quarantine with `xattr -dr com.apple.quarantine`.
# developer-id Sign with a Developer ID Application identity (set
# CCS_BAR_SIGN_IDENTITY) for the notarized public-launch path.
#
# Usage:
# ./Scripts/package_app.sh [version]
# CCS_BAR_SIGNING=developer-id CCS_BAR_SIGN_IDENTITY="Developer ID Application: ..." ./Scripts/package_app.sh 0.1.0
set -euo pipefail
VERSION="${1:-0.0.0}"
SIGNING="${CCS_BAR_SIGNING:-adhoc}"
APP_NAME="CCS Bar"
EXEC_NAME="CCSBar"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DIST="$ROOT/dist"
APP="$DIST/$APP_NAME.app"
echo "[i] Building release binary..."
( cd "$ROOT" && swift build -c release )
BIN="$ROOT/.build/release/$EXEC_NAME"
if [[ ! -x "$BIN" ]]; then
echo "[X] Release binary not found at $BIN" >&2
exit 1
fi
echo "[i] Assembling $APP_NAME.app (version $VERSION)..."
rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
cp "$BIN" "$APP/Contents/MacOS/$EXEC_NAME"
sed "s/__VERSION__/$VERSION/g" "$ROOT/Resources/Info.plist" > "$APP/Contents/Info.plist"
# Bundle the CCS icon assets (menu-bar color/template + header logo) so
# Bundle.main can resolve them at runtime.
if [[ -d "$ROOT/Resources/Assets" ]]; then
cp "$ROOT/Resources/Assets/"*.png "$APP/Contents/Resources/" 2>/dev/null || true
fi
echo "[i] Signing ($SIGNING)..."
case "$SIGNING" in
adhoc)
codesign --force --deep --sign - "$APP"
;;
developer-id)
: "${CCS_BAR_SIGN_IDENTITY:?Set CCS_BAR_SIGN_IDENTITY for developer-id signing}"
codesign --force --deep --options runtime --timestamp \
--sign "$CCS_BAR_SIGN_IDENTITY" "$APP"
echo "[i] Signed with Developer ID. Notarize before public distribution:"
echo " xcrun notarytool submit <zip> --keychain-profile <profile> --wait"
;;
*)
echo "[X] Unknown CCS_BAR_SIGNING: $SIGNING (expected adhoc|developer-id)" >&2
exit 1
;;
esac
ZIP="$DIST/CCS-Bar.app.zip"
echo "[i] Zipping -> $ZIP"
rm -f "$ZIP"
( cd "$DIST" && ditto -c -k --keepParent "$APP_NAME.app" "CCS-Bar.app.zip" )
echo "[OK] Packaged: $APP"
echo "[OK] Asset: $ZIP"
if [[ "$SIGNING" == "adhoc" ]]; then
echo "[!] Ad-hoc build: first launch needs right-click > Open, or"
echo " xattr -dr com.apple.quarantine \"/Applications/$APP_NAME.app\""
fi
echo "[i] To publish: gh release upload ccs-bar-latest \"$ZIP\" --clobber"
@@ -0,0 +1,209 @@
import SwiftUI
import CCSBarCore
/// Demoted spend strip + surface/model breakdown.
///
/// Spend is informational pool context, NEVER the headline so the loud 2×2
/// StatCell grid + 28pt sparkline that used to dominate the dropdown collapses
/// into one muted caption line ("today $NN · 7d $N.Nk") with an optional thin
/// inline sparkline. The By-surface / Top-models breakdowns stay, tightened and
/// subordinate, below the pool accounts.
///
/// When the trailing 30 days carry no spend the strip reads the honest idle line
/// ("No usage in N days") instead of three dead "$0.00" cells.
struct BarAnalyticsView: View {
@Environment(\.barTheme) private var theme
let analytics: BarAnalytics
/// Which slot of the dropdown this instance renders. `.spend` is the thin strip
/// placed below the subscriptions cockpit; `.breakdown` is the by-surface /
/// top-models detail placed below the pool accounts.
enum Section { case spend, breakdown }
var section: Section = .spend
/// Controls whether the spend sparkline renders as bars or a line graph.
/// Passed in by BarMenuView so it reflects the user's persisted choice live.
var spendChartStyle: SpendChartStyle = .bars
/// Inline flip for the spend chart style, surfaced as a small toggle in the
/// Spend header (using the otherwise-blank space) so the user switches
/// bars/line in place rather than digging into Settings. nil for `.breakdown`.
var onToggleSpendStyle: (() -> Void)? = nil
private var lastActive: String? {
BarFormatting.lastActiveLabel(
iso: analytics.lastActivityAt, daysSince: analytics.daysSinceLastActivity)
}
var body: some View {
switch section {
case .spend:
spendStrip
case .breakdown:
breakdown
}
}
/// By-surface + top-models detail, tightened and subordinate.
@ViewBuilder private var breakdown: some View {
VStack(alignment: .leading, spacing: 8) {
// Surface breakdown: "how much Claude Code vs Codex" only shown when
// the backend supplies at least one surface entry. Top 5 keeps it compact.
if !analytics.bySurface.isEmpty {
SectionLabel("By surface")
let peakSurface = analytics.bySurface.map(\.cost).max() ?? 1
ForEach(analytics.bySurface.prefix(5)) { surface in
SurfaceBar(surface: surface, peak: peakSurface)
}
}
// Top models.
if !analytics.topModels.isEmpty {
let scope = analytics.topModelsWindow == "30d" ? "30d" : "all-time"
SectionLabel("Top models · \(scope)")
let peak = analytics.topModels.map(\.cost).max() ?? 1
ForEach(analytics.topModels.prefix(4)) { model in
ModelBar(model: model, peak: peak)
}
}
}
}
/// True when there's any surface/model detail worth a divider + section.
var hasBreakdown: Bool {
!analytics.bySurface.isEmpty || !analytics.topModels.isEmpty
}
/// The collapsed informational spend strip: a "SPEND" label, one muted caption
/// line, and a thin inline 30-day sparkline when there is real spend.
private var spendStrip: some View {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 6) {
SectionLabel("Spend")
Spacer()
// Inline bars/line toggle in the header's blank space no Settings trip.
if let toggle = onToggleSpendStyle, analytics.hasRecentData, !sparklineIsEmpty {
Button(action: toggle) {
Image(
systemName: spendChartStyle == .bars
? "chart.line.uptrend.xyaxis" : "chart.bar.fill"
)
.font(.system(size: 10))
}
.buttonStyle(.borderless)
.foregroundStyle(.tertiary)
.help("Spend graph: switch to \(spendChartStyle == .bars ? "line" : "bars")")
}
}
if analytics.hasRecentData {
Text(spendCaption)
.font(.caption2)
.foregroundStyle(.secondary)
if !sparklineIsEmpty {
// height: 30 (up from 18) so daily spend gradations are clearly readable.
Sparkline(values: analytics.byDay.map(\.cost), accent: theme.accent,
style: spendChartStyle)
.frame(height: 30)
}
} else {
Text(idleCaption)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
/// One-line rollup: "today $NN · 7d $N.Nk · 30d $N.Nk".
private var spendCaption: String {
"today \(BarFormatting.money(analytics.today.cost))"
+ " · 7d \(BarFormatting.money(analytics.last7d.cost))"
+ " · 30d \(BarFormatting.money(analytics.last30d.cost))"
}
/// Honest idle caption when there's no recent spend, folding in last-active.
private var idleCaption: String {
let headline =
analytics.daysSinceLastActivity.map { "No usage in \($0) days" } ?? "No usage in 30 days"
if let lastActive { return "\(headline) · \(lastActive.lowercased())" }
return headline
}
private var sparklineIsEmpty: Bool {
analytics.byDay.allSatisfy { $0.cost <= 0 }
}
}
/// One usage-surface row: surface name + proportional accent bar + cost and
/// request count. Mirrors ModelBar visually so the two sections feel cohesive.
private struct SurfaceBar: View {
@Environment(\.barTheme) private var theme
let surface: BarAnalyticsSurface
let peak: Double
var body: some View {
GeometryReader { geo in
let fraction = peak > 0 ? CGFloat(surface.cost / peak) : 0
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 5)
.fill(theme.accent.opacity(0.16))
.frame(width: max(8, geo.size.width * fraction))
HStack {
Text(surface.surface)
.font(.caption)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
HStack(spacing: 4) {
Text(BarFormatting.count(surface.requests))
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.tertiary)
Text(BarFormatting.money(surface.cost))
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 10)
}
}
.frame(height: 26)
}
}
/// One top-model row: name + spend with a proportional accent bar behind.
private struct ModelBar: View {
@Environment(\.barTheme) private var theme
let model: BarAnalytics.Model
let peak: Double
var body: some View {
GeometryReader { geo in
let fraction = peak > 0 ? CGFloat(model.cost / peak) : 0
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 5)
.fill(theme.accent.opacity(0.16))
.frame(width: max(8, geo.size.width * fraction))
HStack {
Text(model.model)
.font(.caption)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
Text(BarFormatting.money(model.cost))
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 10)
}
}
.frame(height: 26)
}
}
/// Uppercase section divider label.
struct SectionLabel: View {
let text: String
init(_ text: String) { self.text = text }
var body: some View {
Text(text.uppercased())
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.secondary)
.padding(.top, 1)
}
}
@@ -0,0 +1,54 @@
import Foundation
import CCSBarCore
/// App-side date phrasing for the subscription card. Kept out of Core so the
/// shared formatting contract there stays untouched. Parses ISO-8601 the same
/// way Core does (with and without fractional seconds) so timestamp handling
/// matches the rest of the bar.
enum BarCardFormatting {
/// Parse an ISO-8601 timestamp, tolerating an optional fractional-seconds
/// component. Mirrors Core's parser, which is module-internal there.
private static func isoDate(_ iso: String) -> Date? {
let withFraction = ISO8601DateFormatter()
withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let d = withFraction.date(from: iso) { return d }
let plain = ISO8601DateFormatter()
plain.formatOptions = [.withInternetDateTime]
return plain.date(from: iso)
}
/// Compact reset form for the per-window bar chips and split lines:
/// <24h compact duration via BarQuotaGauge.compactDuration (e.g. "3h 15m", "22m")
/// <7d weekday abbreviation (e.g. "Fri")
/// >=7d calendar date (e.g. "Jun 14")
/// Returns nil for a missing/unparseable timestamp (caller omits the clause).
static func shortReset(iso: String?, now: Date) -> String? {
guard let iso, let date = isoDate(iso) else { return nil }
let secs = date.timeIntervalSince(now)
if secs <= 0 { return "due" }
if secs < 24 * 3600 {
// Delegate to Core's authoritative compactDuration so both layers are
// consistent and the days-tier is automatically handled if ever needed.
let totalMinutes = Int(secs / 60)
return BarQuotaGauge.compactDuration(minutes: totalMinutes)
}
let fmt = DateFormatter()
fmt.locale = Locale(identifier: "en_US_POSIX")
if secs < 7 * 24 * 3600 {
fmt.dateFormat = "EEE" // weekday, e.g. "Fri"
} else {
fmt.dateFormat = "MMM d" // e.g. "Jun 14"
}
return fmt.string(from: date)
}
/// Local wall-clock "HH:mm" for the Codex stale footnote, e.g. "13:42".
/// Returns nil for a missing/unparseable timestamp.
static func clockTime(iso: String?) -> String? {
guard let iso, let date = isoDate(iso) else { return nil }
let fmt = DateFormatter()
fmt.locale = Locale(identifier: "en_US_POSIX")
fmt.dateFormat = "HH:mm"
return fmt.string(from: date)
}
}
@@ -0,0 +1,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 greenambercoralred 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")
/// 1h24h "Hh Mm" (e.g. 195m "3h 15m")
/// < 1h "Mm" (e.g. 35m "35m")
///
/// Named `compactDuration` and `public` so it is reusable from App-side
/// formatting (BarCardFormatting) without duplicating the logic.
public static func compactDuration(minutes: Int) -> String {
let totalHours = minutes / 60
let m = minutes % 60
if totalHours >= 24 {
let d = totalHours / 24
let h = totalHours % 24
return "\(d)d \(h)h"
}
if totalHours > 0 { return "\(totalHours)h \(m)m" }
return "\(m)m"
}
/// Header "most room" leader: among subscription rows that have a binding
/// window, the one whose BINDING window has the HIGHEST remaining%. Rows with
/// no binding window (error/reauth) are excluded. Tie-breaks alphabetically by
/// display name (falling back to provider). Returns nil with fewer than two
/// eligible subscriptions (the header is suppressed below that).
public static func headroomLeader(_ rows: [BarSummaryRow]) -> (label: String, remainingPercent: Double)? {
let eligible: [(label: String, remaining: Double)] = rows.compactMap { row in
guard let binding = selectBindingWindow(row.quotaWindows ?? []) else { return nil }
let label = row.displayName ?? row.provider
return (label, binding.remainingPercent)
}
guard eligible.count >= 2 else { return nil }
let leader = eligible.max { a, b in
if a.remaining != b.remaining { return a.remaining < b.remaining }
// Highest remaining wins; alphabetical tie-break (smaller name "wins" max
// only when remaining is equal, so invert the name comparison).
return a.label > b.label
}
guard let leader else { return nil }
return (leader.label, leader.remaining)
}
}
@@ -0,0 +1,173 @@
import Foundation
/// One quota window for a native subscription row (Claude/Codex).
///
/// Carries BOTH `usedPercent` and `remainingPercent` verbatim from the backend
/// so the bar never re-derives one from the other (a single source of truth
/// avoids rounding drift between the collapsed glance value and the per-window
/// pace math). `windowMinutes` is the window length (300 = 5h, 10080 = 7d) used
/// by the burn-rate projection; nil when the backend could not determine it.
///
/// JSON: the parent field serializes to "quota_windows" (snake_case), but the
/// inner keys stay camelCase to match the live serializer.
public struct QuotaWindowDetail: Codable, Sendable, Equatable, Identifiable {
public let key: String
public let label: String
public let usedPercent: Double
public let remainingPercent: Double
public let resetAt: String?
public let windowMinutes: Int?
/// SwiftUI list identity: the window key is unique within a row.
public var id: String { key }
public init(
key: String,
label: String,
usedPercent: Double,
remainingPercent: Double,
resetAt: String? = nil,
windowMinutes: Int? = nil
) {
self.key = key
self.label = label
self.usedPercent = usedPercent
self.remainingPercent = remainingPercent
self.resetAt = resetAt
self.windowMinutes = windowMinutes
}
}
/// One account row in the menu-bar glance.
///
/// Mirrors the `GET /api/bar/summary` payload exactly (mixed snake_case and
/// camelCase keys, matching the CCS web-server response).
public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
public let accountId: String
public let provider: String
public let displayName: String?
public let tier: String?
public let paused: Bool
public let quotaPercentage: Double?
/// Tri-state quota availability: "ok" (provider has a quota API and the fetch
/// succeeded), "unsupported" (provider has no quota API at all, e.g. ghcp/kiro),
/// or "error" (should report quota but the fetch failed/timed out/needs reauth).
/// Drives "no quota" (unsupported) vs "quota ?" (error) so a bare "--" never
/// conflates the two.
public let quotaStatus: String
public let nextReset: String?
/// True when this is the provider's default account; drives the active/default badge.
public let isDefault: Bool
/// ISO timestamp this account was last used, null if never/unknown.
public let lastActivityAt: String?
public let todayCost: Double?
public let health: String
public let cached: Bool
public let fetchedAt: String?
public let needsReauth: Bool
/// Native-only per-window quota breakdown (Claude: 5h/week/opus/sonnet,
/// Codex: 5h/week). nil for CLIProxy pool rows, which omit "quota_windows"
/// entirely so legacy payloads decode unchanged (backward compatible).
public let quotaWindows: [QuotaWindowDetail]?
/// Native-only ISO mtime of the source session that supplied a STALE Codex
/// reading. Present only when the data is stale; nil otherwise. Drives the
/// "as of HH:mm (older session)" footnote without faking a "live" badge.
public let staleAsOf: String?
/// Stable identity for SwiftUI lists: provider-scoped account id.
public var id: String { "\(provider):\(accountId)" }
enum CodingKeys: String, CodingKey {
case accountId = "account_id"
case provider
case displayName
case tier
case paused
case quotaPercentage = "quota_percentage"
case quotaStatus
case nextReset = "next_reset"
case isDefault = "is_default"
case lastActivityAt = "last_activity_at"
case todayCost = "today_cost"
case health
case cached
case fetchedAt
case needsReauth
case quotaWindows = "quota_windows"
case staleAsOf = "stale_as_of"
}
public init(
accountId: String,
provider: String,
displayName: String? = nil,
tier: String? = nil,
paused: Bool = false,
quotaPercentage: Double? = nil,
quotaStatus: String = "ok",
nextReset: String? = nil,
isDefault: Bool = false,
lastActivityAt: String? = nil,
todayCost: Double? = nil,
health: String = "ok",
cached: Bool = false,
fetchedAt: String? = nil,
needsReauth: Bool = false,
quotaWindows: [QuotaWindowDetail]? = nil,
staleAsOf: String? = nil
) {
self.accountId = accountId
self.provider = provider
self.displayName = displayName
self.tier = tier
self.paused = paused
self.quotaPercentage = quotaPercentage
self.quotaStatus = quotaStatus
self.nextReset = nextReset
self.isDefault = isDefault
self.lastActivityAt = lastActivityAt
self.todayCost = todayCost
self.health = health
self.cached = cached
self.fetchedAt = fetchedAt
self.needsReauth = needsReauth
self.quotaWindows = quotaWindows
self.staleAsOf = staleAsOf
}
/// Resilient decode: the two native-only keys are decoded with
/// `decodeIfPresent` so a legacy payload (no "quota_windows"/"stale_as_of")
/// yields nil rather than a decode failure. All other keys keep synthesized
/// behavior.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
accountId = try c.decode(String.self, forKey: .accountId)
provider = try c.decode(String.self, forKey: .provider)
displayName = try c.decodeIfPresent(String.self, forKey: .displayName)
tier = try c.decodeIfPresent(String.self, forKey: .tier)
paused = try c.decode(Bool.self, forKey: .paused)
quotaPercentage = try c.decodeIfPresent(Double.self, forKey: .quotaPercentage)
quotaStatus = try c.decode(String.self, forKey: .quotaStatus)
nextReset = try c.decodeIfPresent(String.self, forKey: .nextReset)
isDefault = try c.decode(Bool.self, forKey: .isDefault)
lastActivityAt = try c.decodeIfPresent(String.self, forKey: .lastActivityAt)
todayCost = try c.decodeIfPresent(Double.self, forKey: .todayCost)
health = try c.decode(String.self, forKey: .health)
cached = try c.decode(Bool.self, forKey: .cached)
fetchedAt = try c.decodeIfPresent(String.self, forKey: .fetchedAt)
needsReauth = try c.decode(Bool.self, forKey: .needsReauth)
quotaWindows = try c.decodeIfPresent([QuotaWindowDetail].self, forKey: .quotaWindows)
staleAsOf = try c.decodeIfPresent(String.self, forKey: .staleAsOf)
}
}
extension BarSummaryRow {
/// Health rendered as an ASCII-safe dot for the dropdown.
public var healthDot: String {
switch health {
case "error": return "X"
case "warning": return "!"
default: return "OK"
}
}
}
+196
View File
@@ -0,0 +1,196 @@
import SwiftUI
// Theme token model for the menu-bar dropdown.
//
// Lives in CCSBarCore (not the SwiftUI app target) so the assert harness can
// import and verify the palette/enum/resolver without a full Xcode/XCTest
// toolchain. This is the ONE file in CCSBarCore that imports SwiftUI every
// other Core file stays Foundation-only. SwiftUI's ColorScheme/Color compile
// and run on the CommandLineTools toolchain, which is why this is safe here.
// MARK: - Raw palette (harness-assertable)
/// A plain RGB triple. We keep raw Doubles (not SwiftUI `Color`) as the source
/// of truth because `Color` equality is unreliable for tests two Colors built
/// from identical components are not guaranteed `==`. Asserting on these Doubles
/// is exact, so the dark-regression lock and light-value lock are byte-precise.
public struct RGB: Equatable, Sendable {
public let r: Double
public let g: Double
public let b: Double
public init(_ r: Double, _ g: Double, _ b: Double) {
self.r = r
self.g = g
self.b = b
}
}
/// Named color triples for one appearance. Pure data so it round-trips through
/// the harness with exact equality.
public struct BarPalette: Equatable, Sendable {
public let accentRGB: RGB
public let subscriptionRGB: RGB
public let bandGreenRGB: RGB
public let bandAmberRGB: RGB
public let bandCoralRGB: RGB
public let bandRedRGB: RGB
/// Light-mode window plate. In dark mode the window defers to the native
/// MenuBarExtra material, so this value is unused there (windowSurface == .clear).
public let windowSurfaceRGB: RGB
public init(
accentRGB: RGB, subscriptionRGB: RGB, bandGreenRGB: RGB, bandAmberRGB: RGB,
bandCoralRGB: RGB, bandRedRGB: RGB, windowSurfaceRGB: RGB
) {
self.accentRGB = accentRGB
self.subscriptionRGB = subscriptionRGB
self.bandGreenRGB = bandGreenRGB
self.bandAmberRGB = bandAmberRGB
self.bandCoralRGB = bandCoralRGB
self.bandRedRGB = bandRedRGB
self.windowSurfaceRGB = windowSurfaceRGB
}
/// DARK = today's exact values, lifted verbatim from the original Sparkline
/// `BarTheme` enum. These are LOCKED: any drift fails the harness, guaranteeing
/// byte-identical rendering on upgrade for users who stay on the default theme.
public static let dark = BarPalette(
accentRGB: RGB(0.886, 0.451, 0.137), // #E2732A CCS orange
subscriptionRGB: RGB(0.357, 0.388, 0.851), // #5B63D9 indigo
bandGreenRGB: RGB(0.36, 0.74, 0.56), // #5CBC8F emerald
bandAmberRGB: RGB(0.86, 0.67, 0.31), // #DBAB4F gold
bandCoralRGB: RGB(0.91, 0.46, 0.36), // #E8755C warning
bandRedRGB: RGB(0.85, 0.34, 0.31), // #D9564F critical
windowSurfaceRGB: RGB(0, 0, 0) // unused in dark (windowSurface == .clear)
)
/// LIGHT = deepened/saturated variants tuned for legibility on a ~#F5F5F7
/// white plate. The dark-tuned muted values read too pale on white, so each
/// themed token is darkened with more saturation while preserving the
/// greenambercoralred 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',
+68 -24
View File
@@ -218,25 +218,19 @@ function buildEmptyResult(
}
/**
* Fetch quota for a single Claude account.
* Run the Anthropic OAuth usage fetch loop for a known-good access token.
*
* This is the single Anthropic-call surface shared by both the CLIProxy-managed
* path (fetchClaudeQuota) and the native-login path (fetchClaudeQuotaWithToken).
* It owns the 401/403/404/429/5xx branch logic, the bounded retry loop, and the
* window normalization so neither caller re-implements the hostile-endpoint
* handling. The callers differ only in WHERE the token comes from.
*/
export async function fetchClaudeQuota(
async function runClaudeUsageFetch(
accessToken: string,
accountId: string,
verbose = false
verbose: boolean
): Promise<ClaudeQuotaResult> {
const authData = await readClaudeAuthData(accountId);
if (!authData) {
return buildEmptyResult('Auth file not found for Claude account', accountId);
}
if (authData.isExpired) {
return buildEmptyResult(
'Token expired - re-authenticate with ccs cliproxy auth claude',
accountId,
true
);
}
let lastError = 'Unknown error';
for (let attempt = 1; attempt <= CLAUDE_QUOTA_MAX_ATTEMPTS; attempt++) {
@@ -248,7 +242,7 @@ export async function fetchClaudeQuota(
method: 'GET',
signal: controller.signal,
headers: {
Authorization: `Bearer ${authData.accessToken}`,
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
'anthropic-beta': CLAUDE_OAUTH_BETA_HEADER,
@@ -283,15 +277,23 @@ export async function fetchClaudeQuota(
lastError =
(await readResponseErrorMessage(response)) ||
`Claude OAuth usage API error: ${response.status}`;
if (
attempt < CLAUDE_QUOTA_MAX_ATTEMPTS &&
(response.status === 429 || response.status >= 500)
) {
// Surface the upstream status + Retry-After so an outer caller (the
// native collector's circuit-breaker) can honor backoff guidance.
const retryAfter = response.headers.get('retry-after');
// Do not inner-retry 429 with no delay; the outer cache + circuit breaker
// honor Retry-After and bound total volume. Inner retry stays for
// transient 5xx only.
if (attempt < CLAUDE_QUOTA_MAX_ATTEMPTS && response.status >= 500) {
clearTimeout(timeoutId);
continue;
}
clearTimeout(timeoutId);
return buildEmptyResult(lastError, accountId);
return {
...buildEmptyResult(lastError, accountId),
httpStatus: response.status,
retryable: response.status === 429 || response.status >= 500,
...(retryAfter ? { errorDetail: `retry-after:${retryAfter}` } : {}),
};
}
let payload: unknown;
@@ -338,12 +340,54 @@ export async function fetchClaudeQuota(
if (attempt >= CLAUDE_QUOTA_MAX_ATTEMPTS) {
clearTimeout(timeoutId);
return buildEmptyResult(lastError, accountId);
return { ...buildEmptyResult(lastError, accountId), retryable: true };
}
}
}
return buildEmptyResult(lastError, accountId);
return { ...buildEmptyResult(lastError, accountId), retryable: true };
}
/**
* Fetch quota using a directly-supplied native OAuth access token.
*
* Reuses the exact Anthropic call + normalization as fetchClaudeQuota; the only
* difference is the token source (the logged-in Claude Code credential rather
* than a CLIProxy-managed auth file). Lives in this file so it can share the
* file-private beta header, timeout, attempt count, and branch logic.
*/
export async function fetchClaudeQuotaWithToken(
accessToken: string,
accountId = 'claude-code',
verbose = false
): Promise<ClaudeQuotaResult> {
if (!accessToken || accessToken.trim().length === 0) {
return buildEmptyResult('Missing native Claude access token', accountId, true);
}
return runClaudeUsageFetch(accessToken.trim(), accountId, verbose);
}
/**
* Fetch quota for a single Claude account.
*/
export async function fetchClaudeQuota(
accountId: string,
verbose = false
): Promise<ClaudeQuotaResult> {
const authData = await readClaudeAuthData(accountId);
if (!authData) {
return buildEmptyResult('Auth file not found for Claude account', accountId);
}
if (authData.isExpired) {
return buildEmptyResult(
'Token expired - re-authenticate with ccs cliproxy auth claude',
accountId,
true
);
}
return runClaudeUsageFetch(authData.accessToken, accountId, verbose);
}
/**
+4
View File
@@ -835,11 +835,15 @@ export async function fetchAccountQuota(
if (provider !== 'agy') {
const error = `Quota not supported for provider: ${provider}`;
if (verbose) console.error(`[!] Error: ${error}`);
// Stable machine code so callers branch on a code, not the human string.
// This is "no quota API for this provider", which is healthy — distinct
// from a transient fetch failure or an expired token.
return {
success: false,
models: [],
lastUpdated: Date.now(),
error,
errorCode: 'quota_not_supported',
};
}
+34 -1
View File
@@ -35,6 +35,7 @@ import {
import type { RuntimeMonitorConfig } from '../../config/unified-config-types';
import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade';
import { getTierLockForProvider } from '../../config/schemas/quota';
export type ManagedQuotaProvider = 'agy' | 'claude' | 'codex' | 'gemini' | 'ghcp';
type ManagedQuotaResult =
@@ -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');
+26 -1
View File
@@ -344,7 +344,9 @@ async function fetchManagementJson<T>(
}
}
async function fetchCliproxyAuthFiles(port?: number): Promise<CliproxyManagementAuthFile[] | null> {
export async function fetchCliproxyAuthFiles(
port?: number
): Promise<CliproxyManagementAuthFile[] | null> {
try {
const result = await fetchManagementJson<{ files?: CliproxyManagementAuthFile[] }>(
'/v0/management/auth-files',
@@ -367,6 +369,29 @@ export const __testExports = {
},
};
/**
* Build an auth_index → account email/id map from CLIProxy auth file metadata.
*
* Keys are stored as strings (String(auth_index)) so both numeric and string
* auth_index values resolve consistently via `map.get(String(auth_index))`.
*
* Entries missing either `auth_index` or `email` are silently skipped.
*
* @param authFiles Auth file records from /v0/management/auth-files
* @returns Map from String(auth_index) → email
*/
export function buildAuthIndexToAccountMap(
authFiles: CliproxyManagementAuthFile[]
): Map<string, string> {
const map = new Map<string, string>();
for (const file of authFiles) {
if (file.auth_index === undefined || file.auth_index === null) continue;
if (!file.email || file.email.trim().length === 0) continue;
map.set(String(file.auth_index), file.email.trim());
}
return map;
}
/** OpenAI-compatible model object from /v1/models endpoint */
export interface CliproxyModel {
id: string;
+47
View File
@@ -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));
}
+451
View File
@@ -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.');
}
+157
View File
@@ -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}"`);
}
}
}
+68
View File
@@ -0,0 +1,68 @@
/**
* `ccs bar uninstall` — remove CCS Bar.app from ~/Applications
* and clear the version pin at ~/.ccs/bar/.version.
*
* No-op (and no error) when neither the app nor the pin exists.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { getCcsDir } from '../../config/config-loader-facade';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface UninstallDeps {
getCcsDir: () => string;
getAppsDir: () => string;
appName: string;
}
// ---------------------------------------------------------------------------
// Implementation
// ---------------------------------------------------------------------------
export async function handleBarUninstall(
_args: string[],
deps: Partial<UninstallDeps> = {}
): Promise<void> {
const ccsDir = (deps.getCcsDir ?? (() => getCcsDir()))();
const appsDir = (deps.getAppsDir ?? (() => path.join(os.homedir(), 'Applications')))();
const appName = deps.appName ?? 'CCS Bar.app';
const appPath = path.join(appsDir, appName);
const versionPin = path.join(ccsDir, 'bar', '.version');
let removed = false;
// Remove the .app bundle (a directory on macOS).
if (fs.existsSync(appPath)) {
try {
fs.rmSync(appPath, { recursive: true, force: true });
console.log(`[OK] Removed ${appPath}`);
removed = true;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[X] Failed to remove ${appPath}: ${msg}`);
}
}
// Clear the version pin.
if (fs.existsSync(versionPin)) {
try {
fs.unlinkSync(versionPin);
removed = true;
} catch {
// Non-fatal — pin may already be gone.
}
}
if (!removed) {
console.log('[i] CCS Bar is not installed — nothing to remove.');
} else {
console.log('[OK] CCS Bar uninstalled.');
console.log('[i] Run `ccs bar install` to reinstall.');
}
}
+37
View File
@@ -0,0 +1,37 @@
/**
* `ccs bar --version` / `ccs bar version`
*
* Prints the CCS CLI version alongside the installed CCS Bar app version
* (read from ~/.ccs/bar/.version, if present).
*/
import * as fs from 'fs';
import * as path from 'path';
import { getVersion } from '../../utils/version';
import { getCcsDir } from '../../config/config-loader-facade';
function readInstalledBarVersion(ccsDir: string): string | null {
const versionFile = path.join(ccsDir, 'bar', '.version');
try {
const content = fs.readFileSync(versionFile, 'utf8').trim();
return content || null;
} catch {
return null;
}
}
export async function handleBarVersion(): Promise<void> {
const cliVersion = getVersion();
const ccsDir = getCcsDir();
const barVersion = readInstalledBarVersion(ccsDir);
// Finding #13: label each line unambiguously — CLI version vs installed Bar app version.
console.log(`[i] CCS CLI v${cliVersion}`);
if (barVersion) {
console.log(`[i] CCS Bar app: v${barVersion}`);
} else {
console.log('[i] CCS Bar app: not installed (run `ccs bar install`)');
}
process.exit(0);
}
+6
View File
@@ -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',
+7
View File
@@ -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> {
+19
View File
@@ -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');
}
+35 -2
View File
@@ -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.
*/
+133 -1
View File
@@ -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;
+616
View File
@@ -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;
+22
View File
@@ -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);
+419
View File
@@ -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 [];
}
}
+21 -3
View File
@@ -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),
};
}
+39
View File
@@ -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;
}
+2
View File
@@ -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);
});
+235
View File
@@ -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>
);
}
+5
View File
@@ -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';
+11
View File
@@ -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',
+13
View File
@@ -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">