FMG-11: Add ChatGPT as a third usage provider #1

Open
butterrobot wants to merge 3 commits from butterrobot/fmg-11-chatgpt-provider into main AGit
16 changed files with 646 additions and 35 deletions

View file

@ -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 = "<group>"; };
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 = "<group>"; };
42E5EC443BF74037D24F7578 /* chatgpt-codex-usage.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "chatgpt-codex-usage.json"; sourceTree = "<group>"; };
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 = "<group>"; };
5CAAFDF70292C8F5EBF0B950 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = "<group>"; };
@ -72,6 +77,7 @@
93B331C96A907C7CB9172650 /* MenuBarDisplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarDisplayTests.swift; sourceTree = "<group>"; };
952E850263D70B58A8622D84 /* CursorAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CursorAuthService.swift; sourceTree = "<group>"; };
A5CAC3724673DBDC415CB9C1 /* cursor-usage-summary.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "cursor-usage-summary.json"; sourceTree = "<group>"; };
A6E870E40099D74E699A3A31 /* ChatGPTProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatGPTProviderTests.swift; sourceTree = "<group>"; };
AA51A975D1EFCACAD5591EF2 /* ClaudeAuthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeAuthTests.swift; sourceTree = "<group>"; };
B0F6E6ACBDD186D4701E4B63 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
B1A929A7D2E06CA26E801EE5 /* ProviderUsageSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderUsageSnapshot.swift; sourceTree = "<group>"; };
@ -81,13 +87,16 @@
DE93F3CBB065D23A36D4BE3D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
E0F49A20AA99ADBF5FA32D5A /* ClaudeCLIParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeCLIParserTests.swift; sourceTree = "<group>"; };
E57FF9519DD9DADBAA366503 /* UsageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageCache.swift; sourceTree = "<group>"; };
EBE841AA7311B637A59E5893 /* CodexCLIUsageService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexCLIUsageService.swift; sourceTree = "<group>"; };
F0E6E438C251CE8879D29C39 /* KeychainService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainService.swift; sourceTree = "<group>"; };
FF55E59C2BD1F3154CAFCDC0 /* ChatGPTProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatGPTProvider.swift; sourceTree = "<group>"; };
/* 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 */,

View file

@ -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 }

View file

@ -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"
}
}
}

View file

@ -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 }
}
}
}

View file

@ -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
}
}
}

View file

@ -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()
}
}
}

View file

@ -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"
}
}

View file

@ -0,0 +1 @@
<svg fill="#000000" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>ChatGPT</title><path d="M22.282 9.821a6 6 0 0 0-.516-4.91a6.05 6.05 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a6 6 0 0 0-3.998 2.9a6.05 6.05 0 0 0 .743 7.097a5.98 5.98 0 0 0 .51 4.911a6.05 6.05 0 0 0 6.515 2.9A6 6 0 0 0 13.26 24a6.06 6.06 0 0 0 5.772-4.206a6 6 0 0 0 3.997-2.9a6.06 6.06 0 0 0-.747-7.073M13.26 22.43a4.48 4.48 0 0 1-2.876-1.04l.141-.081l4.779-2.758a.8.8 0 0 0 .392-.681v-6.737l2.02 1.168a.07.07 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494M3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085l4.783 2.759a.77.77 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646M2.34 7.896a4.5 4.5 0 0 1 2.366-1.973V11.6a.77.77 0 0 0 .388.677l5.815 3.354l-2.02 1.168a.08.08 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.08.08 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667m2.01-3.023l-.141-.085l-4.774-2.782a.78.78 0 0 0-.785 0L9.409 9.23V6.897a.07.07 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.8.8 0 0 0-.393.681zm1.097-2.365l2.602-1.5l2.607 1.5v2.999l-2.597 1.5l-2.607-1.5Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1 @@
<svg fill="#FFFFFF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>ChatGPT</title><path d="M22.282 9.821a6 6 0 0 0-.516-4.91a6.05 6.05 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a6 6 0 0 0-3.998 2.9a6.05 6.05 0 0 0 .743 7.097a5.98 5.98 0 0 0 .51 4.911a6.05 6.05 0 0 0 6.515 2.9A6 6 0 0 0 13.26 24a6.06 6.06 0 0 0 5.772-4.206a6 6 0 0 0 3.997-2.9a6.06 6.06 0 0 0-.747-7.073M13.26 22.43a4.48 4.48 0 0 1-2.876-1.04l.141-.081l4.779-2.758a.8.8 0 0 0 .392-.681v-6.737l2.02 1.168a.07.07 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494M3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085l4.783 2.759a.77.77 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646M2.34 7.896a4.5 4.5 0 0 1 2.366-1.973V11.6a.77.77 0 0 0 .388.677l5.815 3.354l-2.02 1.168a.08.08 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.08.08 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667m2.01-3.023l-.141-.085l-4.774-2.782a.78.78 0 0 0-.785 0L9.409 9.23V6.897a.07.07 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.8.8 0 0 0-.393.681zm1.097-2.365l2.602-1.5l2.607 1.5v2.999l-2.597 1.5l-2.607-1.5Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -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) ?? ""
}
}

View file

@ -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<Void, Never>?
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 {

View file

@ -6,6 +6,7 @@ enum ProviderIconAsset {
switch provider {
case .cursor: "ProviderCursor"
case .claude: "ProviderClaude"
case .chatgpt: "ProviderChatGPT"
}
}
}

View file

@ -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") }

View file

@ -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))
}
}

View file

@ -0,0 +1,18 @@
{
"id": 1,
"result": {
"planType": "plus",
"rateLimits": {
"primary": {
"usedPercent": 42,
"windowDurationMins": 300,
"resetsAt": 1788265323
},
"secondary": {
"usedPercent": 15,
"windowDurationMins": 10080,
"resetsAt": 1788765541
}
}
}
}

View file

@ -4,7 +4,7 @@
<img src=".readme/screenshot.png" alt="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.
</div>
@ -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 → \<provider\>**.
## 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