on master help sign in
feat: show Claude Code usage limits alongside Hyper credits

Adds the Pro/Max 5-hour and 7-day windows to the popover, read from the credentials the Claude Code CLI already stores on this machine. No setup: if Claude Code is signed in the section appears, and if it isn't, it doesn't — missing credentials are 'not configured', not an error. - ClaudeUsageClient: OAuth client for GET /api/oauth/usage, reading Claude Code's Keychain item via /usr/bin/security and falling back to ~/.claude/.credentials.json. An actor, because the token endpoint rotates the refresh token: overlapping refreshes (timer, click, wake) join one in-flight, cancellation-immune task, and the write-back patches the raw JSON so fields we don't model survive. Rotating twice, or losing a field, would sign the user out of their own CLI. - ViewModel: fetches Hyper and Claude concurrently with async let; neither can fail or be unconfigured in a way that touches the other. - MenuView: headline percentage for the limit the server flags as binding, the windows underneath as hairline bars, and a countdown to the reset. - Entitlements: drop the sandbox. Spawning security and reading ~/.claude are both impossible under it, and the app ships ad-hoc signed via Homebrew rather than through the App Store. - Tests: usage decoding, countdowns, token refresh (rotation, persistence, form-encoded fallback), 401-refresh-retry, backoff, and a credential store driven against a temp file and an absent Keychain service.

