diff --git a/macos-bar/.gitignore b/macos-bar/.gitignore
new file mode 100644
index 00000000..837dd9ee
--- /dev/null
+++ b/macos-bar/.gitignore
@@ -0,0 +1,5 @@
+.build/
+dist/
+*.zip
+DerivedData/
+*.xcodeproj
diff --git a/macos-bar/Package.swift b/macos-bar/Package.swift
new file mode 100644
index 00000000..8e8a642f
--- /dev/null
+++ b/macos-bar/Package.swift
@@ -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"]),
+ ]
+)
diff --git a/macos-bar/README.md b/macos-bar/README.md
new file mode 100644
index 00000000..796b072c
--- /dev/null
+++ b/macos-bar/README.md
@@ -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.
diff --git a/macos-bar/Resources/Info.plist b/macos-bar/Resources/Info.plist
new file mode 100644
index 00000000..73a77fb9
--- /dev/null
+++ b/macos-bar/Resources/Info.plist
@@ -0,0 +1,26 @@
+
+
+
+
+ CFBundleExecutable
+ CCSBar
+ CFBundleIdentifier
+ ca.kaitran.ccs.bar
+ CFBundleName
+ CCS Bar
+ CFBundleDisplayName
+ CCS Bar
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ __VERSION__
+ CFBundleVersion
+ __VERSION__
+ LSMinimumSystemVersion
+ 13.0
+ LSUIElement
+
+ NSHumanReadableCopyright
+ CCS Bar
+
+
diff --git a/macos-bar/Scripts/package_app.sh b/macos-bar/Scripts/package_app.sh
new file mode 100755
index 00000000..e23927d1
--- /dev/null
+++ b/macos-bar/Scripts/package_app.sh
@@ -0,0 +1,68 @@
+#!/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"
+
+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 --keychain-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"
diff --git a/macos-bar/Sources/CCSBarApp/BarMenuView.swift b/macos-bar/Sources/CCSBarApp/BarMenuView.swift
new file mode 100644
index 00000000..156b4991
--- /dev/null
+++ b/macos-bar/Sources/CCSBarApp/BarMenuView.swift
@@ -0,0 +1,121 @@
+import SwiftUI
+import AppKit
+import CCSBarCore
+
+/// Dropdown content for the menu bar: per-account rows + actions, an offline
+/// state when CCS isn't running, and footer controls.
+struct BarMenuView: View {
+ @ObservedObject var viewModel: BarViewModel
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ header
+
+ if viewModel.offline {
+ offlineState
+ } else if viewModel.rows.isEmpty {
+ Text("No accounts found")
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(viewModel.rows) { row in
+ BarRowView(row: row, viewModel: viewModel)
+ Divider()
+ }
+ }
+
+ footer
+ }
+ .padding(12)
+ .frame(width: 320)
+ .onAppear { viewModel.onOpen() }
+ }
+
+ private var header: some View {
+ HStack {
+ Text("CCS").font(.headline)
+ Spacer()
+ if viewModel.isRefreshing {
+ Text("refreshing…").font(.caption).foregroundStyle(.secondary)
+ }
+ }
+ }
+
+ private var offlineState: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("CCS is not running").font(.body)
+ Text("Start CCS, then reopen this menu.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Button("Retry") { viewModel.reconnect(); viewModel.onOpen() }
+ }
+ }
+
+ private var footer: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Button("Open dashboard") { openDashboard() }
+ Button("Refresh") { viewModel.onOpen() }
+ Button("Quit") { NSApplication.shared.terminate(nil) }
+ }
+ }
+
+ private func openDashboard() {
+ // The dashboard runs on the same host/port the bar reads from discovery.
+ if case .success(let discovery) = BarDiscovery.load(), let url = discovery.resolvedURL {
+ NSWorkspace.shared.open(url)
+ }
+ }
+}
+
+/// One account row: health dot, name, provider/tier/quota/paused subline, and
+/// a control menu.
+struct BarRowView: View {
+ let row: BarSummaryRow
+ @ObservedObject var viewModel: BarViewModel
+
+ var body: some View {
+ HStack(alignment: .top) {
+ Text(row.healthDot)
+ .font(.system(.caption, design: .monospaced))
+ .frame(width: 22, alignment: .leading)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(row.displayName ?? row.accountId)
+ .font(.body)
+ .lineLimit(1)
+ Text(subline)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+
+ Spacer()
+
+ Menu("Actions") {
+ if row.paused {
+ Button("Resume") { viewModel.resume(row) }
+ } else {
+ Button("Pause") { viewModel.pause(row) }
+ }
+ Button("Set as default") { viewModel.setDefault(row) }
+ Button("Solo (pause others)") { viewModel.solo(row) }
+ if let tier = row.tier {
+ Button("Lock to \(tier)") { viewModel.tierLock(row, tier: tier) }
+ }
+ Button("Clear tier lock") { viewModel.tierLock(row, tier: nil) }
+ }
+ .menuStyle(.borderlessButton)
+ .frame(width: 90)
+ }
+ }
+
+ private var subline: String {
+ var parts: [String] = [row.provider]
+ if let tier = row.tier { parts.append(tier) }
+ parts.append(BarFormatting.quotaLabel(row.quotaPercentage))
+ let cost = BarFormatting.costLabel(row.todayCost)
+ if !cost.isEmpty { parts.append(cost) }
+ if row.paused { parts.append("paused") }
+ if row.needsReauth { parts.append("needs reauth") }
+ return parts.joined(separator: " \u{00B7} ")
+ }
+}
diff --git a/macos-bar/Sources/CCSBarApp/BarViewModel.swift b/macos-bar/Sources/CCSBarApp/BarViewModel.swift
new file mode 100644
index 00000000..785fa2b4
--- /dev/null
+++ b/macos-bar/Sources/CCSBarApp/BarViewModel.swift
@@ -0,0 +1,110 @@
+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 offline = false
+ @Published var lastError: String?
+ @Published var isRefreshing = false
+
+ private let home: String
+ private var client: CCSBarClient?
+ private var debouncer = RefreshDebouncer(interval: 15)
+
+ init(home: String = NSHomeDirectory()) {
+ self.home = home
+ reconnect()
+ }
+
+ /// Compact status-bar title.
+ var statusTitle: String {
+ offline ? "CCS offline" : BarFormatting.statusTitle(rows: rows)
+ }
+
+ /// 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 }
+ }
+ }
+
+ // 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)
+ }
+}
diff --git a/macos-bar/Sources/CCSBarApp/CCSBarApp.swift b/macos-bar/Sources/CCSBarApp/CCSBarApp.swift
new file mode 100644
index 00000000..91ef8824
--- /dev/null
+++ b/macos-bar/Sources/CCSBarApp/CCSBarApp.swift
@@ -0,0 +1,18 @@
+import SwiftUI
+
+/// 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()
+
+ var body: some Scene {
+ MenuBarExtra {
+ BarMenuView(viewModel: viewModel)
+ } label: {
+ Text(viewModel.statusTitle)
+ }
+ .menuBarExtraStyle(.window)
+ }
+}
diff --git a/macos-bar/Sources/CCSBarCheck/main.swift b/macos-bar/Sources/CCSBarCheck/main.swift
new file mode 100644
index 00000000..77e49a70
--- /dev/null
+++ b/macos-bar/Sources/CCSBarCheck/main.swift
@@ -0,0 +1,211 @@
+import Foundation
+import CCSBarCore
+
+// Lightweight assert harness used in place of XCTest (unavailable without a
+// full Xcode install). Run with `swift run ccs-bar-check`; exits non-zero on
+// any failure so it works as a CI/test gate on a CommandLineTools toolchain.
+
+var failures = 0
+func check(_ condition: Bool, _ message: String) {
+ if condition {
+ print("[OK] \(message)")
+ } else {
+ print("[X] \(message)")
+ failures += 1
+ }
+}
+
+// Recording transport so CCSBarClient can be exercised without a live server.
+final class RequestRecorder: @unchecked Sendable {
+ var lastRequest: URLRequest?
+ var responseData = Data("[]".utf8)
+ var status = 200
+}
+struct RecordingTransport: HTTPTransport {
+ let recorder: RequestRecorder
+ func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
+ recorder.lastRequest = request
+ let http = HTTPURLResponse(
+ url: request.url!, statusCode: recorder.status, httpVersion: nil, headerFields: nil)!
+ return (recorder.responseData, http)
+ }
+}
+
+// MARK: BarSummaryRow decoding (mixed snake_case / camelCase keys)
+
+let summaryJSON = """
+[
+ {
+ "account_id": "alice@example.com",
+ "provider": "agy",
+ "displayName": "Alice (Ultra)",
+ "tier": "ultra",
+ "paused": false,
+ "quota_percentage": 82.4,
+ "next_reset": "2026-06-08T00:00:00Z",
+ "today_cost": 3.2,
+ "health": "ok",
+ "cached": true,
+ "fetchedAt": "2026-06-07T19:00:00Z",
+ "needsReauth": false
+ },
+ {
+ "account_id": "bob@example.com",
+ "provider": "codex",
+ "displayName": null,
+ "tier": null,
+ "paused": true,
+ "quota_percentage": null,
+ "next_reset": null,
+ "today_cost": null,
+ "health": "warning",
+ "cached": false,
+ "fetchedAt": null,
+ "needsReauth": true
+ }
+]
+"""
+
+do {
+ let rows = try JSONDecoder().decode([BarSummaryRow].self, from: Data(summaryJSON.utf8))
+ check(rows.count == 2, "decodes two rows")
+ check(rows[0].accountId == "alice@example.com", "maps account_id")
+ check(rows[0].quotaPercentage == 82.4, "maps quota_percentage")
+ check(rows[0].todayCost == 3.2, "maps today_cost")
+ check(rows[0].id == "agy:alice@example.com", "stable id is provider:account")
+ check(rows[1].quotaPercentage == nil, "null quota decodes to nil")
+ check(rows[1].paused == true, "maps paused")
+ check(rows[1].needsReauth == true, "maps needsReauth")
+ check(rows[1].healthDot == "!", "warning -> ! dot")
+ check(rows[0].healthDot == "OK", "ok -> OK dot")
+} catch {
+ check(false, "decoding threw: \(error)")
+}
+
+// MARK: BarDiscovery loading
+
+let tmp = NSTemporaryDirectory() + "ccs-bar-check-\(ProcessInfo.processInfo.globallyUniqueString)"
+let ccsDir = tmp + "/.ccs"
+try? FileManager.default.createDirectory(atPath: ccsDir, withIntermediateDirectories: true)
+let barJSON = """
+{ "baseUrl": "http://127.0.0.1:3210", "port": 3210, "authMode": "loopback" }
+"""
+try? barJSON.write(toFile: ccsDir + "/bar.json", atomically: true, encoding: .utf8)
+
+switch BarDiscovery.load(home: tmp) {
+case .success(let d):
+ check(d.port == 3210, "discovery reads port")
+ check(d.authMode == "loopback", "discovery reads authMode")
+ check(d.resolvedURL?.absoluteString == "http://127.0.0.1:3210", "resolvedURL from baseUrl")
+case .failure(let e):
+ check(false, "discovery load failed: \(e)")
+}
+
+let missingHome = NSTemporaryDirectory() + "ccs-bar-check-missing-\(ProcessInfo.processInfo.globallyUniqueString)"
+switch BarDiscovery.load(home: missingHome) {
+case .failure(.missing):
+ check(true, "absent bar.json -> .missing (offline state)")
+case .success:
+ check(false, "expected .missing for absent file")
+case .failure(let e):
+ check(false, "expected .missing, got \(e)")
+}
+
+// MARK: BarFormatting
+
+check(BarFormatting.quotaLabel(82.4) == "82%", "quota label rounds")
+check(BarFormatting.quotaLabel(nil) == "--", "nil quota -> --")
+check(BarFormatting.costLabel(3.2) == "$3.20", "cost label formats")
+check(BarFormatting.costLabel(0) == "", "zero cost hidden")
+check(BarFormatting.costLabel(nil) == "", "nil cost hidden")
+
+do {
+ let rows = try JSONDecoder().decode([BarSummaryRow].self, from: Data(summaryJSON.utf8))
+ let title = BarFormatting.statusTitle(rows: rows)
+ // Active rows only (bob is paused); alice leads. Total cost = 3.2.
+ check(title.contains("agy 82%"), "title shows leading active account + quota")
+ check(title.contains("$3.20"), "title shows total cost")
+}
+
+// leadRow features the account CLOSEST TO EXHAUSTION (lowest remaining %),
+// not the healthiest. quota_percentage is REMAINING quota.
+let twoActive = [
+ BarSummaryRow(accountId: "a", provider: "agy", quotaPercentage: 90, health: "ok"),
+ BarSummaryRow(accountId: "b", provider: "agy", quotaPercentage: 30, health: "ok"),
+]
+let twoTitle = BarFormatting.statusTitle(rows: twoActive)
+check(twoTitle.contains("30%"), "title features lowest-remaining (closest to exhaustion)")
+check(!twoTitle.contains("90%"), "title does not feature the healthiest account")
+
+// MARK: RefreshDebouncer (arms at decision time)
+
+var deb = RefreshDebouncer(interval: 15)
+let t0 = Date(timeIntervalSince1970: 1000)
+check(deb.shouldRefresh(now: t0), "first force-refresh proceeds")
+check(!deb.shouldRefresh(now: t0.addingTimeInterval(5)), "within 15s blocked")
+check(!deb.shouldRefresh(now: t0.addingTimeInterval(14.9)), "just before window end blocked")
+check(deb.shouldRefresh(now: t0.addingTimeInterval(15)), "at/after 15s proceeds")
+
+// MARK: CCSBarClient (recording transport)
+
+let recorder = RequestRecorder()
+recorder.responseData = Data(summaryJSON.utf8)
+let client = CCSBarClient(
+ baseURL: URL(string: "http://127.0.0.1:3210")!,
+ transport: RecordingTransport(recorder: recorder)
+)
+
+do {
+ let rows = try await client.summary(refresh: true)
+ check(rows.count == 2, "client.summary decodes rows")
+ check(
+ recorder.lastRequest?.url?.query?.contains("refresh=true") == true,
+ "summary(refresh: true) adds ?refresh=true")
+} catch {
+ check(false, "client.summary threw: \(error)")
+}
+
+recorder.responseData = Data("{}".utf8)
+recorder.lastRequest = nil
+do {
+ try await client.pause(provider: "agy", accountId: "alice@example.com")
+ check(recorder.lastRequest?.httpMethod == "POST", "pause is POST")
+ check(
+ recorder.lastRequest?.url?.path.hasSuffix("bulk-pause") == true,
+ "pause hits bulk-pause endpoint")
+} catch {
+ check(false, "pause threw: \(error)")
+}
+
+recorder.lastRequest = nil
+do {
+ try await client.tierLock(provider: "agy", tier: nil)
+ let body = recorder.lastRequest?.httpBody ?? Data()
+ let obj = (try? JSONSerialization.jsonObject(with: body)) as? [String: Any]
+ check(obj?["tier"] is NSNull, "tierLock(nil) serializes tier: null")
+ check((obj?["provider"] as? String) == "agy", "tierLock sends provider")
+} catch {
+ check(false, "tierLock threw: \(error)")
+}
+
+recorder.status = 409
+do {
+ _ = try await client.summary(refresh: false)
+ check(false, "non-200 summary should throw")
+} catch CCSBarClientError.httpStatus(let code) {
+ check(code == 409, "non-200 -> httpStatus(409)")
+} catch {
+ check(false, "wrong error type: \(error)")
+}
+recorder.status = 200
+
+// cleanup
+try? FileManager.default.removeItem(atPath: tmp)
+
+if failures > 0 {
+ print("\nFAILED: \(failures) check(s)")
+ exit(1)
+} else {
+ print("\nALL CHECKS PASSED")
+ exit(0)
+}
diff --git a/macos-bar/Sources/CCSBarCore/BarDiscovery.swift b/macos-bar/Sources/CCSBarCore/BarDiscovery.swift
new file mode 100644
index 00000000..3bbf4a8f
--- /dev/null
+++ b/macos-bar/Sources/CCSBarCore/BarDiscovery.swift
@@ -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 {
+ 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)
+ }
+}
diff --git a/macos-bar/Sources/CCSBarCore/BarFormatting.swift b/macos-bar/Sources/CCSBarCore/BarFormatting.swift
new file mode 100644
index 00000000..75cda29c
--- /dev/null
+++ b/macos-bar/Sources/CCSBarCore/BarFormatting.swift
@@ -0,0 +1,66 @@
+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 {
+ /// Quota percentage label, e.g. "82%" or "--" when unknown.
+ public static func quotaLabel(_ pct: Double?) -> String {
+ guard let pct else { return "--" }
+ 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)
+ }
+
+ /// Compact status-bar title. Shows the most-used (lowest remaining quota)
+ /// active account, plus today's total cost when available.
+ /// Example: "agy 82% · $3.20". Falls back to "CCS" when there are no rows.
+ public static func statusTitle(rows: [BarSummaryRow]) -> String {
+ let active = rows.filter { !$0.paused }
+ guard let lead = leadRow(active.isEmpty ? rows : active) else { return "CCS" }
+ var parts: [String] = []
+ let q = quotaLabel(lead.quotaPercentage)
+ parts.append("\(lead.provider) \(q)")
+ let total = rows.compactMap { $0.todayCost }.reduce(0, +)
+ let cost = costLabel(total)
+ if !cost.isEmpty { parts.append(cost) }
+ return parts.joined(separator: " \u{00B7} ")
+ }
+
+ /// The row to surface in the compact title: the one closest to exhaustion.
+ /// `quota_percentage` is REMAINING quota (higher = more left), so the lead is
+ /// the LOWEST remaining percentage. Rows without a known percentage are not
+ /// chosen unless no row has one.
+ static func leadRow(_ rows: [BarSummaryRow]) -> BarSummaryRow? {
+ let withPct = rows.filter { $0.quotaPercentage != nil }
+ if let lead = withPct.min(by: { ($0.quotaPercentage ?? 0) < ($1.quotaPercentage ?? 0) }) {
+ return lead
+ }
+ return rows.first
+ }
+}
+
+/// 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
+ }
+}
diff --git a/macos-bar/Sources/CCSBarCore/BarSummary.swift b/macos-bar/Sources/CCSBarCore/BarSummary.swift
new file mode 100644
index 00000000..58bc3d4e
--- /dev/null
+++ b/macos-bar/Sources/CCSBarCore/BarSummary.swift
@@ -0,0 +1,77 @@
+import Foundation
+
+/// 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?
+ public let nextReset: String?
+ public let todayCost: Double?
+ public let health: String
+ public let cached: Bool
+ public let fetchedAt: String?
+ public let needsReauth: Bool
+
+ /// 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 nextReset = "next_reset"
+ case todayCost = "today_cost"
+ case health
+ case cached
+ case fetchedAt
+ case needsReauth
+ }
+
+ public init(
+ accountId: String,
+ provider: String,
+ displayName: String? = nil,
+ tier: String? = nil,
+ paused: Bool = false,
+ quotaPercentage: Double? = nil,
+ nextReset: String? = nil,
+ todayCost: Double? = nil,
+ health: String = "ok",
+ cached: Bool = false,
+ fetchedAt: String? = nil,
+ needsReauth: Bool = false
+ ) {
+ self.accountId = accountId
+ self.provider = provider
+ self.displayName = displayName
+ self.tier = tier
+ self.paused = paused
+ self.quotaPercentage = quotaPercentage
+ self.nextReset = nextReset
+ self.todayCost = todayCost
+ self.health = health
+ self.cached = cached
+ self.fetchedAt = fetchedAt
+ self.needsReauth = needsReauth
+ }
+}
+
+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"
+ }
+ }
+}
diff --git a/macos-bar/Sources/CCSBarCore/CCSBarClient.swift b/macos-bar/Sources/CCSBarCore/CCSBarClient.swift
new file mode 100644
index 00000000..b0fd5773
--- /dev/null
+++ b/macos-bar/Sources/CCSBarCore/CCSBarClient.swift
@@ -0,0 +1,96 @@
+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
+ }
+ }
+
+ // 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
+ }
+}