this repo has no description
at fix/fnm-node-path-resolution 550 lines 19 kB view raw
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}