commit4cb1f6373af9cca9e6b55c3d53238139e18b3e4b authoriko <mail@iko.soy> dateSat 11 Jul 2026, 18:27 (3 months ago) parent2ee81a2
9 files changed+1856−51
CLAUDE.md+24−5 HyperCreditsMenubar/ClaudeUsageClient.swift+727 HyperCreditsMenubar/HyperCreditsMenubar.entitlements+8−2 HyperCreditsMenubar/MenuView.swift+156−3 HyperCreditsMenubar/ViewModel.swift+137−36 HyperCreditsMenubarTests/ClaudeUsageClientTests.swift+752 HyperCreditsMenubarTests/CreditsCheckerTests.swift+29 README.md+20−3 project.yml+3−2
CLAUDE.md+24−5
@@ -1,14 +1,32 @@
11# HyperCreditsMenubar
22
33## Architecture
4−macOS menu bar app (SwiftUI, macOS 14+) showing Hyper (Charm) credit balance.
4+macOS menu bar app (SwiftUI, macOS 14+) showing Hyper (Charm) credit balance, plus
5+Claude Code's Pro/Max usage limits when the CLI is signed in on the machine.
56- `HyperCreditsApp.swift` — @main App, NSStatusBar, timer, sleep/wake, popover
6−- `ViewModel.swift` — Observable state: balance, refresh, notifications, history
7−- `MenuView.swift` — SwiftUI popover view (balance, sparkline, settings)
7+- `ViewModel.swift` — Observable state: balance, Claude usage, refresh, notifications, history
8+- `MenuView.swift` — SwiftUI popover view (balance, sparkline, Claude limits, settings)
89- `CreditsChecker.swift` — API client for `GET https://hyper.charm.land/v1/credits`
10+- `ClaudeUsageClient.swift` — Claude OAuth client + models for `GET https://api.anthropic.com/api/oauth/usage`
911- `KeychainHelper.swift` — Keychain wrapper (KeychainStore + KeychainHelper)
1012- `VersionFormatter.swift` — Version string formatting
1113
14+The status bar title stays `⚡{balance}` (Hyper only); Claude usage lives in the popover.
15+The two fetches are independent — `ViewModel.refresh()` runs them with `async let`, and
16+either can fail or be unconfigured without touching the other.
17+
18+## Claude credentials (read-only, with one exception)
19+`ClaudeUsageClient` reads the credentials **Claude Code owns**: its `Claude Code-credentials`
20+Keychain item (via `/usr/bin/security`), falling back to `~/.claude/.credentials.json`.
21+- Never modify them, except the token refresh, which writes back a rotated token to
22+ whichever source it was read from, preserving fields we don't model (patched raw JSON).
23+- The refresh endpoint **rotates the refresh token** — refreshing twice with the same one
24+ signs the user out of their own CLI. Hence the client is an `actor` and dedups
25+ concurrent refreshes onto one in-flight, cancellation-immune `Task`.
26+- Missing credentials are **not an error** — the popover just omits the section.
27+- **The app cannot be sandboxed**: spawning `security` and reading `~/.claude` require it
28+ to be off. Distribution is ad-hoc signed via Homebrew, not the App Store.
29+
1230## Build
1331- Uses [xcodegen](https://github.com/yonaskolb/XcodeGen) — `project.yml` is source of truth
1432- `xcodegen generate` creates the .xcodeproj (not committed)
@@ -19,9 +37,10 @@ macOS menu bar app (SwiftUI, macOS 14+) showing Hyper (Charm) credit balance.
1937## Conventions
2038- Clean minimal UI — big hero number, generous spacing, restrained color, no chrome
2139- No card-heavy/multi-material/gradient-rich designs
22−- Rounded font family at 4 sizes (hero/section/control/caption/footnote)
40+- Rounded font family at one scale (hero/subhero/section/control/caption/footnote)
2341- Monospaced digits for numbers that change
24−- Tests use StubURLProtocol for network mocking, FakeKeychain for keychain mocking
42+- Tests use StubURLProtocol for network mocking, FakeKeychain for keychain mocking,
43+ FakeClaudeCredentialStore for Claude credentials — never touch the real ones in a test
2544- The app is an agent app (LSUIElement=true) — no dock icon, no main window
2645
2746## VCS
HyperCreditsMenubar/ClaudeUsageClient.swiftnew file+727
@@ -0,0 +1,727 @@
1+import Foundation
2+
3+// MARK: - Usage
4+
5+/// Usage data from `GET https://api.anthropic.com/api/oauth/usage` — the endpoint
6+/// behind Claude Code's own `/usage` command.
7+///
8+/// Every field is optional. The payload varies by plan, and Anthropic adds and
9+/// removes windows without notice, so an unrecognized shape has to degrade to
10+/// "nothing to show" rather than fail the whole decode.
11+struct ClaudeUsage: Decodable, Equatable {
12+ let fiveHour: UsageWindow?
13+ let sevenDay: UsageWindow?
14+ let sevenDayOpus: UsageWindow?
15+ let limits: [Limit]?
16+
17+ private enum CodingKeys: String, CodingKey {
18+ case fiveHour = "five_hour"
19+ case sevenDay = "seven_day"
20+ case sevenDayOpus = "seven_day_opus"
21+ case limits
22+ }
23+
24+ /// The limit the server itself flags as currently binding — the one worth leading
25+ /// with. `nil` when it flags none, which is the normal state below the thresholds.
26+ var activeLimit: Limit? {
27+ limits?.first { $0.isActive }
28+ }
29+
30+ /// Whether there is anything at all to render. The endpoint answers `{}` for an
31+ /// account that has never used the plan, and a section header over nothing reads
32+ /// as a bug.
33+ var isEmpty: Bool {
34+ fiveHour == nil && sevenDay == nil && sevenDayOpus == nil && (limits?.isEmpty ?? true)
35+ }
36+}
37+
38+/// One usage window, e.g. the rolling 5-hour or 7-day allowance.
39+struct UsageWindow: Decodable, Equatable, ResetCountdown {
40+ /// Percent of the window consumed, 0–100.
41+ let utilization: Double
42+
43+ /// ISO 8601, or `nil` when nothing has been used and so nothing is counting down.
44+ let resetsAt: String?
45+
46+ private enum CodingKeys: String, CodingKey {
47+ case utilization
48+ case resetsAt = "resets_at"
49+ }
50+
51+ init(utilization: Double, resetsAt: String? = nil) {
52+ self.utilization = utilization
53+ self.resetsAt = resetsAt
54+ }
55+
56+ init(from decoder: Decoder) throws {
57+ let container = try decoder.container(keyedBy: CodingKeys.self)
58+ utilization = try container.decodeIfPresent(Double.self, forKey: .utilization) ?? 0
59+ resetsAt = try container.decodeIfPresent(String.self, forKey: .resetsAt)
60+ }
61+}
62+
63+/// One entry in the `limits` array: the server's own classification of a limit,
64+/// including whether it is the one currently in effect.
65+struct Limit: Decodable, Equatable, ResetCountdown {
66+ let percent: Int
67+ let resetsAt: String?
68+ let isActive: Bool
69+
70+ private enum CodingKeys: String, CodingKey {
71+ case percent
72+ case resetsAt = "resets_at"
73+ case isActive = "is_active"
74+ }
75+
76+ init(percent: Int, resetsAt: String? = nil, isActive: Bool = false) {
77+ self.percent = percent
78+ self.resetsAt = resetsAt
79+ self.isActive = isActive
80+ }
81+
82+ init(from decoder: Decoder) throws {
83+ let container = try decoder.container(keyedBy: CodingKeys.self)
84+ // Decoded as a Double and rounded: the field is documented as an integer, but
85+ // decoding it as one would throw on a fractional value and take the whole
86+ // payload down with it.
87+ let rawPercent = try container.decodeIfPresent(Double.self, forKey: .percent) ?? 0
88+ percent = Int(rawPercent.rounded())
89+ resetsAt = try container.decodeIfPresent(String.self, forKey: .resetsAt)
90+ isActive = try container.decodeIfPresent(Bool.self, forKey: .isActive) ?? false
91+ }
92+}
93+
94+// MARK: - Reset Countdown
95+
96+/// Anything carrying an ISO 8601 reset timestamp, and so able to render a countdown.
97+protocol ResetCountdown {
98+ var resetsAt: String? { get }
99+}
100+
101+extension ResetCountdown {
102+ /// `resetsAt` as a date, or `nil` when absent or unparseable.
103+ var resetsAtDate: Date? {
104+ guard let resetsAt else { return nil }
105+ return ISO8601.date(from: resetsAt)
106+ }
107+
108+ /// How long until the window resets, e.g. `"3h 20m"`, `"1d 4h"` or `"12m"`.
109+ /// `nil` once the reset time has passed, or when there is no timestamp at all.
110+ ///
111+ /// - Parameter reference: The instant to count from. Injectable for tests.
112+ func resetsIn(from reference: Date = Date()) -> String? {
113+ guard let date = resetsAtDate else { return nil }
114+
115+ let interval = date.timeIntervalSince(reference)
116+ guard interval > 0 else { return nil }
117+
118+ // Rounded to the nearest second, so a window that is exactly "3h 20m" out
119+ // isn't reported a minute short because of the milliseconds spent getting here.
120+ let seconds = Int(interval.rounded())
121+ let days = seconds / 86_400
122+ let hours = (seconds % 86_400) / 3_600
123+ let minutes = (seconds % 3_600) / 60
124+
125+ if days > 0 { return "\(days)d \(hours)h" }
126+ if hours > 0 { return "\(hours)h \(minutes)m" }
127+ return "\(minutes)m"
128+ }
129+
130+ /// The countdown as of now.
131+ var resetsInFormatted: String? {
132+ resetsIn()
133+ }
134+}
135+
136+/// Claude timestamps carry fractional seconds, but not always — both spellings parse.
137+private enum ISO8601 {
138+ private static let fractional: ISO8601DateFormatter = {
139+ let formatter = ISO8601DateFormatter()
140+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
141+ return formatter
142+ }()
143+
144+ private static let whole: ISO8601DateFormatter = {
145+ let formatter = ISO8601DateFormatter()
146+ formatter.formatOptions = [.withInternetDateTime]
147+ return formatter
148+ }()
149+
150+ static func date(from string: String) -> Date? {
151+ fractional.date(from: string) ?? whole.date(from: string)
152+ }
153+}
154+
155+// MARK: - Credentials
156+
157+/// The OAuth credentials the Claude Code CLI owns. The key names are Claude Code's,
158+/// not ours — this is its blob, and we only ever read it and rotate its tokens.
159+struct ClaudeOAuthCredentials: Codable, Equatable {
160+ var claudeAiOauth: OAuthToken
161+
162+ struct OAuthToken: Codable, Equatable {
163+ var accessToken: String
164+ var refreshToken: String
165+ /// Epoch milliseconds.
166+ var expiresAt: Int64
167+ var scopes: [String]?
168+ /// `"pro"`, `"max"`, `"team"`, …
169+ var subscriptionType: String?
170+ var rateLimitTier: String?
171+ }
172+}
173+
174+/// Where credentials were read from, so a rotated token is written back to the same
175+/// place. Writing to the other one would leave Claude Code holding a refresh token
176+/// the server has already retired — i.e. would sign the user out of their own CLI.
177+enum ClaudeCredentialSource: Equatable {
178+ case keychain(account: String)
179+ case file(URL)
180+}
181+
182+/// Credentials together with where they came from.
183+struct StoredClaudeCredentials: Equatable {
184+ var credentials: ClaudeOAuthCredentials
185+ var source: ClaudeCredentialSource
186+}
187+
188+/// Reads — and, after a token refresh, writes back — the credentials Claude Code owns.
189+/// Injectable so tests never go near the real Keychain or the real credentials file.
190+protocol ClaudeCredentialStoring {
191+ func load() -> StoredClaudeCredentials?
192+
193+ /// Best effort: failing to persist a refreshed token is not worth failing the
194+ /// fetch over, and the next launch will simply refresh again.
195+ func save(_ stored: StoredClaudeCredentials)
196+}
197+
198+/// The real store: Claude Code's login-Keychain item, falling back to
199+/// `~/.claude/.credentials.json`.
200+///
201+/// The Keychain item belongs to Claude Code, so it is read through the `security`
202+/// CLI rather than `SecItemCopyMatching`: the item's ACL trusts `/usr/bin/security`,
203+/// and macOS asks the user once to allow it. Spawning a process and reading a file in
204+/// the user's home is also why this app cannot be sandboxed.
205+struct ClaudeCodeCredentialStore: ClaudeCredentialStoring {
206+ static let defaultKeychainService = "Claude Code-credentials"
207+
208+ /// `NSHomeDirectory()` rather than `FileManager.homeDirectoryForCurrentUser`: the
209+ /// latter can resolve to a container path, while Claude Code always writes to the
210+ /// real home directory.
211+ static var defaultCredentialsFile: URL {
212+ URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".claude/.credentials.json")
213+ }
214+
215+ private let keychainService: String
216+ private let credentialsFile: URL
217+
218+ init(
219+ keychainService: String = ClaudeCodeCredentialStore.defaultKeychainService,
220+ credentialsFile: URL = ClaudeCodeCredentialStore.defaultCredentialsFile
221+ ) {
222+ self.keychainService = keychainService
223+ self.credentialsFile = credentialsFile
224+ }
225+
226+ func load() -> StoredClaudeCredentials? {
227+ if let json = Self.runSecurity(["find-generic-password", "-s", keychainService, "-w"]),
228+ let credentials = Self.decode(Data(json.utf8)) {
229+ return StoredClaudeCredentials(
230+ credentials: credentials,
231+ source: .keychain(account: keychainAccount())
232+ )
233+ }
234+
235+ guard let data = try? Data(contentsOf: credentialsFile),
236+ let credentials = Self.decode(data) else { return nil }
237+
238+ return StoredClaudeCredentials(credentials: credentials, source: .file(credentialsFile))
239+ }
240+
241+ func save(_ stored: StoredClaudeCredentials) {
242+ guard let json = rotatedJSON(for: stored) else { return }
243+
244+ switch stored.source {
245+ case .keychain(let account):
246+ _ = Self.runSecurity([
247+ "add-generic-password",
248+ "-U", // update the existing item rather than adding a second one
249+ "-s", keychainService,
250+ "-a", account,
251+ "-w", json,
252+ ])
253+
254+ case .file(let url):
255+ guard let data = json.data(using: .utf8) else { return }
256+ try? data.write(to: url, options: .atomic)
257+ // An atomic write replaces the file, so the 0600 mode has to be reapplied.
258+ try? FileManager.default.setAttributes(
259+ [.posixPermissions: 0o600],
260+ ofItemAtPath: url.path
261+ )
262+ }
263+ }
264+
265+ /// The stored blob with only the three token fields rotated.
266+ ///
267+ /// Patches the raw JSON rather than re-encoding `ClaudeOAuthCredentials`, because
268+ /// a round-trip through our own type would silently drop any field Anthropic adds
269+ /// to the blob that we don't model — and this is the file the user's CLI logs in
270+ /// with. Re-encoding is the fallback for when the raw blob can't be read back.
271+ private func rotatedJSON(for stored: StoredClaudeCredentials) -> String? {
272+ let token = stored.credentials.claudeAiOauth
273+
274+ if let raw = rawJSON(from: stored.source),
275+ let parsed = try? JSONSerialization.jsonObject(with: raw),
276+ var object = parsed as? [String: Any],
277+ var oauth = object["claudeAiOauth"] as? [String: Any] {
278+ oauth["accessToken"] = token.accessToken
279+ oauth["refreshToken"] = token.refreshToken
280+ oauth["expiresAt"] = token.expiresAt
281+ object["claudeAiOauth"] = oauth
282+
283+ if let patched = try? JSONSerialization.data(withJSONObject: object),
284+ let json = String(data: patched, encoding: .utf8) {
285+ return json
286+ }
287+ }
288+
289+ guard let encoded = try? JSONEncoder().encode(stored.credentials) else { return nil }
290+ return String(data: encoded, encoding: .utf8)
291+ }
292+
293+ private func rawJSON(from source: ClaudeCredentialSource) -> Data? {
294+ switch source {
295+ case .keychain:
296+ return Self.runSecurity(["find-generic-password", "-s", keychainService, "-w"])
297+ .map { Data($0.utf8) }
298+ case .file(let url):
299+ return try? Data(contentsOf: url)
300+ }
301+ }
302+
303+ /// The account on Claude Code's Keychain item, so a write-back updates that item
304+ /// instead of adding a second one under a different account.
305+ private func keychainAccount() -> String {
306+ guard let output = Self.runSecurity(["find-generic-password", "-s", keychainService]) else {
307+ return NSUserName()
308+ }
309+
310+ // The attribute dump spells the account as: "acct"<blob>="someone"
311+ for line in output.split(separator: "\n") {
312+ let trimmed = line.trimmingCharacters(in: .whitespaces)
313+ guard trimmed.hasPrefix("\"acct\""),
314+ let range = trimmed.range(of: #"="[^"]+"#, options: .regularExpression)
315+ else { continue }
316+ // The match starts at `="` and stops before the closing quote.
317+ return String(trimmed[range].dropFirst(2))
318+ }
319+
320+ return NSUserName()
321+ }
322+
323+ private static func decode(_ data: Data) -> ClaudeOAuthCredentials? {
324+ try? JSONDecoder().decode(ClaudeOAuthCredentials.self, from: data)
325+ }
326+
327+ /// Runs `/usr/bin/security`, returning its trimmed stdout, or `nil` when it fails
328+ /// or says nothing — which is the ordinary answer when Claude Code was never
329+ /// signed in on this machine.
330+ private static func runSecurity(_ arguments: [String]) -> String? {
331+ let process = Process()
332+ process.executableURL = URL(fileURLWithPath: "/usr/bin/security")
333+ process.arguments = arguments
334+
335+ let output = Pipe()
336+ process.standardOutput = output
337+ // Discarded rather than piped: an undrained error pipe can fill and deadlock.
338+ process.standardError = FileHandle.nullDevice
339+
340+ do {
341+ try process.run()
342+ } catch {
343+ return nil
344+ }
345+
346+ // Drained before waiting, for the same reason.
347+ let data = output.fileHandleForReading.readDataToEndOfFile()
348+ process.waitUntilExit()
349+
350+ guard process.terminationStatus == 0 else { return nil }
351+
352+ let text = String(data: data, encoding: .utf8)?
353+ .trimmingCharacters(in: .whitespacesAndNewlines)
354+ guard let text, !text.isEmpty else { return nil }
355+ return text
356+ }
357+}
358+
359+// MARK: - Errors
360+
361+/// Errors that can occur while fetching Claude usage.
362+enum ClaudeUsageError: LocalizedError, Equatable {
363+ /// Claude Code has never signed in here. Not an error the user needs to see — the
364+ /// popover just leaves the section out.
365+ case noCredentials
366+ case invalidCredentials
367+ case tokenRefreshFailed
368+ case requestFailed(String)
369+ case decodingFailed(String)
370+
371+ var errorDescription: String? {
372+ switch self {
373+ case .noCredentials:
374+ return "No Claude Code credentials found. Run `claude` in a terminal to sign in."
375+ case .invalidCredentials:
376+ return "Claude sign-in expired. Run `claude` in a terminal to sign in again."
377+ case .tokenRefreshFailed:
378+ return "Could not refresh Claude credentials. Run `claude` in a terminal to sign in again."
379+ case .requestFailed(let message):
380+ return "Claude request failed: \(message)"
381+ case .decodingFailed(let message):
382+ return "Failed to decode Claude usage: \(message)"
383+ }
384+ }
385+}
386+
387+/// What a successful fetch yields: the usage itself, plus the plan it belongs to.
388+struct ClaudeUsageReport: Equatable {
389+ let usage: ClaudeUsage
390+
391+ /// `"pro"`, `"max"`, … straight from the OAuth credentials; `nil` when absent.
392+ let subscriptionType: String?
393+}
394+
395+/// Protocol abstraction for fetching Claude usage, enabling test injection.
396+protocol ClaudeUsageChecking {
397+ func fetchUsage() async throws -> ClaudeUsageReport
398+}
399+
400+// MARK: - Client
401+
402+/// Client for Claude Code's subscription usage API, authenticating with the OAuth
403+/// credentials the CLI already stores on this machine.
404+///
405+/// An actor rather than a plain client, because the refresh path mutates state the
406+/// user's CLI depends on: the token endpoint rotates the refresh token, so two
407+/// concurrent refreshes would leave one of them holding a token the server has
408+/// already retired. Overlapping fetches (timer, manual click, wake) instead join a
409+/// single in-flight refresh.
410+actor ClaudeUsageClient: ClaudeUsageChecking {
411+ static let usageEndpoint = URL(string: "https://api.anthropic.com/api/oauth/usage")!
412+ static let tokenEndpoint = URL(string: "https://platform.claude.com/v1/oauth/token")!
413+
414+ /// Claude Code's public OAuth client id, and the scopes it signs in with.
415+ static let clientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
416+ static let scope = "user:inference user:profile user:sessions:claude_code"
417+
418+ /// Cloudflare fronts the token endpoint and answers 403 `browser_signature_banned`
419+ /// to anything without a browser User-Agent.
420+ static let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
421+ + "(KHTML, like Gecko) Chrome/126.0 Safari/537.36"
422+
423+ /// Refresh this far ahead of the stated expiry rather than racing it.
424+ static let expiryBuffer: TimeInterval = 60
425+
426+ /// Used when the token endpoint omits `expires_in`.
427+ static let defaultTokenLifetime: TimeInterval = 8 * 60 * 60
428+
429+ /// Retries *after* the initial attempt, so at most 3 requests per call.
430+ static let maxRetries = 2
431+
432+ /// URL error codes worth retrying: the request never reached a server that had an
433+ /// opinion about it, so the same request may well succeed a moment later.
434+ private static let retryableURLErrorCodes: Set<URLError.Code> = [
435+ .timedOut,
436+ .cannotConnectToHost,
437+ .cannotFindHost,
438+ .dnsLookupFailed,
439+ .networkConnectionLost,
440+ .notConnectedToInternet,
441+ .resourceUnavailable,
442+ .badServerResponse,
443+ ]
444+
445+ /// Client-error status codes that are nonetheless worth retrying: both are the
446+ /// server telling us to come back later, not that the request itself is wrong.
447+ private static let retryableStatusCodes: Set<Int> = [
448+ 408, // Request Timeout
449+ 429, // Too Many Requests
450+ ]
451+
452+ private let session: URLSession
453+ private let store: ClaudeCredentialStoring
454+ private let retryBaseDelay: Duration
455+ private let now: @Sendable () -> Date
456+
457+ /// The refresh currently in flight, if any. See `refreshCredentials(from:)`.
458+ private var inFlightRefresh: Task<StoredClaudeCredentials, Error>?
459+
460+ /// - Parameters:
461+ /// - retryBaseDelay: Delay before the first retry; doubles on each subsequent
462+ /// one (2s, then 4s). Injectable so tests need not wait in real time.
463+ /// - now: The current instant. Injectable so tests can drive token expiry.
464+ init(
465+ session: URLSession? = nil,
466+ store: ClaudeCredentialStoring = ClaudeCodeCredentialStore(),
467+ retryBaseDelay: Duration = .seconds(2),
468+ now: @escaping @Sendable () -> Date = { Date() }
469+ ) {
470+ self.store = store
471+ self.retryBaseDelay = retryBaseDelay
472+ self.now = now
473+ if let session {
474+ self.session = session
475+ } else {
476+ let config = URLSessionConfiguration.default
477+ config.timeoutIntervalForRequest = 10
478+ config.timeoutIntervalForResource = 15
479+ self.session = URLSession(configuration: config)
480+ }
481+ }
482+
483+ /// Fetches current usage, refreshing the OAuth token first if it has expired.
484+ ///
485+ /// - Throws: `ClaudeUsageError`. `.noCredentials` means Claude Code was never
486+ /// signed in here, which callers should treat as "nothing to show" rather than
487+ /// as a failure.
488+ func fetchUsage() async throws -> ClaudeUsageReport {
489+ guard let stored = store.load() else { throw ClaudeUsageError.noCredentials }
490+
491+ var current = stored
492+ var didRefresh = false
493+ if isExpired(stored.credentials) {
494+ current = try await refreshCredentials(from: stored)
495+ didRefresh = true
496+ }
497+
498+ do {
499+ return try await report(with: current)
500+ } catch ClaudeUsageError.invalidCredentials where !didRefresh {
501+ // A token can be revoked server-side well before its stated expiry, so a
502+ // rejection is worth one refresh and one retry.
503+ guard let refreshed = try? await refreshCredentials(from: current) else {
504+ // A failed refresh (Cloudflare, a retired refresh token) says less
505+ // than the rejection that sent us here, so surface that instead.
506+ throw ClaudeUsageError.invalidCredentials
507+ }
508+ return try await report(with: refreshed)
509+ }
510+ }
511+
512+ // MARK: - Usage Request
513+
514+ private func report(with stored: StoredClaudeCredentials) async throws -> ClaudeUsageReport {
515+ let token = stored.credentials.claudeAiOauth
516+ let usage = try await requestUsage(token: token.accessToken)
517+ return ClaudeUsageReport(usage: usage, subscriptionType: token.subscriptionType)
518+ }
519+
520+ private func requestUsage(token: String) async throws -> ClaudeUsage {
521+ var request = URLRequest(url: Self.usageEndpoint, cachePolicy: .reloadIgnoringLocalCacheData)
522+ request.httpMethod = "GET"
523+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
524+ request.setValue("oauth-2025-04-20", forHTTPHeaderField: "anthropic-beta")
525+ request.setValue("application/json", forHTTPHeaderField: "Accept")
526+ request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent")
527+
528+ let data = try await send(request)
529+
530+ do {
531+ return try JSONDecoder().decode(ClaudeUsage.self, from: data)
532+ } catch {
533+ throw ClaudeUsageError.decodingFailed(error.localizedDescription)
534+ }
535+ }
536+
537+ // MARK: - Token Refresh
538+
539+ /// Exchanges the refresh token for a fresh access token and persists the result.
540+ ///
541+ /// Callers join whatever refresh is already running rather than starting a second
542+ /// one, and the exchange itself runs in an unstructured task so that a cancelled
543+ /// caller — the view model cancels the previous refresh whenever a new one starts
544+ /// — can never abandon a rotation half-done, with the new refresh token neither
545+ /// used nor written back.
546+ private func refreshCredentials(from stored: StoredClaudeCredentials) async throws -> StoredClaudeCredentials {
547+ if let existing = inFlightRefresh {
548+ return try await existing.value
549+ }
550+
551+ let task = Task { () throws -> StoredClaudeCredentials in
552+ let response = try await self.requestTokenRefresh(
553+ refreshToken: stored.credentials.claudeAiOauth.refreshToken
554+ )
555+
556+ var refreshed = stored
557+ refreshed.credentials.claudeAiOauth.accessToken = response.accessToken
558+ if let rotated = response.refreshToken {
559+ refreshed.credentials.claudeAiOauth.refreshToken = rotated
560+ }
561+ let lifetime = response.expiresIn.map(TimeInterval.init) ?? Self.defaultTokenLifetime
562+ refreshed.credentials.claudeAiOauth.expiresAt =
563+ Int64((self.now().timeIntervalSince1970 + lifetime) * 1000)
564+
565+ self.store.save(refreshed)
566+ return refreshed
567+ }
568+ // Only the task's creator ever clears the slot, so a joiner resuming late can
569+ // never wipe out a refresh that started after it.
570+ inFlightRefresh = task
571+
572+ do {
573+ let refreshed = try await task.value
574+ inFlightRefresh = nil
575+ return refreshed
576+ } catch {
577+ inFlightRefresh = nil
578+ throw error
579+ }
580+ }
581+
582+ /// Posts the refresh grant as JSON, then — for token endpoints that reject a JSON
583+ /// body — retries the same exchange form-encoded before giving up.
584+ private func requestTokenRefresh(refreshToken: String) async throws -> TokenRefreshResponse {
585+ let parameters = [
586+ "grant_type": "refresh_token",
587+ "refresh_token": refreshToken,
588+ "client_id": Self.clientID,
589+ "scope": Self.scope,
590+ ]
591+
592+ if let response = await tokenRefreshAttempt(parameters: parameters, formEncoded: false) {
593+ return response
594+ }
595+ if let response = await tokenRefreshAttempt(parameters: parameters, formEncoded: true) {
596+ return response
597+ }
598+ throw ClaudeUsageError.tokenRefreshFailed
599+ }
600+
601+ /// One refresh exchange in one encoding. Returns `nil` rather than throwing, so the
602+ /// caller can fall through to the other encoding.
603+ private func tokenRefreshAttempt(
604+ parameters: [String: String],
605+ formEncoded: Bool
606+ ) async -> TokenRefreshResponse? {
607+ var request = URLRequest(url: Self.tokenEndpoint)
608+ request.httpMethod = "POST"
609+ request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent")
610+ request.setValue("application/json", forHTTPHeaderField: "Accept")
611+
612+ if formEncoded {
613+ request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
614+ request.httpBody = Self.formURLEncoded(parameters).data(using: .utf8)
615+ } else {
616+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
617+ request.httpBody = try? JSONSerialization.data(withJSONObject: parameters)
618+ }
619+
620+ guard let data = try? await send(request) else { return nil }
621+ return try? JSONDecoder().decode(TokenRefreshResponse.self, from: data)
622+ }
623+
624+ /// Whether the access token is spent, or close enough that using it would be a race.
625+ private func isExpired(_ credentials: ClaudeOAuthCredentials) -> Bool {
626+ let expiry = Date(
627+ timeIntervalSince1970: TimeInterval(credentials.claudeAiOauth.expiresAt) / 1000
628+ )
629+ return expiry.timeIntervalSince(now()) <= Self.expiryBuffer
630+ }
631+
632+ // MARK: - Transport
633+
634+ /// Sends a request, retrying transient failures with exponential backoff.
635+ private func send(_ request: URLRequest) async throws -> Data {
636+ var attempt = 0
637+
638+ while true {
639+ do {
640+ return try await performAttempt(request)
641+ } catch let failure as AttemptFailure {
642+ guard failure.isRetryable, attempt < Self.maxRetries else {
643+ throw failure.underlying
644+ }
645+ try await Task.sleep(for: retryBaseDelay * (1 << attempt))
646+ attempt += 1
647+ }
648+ }
649+ }
650+
651+ /// Performs a single request, tagging the failure with whether a retry could
652+ /// plausibly help.
653+ private func performAttempt(_ request: URLRequest) async throws -> Data {
654+ let data: Data
655+ let response: URLResponse
656+ do {
657+ (data, response) = try await session.data(for: request)
658+ } catch let urlError as URLError {
659+ throw AttemptFailure(
660+ underlying: .requestFailed(urlError.localizedDescription),
661+ isRetryable: Self.retryableURLErrorCodes.contains(urlError.code)
662+ )
663+ } catch {
664+ throw AttemptFailure(
665+ underlying: .requestFailed(error.localizedDescription),
666+ isRetryable: false
667+ )
668+ }
669+
670+ guard let httpResponse = response as? HTTPURLResponse else {
671+ throw AttemptFailure(underlying: .requestFailed("Invalid response type"), isRetryable: false)
672+ }
673+
674+ switch httpResponse.statusCode {
675+ case 200...299:
676+ return data
677+ case 401, 403:
678+ throw AttemptFailure(underlying: .invalidCredentials, isRetryable: false)
679+ case 500...599:
680+ throw AttemptFailure(
681+ underlying: .requestFailed("HTTP \(httpResponse.statusCode)"),
682+ isRetryable: true
683+ )
684+ default:
685+ throw AttemptFailure(
686+ underlying: .requestFailed("HTTP \(httpResponse.statusCode)"),
687+ isRetryable: Self.retryableStatusCodes.contains(httpResponse.statusCode)
688+ )
689+ }
690+ }
691+
692+ /// Encodes parameters as an `application/x-www-form-urlencoded` body. Percent-encodes
693+ /// against RFC 3986's unreserved set, so the spaces and colons in the scope don't
694+ /// travel raw.
695+ private static func formURLEncoded(_ parameters: [String: String]) -> String {
696+ var allowed = CharacterSet.alphanumerics
697+ allowed.insert(charactersIn: "-._~")
698+
699+ return parameters.map { key, value in
700+ let encodedKey = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
701+ let encodedValue = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
702+ return "\(encodedKey)=\(encodedValue)"
703+ }
704+ .joined(separator: "&")
705+ }
706+
707+ /// A failed attempt, carrying the error to surface plus whether retrying is worthwhile.
708+ private struct AttemptFailure: Error {
709+ let underlying: ClaudeUsageError
710+ let isRetryable: Bool
711+ }
712+}
713+
714+/// The token endpoint's answer to a refresh grant.
715+struct TokenRefreshResponse: Decodable, Equatable {
716+ let accessToken: String
717+ let refreshToken: String?
718+
719+ /// Seconds. Omitted by some deployments, in which case the caller assumes a default.
720+ let expiresIn: Int?
721+
722+ private enum CodingKeys: String, CodingKey {
723+ case accessToken = "access_token"
724+ case refreshToken = "refresh_token"
725+ case expiresIn = "expires_in"
726+ }
727+}
HyperCreditsMenubar/HyperCreditsMenubar.entitlements+8−2
@@ -1,9 +1,15 @@
11<?xml version="1.0" encoding="UTF-8"?>
22<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+<!--
4+ Deliberately not sandboxed. Reading Claude Code's usage limits means reading the
5+ credentials Claude Code owns: its login-Keychain item (via /usr/bin/security) and
6+ ~/.claude/.credentials.json. A sandboxed app can spawn neither the process nor read
7+ a file outside its container, so the sandbox and the feature are mutually exclusive.
8+ The app ships ad-hoc signed through Homebrew rather than through the App Store, where
9+ the sandbox would be mandatory.
10+-->
311<plist version="1.0">
412<dict>
5− <key>com.apple.security.app-sandbox</key>
6− <true/>
713 <key>com.apple.security.network.client</key>
814 <true/>
915</dict>
HyperCreditsMenubar/MenuView.swift+156−3
@@ -11,11 +11,15 @@ struct MenuView: View {
1111
1212 // MARK: - Type Scale
1313
14− /// One rounded family at four sizes. The balance is the only large element and
15− /// everything else steps down from it, so nothing competes with it for attention.
16− /// Monospaced digits keep the balance from jittering as its digits change.
14+ /// One rounded family, sized so that the balance is the only large element and
15+ /// everything else steps down from it — nothing competes with it for attention.
16+ /// The Claude percentage is the one exception, and it is deliberately set well
17+ /// below the balance so it reads as the second thing on the page, not the first.
18+ /// Monospaced digits keep numbers from jittering as their digits change.
1719 private static let heroFont = Font.system(size: 46, weight: .semibold, design: .rounded)
1820 .monospacedDigit()
21+ private static let subheroFont = Font.system(size: 26, weight: .semibold, design: .rounded)
22+ .monospacedDigit()
1923 private static let sectionFont = Font.system(size: 12, weight: .semibold, design: .rounded)
2024 private static let controlFont = Font.system(size: 12, weight: .regular, design: .rounded)
2125 private static let captionFont = Font.system(size: 11, weight: .medium, design: .rounded)
@@ -24,6 +28,10 @@ struct MenuView: View {
2428 var body: some View {
2529 VStack(alignment: .leading, spacing: 18) {
2630 hero
31+ if showsClaude {
32+ hairline
33+ claude
34+ }
2735 hairline
2836 settings
2937 hairline
@@ -42,6 +50,8 @@ struct MenuView: View {
4250 .animation(.easeInOut(duration: 0.25), value: viewModel.balance)
4351 .animation(.easeInOut(duration: 0.25), value: viewModel.isLoading)
4452 .animation(.easeInOut(duration: 0.3), value: historyValues.count)
53+ .animation(.easeInOut(duration: 0.25), value: viewModel.claudeUsage)
54+ .animation(.easeInOut(duration: 0.2), value: viewModel.claudeError)
4555 }
4656
4757 /// A section rule light enough to read as a pause rather than a border.
@@ -160,6 +170,121 @@ struct MenuView: View {
160170 .padding(.top, 2)
161171 }
162172
173+ // MARK: - Claude
174+
175+ /// Whether there is anything to say about Claude. Nothing at all when Claude Code
176+ /// isn't signed in on this machine — the section doesn't appear, and no error is
177+ /// shown for a service the user may simply not use.
178+ private var showsClaude: Bool {
179+ if viewModel.claudeError != nil { return true }
180+ if let usage = viewModel.claudeUsage { return !usage.isEmpty }
181+ return false
182+ }
183+
184+ /// Claude Code's plan limits. The percentage leads, as the balance does above it,
185+ /// with the individual windows underneath as quiet supporting detail.
186+ private var claude: some View {
187+ VStack(alignment: .leading, spacing: 12) {
188+ HStack(spacing: 8) {
189+ Text("Claude Code")
190+ .font(Self.sectionFont)
191+
192+ Spacer(minLength: 0)
193+
194+ if let plan = viewModel.claudePlanLabel {
195+ Text(plan)
196+ .font(Self.captionFont)
197+ .foregroundColor(.secondary)
198+ }
199+ }
200+
201+ if let usage = viewModel.claudeUsage, !usage.isEmpty {
202+ usageDetail(usage)
203+ }
204+
205+ if let error = viewModel.claudeError {
206+ errorRow(error)
207+ }
208+ }
209+ }
210+
211+ private func usageDetail(_ usage: ClaudeUsage) -> some View {
212+ VStack(alignment: .leading, spacing: 12) {
213+ if let headline = Self.headline(for: usage) {
214+ HStack(alignment: .firstTextBaseline, spacing: 8) {
215+ Text("\(headline.percent)%")
216+ .font(Self.subheroFont)
217+ .foregroundColor(Self.usageColor(headline.percent))
218+ .contentTransition(.opacity)
219+
220+ Spacer(minLength: 0)
221+
222+ if let resets = headline.resetsIn {
223+ Text("resets in \(resets)")
224+ .font(Self.footnoteFont)
225+ .foregroundColor(.secondary)
226+ }
227+ }
228+ }
229+
230+ VStack(alignment: .leading, spacing: 9) {
231+ if let fiveHour = usage.fiveHour {
232+ window("5-hour", fiveHour)
233+ }
234+ if let sevenDay = usage.sevenDay {
235+ window("7-day", sevenDay)
236+ }
237+ // Opus has its own weekly cap on Max plans, but it is only worth a row
238+ // once it has actually been used.
239+ if let opus = usage.sevenDayOpus, opus.utilization > 0 {
240+ window("7-day Opus", opus)
241+ }
242+ }
243+ }
244+ }
245+
246+ /// One window: its name, its share of the allowance, and a hairline bar. The bar is
247+ /// the only chrome here, and it carries the same color as the number beside it.
248+ private func window(_ label: String, _ window: UsageWindow) -> some View {
249+ let percent = Int(window.utilization.rounded())
250+
251+ return VStack(alignment: .leading, spacing: 5) {
252+ HStack(spacing: 6) {
253+ Text(label)
254+ .font(Self.captionFont)
255+ .foregroundColor(.secondary)
256+
257+ Spacer(minLength: 0)
258+
259+ Text("\(percent)%")
260+ .font(Self.captionFont.monospacedDigit())
261+ .foregroundColor(Self.usageColor(percent))
262+ }
263+
264+ UsageBar(percent: percent, color: Self.usageColor(percent))
265+ }
266+ }
267+
268+ /// The number the section leads with: whichever limit the server flags as binding,
269+ /// falling back to the 5-hour window when it flags none.
270+ private static func headline(for usage: ClaudeUsage) -> (percent: Int, resetsIn: String?)? {
271+ if let active = usage.activeLimit {
272+ return (active.percent, active.resetsInFormatted)
273+ }
274+ if let fiveHour = usage.fiveHour {
275+ return (Int(fiveHour.utilization.rounded()), fiveHour.resetsInFormatted)
276+ }
277+ return nil
278+ }
279+
280+ /// The balance colors read the other way round here, because for a limit more is
281+ /// worse: green with room to spare, yellow closing in, red nearly spent.
282+ private static func usageColor(_ percent: Int) -> Color {
283+ if percent >= 90 { return .red }
284+ if percent >= 70 { return .yellow }
285+ return .green
286+ }
287+
163288 // MARK: - Settings
164289
165290 private var settings: some View {
@@ -275,6 +400,34 @@ private struct PulsingBolt: View {
275400 }
276401}
277402
403+// MARK: - Usage Bar
404+
405+/// A hairline utilization bar: a track, a fill, and nothing else. Sized to read as a
406+/// rule under the label rather than as a control.
407+private struct UsageBar: View {
408+ /// 0–100. Clamped, because a plan with extra usage enabled can report past 100.
409+ let percent: Int
410+ let color: Color
411+
412+ private let height: CGFloat = 3
413+
414+ var body: some View {
415+ GeometryReader { geometry in
416+ let fraction = min(max(Double(percent) / 100, 0), 1)
417+
418+ ZStack(alignment: .leading) {
419+ Capsule()
420+ .fill(Color.secondary.opacity(0.15))
421+
422+ Capsule()
423+ .fill(color)
424+ .frame(width: geometry.size.width * fraction)
425+ }
426+ }
427+ .frame(height: height)
428+ }
429+}
430+
278431// MARK: - Sparkline
279432
280433/// A minimal line chart of recent balances: no axes, no labels, no grid. The stroke
HyperCreditsMenubar/ViewModel.swift+137−36
@@ -62,12 +62,24 @@ final class ViewModel: ObservableObject {
6262 /// The last known balance. `nil` when there has never been a successful fetch.
6363 @Published var balance: Int?
6464
65− /// `true` while a network request is in flight.
65+ /// `true` while a balance request is in flight.
6666 @Published var isLoading = false
6767
6868 /// A human-readable error message, shown when the balance can't be fetched.
6969 @Published var errorMessage: String?
7070
71+ /// Claude Code's subscription usage. `nil` when Claude Code has never signed in on
72+ /// this machine, in which case the popover leaves the section out entirely rather
73+ /// than nagging someone who doesn't use it.
74+ @Published var claudeUsage: ClaudeUsage?
75+
76+ /// A human-readable error message from the Claude fetch. Independent of
77+ /// `errorMessage`: the two services fail separately.
78+ @Published var claudeError: String?
79+
80+ /// The Claude plan the credentials belong to — `"pro"`, `"max"`, …
81+ @Published var claudePlan: String?
82+
7183 /// The API key draft bound to the text field. Two-way bound to the `SecureField`,
7284 /// so it changes on every keystroke — never fetch with it, use `activeAPIKey`.
7385 @Published var apiKeyInput: String = ""
@@ -114,6 +126,7 @@ final class ViewModel: ObservableObject {
114126 private var activeAPIKey: String
115127
116128 private let checker: CreditsChecking
129+ private let claudeChecker: ClaudeUsageChecking
117130 private let defaults: UserDefaults
118131
119132 /// The currently in-flight refresh task, if any. Used to cancel
@@ -134,8 +147,13 @@ final class ViewModel: ObservableObject {
134147 private static let maxHistoryEntries = 300
135148 private static let historyWindow: TimeInterval = 24 * 60 * 60
136149
137− init(checker: CreditsChecking = CreditsChecker(), defaults: UserDefaults = .standard) {
150+ init(
151+ checker: CreditsChecking = CreditsChecker(),
152+ claudeChecker: ClaudeUsageChecking = ClaudeUsageClient(),
153+ defaults: UserDefaults = .standard
154+ ) {
138155 self.checker = checker
156+ self.claudeChecker = claudeChecker
139157 self.defaults = defaults
140158 let storedKey = KeychainHelper.load() ?? ""
141159 apiKeyInput = storedKey
@@ -241,58 +259,141 @@ final class ViewModel: ObservableObject {
241259
242260 // MARK: - Refresh
243261
244− /// Refreshes the balance from the API. No-op if no API key is set.
245− /// Cancels any previously in-flight refresh to prevent races.
262+ /// Refreshes the Hyper balance and the Claude usage, both at once.
263+ ///
264+ /// The two are independent: either can fail, or not be configured at all, without
265+ /// touching the other. Cancels any previously in-flight refresh to prevent races.
246266 func refresh() {
247267 refreshTask?.cancel()
248268
249269 // Deliberately the saved key rather than `apiKeyInput`: see `activeAPIKey`.
250270 let key = activeAPIKey
251− guard !key.isEmpty else {
252− refreshTask = nil
271+ if key.isEmpty {
272+ // Nothing to fetch, and nothing worth keeping: the balance, its history and
273+ // any error all belonged to a key that is no longer there. Claude is left
274+ // alone — it doesn't depend on the Hyper key.
253275 balance = nil
254276 errorMessage = nil
255277 lastUpdated = nil
256278 history.removeAll()
257279 isLoading = false
258− return
259280 }
260281
261282 refreshTask = Task { @MainActor in
262283 // Check cancellation before setting isLoading — a previous
263284 // refresh() may have cancelled us and already set isLoading = false.
264285 guard !Task.isCancelled else { return }
265− isLoading = true
266− // Don't clear errorMessage here — keep showing the last error
286+ if !key.isEmpty { isLoading = true }
287+ // Don't clear the errors here — keep showing the last one
267288 // until we have a successful result.
268− do {
269− let result = try await checker.fetchBalance(apiKey: key)
270− // Check for cancellation after the await
271− guard !Task.isCancelled else { return }
272− // Check for low-balance threshold crossing before updating
273− let previousBalance = balance
274− balance = result
275− errorMessage = nil
276− let now = Date()
277− lastUpdated = now
278− recordHistory(balance: result, at: now)
279− isLoading = false
280−
281− // Low-balance notification: on threshold crossing (≥10 → <10)
282− // OR on first successful fetch if already below threshold.
283− if let prev = previousBalance, prev >= 10, result < 10 {
284− sendLowBalanceNotification(balance: result)
285− } else if previousBalance == nil && result < 10 {
286− sendLowBalanceNotification(balance: result)
287− }
288− } catch {
289− // Keep the stale balance value — don't wipe it on error.
290− // Only set balance = nil if there was never a successful fetch
291− // (it's already nil in that case, so nothing to do).
292− guard !Task.isCancelled else { return }
293− errorMessage = error.localizedDescription
294− isLoading = false
289+
290+ // Both requests go out at once; neither waits on the other.
291+ async let balanceOutcome = fetchBalance(key: key)
292+ async let claudeOutcome = fetchClaudeUsage()
293+
294+ let balanceResult = await balanceOutcome
295+ let claudeResult = await claudeOutcome
296+ guard !Task.isCancelled else { return }
297+
298+ applyBalance(balanceResult)
299+ applyClaude(claudeResult)
300+ }
301+ }
302+
303+ /// The balance fetch, or `nil` when there is no key to fetch with.
304+ private func fetchBalance(key: String) async -> Result<Int, Error>? {
305+ guard !key.isEmpty else { return nil }
306+ do {
307+ return .success(try await checker.fetchBalance(apiKey: key))
308+ } catch {
309+ return .failure(error)
310+ }
311+ }
312+
313+ private func applyBalance(_ result: Result<Int, Error>?) {
314+ guard let result else { return }
315+
316+ switch result {
317+ case .success(let newBalance):
318+ // Capture the previous balance before overwriting it: the low-balance
319+ // notification fires on the crossing, not on the value.
320+ let previousBalance = balance
321+ balance = newBalance
322+ errorMessage = nil
323+ let now = Date()
324+ lastUpdated = now
325+ recordHistory(balance: newBalance, at: now)
326+
327+ // Low-balance notification: on threshold crossing (≥10 → <10)
328+ // OR on first successful fetch if already below threshold.
329+ if let previous = previousBalance, previous >= 10, newBalance < 10 {
330+ sendLowBalanceNotification(balance: newBalance)
331+ } else if previousBalance == nil && newBalance < 10 {
332+ sendLowBalanceNotification(balance: newBalance)
295333 }
334+
335+ case .failure(let error):
336+ // Keep the stale balance value — don't wipe it on error.
337+ // Only set balance = nil if there was never a successful fetch
338+ // (it's already nil in that case, so nothing to do).
339+ errorMessage = error.localizedDescription
340+ }
341+
342+ isLoading = false
343+ }
344+
345+ // MARK: - Claude Usage
346+
347+ /// What a Claude fetch produced. Missing credentials is deliberately not a failure:
348+ /// plenty of people don't use Claude Code, and an error telling them so would be
349+ /// noise rather than news.
350+ private enum ClaudeOutcome {
351+ case notConfigured
352+ case usage(ClaudeUsageReport)
353+ case failure(String)
354+ }
355+
356+ private func fetchClaudeUsage() async -> ClaudeOutcome {
357+ do {
358+ return .usage(try await claudeChecker.fetchUsage())
359+ } catch ClaudeUsageError.noCredentials {
360+ return .notConfigured
361+ } catch {
362+ return .failure(error.localizedDescription)
363+ }
364+ }
365+
366+ private func applyClaude(_ outcome: ClaudeOutcome) {
367+ switch outcome {
368+ case .notConfigured:
369+ claudeUsage = nil
370+ claudePlan = nil
371+ claudeError = nil
372+
373+ case .usage(let report):
374+ claudeUsage = report.usage
375+ claudePlan = report.subscriptionType
376+ claudeError = nil
377+
378+ case .failure(let message):
379+ // As with the balance: keep the last good numbers on screen and say what
380+ // went wrong, rather than blanking the section on a transient failure.
381+ claudeError = message
382+ }
383+ }
384+
385+ /// Display label for the Claude plan, e.g. "Pro" or "Max".
386+ var claudePlanLabel: String? {
387+ guard let plan = claudePlan?.trimmingCharacters(in: .whitespacesAndNewlines),
388+ !plan.isEmpty else { return nil }
389+
390+ switch plan.lowercased() {
391+ case "free": return "Free"
392+ case "pro": return "Pro"
393+ case "max": return "Max"
394+ case "team": return "Team"
395+ case "enterprise": return "Enterprise"
396+ default: return plan.capitalized
296397 }
297398 }
298399
HyperCreditsMenubarTests/ClaudeUsageClientTests.swiftnew file+752
@@ -0,0 +1,752 @@
1+import XCTest
2+import Foundation
3+@testable import HyperCreditsMenubar
4+
5+/// The instant every test pretends it is. Injected into the client, so token expiry is
6+/// a decision the test makes rather than a race against the wall clock.
7+private let fixedNow = Date(timeIntervalSince1970: 1_700_000_000)
8+
9+final class ClaudeUsageClientTests: XCTestCase {
10+
11+ override func setUp() {
12+ super.setUp()
13+ StubURLProtocol.reset()
14+ }
15+
16+ override func tearDown() {
17+ StubURLProtocol.reset()
18+ super.tearDown()
19+ }
20+
21+ // MARK: - Fixtures
22+
23+ /// A client whose requests are served by `StubURLProtocol` and whose credentials
24+ /// come from memory, so no test here touches the real Keychain, the real
25+ /// `~/.claude/.credentials.json`, or the network. The backoff is short enough that
26+ /// the retry tests run in milliseconds rather than seconds.
27+ private func makeClient(
28+ store: ClaudeCredentialStoring,
29+ retryBaseDelay: Duration = .milliseconds(1)
30+ ) -> ClaudeUsageClient {
31+ let config = URLSessionConfiguration.ephemeral
32+ config.protocolClasses = [StubURLProtocol.self]
33+ return ClaudeUsageClient(
34+ session: URLSession(configuration: config),
35+ store: store,
36+ retryBaseDelay: retryBaseDelay,
37+ now: { fixedNow }
38+ )
39+ }
40+
41+ private func credentials(
42+ accessToken: String = "access-1",
43+ refreshToken: String = "refresh-1",
44+ expiresAt: Date = fixedNow.addingTimeInterval(3600),
45+ subscriptionType: String? = "max"
46+ ) -> ClaudeOAuthCredentials {
47+ ClaudeOAuthCredentials(
48+ claudeAiOauth: .init(
49+ accessToken: accessToken,
50+ refreshToken: refreshToken,
51+ expiresAt: Int64(expiresAt.timeIntervalSince1970 * 1000),
52+ scopes: ["user:inference"],
53+ subscriptionType: subscriptionType,
54+ rateLimitTier: nil
55+ )
56+ )
57+ }
58+
59+ /// Credentials whose access token is already spent, so a fetch has to refresh first.
60+ private func expiredCredentials() -> ClaudeOAuthCredentials {
61+ credentials(expiresAt: fixedNow.addingTimeInterval(-1))
62+ }
63+
64+ private func usageJSON(fiveHour: Int = 68, sevenDay: Int = 41) -> Data {
65+ Data("""
66+ {
67+ "five_hour": {"utilization": \(fiveHour), "resets_at": "2023-11-14T23:53:20.000Z"},
68+ "seven_day": {"utilization": \(sevenDay), "resets_at": "2023-11-20T23:53:20.000Z"}
69+ }
70+ """.utf8)
71+ }
72+
73+ private func tokenJSON(
74+ accessToken: String = "access-2",
75+ refreshToken: String? = "refresh-2",
76+ expiresIn: Int? = 3600
77+ ) -> Data {
78+ var fields = [#""access_token": "\#(accessToken)""#]
79+ if let refreshToken { fields.append(#""refresh_token": "\#(refreshToken)""#) }
80+ if let expiresIn { fields.append(#""expires_in": \#(expiresIn)"#) }
81+ return Data("{\(fields.joined(separator: ", "))}".utf8)
82+ }
83+
84+ private func requests(to url: URL) -> [URLRequest] {
85+ StubURLProtocol.recordedRequests.filter { $0.url == url }
86+ }
87+
88+ // MARK: - Usage decoding
89+
90+ func testDecodesUsagePayload() throws {
91+ let json = Data("""
92+ {
93+ "five_hour": {"utilization": 68.4, "resets_at": "2026-07-11T20:00:00.000Z"},
94+ "seven_day": {"utilization": 41, "resets_at": "2026-07-15T20:00:00.000Z"},
95+ "seven_day_opus": {"utilization": 12, "resets_at": null},
96+ "limits": [
97+ {"percent": 41, "is_active": false, "resets_at": "2026-07-15T20:00:00.000Z"},
98+ {"percent": 68, "is_active": true, "resets_at": "2026-07-11T20:00:00.000Z"}
99+ ]
100+ }
101+ """.utf8)
102+
103+ let usage = try JSONDecoder().decode(ClaudeUsage.self, from: json)
104+
105+ XCTAssertEqual(usage.fiveHour?.utilization, 68.4)
106+ XCTAssertEqual(usage.sevenDay?.utilization, 41)
107+ XCTAssertEqual(usage.sevenDayOpus?.utilization, 12)
108+ XCTAssertNil(usage.sevenDayOpus?.resetsAt)
109+ XCTAssertEqual(usage.limits?.count, 2)
110+ XCTAssertFalse(usage.isEmpty)
111+ }
112+
113+ func testDecodesEmptyPayload() throws {
114+ let usage = try JSONDecoder().decode(ClaudeUsage.self, from: Data("{}".utf8))
115+
116+ XCTAssertNil(usage.fiveHour)
117+ XCTAssertNil(usage.limits)
118+ XCTAssertTrue(usage.isEmpty, "An account with no usage has nothing worth a section")
119+ }
120+
121+ func testIgnoresWindowsItDoesNotModel() throws {
122+ // Anthropic adds and retires windows without notice; an unknown one must not
123+ // take the rest of the payload down with it.
124+ let json = Data("""
125+ {
126+ "five_hour": {"utilization": 5},
127+ "tangelo": {"utilization": 99},
128+ "iguana_necktie": {"utilization": 99},
129+ "spend": {"percent": 3, "enabled": true}
130+ }
131+ """.utf8)
132+
133+ let usage = try JSONDecoder().decode(ClaudeUsage.self, from: json)
134+
135+ XCTAssertEqual(usage.fiveHour?.utilization, 5)
136+ XCTAssertFalse(usage.isEmpty)
137+ }
138+
139+ func testMissingUtilizationDefaultsToZero() throws {
140+ let json = Data(#"{"five_hour": {"resets_at": "2026-07-11T20:00:00.000Z"}}"#.utf8)
141+
142+ let usage = try JSONDecoder().decode(ClaudeUsage.self, from: json)
143+
144+ XCTAssertEqual(usage.fiveHour?.utilization, 0)
145+ }
146+
147+ func testFractionalPercentIsRounded() throws {
148+ // The field is documented as an integer, but decoding it as one would throw on
149+ // a fractional value and lose the whole payload.
150+ let json = Data(#"{"limits": [{"percent": 68.6, "is_active": true}]}"#.utf8)
151+
152+ let usage = try JSONDecoder().decode(ClaudeUsage.self, from: json)
153+
154+ XCTAssertEqual(usage.activeLimit?.percent, 69)
155+ }
156+
157+ func testActiveLimitIsTheOneTheServerFlags() throws {
158+ let json = Data("""
159+ {"limits": [
160+ {"percent": 41, "is_active": false},
161+ {"percent": 68, "is_active": true}
162+ ]}
163+ """.utf8)
164+
165+ let usage = try JSONDecoder().decode(ClaudeUsage.self, from: json)
166+
167+ XCTAssertEqual(usage.activeLimit?.percent, 68)
168+ }
169+
170+ func testActiveLimitIsNilWhenNoneIsFlagged() throws {
171+ let json = Data(#"{"limits": [{"percent": 41}, {"percent": 12}]}"#.utf8)
172+
173+ let usage = try JSONDecoder().decode(ClaudeUsage.self, from: json)
174+
175+ XCTAssertNil(usage.activeLimit)
176+ }
177+
178+ // MARK: - Reset countdown
179+
180+ func testResetsInFormatsHoursAndMinutes() {
181+ let window = UsageWindow(utilization: 50, resetsAt: iso(fixedNow.addingTimeInterval(3 * 3600 + 20 * 60)))
182+
183+ XCTAssertEqual(window.resetsIn(from: fixedNow), "3h 20m")
184+ }
185+
186+ func testResetsInFormatsDaysAndHours() {
187+ let window = UsageWindow(utilization: 50, resetsAt: iso(fixedNow.addingTimeInterval(28 * 3600)))
188+
189+ XCTAssertEqual(window.resetsIn(from: fixedNow), "1d 4h")
190+ }
191+
192+ func testResetsInFormatsMinutesAlone() {
193+ let window = UsageWindow(utilization: 50, resetsAt: iso(fixedNow.addingTimeInterval(12 * 60)))
194+
195+ XCTAssertEqual(window.resetsIn(from: fixedNow), "12m")
196+ }
197+
198+ func testResetsInIsNilOnceTheWindowHasPassed() {
199+ let window = UsageWindow(utilization: 50, resetsAt: iso(fixedNow.addingTimeInterval(-60)))
200+
201+ XCTAssertNil(window.resetsIn(from: fixedNow))
202+ }
203+
204+ func testResetsInIsNilWithoutATimestamp() {
205+ XCTAssertNil(UsageWindow(utilization: 0).resetsIn(from: fixedNow))
206+ }
207+
208+ func testResetsAtParsesTimestampsWithoutFractionalSeconds() {
209+ // Claude's timestamps normally carry fractional seconds; this spelling has to
210+ // parse too, rather than silently costing us the countdown.
211+ let window = UsageWindow(utilization: 50, resetsAt: "2026-07-11T20:00:00Z")
212+
213+ XCTAssertEqual(window.resetsAtDate?.timeIntervalSince1970, 1_783_800_000)
214+ }
215+
216+ func testResetsAtIsNilForAnUnparseableTimestamp() {
217+ XCTAssertNil(UsageWindow(utilization: 50, resetsAt: "not a date").resetsAtDate)
218+ }
219+
220+ func testLimitAlsoCountsDown() {
221+ let limit = Limit(percent: 68, resetsAt: iso(fixedNow.addingTimeInterval(90 * 60)), isActive: true)
222+
223+ XCTAssertEqual(limit.resetsIn(from: fixedNow), "1h 30m")
224+ }
225+
226+ // MARK: - Fetching
227+
228+ func testFetchSendsTheOAuthHeaders() async throws {
229+ let store = FakeClaudeCredentialStore(credentials: credentials())
230+ StubURLProtocol.enqueue(.status(200, usageJSON()))
231+
232+ let report = try await makeClient(store: store).fetchUsage()
233+
234+ XCTAssertEqual(report.usage.fiveHour?.utilization, 68)
235+ XCTAssertEqual(report.subscriptionType, "max")
236+
237+ let request = try XCTUnwrap(StubURLProtocol.recordedRequests.first)
238+ XCTAssertEqual(request.url, ClaudeUsageClient.usageEndpoint)
239+ XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer access-1")
240+ XCTAssertEqual(request.value(forHTTPHeaderField: "anthropic-beta"), "oauth-2025-04-20")
241+ XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/json")
242+ }
243+
244+ func testMissingCredentialsThrowWithoutTouchingTheNetwork() async {
245+ let store = FakeClaudeCredentialStore(credentials: nil)
246+
247+ do {
248+ _ = try await makeClient(store: store).fetchUsage()
249+ XCTFail("Expected noCredentials error")
250+ } catch ClaudeUsageError.noCredentials {
251+ // expected — the caller reads this as "Claude Code isn't set up here"
252+ } catch {
253+ XCTFail("Unexpected error: \(error)")
254+ }
255+
256+ XCTAssertEqual(StubURLProtocol.requestCount, 0)
257+ }
258+
259+ func testValidTokenIsUsedWithoutRefreshing() async throws {
260+ let store = FakeClaudeCredentialStore(credentials: credentials())
261+ StubURLProtocol.enqueue(.status(200, usageJSON()))
262+
263+ _ = try await makeClient(store: store).fetchUsage()
264+
265+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
266+ XCTAssertTrue(requests(to: ClaudeUsageClient.tokenEndpoint).isEmpty)
267+ XCTAssertTrue(store.savedCredentials.isEmpty, "Nothing was rotated, so nothing to write back")
268+ }
269+
270+ // MARK: - Token refresh
271+
272+ func testExpiredTokenIsRefreshedBeforeTheUsageRequest() async throws {
273+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
274+ StubURLProtocol.enqueue(
275+ .status(200, tokenJSON(accessToken: "access-2")),
276+ .status(200, usageJSON())
277+ )
278+
279+ let report = try await makeClient(store: store).fetchUsage()
280+
281+ XCTAssertEqual(report.usage.fiveHour?.utilization, 68)
282+ XCTAssertEqual(StubURLProtocol.requestCount, 2)
283+
284+ let recorded = StubURLProtocol.recordedRequests
285+ XCTAssertEqual(recorded[0].url, ClaudeUsageClient.tokenEndpoint)
286+ XCTAssertEqual(recorded[0].httpMethod, "POST")
287+ XCTAssertEqual(recorded[1].url, ClaudeUsageClient.usageEndpoint)
288+ XCTAssertEqual(
289+ recorded[1].value(forHTTPHeaderField: "Authorization"),
290+ "Bearer access-2",
291+ "The usage request has to carry the refreshed token, not the spent one"
292+ )
293+ }
294+
295+ func testRefreshRotatesAndPersistsTheCredentials() async throws {
296+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
297+ StubURLProtocol.enqueue(
298+ .status(200, tokenJSON(accessToken: "access-2", refreshToken: "refresh-2", expiresIn: 3600)),
299+ .status(200, usageJSON())
300+ )
301+
302+ _ = try await makeClient(store: store).fetchUsage()
303+
304+ let saved = try XCTUnwrap(store.savedCredentials.last)
305+ let token = saved.credentials.claudeAiOauth
306+ XCTAssertEqual(token.accessToken, "access-2")
307+ XCTAssertEqual(
308+ token.refreshToken,
309+ "refresh-2",
310+ "The server rotated the refresh token; failing to store it would sign Claude Code out"
311+ )
312+ XCTAssertEqual(token.expiresAt, Int64((fixedNow.timeIntervalSince1970 + 3600) * 1000))
313+ XCTAssertEqual(saved.source, .keychain(account: "tester"), "Written back where it was read from")
314+ }
315+
316+ func testRefreshKeepsTheOldRefreshTokenWhenTheServerDoesNotRotateIt() async throws {
317+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
318+ StubURLProtocol.enqueue(
319+ .status(200, tokenJSON(accessToken: "access-2", refreshToken: nil)),
320+ .status(200, usageJSON())
321+ )
322+
323+ _ = try await makeClient(store: store).fetchUsage()
324+
325+ let saved = try XCTUnwrap(store.savedCredentials.last)
326+ XCTAssertEqual(saved.credentials.claudeAiOauth.refreshToken, "refresh-1")
327+ }
328+
329+ func testRefreshWithoutExpiresInAssumesEightHours() async throws {
330+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
331+ StubURLProtocol.enqueue(
332+ .status(200, tokenJSON(accessToken: "access-2", expiresIn: nil)),
333+ .status(200, usageJSON())
334+ )
335+
336+ _ = try await makeClient(store: store).fetchUsage()
337+
338+ let saved = try XCTUnwrap(store.savedCredentials.last)
339+ XCTAssertEqual(
340+ saved.credentials.claudeAiOauth.expiresAt,
341+ Int64((fixedNow.timeIntervalSince1970 + 8 * 3600) * 1000)
342+ )
343+ }
344+
345+ func testRefreshSendsTheGrant() async throws {
346+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
347+ StubURLProtocol.enqueue(.status(200, tokenJSON()), .status(200, usageJSON()))
348+
349+ _ = try await makeClient(store: store).fetchUsage()
350+
351+ let body = try XCTUnwrap(StubURLProtocol.recordedBodies.first ?? nil)
352+ let grant = try XCTUnwrap(try JSONSerialization.jsonObject(with: body) as? [String: String])
353+ XCTAssertEqual(grant["grant_type"], "refresh_token")
354+ XCTAssertEqual(grant["refresh_token"], "refresh-1")
355+ XCTAssertEqual(grant["client_id"], ClaudeUsageClient.clientID)
356+ XCTAssertEqual(grant["scope"], ClaudeUsageClient.scope)
357+ }
358+
359+ func testRefreshFallsBackToFormEncoding() async throws {
360+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
361+ StubURLProtocol.enqueue(
362+ .status(400, Data()), // the JSON body is rejected
363+ .status(200, tokenJSON(accessToken: "access-2")),
364+ .status(200, usageJSON())
365+ )
366+
367+ _ = try await makeClient(store: store).fetchUsage()
368+
369+ XCTAssertEqual(StubURLProtocol.requestCount, 3)
370+ let contentTypes = StubURLProtocol.recordedRequests.prefix(2).map {
371+ $0.value(forHTTPHeaderField: "Content-Type")
372+ }
373+ XCTAssertEqual(contentTypes, ["application/json", "application/x-www-form-urlencoded"])
374+
375+ let form = try XCTUnwrap(StubURLProtocol.recordedBodies[1].flatMap { String(data: $0, encoding: .utf8) })
376+ XCTAssertTrue(form.contains("grant_type=refresh_token"), "Got: \(form)")
377+ XCTAssertTrue(form.contains("refresh_token=refresh-1"), "Got: \(form)")
378+ XCTAssertTrue(
379+ form.contains("user%3Ainference"),
380+ "The colons in the scope have to be percent-encoded, not sent raw. Got: \(form)"
381+ )
382+ }
383+
384+ func testRefreshFailureThrowsTokenRefreshFailed() async {
385+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
386+ StubURLProtocol.enqueue(.status(400, Data())) // repeats, so both encodings fail
387+
388+ do {
389+ _ = try await makeClient(store: store).fetchUsage()
390+ XCTFail("Expected tokenRefreshFailed error")
391+ } catch ClaudeUsageError.tokenRefreshFailed {
392+ // expected
393+ } catch {
394+ XCTFail("Unexpected error: \(error)")
395+ }
396+
397+ // Both encodings tried, and no usage request made with a token we never got.
398+ XCTAssertEqual(StubURLProtocol.requestCount, 2)
399+ XCTAssertTrue(requests(to: ClaudeUsageClient.usageEndpoint).isEmpty)
400+ }
401+
402+ func testConcurrentFetchesShareASingleRefresh() async throws {
403+ // Two refreshes at once would each spend the refresh token, and whichever
404+ // finished second would be left holding one the server had already retired.
405+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
406+ StubURLProtocol.enqueue(.status(200, tokenJSON()), .status(200, usageJSON()))
407+
408+ let client = makeClient(store: store)
409+ async let first = client.fetchUsage()
410+ async let second = client.fetchUsage()
411+ _ = try await (first, second)
412+
413+ XCTAssertEqual(requests(to: ClaudeUsageClient.tokenEndpoint).count, 1)
414+ XCTAssertEqual(requests(to: ClaudeUsageClient.usageEndpoint).count, 2)
415+ XCTAssertEqual(store.savedCredentials.count, 1)
416+ }
417+
418+ // MARK: - Rejected tokens
419+
420+ func testRejectedTokenIsRefreshedOnceThenRetried() async throws {
421+ // A token can be revoked server-side well before its stated expiry.
422+ let store = FakeClaudeCredentialStore(credentials: credentials())
423+ StubURLProtocol.enqueue(
424+ .status(401, Data()),
425+ .status(200, tokenJSON(accessToken: "access-2")),
426+ .status(200, usageJSON())
427+ )
428+
429+ let report = try await makeClient(store: store).fetchUsage()
430+
431+ XCTAssertEqual(report.usage.fiveHour?.utilization, 68)
432+ XCTAssertEqual(StubURLProtocol.requestCount, 3)
433+ XCTAssertEqual(
434+ StubURLProtocol.recordedRequests[2].value(forHTTPHeaderField: "Authorization"),
435+ "Bearer access-2"
436+ )
437+ }
438+
439+ func testRepeatedRejectionSurfacesInvalidCredentials() async {
440+ let store = FakeClaudeCredentialStore(credentials: credentials())
441+ StubURLProtocol.enqueue(
442+ .status(401, Data()),
443+ .status(200, tokenJSON(accessToken: "access-2")),
444+ .status(401, Data())
445+ )
446+
447+ do {
448+ _ = try await makeClient(store: store).fetchUsage()
449+ XCTFail("Expected invalidCredentials error")
450+ } catch ClaudeUsageError.invalidCredentials {
451+ // expected
452+ } catch {
453+ XCTFail("Unexpected error: \(error)")
454+ }
455+
456+ // One refresh, and no attempt at a second: it plainly isn't the token.
457+ XCTAssertEqual(StubURLProtocol.requestCount, 3)
458+ XCTAssertEqual(requests(to: ClaudeUsageClient.tokenEndpoint).count, 1)
459+ }
460+
461+ func testFailedRefreshAfterRejectionSurfacesTheRejection() async {
462+ // "Couldn't refresh" says less than "the server rejected your token", and both
463+ // point at the same fix, so the rejection is the more useful of the two.
464+ let store = FakeClaudeCredentialStore(credentials: credentials())
465+ StubURLProtocol.enqueue(.status(401, Data()), .status(403, Data()))
466+
467+ do {
468+ _ = try await makeClient(store: store).fetchUsage()
469+ XCTFail("Expected invalidCredentials error")
470+ } catch ClaudeUsageError.invalidCredentials {
471+ // expected
472+ } catch {
473+ XCTFail("Unexpected error: \(error)")
474+ }
475+ }
476+
477+ func testForbiddenIsTreatedAsARejectedToken() async {
478+ let store = FakeClaudeCredentialStore(credentials: credentials())
479+ StubURLProtocol.enqueue(.status(403, Data()))
480+
481+ do {
482+ _ = try await makeClient(store: store).fetchUsage()
483+ XCTFail("Expected invalidCredentials error")
484+ } catch ClaudeUsageError.invalidCredentials {
485+ // expected
486+ } catch {
487+ XCTFail("Unexpected error: \(error)")
488+ }
489+ }
490+
491+ // MARK: - Retries
492+
493+ func testRetriesOnServerErrorThenSucceeds() async throws {
494+ let store = FakeClaudeCredentialStore(credentials: credentials())
495+ StubURLProtocol.enqueue(
496+ .status(500, Data()),
497+ .status(503, Data()),
498+ .status(200, usageJSON())
499+ )
500+
501+ let report = try await makeClient(store: store).fetchUsage()
502+
503+ XCTAssertEqual(report.usage.fiveHour?.utilization, 68)
504+ XCTAssertEqual(StubURLProtocol.requestCount, 3)
505+ }
506+
507+ func testRetriesOnRateLimitThenSucceeds() async throws {
508+ let store = FakeClaudeCredentialStore(credentials: credentials())
509+ StubURLProtocol.enqueue(.status(429, Data()), .status(200, usageJSON()))
510+
511+ _ = try await makeClient(store: store).fetchUsage()
512+
513+ XCTAssertEqual(StubURLProtocol.requestCount, 2)
514+ }
515+
516+ func testRetriesOnNetworkErrorThenSucceeds() async throws {
517+ let store = FakeClaudeCredentialStore(credentials: credentials())
518+ StubURLProtocol.enqueue(.failure(URLError(.timedOut)), .status(200, usageJSON()))
519+
520+ _ = try await makeClient(store: store).fetchUsage()
521+
522+ XCTAssertEqual(StubURLProtocol.requestCount, 2)
523+ }
524+
525+ func testGivesUpAfterTwoRetries() async {
526+ let store = FakeClaudeCredentialStore(credentials: credentials())
527+ StubURLProtocol.enqueue(.status(500, Data())) // repeats
528+
529+ do {
530+ _ = try await makeClient(store: store).fetchUsage()
531+ XCTFail("Expected requestFailed error")
532+ } catch ClaudeUsageError.requestFailed(let message) {
533+ XCTAssertEqual(message, "HTTP 500")
534+ } catch {
535+ XCTFail("Unexpected error: \(error)")
536+ }
537+
538+ // Initial attempt + 2 retries.
539+ XCTAssertEqual(StubURLProtocol.requestCount, 3)
540+ }
541+
542+ func testDoesNotRetryOnClientError() async {
543+ let store = FakeClaudeCredentialStore(credentials: credentials())
544+ StubURLProtocol.enqueue(.status(404, Data()))
545+
546+ do {
547+ _ = try await makeClient(store: store).fetchUsage()
548+ XCTFail("Expected requestFailed error")
549+ } catch ClaudeUsageError.requestFailed(let message) {
550+ XCTAssertEqual(message, "HTTP 404")
551+ } catch {
552+ XCTFail("Unexpected error: \(error)")
553+ }
554+
555+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
556+ }
557+
558+ func testDoesNotRetryOnDecodingFailure() async {
559+ let store = FakeClaudeCredentialStore(credentials: credentials())
560+ StubURLProtocol.enqueue(.status(200, Data("not json".utf8)))
561+
562+ do {
563+ _ = try await makeClient(store: store).fetchUsage()
564+ XCTFail("Expected decodingFailed error")
565+ } catch ClaudeUsageError.decodingFailed {
566+ // expected
567+ } catch {
568+ XCTFail("Unexpected error: \(error)")
569+ }
570+
571+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
572+ }
573+
574+ // MARK: - Errors
575+
576+ func testErrorsAreEquatable() {
577+ XCTAssertEqual(ClaudeUsageError.noCredentials, .noCredentials)
578+ XCTAssertNotEqual(ClaudeUsageError.noCredentials, .invalidCredentials)
579+ XCTAssertEqual(ClaudeUsageError.requestFailed("HTTP 500"), .requestFailed("HTTP 500"))
580+ }
581+
582+ func testClaudeUsageClientConformsToProtocol() {
583+ let checker: ClaudeUsageChecking = ClaudeUsageClient()
584+ // If this compiles, ClaudeUsageClient conforms to ClaudeUsageChecking.
585+ XCTAssertNotNil(checker)
586+ }
587+
588+ // MARK: - Helpers
589+
590+ /// An ISO 8601 timestamp with fractional seconds, as Claude's API spells them.
591+ private func iso(_ date: Date) -> String {
592+ let formatter = ISO8601DateFormatter()
593+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
594+ return formatter.string(from: date)
595+ }
596+}
597+
598+// MARK: - Credential store
599+
600+/// These drive `ClaudeCodeCredentialStore` against a throwaway file and a Keychain
601+/// service that cannot exist, so `security` always misses and the file fallback is what
602+/// gets exercised. Nothing here can read — let alone write — the real Claude Code
603+/// credentials, which is the whole point: a test that clobbered them would sign the
604+/// user out of their own CLI.
605+final class ClaudeCodeCredentialStoreTests: XCTestCase {
606+
607+ private var directory: URL!
608+
609+ /// A credentials blob carrying two fields we deliberately don't model.
610+ private let credentialsJSON = """
611+ {
612+ "claudeAiOauth": {
613+ "accessToken": "access-1",
614+ "refreshToken": "refresh-1",
615+ "expiresAt": 4000000000000,
616+ "scopes": ["user:inference", "user:profile"],
617+ "subscriptionType": "max",
618+ "somethingWeDoNotModel": "keep me"
619+ },
620+ "anotherTopLevelKey": "keep me too"
621+ }
622+ """
623+
624+ override func setUpWithError() throws {
625+ try super.setUpWithError()
626+ directory = URL(fileURLWithPath: NSTemporaryDirectory())
627+ .appendingPathComponent("claude-credentials-\(UUID().uuidString)")
628+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
629+ }
630+
631+ override func tearDownWithError() throws {
632+ if let directory {
633+ try? FileManager.default.removeItem(at: directory)
634+ }
635+ try super.tearDownWithError()
636+ }
637+
638+ private func makeStore(file: URL) -> ClaudeCodeCredentialStore {
639+ ClaudeCodeCredentialStore(
640+ keychainService: "com.example.tests.absent-\(UUID().uuidString)",
641+ credentialsFile: file
642+ )
643+ }
644+
645+ private func writeCredentialsFile() throws -> URL {
646+ let file = directory.appendingPathComponent(".credentials.json")
647+ try Data(credentialsJSON.utf8).write(to: file)
648+ return file
649+ }
650+
651+ func testReadsCredentialsFromTheFileWhenTheKeychainHasNothing() throws {
652+ let file = try writeCredentialsFile()
653+
654+ let stored = try XCTUnwrap(makeStore(file: file).load())
655+
656+ XCTAssertEqual(stored.credentials.claudeAiOauth.accessToken, "access-1")
657+ XCTAssertEqual(stored.credentials.claudeAiOauth.refreshToken, "refresh-1")
658+ XCTAssertEqual(stored.credentials.claudeAiOauth.subscriptionType, "max")
659+ XCTAssertEqual(stored.source, .file(file))
660+ }
661+
662+ func testReturnsNilWhenThereIsNeitherAKeychainItemNorAFile() {
663+ let missing = directory.appendingPathComponent("nothing-here.json")
664+
665+ XCTAssertNil(makeStore(file: missing).load())
666+ }
667+
668+ func testReturnsNilForACorruptedFile() throws {
669+ let file = directory.appendingPathComponent(".credentials.json")
670+ try Data("{ not json".utf8).write(to: file)
671+
672+ XCTAssertNil(makeStore(file: file).load())
673+ }
674+
675+ func testWriteBackRotatesTheTokensAndKeepsEverythingElse() throws {
676+ let file = try writeCredentialsFile()
677+ let store = makeStore(file: file)
678+
679+ var stored = try XCTUnwrap(store.load())
680+ stored.credentials.claudeAiOauth.accessToken = "access-2"
681+ stored.credentials.claudeAiOauth.refreshToken = "refresh-2"
682+ stored.credentials.claudeAiOauth.expiresAt = 4_100_000_000_000
683+ store.save(stored)
684+
685+ let written = try XCTUnwrap(
686+ try JSONSerialization.jsonObject(with: Data(contentsOf: file)) as? [String: Any]
687+ )
688+ let oauth = try XCTUnwrap(written["claudeAiOauth"] as? [String: Any])
689+
690+ XCTAssertEqual(oauth["accessToken"] as? String, "access-2")
691+ XCTAssertEqual(oauth["refreshToken"] as? String, "refresh-2")
692+ XCTAssertEqual((oauth["expiresAt"] as? NSNumber)?.int64Value, 4_100_000_000_000)
693+
694+ // This is the file the user's CLI signs in with. Round-tripping it through our
695+ // own type would drop the fields we don't model, so the write patches the raw
696+ // JSON instead — and these two have to survive it.
697+ XCTAssertEqual(oauth["somethingWeDoNotModel"] as? String, "keep me")
698+ XCTAssertEqual(written["anotherTopLevelKey"] as? String, "keep me too")
699+ XCTAssertEqual(oauth["subscriptionType"] as? String, "max")
700+ }
701+
702+ func testWriteBackLeavesTheFileReadableOnlyByItsOwner() throws {
703+ let file = try writeCredentialsFile()
704+ let store = makeStore(file: file)
705+
706+ let stored = try XCTUnwrap(store.load())
707+ store.save(stored)
708+
709+ let attributes = try FileManager.default.attributesOfItem(atPath: file.path)
710+ XCTAssertEqual(
711+ (attributes[.posixPermissions] as? NSNumber)?.int16Value,
712+ 0o600,
713+ "An atomic write replaces the file, so the mode has to be reapplied"
714+ )
715+ }
716+}
717+
718+// MARK: - Fake credential store
719+
720+/// An in-memory `ClaudeCredentialStoring`. Never goes near the Keychain or the disk.
721+final class FakeClaudeCredentialStore: ClaudeCredentialStoring {
722+ private let lock = NSLock()
723+ private var current: StoredClaudeCredentials?
724+ private var writes: [StoredClaudeCredentials] = []
725+
726+ init(
727+ credentials: ClaudeOAuthCredentials?,
728+ source: ClaudeCredentialSource = .keychain(account: "tester")
729+ ) {
730+ current = credentials.map { StoredClaudeCredentials(credentials: $0, source: source) }
731+ }
732+
733+ /// Every write-back the client has made, oldest first.
734+ var savedCredentials: [StoredClaudeCredentials] {
735+ lock.lock()
736+ defer { lock.unlock() }
737+ return writes
738+ }
739+
740+ func load() -> StoredClaudeCredentials? {
741+ lock.lock()
742+ defer { lock.unlock() }
743+ return current
744+ }
745+
746+ func save(_ stored: StoredClaudeCredentials) {
747+ lock.lock()
748+ defer { lock.unlock() }
749+ current = stored
750+ writes.append(stored)
751+ }
752+}
HyperCreditsMenubarTests/CreditsCheckerTests.swift+29
@@ -509,6 +509,7 @@ final class StubURLProtocol: URLProtocol {
509509 private static var outcomes: [Outcome] = []
510510 private static var lastOutcome: Outcome?
511511 private static var requests: [URLRequest] = []
512+ private static var bodies: [Data?] = []
512513
513514 /// Queues the outcomes for successive requests. Once the queue is drained the final
514515 /// outcome repeats, so a persistent failure needs only one entry.
@@ -524,6 +525,7 @@ final class StubURLProtocol: URLProtocol {
524525 outcomes = []
525526 lastOutcome = nil
526527 requests = []
528+ bodies = []
527529 }
528530
529531 static var recordedRequests: [URLRequest] {
@@ -532,6 +534,15 @@ final class StubURLProtocol: URLProtocol {
532534 return requests
533535 }
534536
537+ /// The recorded requests' bodies, in the same order. Captured here rather than read
538+ /// from `URLRequest.httpBody` afterwards, because `URLSession` hands a protocol the
539+ /// body as a stream — one that has to be drained while the request is being loaded.
540+ static var recordedBodies: [Data?] {
541+ lock.lock()
542+ defer { lock.unlock() }
543+ return bodies
544+ }
545+
535546 static var requestCount: Int {
536547 recordedRequests.count
537548 }
@@ -541,6 +552,7 @@ final class StubURLProtocol: URLProtocol {
541552 lock.lock()
542553 defer { lock.unlock() }
543554 requests.append(request)
555+ bodies.append(drainBody(from: request))
544556
545557 if !outcomes.isEmpty {
546558 lastOutcome = outcomes.removeFirst()
@@ -548,6 +560,23 @@ final class StubURLProtocol: URLProtocol {
548560 return lastOutcome ?? .failure(StubError.noOutcomeConfigured)
549561 }
550562
563+ private static func drainBody(from request: URLRequest) -> Data? {
564+ if let body = request.httpBody { return body }
565+ guard let stream = request.httpBodyStream else { return nil }
566+
567+ stream.open()
568+ defer { stream.close() }
569+
570+ var body = Data()
571+ var buffer = [UInt8](repeating: 0, count: 4096)
572+ while stream.hasBytesAvailable {
573+ let read = stream.read(&buffer, maxLength: buffer.count)
574+ guard read > 0 else { break }
575+ body.append(buffer, count: read)
576+ }
577+ return body
578+ }
579+
551580 override class func canInit(with request: URLRequest) -> Bool { true }
552581
553582 override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
README.md+20−3
@@ -1,10 +1,11 @@
11# HyperCreditsMenubar
22
3−A macOS menu bar application that displays your [Hyper (Charm)](https://hyper.charm.land) credit balance.
3+A macOS menu bar application that displays your [Hyper (Charm)](https://hyper.charm.land) credit balance — and, if you use the Claude Code CLI, your Claude subscription limits alongside it.
44
55## Features
66
77- ⚡ **Menu bar display** — shows `⚡{balance}` (e.g. `⚡42`) right in your menu bar
8+- 🤖 **Claude Code limits** — your Pro/Max 5-hour and 7-day usage windows in the popover, with the percentage used and a countdown to the next reset. No setup: if you have signed in with the Claude Code CLI, it just appears. If you haven't, the section doesn't
89- 🔄 **Auto-refresh** — fetches your balance on a configurable interval (1m / 5m / 15m / 30m), and again on wake from sleep
910- 📊 **Balance sparkline** — a subtle mini-chart in the popover shows your balance trend over recent fetches
1011- 📋 **Click-to-copy** — click the balance number in the popover to copy it to the clipboard
@@ -50,6 +51,17 @@ brew install --cask ilyakooo0/tap/hyper-credits-menubar
5051
5152You can get your API key from [hyper.charm.land](https://hyper.charm.land).
5253
54+### Claude Code limits (optional, no setup)
55+
56+If you use the [Claude Code CLI](https://claude.com/claude-code), the popover also shows how much of your Pro/Max plan you have used: the limit currently in effect as a headline percentage, the 5-hour and 7-day windows underneath, and how long until the next reset.
57+
58+There is nothing to configure. The app reads the OAuth credentials Claude Code already stores when you run `claude` and sign in — first from its login-Keychain item, then from `~/.claude/.credentials.json` if the Keychain has nothing. If neither exists, the Claude section simply doesn't appear; it is not treated as an error, and the Hyper balance is unaffected.
59+
60+Two things worth knowing:
61+
62+- **macOS will ask once.** Those credentials belong to Claude Code, not to this app, so the first read prompts you to allow access to the `Claude Code-credentials` Keychain item. Choose **Always Allow** and you won't be asked again.
63+- **The credentials are read-only, with one exception.** When the access token has expired, the app refreshes it against Anthropic's token endpoint and writes the result back to wherever it read it from — exactly as Claude Code itself would. Nothing else about them is ever modified, and the fields the app doesn't model are preserved verbatim, so your CLI session keeps working.
64+
5365## Settings
5466
5567Everything is configured from the popover — click the ⚡ icon in the menu bar.
@@ -122,16 +134,18 @@ hyper-credits-menubar/
122134├── project.yml # xcodegen project definition
123135├── HyperCreditsMenubar/
124136│ ├── HyperCreditsApp.swift # @main App, NSStatusBar, timer, sleep/wake
125−│ ├── ViewModel.swift # Observable state: balance, refresh, notifications
137+│ ├── ViewModel.swift # Observable state: balance, Claude usage, refresh, notifications
126138│ ├── MenuView.swift # SwiftUI popover view
127139│ ├── CreditsChecker.swift # API client (GET /v1/credits)
140+│ ├── ClaudeUsageClient.swift # Claude OAuth client (GET /api/oauth/usage) + models
128141│ ├── KeychainHelper.swift # Keychain wrapper for API key
129142│ ├── VersionFormatter.swift # YYYY.MM.DD.HHHH formatting
130143│ ├── Info.plist # LSUIElement=true (agent app)
131−│ ├── HyperCreditsMenubar.entitlements # Network client entitlements
144+│ ├── HyperCreditsMenubar.entitlements # Network client; deliberately not sandboxed
132145│ └── Assets.xcassets/ # App icon, accent color
133146├── HyperCreditsMenubarTests/
134147│ ├── CreditsCheckerTests.swift # API, retry, fractional balance, keychain tests
148+│ ├── ClaudeUsageClientTests.swift # Usage decoding, token refresh, retry, credential store
135149│ └── VersionFormatterTests.swift # Version formatting tests
136150├── .github/workflows/
137151│ └── release.yml # Build + release on push to master
@@ -150,6 +164,9 @@ hyper-credits-menubar/
1501646. **State**: `ViewModel` owns the balance, loading/error state, balance history (for the sparkline), refresh task (cancellable to prevent races), and the refresh interval, and is shared between the menu bar item and the popover.
1511657. **Notifications**: When the balance crosses below 10 credits — or is already below 10 on the first successful fetch — `ViewModel` posts a local `UNUserNotification`. Permission is requested only once.
1521668. **Agent app**: `LSUIElement=true` in `Info.plist` makes the app a background agent — no dock icon, no main window.
167+9. **Claude usage**: `ClaudeUsageClient` reads Claude Code's OAuth credentials (its `Claude Code-credentials` Keychain item via `/usr/bin/security`, falling back to `~/.claude/.credentials.json`), refreshes the access token when it has expired, and fetches `GET https://api.anthropic.com/api/oauth/usage` with `Authorization: Bearer …` and `anthropic-beta: oauth-2025-04-20`. It is an actor so that overlapping refreshes (timer, manual click, wake) join a single in-flight token exchange — the endpoint rotates the refresh token, and rotating it twice would sign Claude Code out. Missing credentials are reported as "not configured" rather than as an error, and the popover hides the section.
168+10. **Independent fetches**: `ViewModel.refresh()` runs the Hyper and Claude requests concurrently with `async let`. Neither can fail, stall, or be unconfigured in a way that affects the other.
169+11. **No sandbox**: Reading credentials that belong to another app means spawning `/usr/bin/security` and reading a file in the user's home directory, neither of which the App Sandbox permits. The app is distributed ad-hoc signed via Homebrew rather than through the App Store, where the sandbox would be mandatory.
153170
154171## Credits
155172
project.yml+3−2
@@ -63,8 +63,9 @@ targets:
6363 PRODUCT_NAME: HyperCreditsMenubar
6464 INFOPLIST_FILE: HyperCreditsMenubar/Info.plist
6565 # Grants the outgoing-network-connection entitlement needed to reach
66− # the Hyper API. Stripped in CI's archive step (unsigned builds can't
67− # carry entitlements).
66+ # the Hyper and Claude APIs, and deliberately does not sandbox the app —
67+ # see the comment in the entitlements file. Stripped in CI's archive step
68+ # (unsigned builds can't carry entitlements).
6869 CODE_SIGN_ENTITLEMENTS: HyperCreditsMenubar/HyperCreditsMenubar.entitlements
6970 ENABLE_HARDENED_RUNTIME: YES
7071 MACOSX_DEPLOYMENT_TARGET: "14.0"