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( _ override: FetchOverride?, operation: () async throws -> T) async rethrows -> T { try await self.$fetchOverride.withValue(override) { try await operation() } } public static func withFetchOverrideForTesting( _ 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..= 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..= 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. private static func allPercents(_ text: String) -> [Int] { let lines = text.components(separatedBy: .newlines) let normalized = text.lowercased().filter { !$0.isWhitespace } let hasUsageWindows = normalized.contains("currentsession") || normalized.contains("currentweek") let hasLoading = normalized.contains("loadingusage") let hasUsagePercentKeywords = normalized.contains("used") || normalized.contains("left") || normalized.contains("remaining") || normalized.contains("available") let loadingOnly = hasLoading && !hasUsageWindows guard hasUsageWindows || hasLoading else { return [] } if loadingOnly { return [] } guard hasUsagePercentKeywords else { return [] } // Keep this strict to avoid matching Claude's status-line context meter (e.g. "0%") as session usage when the // /usage panel is still rendering. return lines.compactMap { self.percentFromLine($0, assumeRemainingWhenUnclear: false) } } /// Attempts to isolate the most recent /usage panel output from a PTY capture. /// The Claude TUI draws a "Settings: … Usage …" header; we slice from its last occurrence to avoid earlier screen /// fragments (like the status bar) contaminating percent scraping. private static func trimToLatestUsagePanel(_ text: String) -> String? { guard let settingsRange = text.range(of: "Settings:", options: [.caseInsensitive, .backwards]) else { return nil } let tail = text[settingsRange.lowerBound...] guard tail.range(of: "Usage", options: .caseInsensitive) != nil else { return nil } let lower = tail.lowercased() let hasPercent = lower.contains("%") let hasUsageWords = lower.contains("used") || lower.contains("left") || lower.contains("remaining") || lower.contains("available") let hasLoading = lower.contains("loading usage") guard (hasPercent && hasUsageWords) || hasLoading else { return nil } return String(tail) } private static func extractReset(labelSubstring: String, context: LabelSearchContext) -> String? { let lines = context.lines let label = self.normalizedForLabelSearch(labelSubstring) for (idx, normalizedLine) in context.normalizedLines.enumerated() where normalizedLine.contains(label) { let window = lines.dropFirst(idx).prefix(14) for candidate in window { let trimmed = candidate.trimmingCharacters(in: .whitespacesAndNewlines) let normalized = self.normalizedForLabelSearch(trimmed) if normalized.hasPrefix("current"), !normalized.contains(label) { break } if let reset = self.resetFromLine(candidate) { return reset } } } return nil } private static func extractReset(labelSubstrings: [String], context: LabelSearchContext) -> String? { for label in labelSubstrings { if let value = self.extractReset(labelSubstring: label, context: context) { return value } } return nil } private static func resetFromLine(_ line: String) -> String? { guard let range = line.range(of: "Resets", options: [.caseInsensitive]) else { return nil } let raw = String(line[range.lowerBound...]).trimmingCharacters(in: .whitespacesAndNewlines) return self.cleanResetLine(raw) } private static func normalizedForLabelSearch(_ text: String) -> String { String(text.lowercased().unicodeScalars.filter(CharacterSet.alphanumerics.contains)) } /// Capture all "Resets ..." strings to surface in the menu. private static func allResets(_ text: String) -> [String] { let pat = #"Resets[^\r\n]*"# guard let regex = try? NSRegularExpression(pattern: pat, options: [.caseInsensitive]) else { return [] } let nsrange = NSRange(text.startIndex.. String { // TTY capture sometimes appends a stray ")" at line ends; trim it to keep snapshots stable. var cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines) cleaned = cleaned.trimmingCharacters(in: CharacterSet(charactersIn: " )")) let openCount = cleaned.count(where: { $0 == "(" }) let closeCount = cleaned.count(where: { $0 == ")" }) if openCount > closeCount { cleaned.append(")") } return cleaned } /// Attempts to parse a Claude reset string into a Date, using the current year and handling optional timezones. public static func parseResetDate(from text: String?, now: Date = .init()) -> Date? { guard let normalized = self.normalizeResetInput(text) else { return nil } let (raw, timeZone) = normalized let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = timeZone ?? TimeZone.current formatter.defaultDate = now var calendar = Calendar(identifier: .gregorian) calendar.timeZone = formatter.timeZone if let date = self.parseDate(raw, formats: Self.resetDateTimeWithMinutes, formatter: formatter) { var comps = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date) comps.second = 0 return calendar.date(from: comps) } if let date = self.parseDate(raw, formats: Self.resetDateTimeHourOnly, formatter: formatter) { var comps = calendar.dateComponents([.year, .month, .day, .hour], from: date) comps.minute = 0 comps.second = 0 return calendar.date(from: comps) } if let time = self.parseDate(raw, formats: Self.resetTimeWithMinutes, formatter: formatter) { let comps = calendar.dateComponents([.hour, .minute], from: time) guard let anchored = calendar.date( bySettingHour: comps.hour ?? 0, minute: comps.minute ?? 0, second: 0, of: now) else { return nil } if anchored >= now { return anchored } return calendar.date(byAdding: .day, value: 1, to: anchored) } guard let time = self.parseDate(raw, formats: Self.resetTimeHourOnly, formatter: formatter) else { return nil } let comps = calendar.dateComponents([.hour], from: time) guard let anchored = calendar.date( bySettingHour: comps.hour ?? 0, minute: 0, second: 0, of: now) else { return nil } if anchored >= now { return anchored } return calendar.date(byAdding: .day, value: 1, to: anchored) } private static let resetTimeWithMinutes = ["h:mma", "h:mm a", "HH:mm", "H:mm"] private static let resetTimeHourOnly = ["ha", "h a"] private static let resetDateTimeWithMinutes = [ "MMM d, h:mma", "MMM d, h:mm a", "MMM d h:mma", "MMM d h:mm a", "MMM d, HH:mm", "MMM d HH:mm", ] private static let resetDateTimeHourOnly = [ "MMM d, ha", "MMM d, h a", "MMM d ha", "MMM d h a", ] private static func normalizeResetInput(_ text: String?) -> (String, TimeZone?)? { guard var raw = text?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } raw = raw.replacingOccurrences(of: #"(?i)^resets?:?\s*"#, with: "", options: .regularExpression) raw = raw.replacingOccurrences(of: " at ", with: " ", options: .caseInsensitive) raw = raw.replacingOccurrences(of: #"(?i)\b([A-Za-z]{3})(\d)"#, with: "$1 $2", options: .regularExpression) raw = raw.replacingOccurrences(of: #",(\d)"#, with: ", $1", options: .regularExpression) raw = raw.replacingOccurrences(of: #"(?i)(\d)at(?=\d)"#, with: "$1 ", options: .regularExpression) raw = raw.replacingOccurrences( of: #"(?<=\d)\.(\d{2})\b"#, with: ":$1", options: .regularExpression) let timeZone = self.extractTimeZone(from: &raw) raw = raw.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) .trimmingCharacters(in: .whitespacesAndNewlines) return raw.isEmpty ? nil : (raw, timeZone) } private static func extractTimeZone(from text: inout String) -> TimeZone? { guard let tzRange = text.range(of: #"\(([^)]+)\)"#, options: .regularExpression) else { return nil } let tzID = String(text[tzRange]).trimmingCharacters(in: CharacterSet(charactersIn: "() ")) text.removeSubrange(tzRange) text = text.trimmingCharacters(in: .whitespacesAndNewlines) return TimeZone(identifier: tzID) } private static func parseDate(_ text: String, formats: [String], formatter: DateFormatter) -> Date? { for pattern in formats { formatter.dateFormat = pattern if let date = formatter.date(from: text) { return date } } return nil } /// Extract login/plan string from CLI output. private static func extractLoginMethod(text: String) -> String? { guard !text.isEmpty else { return nil } if let explicit = self.extractFirst(pattern: #"(?i)login\s+method:\s*(.+)"#, text: text) { return ClaudePlan.cliCompatibilityLoginMethod(self.cleanPlan(explicit)) } // Capture any "Claude <...>" phrase (e.g., Max/Pro/Ultra/Team) to avoid future plan-name churn. // Strip any leading ANSI that may have survived (rare) before matching. // Use horizontal whitespace ([ \t]) rather than \s so the match stays on a single rendered line: // \s spans newlines, which let "…use Claude" bridge into the /usage panel's "d → today" hint and // produced a bogus "Dtoday" plan label after ANSI stripping glued the lines together. let planPattern = #"(?i)(claude[ \t]+[a-z0-9][a-z0-9 \t._-]{0,24})"# var candidates: [String] = [] if let regex = try? NSRegularExpression(pattern: planPattern, options: []) { let nsrange = NSRange(text.startIndex..= 2, let r = Range(match.range(at: 1), in: text) else { return } let raw = String(text[r]) let val = ClaudePlan.cliCompatibilityLoginMethod(Self.cleanPlan(raw)) ?? Self.cleanPlan(raw) candidates.append(val) } } if let plan = candidates.first(where: { cand in let lower = cand.lowercased() return !lower.contains("code v") && !lower.contains("code version") && !lower.contains("code") }) { return plan } return nil } /// Strips ANSI and stray bracketed codes like "[22m" that can survive CLI output. private static func cleanPlan(_ text: String) -> String { UsageFormatter.cleanPlanName(text) } private static func dumpIfNeeded(enabled: Bool, reason: String, usage: String, status: String?) { guard enabled else { return } let stamp = ISO8601DateFormatter().string(from: Date()) var parts = [ "=== Claude parse dump @ \(stamp) ===", "Reason: \(reason)", "", "--- usage (clean) ---", usage, "", ] if let status { parts.append(contentsOf: [ "--- status (raw/optional) ---", status, "", ]) } let body = parts.joined(separator: "\n") Task { @MainActor in self.recordDump(body) } } // MARK: - Dump storage (in-memory ring buffer) @MainActor private static var recentDumps: [String] = [] @MainActor private static func recordDump(_ text: String) { if self.recentDumps.count >= 5 { self.recentDumps.removeFirst() } self.recentDumps.append(text) } public static func latestDumps() async -> String { await MainActor.run { let result = Self.recentDumps.joined(separator: "\n\n---\n\n") return result.isEmpty ? "No Claude parse dumps captured yet." : result } } #if DEBUG public static func _replaceDumpsForTesting(_ dumps: [String]) async { await MainActor.run { self.recentDumps = dumps } } #endif private static func extractUsageErrorJSON(text: String) -> String? { let pattern = #"Failed\s*to\s*load\s*usage\s*data:\s*(\{.*\})"# guard let regex = try? NSRegularExpression(pattern: pattern, options: [.dotMatchesLineSeparators]) else { return nil } let range = NSRange(text.startIndex..= 2, let jsonRange = Range(match.range(at: 1), in: text) else { return nil } let jsonString = String(text[jsonRange]) let compactJSON = jsonString.replacingOccurrences(of: "\r", with: "").replacingOccurrences(of: "\n", with: "") let data = (compactJSON.isEmpty ? jsonString : compactJSON).data(using: .utf8) guard let data, let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let error = payload["error"] as? [String: Any] else { return nil } let message = (error["message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let details = error["details"] as? [String: Any] let code = (details?["error_code"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let type = (error["type"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if type == "rate_limit_error" { return "Claude CLI usage endpoint is rate limited right now. Please try again later." } var parts: [String] = [] if let message, !message.isEmpty { parts.append(message) } if let code, !code.isEmpty { parts.append("(\(code))") } guard !parts.isEmpty else { return nil } let hint = parts.joined(separator: " ") if let code, code.lowercased().contains("token") { return "\(hint). Run `claude login` to refresh." } return "Claude CLI error: \(hint)" } // MARK: - Process helpers private static func resolvedBinaryPath( binaryName: String, environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { if binaryName.contains("/") { return binaryName } return ClaudeCLIResolver.resolvedBinaryPath(environment: environment) } private static func isBinaryAvailable(_ binaryPathOrName: String?) -> Bool { guard let binaryPathOrName else { return false } return FileManager.default.isExecutableFile(atPath: binaryPathOrName) || TTYCommandRunner.which(binaryPathOrName) != nil } static func probeWorkingDirectoryURL() -> URL { let fm = FileManager.default let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? fm.temporaryDirectory let dir = base .appendingPathComponent("CodexBar", isDirectory: true) .appendingPathComponent("ClaudeProbe", isDirectory: true) do { try fm.createDirectory(at: dir, withIntermediateDirectories: true) return dir } catch { return fm.temporaryDirectory } } static func preparedProbeWorkingDirectoryURL() -> URL { let directory = self.probeWorkingDirectoryURL() do { try self.prepareProbeWorkingDirectory(at: directory) } catch { Self.log.warning( "Claude probe local settings unavailable", metadata: ["error": error.localizedDescription]) } return directory } static func prepareProbeWorkingDirectory(at directory: URL, fileManager fm: FileManager = .default) throws { try fm.createDirectory(at: directory, withIntermediateDirectories: true) let claudeDirectory = directory.appendingPathComponent(".claude", isDirectory: true) try fm.createDirectory(at: claudeDirectory, withIntermediateDirectories: true) let settingsURL = claudeDirectory.appendingPathComponent("settings.local.json") var settings = (try? self.readSettingsObject(from: settingsURL, fileManager: fm)) ?? [:] settings["disableDeepLinkRegistration"] = "disable" let data = try JSONSerialization.data( withJSONObject: settings, options: [.prettyPrinted, .sortedKeys]) try data.write(to: settingsURL, options: .atomic) } private static func readSettingsObject(from url: URL, fileManager fm: FileManager) throws -> [String: Any] { guard fm.fileExists(atPath: url.path) else { return [:] } let data = try Data(contentsOf: url) guard !data.isEmpty else { return [:] } let object = try JSONSerialization.jsonObject(with: data) return object as? [String: Any] ?? [:] } /// Run claude CLI inside a PTY so we can respond to interactive permission prompts. private static func capture(subcommand: String, binary: String, timeout: TimeInterval) async throws -> String { let stopOnSubstrings = subcommand == "/usage" ? [ "Failed to load usage data", "failed to load usage data", "Failedto loadusagedata", "failedtoloadusagedata", ] : [] let idleTimeout: TimeInterval? = subcommand == "/usage" ? nil : 3.0 let sendEnterEvery: TimeInterval? = subcommand == "/usage" ? 0.8 : nil let stopWhenNormalized: (@Sendable (String) -> Bool)? = subcommand == "/usage" ? { @Sendable normalizedScan in Self.usageCaptureHasSessionValue(normalizedScan) || Self.usageCaptureHasSubscriptionNotice(normalizedScan) } : nil do { return try await ClaudeCLISession.current.capture( subcommand: subcommand, binary: binary, timeout: timeout, idleTimeout: idleTimeout, stopOnSubstrings: stopOnSubstrings, stopWhenNormalized: stopWhenNormalized, settleAfterStop: subcommand == "/usage" ? 2.0 : 0.25, sendEnterEvery: sendEnterEvery) } catch ClaudeCLISession.SessionError.processExited { await ClaudeCLISession.current.reset() throw ClaudeStatusProbeError.timedOut } catch ClaudeCLISession.SessionError.timedOut { await ClaudeCLISession.current.reset() throw ClaudeStatusProbeError.timedOut } catch ClaudeCLISession.SessionError.launchFailed(_) { throw ClaudeStatusProbeError.claudeNotInstalled } catch { await ClaudeCLISession.current.reset() throw error } } private static func usageCaptureHasSessionValue(_ normalizedText: String) -> Bool { guard let labelRange = normalizedText.range(of: "currentsession") else { return false } let tail = normalizedText[labelRange.upperBound...] return tail.range(of: #"[0-9]{1,3}(?:\.[0-9]+)?%"#, options: .regularExpression) != nil } private static func usageCaptureHasSubscriptionNotice(_ normalizedText: String) -> Bool { normalizedText.contains("currentlyusingyoursubscription") && normalizedText.contains("claudecodeusage") } }