this repo has no description
at main 125 lines 4.9 kB view raw
1import Foundation 2 3public struct CodexStatusSnapshot: Sendable { 4 public let credits: Double? 5 public let fiveHourPercentLeft: Int? 6 public let weeklyPercentLeft: Int? 7 public let fiveHourResetDescription: String? 8 public let weeklyResetDescription: String? 9 public let rawText: String 10 11 public init( 12 credits: Double?, 13 fiveHourPercentLeft: Int?, 14 weeklyPercentLeft: Int?, 15 fiveHourResetDescription: String?, 16 weeklyResetDescription: String?, 17 rawText: String) 18 { 19 self.credits = credits 20 self.fiveHourPercentLeft = fiveHourPercentLeft 21 self.weeklyPercentLeft = weeklyPercentLeft 22 self.fiveHourResetDescription = fiveHourResetDescription 23 self.weeklyResetDescription = weeklyResetDescription 24 self.rawText = rawText 25 } 26} 27 28public enum CodexStatusProbeError: LocalizedError, Sendable { 29 case codexNotInstalled 30 case parseFailed(String) 31 case timedOut 32 case updateRequired(String) 33 34 public var errorDescription: String? { 35 switch self { 36 case .codexNotInstalled: 37 "Codex CLI is not installed or not on PATH." 38 case .parseFailed: 39 "Could not parse Codex status; will retry shortly." 40 case .timedOut: 41 "Codex status probe timed out." 42 case let .updateRequired(msg): 43 "Codex CLI update needed: \(msg)" 44 } 45 } 46} 47 48/// Runs `codex` inside a PTY, sends `/status`, captures text, and parses credits/limits. 49public struct CodexStatusProbe { 50 public var codexBinary: String = "codex" 51 public var timeout: TimeInterval = 18.0 52 53 public init() {} 54 55 public init(codexBinary: String = "codex", timeout: TimeInterval = 18.0) { 56 self.codexBinary = codexBinary 57 self.timeout = timeout 58 } 59 60 public func fetch() async throws -> CodexStatusSnapshot { 61 guard TTYCommandRunner.which(self.codexBinary) != nil else { throw CodexStatusProbeError.codexNotInstalled } 62 do { 63 return try self.runAndParse(rows: 60, cols: 200, timeout: self.timeout) 64 } catch let error as CodexStatusProbeError { 65 // Codex sometimes returns an incomplete screen on the first try; retry once with a longer window. 66 switch error { 67 case .parseFailed, .timedOut: 68 return try self.runAndParse(rows: 70, cols: 220, timeout: max(self.timeout, 24.0)) 69 default: 70 throw error 71 } 72 } 73 } 74 75 // MARK: - Parsing 76 77 public static func parse(text: String) throws -> CodexStatusSnapshot { 78 let clean = TextParsing.stripANSICodes(text) 79 guard !clean.isEmpty else { throw CodexStatusProbeError.timedOut } 80 if clean.localizedCaseInsensitiveContains("data not available yet") { 81 throw CodexStatusProbeError.parseFailed("data not available yet") 82 } 83 if self.containsUpdatePrompt(clean) { 84 throw CodexStatusProbeError.updateRequired( 85 "Run `bun install -g @openai/codex` to continue (update prompt blocking /status).") 86 } 87 let credits = TextParsing.firstNumber(pattern: #"Credits:\s*([0-9][0-9.,]*)"#, text: clean) 88 // Pull reset info from the same lines that contain the percentages. 89 let fiveLine = TextParsing.firstLine(matching: #"5h limit[^\n]*"#, text: clean) 90 let weekLine = TextParsing.firstLine(matching: #"Weekly limit[^\n]*"#, text: clean) 91 let fivePct = fiveLine.flatMap(TextParsing.percentLeft(fromLine:)) 92 let weekPct = weekLine.flatMap(TextParsing.percentLeft(fromLine:)) 93 let fiveReset = fiveLine.flatMap(TextParsing.resetString(fromLine:)) 94 let weekReset = weekLine.flatMap(TextParsing.resetString(fromLine:)) 95 if credits == nil, fivePct == nil, weekPct == nil { 96 throw CodexStatusProbeError.parseFailed(clean.prefix(400).description) 97 } 98 return CodexStatusSnapshot( 99 credits: credits, 100 fiveHourPercentLeft: fivePct, 101 weeklyPercentLeft: weekPct, 102 fiveHourResetDescription: fiveReset, 103 weeklyResetDescription: weekReset, 104 rawText: clean) 105 } 106 107 private func runAndParse(rows: UInt16, cols: UInt16, timeout: TimeInterval) throws -> CodexStatusSnapshot { 108 let runner = TTYCommandRunner() 109 let script = "/status\n" 110 let result = try runner.run( 111 binary: self.codexBinary, 112 send: script, 113 options: .init( 114 rows: rows, 115 cols: cols, 116 timeout: timeout, 117 extraArgs: ["-s", "read-only", "-a", "untrusted"])) 118 return try Self.parse(text: result.text) 119 } 120 121 private static func containsUpdatePrompt(_ text: String) -> Bool { 122 let lower = text.lowercased() 123 return lower.contains("update available") && lower.contains("codex") 124 } 125}