import Foundation public struct ClaudeStatusSnapshot: Sendable { public let sessionPercentLeft: Int? public let weeklyPercentLeft: Int? public let opusPercentLeft: Int? public let accountEmail: String? public let accountOrganization: String? public let loginMethod: String? public let primaryResetDescription: String? public let secondaryResetDescription: String? public let opusResetDescription: String? public let rawText: String} public struct ClaudeAccountIdentity: Sendable { public let accountEmail: String? public let accountOrganization: String? public let loginMethod: String? public init(accountEmail: String?, accountOrganization: String?, loginMethod: String?) { self.accountEmail = accountEmail self.accountOrganization = accountOrganization self.loginMethod = loginMethod }} public enum ClaudeStatusProbeError: LocalizedError, Sendable { case claudeNotInstalled case parseFailed(String) case timedOut public var errorDescription: String? { switch self { case .claudeNotInstalled: "Claude CLI is not installed or not on PATH." case let .parseFailed(msg): "Could not parse Claude usage: \(msg)" case .timedOut: "Claude usage probe timed out." } }} /// Runs `claude` inside a PTY, sends `/usage`, and parses the rendered text panel.public struct ClaudeStatusProbe: Sendable { public var claudeBinary: String = "claude" public var timeout: TimeInterval = 20.0 public var keepCLISessionsAlive: Bool = false private static let log = CodexBarLog.logger(LogCategories.claudeProbe) #if DEBUG public typealias FetchOverride = @Sendable (String, TimeInterval, Bool) async throws -> ClaudeStatusSnapshot @TaskLocal static var fetchOverride: FetchOverride? #endif public init(claudeBinary: String = "claude", timeout: TimeInterval = 20.0, keepCLISessionsAlive: Bool = false) { self.claudeBinary = claudeBinary self.timeout = timeout self.keepCLISessionsAlive = keepCLISessionsAlive } #if DEBUG public static var currentFetchOverrideForTesting: FetchOverride? { self.fetchOverride } public static func withFetchOverrideForTesting<T>( _ override: FetchOverride?, operation: () async throws -> T) async rethrows -> T { try await self.$fetchOverride.withValue(override) { try await operation() } } public static func withFetchOverrideForTesting<T>( _ override: FetchOverride?, operation: () async -> T) async -> T { await self.$fetchOverride.withValue(override) { await operation() } } #endif public func fetch() async throws -> ClaudeStatusSnapshot { let resolved = Self.resolvedBinaryPath(binaryName: self.claudeBinary) guard let resolved, Self.isBinaryAvailable(resolved) else { throw ClaudeStatusProbeError.claudeNotInstalled } // Run commands sequentially through a shared Claude session to avoid warm-up churn. let timeout = self.timeout let keepAlive = self.keepCLISessionsAlive #if DEBUG if let override = Self.fetchOverride { return try await override(resolved, timeout, keepAlive) } #endif do { var usage = try await Self.capture(subcommand: "/usage", binary: resolved, timeout: timeout) if !Self.usageOutputLooksRelevant(usage) { Self.log.debug("Claude CLI /usage looked like startup output; retrying once") usage = try await Self.capture(subcommand: "/usage", binary: resolved, timeout: max(timeout, 14)) } let status = try? await Self.capture(subcommand: "/status", binary: resolved, timeout: min(timeout, 12)) let snap = try Self.parse(text: usage, statusText: status) Self.log.info("Claude CLI scrape ok", metadata: [ "sessionPercentLeft": "\(snap.sessionPercentLeft ?? -1)", "weeklyPercentLeft": "\(snap.weeklyPercentLeft ?? -1)", "opusPercentLeft": "\(snap.opusPercentLeft ?? -1)", ]) if !keepAlive { await Self.resetTransientCLISessionAndCleanupProbeArtifacts() } return snap } catch { if !keepAlive { await Self.resetTransientCLISessionAndCleanupProbeArtifacts() } throw error } } private static func resetTransientCLISessionAndCleanupProbeArtifacts() async { await ClaudeCLISession.current.reset() let removed = ClaudeProbeSessionArtifactCleaner.cleanupProbeSessionArtifacts() guard !removed.isEmpty else { return } Self.log.debug("Claude probe session artifacts removed", metadata: ["count": "\(removed.count)"]) } // MARK: - Parsing helpers private struct LabelSearchContext { let lines: [String] let normalizedLines: [String] let normalizedData: Data init(text: String) { self.lines = text.components(separatedBy: .newlines) self.normalizedLines = self.lines.map { ClaudeStatusProbe.normalizedForLabelSearch($0) } let normalized = ClaudeStatusProbe.normalizedForLabelSearch(text) self.normalizedData = Data(normalized.utf8) } func contains(_ needle: String) -> Bool { self.normalizedData.range(of: Data(needle.utf8)) != nil } } public static func parse(text: String, statusText: String? = nil) throws -> ClaudeStatusSnapshot { let clean = TextParsing.stripANSICodes(text) let statusClean = statusText.map(TextParsing.stripANSICodes) guard !clean.isEmpty else { throw ClaudeStatusProbeError.timedOut } let shouldDump = ProcessInfo.processInfo.environment["DEBUG_CLAUDE_DUMP"] == "1" if let usageError = self.extractUsageError(text: clean) { Self.dumpIfNeeded( enabled: shouldDump, reason: "usageError: \(usageError)", usage: clean, status: statusText) throw ClaudeStatusProbeError.parseFailed(usageError) } let latestUsagePanel = self.trimToLatestUsagePanel(clean) if self.isUsageStillLoading(text: latestUsagePanel ?? clean) { Self.dumpIfNeeded( enabled: shouldDump, reason: "usage still loading", usage: clean, status: statusText) throw ClaudeStatusProbeError.parseFailed("Claude CLI /usage is still loading usage data.") } // Claude CLI renders /usage as a TUI. Our PTY capture includes earlier screen fragments (including a status // line // with a "0%" context meter) before the usage panel is drawn. To keep parsing stable, trim to the last // Settings/Usage panel when present. let usagePanelText = latestUsagePanel ?? clean let labelContext = LabelSearchContext(text: usagePanelText) var sessionPct = self.extractPercent(labelSubstring: "Current session", context: labelContext) var weeklyPct = self.extractPercent(labelSubstring: "Current week (all models)", context: labelContext) var opusPct = self.extractPercent( labelSubstrings: [ "Current week (Opus)", "Current week (Sonnet only)", "Current week (Sonnet)", ], context: labelContext) // Fallback: order-based percent scraping when labels are present but the surrounding layout moved. // Only apply the fallback when the corresponding label exists in the rendered panel; enterprise accounts // may omit the weekly panel entirely, and we should treat that as "unavailable" rather than guessing. let compactContext = usagePanelText.lowercased().filter { !$0.isWhitespace } let hasWeeklyLabel = labelContext.contains("currentweek") || compactContext.contains("currentweek") let hasOpusLabel = labelContext.contains("opus") || labelContext.contains("sonnet") if sessionPct == nil || (hasWeeklyLabel && weeklyPct == nil) || (hasOpusLabel && opusPct == nil) { let ordered = self.allPercents(usagePanelText) if sessionPct == nil, ordered.indices.contains(0) { sessionPct = ordered[0] } if hasWeeklyLabel, weeklyPct == nil, ordered.indices.contains(1) { weeklyPct = ordered[1] } if hasOpusLabel, opusPct == nil, ordered.indices.contains(2) { opusPct = ordered[2] } } let identity = Self.parseIdentity(usageText: clean, statusText: statusClean) guard let sessionPct else { Self.dumpIfNeeded( enabled: shouldDump, reason: "missing session label", usage: clean, status: statusText) if shouldDump { let tail = usagePanelText.suffix(1800) let snippet = tail.isEmpty ? "(empty)" : String(tail) throw ClaudeStatusProbeError.parseFailed( "Missing Current session.\n\n--- Clean usage tail ---\n\(snippet)") } throw ClaudeStatusProbeError.parseFailed("Missing Current session.") } let sessionReset = self.extractReset(labelSubstring: "Current session", context: labelContext) let weeklyReset = hasWeeklyLabel ? self.extractReset(labelSubstring: "Current week (all models)", context: labelContext) : nil let opusReset = hasOpusLabel ? self.extractReset( labelSubstrings: [ "Current week (Opus)", "Current week (Sonnet only)", "Current week (Sonnet)", ], context: labelContext) : nil return ClaudeStatusSnapshot( sessionPercentLeft: sessionPct, weeklyPercentLeft: weeklyPct, opusPercentLeft: opusPct, accountEmail: identity.accountEmail, accountOrganization: identity.accountOrganization, loginMethod: identity.loginMethod, primaryResetDescription: sessionReset, secondaryResetDescription: weeklyReset, opusResetDescription: opusReset, rawText: text + (statusText ?? "")) } public static func parseIdentity(usageText: String?, statusText: String?) -> ClaudeAccountIdentity { let usageClean = usageText.map(TextParsing.stripANSICodes) ?? "" let statusClean = statusText.map(TextParsing.stripANSICodes) return self.extractIdentity(usageText: usageClean, statusText: statusClean) } public static func fetchIdentity( timeout: TimeInterval = 12.0, environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> ClaudeAccountIdentity { let resolved = self.resolvedBinaryPath(binaryName: "claude", environment: environment) guard let resolved, self.isBinaryAvailable(resolved) else { throw ClaudeStatusProbeError.claudeNotInstalled } let statusText = try await Self.capture(subcommand: "/status", binary: resolved, timeout: timeout) return Self.parseIdentity(usageText: nil, statusText: statusText) } public static func touchOAuthAuthPath( timeout: TimeInterval = 8, environment: [String: String] = ProcessInfo.processInfo.environment) async throws { let resolved = self.resolvedBinaryPath(binaryName: "claude", environment: environment) guard let resolved, self.isBinaryAvailable(resolved) else { throw ClaudeStatusProbeError.claudeNotInstalled } do { // Use a more robust capture configuration than the standard `/status` scrape: // - Avoid the short idle-timeout which can terminate the session while CLI auth checks are still running. // - We intentionally do not parse output here; success is "the command ran without timing out". _ = try await ClaudeCLISession.shared.capture( subcommand: "/status", binary: resolved, timeout: timeout, idleTimeout: nil, stopOnSubstrings: [], settleAfterStop: 0.8, sendEnterEvery: 0.8) await ClaudeCLISession.shared.reset() } catch { await ClaudeCLISession.shared.reset() throw error } } public static func isClaudeBinaryAvailable( environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool { let resolved = self.resolvedBinaryPath(binaryName: "claude", environment: environment) return self.isBinaryAvailable(resolved) } private static func extractPercent(labelSubstring: String, context: LabelSearchContext) -> Int? { let lines = context.lines let label = self.normalizedForLabelSearch(labelSubstring) for (idx, normalizedLine) in context.normalizedLines.enumerated() where normalizedLine.contains(label) { // Claude's usage panel can take a moment to render percentages (especially on enterprise accounts), // so scan a larger window than the original 3–4 lines. let window = lines.dropFirst(idx).prefix(12) for candidate in window { if let pct = self.percentFromLine(candidate) { return pct } } } return nil } private static func usageOutputLooksRelevant(_ text: String) -> Bool { let normalized = TextParsing.stripANSICodes(text).lowercased().filter { !$0.isWhitespace } return normalized.contains("currentsession") || normalized.contains("currentweek") || normalized.contains("loadingusage") || normalized.contains("failedtoloadusagedata") || self.usageCaptureHasSubscriptionNotice(normalized) } private static func extractPercent(labelSubstrings: [String], context: LabelSearchContext) -> Int? { for label in labelSubstrings { if let value = self.extractPercent(labelSubstring: label, context: context) { return value } } return nil } private static func percentFromLine(_ line: String, assumeRemainingWhenUnclear: Bool = false) -> Int? { if self.isLikelyStatusContextLine(line) { return nil } // Allow optional Unicode whitespace before % to handle CLI formatting changes. let pattern = #"([0-9]{1,3}(?:\.[0-9]+)?)\p{Zs}*%"# guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return nil } let range = NSRange(line.startIndex..<line.endIndex, in: line) guard let match = regex.firstMatch(in: line, options: [], range: range), match.numberOfRanges >= 2, let valRange = Range(match.range(at: 1), in: line) else { return nil } let rawVal = Double(line[valRange]) ?? 0 let clamped = max(0, min(100, rawVal)) let lower = line.lowercased() let usedKeywords = ["used", "spent", "consumed"] let remainingKeywords = ["left", "remaining", "available"] if usedKeywords.contains(where: lower.contains) { return Int(max(0, min(100, 100 - clamped)).rounded()) } if remainingKeywords.contains(where: lower.contains) { return Int(clamped.rounded()) } return assumeRemainingWhenUnclear ? Int(clamped.rounded()) : nil } private static func isLikelyStatusContextLine(_ line: String) -> Bool { guard line.contains("|") else { return false } let lower = line.lowercased() let modelTokens = ["opus", "sonnet", "haiku", "default"] return modelTokens.contains(where: lower.contains) } private static func extractFirst(pattern: String, text: String) -> String? { guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return nil } let range = NSRange(text.startIndex..<text.endIndex, in: text) guard let match = regex.firstMatch(in: text, options: [], range: range), match.numberOfRanges >= 2, let r = Range(match.range(at: 1), in: text) else { return nil } return String(text[r]).trimmingCharacters(in: .whitespacesAndNewlines) } private static func extractIdentity(usageText: String, statusText: String?) -> ClaudeAccountIdentity { let emailPatterns = [ #"(?i)Account:\s+([^\s@]+@[^\s@]+)"#, #"(?i)Email:\s+([^\s@]+@[^\s@]+)"#, ] let looseEmailPatterns = [ #"(?i)Account:\s+(\S+)"#, #"(?i)Email:\s+(\S+)"#, ] let email = emailPatterns .compactMap { self.extractFirst(pattern: $0, text: usageText) } .first ?? emailPatterns .compactMap { self.extractFirst(pattern: $0, text: statusText ?? "") } .first ?? looseEmailPatterns .compactMap { self.extractFirst(pattern: $0, text: usageText) } .first ?? looseEmailPatterns .compactMap { self.extractFirst(pattern: $0, text: statusText ?? "") } .first ?? self.extractFirst( pattern: #"(?i)[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}"#, text: usageText) ?? self.extractFirst( pattern: #"(?i)[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}"#, text: statusText ?? "") let orgPatterns = [ #"(?i)Org:\s*(.+)"#, #"(?i)Organization:\s*(.+)"#, ] let orgRaw = orgPatterns .compactMap { self.extractFirst(pattern: $0, text: usageText) } .first ?? orgPatterns .compactMap { self.extractFirst(pattern: $0, text: statusText ?? "") } .first let org: String? = { guard let orgText = orgRaw?.trimmingCharacters(in: .whitespacesAndNewlines), !orgText.isEmpty else { return nil } // Suppress org if it’s just the email prefix (common in CLI panels). if let email, orgText.lowercased().hasPrefix(email.lowercased()) { return nil } return orgText }() // Prefer explicit login method from /status, then fall back to /usage header heuristics. let login = self.extractLoginMethod(text: statusText ?? "") ?? self.extractLoginMethod(text: usageText) return ClaudeAccountIdentity(accountEmail: email, accountOrganization: org, loginMethod: login) } private static func extractUsageError(text: String) -> String? { if let jsonHint = self.extractUsageErrorJSON(text: text) { return jsonHint } let lower = text.lowercased() let compact = lower.filter { !$0.isWhitespace } if lower.contains("do you trust the files in this folder?"), !lower.contains("current session") { let folder = self.extractFirst( pattern: #"Do you trust the files in this folder\?\s*(?:\r?\n)+\s*([^\r\n]+)"#, text: text) let folderHint = folder.flatMap { value in let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } if let folderHint { return """ Claude CLI is waiting for a folder trust prompt (\(folderHint)). CodexBar tries to auto-accept this, \ but if it keeps appearing run: `cd "\(folderHint)" && claude` and choose “Yes, proceed”, then retry. """ } return """ Claude CLI is waiting for a folder trust prompt. CodexBar tries to auto-accept this, but if it keeps \ appearing open `claude` once, choose “Yes, proceed”, then retry. """ } if lower.contains("token_expired") || lower.contains("token has expired") { return "Claude CLI token expired. Run `claude login` to refresh." } if lower.contains("authentication_error") { return "Claude CLI authentication error. Run `claude login`." } if lower.contains("rate_limit_error") || lower.contains("rate limited") || compact.contains("ratelimited") { return "Claude CLI usage endpoint is rate limited right now. Please try again later." } if self.isSubscriptionNoticeOnly(text: text) { return "Claude CLI /usage returned a subscription notice without session quota data." } if lower.contains("failed to load usage data") { return "Claude CLI could not load usage data. Open the CLI and retry `/usage`." } if compact.contains("failedtoloadusagedata") { return "Claude CLI could not load usage data. Open the CLI and retry `/usage`." } return nil } private static func isUsageStillLoading(text: String) -> Bool { let normalized = TextParsing.stripANSICodes(text).lowercased().filter { !$0.isWhitespace } guard normalized.contains("loadingusage") else { return false } return !self.usageCaptureHasSessionValue(normalized) && self.allPercents(text).isEmpty } /// Returns true when the text contains only a subscription notice with no session/weekly quota data. /// CLI 2.1+ can return "You are currently using your subscription to power your Claude Code usage" /// which lacks the "Current session" / "Current week" labels and percentage values needed for quota display. /// A PTY capture may contain both an intermediate "Loading usage data…" panel and the final subscription /// notice; `loadingusage` is not treated as quota data in this check so mixed captures surface correctly. private static func isSubscriptionNoticeOnly(text: String) -> Bool { let normalized = text.lowercased().filter { !$0.isWhitespace } guard normalized.contains("currentlyusingyoursubscription") else { return false } guard normalized.contains("claudecodeusage") else { return false } // Only real session/week labels and actual percentage values count as quota data. // `loadingusage` is not quota data — a mixed loading+subscription PTY capture should // surface the subscription error, not a still-loading stall. let hasQuotaData = normalized.contains("currentsession") || normalized.contains("currentweek") || normalized.contains("%used") || normalized.contains("%left") || normalized.contains("%remaining") || normalized.contains("%available") return !hasQuotaData } /// Collect remaining percentages in the order they appear; used as a backup when labels move/rename.Lines 1–500 of 943. Load more
import Foundation public struct ClaudeStatusSnapshot: Sendable { public let sessionPercentLeft: Int? public let weeklyPercentLeft: Int? public let opusPercentLeft: Int? public let accountEmail: String? public let accountOrganization: String? public let loginMethod: String? public let primaryResetDescription: String? public let secondaryResetDescription: String? public let opusResetDescription: String? public let rawText: String } public struct ClaudeAccountIdentity: Sendable { public let accountEmail: String? public let accountOrganization: String? public let loginMethod: String? public init(accountEmail: String?, accountOrganization: String?, loginMethod: String?) { self.accountEmail = accountEmail self.accountOrganization = accountOrganization self.loginMethod = loginMethod } } public enum ClaudeStatusProbeError: LocalizedError, Sendable { case claudeNotInstalled case parseFailed(String) case timedOut public var errorDescription: String? { switch self { case .claudeNotInstalled: "Claude CLI is not installed or not on PATH." case let .parseFailed(msg): "Could not parse Claude usage: \(msg)" case .timedOut: "Claude usage probe timed out." } } } /// Runs `claude` inside a PTY, sends `/usage`, and parses the rendered text panel. public struct ClaudeStatusProbe: Sendable { public var claudeBinary: String = "claude" public var timeout: TimeInterval = 20.0 public var keepCLISessionsAlive: Bool = false private static let log = CodexBarLog.logger(LogCategories.claudeProbe) #if DEBUG public typealias FetchOverride = @Sendable (String, TimeInterval, Bool) async throws -> ClaudeStatusSnapshot @TaskLocal static var fetchOverride: FetchOverride? #endif public init(claudeBinary: String = "claude", timeout: TimeInterval = 20.0, keepCLISessionsAlive: Bool = false) { self.claudeBinary = claudeBinary self.timeout = timeout self.keepCLISessionsAlive = keepCLISessionsAlive } #if DEBUG public static var currentFetchOverrideForTesting: FetchOverride? { self.fetchOverride } public static func withFetchOverrideForTesting<T>( _ override: FetchOverride?, operation: () async throws -> T) async rethrows -> T { try await self.$fetchOverride.withValue(override) { try await operation() } } public static func withFetchOverrideForTesting<T>( _ override: FetchOverride?, operation: () async -> T) async -> T { await self.$fetchOverride.withValue(override) { await operation() } } #endif public func fetch() async throws -> ClaudeStatusSnapshot { let resolved = Self.resolvedBinaryPath(binaryName: self.claudeBinary) guard let resolved, Self.isBinaryAvailable(resolved) else { throw ClaudeStatusProbeError.claudeNotInstalled } // Run commands sequentially through a shared Claude session to avoid warm-up churn. let timeout = self.timeout let keepAlive = self.keepCLISessionsAlive #if DEBUG if let override = Self.fetchOverride { return try await override(resolved, timeout, keepAlive) } #endif do { var usage = try await Self.capture(subcommand: "/usage", binary: resolved, timeout: timeout) if !Self.usageOutputLooksRelevant(usage) { Self.log.debug("Claude CLI /usage looked like startup output; retrying once") usage = try await Self.capture(subcommand: "/usage", binary: resolved, timeout: max(timeout, 14)) } let status = try? await Self.capture(subcommand: "/status", binary: resolved, timeout: min(timeout, 12)) let snap = try Self.parse(text: usage, statusText: status) Self.log.info("Claude CLI scrape ok", metadata: [ "sessionPercentLeft": "\(snap.sessionPercentLeft ?? -1)", "weeklyPercentLeft": "\(snap.weeklyPercentLeft ?? -1)", "opusPercentLeft": "\(snap.opusPercentLeft ?? -1)", ]) if !keepAlive { await Self.resetTransientCLISessionAndCleanupProbeArtifacts() } return snap } catch { if !keepAlive { await Self.resetTransientCLISessionAndCleanupProbeArtifacts() } throw error } } private static func resetTransientCLISessionAndCleanupProbeArtifacts() async { await ClaudeCLISession.current.reset() let removed = ClaudeProbeSessionArtifactCleaner.cleanupProbeSessionArtifacts() guard !removed.isEmpty else { return } Self.log.debug("Claude probe session artifacts removed", metadata: ["count": "\(removed.count)"]) } // MARK: - Parsing helpers private struct LabelSearchContext { let lines: [String] let normalizedLines: [String] let normalizedData: Data init(text: String) { self.lines = text.components(separatedBy: .newlines) self.normalizedLines = self.lines.map { ClaudeStatusProbe.normalizedForLabelSearch($0) } let normalized = ClaudeStatusProbe.normalizedForLabelSearch(text) self.normalizedData = Data(normalized.utf8) } func contains(_ needle: String) -> Bool { self.normalizedData.range(of: Data(needle.utf8)) != nil } } public static func parse(text: String, statusText: String? = nil) throws -> ClaudeStatusSnapshot { let clean = TextParsing.stripANSICodes(text) let statusClean = statusText.map(TextParsing.stripANSICodes) guard !clean.isEmpty else { throw ClaudeStatusProbeError.timedOut } let shouldDump = ProcessInfo.processInfo.environment["DEBUG_CLAUDE_DUMP"] == "1" if let usageError = self.extractUsageError(text: clean) { Self.dumpIfNeeded( enabled: shouldDump, reason: "usageError: \(usageError)", usage: clean, status: statusText) throw ClaudeStatusProbeError.parseFailed(usageError) } let latestUsagePanel = self.trimToLatestUsagePanel(clean) if self.isUsageStillLoading(text: latestUsagePanel ?? clean) { Self.dumpIfNeeded( enabled: shouldDump, reason: "usage still loading", usage: clean, status: statusText) throw ClaudeStatusProbeError.parseFailed("Claude CLI /usage is still loading usage data.") } // Claude CLI renders /usage as a TUI. Our PTY capture includes earlier screen fragments (including a status // line // with a "0%" context meter) before the usage panel is drawn. To keep parsing stable, trim to the last // Settings/Usage panel when present. let usagePanelText = latestUsagePanel ?? clean let labelContext = LabelSearchContext(text: usagePanelText) var sessionPct = self.extractPercent(labelSubstring: "Current session", context: labelContext) var weeklyPct = self.extractPercent(labelSubstring: "Current week (all models)", context: labelContext) var opusPct = self.extractPercent( labelSubstrings: [ "Current week (Opus)", "Current week (Sonnet only)", "Current week (Sonnet)", ], context: labelContext) // Fallback: order-based percent scraping when labels are present but the surrounding layout moved. // Only apply the fallback when the corresponding label exists in the rendered panel; enterprise accounts // may omit the weekly panel entirely, and we should treat that as "unavailable" rather than guessing. let compactContext = usagePanelText.lowercased().filter { !$0.isWhitespace } let hasWeeklyLabel = labelContext.contains("currentweek") || compactContext.contains("currentweek") let hasOpusLabel = labelContext.contains("opus") || labelContext.contains("sonnet") if sessionPct == nil || (hasWeeklyLabel && weeklyPct == nil) || (hasOpusLabel && opusPct == nil) { let ordered = self.allPercents(usagePanelText) if sessionPct == nil, ordered.indices.contains(0) { sessionPct = ordered[0] } if hasWeeklyLabel, weeklyPct == nil, ordered.indices.contains(1) { weeklyPct = ordered[1] } if hasOpusLabel, opusPct == nil, ordered.indices.contains(2) { opusPct = ordered[2] } } let identity = Self.parseIdentity(usageText: clean, statusText: statusClean) guard let sessionPct else { Self.dumpIfNeeded( enabled: shouldDump, reason: "missing session label", usage: clean, status: statusText) if shouldDump { let tail = usagePanelText.suffix(1800) let snippet = tail.isEmpty ? "(empty)" : String(tail) throw ClaudeStatusProbeError.parseFailed( "Missing Current session.\n\n--- Clean usage tail ---\n\(snippet)") } throw ClaudeStatusProbeError.parseFailed("Missing Current session.") } let sessionReset = self.extractReset(labelSubstring: "Current session", context: labelContext) let weeklyReset = hasWeeklyLabel ? self.extractReset(labelSubstring: "Current week (all models)", context: labelContext) : nil let opusReset = hasOpusLabel ? self.extractReset( labelSubstrings: [ "Current week (Opus)", "Current week (Sonnet only)", "Current week (Sonnet)", ], context: labelContext) : nil return ClaudeStatusSnapshot( sessionPercentLeft: sessionPct, weeklyPercentLeft: weeklyPct, opusPercentLeft: opusPct, accountEmail: identity.accountEmail, accountOrganization: identity.accountOrganization, loginMethod: identity.loginMethod, primaryResetDescription: sessionReset, secondaryResetDescription: weeklyReset, opusResetDescription: opusReset, rawText: text + (statusText ?? "")) } public static func parseIdentity(usageText: String?, statusText: String?) -> ClaudeAccountIdentity { let usageClean = usageText.map(TextParsing.stripANSICodes) ?? "" let statusClean = statusText.map(TextParsing.stripANSICodes) return self.extractIdentity(usageText: usageClean, statusText: statusClean) } public static func fetchIdentity( timeout: TimeInterval = 12.0, environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> ClaudeAccountIdentity { let resolved = self.resolvedBinaryPath(binaryName: "claude", environment: environment) guard let resolved, self.isBinaryAvailable(resolved) else { throw ClaudeStatusProbeError.claudeNotInstalled } let statusText = try await Self.capture(subcommand: "/status", binary: resolved, timeout: timeout) return Self.parseIdentity(usageText: nil, statusText: statusText) } public static func touchOAuthAuthPath( timeout: TimeInterval = 8, environment: [String: String] = ProcessInfo.processInfo.environment) async throws { let resolved = self.resolvedBinaryPath(binaryName: "claude", environment: environment) guard let resolved, self.isBinaryAvailable(resolved) else { throw ClaudeStatusProbeError.claudeNotInstalled } do { // Use a more robust capture configuration than the standard `/status` scrape: // - Avoid the short idle-timeout which can terminate the session while CLI auth checks are still running. // - We intentionally do not parse output here; success is "the command ran without timing out". _ = try await ClaudeCLISession.shared.capture( subcommand: "/status", binary: resolved, timeout: timeout, idleTimeout: nil, stopOnSubstrings: [], settleAfterStop: 0.8, sendEnterEvery: 0.8) await ClaudeCLISession.shared.reset() } catch { await ClaudeCLISession.shared.reset() throw error } } public static func isClaudeBinaryAvailable( environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool { let resolved = self.resolvedBinaryPath(binaryName: "claude", environment: environment) return self.isBinaryAvailable(resolved) } private static func extractPercent(labelSubstring: String, context: LabelSearchContext) -> Int? { let lines = context.lines let label = self.normalizedForLabelSearch(labelSubstring) for (idx, normalizedLine) in context.normalizedLines.enumerated() where normalizedLine.contains(label) { // Claude's usage panel can take a moment to render percentages (especially on enterprise accounts), // so scan a larger window than the original 3–4 lines. let window = lines.dropFirst(idx).prefix(12) for candidate in window { if let pct = self.percentFromLine(candidate) { return pct } } } return nil } private static func usageOutputLooksRelevant(_ text: String) -> Bool { let normalized = TextParsing.stripANSICodes(text).lowercased().filter { !$0.isWhitespace } return normalized.contains("currentsession") || normalized.contains("currentweek") || normalized.contains("loadingusage") || normalized.contains("failedtoloadusagedata") || self.usageCaptureHasSubscriptionNotice(normalized) } private static func extractPercent(labelSubstrings: [String], context: LabelSearchContext) -> Int? { for label in labelSubstrings { if let value = self.extractPercent(labelSubstring: label, context: context) { return value } } return nil } private static func percentFromLine(_ line: String, assumeRemainingWhenUnclear: Bool = false) -> Int? { if self.isLikelyStatusContextLine(line) { return nil } // Allow optional Unicode whitespace before % to handle CLI formatting changes. let pattern = #"([0-9]{1,3}(?:\.[0-9]+)?)\p{Zs}*%"# guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return nil } let range = NSRange(line.startIndex..<line.endIndex, in: line) guard let match = regex.firstMatch(in: line, options: [], range: range), match.numberOfRanges >= 2, let valRange = Range(match.range(at: 1), in: line) else { return nil } let rawVal = Double(line[valRange]) ?? 0 let clamped = max(0, min(100, rawVal)) let lower = line.lowercased() let usedKeywords = ["used", "spent", "consumed"] let remainingKeywords = ["left", "remaining", "available"] if usedKeywords.contains(where: lower.contains) { return Int(max(0, min(100, 100 - clamped)).rounded()) } if remainingKeywords.contains(where: lower.contains) { return Int(clamped.rounded()) } return assumeRemainingWhenUnclear ? Int(clamped.rounded()) : nil } private static func isLikelyStatusContextLine(_ line: String) -> Bool { guard line.contains("|") else { return false } let lower = line.lowercased() let modelTokens = ["opus", "sonnet", "haiku", "default"] return modelTokens.contains(where: lower.contains) } private static func extractFirst(pattern: String, text: String) -> String? { guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return nil } let range = NSRange(text.startIndex..<text.endIndex, in: text) guard let match = regex.firstMatch(in: text, options: [], range: range), match.numberOfRanges >= 2, let r = Range(match.range(at: 1), in: text) else { return nil } return String(text[r]).trimmingCharacters(in: .whitespacesAndNewlines) } private static func extractIdentity(usageText: String, statusText: String?) -> ClaudeAccountIdentity { let emailPatterns = [ #"(?i)Account:\s+([^\s@]+@[^\s@]+)"#, #"(?i)Email:\s+([^\s@]+@[^\s@]+)"#, ] let looseEmailPatterns = [ #"(?i)Account:\s+(\S+)"#, #"(?i)Email:\s+(\S+)"#, ] let email = emailPatterns .compactMap { self.extractFirst(pattern: $0, text: usageText) } .first ?? emailPatterns .compactMap { self.extractFirst(pattern: $0, text: statusText ?? "") } .first ?? looseEmailPatterns .compactMap { self.extractFirst(pattern: $0, text: usageText) } .first ?? looseEmailPatterns .compactMap { self.extractFirst(pattern: $0, text: statusText ?? "") } .first ?? self.extractFirst( pattern: #"(?i)[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}"#, text: usageText) ?? self.extractFirst( pattern: #"(?i)[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}"#, text: statusText ?? "") let orgPatterns = [ #"(?i)Org:\s*(.+)"#, #"(?i)Organization:\s*(.+)"#, ] let orgRaw = orgPatterns .compactMap { self.extractFirst(pattern: $0, text: usageText) } .first ?? orgPatterns .compactMap { self.extractFirst(pattern: $0, text: statusText ?? "") } .first let org: String? = { guard let orgText = orgRaw?.trimmingCharacters(in: .whitespacesAndNewlines), !orgText.isEmpty else { return nil } // Suppress org if it’s just the email prefix (common in CLI panels). if let email, orgText.lowercased().hasPrefix(email.lowercased()) { return nil } return orgText }() // Prefer explicit login method from /status, then fall back to /usage header heuristics. let login = self.extractLoginMethod(text: statusText ?? "") ?? self.extractLoginMethod(text: usageText) return ClaudeAccountIdentity(accountEmail: email, accountOrganization: org, loginMethod: login) } private static func extractUsageError(text: String) -> String? { if let jsonHint = self.extractUsageErrorJSON(text: text) { return jsonHint } let lower = text.lowercased() let compact = lower.filter { !$0.isWhitespace } if lower.contains("do you trust the files in this folder?"), !lower.contains("current session") { let folder = self.extractFirst( pattern: #"Do you trust the files in this folder\?\s*(?:\r?\n)+\s*([^\r\n]+)"#, text: text) let folderHint = folder.flatMap { value in let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } if let folderHint { return """ Claude CLI is waiting for a folder trust prompt (\(folderHint)). CodexBar tries to auto-accept this, \ but if it keeps appearing run: `cd "\(folderHint)" && claude` and choose “Yes, proceed”, then retry. """ } return """ Claude CLI is waiting for a folder trust prompt. CodexBar tries to auto-accept this, but if it keeps \ appearing open `claude` once, choose “Yes, proceed”, then retry. """ } if lower.contains("token_expired") || lower.contains("token has expired") { return "Claude CLI token expired. Run `claude login` to refresh." } if lower.contains("authentication_error") { return "Claude CLI authentication error. Run `claude login`." } if lower.contains("rate_limit_error") || lower.contains("rate limited") || compact.contains("ratelimited") { return "Claude CLI usage endpoint is rate limited right now. Please try again later." } if self.isSubscriptionNoticeOnly(text: text) { return "Claude CLI /usage returned a subscription notice without session quota data." } if lower.contains("failed to load usage data") { return "Claude CLI could not load usage data. Open the CLI and retry `/usage`." } if compact.contains("failedtoloadusagedata") { return "Claude CLI could not load usage data. Open the CLI and retry `/usage`." } return nil } private static func isUsageStillLoading(text: String) -> Bool { let normalized = TextParsing.stripANSICodes(text).lowercased().filter { !$0.isWhitespace } guard normalized.contains("loadingusage") else { return false } return !self.usageCaptureHasSessionValue(normalized) && self.allPercents(text).isEmpty } /// Returns true when the text contains only a subscription notice with no session/weekly quota data. /// CLI 2.1+ can return "You are currently using your subscription to power your Claude Code usage" /// which lacks the "Current session" / "Current week" labels and percentage values needed for quota display. /// A PTY capture may contain both an intermediate "Loading usage data…" panel and the final subscription /// notice; `loadingusage` is not treated as quota data in this check so mixed captures surface correctly. private static func isSubscriptionNoticeOnly(text: String) -> Bool { let normalized = text.lowercased().filter { !$0.isWhitespace } guard normalized.contains("currentlyusingyoursubscription") else { return false } guard normalized.contains("claudecodeusage") else { return false } // Only real session/week labels and actual percentage values count as quota data. // `loadingusage` is not quota data — a mixed loading+subscription PTY capture should // surface the subscription error, not a still-loading stall. let hasQuotaData = normalized.contains("currentsession") || normalized.contains("currentweek") || normalized.contains("%used") || normalized.contains("%left") || normalized.contains("%remaining") || normalized.contains("%available") return !hasQuotaData } /// Collect remaining percentages in the order they appear; used as a backup when labels move/rename.