diff --git a/AIMenubarUsage.xcodeproj/project.pbxproj b/AIMenubarUsage.xcodeproj/project.pbxproj index 81ab3a3..1c7e00e 100644 --- a/AIMenubarUsage.xcodeproj/project.pbxproj +++ b/AIMenubarUsage.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 14EEEE6F7825B2B65AE18FC4 /* MenuBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5BC8D498AD23776B7EF6D7 /* MenuBarController.swift */; }; 350A49467C190655A44A1B72 /* cursor-usage-summary.json in Resources */ = {isa = PBXBuildFile; fileRef = A5CAC3724673DBDC415CB9C1 /* cursor-usage-summary.json */; }; 474CD6506A1028547A88F63E /* ProviderUsageSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A929A7D2E06CA26E801EE5 /* ProviderUsageSnapshot.swift */; }; + 605967B2F70578393027499F /* ChatGPTProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E870E40099D74E699A3A31 /* ChatGPTProviderTests.swift */; }; 692ED3CCC92E543EEF808F06 /* PopoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12BF3837FE5F27BCB9F05825 /* PopoverView.swift */; }; 6AA6002791A3C8146BE308C8 /* CursorProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E90D01F19914785954D4054 /* CursorProviderTests.swift */; }; 80377D42C74851D9C7CF9DDC /* ClaudeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27BB9DEC72FBE9B5E3D71AE7 /* ClaudeProvider.swift */; }; @@ -23,12 +24,15 @@ 9ADC813DA1DC6C9F6AF03D96 /* claude-oauth-usage.json in Resources */ = {isa = PBXBuildFile; fileRef = C2DDCF0D74548E0E3EE8BBC2 /* claude-oauth-usage.json */; }; 9C03E35D6CC3AE953BEB3EF7 /* ClaudeCLIParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0F49A20AA99ADBF5FA32D5A /* ClaudeCLIParserTests.swift */; }; B00F7D3788898A4FBDAA205D /* ProviderIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA37F8450D730231198E183 /* ProviderIcon.swift */; }; + B3CC65806679B0201BD2CAFA /* CodexCLIUsageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBE841AA7311B637A59E5893 /* CodexCLIUsageService.swift */; }; B53990EEE51F907CCD4B5822 /* UsageMetric.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15C24ACF2657E390A6F1EE20 /* UsageMetric.swift */; }; B9BA0C825207364FD8309DC7 /* UsageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61452F7607C80021EAE24E6C /* UsageProvider.swift */; }; BDF64716296F11F448D0E604 /* UsageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = E57FF9519DD9DADBAA366503 /* UsageCache.swift */; }; C5715AEDEB19F7B36298B0FC /* ClaudeProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3717BFFDC22E029B1CF81C65 /* ClaudeProviderTests.swift */; }; + C5DCA64163D93004462D13C3 /* ChatGPTProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF55E59C2BD1F3154CAFCDC0 /* ChatGPTProvider.swift */; }; C940AAD549BFBF0DBCBB89C6 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0F6E6ACBDD186D4701E4B63 /* SettingsView.swift */; }; C9CCA6B2E3EF24D42A7F96C2 /* ClaudeCLIUsageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8BC68BB05A29514C7775589 /* ClaudeCLIUsageService.swift */; }; + D4A241051CBA23EBF22E7B33 /* chatgpt-codex-usage.json in Resources */ = {isa = PBXBuildFile; fileRef = 42E5EC443BF74037D24F7578 /* chatgpt-codex-usage.json */; }; DACE19613E28C972D18338B9 /* ProviderCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E8B7EBB88662A7526DC0DAC /* ProviderCardView.swift */; }; DBB881F7D74D8AF0A56987F3 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CAAFDF70292C8F5EBF0B950 /* AppSettings.swift */; }; E4C0E55E8FE83106E2ED480B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 62783B05EB9FEC95006D048D /* Assets.xcassets */; }; @@ -60,6 +64,7 @@ 3717BFFDC22E029B1CF81C65 /* ClaudeProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeProviderTests.swift; sourceTree = ""; }; 3B500F187AFF84E5C8BC212C /* AIMenubarUsage.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AIMenubarUsage.app; sourceTree = BUILT_PRODUCTS_DIR; }; 3CA37F8450D730231198E183 /* ProviderIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderIcon.swift; sourceTree = ""; }; + 42E5EC443BF74037D24F7578 /* chatgpt-codex-usage.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "chatgpt-codex-usage.json"; sourceTree = ""; }; 4416B8C95B50BCEDF54FA130 /* AIMenubarUsageTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AIMenubarUsageTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5AA4B4C369488D4FA6312484 /* CursorProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CursorProvider.swift; sourceTree = ""; }; 5CAAFDF70292C8F5EBF0B950 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; @@ -72,6 +77,7 @@ 93B331C96A907C7CB9172650 /* MenuBarDisplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarDisplayTests.swift; sourceTree = ""; }; 952E850263D70B58A8622D84 /* CursorAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CursorAuthService.swift; sourceTree = ""; }; A5CAC3724673DBDC415CB9C1 /* cursor-usage-summary.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "cursor-usage-summary.json"; sourceTree = ""; }; + A6E870E40099D74E699A3A31 /* ChatGPTProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatGPTProviderTests.swift; sourceTree = ""; }; AA51A975D1EFCACAD5591EF2 /* ClaudeAuthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeAuthTests.swift; sourceTree = ""; }; B0F6E6ACBDD186D4701E4B63 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; B1A929A7D2E06CA26E801EE5 /* ProviderUsageSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderUsageSnapshot.swift; sourceTree = ""; }; @@ -81,13 +87,16 @@ DE93F3CBB065D23A36D4BE3D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; E0F49A20AA99ADBF5FA32D5A /* ClaudeCLIParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeCLIParserTests.swift; sourceTree = ""; }; E57FF9519DD9DADBAA366503 /* UsageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageCache.swift; sourceTree = ""; }; + EBE841AA7311B637A59E5893 /* CodexCLIUsageService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexCLIUsageService.swift; sourceTree = ""; }; F0E6E438C251CE8879D29C39 /* KeychainService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainService.swift; sourceTree = ""; }; + FF55E59C2BD1F3154CAFCDC0 /* ChatGPTProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatGPTProvider.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ 78437CF65E89E1611E94DB24 /* AIMenubarUsageTests */ = { isa = PBXGroup; children = ( + A6E870E40099D74E699A3A31 /* ChatGPTProviderTests.swift */, AA51A975D1EFCACAD5591EF2 /* ClaudeAuthTests.swift */, E0F49A20AA99ADBF5FA32D5A /* ClaudeCLIParserTests.swift */, 3717BFFDC22E029B1CF81C65 /* ClaudeProviderTests.swift */, @@ -103,6 +112,7 @@ children = ( 7FCE5EFE595B010E1E54D98C /* ClaudeAuthService.swift */, B8BC68BB05A29514C7775589 /* ClaudeCLIUsageService.swift */, + EBE841AA7311B637A59E5893 /* CodexCLIUsageService.swift */, 952E850263D70B58A8622D84 /* CursorAuthService.swift */, F0E6E438C251CE8879D29C39 /* KeychainService.swift */, 8658C9C077212181A65D761F /* LaunchAtLoginService.swift */, @@ -141,6 +151,7 @@ AB234C351A82D7F8C29D59FC /* Fixtures */ = { isa = PBXGroup; children = ( + 42E5EC443BF74037D24F7578 /* chatgpt-codex-usage.json */, C2DDCF0D74548E0E3EE8BBC2 /* claude-oauth-usage.json */, A5CAC3724673DBDC415CB9C1 /* cursor-usage-summary.json */, ); @@ -171,6 +182,7 @@ E9887E1F5E66E9471D6346EB /* Providers */ = { isa = PBXGroup; children = ( + FF55E59C2BD1F3154CAFCDC0 /* ChatGPTProvider.swift */, 27BB9DEC72FBE9B5E3D71AE7 /* ClaudeProvider.swift */, 5AA4B4C369488D4FA6312484 /* CursorProvider.swift */, 61452F7607C80021EAE24E6C /* UsageProvider.swift */, @@ -272,6 +284,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + D4A241051CBA23EBF22E7B33 /* chatgpt-codex-usage.json in Resources */, 9ADC813DA1DC6C9F6AF03D96 /* claude-oauth-usage.json in Resources */, 350A49467C190655A44A1B72 /* cursor-usage-summary.json in Resources */, ); @@ -294,9 +307,11 @@ files = ( EE77658E03D7D2B29ECEE8E2 /* AIMenubarUsageApp.swift in Sources */, DBB881F7D74D8AF0A56987F3 /* AppSettings.swift in Sources */, + C5DCA64163D93004462D13C3 /* ChatGPTProvider.swift in Sources */, 84E50E6BE603EFED0E82E96A /* ClaudeAuthService.swift in Sources */, C9CCA6B2E3EF24D42A7F96C2 /* ClaudeCLIUsageService.swift in Sources */, 80377D42C74851D9C7CF9DDC /* ClaudeProvider.swift in Sources */, + B3CC65806679B0201BD2CAFA /* CodexCLIUsageService.swift in Sources */, 020588A97FA4F49E70833A88 /* CursorAuthService.swift in Sources */, 9226F7526F0F46F2265AE091 /* CursorProvider.swift in Sources */, E80856A5BCA4400F795AB66F /* KeychainService.swift in Sources */, @@ -320,6 +335,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 605967B2F70578393027499F /* ChatGPTProviderTests.swift in Sources */, 0FDD856746B2EDD9346A380D /* ClaudeAuthTests.swift in Sources */, 9C03E35D6CC3AE953BEB3EF7 /* ClaudeCLIParserTests.swift in Sources */, C5715AEDEB19F7B36298B0FC /* ClaudeProviderTests.swift in Sources */, diff --git a/AIMenubarUsage/Models/AppSettings.swift b/AIMenubarUsage/Models/AppSettings.swift index a85d94b..bd818c8 100644 --- a/AIMenubarUsage/Models/AppSettings.swift +++ b/AIMenubarUsage/Models/AppSettings.swift @@ -23,6 +23,8 @@ enum AuthMode: String, Codable, CaseIterable, Identifiable, Sendable { "Claude Code CLI" case (.auto, .cursor): "Auto-detect (Cursor IDE)" + case (.auto, .chatgpt): + "Auto-detect (Codex CLI)" default: displayName } @@ -127,7 +129,7 @@ final class AppSettings: ObservableObject { private init() { if let data = UserDefaults.standard.data(forKey: Keys.providerSettings), let decoded = try? JSONDecoder().decode([ProviderID: ProviderSettings].self, from: data) { - providerSettings = decoded + providerSettings = Self.addingProvidersMissing(from: decoded) } else { providerSettings = Dictionary( uniqueKeysWithValues: ProviderID.allCases.map { ($0, ProviderSettings.defaults(for: $0)) } @@ -199,6 +201,18 @@ final class AppSettings: ObservableObject { return enabledCount == 0 } + /// Providers introduced after a user's settings were saved start disabled, so upgrading + /// never adds an unconfigured indicator to an existing menu bar. + private static func addingProvidersMissing(from stored: [ProviderID: ProviderSettings]) -> [ProviderID: ProviderSettings] { + var merged = stored + for providerID in ProviderID.allCases where merged[providerID] == nil { + var defaults = ProviderSettings.defaults(for: providerID) + defaults.enabled = false + merged[providerID] = defaults + } + return merged + } + private func ensureAtLeastOneProviderEnabled() { guard enabledProviders.isEmpty else { return } diff --git a/AIMenubarUsage/Models/ProviderID.swift b/AIMenubarUsage/Models/ProviderID.swift index 7217b0d..9c51869 100644 --- a/AIMenubarUsage/Models/ProviderID.swift +++ b/AIMenubarUsage/Models/ProviderID.swift @@ -3,6 +3,7 @@ import Foundation enum ProviderID: String, Codable, CaseIterable, Identifiable, Sendable { case cursor case claude + case chatgpt var id: String { rawValue } @@ -10,6 +11,15 @@ enum ProviderID: String, Codable, CaseIterable, Identifiable, Sendable { switch self { case .cursor: "Cursor" case .claude: "Claude" + case .chatgpt: "ChatGPT" + } + } + + /// Codex owns its own credentials, so ChatGPT has nothing for this app to store. + var supportedAuthModes: [AuthMode] { + switch self { + case .cursor, .claude: AuthMode.allCases + case .chatgpt: [.auto] } } @@ -17,6 +27,7 @@ enum ProviderID: String, Codable, CaseIterable, Identifiable, Sendable { switch self { case .cursor: "Cu" case .claude: "C" + case .chatgpt: "GPT" } } } diff --git a/AIMenubarUsage/Models/UsageMetric.swift b/AIMenubarUsage/Models/UsageMetric.swift index b178cb7..f4bce13 100644 --- a/AIMenubarUsage/Models/UsageMetric.swift +++ b/AIMenubarUsage/Models/UsageMetric.swift @@ -31,6 +31,13 @@ enum UsageMetric: String, Codable, CaseIterable, Identifiable, Sendable { case cursorAdminSpendCents case cursorAdminMemberCount + // ChatGPT (Codex CLI) + case chatgptFiveHourUtilization + case chatgptFiveHourResetsAt + case chatgptWeeklyUtilization + case chatgptWeeklyResetsAt + case chatgptPlanType + var id: String { rawValue } var providerID: ProviderID { @@ -45,6 +52,9 @@ enum UsageMetric: String, Codable, CaseIterable, Identifiable, Sendable { .claudeSevenDaySonnetUtilization, .claudeAdminInputTokens, .claudeAdminOutputTokens, .claudeAdminCostUSD: .claude + case .chatgptFiveHourUtilization, .chatgptFiveHourResetsAt, .chatgptWeeklyUtilization, + .chatgptWeeklyResetsAt, .chatgptPlanType: + .chatgpt } } @@ -72,6 +82,11 @@ enum UsageMetric: String, Codable, CaseIterable, Identifiable, Sendable { case .claudeAdminCostUSD: "Cost USD (7d)" case .cursorAdminSpendCents: "Team spend (cycle)" case .cursorAdminMemberCount: "Team members" + case .chatgptFiveHourUtilization: "5-hour session %" + case .chatgptFiveHourResetsAt: "5-hour reset" + case .chatgptWeeklyUtilization: "Weekly usage %" + case .chatgptWeeklyResetsAt: "Weekly reset" + case .chatgptPlanType: "Plan" } } @@ -98,6 +113,15 @@ enum UsageMetric: String, Codable, CaseIterable, Identifiable, Sendable { .claudeSevenDayUtilization, .claudeSevenDayResetsAt, ] + case (.chatgpt, .menuBar): + return [.chatgptFiveHourUtilization] + case (.chatgpt, .popover): + return [ + .chatgptFiveHourUtilization, + .chatgptFiveHourResetsAt, + .chatgptWeeklyUtilization, + .chatgptWeeklyResetsAt, + ] } } @@ -111,6 +135,8 @@ enum UsageMetric: String, Codable, CaseIterable, Identifiable, Sendable { return [.claudeAdminInputTokens, .claudeAdminOutputTokens, .claudeAdminCostUSD] case (.claude, _): return allCases.filter { $0.providerID == .claude && !$0.rawValue.hasPrefix("claudeAdmin") } + case (.chatgpt, _): + return allCases.filter { $0.providerID == .chatgpt } } } } diff --git a/AIMenubarUsage/Providers/ChatGPTProvider.swift b/AIMenubarUsage/Providers/ChatGPTProvider.swift new file mode 100644 index 0000000..80b8231 --- /dev/null +++ b/AIMenubarUsage/Providers/ChatGPTProvider.swift @@ -0,0 +1,36 @@ +import Foundation + +struct ChatGPTProvider: UsageProvider { + let id: ProviderID = .chatgpt + let displayName = "ChatGPT" + + func resolveCredentials(authMode: AuthMode) throws -> AuthCredentials { + .codexCLI + } + + func fetchUsage(credentials: AuthCredentials, cache: UsageCache) async throws -> ProviderUsageSnapshot { + guard case .codexCLI = credentials else { + throw CodexCLIError.invalidResponse + } + + if let cached = await cache.load(provider: .chatgpt, maxAge: 300), + let parsed = try? JSONDecoder().decode(CodexRateLimitSnapshot.self, from: cached.payload) { + return CodexCLIUsageService.snapshot(from: parsed, fetchedAt: cached.fetchedAt, isStale: false) + } + + do { + let output = try await CodexCLIUsageService.runRateLimitsRequest() + let parsed = try CodexCLIUsageService.parseRateLimitsResponse(output) + if let data = try? JSONEncoder().encode(parsed) { + await cache.save(provider: .chatgpt, payload: data) + } + return CodexCLIUsageService.snapshot(from: parsed, fetchedAt: Date(), isStale: false) + } catch { + if let stale = await cache.loadStale(provider: .chatgpt), + let parsed = try? JSONDecoder().decode(CodexRateLimitSnapshot.self, from: stale.payload) { + return CodexCLIUsageService.snapshot(from: parsed, fetchedAt: stale.fetchedAt, isStale: true) + } + throw error + } + } +} diff --git a/AIMenubarUsage/Providers/UsageProvider.swift b/AIMenubarUsage/Providers/UsageProvider.swift index a13af6f..dae6b54 100644 --- a/AIMenubarUsage/Providers/UsageProvider.swift +++ b/AIMenubarUsage/Providers/UsageProvider.swift @@ -6,6 +6,7 @@ enum AuthCredentials: Sendable { case claudeCLI case claudeOAuth(accessToken: String) case claudeAdminAPI(apiKey: String) + case codexCLI } protocol UsageProvider: Sendable { @@ -14,3 +15,13 @@ protocol UsageProvider: Sendable { func fetchUsage(credentials: AuthCredentials, cache: UsageCache) async throws -> ProviderUsageSnapshot func resolveCredentials(authMode: AuthMode) throws -> AuthCredentials } + +extension ProviderID { + var provider: any UsageProvider { + switch self { + case .cursor: return CursorProvider() + case .claude: return ClaudeProvider() + case .chatgpt: return ChatGPTProvider() + } + } +} diff --git a/AIMenubarUsage/Resources/Assets.xcassets/ProviderChatGPT.imageset/Contents.json b/AIMenubarUsage/Resources/Assets.xcassets/ProviderChatGPT.imageset/Contents.json new file mode 100644 index 0000000..bdd1452 --- /dev/null +++ b/AIMenubarUsage/Resources/Assets.xcassets/ProviderChatGPT.imageset/Contents.json @@ -0,0 +1,32 @@ +{ + "images": [ + { + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ], + "filename": "ProviderChatGPT.svg", + "idiom": "mac" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "filename": "ProviderChatGPTDark.svg", + "idiom": "mac" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "original" + } +} diff --git a/AIMenubarUsage/Resources/Assets.xcassets/ProviderChatGPT.imageset/ProviderChatGPT.svg b/AIMenubarUsage/Resources/Assets.xcassets/ProviderChatGPT.imageset/ProviderChatGPT.svg new file mode 100644 index 0000000..785e0c0 --- /dev/null +++ b/AIMenubarUsage/Resources/Assets.xcassets/ProviderChatGPT.imageset/ProviderChatGPT.svg @@ -0,0 +1 @@ +ChatGPT \ No newline at end of file diff --git a/AIMenubarUsage/Resources/Assets.xcassets/ProviderChatGPT.imageset/ProviderChatGPTDark.svg b/AIMenubarUsage/Resources/Assets.xcassets/ProviderChatGPT.imageset/ProviderChatGPTDark.svg new file mode 100644 index 0000000..6d5184d --- /dev/null +++ b/AIMenubarUsage/Resources/Assets.xcassets/ProviderChatGPT.imageset/ProviderChatGPTDark.svg @@ -0,0 +1 @@ +ChatGPT \ No newline at end of file diff --git a/AIMenubarUsage/Services/CodexCLIUsageService.swift b/AIMenubarUsage/Services/CodexCLIUsageService.swift new file mode 100644 index 0000000..16877c1 --- /dev/null +++ b/AIMenubarUsage/Services/CodexCLIUsageService.swift @@ -0,0 +1,327 @@ +import Foundation + +enum CodexCLIError: Error, LocalizedError, Equatable { + case cliNotFound + case commandFailed(String) + case invalidResponse + case notAuthenticated + + var errorDescription: String? { + switch self { + case .cliNotFound: + "Codex CLI not found. Install it from developers.openai.com/codex." + case .commandFailed(let details): + "Codex CLI failed: \(details)" + case .invalidResponse: + "Could not parse usage output from Codex CLI." + case .notAuthenticated: + "Codex CLI is not signed in. Run `codex login` in Terminal." + } + } +} + +struct CodexRateLimitWindow: Codable, Sendable { + let usedPercent: Double? + let windowDurationMins: Int? + /// Absolute reset, Unix seconds. Newer CLIs send this; older ones send a countdown instead. + let resetsAt: Double? + let resetsInSeconds: Double? + + func resetDate(relativeTo fetchedAt: Date) -> Date? { + if let resetsAt, resetsAt > 0 { + return Date(timeIntervalSince1970: resetsAt) + } + if let resetsInSeconds { + return fetchedAt.addingTimeInterval(resetsInSeconds) + } + return nil + } +} + +struct CodexRateLimits: Codable, Sendable { + let primary: CodexRateLimitWindow? + let secondary: CodexRateLimitWindow? +} + +struct CodexRateLimitSnapshot: Codable, Sendable { + let rateLimits: CodexRateLimits? + let planType: String? + + enum CodingKeys: String, CodingKey { + case rateLimits + case planType + } + + /// app-server is documented as experimental, so tolerate envelope drift: accept the + /// windows wrapped in `rateLimits` or inlined at the root. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + planType = try container.decodeIfPresent(String.self, forKey: .planType) + if let wrapped = try container.decodeIfPresent(CodexRateLimits.self, forKey: .rateLimits) { + rateLimits = wrapped + } else { + rateLimits = try? CodexRateLimits(from: decoder) + } + } +} + +/// Reads Codex usage through `codex app-server`, the CLI's stdio JSON-RPC interface, so the +/// CLI owns authentication end to end. This app never reads, stores or forwards Codex +/// credentials — which also means it works regardless of where Codex keeps them. +enum CodexCLIUsageService { + private static let timeoutSeconds: TimeInterval = 30 + private static let rateLimitsRequestID = 1 + + static func resolveCodexExecutable() -> String { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let candidates = [ + "\(home)/.local/bin/codex", + "/opt/homebrew/bin/codex", + "/usr/local/bin/codex", + ] + + for candidate in candidates where FileManager.default.isExecutableFile(atPath: candidate) { + return candidate + } + + return "codex" + } + + static func fetchUsage() async throws -> ProviderUsageSnapshot { + let output = try await runRateLimitsRequest() + let parsed = try parseRateLimitsResponse(output) + return snapshot(from: parsed, fetchedAt: Date(), isStale: false) + } + + static func runRateLimitsRequest() async throws -> String { + let codexPath = resolveCodexExecutable() + let process = Process() + // A bare name has to go through `env`; a file URL built from it would resolve + // against the working directory instead of PATH. + let needsPathLookup = !codexPath.hasPrefix("/") + process.executableURL = URL(fileURLWithPath: needsPathLookup ? "/usr/bin/env" : codexPath) + process.arguments = needsPathLookup ? [codexPath, "app-server"] : ["app-server"] + process.environment = environmentWithCLIPaths(for: codexPath) + + let input = Pipe() + let outputPipe = Pipe() + let errorPipe = Pipe() + process.standardInput = input + process.standardOutput = outputPipe + process.standardError = errorPipe + + // Drain both pipes as they fill: a stdio server blocks once a pipe buffer is full, + // and reading only after exit would deadlock a server that outlives its stdin. + let stdout = OutputCollector() + let stderr = OutputCollector() + outputPipe.fileHandleForReading.readabilityHandler = { stdout.append($0.availableData) } + errorPipe.fileHandleForReading.readabilityHandler = { stderr.append($0.availableData) } + defer { + outputPipe.fileHandleForReading.readabilityHandler = nil + errorPipe.fileHandleForReading.readabilityHandler = nil + if process.isRunning { process.terminate() } + } + + do { + try process.run() + } catch { + throw CodexCLIError.cliNotFound + } + + write(handshakeMessages, to: input) + // The server drops requests that arrive before the handshake settles. + try? await Task.sleep(nanoseconds: 500_000_000) + write([rateLimitsMessage], to: input) + try? input.fileHandleForWriting.close() + + let deadline = Date().addingTimeInterval(timeoutSeconds) + while true { + let text = stdout.text + if containsRateLimitsResponse(text) { return text } + if !process.isRunning || Date() >= deadline { break } + try await Task.sleep(nanoseconds: 100_000_000) + } + + // Give the readers a moment to drain whatever landed just before the process exited. + try? await Task.sleep(nanoseconds: 200_000_000) + let text = stdout.text + if containsRateLimitsResponse(text) { return text } + + throw failure(stdout: text, stderr: stderr.text, process: process, codexPath: codexPath) + } + + private static func environmentWithCLIPaths(for codexPath: String) -> [String: String] { + var environment = ProcessInfo.processInfo.environment + let home = FileManager.default.homeDirectoryForCurrentUser.path + var extraPaths = [ + "\(home)/.local/bin", + "/opt/homebrew/bin", + "/usr/local/bin", + ] + if codexPath.hasPrefix("/") { + extraPaths.insert(URL(fileURLWithPath: codexPath).deletingLastPathComponent().path, at: 0) + } + let existingPath = environment["PATH"] ?? "/usr/bin:/bin" + environment["PATH"] = (extraPaths + [existingPath]).joined(separator: ":") + return environment + } + + private static func failure( + stdout: String, + stderr: String, + process: Process, + codexPath: String + ) -> CodexCLIError { + let details = stderr.trimmingCharacters(in: .whitespacesAndNewlines) + + if details.localizedCaseInsensitiveContains("not logged in") + || details.localizedCaseInsensitiveContains("not authenticated") + || details.localizedCaseInsensitiveContains("codex login") { + return .notAuthenticated + } + if codexPath == "codex", + details.contains("not found") || details.contains("No such file") { + return .cliNotFound + } + if process.isRunning { + return .commandFailed("no response after \(Int(timeoutSeconds))s — \(diagnostic(stdout: stdout, stderr: details))") + } + return .commandFailed("exit \(process.terminationStatus) — \(diagnostic(stdout: stdout, stderr: details))") + } + + /// Shown in the popover, so keep it short — but specific enough to act on in a bug report. + private static func diagnostic(stdout: String, stderr: String) -> String { + let lastLine = stdout + .components(separatedBy: .newlines) + .last { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + + if !stderr.isEmpty { + return String(stderr.suffix(200)) + } + if let lastLine { + return "last app-server line: \(lastLine.prefix(200))" + } + return "no output from `codex app-server`" + } + + private static func containsRateLimitsResponse(_ output: String) -> Bool { + message(withID: rateLimitsRequestID, in: output) != nil + } + + static func parseRateLimitsResponse(_ output: String) throws -> CodexRateLimitSnapshot { + guard let message = message(withID: rateLimitsRequestID, in: output) else { + throw CodexCLIError.invalidResponse + } + + if let rpcError = message["error"] as? [String: Any] { + let details = rpcError["message"] as? String ?? "unknown app-server error" + if details.localizedCaseInsensitiveContains("auth") + || details.localizedCaseInsensitiveContains("login") { + throw CodexCLIError.notAuthenticated + } + throw CodexCLIError.commandFailed(details) + } + + guard let result = message["result"], + let resultData = try? JSONSerialization.data(withJSONObject: result), + let parsed = try? JSONDecoder().decode(CodexRateLimitSnapshot.self, from: resultData) + else { + throw CodexCLIError.invalidResponse + } + return parsed + } + + private static func message(withID id: Int, in output: String) -> [String: Any]? { + for line in output.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, + let data = trimmed.data(using: .utf8), + let message = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + intValue(message["id"]) == id + else { + continue + } + return message + } + return nil + } + + static func snapshot(from parsed: CodexRateLimitSnapshot, fetchedAt: Date, isStale: Bool) -> ProviderUsageSnapshot { + var values: [UsageMetric: MetricValue] = [:] + + func apply(_ window: CodexRateLimitWindow?, percent: UsageMetric, reset: UsageMetric) { + guard let window else { return } + if let usedPercent = window.usedPercent { + values[percent] = .percent(usedPercent) + } + if let resetDate = window.resetDate(relativeTo: fetchedAt) { + values[reset] = .date(resetDate) + } + } + + apply( + parsed.rateLimits?.primary, + percent: .chatgptFiveHourUtilization, + reset: .chatgptFiveHourResetsAt + ) + apply( + parsed.rateLimits?.secondary, + percent: .chatgptWeeklyUtilization, + reset: .chatgptWeeklyResetsAt + ) + + if let planType = parsed.planType, !planType.isEmpty { + values[.chatgptPlanType] = .text(planType) + } + + return ProviderUsageSnapshot( + providerID: .chatgpt, + fetchedAt: fetchedAt, + values: values, + isStale: isStale + ) + } + + /// app-server omits the `"jsonrpc": "2.0"` header on the wire, and gates part of its + /// surface behind the `experimentalApi` capability. + private static var handshakeMessages: [String] { + [ + #"{"id":0,"method":"initialize","params":{"clientInfo":{"name":"ai-menubar-usage","title":"AI Menubar Usage","version":"1.0.0"},"capabilities":{"experimentalApi":true}}}"#, + #"{"method":"initialized","params":{}}"#, + ] + } + + private static var rateLimitsMessage: String { + #"{"id":\#(rateLimitsRequestID),"method":"account/rateLimits/read","params":{}}"# + } + + private static func write(_ messages: [String], to pipe: Pipe) { + let payload = messages.map { $0 + "\n" }.joined() + try? pipe.fileHandleForWriting.write(contentsOf: Data(payload.utf8)) + } + + private static func intValue(_ value: Any?) -> Int? { + if let number = value as? NSNumber { return number.intValue } + if let string = value as? String { return Int(string) } + return nil + } +} + +/// `readabilityHandler` fires on a background queue, so the buffer needs its own lock. +private final class OutputCollector: @unchecked Sendable { + private let lock = NSLock() + private var data = Data() + + func append(_ chunk: Data) { + guard !chunk.isEmpty else { return } + lock.lock() + data.append(chunk) + lock.unlock() + } + + var text: String { + lock.lock() + defer { lock.unlock() } + return String(data: data, encoding: .utf8) ?? "" + } +} diff --git a/AIMenubarUsage/Services/UsageRefreshService.swift b/AIMenubarUsage/Services/UsageRefreshService.swift index 46806aa..73bd5e2 100644 --- a/AIMenubarUsage/Services/UsageRefreshService.swift +++ b/AIMenubarUsage/Services/UsageRefreshService.swift @@ -11,10 +11,6 @@ final class UsageRefreshService: ObservableObject { private let settings = AppSettings.shared private let cache = UsageCache.shared - private let providers: [ProviderID: any UsageProvider] = [ - .cursor: CursorProvider(), - .claude: ClaudeProvider(), - ] private var refreshTask: Task? private var timerCancellable: AnyCancellable? @@ -65,10 +61,7 @@ final class UsageRefreshService: ObservableObject { } private func fetchProvider(_ providerID: ProviderID) async -> (ProviderID, ProviderDisplayState) { - guard let provider = providers[providerID] else { - return (providerID, ProviderDisplayState.failure(providerID, message: "Unknown provider")) - } - + let provider = providerID.provider let providerSettings = settings.settings(for: providerID) do { diff --git a/AIMenubarUsage/Views/ProviderIcon.swift b/AIMenubarUsage/Views/ProviderIcon.swift index 787720c..4a1893d 100644 --- a/AIMenubarUsage/Views/ProviderIcon.swift +++ b/AIMenubarUsage/Views/ProviderIcon.swift @@ -6,6 +6,7 @@ enum ProviderIconAsset { switch provider { case .cursor: "ProviderCursor" case .claude: "ProviderClaude" + case .chatgpt: "ProviderChatGPT" } } } diff --git a/AIMenubarUsage/Views/SettingsView.swift b/AIMenubarUsage/Views/SettingsView.swift index db88660..4f91bb6 100644 --- a/AIMenubarUsage/Views/SettingsView.swift +++ b/AIMenubarUsage/Views/SettingsView.swift @@ -37,15 +37,15 @@ struct ProviderSettingsTab: View { } } - if providerID == .claude, providerSettings.authMode == .auto { - Text("Runs `claude -p \"/usage\"` so the CLI handles authentication. No Keychain access from this app.") + if providerSettings.authMode == .auto, let autoModeHelp { + Text(autoModeHelp) .font(.caption) .foregroundStyle(.secondary) } - if providerSettings.authMode == .manualSession { + if providerSettings.authMode == .manualSession, let manualTokenHelp { SecureField("Session token", text: $manualToken) - Text("For Cursor: WorkosCursorSessionToken value. For Claude: OAuth access token.") + Text(manualTokenHelp) .font(.caption) .foregroundStyle(.secondary) Button("Save Token") { @@ -53,7 +53,7 @@ struct ProviderSettingsTab: View { } } - if providerSettings.authMode == .adminAPI { + if providerSettings.authMode == .adminAPI, let adminAPIHelp { SecureField("Admin API key", text: $adminAPIKey) Text(adminAPIHelp) .font(.caption) @@ -103,15 +103,39 @@ struct ProviderSettingsTab: View { } private var availableAuthModes: [AuthMode] { - AuthMode.allCases + providerID.supportedAuthModes } - private var adminAPIHelp: String { + private var autoModeHelp: String? { switch providerID { case .cursor: - "Cursor Admin API key with admin:* scope from cursor.com/dashboard/api" + return nil case .claude: - "Anthropic Admin API key (sk-ant-admin01-...) from console.anthropic.com" + return "Runs `claude -p \"/usage\"` so the CLI handles authentication. No Keychain access from this app." + case .chatgpt: + return "Runs `codex app-server` so the CLI handles authentication. No Keychain access from this app." + } + } + + private var manualTokenHelp: String? { + switch providerID { + case .cursor: + return "WorkosCursorSessionToken cookie value from cursor.com." + case .claude: + return "Claude OAuth access token." + case .chatgpt: + return nil + } + } + + private var adminAPIHelp: String? { + switch providerID { + case .cursor: + return "Cursor Admin API key with admin:* scope from cursor.com/dashboard/api" + case .claude: + return "Anthropic Admin API key (sk-ant-admin01-...) from console.anthropic.com" + case .chatgpt: + return nil } } @@ -156,7 +180,14 @@ struct ProviderSettingsTab: View { } } + /// ChatGPT never stores credentials here, so it must not even query the Keychain. + private var usesKeychain: Bool { + providerID.supportedAuthModes.contains { $0 != .auto } + } + private func loadKeychainValues() { + guard usesKeychain else { return } + let manualAccount = KeychainService.keychainAccount(provider: providerID, authMode: .manualSession) manualToken = KeychainService.load(account: manualAccount) ?? "" @@ -191,7 +222,7 @@ struct ProviderSettingsTab: View { connectionMessage = nil defer { isTesting = false } - let provider: any UsageProvider = providerID == .cursor ? CursorProvider() : ClaudeProvider() + let provider = providerID.provider do { let credentials = try provider.resolveCredentials(authMode: providerSettings.authMode) _ = try await provider.fetchUsage(credentials: credentials, cache: UsageCache.shared) @@ -250,16 +281,16 @@ struct AboutSettingsTab: View { Text("AI Menubar Usage") .font(.title2) .fontWeight(.semibold) - Text("Monitor Claude and Cursor usage from your menu bar.") + Text("Monitor Cursor, Claude and ChatGPT usage from your menu bar.") .foregroundStyle(.secondary) Text("Authentication") .font(.headline) - Text("Claude auto mode shells out to the Claude Code CLI (`claude -p \"/usage\"`), which handles login itself. Cursor auto mode reads the local Cursor IDE session. Manual tokens and Admin API keys are stored in this app's Keychain.") + Text("Claude and ChatGPT shell out to their own CLIs — `claude -p \"/usage\"` and `codex app-server` — which handle login themselves, so this app never sees their credentials. Cursor auto mode reads the local Cursor IDE session. Cursor and Claude manual tokens and Admin API keys are stored in this app's Keychain.") Text("Unofficial APIs") .font(.headline) - Text("Cursor dashboard and Claude OAuth usage endpoints are unofficial and may change without notice. Admin APIs require organization or team accounts.") + Text("The Cursor dashboard and Claude OAuth usage endpoints are unofficial and may change without notice. Codex's `app-server` interface is experimental and its fields may change between CLI releases. Admin APIs require organization or team accounts.") Spacer() } @@ -277,11 +308,10 @@ struct SettingsView: View { GeneralSettingsTab(settings: settings, refreshService: refreshService) .tabItem { Label("General", systemImage: "gearshape") } - ProviderSettingsTab(providerID: .cursor, settings: settings, refreshService: refreshService) - .tabItem { Label("Cursor", image: ProviderIconAsset.name(for: .cursor)) } - - ProviderSettingsTab(providerID: .claude, settings: settings, refreshService: refreshService) - .tabItem { Label("Claude", image: ProviderIconAsset.name(for: .claude)) } + ForEach(ProviderID.allCases) { providerID in + ProviderSettingsTab(providerID: providerID, settings: settings, refreshService: refreshService) + .tabItem { Label(providerID.displayName, image: ProviderIconAsset.name(for: providerID)) } + } AboutSettingsTab() .tabItem { Label("About", systemImage: "info.circle") } diff --git a/AIMenubarUsageTests/ChatGPTProviderTests.swift b/AIMenubarUsageTests/ChatGPTProviderTests.swift new file mode 100644 index 0000000..db0d6f8 --- /dev/null +++ b/AIMenubarUsageTests/ChatGPTProviderTests.swift @@ -0,0 +1,88 @@ +import XCTest +@testable import AIMenubarUsage + +final class ChatGPTProviderTests: XCTestCase { + func testParseRateLimitsResponseFromAppServerOutput() throws { + let output = try appServerOutput() + let parsed = try CodexCLIUsageService.parseRateLimitsResponse(output) + + XCTAssertEqual(parsed.planType, "plus") + XCTAssertEqual(parsed.rateLimits?.primary?.usedPercent ?? 0, 42, accuracy: 0.001) + XCTAssertEqual(parsed.rateLimits?.primary?.windowDurationMins, 300) + XCTAssertEqual(parsed.rateLimits?.secondary?.windowDurationMins, 10_080) + } + + func testParseSkipsHandshakeAndNotificationLines() throws { + let noise = """ + {"id":0,"result":{"userAgent":"codex"}} + {"method":"someNotification","params":{}} + """ + let output = try appServerOutput() + let parsed = try CodexCLIUsageService.parseRateLimitsResponse(noise + "\n" + output) + + XCTAssertEqual(parsed.planType, "plus") + } + + func testSnapshotMapping() throws { + let output = try appServerOutput() + let parsed = try CodexCLIUsageService.parseRateLimitsResponse(output) + let snapshot = CodexCLIUsageService.snapshot(from: parsed, fetchedAt: Date(), isStale: false) + + XCTAssertEqual(snapshot.providerID, .chatgpt) + XCTAssertEqual(snapshot.formatted(.chatgptFiveHourUtilization), "42%") + XCTAssertEqual(snapshot.formatted(.chatgptWeeklyUtilization), "15%") + XCTAssertEqual(snapshot.formatted(.chatgptPlanType), "plus") + XCTAssertEqual( + snapshot.value(for: .chatgptFiveHourResetsAt), + MetricValue.date(Date(timeIntervalSince1970: 1_788_265_323)) + ) + } + + func testCountdownResetIsResolvedAgainstFetchTime() throws { + let output = #"{"id":1,"result":{"rateLimits":{"primary":{"usedPercent":7,"resetsInSeconds":3600}}}}"# + let fetchedAt = Date(timeIntervalSince1970: 1_700_000_000) + + let parsed = try CodexCLIUsageService.parseRateLimitsResponse(output) + let snapshot = CodexCLIUsageService.snapshot(from: parsed, fetchedAt: fetchedAt, isStale: false) + + XCTAssertEqual(snapshot.formatted(.chatgptFiveHourUtilization), "7%") + XCTAssertEqual( + snapshot.value(for: .chatgptFiveHourResetsAt), + MetricValue.date(fetchedAt.addingTimeInterval(3600)) + ) + } + + func testParseAcceptsWindowsInlinedAtRoot() throws { + let output = #"{"id":1,"result":{"primary":{"usedPercent":20},"secondary":{"usedPercent":55}}}"# + + let parsed = try CodexCLIUsageService.parseRateLimitsResponse(output) + let snapshot = CodexCLIUsageService.snapshot(from: parsed, fetchedAt: Date(), isStale: false) + + XCTAssertEqual(snapshot.formatted(.chatgptFiveHourUtilization), "20%") + XCTAssertEqual(snapshot.formatted(.chatgptWeeklyUtilization), "55%") + XCTAssertNil(snapshot.value(for: .chatgptPlanType)) + } + + func testRPCErrorAboutLoginSurfacesAsNotAuthenticated() { + let output = #"{"id":1,"error":{"code":-32000,"message":"not logged in; run codex login"}}"# + + XCTAssertThrowsError(try CodexCLIUsageService.parseRateLimitsResponse(output)) { error in + XCTAssertEqual(error as? CodexCLIError, CodexCLIError.notAuthenticated) + } + } + + func testMissingResponseThrows() { + XCTAssertThrowsError(try CodexCLIUsageService.parseRateLimitsResponse("not json\n")) + } + + /// The fixture is pretty-printed for review; app-server emits one JSON object per line. + private func appServerOutput() throws -> String { + let url = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures") + .appendingPathComponent("chatgpt-codex-usage.json") + let object = try JSONSerialization.jsonObject(with: Data(contentsOf: url)) + let compact = try JSONSerialization.data(withJSONObject: object) + return try XCTUnwrap(String(data: compact, encoding: .utf8)) + } +} diff --git a/AIMenubarUsageTests/Fixtures/chatgpt-codex-usage.json b/AIMenubarUsageTests/Fixtures/chatgpt-codex-usage.json new file mode 100644 index 0000000..7d60898 --- /dev/null +++ b/AIMenubarUsageTests/Fixtures/chatgpt-codex-usage.json @@ -0,0 +1,18 @@ +{ + "id": 1, + "result": { + "planType": "plus", + "rateLimits": { + "primary": { + "usedPercent": 42, + "windowDurationMins": 300, + "resetsAt": 1788265323 + }, + "secondary": { + "usedPercent": 15, + "windowDurationMins": 10080, + "resetsAt": 1788765541 + } + } + } +} diff --git a/README.md b/README.md index d3de8d5..3ef6258 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ AI Menubar Usage in the macOS menu bar showing Cursor and Claude usage -A native macOS menu bar app for **Cursor** and **Claude** usage at a glance. +A native macOS menu bar app for **Cursor**, **Claude** and **ChatGPT** usage at a glance. @@ -12,7 +12,7 @@ A native macOS menu bar app for **Cursor** and **Claude** usage at a glance. - Per-provider menu bar indicators with usage percentages - Popover with a detailed breakdown per provider -- Auto-detect auth from Cursor IDE and Claude Code CLI +- Auto-detect auth from Cursor IDE, Claude Code CLI and Codex CLI - Manual tokens or Admin API keys (Keychain) - Configurable metrics, refresh interval, and appearance @@ -20,7 +20,7 @@ A native macOS menu bar app for **Cursor** and **Claude** usage at a glance. - macOS 14+ - Xcode 15+ -- [Cursor](https://cursor.com) signed in, and/or [Claude Code CLI](https://claude.ai/code) with `claude auth login` +- Any of: [Cursor](https://cursor.com) signed in, [Claude Code CLI](https://claude.ai/code) with `claude auth login`, [Codex CLI](https://developers.openai.com/codex/cli) with `codex login` ## Build & Run @@ -34,14 +34,20 @@ First launch from an unsigned build: right-click the app → **Open**, or `xattr ## Authentication -| Provider | Auto | Manual | -| ---------- | ---------------------------------------- | ------------------------------ | -| **Cursor** | Session from local Cursor IDE database | Session token or Admin API key | -| **Claude** | `claude -p "/usage"` via Claude Code CLI | OAuth token or Admin API key | +| Provider | Auto | Manual | +| ----------- | ---------------------------------------- | ------------------------------- | +| **Cursor** | Session from local Cursor IDE database | Session token or Admin API key | +| **Claude** | `claude -p "/usage"` via Claude Code CLI | OAuth token or Admin API key | +| **ChatGPT** | `codex app-server` via Codex CLI | — (Codex owns its credentials) | + +ChatGPT usage comes entirely from the Codex CLI: the app shells out to `codex app-server` and never reads, +stores or forwards Codex credentials, so it works wherever Codex keeps them. + +Providers added in an app update start disabled — enable them in **Settings → \**. ## Disclaimer -Cursor and Claude usage endpoints used in auto mode are unofficial and may change. Not affiliated with Anthropic or Cursor. +The Cursor and Claude usage endpoints used in auto mode are unofficial and may change. Codex's `app-server` interface is experimental and its fields may change between CLI releases. Not affiliated with Anthropic, Cursor or OpenAI. ## License