this repo has no description
1import CodexBarCore
2import Commander
3import Darwin
4import Foundation
5
6@main
7enum CodexBarCLI {
8 static func main() async {
9 let rawArgv = Array(CommandLine.arguments.dropFirst())
10 let argv = Self.effectiveArgv(rawArgv)
11
12 // Fast path: global help/version before building descriptors.
13 if let helpIndex = argv.firstIndex(where: { $0 == "-h" || $0 == "--help" }) {
14 let command = helpIndex == 0 ? argv.dropFirst().first : argv.first
15 Self.printHelp(for: command)
16 }
17 if argv.contains("-V") || argv.contains("--version") {
18 Self.printVersion()
19 }
20
21 let usageSignature = CommandSignature
22 .describe(UsageOptions())
23 .withStandardRuntimeFlags()
24
25 let descriptors: [CommandDescriptor] = [
26 CommandDescriptor(
27 name: "usage",
28 abstract: "Print usage as text or JSON",
29 discussion: nil,
30 signature: usageSignature),
31 ]
32
33 let program = Program(descriptors: descriptors)
34
35 do {
36 let invocation = try program.resolve(argv: argv)
37 switch invocation.descriptor.name {
38 case "usage":
39 await self.runUsage(invocation.parsedValues)
40 default:
41 Self.exit(code: .failure, message: "Unknown command")
42 }
43 } catch let error as CommanderProgramError {
44 Self.exit(code: .failure, message: error.description)
45 } catch {
46 Self.exit(code: .failure, message: error.localizedDescription)
47 }
48 }
49
50 // MARK: - Commands
51
52 private static func runUsage(_ values: ParsedValues) async {
53 let provider = Self.decodeProvider(from: values)
54 let format = Self.decodeFormat(from: values)
55 let includeCredits = format == .json ? true : !values.flags.contains("noCredits")
56 let includeStatus = values.flags.contains("status")
57 let pretty = values.flags.contains("pretty")
58 let useColor = Self.shouldUseColor()
59 let fetcher = UsageFetcher()
60 let claudeFetcher = ClaudeUsageFetcher()
61
62 var sections: [String] = []
63 var payload: [ProviderPayload] = []
64 var exitCode: ExitCode = .success
65
66 for p in provider.asList {
67 let versionInfo = Self.formatVersion(provider: p, raw: Self.detectVersion(for: p))
68 let header = Self.makeHeader(provider: p, version: versionInfo.version, source: versionInfo.source)
69 let status = includeStatus ? await Self.fetchStatus(for: p) : nil
70 switch await Self.fetch(
71 provider: p,
72 includeCredits: includeCredits,
73 fetcher: fetcher,
74 claudeFetcher: claudeFetcher)
75 {
76 case let .success(result):
77 switch format {
78 case .text:
79 sections.append(CLIRenderer.renderText(
80 provider: p,
81 snapshot: result.usage,
82 credits: result.credits,
83 context: RenderContext(header: header, status: status, useColor: useColor)))
84 case .json:
85 payload.append(ProviderPayload(
86 provider: p,
87 version: versionInfo.version,
88 source: versionInfo.source,
89 status: status,
90 usage: result.usage,
91 credits: result.credits))
92 }
93 case let .failure(error):
94 exitCode = Self.mapError(error)
95 Self.printError(error)
96 }
97 }
98
99 switch format {
100 case .text:
101 if !sections.isEmpty {
102 print(sections.joined(separator: "\n\n"))
103 }
104 case .json:
105 if !payload.isEmpty {
106 let encoder = JSONEncoder()
107 encoder.dateEncodingStrategy = .iso8601
108 encoder.outputFormatting = pretty ? [.prettyPrinted, .sortedKeys] : []
109 if let data = try? encoder.encode(payload),
110 let output = String(data: data, encoding: .utf8)
111 {
112 print(output)
113 }
114 }
115 }
116
117 Self.exit(code: exitCode)
118 }
119
120 // MARK: - Helpers
121
122 static func effectiveArgv(_ argv: [String]) -> [String] {
123 guard let first = argv.first else { return ["usage"] }
124 if first.hasPrefix("-") { return ["usage"] + argv }
125 return argv
126 }
127
128 fileprivate static func decodeProvider(from values: ParsedValues) -> ProviderSelection {
129 if let raw = values.options["provider"]?.last, let parsed = ProviderSelection(argument: raw) {
130 return parsed
131 }
132 let enabled = Self.enabledProvidersFromDefaults()
133 if enabled.count == 2 { return .both }
134 if let first = enabled.first { return ProviderSelection(provider: first) }
135 return .codex
136 }
137
138 private static func decodeFormat(from values: ParsedValues) -> OutputFormat {
139 if let raw = values.options["format"]?.last, let parsed = OutputFormat(argument: raw) {
140 return parsed
141 }
142 if values.flags.contains("json") { return .json }
143 return .text
144 }
145
146 private static func shouldUseColor() -> Bool {
147 isatty(STDOUT_FILENO) == 1
148 }
149
150 private static func detectVersion(for provider: UsageProvider) -> String? {
151 switch provider {
152 case .codex:
153 VersionDetector.codexVersion()
154 case .claude:
155 ClaudeUsageFetcher().detectVersion()
156 }
157 }
158
159 private static func formatVersion(provider: UsageProvider, raw: String?) -> (version: String?, source: String) {
160 let source = provider == .codex ? "codex-cli" : "claude"
161 guard let raw, !raw.isEmpty else { return (nil, source) }
162 if let match = raw.range(of: #"(\d+(?:\.\d+)+)"#, options: .regularExpression) {
163 let version = String(raw[match]).trimmingCharacters(in: .whitespacesAndNewlines)
164 return (version, source)
165 }
166 return (raw.trimmingCharacters(in: .whitespacesAndNewlines), source)
167 }
168
169 private static func makeHeader(provider: UsageProvider, version: String?, source: String) -> String {
170 let name = ProviderDefaults.metadata[provider]?.displayName ?? provider.rawValue.capitalized
171 if let version, !version.isEmpty {
172 return "\(name) \(version) (\(source))"
173 }
174 return "\(name) (\(source))"
175 }
176
177 private static func fetchStatus(for provider: UsageProvider) async -> ProviderStatusPayload? {
178 guard let urlString = ProviderDefaults.metadata[provider]?.statusPageURL,
179 let baseURL = URL(string: urlString) else { return nil }
180 do {
181 return try await StatusFetcher.fetch(from: baseURL)
182 } catch {
183 return ProviderStatusPayload(
184 indicator: .unknown,
185 description: error.localizedDescription,
186 updatedAt: nil,
187 url: urlString)
188 }
189 }
190
191 private static func enabledProvidersFromDefaults() -> [UsageProvider] {
192 // Prefer the app's defaults domain so CLI mirrors in-app toggles.
193 let domains = [
194 "com.steipete.codexbar",
195 "com.steipete.codexbar.debug",
196 ]
197
198 var toggles: [String: Bool] = [:]
199 for domain in domains {
200 if let dict = UserDefaults(suiteName: domain)?.dictionary(forKey: "providerToggles") as? [String: Bool],
201 !dict.isEmpty
202 {
203 toggles = dict
204 break
205 }
206 }
207
208 if toggles.isEmpty {
209 toggles = UserDefaults.standard.dictionary(forKey: "providerToggles") as? [String: Bool] ?? [:]
210 }
211
212 return ProviderDefaults.metadata.compactMap { provider, meta in
213 let isOn = toggles[meta.cliName] ?? meta.defaultEnabled
214 return isOn ? provider : nil
215 }.sorted { $0.rawValue < $1.rawValue }
216 }
217
218 private static func fetch(
219 provider: UsageProvider,
220 includeCredits: Bool,
221 fetcher: UsageFetcher,
222 claudeFetcher: ClaudeUsageFetcher) async -> Result<(usage: UsageSnapshot, credits: CreditsSnapshot?), Error>
223 {
224 do {
225 switch provider {
226 case .codex:
227 let usage = try await fetcher.loadLatestUsage()
228 let credits = includeCredits ? try? await fetcher.loadLatestCredits() : nil
229 return .success((usage, credits))
230 case .claude:
231 let usage = try await claudeFetcher.loadLatestUsage(model: "sonnet")
232 return .success((
233 usage: UsageSnapshot(
234 primary: usage.primary,
235 secondary: usage.secondary,
236 tertiary: usage.opus,
237 updatedAt: usage.updatedAt,
238 accountEmail: usage.accountEmail,
239 accountOrganization: usage.accountOrganization,
240 loginMethod: usage.loginMethod),
241 credits: nil))
242 }
243 } catch {
244 return .failure(error)
245 }
246 }
247
248 private static func mapError(_ error: Error) -> ExitCode {
249 switch error {
250 case TTYCommandRunner.Error.binaryNotFound,
251 CodexStatusProbeError.codexNotInstalled,
252 ClaudeUsageError.claudeNotInstalled:
253 ExitCode(2)
254 case CodexStatusProbeError.timedOut,
255 TTYCommandRunner.Error.timedOut:
256 ExitCode(4)
257 case ClaudeUsageError.parseFailed,
258 UsageError.decodeFailed,
259 UsageError.noRateLimitsFound:
260 ExitCode(3)
261 default:
262 .failure
263 }
264 }
265
266 private static func printError(_ error: Error) {
267 fputs("Error: \(error.localizedDescription)\n", stderr)
268 }
269
270 private static func exit(code: ExitCode, message: String? = nil) -> Never {
271 if let message {
272 fputs("\(message)\n", stderr)
273 }
274 Darwin.exit(code.rawValue)
275 }
276
277 static func printVersion() -> Never {
278 if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
279 print("CodexBar \(version)")
280 } else {
281 print("CodexBar")
282 }
283 Darwin.exit(0)
284 }
285
286 static func printHelp(for command: String?) -> Never {
287 let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
288 switch command {
289 case "usage":
290 print("""
291 CodexBar \(version)
292
293 Usage:
294 codexbar usage [--format text|json] [--provider codex|claude|both] [--no-credits] [--pretty] [--status]
295
296 Description:
297 Print usage from enabled providers as text (default) or JSON. Honors your in-app toggles.
298
299 Examples:
300 codexbar usage
301 codexbar usage --provider claude
302 codexbar usage --format json --provider both --pretty
303 codexbar usage --status
304 """)
305 default:
306 print("""
307 CodexBar \(version)
308
309 Usage:
310 codexbar [--format text|json] [--provider codex|claude|both] [--no-credits] [--pretty] [--status]
311
312 Global flags:
313 -h, --help Show help
314 -V, --version Show version
315 -v, --verbose Enable verbose logging
316 --log-level <trace|verbose|debug|info|warning|error|critical>
317 --json-output Emit machine-readable logs
318
319 Examples:
320 codexbar
321 codexbar --format json --provider both --pretty
322 codexbar --provider claude
323 """)
324 }
325 Darwin.exit(0)
326 }
327}
328
329// MARK: - Options & decoding helpers
330
331private struct UsageOptions: CommanderParsable {
332 @Option(name: .long("provider"), help: "Provider to query: codex | claude | both")
333 var provider: ProviderSelection?
334
335 @Option(name: .long("format"), help: "Output format: text | json")
336 var format: OutputFormat?
337
338 @Flag(name: .long("json"), help: "")
339 var jsonShortcut: Bool = false
340
341 @Flag(name: .long("no-credits"), help: "Skip Codex credits line")
342 var noCredits: Bool = false
343
344 @Flag(name: .long("pretty"), help: "Pretty-print JSON output")
345 var pretty: Bool = false
346
347 @Flag(name: .long("status"), help: "Fetch and include provider status")
348 var status: Bool = false
349}
350
351private enum ProviderSelection: String, Sendable, ExpressibleFromArgument {
352 case codex
353 case claude
354 case both
355
356 init?(argument: String) {
357 switch argument.lowercased() {
358 case "codex": self = .codex
359 case "claude": self = .claude
360 case "both": self = .both
361 default: return nil
362 }
363 }
364
365 init(provider: UsageProvider) {
366 switch provider {
367 case .codex: self = .codex
368 case .claude: self = .claude
369 }
370 }
371
372 var asList: [UsageProvider] {
373 switch self {
374 case .codex: [.codex]
375 case .claude: [.claude]
376 case .both: [.codex, .claude]
377 }
378 }
379}
380
381enum OutputFormat: String, Sendable, ExpressibleFromArgument {
382 case text
383 case json
384
385 init?(argument: String) {
386 switch argument.lowercased() {
387 case "text": self = .text
388 case "json": self = .json
389 default: return nil
390 }
391 }
392}
393
394struct ProviderPayload: Encodable {
395 let provider: String
396 let version: String?
397 let source: String
398 let status: ProviderStatusPayload?
399 let usage: UsageSnapshot
400 let credits: CreditsSnapshot?
401
402 init(
403 provider: UsageProvider,
404 version: String?,
405 source: String,
406 status: ProviderStatusPayload?,
407 usage: UsageSnapshot,
408 credits: CreditsSnapshot?)
409 {
410 self.provider = provider.rawValue
411 self.version = version
412 self.source = source
413 self.status = status
414 self.usage = usage
415 self.credits = credits
416 }
417}
418
419struct ProviderStatusPayload: Encodable {
420 let indicator: ProviderStatusIndicator
421 let description: String?
422 let updatedAt: Date?
423 let url: String
424
425 enum ProviderStatusIndicator: String, Encodable {
426 case none
427 case minor
428 case major
429 case critical
430 case maintenance
431 case unknown
432
433 var label: String {
434 switch self {
435 case .none: "Operational"
436 case .minor: "Partial outage"
437 case .major: "Major outage"
438 case .critical: "Critical issue"
439 case .maintenance: "Maintenance"
440 case .unknown: "Status unknown"
441 }
442 }
443 }
444
445 var descriptionSuffix: String {
446 guard let description, !description.isEmpty else { return "" }
447 return " – \(description)"
448 }
449}
450
451private enum VersionDetector {
452 static func codexVersion() -> String? {
453 guard let path = TTYCommandRunner.which("codex") else { return nil }
454 let candidates = [
455 ["--version"],
456 ["version"],
457 ["-v"],
458 ]
459 for args in candidates {
460 if let version = Self.run(path: path, args: args) { return version }
461 }
462 return nil
463 }
464
465 private static func run(path: String, args: [String]) -> String? {
466 let proc = Process()
467 proc.executableURL = URL(fileURLWithPath: path)
468 proc.arguments = args
469 let out = Pipe()
470 proc.standardOutput = out
471 proc.standardError = Pipe()
472
473 do {
474 try proc.run()
475 } catch {
476 return nil
477 }
478
479 let deadline = Date().addingTimeInterval(2.0)
480 while proc.isRunning, Date() < deadline {
481 usleep(50000)
482 }
483 if proc.isRunning {
484 proc.terminate()
485 let killDeadline = Date().addingTimeInterval(0.5)
486 while proc.isRunning, Date() < killDeadline {
487 usleep(20000)
488 }
489 if proc.isRunning {
490 kill(proc.processIdentifier, SIGKILL)
491 }
492 }
493
494 let data = out.fileHandleForReading.readDataToEndOfFile()
495 guard proc.terminationStatus == 0,
496 let text = String(data: data, encoding: .utf8)?
497 .split(whereSeparator: \.isNewline).first
498 else { return nil }
499 let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
500 return trimmed.isEmpty ? nil : trimmed
501 }
502}
503
504private enum StatusFetcher {
505 static func fetch(from baseURL: URL) async throws -> ProviderStatusPayload {
506 let apiURL = baseURL.appendingPathComponent("api/v2/status.json")
507 var request = URLRequest(url: apiURL)
508 request.timeoutInterval = 10
509
510 let (data, _) = try await URLSession.shared.data(for: request)
511
512 struct Response: Decodable {
513 struct Status: Decodable {
514 let indicator: String
515 let description: String?
516 }
517
518 struct Page: Decodable {
519 let updatedAt: Date?
520
521 private enum CodingKeys: String, CodingKey {
522 case updatedAt = "updated_at"
523 }
524 }
525
526 let page: Page?
527 let status: Status
528 }
529
530 let decoder = JSONDecoder()
531 decoder.dateDecodingStrategy = .custom { decoder in
532 let container = try decoder.singleValueContainer()
533 let raw = try container.decode(String.self)
534 let formatter = ISO8601DateFormatter()
535 formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
536 if let date = formatter.date(from: raw) { return date }
537 formatter.formatOptions = [.withInternetDateTime]
538 if let date = formatter.date(from: raw) { return date }
539 throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid ISO8601 date")
540 }
541
542 let response = try decoder.decode(Response.self, from: data)
543 let indicator = ProviderStatusPayload.ProviderStatusIndicator(rawValue: response.status.indicator) ?? .unknown
544 return ProviderStatusPayload(
545 indicator: indicator,
546 description: response.status.description,
547 updatedAt: response.page?.updatedAt,
548 url: baseURL.absoluteString)
549 }
550}