import Foundation public protocol ClaudeUsageFetching: Sendable { func loadLatestUsage(model: String) async throws -> ClaudeUsageSnapshot func debugRawProbe(model: String) async -> String func detectVersion() -> String? } public struct ClaudeUsageSnapshot: Sendable { public enum PrimaryWindowKind: Equatable, Sendable { case usage case spendLimit } public let primary: RateWindow public let primaryWindowKind: PrimaryWindowKind public let secondary: RateWindow? public let opus: RateWindow? public let extraRateWindows: [NamedRateWindow] public let providerCost: ProviderCostSnapshot? public let updatedAt: Date public let accountEmail: String? public let accountOrganization: String? public let loginMethod: String? public let rawText: String? /// Present only when the credential used for this OAuth fetch matches the current Claude Keychain item. public let oauthKeychainPersistentRefHash: String? /// One-way, high-entropy ownership evidence derived from the exact credential used for this OAuth fetch. public let oauthHistoryOwnerIdentifier: String? /// True when a prompt-free comparison proved this credential differs from Claude Code's Keychain entry. public let oauthKeychainCredentialMismatch: Bool /// True when a prompt-free probe proved Claude Code has no Keychain credential. public let oauthKeychainCredentialAbsent: Bool /// True when a Claude CLI credential won routing but the prompt-free Keychain comparison was unavailable. public let oauthKeychainCredentialUnavailable: Bool public init( primary: RateWindow, primaryWindowKind: PrimaryWindowKind = .usage, secondary: RateWindow?, opus: RateWindow?, extraRateWindows: [NamedRateWindow] = [], providerCost: ProviderCostSnapshot? = nil, updatedAt: Date, accountEmail: String?, accountOrganization: String?, loginMethod: String?, rawText: String?, oauthKeychainPersistentRefHash: String? = nil, oauthHistoryOwnerIdentifier: String? = nil, oauthKeychainCredentialMismatch: Bool = false, oauthKeychainCredentialAbsent: Bool = false, oauthKeychainCredentialUnavailable: Bool = false) { self.primary = primary self.primaryWindowKind = primaryWindowKind self.secondary = secondary self.opus = opus self.extraRateWindows = extraRateWindows self.providerCost = providerCost self.updatedAt = updatedAt self.accountEmail = accountEmail self.accountOrganization = accountOrganization self.loginMethod = loginMethod self.rawText = rawText self.oauthKeychainPersistentRefHash = oauthKeychainPersistentRefHash self.oauthHistoryOwnerIdentifier = oauthHistoryOwnerIdentifier self.oauthKeychainCredentialMismatch = oauthKeychainCredentialMismatch self.oauthKeychainCredentialAbsent = oauthKeychainCredentialAbsent self.oauthKeychainCredentialUnavailable = oauthKeychainCredentialUnavailable } } public enum ClaudeUsageError: LocalizedError, Sendable { case claudeNotInstalled case parseFailed(String) case oauthFailed(String) public var errorDescription: String? { switch self { case .claudeNotInstalled: "Claude CLI is not installed. Install it from https://code.claude.com/docs/en/overview." case let .parseFailed(details): "Could not parse Claude usage: \(details)" case let .oauthFailed(details): details } } } public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { private static let sessionWindowMinutes = 5 * 60 private static let weeklyWindowMinutes = 7 * 24 * 60 private static let cliAutoProbeTimeout: TimeInterval = 12 private static let cliProbeTimeout: TimeInterval = 24 private static let cliRetryProbeTimeout: TimeInterval = 60 private struct Configuration { let environment: [String: String] let runtime: ProviderRuntime let dataSource: ClaudeUsageDataSource let oauthKeychainPromptCooldownEnabled: Bool let allowBackgroundDelegatedRefresh: Bool let allowStartupBootstrapPrompt: Bool let useWebExtras: Bool let manualCookieHeader: String? let webOrganizationID: String? let keepCLISessionsAlive: Bool let browserDetection: BrowserDetection } private let configuration: Configuration private static let log = CodexBarLog.logger(LogCategories.claudeUsage) private static var isClaudeOAuthFlowDebugEnabled: Bool { ProcessInfo.processInfo.environment["CODEXBAR_DEBUG_CLAUDE_OAUTH_FLOW"] == "1" } private var environment: [String: String] { self.configuration.environment } private var runtime: ProviderRuntime { self.configuration.runtime } private var dataSource: ClaudeUsageDataSource { self.configuration.dataSource } private var oauthKeychainPromptCooldownEnabled: Bool { self.configuration.oauthKeychainPromptCooldownEnabled } private var allowsDelegatedOAuthRefresh: Bool { self.runtime == .app } private var allowsOAuthClaudeVersionDetection: Bool { self.runtime == .app } private var allowBackgroundDelegatedRefresh: Bool { self.configuration.allowBackgroundDelegatedRefresh } private var allowStartupBootstrapPrompt: Bool { self.configuration.allowStartupBootstrapPrompt } private var useWebExtras: Bool { self.configuration.useWebExtras } private var manualCookieHeader: String? { self.configuration.manualCookieHeader } private var webOrganizationID: String? { self.configuration.webOrganizationID } private var keepCLISessionsAlive: Bool { self.configuration.keepCLISessionsAlive } private var browserDetection: BrowserDetection { self.configuration.browserDetection } private struct ClaudeOAuthKeychainPromptPolicy { let mode: ClaudeOAuthKeychainPromptMode let isApplicable: Bool let interaction: ProviderInteraction var canPromptNow: Bool { switch self.mode { case .never: false case .onlyOnUserAction: self.interaction == .userInitiated case .always: true } } /// Respect the Keychain prompt cooldown for background operations to avoid spamming system dialogs. /// User actions (menu open / refresh / settings) are allowed to bypass the cooldown. var shouldRespectKeychainPromptCooldown: Bool { self.interaction != .userInitiated } var interactionLabel: String { self.interaction == .userInitiated ? "user" : "background" } } private static func currentClaudeOAuthInteractivePromptPolicy() -> ClaudeOAuthKeychainPromptPolicy { let policy = ClaudeOAuthKeychainPromptPolicy( mode: ClaudeOAuthKeychainPromptPreference.securityFrameworkFallbackMode(), isApplicable: true, interaction: ProviderInteractionContext.current) // User actions should be able to immediately retry a Security.framework fallback repair after a background // cooldown was recorded, even when /usr/bin/security is the primary reader. if policy.interaction == .userInitiated { if ClaudeOAuthKeychainAccessGate.clearDenied() { Self.log.info("Claude OAuth keychain cooldown cleared by user action") } } return policy } private static func currentClaudeOAuthDelegatedRefreshPolicy() -> ClaudeOAuthKeychainPromptPolicy { // Delegated refresh must honor the stored prompt mode for every keychain read strategy. // securityCLIExperimental is not "prompt policy N/A" for CLI touch repair. ClaudeOAuthKeychainPromptPolicy( mode: ClaudeOAuthKeychainPromptPreference.storedMode(), isApplicable: true, interaction: ProviderInteractionContext.current) } private static func assertDelegatedRefreshAllowedInCurrentInteraction( policy: ClaudeOAuthKeychainPromptPolicy, allowBackgroundDelegatedRefresh: Bool) throws { if policy.mode == .never { throw ClaudeUsageError.oauthFailed("Delegated refresh is disabled by 'never' keychain policy.") } if policy.mode == .onlyOnUserAction, policy.interaction != .userInitiated, !allowBackgroundDelegatedRefresh { throw ClaudeUsageError.oauthFailed( "Claude OAuth token expired, but background repair is suppressed when Keychain prompt policy " + "is set to only prompt on user action. Open the CodexBar menu or click Refresh to retry.") } } #if DEBUG @TaskLocal static var loadOAuthCredentialsOverride: (@Sendable ( [String: String], Bool, Bool) async throws -> ClaudeOAuthCredentials)? @TaskLocal static var fetchOAuthUsageOverride: (@Sendable ( String, Bool) async throws -> OAuthUsageResponse)? @TaskLocal static var delegatedRefreshAttemptOverride: (@Sendable ( Date, TimeInterval, [String: String]) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? @TaskLocal static var hasCachedCredentialsOverride: Bool? #endif /// Creates a new ClaudeUsageFetcher. /// - Parameters: /// - environment: Process environment (default: current process environment) /// - dataSource: Usage data source (default: OAuth API). /// - useWebExtras: If true, attempts to enrich usage with Claude web data (cookies). public init( browserDetection: BrowserDetection, environment: [String: String] = ProcessInfo.processInfo.environment, runtime: ProviderRuntime = .app, dataSource: ClaudeUsageDataSource = .oauth, oauthKeychainPromptCooldownEnabled: Bool = false, allowBackgroundDelegatedRefresh: Bool = false, allowStartupBootstrapPrompt: Bool = false, useWebExtras: Bool = false, manualCookieHeader: String? = nil, webOrganizationID: String? = nil, keepCLISessionsAlive: Bool = false) { self.configuration = Configuration( environment: environment, runtime: runtime, dataSource: dataSource, oauthKeychainPromptCooldownEnabled: oauthKeychainPromptCooldownEnabled, allowBackgroundDelegatedRefresh: allowBackgroundDelegatedRefresh, allowStartupBootstrapPrompt: allowStartupBootstrapPrompt, useWebExtras: useWebExtras, manualCookieHeader: manualCookieHeader, webOrganizationID: webOrganizationID, keepCLISessionsAlive: keepCLISessionsAlive, browserDetection: browserDetection) } private struct OAuthExecutor { let fetcher: ClaudeUsageFetcher func load(allowDelegatedRetry: Bool) async throws -> ClaudeUsageSnapshot { do { let promptPolicy = ClaudeUsageFetcher.currentClaudeOAuthInteractivePromptPolicy() #if DEBUG let hasCache = if let hasCachedCredentialsOverride = ClaudeUsageFetcher.hasCachedCredentialsOverride { hasCachedCredentialsOverride } else if ClaudeUsageFetcher.loadOAuthCredentialsOverride != nil { false } else { ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: self.fetcher.environment) } #else let hasCache = ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: self.fetcher.environment) #endif let startupBootstrapOverride = self.shouldAllowStartupBootstrapPrompt( policy: promptPolicy, hasCache: hasCache) let allowKeychainPrompt = (promptPolicy.canPromptNow || startupBootstrapOverride) && !hasCache ClaudeUsageFetcher.logOAuthBootstrapPromptDecision( allowKeychainPrompt: allowKeychainPrompt, policy: promptPolicy, hasCache: hasCache, startupBootstrapOverride: startupBootstrapOverride) let credentialRecord = try await ClaudeOAuthCredentialsStore.$allowBackgroundPromptBootstrap .withValue(startupBootstrapOverride) { try await ClaudeUsageFetcher.loadOAuthCredentialRecord( environment: self.fetcher.environment, allowKeychainPrompt: allowKeychainPrompt, respectKeychainPromptCooldown: promptPolicy.shouldRespectKeychainPromptCooldown) } let credentials = credentialRecord.credentials try self.validateRequiredOAuthScope(credentials) let usage = try await ClaudeUsageFetcher.fetchOAuthUsage( accessToken: credentials.accessToken, detectClaudeVersion: self.fetcher.allowsOAuthClaudeVersionDetection) let keychainMatch = ClaudeOAuthCredentialsStore .claudeKeychainCredentialMatchWithoutPrompt(for: credentialRecord) return try ClaudeUsageFetcher.mapOAuthUsage( usage, credentials: credentials, oauthKeychainPersistentRefHash: keychainMatch.persistentRefHash, oauthHistoryOwnerIdentifier: credentialRecord.historyOwnerIdentifier, oauthKeychainCredentialMismatch: keychainMatch.isMismatch, oauthKeychainCredentialAbsent: keychainMatch.isAbsent, oauthKeychainCredentialUnavailable: keychainMatch.isUnavailable) } catch let error as CancellationError { throw error } catch let error as ClaudeUsageError { throw error } catch let error as ClaudeOAuthCredentialsError { if case .refreshDelegatedToClaudeCLI = error { return try await self.loadAfterDelegatedRefresh(allowDelegatedRetry: allowDelegatedRetry) } throw ClaudeUsageError.oauthFailed(error.localizedDescription) } catch let error as ClaudeOAuthFetchError { if case .rateLimited = error { throw ClaudeUsageError.oauthFailed(error.localizedDescription) } ClaudeOAuthCredentialsStore.invalidateCache() if case let .serverError(statusCode, body) = error, statusCode == 403, body?.contains("user:profile") ?? false { throw ClaudeUsageError.oauthFailed( "Claude OAuth token does not meet scope requirement 'user:profile'. " + "Run `claude setup-token` to re-generate credentials, or switch Claude Source to " + "Web/CLI.") } throw ClaudeUsageError.oauthFailed(error.localizedDescription) } catch { throw ClaudeUsageError.oauthFailed(error.localizedDescription) } } private func shouldAllowStartupBootstrapPrompt( policy: ClaudeOAuthKeychainPromptPolicy, hasCache: Bool) -> Bool { guard self.fetcher.allowStartupBootstrapPrompt else { return false } guard !hasCache else { return false } guard ClaudeOAuthKeychainPromptPreference.securityFrameworkFallbackMode() == .onlyOnUserAction else { return false } guard policy.interaction == .background else { return false } return ProviderRefreshContext.current == .startup } private func loadAfterDelegatedRefresh(allowDelegatedRetry: Bool) async throws -> ClaudeUsageSnapshot { guard allowDelegatedRetry else { throw ClaudeUsageError.oauthFailed( "Claude OAuth token expired. CodexBar CLI does not launch Claude to refresh credentials. " + "Run `claude login`, then retry.") } try Task.checkCancellation() let delegatedPromptPolicy = ClaudeUsageFetcher.currentClaudeOAuthDelegatedRefreshPolicy() try ClaudeUsageFetcher.assertDelegatedRefreshAllowedInCurrentInteraction( policy: delegatedPromptPolicy, allowBackgroundDelegatedRefresh: self.fetcher.allowBackgroundDelegatedRefresh) let delegatedOutcome = await ClaudeUsageFetcher.attemptDelegatedRefresh( environment: self.fetcher.environment) ClaudeUsageFetcher.log.info( "Claude OAuth delegated refresh attempted", metadata: [ "outcome": ClaudeUsageFetcher.delegatedRefreshOutcomeLabel(delegatedOutcome), ]) do { if self.fetcher.oauthKeychainPromptCooldownEnabled { switch delegatedOutcome { case .skippedByCooldown, .cliUnavailable: throw ClaudeUsageError.oauthFailed( "Claude OAuth token expired; delegated refresh is unavailable (outcome=" + "\(ClaudeUsageFetcher.delegatedRefreshOutcomeLabel(delegatedOutcome))).") case .attemptedSucceeded, .attemptedFailed: break } } try Task.checkCancellation() _ = ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged() let didSyncSilently = delegatedOutcome == .attemptedSucceeded && ClaudeOAuthCredentialsStore.syncFromClaudeKeychainWithoutPrompt(now: Date()) let promptPolicy = ClaudeUsageFetcher.currentClaudeOAuthInteractivePromptPolicy() ClaudeUsageFetcher.logDeferredBackgroundDelegatedRecoveryIfNeeded( delegatedOutcome: delegatedOutcome, didSyncSilently: didSyncSilently, policy: promptPolicy) let retryAllowKeychainPrompt = promptPolicy.canPromptNow && !didSyncSilently if retryAllowKeychainPrompt { ClaudeUsageFetcher.log.info( "Claude OAuth keychain prompt allowed (post-delegation retry)", metadata: [ "interaction": promptPolicy.interactionLabel, "promptMode": promptPolicy.mode.rawValue, "promptPolicyApplicable": "\(promptPolicy.isApplicable)", "delegatedOutcome": ClaudeUsageFetcher.delegatedRefreshOutcomeLabel(delegatedOutcome), "didSyncSilently": "\(didSyncSilently)", ]) } if ClaudeUsageFetcher.isClaudeOAuthFlowDebugEnabled { ClaudeUsageFetcher.log.debug( "Claude OAuth credential load (post-delegation retry start)", metadata: [ "cooldownEnabled": "\(self.fetcher.oauthKeychainPromptCooldownEnabled)", "didSyncSilently": "\(didSyncSilently)", "allowKeychainPrompt": "\(retryAllowKeychainPrompt)", "delegatedOutcome": ClaudeUsageFetcher.delegatedRefreshOutcomeLabel(delegatedOutcome), "interaction": promptPolicy.interactionLabel, "promptMode": promptPolicy.mode.rawValue, "promptPolicyApplicable": "\(promptPolicy.isApplicable)", ]) } let refreshedRecord = try await ClaudeUsageFetcher.loadOAuthCredentialRecord( environment: self.fetcher.environment, allowKeychainPrompt: retryAllowKeychainPrompt, respectKeychainPromptCooldown: promptPolicy.shouldRespectKeychainPromptCooldown) let refreshedCredentials = refreshedRecord.credentials if ClaudeUsageFetcher.isClaudeOAuthFlowDebugEnabled { ClaudeUsageFetcher.log.debug( "Claude OAuth credential load (post-delegation retry)", metadata: [ "cooldownEnabled": "\(self.fetcher.oauthKeychainPromptCooldownEnabled)", "didSyncSilently": "\(didSyncSilently)", "allowKeychainPrompt": "\(retryAllowKeychainPrompt)", "delegatedOutcome": ClaudeUsageFetcher.delegatedRefreshOutcomeLabel(delegatedOutcome), "interaction": promptPolicy.interactionLabel, "promptMode": promptPolicy.mode.rawValue, "promptPolicyApplicable": "\(promptPolicy.isApplicable)", ]) } try self.validateRequiredOAuthScope(refreshedCredentials) let usage = try await ClaudeUsageFetcher.fetchOAuthUsage( accessToken: refreshedCredentials.accessToken, detectClaudeVersion: self.fetcher.allowsOAuthClaudeVersionDetection) let keychainMatch = ClaudeOAuthCredentialsStore .claudeKeychainCredentialMatchWithoutPrompt(for: refreshedRecord) return try ClaudeUsageFetcher.mapOAuthUsage( usage, credentials: refreshedCredentials, oauthKeychainPersistentRefHash: keychainMatch.persistentRefHash, oauthHistoryOwnerIdentifier: refreshedRecord.historyOwnerIdentifier, oauthKeychainCredentialMismatch: keychainMatch.isMismatch, oauthKeychainCredentialAbsent: keychainMatch.isAbsent, oauthKeychainCredentialUnavailable: keychainMatch.isUnavailable) } catch { ClaudeUsageFetcher.log.debug( "Claude OAuth post-delegation retry failed", metadata: ClaudeUsageFetcher.delegatedRetryFailureMetadata( error: error, oauthKeychainPromptCooldownEnabled: self.fetcher.oauthKeychainPromptCooldownEnabled, delegatedOutcome: delegatedOutcome)) throw ClaudeUsageError.oauthFailed( ClaudeUsageFetcher.delegatedRefreshFailureMessage( for: delegatedOutcome, retryError: error)) } } private func validateRequiredOAuthScope(_ credentials: ClaudeOAuthCredentials) throws { guard credentials.scopes.contains("user:profile") else { let scopes = credentials.scopes.joined(separator: ", ") let detail = scopes.isEmpty ? "Claude OAuth token missing 'user:profile' scope." : "Claude OAuth token missing 'user:profile' scope (has: \(scopes))." throw ClaudeUsageError.oauthFailed( detail + " Run `claude setup-token` to re-generate credentials, or switch Claude Source to " + "Web/CLI.") } } } private struct StepExecutor { let fetcher: ClaudeUsageFetcher func loadLatestUsage(model: String) async throws -> ClaudeUsageSnapshot { switch self.fetcher.dataSource { case .auto: return try await self.executeAuto(model: model) case .api: throw ClaudeUsageError.parseFailed("Claude Admin API usage is handled by the provider descriptor.") case .oauth: var snapshot = try await self.fetcher.loadViaOAuth( allowDelegatedRetry: self.fetcher.allowsDelegatedOAuthRefresh) snapshot = await self.fetcher.applyWebExtrasIfNeeded(to: snapshot) return snapshot case .web: return try await self.fetcher.loadViaWebAPI() case .cli: return try await self.loadViaCLIWithRetry(model: model) } } private func executeAuto(model: String) async throws -> ClaudeUsageSnapshot { let plan = await self.makeAutoFetchPlan() self.logAutoPlan(plan) let executionSteps = plan.executionSteps for (index, step) in executionSteps.enumerated() { do { return try await self.execute(step: step, model: model) } catch { if index < executionSteps.count - 1 { ClaudeUsageFetcher.log.debug( "Claude planner step failed; falling back to next step", metadata: [ "step": step.dataSource.rawValue, "reason": step.inclusionReason.rawValue, "errorType": String(describing: type(of: error)), ]) continue } throw error } } throw ClaudeUsageError.parseFailed("Claude planner produced no executable steps.") } private func makeAutoFetchPlan() async -> ClaudeFetchPlan { let hasWebSession = if let header = self.fetcher.manualCookieHeader { ClaudeWebAPIFetcher.hasSessionKey(cookieHeader: header) } else { ClaudeWebAPIFetcher.hasSessionKey(browserDetection: self.fetcher.browserDetection) } let hasCLI = ClaudeCLIResolver.isAvailable(environment: self.fetcher.environment) return ClaudeSourcePlanner.resolve(input: ClaudeSourcePlanningInput( runtime: self.fetcher.runtime, selectedDataSource: .auto, webExtrasEnabled: self.fetcher.useWebExtras, hasWebSession: hasWebSession, hasCLI: hasCLI, hasOAuthCredentials: ClaudeOAuthPlanningAvailability.isAvailable( runtime: self.fetcher.runtime, sourceMode: .auto, environment: self.fetcher.environment))) } private func logAutoPlan(_ plan: ClaudeFetchPlan) { var metadata: [String: String] = [ "plannerOrder": plan.orderLabel, "selected": plan.preferredStep?.dataSource.rawValue ?? "none", "noSourceAvailable": "\(plan.isNoSourceAvailable)", "webExtrasEnabled": "\(self.fetcher.useWebExtras)", "oauthReadStrategy": ClaudeOAuthKeychainReadStrategyPreference.current().rawValue, ] for (index, step) in plan.orderedSteps.enumerated() { metadata["step\(index)"] = "\(step.dataSource.rawValue):\(step.inclusionReason.rawValue):\(step.isPlausiblyAvailable)" } ClaudeUsageFetcher.log.debug("Claude auto source planner", metadata: metadata) } private func execute(step: ClaudeFetchPlanStep, model: String) async throws -> ClaudeUsageSnapshot { switch step.dataSource { case .api: throw ClaudeUsageError.parseFailed("Planner emitted invalid api execution step.") case .oauth: var snapshot = try await self.fetcher.loadViaOAuth( allowDelegatedRetry: self.fetcher.allowsDelegatedOAuthRefresh) snapshot = await self.fetcher.applyWebExtrasIfNeeded(to: snapshot) return snapshot case .web: return try await self.fetcher.loadViaWebAPI() case .cli: return try await self.loadViaAutoCLI(model: model) case .auto: throw ClaudeUsageError.parseFailed("Planner emitted invalid auto execution step.") } } private func loadViaAutoCLI(model: String) async throws -> ClaudeUsageSnapshot { do { return try await self.loadViaCLI(model: model, timeout: ClaudeUsageFetcher.cliAutoProbeTimeout) } catch { if error is CancellationError { throw error } guard Self.shouldRetryCLIProbe(after: error) else { throw error } return try await self.loadViaCLI(model: model, timeout: ClaudeUsageFetcher.cliRetryProbeTimeout) } } private func loadViaCLIWithRetry(model: String) async throws -> ClaudeUsageSnapshot { do { return try await self.loadViaCLI(model: model, timeout: ClaudeUsageFetcher.cliProbeTimeout) } catch { if error is CancellationError { throw error } guard Self.shouldRetryCLIProbe(after: error) else { throw error } return try await self.loadViaCLI(model: model, timeout: ClaudeUsageFetcher.cliRetryProbeTimeout) } } private func loadViaCLI(model: String, timeout: TimeInterval) async throws -> ClaudeUsageSnapshot { if ClaudeCLIRateLimitGate.blockedUntil() != nil { throw ClaudeUsageError.parseFailed(ClaudeCLIRateLimitGate.message) } var snapshot: ClaudeUsageSnapshot do { snapshot = try await self.fetcher.loadViaPTY(model: model, timeout: timeout) } catch { if error is CancellationError { throw error } if ClaudeUsageFetcher.isCLIRateLimitError(error) { ClaudeCLIRateLimitGate.recordRateLimit() throw error } guard Self.shouldTryDirectCLIUsage(after: error) else { throw error } let ptyError = error do { snapshot = try await self.fetcher.loadViaDirectCLI( timeout: Self.directCLIUsageTimeout(for: timeout)) } catch let directError { if directError is CancellationError { throw directError } if ClaudeUsageFetcher.isCLIRateLimitError(directError) { ClaudeCLIRateLimitGate.recordRateLimit() throw directError } guard Self.directCLIErrorShouldReplacePTYError(directError) else { throw ptyError } throw directError } } ClaudeCLIRateLimitGate.recordSuccess() snapshot = await self.fetcher.applyWebExtrasIfNeeded(to: snapshot) return snapshot } private static func directCLIUsageTimeout(for ptyTimeout: TimeInterval) -> TimeInterval { min(max(ptyTimeout / 3, 6), 8) } private static func directCLIErrorShouldReplacePTYError(_ error: Error) -> Bool { if case let ClaudeStatusProbeError.parseFailed(message) = error { return message.lowercased().contains("subscription") } if case let ClaudeUsageError.parseFailed(message) = error { return message.lowercased().contains("subscription") } return false } private static func shouldTryDirectCLIUsage(after error: Error) -> Bool { if case ClaudeStatusProbeError.timedOut = error { return true } if case let ClaudeStatusProbeError.parseFailed(message) = error { let lower = message.lowercased() return lower.contains("still loading usage") || lower.contains("could not load usage data") } let message = error.localizedDescription.lowercased() return message.contains("timed out") || message.contains("timeout") } private static func shouldRetryCLIProbe(after error: Error) -> Bool { if case ClaudeStatusProbeError.timedOut = error { return true } if case let ClaudeStatusProbeError.parseFailed(message) = error { return message.lowercased().contains("still loading usage") } let message = error.localizedDescription.lowercased() return message.contains("timed out") || message.contains("timeout") } } } extension ClaudeUsageFetcher { // MARK: - Parsing helpers public static func parse(json: Data) -> ClaudeUsageSnapshot? { self.parse(json: json, now: .init()) } package static func parse(json: Data, now: Date) -> ClaudeUsageSnapshot? { guard let output = String(data: json, encoding: .utf8) else { return nil } return try? Self.parse(output: output, now: now) } private static func parse(output: String, now: Date) throws -> ClaudeUsageSnapshot { guard let data = output.data(using: .utf8), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw ClaudeUsageError.parseFailed(output.prefix(500).description) } if let ok = obj["ok"] as? Bool, !ok { let hint = obj["hint"] as? String ?? (obj["pane_preview"] as? String ?? "") throw ClaudeUsageError.parseFailed(hint) } func firstWindowDict(_ keys: [String]) -> [String: Any]? { for key in keys { if let dict = obj[key] as? [String: Any] { return dict } } return nil } func makeWindow(_ dict: [String: Any]?, windowMinutes: Int) -> RateWindow? { guard let dict else { return nil } let pct = (dict["pct_used"] as? NSNumber)?.doubleValue ?? 0 let resetText = dict["resets"] as? String return RateWindow( usedPercent: pct, windowMinutes: windowMinutes, resetsAt: ClaudeStatusProbe.parseResetDate( from: resetText, now: now, expectedWindow: TimeInterval(windowMinutes * 60)), resetDescription: resetText) } guard let session = makeWindow( firstWindowDict(["session_5h"]), windowMinutes: Self.sessionWindowMinutes) else { throw ClaudeUsageError.parseFailed("missing session data") } let weekAll = makeWindow( firstWindowDict(["week_all_models", "week_all"]), windowMinutes: Self.weeklyWindowMinutes) let rawEmail = (obj["account_email"] as? String)?.trimmingCharacters( in: .whitespacesAndNewlines) let email = (rawEmail?.isEmpty ?? true) ? nil : rawEmail let rawOrg = (obj["account_org"] as? String)?.trimmingCharacters( in: .whitespacesAndNewlines) let org = (rawOrg?.isEmpty ?? true) ? nil : rawOrg let loginMethod = (obj["login_method"] as? String)?.trimmingCharacters( in: .whitespacesAndNewlines) let opusWindow = makeWindow( firstWindowDict([ "week_sonnet", "week_sonnet_only", "week_opus", ]), windowMinutes: Self.weeklyWindowMinutes) return ClaudeUsageSnapshot( primary: session, secondary: weekAll, opus: opusWindow, providerCost: nil, updatedAt: now, accountEmail: email, accountOrganization: org, loginMethod: loginMethod, rawText: output) } // MARK: - Public API public func detectVersion() -> String? { ProviderVersionDetector.claudeVersion() } public func debugRawProbe(model: String = "sonnet") async -> String { do { let snap = try await self.loadViaPTY(model: model, timeout: Self.cliProbeTimeout) let opus = snap.opus?.remainingPercent ?? -1 let email = snap.accountEmail ?? "nil" let org = snap.accountOrganization ?? "nil" let weekly = snap.secondary?.remainingPercent ?? -1 let primary = snap.primary.remainingPercent return """ session_left=\(primary) weekly_left=\(weekly) opus_left=\(opus) email \(email) org \(org) \(snap) """ } catch { return "Probe failed: \(error)" } } public func loadLatestUsage(model: String = "sonnet") async throws -> ClaudeUsageSnapshot { try await StepExecutor(fetcher: self).loadLatestUsage(model: model) } public static func isCLIRateLimitError(_ error: Error) -> Bool { ClaudeCLIRateLimitGate.isRateLimitError(error) } } extension ClaudeUsageFetcher { // MARK: - OAuth API path private static func logOAuthBootstrapPromptDecision( allowKeychainPrompt: Bool, policy: ClaudeOAuthKeychainPromptPolicy, hasCache: Bool, startupBootstrapOverride: Bool) { guard allowKeychainPrompt else { return } self.log.info( "Claude OAuth keychain prompt allowed (bootstrap)", metadata: [ "interaction": policy.interactionLabel, "promptMode": policy.mode.rawValue, "promptPolicyApplicable": "\(policy.isApplicable)", "hasCache": "\(hasCache)", "startupBootstrapOverride": "\(startupBootstrapOverride)", ]) } private static func logDeferredBackgroundDelegatedRecoveryIfNeeded( delegatedOutcome: ClaudeOAuthDelegatedRefreshCoordinator.Outcome, didSyncSilently: Bool, policy: ClaudeOAuthKeychainPromptPolicy) { guard delegatedOutcome == .attemptedSucceeded else { return } guard !didSyncSilently else { return } guard policy.mode == .onlyOnUserAction else { return } guard policy.interaction == .background else { return } self.log.info( "Claude OAuth delegated refresh completed; background recovery deferred until user action", metadata: [ "interaction": policy.interactionLabel, "promptMode": policy.mode.rawValue, "delegatedOutcome": self.delegatedRefreshOutcomeLabel(delegatedOutcome), ]) } private func loadViaOAuth(allowDelegatedRetry: Bool) async throws -> ClaudeUsageSnapshot { try await OAuthExecutor(fetcher: self).load(allowDelegatedRetry: allowDelegatedRetry) } private static func loadOAuthCredentialRecord( environment: [String: String], allowKeychainPrompt: Bool, respectKeychainPromptCooldown: Bool) async throws -> ClaudeOAuthCredentialRecord { #if DEBUG if let override = loadOAuthCredentialsOverride { return try await ClaudeOAuthCredentialRecord( credentials: override(environment, allowKeychainPrompt, respectKeychainPromptCooldown), owner: .environment, source: .environment) } #endif return try await ClaudeOAuthCredentialsStore.loadRecordWithAutoRefresh( environment: environment, allowKeychainPrompt: allowKeychainPrompt, respectKeychainPromptCooldown: respectKeychainPromptCooldown) } private static func fetchOAuthUsage( accessToken: String, detectClaudeVersion: Bool) async throws -> OAuthUsageResponse { #if DEBUG if let override = fetchOAuthUsageOverride { return try await override(accessToken, detectClaudeVersion) } #endif return try await ClaudeOAuthUsageFetcher.fetchUsage( accessToken: accessToken, detectClaudeVersion: detectClaudeVersion) } private static func attemptDelegatedRefresh( now: Date = Date(), timeout: TimeInterval = 15, environment: [String: String] = ProcessInfo.processInfo.environment) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome { #if DEBUG if let override = delegatedRefreshAttemptOverride { return await override(now, timeout, environment) } #endif return await ClaudeOAuthDelegatedRefreshCoordinator.attempt( now: now, timeout: timeout, environment: environment) } private static func delegatedRefreshOutcomeLabel( _ outcome: ClaudeOAuthDelegatedRefreshCoordinator.Outcome) -> String { switch outcome { case .skippedByCooldown: "skippedByCooldown" case .cliUnavailable: "cliUnavailable" case .attemptedSucceeded: "attemptedSucceeded" case .attemptedFailed: "attemptedFailed" } } private static func delegatedRefreshFailureMessage( for outcome: ClaudeOAuthDelegatedRefreshCoordinator.Outcome, retryError: Error) -> String { if let oauthError = retryError as? ClaudeOAuthFetchError, case .rateLimited = oauthError { return oauthError.localizedDescription } switch outcome { case .skippedByCooldown: return "Claude OAuth token expired and delegated refresh is cooling down. " + "Please retry shortly, or run `claude login`." case .cliUnavailable: return "Claude OAuth token expired and Claude CLI is not available for delegated refresh. " + "Install/configure `claude`, or run `claude login`." case .attemptedSucceeded: return "Claude OAuth token is still unavailable after delegated Claude CLI refresh. " + "Run `claude login`, then retry." case let .attemptedFailed(message): return "Claude OAuth token expired and delegated Claude CLI refresh failed: \(message). " + "Run `claude login`, then retry." } } private static func delegatedRetryFailureMetadata( error: Error, oauthKeychainPromptCooldownEnabled: Bool, delegatedOutcome: ClaudeOAuthDelegatedRefreshCoordinator.Outcome) -> [String: String] { var metadata: [String: String] = [ "errorType": String(describing: type(of: error)), "cooldownEnabled": "\(oauthKeychainPromptCooldownEnabled)", "delegatedOutcome": Self.delegatedRefreshOutcomeLabel(delegatedOutcome), ] // Avoid `localizedDescription` here: some error types include server response bodies in their // `errorDescription`, which can leak potentially identifying information into logs. if let oauthError = error as? ClaudeOAuthFetchError { switch oauthError { case .unauthorized: metadata["oauthError"] = "unauthorized" case let .rateLimited(retryAfter): metadata["oauthError"] = "rateLimited" metadata["retryAfter"] = retryAfter.map { "\($0.timeIntervalSince1970)" } ?? "nil" case .invalidResponse: metadata["oauthError"] = "invalidResponse" case let .serverError(statusCode, body): metadata["oauthError"] = "serverError" metadata["httpStatus"] = "\(statusCode)" metadata["bodyLength"] = "\(body?.utf8.count ?? 0)" case let .networkError(underlying): metadata["oauthError"] = "networkError" metadata["underlyingType"] = String(describing: type(of: underlying)) } } return metadata } private static func mapOAuthUsage( _ usage: OAuthUsageResponse, credentials: ClaudeOAuthCredentials, oauthKeychainPersistentRefHash: String? = nil, oauthHistoryOwnerIdentifier: String? = nil, oauthKeychainCredentialMismatch: Bool = false, oauthKeychainCredentialAbsent: Bool = false, oauthKeychainCredentialUnavailable: Bool = false) throws -> ClaudeUsageSnapshot { let oauthHistoryOwnerIdentifier = ClaudeOAuthCredentials.normalizedHistoryOwnerIdentifier( oauthHistoryOwnerIdentifier) ?? credentials.historyOwnerIdentifier func makeWindow(_ window: OAuthUsageWindow?, windowMinutes: Int?) -> RateWindow? { guard let window, let utilization = window.utilization else { return nil } let resetDate = ClaudeOAuthUsageFetcher.parseISO8601Date(window.resetsAt) let resetDescription = resetDate.map(Self.formatResetDate) return RateWindow( usedPercent: utilization, windowMinutes: windowMinutes, resetsAt: resetDate, resetDescription: resetDescription) } let loginMethod = ClaudePlan.oauthLoginMethod( subscriptionType: credentials.subscriptionType, rateLimitTier: credentials.rateLimitTier) let primary = makeWindow(usage.fiveHour, windowMinutes: 5 * 60) ?? makeWindow(usage.sevenDay, windowMinutes: 7 * 24 * 60) ?? makeWindow(usage.sevenDayOAuthApps, windowMinutes: 7 * 24 * 60) ?? makeWindow(usage.sevenDaySonnet, windowMinutes: 7 * 24 * 60) ?? makeWindow(usage.sevenDayOpus, windowMinutes: 7 * 24 * 60) let treatAsSpendLimit = primary == nil && usage.extraUsage?.isEnabled == true let providerCost = Self.oauthExtraUsageCost( usage.extraUsage, loginMethod: loginMethod, treatAsSpendLimit: treatAsSpendLimit) guard let primary else { if let spendLimit = Self.oauthSpendLimitWindow(from: providerCost, extraUsage: usage.extraUsage) { return ClaudeUsageSnapshot( primary: spendLimit, primaryWindowKind: .spendLimit, secondary: nil, opus: nil, extraRateWindows: Self.oauthExtraRateWindows(from: usage), providerCost: providerCost, updatedAt: Date(), accountEmail: nil, accountOrganization: nil, loginMethod: loginMethod, rawText: nil, oauthKeychainPersistentRefHash: oauthKeychainPersistentRefHash, oauthHistoryOwnerIdentifier: oauthHistoryOwnerIdentifier, oauthKeychainCredentialMismatch: oauthKeychainCredentialMismatch, oauthKeychainCredentialAbsent: oauthKeychainCredentialAbsent, oauthKeychainCredentialUnavailable: oauthKeychainCredentialUnavailable) } throw ClaudeUsageError.parseFailed("missing session data") } let weekly = makeWindow(usage.sevenDay, windowMinutes: 7 * 24 * 60) let modelSpecific = makeWindow( usage.sevenDaySonnet ?? usage.sevenDayOpus, windowMinutes: 7 * 24 * 60) let extraRateWindows = Self.oauthExtraRateWindows(from: usage) return ClaudeUsageSnapshot( primary: primary, secondary: weekly, opus: modelSpecific, extraRateWindows: extraRateWindows, providerCost: providerCost, updatedAt: Date(), accountEmail: nil, accountOrganization: nil, loginMethod: loginMethod, rawText: nil, oauthKeychainPersistentRefHash: oauthKeychainPersistentRefHash, oauthHistoryOwnerIdentifier: oauthHistoryOwnerIdentifier, oauthKeychainCredentialMismatch: oauthKeychainCredentialMismatch, oauthKeychainCredentialAbsent: oauthKeychainCredentialAbsent, oauthKeychainCredentialUnavailable: oauthKeychainCredentialUnavailable) } private static func oauthExtraUsageCost( _ extra: OAuthExtraUsage?, loginMethod: String?, treatAsSpendLimit: Bool = false) -> ProviderCostSnapshot? { guard let extra, extra.isEnabled == true else { return nil } guard let used = extra.usedCredits, let limit = extra.monthlyLimit else { return nil } let currency = extra.currency?.trimmingCharacters(in: .whitespacesAndNewlines) let code = (currency?.isEmpty ?? true) ? "USD" : currency! let isSpendLimit = treatAsSpendLimit || ClaudePlan.fromCompatibilityLoginMethod(loginMethod) == .enterprise let normalized = Self.normalizeClaudeExtraUsageAmounts( used: used, limit: limit, treatAsMajorUnits: false) return ProviderCostSnapshot( used: normalized.used, limit: normalized.limit, currencyCode: code, period: isSpendLimit ? "Spend limit" : "Monthly cap", resetsAt: nil, updatedAt: Date()) } private static func oauthSpendLimitWindow( from providerCost: ProviderCostSnapshot?, extraUsage: OAuthExtraUsage?) -> RateWindow? { guard let providerCost, providerCost.limit > 0 else { return nil } let usedPercent = extraUsage?.utilization ?? (providerCost.used / providerCost.limit) * 100 let used = UsageFormatter.currencyString(providerCost.used, currencyCode: providerCost.currencyCode) let limit = UsageFormatter.currencyString(providerCost.limit, currencyCode: providerCost.currencyCode) return RateWindow( usedPercent: min(100, max(0, usedPercent)), windowMinutes: nil, resetsAt: providerCost.resetsAt, resetDescription: "\(providerCost.period ?? "Spend limit"): \(used) / \(limit)") } private static func normalizeClaudeExtraUsageAmounts( used: Double, limit: Double, treatAsMajorUnits: Bool) -> (used: Double, limit: Double) { if treatAsMajorUnits { return (used: used, limit: limit) } // Claude's OAuth API returns values in cents (minor units), same as the Web API. // Always convert to dollars (major units) for display consistency. // See: ClaudeWebAPIFetcher.swift which always divides by 100. return (used: used / 100.0, limit: limit / 100.0) } private static func oauthExtraRateWindows(from usage: OAuthUsageResponse) -> [NamedRateWindow] { let definitions: [(id: String, title: String, window: OAuthUsageWindow?, sourceKey: String?)] = [ ( id: "claude-routines", title: "Daily Routines", window: usage.sevenDayRoutines, sourceKey: usage.sevenDayRoutinesSourceKey), ] if let routinesKey = usage.sevenDayRoutinesSourceKey { Self.log.debug("Claude OAuth extra usage key matched: routines=\(routinesKey)") } let routineWindows: [NamedRateWindow] = definitions.compactMap { definition in let utilization: Double let resetDate: Date? if let window = definition.window, let parsedUtilization = window.utilization { utilization = parsedUtilization resetDate = ClaudeOAuthUsageFetcher.parseISO8601Date(window.resetsAt) } else if definition.sourceKey != nil { // Keep product bars visible when the API returns a known key with null payload. utilization = 0 resetDate = nil } else { return nil } let resetDescription = resetDate.map(Self.formatResetDate) return NamedRateWindow( id: definition.id, title: definition.title, window: RateWindow( usedPercent: utilization, windowMinutes: Self.weeklyWindowMinutes, resetsAt: resetDate, resetDescription: resetDescription)) } return routineWindows + Self.oauthScopedWeeklyLimitWindows(from: usage) } private static func oauthScopedWeeklyLimitWindows(from usage: OAuthUsageResponse) -> [NamedRateWindow] { let limits = usage.limits?.map { entry in ClaudeScopedWeeklyLimitMapper.Limit( kind: entry.kind, group: entry.group, percent: entry.percent, resetsAt: ClaudeOAuthUsageFetcher.parseISO8601Date(entry.resetsAt), modelID: entry.scope?.model?.id, modelName: entry.scope?.model?.displayName) } // `is_active` is intentionally not a filter: observed enforceable scoped limits report false. return ClaudeScopedWeeklyLimitMapper.extraRateWindows( from: limits, resetDescription: Self.formatResetDate) } // MARK: - Web API path (uses browser cookies) /// The 5-hour session (`primary`) window for a Claude web usage payload. /// /// When `five_hour` is null the web fetcher reports a synthesized 0% session (an account with no live /// session window). Flag that placeholder so lane classifiers — e.g. the combined Session + Weekly /// menu-bar metric — surface the weekly lane instead of a phantom 5h session. The flag keys off the /// reported presence of the session object, so a real session that is merely idle (0% used, possibly /// with no reset) stays unflagged and keeps rendering. static func webPrimaryWindow(from webData: ClaudeWebAPIFetcher.WebUsageData) -> RateWindow { RateWindow( usedPercent: webData.sessionPercentUsed, windowMinutes: 5 * 60, resetsAt: webData.sessionResetsAt, resetDescription: webData.sessionResetsAt.map { Self.formatResetDate($0) }, isSyntheticPlaceholder: !webData.hasLiveSessionWindow) } private func loadViaWebAPI() async throws -> ClaudeUsageSnapshot { let webData: ClaudeWebAPIFetcher.WebUsageData = if let header = self.manualCookieHeader { try await ClaudeWebAPIFetcher.fetchUsage( cookieHeader: header, targetOrganizationID: self.webOrganizationID) { msg in Self.log.debug(msg) } } else { try await ClaudeWebAPIFetcher.fetchUsage( browserDetection: self.browserDetection, targetOrganizationID: self.webOrganizationID) { msg in Self.log.debug(msg) } } // Convert web API data to ClaudeUsageSnapshot format let primary = Self.webPrimaryWindow(from: webData) let secondary: RateWindow? = webData.weeklyPercentUsed.map { pct in RateWindow( usedPercent: pct, windowMinutes: 7 * 24 * 60, resetsAt: webData.weeklyResetsAt, resetDescription: webData.weeklyResetsAt.map { Self.formatResetDate($0) }) } let opus: RateWindow? = webData.opusPercentUsed.map { opusPct in RateWindow( usedPercent: opusPct, windowMinutes: 7 * 24 * 60, resetsAt: webData.weeklyResetsAt, resetDescription: webData.weeklyResetsAt.map { Self.formatResetDate($0) }) } return ClaudeUsageSnapshot( primary: primary, secondary: secondary, opus: opus, extraRateWindows: webData.extraRateWindows, providerCost: webData.extraUsageCost, updatedAt: Date(), accountEmail: webData.accountEmail, accountOrganization: webData.accountOrganization, loginMethod: webData.loginMethod, rawText: nil) } private static func formatResetDate(_ date: Date) -> String { let formatter = DateFormatter() formatter.dateFormat = "MMM d 'at' h:mma" formatter.locale = Locale(identifier: "en_US_POSIX") return formatter.string(from: date) } // MARK: - PTY-based probe (no tmux) private func loadViaPTY(model: String, timeout: TimeInterval = 10) async throws -> ClaudeUsageSnapshot { guard let claudeBinary = ClaudeCLIResolver.resolvedBinaryPath(environment: self.environment) else { throw ClaudeUsageError.claudeNotInstalled } let probe = ClaudeStatusProbe( claudeBinary: claudeBinary, timeout: timeout, keepCLISessionsAlive: self.keepCLISessionsAlive) let snap = try await probe.fetch() return try Self.makeSnapshot(from: snap) } private func loadViaDirectCLI(timeout: TimeInterval) async throws -> ClaudeUsageSnapshot { guard let claudeBinary = ClaudeCLIResolver.resolvedBinaryPath(environment: self.environment) else { throw ClaudeUsageError.claudeNotInstalled } let workingDirectory = ClaudeStatusProbe.preparedProbeWorkingDirectoryURL() var environment = ClaudeCLISession.launchEnvironment(baseEnv: self.environment) environment["PWD"] = workingDirectory.path defer { ClaudeProbeSessionArtifactCleaner.cleanupProbeSessionArtifacts( probeDirectory: workingDirectory, environment: environment) } let result = try await SubprocessRunner.run( binary: claudeBinary, arguments: ["/usage"], environment: environment, timeout: timeout, standardInput: FileHandle.nullDevice, currentDirectoryURL: workingDirectory, label: "claude-direct-usage") let snap = try ClaudeStatusProbe.parse(text: result.stdout) return try Self.makeSnapshot(from: snap) } private static func makeSnapshot( from snap: ClaudeStatusSnapshot, now: Date = .init()) throws -> ClaudeUsageSnapshot { guard let sessionPctLeft = snap.sessionPercentLeft else { throw ClaudeUsageError.parseFailed("missing session data") } func makeWindow(pctLeft: Int?, reset: String?, windowMinutes: Int) -> RateWindow? { guard let left = pctLeft else { return nil } let used = max(0, min(100, 100 - Double(left))) let resetClean = reset?.trimmingCharacters(in: .whitespacesAndNewlines) return RateWindow( usedPercent: used, windowMinutes: windowMinutes, resetsAt: ClaudeStatusProbe.parseResetDate( from: resetClean, now: now, expectedWindow: TimeInterval(windowMinutes * 60)), resetDescription: resetClean) } let primary = makeWindow( pctLeft: sessionPctLeft, reset: snap.primaryResetDescription, windowMinutes: Self.sessionWindowMinutes)! let weekly = makeWindow( pctLeft: snap.weeklyPercentLeft, reset: snap.secondaryResetDescription, windowMinutes: Self.weeklyWindowMinutes) let opus = makeWindow( pctLeft: snap.opusPercentLeft, reset: snap.opusResetDescription, windowMinutes: Self.weeklyWindowMinutes) return ClaudeUsageSnapshot( primary: primary, secondary: weekly, opus: opus, extraRateWindows: snap.extraRateWindows, providerCost: nil, updatedAt: now, accountEmail: snap.accountEmail, accountOrganization: snap.accountOrganization, loginMethod: snap.loginMethod, rawText: snap.rawText) } private func applyWebExtrasIfNeeded(to snapshot: ClaudeUsageSnapshot) async -> ClaudeUsageSnapshot { guard self.useWebExtras, self.dataSource != .web else { return snapshot } do { let webData: ClaudeWebAPIFetcher.WebUsageData = if let header = self.manualCookieHeader { try await ClaudeWebAPIFetcher.fetchUsage( cookieHeader: header, targetOrganizationID: self.webOrganizationID) { msg in Self.log.debug(msg) } } else { try await ClaudeWebAPIFetcher.fetchUsage( browserDetection: self.browserDetection, targetOrganizationID: self.webOrganizationID) { msg in Self.log.debug(msg) } } // Only merge usage/cost extras; keep identity fields from the primary data source. let mergedExtraRateWindows = Self.mergeExtraRateWindows( primary: snapshot.extraRateWindows, web: webData.extraRateWindows) let mergedProviderCost = snapshot.providerCost ?? webData.extraUsageCost if mergedProviderCost != snapshot.providerCost || mergedExtraRateWindows != snapshot.extraRateWindows { return ClaudeUsageSnapshot( primary: snapshot.primary, primaryWindowKind: snapshot.primaryWindowKind, secondary: snapshot.secondary, opus: snapshot.opus, extraRateWindows: mergedExtraRateWindows, providerCost: mergedProviderCost, updatedAt: snapshot.updatedAt, accountEmail: snapshot.accountEmail, accountOrganization: snapshot.accountOrganization, loginMethod: snapshot.loginMethod, rawText: snapshot.rawText, oauthKeychainPersistentRefHash: snapshot.oauthKeychainPersistentRefHash, oauthHistoryOwnerIdentifier: snapshot.oauthHistoryOwnerIdentifier, oauthKeychainCredentialMismatch: snapshot.oauthKeychainCredentialMismatch, oauthKeychainCredentialAbsent: snapshot.oauthKeychainCredentialAbsent, oauthKeychainCredentialUnavailable: snapshot.oauthKeychainCredentialUnavailable) } } catch { Self.log.debug("Claude web extras fetch failed: \(error.localizedDescription)") } return snapshot } private static func mergeExtraRateWindows( primary: [NamedRateWindow], web: [NamedRateWindow]) -> [NamedRateWindow] { guard !primary.isEmpty else { return web } guard !web.isEmpty else { return primary } let primaryScopedKeys = Set(primary.compactMap(self.scopedExtraRateWindowMergeKey)) var webScopedIDsByKey: [String: Set] = [:] for window in web { guard let scopedKey = self.scopedExtraRateWindowMergeKey(window) else { continue } webScopedIDsByKey[scopedKey, default: []].insert(window.id) } var seenIDs = Set(primary.map(\.id)) var merged = primary for window in web where seenIDs.insert(window.id).inserted { if let scopedKey = self.scopedExtraRateWindowMergeKey(window), primaryScopedKeys.contains(scopedKey), webScopedIDsByKey[scopedKey]?.count == 1 { continue } merged.append(window) } return merged } private static func scopedExtraRateWindowMergeKey(_ window: NamedRateWindow) -> String? { guard window.id.hasPrefix("claude-weekly-scoped-") else { return nil } let modelName = window.title.replacingOccurrences( of: #"\s+only\s*$"#, with: "", options: [.regularExpression, .caseInsensitive]) let normalizedName = String(modelName.lowercased().unicodeScalars.filter(CharacterSet.alphanumerics.contains)) guard !normalizedName.isEmpty else { return nil } return normalizedName } // MARK: - Process helpers private static func which(_ tool: String) -> String? { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/which") process.arguments = [tool] let pipe = Pipe() process.standardOutput = pipe try? process.run() process.waitUntilExit() guard process.terminationStatus == 0 else { return nil } let data = pipe.fileHandleForReading.readDataToEndOfFile() guard let path = String(data: data, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines), !path.isEmpty else { return nil } return path } private static func readString(cmd: String, args: [String]) -> String? { let task = Process() task.executableURL = URL(fileURLWithPath: cmd) task.arguments = args let pipe = Pipe() task.standardOutput = pipe try? task.run() task.waitUntilExit() guard task.terminationStatus == 0 else { return nil } let data = pipe.fileHandleForReading.readDataToEndOfFile() return String(data: data, encoding: .utf8) } private static func oauthCredentialProbeErrorLabel(_ error: Error) -> String { guard let oauthError = error as? ClaudeOAuthCredentialsError else { return String(describing: type(of: error)) } return switch oauthError { case .decodeFailed: "decodeFailed" case .missingOAuth: "missingOAuth" case .mcpOAuthOnlyKeychain: "mcpOAuthOnlyKeychain" case .missingAccessToken: "missingAccessToken" case .notFound: "notFound" case let .keychainError(status): "keychainError:\(status)" case .readFailed: "readFailed" case .refreshFailed: "refreshFailed" case .noRefreshToken: "noRefreshToken" case .refreshDelegatedToClaudeCLI: "refreshDelegatedToClaudeCLI" } } } #if DEBUG extension ClaudeUsageFetcher { public static func _mergeExtraRateWindowsForTesting( primary: [NamedRateWindow], web: [NamedRateWindow]) -> [NamedRateWindow] { self.mergeExtraRateWindows(primary: primary, web: web) } public static func _mapOAuthUsageForTesting( _ data: Data, rateLimitTier: String? = nil, subscriptionType: String? = nil) throws -> ClaudeUsageSnapshot { let usage = try ClaudeOAuthUsageFetcher.decodeUsageResponse(data) let creds = ClaudeOAuthCredentials( accessToken: "test", refreshToken: nil, expiresAt: Date().addingTimeInterval(3600), scopes: [], rateLimitTier: rateLimitTier, subscriptionType: subscriptionType) return try Self.mapOAuthUsage(usage, credentials: creds) } } #endif