Skip to content
Open
1 change: 1 addition & 0 deletions CLIProxyAPI
Submodule CLIProxyAPI added at 7e9d0d
4 changes: 4 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ let sweetCookieKitDependency: Package.Dependency =

let package = Package(
name: "CodexBar",
defaultLocalization: "en",
platforms: [
.macOS(.v14),
],
Expand All @@ -33,6 +34,9 @@ let package = Package(
.product(name: "Logging", package: "swift-log"),
.product(name: "SweetCookieKit", package: "SweetCookieKit"),
],
resources: [
.process("Resources"),
],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, and JetBrains AI limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar.

Now includes first-class CLIProxyAPI usage paths, plus app language options (System / English / 简体中文).

<img src="codexbar.png" alt="CodexBar menu screenshot" width="520" />

## Install
Expand All @@ -28,14 +30,18 @@ Linux support via Omarchy: community Waybar module and TUI, driven by the `codex
- Open Settings → Providers and enable what you use.
- Install/sign in to the provider sources you rely on (e.g. `codex`, `claude`, `gemini`, browser cookies, or OAuth; Antigravity requires the Antigravity app running).
- Optional: Settings → Providers → Codex → OpenAI cookies (Automatic or Manual) to add dashboard extras.
- Optional: Settings → General → CLIProxyAPI to set base URL, management key, and optional `auth_index`.

## Providers

- [Codex](docs/codex.md) — Local Codex CLI RPC (+ PTY fallback) and optional OpenAI web dashboard extras.
- [CLIProxy Codex](docs/codex.md) — Codex quota via CLIProxyAPI management endpoints, with multi-auth aggregation + per-auth drill-down.
- [Claude](docs/claude.md) — OAuth API or browser cookies (+ CLI PTY fallback); session + weekly usage.
- [Cursor](docs/cursor.md) — Browser session cookies for plan + usage + billing resets.
- [Gemini](docs/gemini.md) — OAuth-backed quota API using Gemini CLI credentials (no browser cookies).
- CLIProxy Gemini — Gemini quota via CLIProxyAPI (`gemini` auth entries).
- [Antigravity](docs/antigravity.md) — Local language server probe (experimental); no external auth.
- CLIProxy Antigravity — Antigravity quota via CLIProxyAPI (`antigravity` auth entries).
- [Droid](docs/factory.md) — Browser cookies + WorkOS token flows for Factory usage + billing.
- [Copilot](docs/copilot.md) — GitHub device flow + Copilot internal usage API.
- [z.ai](docs/zai.md) — API token (Keychain) for quota + MCP windows.
Expand All @@ -58,12 +64,14 @@ The menu bar icon is a tiny two-bar meter:
- Multi-provider menu bar with per-provider toggles (Settings → Providers).
- Session + weekly meters with reset countdowns.
- Optional Codex web dashboard enrichments (code review remaining, usage breakdown, credits history).
- CLIProxyAPI integration for Codex/Gemini/Antigravity with multi-auth support.
- Local cost-usage scan for Codex + Claude (last 30 days).
- Provider status polling with incident badges in the menu and icon overlay.
- Merge Icons mode to combine providers into one status item + switcher.
- Refresh cadence presets (manual, 1m, 2m, 5m, 15m).
- Bundled CLI (`codexbar`) for scripts and CI (including `codexbar cost --provider codex|claude` for local cost usage); Linux CLI builds available.
- WidgetKit widget mirrors the menu card snapshot.
- Built-in i18n language switcher: follow system, English, and Simplified Chinese.
- Privacy-first: on-device parsing by default; browser cookies are opt-in and reused (no passwords stored).

## Privacy note
Expand Down
4 changes: 4 additions & 0 deletions Scripts/package_app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,10 @@ cat > "$APP/Contents/Info.plist" <<PLIST
<key>CFBundleVersion</key><string>${BUILD_NUMBER}</string>
<key>LSMinimumSystemVersion</key><string>14.0</string>
<key>LSUIElement</key><true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key><true/>
</dict>
<key>CFBundleIconFile</key><string>Icon</string>
<key>NSHumanReadableCopyright</key><string>© 2025 Peter Steinberger. MIT License.</string>
<key>SUFeedURL</key><string>${FEED_URL}</string>
Expand Down
88 changes: 67 additions & 21 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,19 @@ struct UsageMenuCardView: View {

var labelSuffix: String {
switch self {
case .left: "left"
case .used: "used"
case .left:
L10n.tr("menu.card.percent.left", fallback: "left")
case .used:
L10n.tr("menu.card.percent.used", fallback: "used")
}
}

var accessibilityLabel: String {
switch self {
case .left: "Usage remaining"
case .used: "Usage used"
case .left:
L10n.tr("menu.card.accessibility.usage_remaining", fallback: "Usage remaining")
case .used:
L10n.tr("menu.card.accessibility.usage_used", fallback: "Usage used")
}
}
}
Expand Down Expand Up @@ -135,7 +139,7 @@ struct UsageMenuCardView: View {
}
if let tokenUsage = self.model.tokenUsage {
VStack(alignment: .leading, spacing: 6) {
Text("Cost")
Text(L10n.tr("menu.card.cost.title", fallback: "Cost"))
.font(.body)
.fontWeight(.medium)
Text(tokenUsage.sessionLine)
Expand Down Expand Up @@ -460,19 +464,22 @@ private struct CreditsBarContent: View {

private var scaleText: String {
let scale = UsageFormatter.tokenCountString(Int(Self.fullScaleTokens))
return "\(scale) tokens"
let format = L10n.tr("menu.card.tokens.unit", fallback: "%@ tokens")
return String(format: format, locale: .current, scale)
}

var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Credits")
Text(L10n.tr("menu.card.credits.title", fallback: "Credits"))
.font(.body)
.fontWeight(.medium)
if let percentLeft {
UsageProgressBar(
percent: percentLeft,
tint: self.progressColor,
accessibilityLabel: "Credits remaining")
accessibilityLabel: L10n.tr(
"menu.card.accessibility.credits_remaining",
fallback: "Credits remaining"))
HStack(alignment: .firstTextBaseline) {
Text(self.creditsText)
.font(.caption)
Expand Down Expand Up @@ -513,7 +520,7 @@ struct UsageMenuCardCostSectionView: View {
VStack(alignment: .leading, spacing: 10) {
if let tokenUsage = self.model.tokenUsage {
VStack(alignment: .leading, spacing: 6) {
Text("Cost")
Text(L10n.tr("menu.card.cost.title", fallback: "Cost"))
.font(.body)
.fontWeight(.medium)
Text(tokenUsage.sessionLine)
Expand Down Expand Up @@ -576,6 +583,7 @@ extension UsageMenuCardView.Model {
struct Input {
let provider: UsageProvider
let metadata: ProviderMetadata
let sourceLabel: String?
let snapshot: UsageSnapshot?
let credits: CreditsSnapshot?
let creditsError: String?
Expand All @@ -601,10 +609,16 @@ extension UsageMenuCardView.Model {
account: input.account,
metadata: input.metadata)
let metrics = Self.metrics(input: input)
let isCodexCLIProxy = input.provider == .codex &&
(input.sourceLabel?.localizedCaseInsensitiveContains("cliproxy-api") ?? false)
let creditsText: String? = if input.provider == .codex, !input.showOptionalCreditsAndExtraUsage {
nil
} else {
Self.creditsLine(metadata: input.metadata, credits: input.credits, error: input.creditsError)
Self.creditsLine(
metadata: input.metadata,
credits: input.credits,
error: input.creditsError,
showUnavailableHint: !isCodexCLIProxy)
}
let providerCost: ProviderCostSection? = if input.provider == .claude, !input.showOptionalCreditsAndExtraUsage {
nil
Expand All @@ -613,6 +627,8 @@ extension UsageMenuCardView.Model {
}
let tokenUsage = Self.tokenUsageSection(
provider: input.provider,
sourceLabel: input.sourceLabel,
hasUsageSnapshot: input.snapshot != nil,
enabled: input.tokenCostUsageEnabled,
snapshot: input.tokenSnapshot,
error: input.tokenError)
Expand Down Expand Up @@ -745,7 +761,7 @@ extension UsageMenuCardView.Model {
if let primary = snapshot.primary {
metrics.append(Metric(
id: "primary",
title: input.metadata.sessionLabel,
title: Self.primaryWindowLabel(for: input.provider, fallback: input.metadata.sessionLabel),
percent: Self.clamped(
input.usageBarsShowUsed ? primary.usedPercent : primary.remainingPercent),
percentStyle: percentStyle,
Expand All @@ -764,7 +780,7 @@ extension UsageMenuCardView.Model {
showUsed: input.usageBarsShowUsed)
metrics.append(Metric(
id: "secondary",
title: input.metadata.weeklyLabel,
title: Self.secondaryWindowLabel(for: input.provider, fallback: input.metadata.weeklyLabel),
percent: Self.clamped(input.usageBarsShowUsed ? weekly.usedPercent : weekly.remainingPercent),
percentStyle: percentStyle,
resetText: Self.resetText(for: weekly, style: input.resetTimeDisplayStyle, now: input.now),
Expand Down Expand Up @@ -805,6 +821,20 @@ extension UsageMenuCardView.Model {
return metrics
}

private static func primaryWindowLabel(for provider: UsageProvider, fallback: String) -> String {
if provider == .codex || provider == .codexproxy {
return L10n.tr("provider.codex.metadata.session_label", fallback: "Session")
}
return fallback
}

private static func secondaryWindowLabel(for provider: UsageProvider, fallback: String) -> String {
if provider == .codex || provider == .codexproxy {
return L10n.tr("provider.codex.metadata.weekly_label", fallback: "Weekly")
}
return fallback
}

private static func zaiLimitDetailText(limit: ZaiLimitEntry?) -> String? {
guard let limit else { return nil }
let currentStr = UsageFormatter.tokenCountString(limit.currentValue)
Expand Down Expand Up @@ -844,12 +874,14 @@ extension UsageMenuCardView.Model {
private static func creditsLine(
metadata: ProviderMetadata,
credits: CreditsSnapshot?,
error: String?) -> String?
error: String?,
showUnavailableHint: Bool = true) -> String?
{
guard metadata.supportsCredits else { return nil }
if let credits {
return UsageFormatter.creditsString(from: credits.remaining)
}
guard showUnavailableHint else { return nil }
if let error, !error.isEmpty {
return error.trimmingCharacters(in: .whitespacesAndNewlines)
}
Expand All @@ -864,21 +896,31 @@ extension UsageMenuCardView.Model {

private static func tokenUsageSection(
provider: UsageProvider,
sourceLabel: String?,
hasUsageSnapshot: Bool,
enabled: Bool,
snapshot: CostUsageTokenSnapshot?,
error: String?) -> TokenUsageSection?
{
guard provider == .codex || provider == .claude || provider == .vertexai else { return nil }
guard provider == .codex || provider == .codexproxy || provider == .claude || provider == .vertexai else {
return nil
}
if provider == .codex {
guard hasUsageSnapshot else { return nil }
if sourceLabel?.localizedCaseInsensitiveContains("cliproxy-api") == true { return nil }
}
guard enabled else { return nil }
guard let snapshot else { return nil }

let sessionCost = snapshot.sessionCostUSD.map { UsageFormatter.usdString($0) } ?? "—"
let sessionTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) }
let sessionLine: String = {
if let sessionTokens {
return "Today: \(sessionCost) · \(sessionTokens) tokens"
let format = L10n.tr("menu.card.cost.today_with_tokens", fallback: "Today: %@ · %@ tokens")
return String(format: format, locale: .current, sessionCost, sessionTokens)
}
return "Today: \(sessionCost)"
let format = L10n.tr("menu.card.cost.today", fallback: "Today: %@")
return String(format: format, locale: .current, sessionCost)
}()

let monthCost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—"
Expand All @@ -887,9 +929,13 @@ extension UsageMenuCardView.Model {
let monthTokens = monthTokensValue.map { UsageFormatter.tokenCountString($0) }
let monthLine: String = {
if let monthTokens {
return "Last 30 days: \(monthCost) · \(monthTokens) tokens"
let format = L10n.tr(
"menu.card.cost.last_30_days_with_tokens",
fallback: "Last 30 days: %@ · %@ tokens")
return String(format: format, locale: .current, monthCost, monthTokens)
}
return "Last 30 days: \(monthCost)"
let format = L10n.tr("menu.card.cost.last_30_days", fallback: "Last 30 days: %@")
return String(format: format, locale: .current, monthCost)
}()
let err = (error?.isEmpty ?? true) ? nil : error
return TokenUsageSection(
Expand All @@ -912,17 +958,17 @@ extension UsageMenuCardView.Model {
let title: String

if cost.currencyCode == "Quota" {
title = "Quota usage"
title = L10n.tr("menu.card.provider_cost.quota_usage", fallback: "Quota usage")
used = String(format: "%.0f", cost.used)
limit = String(format: "%.0f", cost.limit)
} else {
title = "Extra usage"
title = L10n.tr("menu.card.provider_cost.extra_usage", fallback: "Extra usage")
used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode)
limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode)
}

let percentUsed = Self.clamped((cost.used / cost.limit) * 100)
let periodLabel = cost.period ?? "This month"
let periodLabel = cost.period ?? L10n.tr("menu.card.provider_cost.this_month", fallback: "This month")

return ProviderCostSection(
title: title,
Expand Down
Loading