943 lines · 42.6 KB · Swift
RawDownload
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.