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