this repo has no description
at main 534 lines 23 kB view raw
1import Foundation 2import os.log 3 4public struct ClaudeStatusSnapshot: Sendable { 5 public let sessionPercentLeft: Int? 6 public let weeklyPercentLeft: Int? 7 public let opusPercentLeft: Int? 8 public let accountEmail: String? 9 public let accountOrganization: String? 10 public let loginMethod: String? 11 public let primaryResetDescription: String? 12 public let secondaryResetDescription: String? 13 public let opusResetDescription: String? 14 public let rawText: String 15} 16 17public enum ClaudeStatusProbeError: LocalizedError, Sendable { 18 case claudeNotInstalled 19 case parseFailed(String) 20 case timedOut 21 22 public var errorDescription: String? { 23 switch self { 24 case .claudeNotInstalled: 25 "Claude CLI is not installed or not on PATH." 26 case let .parseFailed(msg): 27 "Could not parse Claude usage: \(msg)" 28 case .timedOut: 29 "Claude usage probe timed out." 30 } 31 } 32} 33 34/// Runs `claude` inside a PTY, sends `/usage`, and parses the rendered text panel. 35public struct ClaudeStatusProbe: Sendable { 36 public var claudeBinary: String = "claude" 37 public var timeout: TimeInterval = 20.0 38 39 public init(claudeBinary: String = "claude", timeout: TimeInterval = 20.0) { 40 self.claudeBinary = claudeBinary 41 self.timeout = timeout 42 } 43 44 public func fetch() async throws -> ClaudeStatusSnapshot { 45 let env = ProcessInfo.processInfo.environment 46 let resolved = BinaryLocator.resolveClaudeBinary(env: env, loginPATH: LoginShellPathCache.shared.current) 47 ?? TTYCommandRunner.which(self.claudeBinary) 48 ?? self.claudeBinary 49 guard FileManager.default.isExecutableFile(atPath: resolved) || TTYCommandRunner.which(resolved) != nil else { 50 throw ClaudeStatusProbeError.claudeNotInstalled 51 } 52 53 // Run both commands in parallel; /usage provides quotas, /status may provide org/account metadata. 54 let timeout = self.timeout 55 async let usageText = Self.capture(subcommand: "/usage", binary: resolved, timeout: timeout) 56 async let statusText = Self.capture(subcommand: "/status", binary: resolved, timeout: timeout) 57 58 let usage = try await usageText 59 let status = try? await statusText 60 let snap = try Self.parse(text: usage, statusText: status) 61 62 if #available(macOS 13.0, *) { 63 os_log( 64 "[ClaudeStatusProbe] CLI scrape ok — session %d%% left, week %d%% left, opus %d%% left", 65 log: .default, 66 type: .info, 67 snap.sessionPercentLeft ?? -1, 68 snap.weeklyPercentLeft ?? -1, 69 snap.opusPercentLeft ?? -1) 70 } 71 return snap 72 } 73 74 // MARK: - Parsing helpers 75 76 public static func parse(text: String, statusText: String? = nil) throws -> ClaudeStatusSnapshot { 77 let clean = TextParsing.stripANSICodes(text) 78 let statusClean = statusText.map(TextParsing.stripANSICodes) 79 guard !clean.isEmpty else { throw ClaudeStatusProbeError.timedOut } 80 81 let shouldDump = ProcessInfo.processInfo.environment["DEBUG_CLAUDE_DUMP"] == "1" 82 83 if let usageError = self.extractUsageError(text: clean) { 84 Self.dumpIfNeeded( 85 enabled: shouldDump, 86 reason: "usageError: \(usageError)", 87 usage: clean, 88 status: statusText) 89 throw ClaudeStatusProbeError.parseFailed(usageError) 90 } 91 92 var sessionPct = self.extractPercent(labelSubstring: "Current session", text: clean) 93 var weeklyPct = self.extractPercent(labelSubstring: "Current week (all models)", text: clean) 94 var opusPct = self.extractPercent( 95 labelSubstrings: [ 96 "Current week (Opus)", 97 "Current week (Sonnet only)", 98 "Current week (Sonnet)", 99 ], 100 text: clean) 101 102 // Fallback: order-based percent scraping if labels change or get localized. 103 if sessionPct == nil || weeklyPct == nil || opusPct == nil { 104 let ordered = self.allPercents(clean) 105 if sessionPct == nil, ordered.indices.contains(0) { sessionPct = ordered[0] } 106 if weeklyPct == nil, ordered.indices.contains(1) { weeklyPct = ordered[1] } 107 if opusPct == nil, ordered.indices.contains(2) { opusPct = ordered[2] } 108 } 109 110 // Prefer usage text for identity; fall back to /status if present. 111 let emailPatterns = [ 112 #"(?i)Account:\s+([^\s@]+@[^\s@]+)"#, 113 #"(?i)Email:\s+([^\s@]+@[^\s@]+)"#, 114 ] 115 let looseEmailPatterns = [ 116 #"(?i)Account:\s+(\S+)"#, 117 #"(?i)Email:\s+(\S+)"#, 118 ] 119 let email = emailPatterns 120 .compactMap { self.extractFirst(pattern: $0, text: clean) } 121 .first 122 ?? emailPatterns 123 .compactMap { self.extractFirst(pattern: $0, text: statusClean ?? "") } 124 .first 125 ?? looseEmailPatterns 126 .compactMap { self.extractFirst(pattern: $0, text: clean) } 127 .first 128 ?? looseEmailPatterns 129 .compactMap { self.extractFirst(pattern: $0, text: statusClean ?? "") } 130 .first 131 ?? self.extractFirst( 132 pattern: #"(?i)[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}"#, 133 text: clean) 134 ?? self.extractFirst( 135 pattern: #"(?i)[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}"#, 136 text: statusClean ?? "") 137 let orgPatterns = [ 138 #"(?i)Org:\s*(.+)"#, 139 #"(?i)Organization:\s*(.+)"#, 140 ] 141 let orgRaw = orgPatterns 142 .compactMap { self.extractFirst(pattern: $0, text: clean) } 143 .first 144 ?? orgPatterns 145 .compactMap { self.extractFirst(pattern: $0, text: statusClean ?? "") } 146 .first 147 let org: String? = { 148 guard let orgText = orgRaw?.trimmingCharacters(in: .whitespacesAndNewlines), !orgText.isEmpty else { 149 return nil 150 } 151 // Suppress org if its just the email prefix (common in CLI panels). 152 if let email, orgText.lowercased().hasPrefix(email.lowercased()) { return nil } 153 return orgText 154 }() 155 // Prefer explicit login method from /status, then fall back to /usage header heuristics. 156 let login = self.extractLoginMethod(text: statusText ?? "") ?? self.extractLoginMethod(text: clean) 157 158 guard let sessionPct, let weeklyPct else { 159 Self.dumpIfNeeded( 160 enabled: shouldDump, 161 reason: "missing session/weekly labels", 162 usage: clean, 163 status: statusText) 164 throw ClaudeStatusProbeError.parseFailed("Missing Current session or Current week (all models)") 165 } 166 167 // Capture reset strings for UI display. 168 let resets = self.allResets(clean) 169 170 return ClaudeStatusSnapshot( 171 sessionPercentLeft: sessionPct, 172 weeklyPercentLeft: weeklyPct, 173 opusPercentLeft: opusPct, 174 accountEmail: email, 175 accountOrganization: org, 176 loginMethod: login, 177 primaryResetDescription: resets.first, 178 secondaryResetDescription: resets.count > 1 ? resets[1] : nil, 179 opusResetDescription: resets.count > 2 ? resets[2] : nil, 180 rawText: text + (statusText ?? "")) 181 } 182 183 private static func extractPercent(labelSubstring: String, text: String) -> Int? { 184 let lines = text.components(separatedBy: .newlines) 185 for (idx, line) in lines.enumerated() where line.lowercased().contains(labelSubstring.lowercased()) { 186 let window = lines.dropFirst(idx).prefix(4) 187 for candidate in window { 188 if let pct = percentFromLine(candidate) { return pct } 189 } 190 } 191 return nil 192 } 193 194 private static func extractPercent(labelSubstrings: [String], text: String) -> Int? { 195 for label in labelSubstrings { 196 if let value = self.extractPercent(labelSubstring: label, text: text) { return value } 197 } 198 return nil 199 } 200 201 private static func percentFromLine(_ line: String) -> Int? { 202 // Allow optional Unicode whitespace before % to handle CLI formatting changes. 203 let pattern = #"([0-9]{1,3})\p{Zs}*%\s*(used|left)"# 204 guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return nil } 205 let range = NSRange(line.startIndex..<line.endIndex, in: line) 206 guard let match = regex.firstMatch(in: line, options: [], range: range), 207 match.numberOfRanges >= 3, 208 let valRange = Range(match.range(at: 1), in: line), 209 let kindRange = Range(match.range(at: 2), in: line) 210 else { return nil } 211 let rawVal = Int(line[valRange]) ?? 0 212 let isUsed = line[kindRange].lowercased().contains("used") 213 return isUsed ? max(0, 100 - rawVal) : rawVal 214 } 215 216 private static func extractFirst(pattern: String, text: String) -> String? { 217 guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return nil } 218 let range = NSRange(text.startIndex..<text.endIndex, in: text) 219 guard let match = regex.firstMatch(in: text, options: [], range: range), 220 match.numberOfRanges >= 2, 221 let r = Range(match.range(at: 1), in: text) else { return nil } 222 return String(text[r]).trimmingCharacters(in: .whitespacesAndNewlines) 223 } 224 225 private static func extractUsageError(text: String) -> String? { 226 if let jsonHint = self.extractUsageErrorJSON(text: text) { return jsonHint } 227 228 let lower = text.lowercased() 229 if lower.contains("token_expired") || lower.contains("token has expired") { 230 return "Claude CLI token expired. Run `claude login` to refresh." 231 } 232 if lower.contains("authentication_error") { 233 return "Claude CLI authentication error. Run `claude login`." 234 } 235 if lower.contains("failed to load usage data") { 236 return "Claude CLI could not load usage data. Open the CLI and retry `/usage`." 237 } 238 return nil 239 } 240 241 // Collect percentages in the order they appear; used as a backup when labels move/rename. 242 private static func allPercents(_ text: String) -> [Int] { 243 let patterns = [ 244 #"([0-9]{1,3})\p{Zs}*%\s*left"#, 245 #"([0-9]{1,3})\p{Zs}*%\s*used"#, 246 #"([0-9]{1,3})\p{Zs}*%"#, 247 ] 248 var results: [Int] = [] 249 for pat in patterns { 250 guard let regex = try? NSRegularExpression(pattern: pat, options: [.caseInsensitive]) else { continue } 251 let nsrange = NSRange(text.startIndex..<text.endIndex, in: text) 252 regex.enumerateMatches(in: text, options: [], range: nsrange) { match, _, _ in 253 guard let match, 254 let r = Range(match.range(at: 1), in: text), 255 let val = Int(text[r]) else { return } 256 let used: Int = if pat.contains("left") { 257 max(0, 100 - val) 258 } else { 259 val 260 } 261 results.append(used) 262 } 263 if results.count >= 3 { break } 264 } 265 return results 266 } 267 268 // Capture all "Resets ..." strings to surface in the menu. 269 private static func allResets(_ text: String) -> [String] { 270 let pat = #"Resets[^\n]*"# 271 guard let regex = try? NSRegularExpression(pattern: pat, options: [.caseInsensitive]) else { return [] } 272 let nsrange = NSRange(text.startIndex..<text.endIndex, in: text) 273 var results: [String] = [] 274 regex.enumerateMatches(in: text, options: [], range: nsrange) { match, _, _ in 275 guard let match, 276 let r = Range(match.range(at: 0), in: text) else { return } 277 // TTY capture sometimes appends a stray ")" at line ends; trim it to keep snapshots stable. 278 let raw = String(text[r]).trimmingCharacters(in: .whitespacesAndNewlines) 279 var cleaned = raw.trimmingCharacters(in: CharacterSet(charactersIn: " )")) 280 let openCount = cleaned.count(where: { $0 == "(" }) 281 let closeCount = cleaned.count(where: { $0 == ")" }) 282 if openCount > closeCount { cleaned.append(")") } 283 results.append(cleaned) 284 } 285 return results 286 } 287 288 /// Attempts to parse a Claude reset string into a Date, using the current year and handling optional timezones. 289 public static func parseResetDate(from text: String?, now: Date = .init()) -> Date? { 290 guard let normalized = self.normalizeResetInput(text) else { return nil } 291 let (raw, timeZone) = normalized 292 293 let formatter = DateFormatter() 294 formatter.locale = Locale(identifier: "en_US_POSIX") 295 formatter.timeZone = timeZone ?? TimeZone.current 296 formatter.defaultDate = now 297 var calendar = Calendar(identifier: .gregorian) 298 calendar.timeZone = formatter.timeZone 299 300 if let date = self.parseDate(raw, formats: Self.resetDateTimeWithMinutes, formatter: formatter) { 301 var comps = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date) 302 comps.second = 0 303 return calendar.date(from: comps) 304 } 305 if let date = self.parseDate(raw, formats: Self.resetDateTimeHourOnly, formatter: formatter) { 306 var comps = calendar.dateComponents([.year, .month, .day, .hour], from: date) 307 comps.minute = 0 308 comps.second = 0 309 return calendar.date(from: comps) 310 } 311 312 if let time = self.parseDate(raw, formats: Self.resetTimeWithMinutes, formatter: formatter) { 313 let comps = calendar.dateComponents([.hour, .minute], from: time) 314 guard let anchored = calendar.date( 315 bySettingHour: comps.hour ?? 0, 316 minute: comps.minute ?? 0, 317 second: 0, 318 of: now) else { return nil } 319 if anchored >= now { return anchored } 320 return calendar.date(byAdding: .day, value: 1, to: anchored) 321 } 322 323 guard let time = self.parseDate(raw, formats: Self.resetTimeHourOnly, formatter: formatter) else { return nil } 324 let comps = calendar.dateComponents([.hour], from: time) 325 guard let anchored = calendar.date( 326 bySettingHour: comps.hour ?? 0, 327 minute: 0, 328 second: 0, 329 of: now) else { return nil } 330 if anchored >= now { return anchored } 331 return calendar.date(byAdding: .day, value: 1, to: anchored) 332 } 333 334 private static let resetTimeWithMinutes = ["h:mma", "h:mm a", "HH:mm", "H:mm"] 335 private static let resetTimeHourOnly = ["ha", "h a"] 336 337 private static let resetDateTimeWithMinutes = [ 338 "MMM d, h:mma", 339 "MMM d, h:mm a", 340 "MMM d h:mma", 341 "MMM d h:mm a", 342 "MMM d, HH:mm", 343 "MMM d HH:mm", 344 ] 345 346 private static let resetDateTimeHourOnly = [ 347 "MMM d, ha", 348 "MMM d, h a", 349 "MMM d ha", 350 "MMM d h a", 351 ] 352 353 private static func normalizeResetInput(_ text: String?) -> (String, TimeZone?)? { 354 guard var raw = text?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } 355 raw = raw.replacingOccurrences(of: #"(?i)^resets?:?\s*"#, with: "", options: .regularExpression) 356 raw = raw.replacingOccurrences(of: " at ", with: " ", options: .caseInsensitive) 357 raw = raw.replacingOccurrences( 358 of: #"(?<=\d)\.(\d{2})\b"#, 359 with: ":$1", 360 options: .regularExpression) 361 362 let timeZone = self.extractTimeZone(from: &raw) 363 raw = raw.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) 364 .trimmingCharacters(in: .whitespacesAndNewlines) 365 return raw.isEmpty ? nil : (raw, timeZone) 366 } 367 368 private static func extractTimeZone(from text: inout String) -> TimeZone? { 369 guard let tzRange = text.range(of: #"\(([^)]+)\)"#, options: .regularExpression) else { return nil } 370 let tzID = String(text[tzRange]).trimmingCharacters(in: CharacterSet(charactersIn: "() ")) 371 text.removeSubrange(tzRange) 372 text = text.trimmingCharacters(in: .whitespacesAndNewlines) 373 return TimeZone(identifier: tzID) 374 } 375 376 private static func parseDate(_ text: String, formats: [String], formatter: DateFormatter) -> Date? { 377 for pattern in formats { 378 formatter.dateFormat = pattern 379 if let date = formatter.date(from: text) { return date } 380 } 381 return nil 382 } 383 384 // Extract login/plan string from CLI output. 385 private static func extractLoginMethod(text: String) -> String? { 386 guard !text.isEmpty else { return nil } 387 if let explicit = self.extractFirst(pattern: #"(?i)login\s+method:\s*(.+)"#, text: text) { 388 return self.cleanPlan(explicit) 389 } 390 // Capture any "Claude <...>" phrase (e.g., Max/Pro/Ultra/Team) to avoid future plan-name churn. 391 // Strip any leading ANSI that may have survived (rare) before matching. 392 let planPattern = #"(?i)(claude\s+[a-z0-9][a-z0-9\s._-]{0,24})"# 393 var candidates: [String] = [] 394 if let regex = try? NSRegularExpression(pattern: planPattern, options: []) { 395 let nsrange = NSRange(text.startIndex..<text.endIndex, in: text) 396 regex.enumerateMatches(in: text, options: [], range: nsrange) { match, _, _ in 397 guard let match, 398 match.numberOfRanges >= 2, 399 let r = Range(match.range(at: 1), in: text) else { return } 400 let raw = String(text[r]) 401 let val = Self.cleanPlan(raw) 402 candidates.append(val) 403 } 404 } 405 if let plan = candidates.first(where: { cand in 406 let lower = cand.lowercased() 407 return !lower.contains("code v") && !lower.contains("code version") && !lower.contains("code") 408 }) { 409 return plan 410 } 411 return nil 412 } 413 414 /// Strips ANSI and stray bracketed codes like "[22m" that can survive CLI output. 415 private static func cleanPlan(_ text: String) -> String { 416 UsageFormatter.cleanPlanName(text) 417 } 418 419 private static func dumpIfNeeded(enabled: Bool, reason: String, usage: String, status: String?) { 420 guard enabled else { return } 421 let stamp = ISO8601DateFormatter().string(from: Date()) 422 var body = """ 423 === Claude parse dump @ \(stamp) === 424 Reason: \(reason) 425 426 --- usage (clean) --- 427 \(usage) 428 429 """ 430 if let status { 431 body += """ 432 --- status (raw/optional) --- 433 \(status) 434 435 """ 436 } 437 Task { @MainActor in self.recordDump(body) } 438 } 439 440 // MARK: - Dump storage (in-memory ring buffer) 441 442 @MainActor private static var recentDumps: [String] = [] 443 444 @MainActor private static func recordDump(_ text: String) { 445 if self.recentDumps.count >= 5 { self.recentDumps.removeFirst() } 446 self.recentDumps.append(text) 447 } 448 449 public static func latestDumps() async -> String { 450 await MainActor.run { 451 let result = Self.recentDumps.joined(separator: "\n\n---\n\n") 452 return result.isEmpty ? "No Claude parse dumps captured yet." : result 453 } 454 } 455 456 private static func extractUsageErrorJSON(text: String) -> String? { 457 let pattern = #"Failed to load usage data:\s*(\{.*\})"# 458 guard let regex = try? NSRegularExpression(pattern: pattern, options: [.dotMatchesLineSeparators]) else { 459 return nil 460 } 461 let range = NSRange(text.startIndex..<text.endIndex, in: text) 462 guard let match = regex.firstMatch(in: text, options: [], range: range), 463 match.numberOfRanges >= 2, 464 let jsonRange = Range(match.range(at: 1), in: text) 465 else { 466 return nil 467 } 468 469 let jsonString = String(text[jsonRange]) 470 guard let data = jsonString.data(using: .utf8), 471 let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any], 472 let error = payload["error"] as? [String: Any] 473 else { 474 return nil 475 } 476 477 let message = (error["message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) 478 let details = error["details"] as? [String: Any] 479 let code = (details?["error_code"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) 480 481 var parts: [String] = [] 482 if let message, !message.isEmpty { parts.append(message) } 483 if let code, !code.isEmpty { parts.append("(\(code))") } 484 485 guard !parts.isEmpty else { return nil } 486 let hint = parts.joined(separator: " ") 487 488 if let code, code.lowercased().contains("token") { 489 return "\(hint). Run `claude login` to refresh." 490 } 491 return "Claude CLI error: \(hint)" 492 } 493 494 // MARK: - Process helpers 495 496 // Run `script -q /dev/null claude <subcommand>` with a hard timeout; avoids fragile PTY keystrokes. 497 private static func capture(subcommand: String, binary: String, timeout: TimeInterval) async throws -> String { 498 try await Task.detached(priority: .utility) { [claudeBinary = binary, timeout] in 499 let process = Process() 500 process.launchPath = "/usr/bin/script" 501 process.arguments = [ 502 "-q", 503 "/dev/null", 504 claudeBinary, 505 subcommand, 506 "--allowed-tools", 507 "", 508 ] 509 let pipe = Pipe() 510 process.standardOutput = pipe 511 process.standardError = Pipe() 512 process.standardInput = nil 513 var env = ProcessInfo.processInfo.environment 514 env["PATH"] = PathBuilder.effectivePATH(purposes: [.tty, .nodeTooling], env: env) 515 process.environment = env 516 517 do { 518 try process.run() 519 } catch { 520 throw ClaudeStatusProbeError.claudeNotInstalled 521 } 522 523 DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { 524 if process.isRunning { process.terminate() } 525 } 526 527 process.waitUntilExit() 528 529 let data = pipe.fileHandleForReading.readDataToEndOfFile() 530 guard !data.isEmpty else { throw ClaudeStatusProbeError.timedOut } 531 return String(data: data, encoding: .utf8) ?? "" 532 }.value 533 } 534}