5/// Usage data from `GET https://api.anthropic.com/api/oauth/usage` — the endpoint
6/// behind Claude Code's own `/usage` command.
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.
11struct ClaudeUsage: Decodable, Equatable {
12 let fiveHour: UsageWindow?
13 let sevenDay: UsageWindow?
14 let sevenDayOpus: UsageWindow?
17 private enum CodingKeys: String, CodingKey {
18 case fiveHour = "five_hour"
19 case sevenDay = "seven_day"
20 case sevenDayOpus = "seven_day_opus"
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 }
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
34 fiveHour == nil && sevenDay == nil && sevenDayOpus == nil && (limits?.isEmpty ?? true)
38/// One usage window, e.g. the rolling 5-hour or 7-day allowance.
39struct UsageWindow: Decodable, Equatable, ResetCountdown {
40 /// Percent of the window consumed, 0–100.
41 let utilization: Double
43 /// ISO 8601, or `nil` when nothing has been used and so nothing is counting down.
46 private enum CodingKeys: String, CodingKey {
48 case resetsAt = "resets_at"
51 init(utilization: Double, resetsAt: String? = nil) {
52 self.utilization = utilization
53 self.resetsAt = resetsAt
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)
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.
65struct Limit: Decodable, Equatable, ResetCountdown {
70 private enum CodingKeys: String, CodingKey {
72 case resetsAt = "resets_at"
73 case isActive = "is_active"
76 init(percent: Int, resetsAt: String? = nil, isActive: Bool = false) {
77 self.percent = percent
78 self.resetsAt = resetsAt
79 self.isActive = isActive
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
94// MARK: - Reset Countdown
96/// Anything carrying an ISO 8601 reset timestamp, and so able to render a countdown.
97protocol ResetCountdown {
98 var resetsAt: String? { get }
101extension 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)
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.
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 }
115 let interval = date.timeIntervalSince(reference)
116 guard interval > 0 else { return nil }
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
125 if days > 0 { return "\(days)d \(hours)h" }
126 if hours > 0 { return "\(hours)h \(minutes)m" }
130 /// The countdown as of now.
131 var resetsInFormatted: String? {
136/// Claude timestamps carry fractional seconds, but not always — both spellings parse.
137private enum ISO8601 {
138 private static let fractional: ISO8601DateFormatter = {
139 let formatter = ISO8601DateFormatter()
140 formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
144 private static let whole: ISO8601DateFormatter = {
145 let formatter = ISO8601DateFormatter()
146 formatter.formatOptions = [.withInternetDateTime]
150 static func date(from string: String) -> Date? {
151 fractional.date(from: string) ?? whole.date(from: string)
155// MARK: - Credentials
157/// The subset of Claude Code's OAuth blob this app reads. The key names are Claude
158/// Code's, not ours — and reading is *all* we do with them.
160/// Deliberately models neither `refreshToken` nor `expiresAt`. Both existed only to
161/// serve a token refresh, and refreshing is precisely what this app must never do: the
162/// token endpoint rotates the refresh token, so an app refreshing behind the CLI's back
163/// leaves the CLI holding a token the server has already retired — signing the user out
164/// of their own terminal. Not modelling the fields is the cheapest way to keep it that
165/// way. Every field Claude Code stores that isn't named here is simply ignored.
166struct ClaudeOAuthCredentials: Decodable, Equatable {
167 let claudeAiOauth: OAuthToken
169 struct OAuthToken: Decodable, Equatable {
170 let accessToken: String
171 let scopes: [String]?
172 /// `"pro"`, `"max"`, `"team"`, …
173 let subscriptionType: String?
174 let rateLimitTier: String?
178/// Reads the credentials Claude Code owns. Read-only by design — there is no `save`,
179/// because the CLI is the only thing allowed to rotate these tokens.
180/// Injectable so tests never go near the real Keychain or the real credentials file.
181protocol ClaudeCredentialStoring {
182 func load() -> ClaudeOAuthCredentials?
185/// The real store: Claude Code's login-Keychain item, falling back to
186/// `~/.claude/.credentials.json`.
188/// The Keychain item belongs to Claude Code, so it is read through the `security`
189/// CLI rather than `SecItemCopyMatching`: the item's ACL trusts `/usr/bin/security`,
190/// and macOS asks the user once to allow it. Spawning a process and reading a file in
191/// the user's home is also why this app cannot be sandboxed.
192struct ClaudeCodeCredentialStore: ClaudeCredentialStoring {
193 static let defaultKeychainService = "Claude Code-credentials"
195 /// `NSHomeDirectory()` rather than `FileManager.homeDirectoryForCurrentUser`: the
196 /// latter can resolve to a container path, while Claude Code always writes to the
197 /// real home directory.
198 static var defaultCredentialsFile: URL {
199 URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".claude/.credentials.json")
202 private let keychainService: String
203 private let credentialsFile: URL
206 keychainService: String = ClaudeCodeCredentialStore.defaultKeychainService,
207 credentialsFile: URL = ClaudeCodeCredentialStore.defaultCredentialsFile
209 self.keychainService = keychainService
210 self.credentialsFile = credentialsFile
213 func load() -> ClaudeOAuthCredentials? {
214 if let json = Self.runSecurity(["find-generic-password", "-s", keychainService, "-w"]),
215 let credentials = Self.decode(Data(json.utf8)) {
219 guard let data = try? Data(contentsOf: credentialsFile) else { return nil }
220 return Self.decode(data)
223 private static func decode(_ data: Data) -> ClaudeOAuthCredentials? {
224 try? JSONDecoder().decode(ClaudeOAuthCredentials.self, from: data)
227 /// Runs `/usr/bin/security`, returning its trimmed stdout, or `nil` when it fails
228 /// or says nothing — which is the ordinary answer when Claude Code was never
229 /// signed in on this machine.
230 private static func runSecurity(_ arguments: [String]) -> String? {
231 let process = Process()
232 process.executableURL = URL(fileURLWithPath: "/usr/bin/security")
233 process.arguments = arguments
236 process.standardOutput = output
237 // Discarded rather than piped: an undrained error pipe can fill and deadlock.
238 process.standardError = FileHandle.nullDevice
246 // Drained before waiting, for the same reason.
247 let data = output.fileHandleForReading.readDataToEndOfFile()
248 process.waitUntilExit()
250 guard process.terminationStatus == 0 else { return nil }
252 let text = String(data: data, encoding: .utf8)?
253 .trimmingCharacters(in: .whitespacesAndNewlines)
254 guard let text, !text.isEmpty else { return nil }
261/// Errors that can occur while fetching Claude usage.
262enum ClaudeUsageError: LocalizedError, Equatable {
263 /// Claude Code has never signed in here. Not an error the user needs to see — the
264 /// popover just leaves the section out.
267 /// The token the CLI last stored has expired or been revoked. The app cannot fix
268 /// this itself — only the CLI may mint a new one — so the message sends the user
270 case invalidCredentials
271 case requestFailed(String)
272 case decodingFailed(String)
274 var errorDescription: String? {
277 return "No Claude Code credentials found. Run `claude` in a terminal to sign in."
278 case .invalidCredentials:
279 return "Claude sign-in expired. Run `claude` in a terminal to sign in again."
280 case .requestFailed(let message):
281 return "Claude request failed: \(message)"
282 case .decodingFailed(let message):
283 return "Failed to decode Claude usage: \(message)"
288/// What a successful fetch yields: the usage itself, plus the plan it belongs to.
289struct ClaudeUsageReport: Equatable {
290 let usage: ClaudeUsage
292 /// `"pro"`, `"max"`, … straight from the OAuth credentials; `nil` when absent.
293 let subscriptionType: String?
296/// Protocol abstraction for fetching Claude usage, enabling test injection.
297protocol ClaudeUsageChecking {
298 func fetchUsage() async throws -> ClaudeUsageReport
303/// Client for Claude Code's subscription usage API, authenticating with whatever OAuth
304/// access token the CLI has most recently stored on this machine.
306/// **A passenger, never a driver.** It reads the CLI's token and spends it as-is. It
307/// never refreshes: the token endpoint *rotates* the refresh token, and the CLI refreshes
308/// on its own schedule, so an app that also refreshed would race it — whichever of the two
309/// went second would present a refresh token the server had already retired, and the user
310/// would find themselves signed out of their own terminal. That bug is not worth a usage
311/// readout. When the token is spent, we say so and point at `claude`.
313/// Credentials are re-read on every fetch (the app polls every 1–5 minutes), so a token
314/// the CLI refreshes is picked up on the next tick without the app lifting a finger.
316/// Still an actor: it holds no mutable state now that the refresh is gone, but the
317/// overlapping fetches (timer, manual click, wake) share one instance, and an actor keeps
318/// that sharing trivially safe.
319actor ClaudeUsageClient: ClaudeUsageChecking {
320 static let usageEndpoint = URL(string: "https://api.anthropic.com/api/oauth/usage")!
322 /// Cloudflare fronts the API and answers 403 `browser_signature_banned` to anything
323 /// without a browser User-Agent.
324 static let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
325 + "(KHTML, like Gecko) Chrome/126.0 Safari/537.36"
327 /// Retries *after* the initial attempt, so at most 3 requests per call.
328 static let maxRetries = 2
330 /// URL error codes worth retrying: the request never reached a server that had an
331 /// opinion about it, so the same request may well succeed a moment later.
332 private static let retryableURLErrorCodes: Set<URLError.Code> = [
334 .cannotConnectToHost,
337 .networkConnectionLost,
338 .notConnectedToInternet,
339 .resourceUnavailable,
343 /// Client-error status codes that are nonetheless worth retrying: both are the
344 /// server telling us to come back later, not that the request itself is wrong.
345 private static let retryableStatusCodes: Set<Int> = [
346 408, // Request Timeout
347 429, // Too Many Requests
350 private let session: URLSession
351 private let store: ClaudeCredentialStoring
352 private let retryBaseDelay: Duration
355 /// - retryBaseDelay: Delay before the first retry; doubles on each subsequent
356 /// one (2s, then 4s). Injectable so tests need not wait in real time.
358 session: URLSession? = nil,
359 store: ClaudeCredentialStoring = ClaudeCodeCredentialStore(),
360 retryBaseDelay: Duration = .seconds(2)
363 self.retryBaseDelay = retryBaseDelay
365 self.session = session
367 let config = URLSessionConfiguration.default
368 config.timeoutIntervalForRequest = 10
369 config.timeoutIntervalForResource = 15
370 self.session = URLSession(configuration: config)
374 /// Fetches current usage with the CLI's current access token, exactly as stored.
376 /// The token is not checked for expiry and not refreshed on rejection — see the type
377 /// comment. A spent token surfaces as `.invalidCredentials`, which asks the user to
378 /// run `claude`; doing so has the CLI mint a fresh token, which the next fetch picks
381 /// - Throws: `ClaudeUsageError`. `.noCredentials` means Claude Code was never
382 /// signed in here, which callers should treat as "nothing to show" rather than
384 func fetchUsage() async throws -> ClaudeUsageReport {
385 guard let credentials = store.load() else { throw ClaudeUsageError.noCredentials }
387 let token = credentials.claudeAiOauth
388 let usage = try await requestUsage(token: token.accessToken)
389 return ClaudeUsageReport(usage: usage, subscriptionType: token.subscriptionType)
392 // MARK: - Usage Request
394 private func requestUsage(token: String) async throws -> ClaudeUsage {
395 var request = URLRequest(url: Self.usageEndpoint, cachePolicy: .reloadIgnoringLocalCacheData)
396 request.httpMethod = "GET"
397 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
398 request.setValue("oauth-2025-04-20", forHTTPHeaderField: "anthropic-beta")
399 request.setValue("application/json", forHTTPHeaderField: "Accept")
400 request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent")
402 let data = try await send(request)
405 return try JSONDecoder().decode(ClaudeUsage.self, from: data)
407 throw ClaudeUsageError.decodingFailed(error.localizedDescription)
413 /// Sends a request, retrying transient failures with exponential backoff.
414 private func send(_ request: URLRequest) async throws -> Data {
419 return try await performAttempt(request)
420 } catch let failure as AttemptFailure {
421 guard failure.isRetryable, attempt < Self.maxRetries else {
422 throw failure.underlying
424 try await Task.sleep(for: retryBaseDelay * (1 << attempt))
430 /// Performs a single request, tagging the failure with whether a retry could
432 private func performAttempt(_ request: URLRequest) async throws -> Data {
434 let response: URLResponse
436 (data, response) = try await session.data(for: request)
437 } catch let urlError as URLError {
438 throw AttemptFailure(
439 underlying: .requestFailed(urlError.localizedDescription),
440 isRetryable: Self.retryableURLErrorCodes.contains(urlError.code)
443 throw AttemptFailure(
444 underlying: .requestFailed(error.localizedDescription),
449 guard let httpResponse = response as? HTTPURLResponse else {
450 throw AttemptFailure(underlying: .requestFailed("Invalid response type"), isRetryable: false)
453 switch httpResponse.statusCode {
457 throw AttemptFailure(underlying: .invalidCredentials, isRetryable: false)
459 throw AttemptFailure(
460 underlying: .requestFailed("HTTP \(httpResponse.statusCode)"),
464 throw AttemptFailure(
465 underlying: .requestFailed("HTTP \(httpResponse.statusCode)"),
466 isRetryable: Self.retryableStatusCodes.contains(httpResponse.statusCode)
471 /// A failed attempt, carrying the error to surface plus whether retrying is worthwhile.
472 private struct AttemptFailure: Error {
473 let underlying: ClaudeUsageError
474 let isRetryable: Bool
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list