this repo has no description
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 it’s 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}