From 2209a9b639459be1b2c4a6765808c46228c3cf1e Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 24 Jun 2026 14:39:47 -0700 Subject: [PATCH 01/17] feat(cursor): add token cost report with Cursor-metered total Cursor previously surfaced only status/quota, not per-model token cost. Add a cost report sourced from Cursor's cookie-authenticated dashboard API, reusing the existing Cursor session resolution. Core: - CursorUsageEventsFetcher pages get-filtered-usage-events, dedupes, and shapes events into an API-rate per-day/per-model report plus a Cursor-metered window total (sum of chargedCents). One fetch backs both numbers so they always cover the same window. - CursorStatusProbe.resolveSession generalizes the auth path so status and cost share one session-resolution flow; fetchCostReport runs on it. - CostUsageFetcher gains a macOS-only Cursor branch and a fetchAllHistory option (all-time window plus "All time" label when set). - Enable Cursor tokenCost capability and add a Cursor cost-estimate hint. CLI: - codexbar cost --provider cursor with --days and a new --all flag. - Text adds a "Cursor-metered" window line; JSON adds meteredCostUSD. App: - Menu cost card shows the Cursor-metered line; Preferences gains a global "Fetch full Cursor cost history" toggle wired through the settings store and the UsageStore refresh scope. Tests: - CursorUsageEventsFetcher pagination/dedupe/metered-cents and daily report mapping; CursorStatusProbe cost-report path. --- .../CodexBar/MenuCardHeightFingerprint.swift | 1 + Sources/CodexBar/MenuCardView+Costs.swift | 7 + .../CodexBar/MenuCardView+ModelHelpers.swift | 3 +- Sources/CodexBar/MenuCardView.swift | 11 + Sources/CodexBar/PreferencesGeneralPane.swift | 12 + Sources/CodexBar/SettingsStore+Defaults.swift | 10 + .../SettingsStore+MenuObservation.swift | 1 + Sources/CodexBar/SettingsStore.swift | 2 + Sources/CodexBar/SettingsStoreState.swift | 1 + .../StatusItemController+CostMenuCard.swift | 2 + Sources/CodexBar/UsageStore.swift | 8 +- Sources/CodexBarCLI/CLICostCommand.swift | 33 +- Sources/CodexBarCore/CostUsageFetcher.swift | 63 ++- Sources/CodexBarCore/CostUsageModels.swift | 6 + .../Cursor/CursorProviderDescriptor.swift | 4 +- .../Providers/Cursor/CursorStatusProbe.swift | 138 ++++-- .../Cursor/CursorUsageEventsFetcher.swift | 451 ++++++++++++++++++ Sources/CodexBarCore/UsageFormatter.swift | 2 + .../CursorStatusProbeTests.swift | 14 +- .../CursorUsageEventsFetcherTests.swift | 274 +++++++++++ 20 files changed, 996 insertions(+), 47 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/Cursor/CursorUsageEventsFetcher.swift create mode 100644 Tests/CodexBarTests/CursorUsageEventsFetcherTests.swift diff --git a/Sources/CodexBar/MenuCardHeightFingerprint.swift b/Sources/CodexBar/MenuCardHeightFingerprint.swift index 9e1962b85c..7bb9de7d75 100644 --- a/Sources/CodexBar/MenuCardHeightFingerprint.swift +++ b/Sources/CodexBar/MenuCardHeightFingerprint.swift @@ -112,6 +112,7 @@ extension UsageMenuCardView.Model.TokenUsageSection { MenuCardHeightFingerprint.join([ MenuCardHeightFingerprint.field("session", self.sessionLine), MenuCardHeightFingerprint.field("month", self.monthLine), + MenuCardHeightFingerprint.field("metered", self.meteredLine), MenuCardHeightFingerprint.field("hint", self.hintLine), MenuCardHeightFingerprint.field("error", self.errorLine), MenuCardHeightFingerprint.field("errorCopy", self.errorCopyText), diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 542ac63a20..e70194f325 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -107,10 +107,17 @@ extension UsageMenuCardView.Model { } return "\(windowLabel): \(monthCost)" }() + // Plan-metered spend over the same window (what the provider actually deducts); + // only providers that report it (currently Cursor) populate `meteredCostUSD`. + let meteredLine: String? = snapshot.meteredCostUSD.map { + let amount = UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) + return String(format: L("Cursor-metered: %@ (%@)"), amount, windowLabel.lowercased()) + } let err = (error?.isEmpty ?? true) ? nil : error return TokenUsageSection( sessionLine: sessionLine, monthLine: monthLine, + meteredLine: meteredLine, hintLine: Self.tokenUsageHint(provider: provider), errorLine: err, errorCopyText: (error?.isEmpty ?? true) ? nil : error) diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index c604bf2f84..05209daf29 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -202,7 +202,8 @@ extension UsageMenuCardView.Model { true case let (current?, candidate?): current.hintLine == candidate.hintLine && - current.errorLine == candidate.errorLine + current.errorLine == candidate.errorLine && + (current.meteredLine == nil) == (candidate.meteredLine == nil) default: false } diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 22de077a85..d5671f463f 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -83,6 +83,7 @@ struct UsageMenuCardView: View { struct TokenUsageSection { let sessionLine: String let monthLine: String + let meteredLine: String? let hintLine: String? let errorLine: String? let errorCopyText: String? @@ -197,6 +198,11 @@ struct UsageMenuCardView: View { Text(tokenUsage.monthLine) .font(.footnote) .lineLimit(1) + if let metered = tokenUsage.meteredLine, !metered.isEmpty { + Text(metered) + .font(.footnote) + .lineLimit(1) + } if let hint = tokenUsage.hintLine, !hint.isEmpty { Text(hint) .font(.footnote) @@ -712,6 +718,11 @@ struct UsageMenuCardCostSectionView: View { Text(tokenUsage.monthLine) .font(.caption) .lineLimit(1) + if let metered = tokenUsage.meteredLine, !metered.isEmpty { + Text(metered) + .font(.caption) + .lineLimit(1) + } if let hint = tokenUsage.hintLine, !hint.isEmpty { Text(hint) .font(.footnote) diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index e60992ce13..b40309e827 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -148,8 +148,20 @@ struct GeneralPane: View { .font(.footnote) .foregroundStyle(.tertiary) + Toggle(isOn: self.$settings.cursorFetchAllCostHistory) { + Text(L("Fetch full Cursor cost history")) + .font(.footnote) + } + .toggleStyle(.checkbox) + + Text(L("Cursor only: total all-time spend instead of the window above. Slower to load.")) + .font(.footnote) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + self.costStatusLine(provider: .claude) self.costStatusLine(provider: .codex) + self.costStatusLine(provider: .cursor) } } } diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index c10b5592e5..f90e04ac36 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -321,6 +321,16 @@ extension SettingsStore { } } + /// When enabled, Cursor cost fetches the full account history (all-time) instead of the + /// `costUsageHistoryDays` window. Cursor-only; other providers ignore it. + var cursorFetchAllCostHistory: Bool { + get { self.defaultsState.cursorFetchAllCostHistory } + set { + self.defaultsState.cursorFetchAllCostHistory = newValue + self.userDefaults.set(newValue, forKey: "cursorFetchAllCostHistory") + } + } + var hidePersonalInfo: Bool { get { self.defaultsState.hidePersonalInfo } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index e688237df7..0a05bb0072 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -34,6 +34,7 @@ extension SettingsStore { _ = self.copilotIconSecondaryWindowIDRaw _ = self.costUsageEnabled _ = self.costUsageHistoryDays + _ = self.cursorFetchAllCostHistory _ = self.appLanguage _ = self.hidePersonalInfo _ = self.randomBlinkEnabled diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index e9a087ca1d..8b2a49c934 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -377,6 +377,7 @@ extension SettingsStore { let costUsageEnabled = userDefaults.object(forKey: "tokenCostUsageEnabled") as? Bool ?? false let rawCostUsageHistoryDays = userDefaults.object(forKey: "tokenCostUsageHistoryDays") as? Int ?? 30 let costUsageHistoryDays = max(1, min(365, rawCostUsageHistoryDays)) + let cursorFetchAllCostHistory = userDefaults.object(forKey: "cursorFetchAllCostHistory") as? Bool ?? false let hidePersonalInfo = userDefaults.object(forKey: "hidePersonalInfo") as? Bool ?? false let randomBlinkEnabled = userDefaults.object(forKey: "randomBlinkEnabled") as? Bool ?? false let confettiOnWeeklyLimitResetsEnabled = userDefaults.object( @@ -451,6 +452,7 @@ extension SettingsStore { copilotIconSecondaryWindowIDRaw: copilotIconSecondaryWindowIDRaw, costUsageEnabled: costUsageEnabled, costUsageHistoryDays: costUsageHistoryDays, + cursorFetchAllCostHistory: cursorFetchAllCostHistory, hidePersonalInfo: hidePersonalInfo, randomBlinkEnabled: randomBlinkEnabled, confettiOnWeeklyLimitResetsEnabled: confettiOnWeeklyLimitResetsEnabled, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 58f7a02af4..f97194a4b7 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -34,6 +34,7 @@ struct SettingsDefaultsState { var copilotIconSecondaryWindowIDRaw: String var costUsageEnabled: Bool var costUsageHistoryDays: Int + var cursorFetchAllCostHistory: Bool var hidePersonalInfo: Bool var randomBlinkEnabled: Bool var confettiOnWeeklyLimitResetsEnabled: Bool diff --git a/Sources/CodexBar/StatusItemController+CostMenuCard.swift b/Sources/CodexBar/StatusItemController+CostMenuCard.swift index 05424c8d50..0e7396185f 100644 --- a/Sources/CodexBar/StatusItemController+CostMenuCard.swift +++ b/Sources/CodexBar/StatusItemController+CostMenuCard.swift @@ -91,6 +91,7 @@ extension StatusItemController { [ tokenUsage?.sessionLine, tokenUsage?.monthLine, + tokenUsage?.meteredLine, tokenUsage?.hintLine, tokenUsage?.errorLine, ] @@ -106,6 +107,7 @@ extension StatusItemController { let primaryLines = [ tokenUsage?.sessionLine, tokenUsage?.monthLine, + tokenUsage?.meteredLine, tokenUsage?.errorLine, ] .compactMap(\.self) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 6c41431886..0caf5cf477 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -81,6 +81,7 @@ extension UsageStore { _ = self.settings.usageBarsShowUsed _ = self.settings.costUsageEnabled _ = self.settings.costUsageHistoryDays + _ = self.settings.cursorFetchAllCostHistory _ = self.settings.randomBlinkEnabled _ = self.settings.configRevision for implementation in ProviderCatalog.all { @@ -1493,8 +1494,10 @@ extension UsageStore { let now = Date() let historyDays = self.settings.costUsageHistoryDays + // Cursor can pull its full account history (all-time) instead of the rolling window. + let fetchAllHistory = provider == .cursor && self.settings.cursorFetchAllCostHistory let costScope = self.tokenCostScope(for: provider) - let costScopeSignature = "\(costScope.signature)|historyDays=\(historyDays)" + let costScopeSignature = "\(costScope.signature)|historyDays=\(historyDays)|fetchAll=\(fetchAllHistory)" if !force, let last = self.lastTokenFetchAt[provider], self.lastTokenFetchScope[provider] == costScopeSignature, @@ -1545,7 +1548,8 @@ extension UsageStore { forceRefresh: force, allowVertexClaudeFallback: !self.isEnabled(.claude), codexHomePath: costScope.codexHomePath, - historyDays: historyDays) + historyDays: historyDays, + fetchAllHistory: fetchAllHistory) } group.addTask { try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000)) diff --git a/Sources/CodexBarCLI/CLICostCommand.swift b/Sources/CodexBarCLI/CLICostCommand.swift index 7f63b24129..a4db37dd77 100644 --- a/Sources/CodexBarCLI/CLICostCommand.swift +++ b/Sources/CodexBarCLI/CLICostCommand.swift @@ -3,7 +3,7 @@ import Commander import Foundation extension CodexBarCLI { - private static let costSupportedProviders: Set = [.claude, .codex] + private static let costSupportedProviders: Set = [.claude, .codex, .cursor] static func runCost(_ values: ParsedValues) async { let output = CLIOutputPreferences.from(values: values) @@ -21,9 +21,13 @@ extension CodexBarCLI { } } guard !providers.isEmpty else { + let supportedNames = Self.costSupportedProviders + .map { ProviderDescriptorRegistry.descriptor(for: $0).metadata.displayName } + .sorted() + .joined(separator: ", ") Self.exit( code: .failure, - message: "Error: cost is only supported for Claude and Codex.", + message: "Error: cost is only supported for \(supportedNames).", output: output, kind: .args) } @@ -32,6 +36,7 @@ extension CodexBarCLI { let forceRefresh = values.flags.contains("refresh") let useColor = Self.shouldUseColor(noColor: values.flags.contains("noColor"), format: format) let historyDays = Self.decodeCostHistoryDays(from: values) + let fetchAllHistory = values.flags.contains("all") let fetcher = CostUsageFetcher() var sections: [String] = [] @@ -40,11 +45,13 @@ extension CodexBarCLI { for provider in providers { do { - // Cost usage is local-only; it does not require web/CLI provider fetches. + // Claude/Codex cost comes from local logs; Cursor cost is fetched from its + // cookie-authenticated dashboard API via the shared session resolution. let snapshot = try await fetcher.loadTokenSnapshot( provider: provider, forceRefresh: forceRefresh, historyDays: historyDays, + fetchAllHistory: fetchAllHistory, refreshPricingInBackground: false) switch format { case .text: @@ -98,8 +105,17 @@ extension CodexBarCLI { "\(historyLabel): \(monthCost) · \($0) tokens" } ?? "\(historyLabel): \(monthCost)" + // Plan-metered spend over the same window (what Cursor actually deducts), shown + // alongside the API-rate estimate. Only providers like Cursor report it. + let meteredLine: String? = snapshot.meteredCostUSD.map { + let amount = UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) + return "Cursor-metered: \(amount) (\(historyLabel.lowercased()))" + } + let hintLine = UsageFormatter.costEstimateHint(provider: provider) - return [header, todayLine, monthLine, hintLine].joined(separator: "\n") + return [header, todayLine, monthLine, meteredLine, hintLine] + .compactMap(\.self) + .joined(separator: "\n") } private static func costHeaderLine(_ header: String, useColor: Bool) -> String { @@ -136,7 +152,7 @@ extension CodexBarCLI { return CostPayload( provider: provider.rawValue, - source: "local", + source: provider == .cursor ? "web" : "local", updatedAt: snapshot?.updatedAt ?? (error == nil ? nil : Date()), currencyCode: snapshot?.currencyCode, sessionTokens: snapshot?.sessionTokens, @@ -144,6 +160,7 @@ extension CodexBarCLI { historyDays: snapshot?.historyDays, last30DaysTokens: snapshot?.last30DaysTokens, last30DaysCostUSD: snapshot?.last30DaysCostUSD, + meteredCostUSD: snapshot?.meteredCostUSD, daily: daily, totals: snapshot.flatMap(Self.costTotals(from:)), error: error.map { Self.makeErrorPayload($0) }) @@ -255,6 +272,9 @@ struct CostOptions: CommanderParsable { @Option(name: .long("days"), help: "Cost history window in days (1...365)") var days: Int? + + @Flag(name: .long("all"), help: "Fetch full cost history instead of the --days window (Cursor only)") + var all: Bool = false } struct CostPayload: Encodable { @@ -267,6 +287,7 @@ struct CostPayload: Encodable { let historyDays: Int? let last30DaysTokens: Int? let last30DaysCostUSD: Double? + let meteredCostUSD: Double? let daily: [CostDailyEntryPayload] let totals: CostTotalsPayload? let error: ProviderErrorPayload? @@ -281,6 +302,7 @@ struct CostPayload: Encodable { historyDays: Int?, last30DaysTokens: Int?, last30DaysCostUSD: Double?, + meteredCostUSD: Double? = nil, daily: [CostDailyEntryPayload], totals: CostTotalsPayload?, error: ProviderErrorPayload?) @@ -294,6 +316,7 @@ struct CostPayload: Encodable { self.historyDays = historyDays self.last30DaysTokens = last30DaysTokens self.last30DaysCostUSD = last30DaysCostUSD + self.meteredCostUSD = meteredCostUSD self.daily = daily self.totals = totals self.error = error diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index c4d59ca60e..1b4aa1b6b2 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -48,6 +48,7 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: Bool = false, codexHomePath: String? = nil, historyDays: Int = 30, + fetchAllHistory: Bool = false, refreshPricingInBackground: Bool = true) async throws -> CostUsageTokenSnapshot { try await Self.loadTokenSnapshot( @@ -58,6 +59,7 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: allowVertexClaudeFallback, codexHomePath: codexHomePath, historyDays: historyDays, + fetchAllHistory: fetchAllHistory, refreshPricingInBackground: refreshPricingInBackground, scannerOptions: self.scannerOptionsOverride()) } @@ -97,12 +99,13 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: Bool = false, codexHomePath: String? = nil, historyDays: Int = 30, + fetchAllHistory: Bool = false, refreshPricingInBackground: Bool = true, scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil, piScannerOptions overridePiScannerOptions: PiSessionCostScanner .Options? = nil) async throws -> CostUsageTokenSnapshot { - guard provider == .codex || provider == .claude || provider == .vertexai || provider == .bedrock else { + guard self.supportsTokenSnapshot(provider) else { throw CostUsageError.unsupportedProvider(provider) } @@ -123,6 +126,16 @@ public struct CostUsageFetcher: Sendable { useCurrentLocalDayForSession: false) } + #if os(macOS) + if provider == .cursor { + return try await Self.loadCursorTokenSnapshot( + now: now, + since: fetchAllHistory ? nil : since, + historyDays: clampedHistoryDays, + fetchAllHistory: fetchAllHistory) + } + #endif + var options = overrideScannerOptions ?? CostUsageScanner.Options() if provider == .codex, let codexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -264,6 +277,23 @@ public struct CostUsageFetcher: Sendable { return cachedSnapshot.flatMap(\.self) } + /// Providers whose token-cost snapshot `loadTokenSnapshot` can produce. Cursor is + /// macOS-only because it reuses the macOS Cursor session resolution. + static func supportsTokenSnapshot(_ provider: UsageProvider) -> Bool { + switch provider { + case .codex, .claude, .vertexai, .bedrock: + return true + case .cursor: + #if os(macOS) + return true + #else + return false + #endif + default: + return false + } + } + private static func loadBedrockDailyReport( environment: [String: String], since: Date, @@ -277,11 +307,38 @@ public struct CostUsageFetcher: Sendable { environment: environment) } + #if os(macOS) + /// Fetch Cursor's per-day token-cost plus its Cursor-metered total via the cookie-authenticated + /// dashboard API, reusing the same session resolution as the Cursor status probe. + /// + /// With `fetchAllHistory`, the window is the full account history, so the windowed totals are + /// themselves the all-time figures and the display label switches to "All time". + private static func loadCursorTokenSnapshot( + now: Date, + since: Date?, + historyDays: Int, + fetchAllHistory: Bool) async throws -> CostUsageTokenSnapshot + { + let probe = CursorStatusProbe(browserDetection: BrowserDetection()) + let report = try await probe.fetchCostReport(since: since, until: now) + let historyLabel = fetchAllHistory ? "All time" : nil + return Self.tokenSnapshot( + from: report.daily, + now: now, + historyDays: historyDays, + useCurrentLocalDayForSession: false, + meteredCostUSD: report.meteredCostUSD, + historyLabel: historyLabel) + } + #endif + static func tokenSnapshot( from daily: CostUsageDailyReport, now: Date, historyDays: Int = 30, - useCurrentLocalDayForSession: Bool = true) -> CostUsageTokenSnapshot + useCurrentLocalDayForSession: Bool = true, + meteredCostUSD: Double? = nil, + historyLabel: String? = nil) -> CostUsageTokenSnapshot { let sessionEntry = useCurrentLocalDayForSession ? CostUsageTokenSnapshot.entry(in: daily.data, forLocalDayContaining: now) @@ -315,6 +372,8 @@ public struct CostUsageFetcher: Sendable { last30DaysTokens: last30DaysTokens, last30DaysCostUSD: last30DaysCostUSD, historyDays: historyDays, + historyLabel: historyLabel, + meteredCostUSD: meteredCostUSD, daily: daily.data, updatedAt: now) } diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index d2a6a4d2c7..552ad71bc3 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -10,6 +10,10 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { public let currencyCode: String public let historyDays: Int public let historyLabel: String? + /// Provider-metered spend over the same window as `last30DaysCostUSD` — what the plan + /// actually deducts, as opposed to the API-rate estimate. Only some providers (e.g. Cursor) + /// report this; `nil` when unknown. + public let meteredCostUSD: Double? public let daily: [CostUsageDailyReport.Entry] public let updatedAt: Date @@ -23,6 +27,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { currencyCode: String = "USD", historyDays: Int = 30, historyLabel: String? = nil, + meteredCostUSD: Double? = nil, daily: [CostUsageDailyReport.Entry], updatedAt: Date) { @@ -37,6 +42,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { : currencyCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() self.historyDays = historyDays self.historyLabel = historyLabel + self.meteredCostUSD = meteredCostUSD self.daily = daily self.updatedAt = updatedAt } diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift index c6f1ff35fa..ad1a1fab97 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift @@ -30,8 +30,8 @@ public enum CursorProviderDescriptor { iconResourceName: "ProviderIcon-cursor", color: ProviderColor(red: 0 / 255, green: 191 / 255, blue: 165 / 255)), tokenCost: ProviderTokenCostConfig( - supportsTokenCost: false, - noDataMessage: { "Cursor cost summary is not supported." }), + supportsTokenCost: true, + noDataMessage: { "No Cursor cost usage found. Sign in to Cursor in your browser or the Cursor app." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .cli], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [CursorStatusFetchStrategy()] })), diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift index 6bb82fa037..ddc0ba0675 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift @@ -840,6 +840,23 @@ public actor CursorSessionStore { } } +// MARK: - Cursor Cost Report + +/// A windowed Cursor cost report: the API-rate per-day breakdown plus the Cursor-metered +/// total (what the plan actually deducts) over the same window. +/// +/// `daily` carries vendor list-price costs (`tokenUsage.totalCents`); `meteredCostUSD` sums +/// each event's `chargedCents` and is `nil` when the events reported no metered amount. +public struct CursorCostReport: Sendable { + public let daily: CostUsageDailyReport + public let meteredCostUSD: Double? + + public init(daily: CostUsageDailyReport, meteredCostUSD: Double?) { + self.daily = daily + self.meteredCostUSD = meteredCostUSD + } +} + // MARK: - Cursor Status Probe public struct CursorStatusProbe: Sendable { @@ -900,13 +917,75 @@ public struct CursorStatusProbe: Sendable { allowAppAuthFallback: Bool = true, logger: ((String) -> Void)? = nil) async throws -> CursorStatusSnapshot + { + try await self.resolveSession( + cookieHeaderOverride: cookieHeaderOverride, + allowCachedSessions: allowCachedSessions, + allowAppAuthFallback: allowAppAuthFallback, + logger: logger) + { cookieHeader, requestUsageUserIDFallback in + try await self.fetchWithCookieHeader( + cookieHeader, + requestUsageUserIDFallback: requestUsageUserIDFallback) + } + } + + /// Fetch Cursor token-cost data (per-day, per-model) using the same session + /// resolution as ``fetch(cookieHeaderOverride:allowCachedSessions:allowAppAuthFallback:logger:)``. + /// + /// Passing `nil` for both `since` and `until` pulls the full history. The returned report + /// carries both the API-rate per-day breakdown and the Cursor-metered window total, both + /// derived from the same events. + public func fetchCostReport( + since: Date?, + until: Date?, + calendar: Calendar = .current, + cookieHeaderOverride: String? = nil, + allowCachedSessions: Bool = true, + allowAppAuthFallback: Bool = true, + logger: ((String) -> Void)? = nil) async throws -> CursorCostReport + { + let fetcher = CursorUsageEventsFetcher( + baseURL: self.baseURL, + transport: self.urlSession, + timeout: self.timeout) + return try await self.resolveSession( + cookieHeaderOverride: cookieHeaderOverride, + allowCachedSessions: allowCachedSessions, + allowAppAuthFallback: allowAppAuthFallback, + logger: logger) + { cookieHeader, _ in + let result = try await fetcher.fetchUsage( + cookieHeader: cookieHeader, + since: since, + until: until, + calendar: calendar, + logger: logger) + return CursorCostReport(daily: result.daily, meteredCostUSD: result.meteredCostUSD) + } + } + + /// Resolve a working Cursor session and run `perform` against it. + /// + /// Each candidate source (manual override, cached cookie, browser cookies, stored + /// session, then the Cursor.app token) is validated by running `perform`. A + /// `.notLoggedIn` result advances to the next source; any other error is recorded + /// and surfaced once the sources are exhausted. This keeps the status and cost + /// fetches on a single auth path. + func resolveSession( + cookieHeaderOverride: String? = nil, + allowCachedSessions: Bool = true, + allowAppAuthFallback: Bool = true, + logger: ((String) -> Void)? = nil, + perform: (_ cookieHeader: String, _ requestUsageUserIDFallback: String?) async throws -> Value) + async throws -> Value { let log: (String) -> Void = { msg in logger?("[cursor] \(msg)") } var firstRecoverableError: CursorStatusProbeError? if let override = CookieHeaderNormalizer.normalize(cookieHeaderOverride) { log("Using manual cookie header") - return try await self.fetchWithCookieHeader(override) + return try await perform(override, nil) } if allowCachedSessions, @@ -915,7 +994,7 @@ public struct CursorStatusProbe: Sendable { { log("Using cached cookie header from \(cached.sourceLabel)") do { - return try await self.fetchWithCookieHeader(cached.cookieHeader) + return try await perform(cached.cookieHeader, nil) } catch let error as CursorStatusProbeError { if case .notLoggedIn = error { CookieHeaderCache.clear(provider: .cursor) @@ -939,11 +1018,11 @@ public struct CursorStatusProbe: Sendable { logger: log) }, attemptFetch: { session in - await self.fetchIfSessionAccepted(session, log: log) + await self.attemptSession(session, perform: perform, log: log) }) { - case let .succeeded(snapshot): - return snapshot + case let .succeeded(value): + return value case let .exhausted(error): firstRecoverableError = error ?? firstRecoverableError } @@ -957,11 +1036,11 @@ public struct CursorStatusProbe: Sendable { logger: log) }, attemptFetch: { session in - await self.fetchIfSessionAccepted(session, log: log) + await self.attemptSession(session, perform: perform, log: log) }) { - case let .succeeded(snapshot): - return snapshot + case let .succeeded(value): + return value case let .exhausted(error): firstRecoverableError = error ?? firstRecoverableError } @@ -973,7 +1052,7 @@ public struct CursorStatusProbe: Sendable { log("Using stored session cookies") let cookieHeader = storedCookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") do { - return try await self.fetchWithCookieHeader(cookieHeader) + return try await perform(cookieHeader, nil) } catch let error as CursorStatusProbeError { if case .notLoggedIn = error { // Clear only when auth is invalid; keep for transient failures. @@ -1003,7 +1082,9 @@ public struct CursorStatusProbe: Sendable { { log("Using Cursor.app local auth fallback") do { - return try await self.fetchWithAppAuthSession(appSession) + let appCookieHeader = try appSession.cookieHeader() + let appUserID = try appSession.userID() + return try await perform(appCookieHeader, appUserID) } catch let error as CursorStatusProbeError { if case .notLoggedIn = error { log("Cursor.app local auth was rejected") @@ -1022,22 +1103,22 @@ public struct CursorStatusProbe: Sendable { throw CursorStatusProbeError.noSessionCookie } - enum ImportedSessionFetchOutcome { - case succeeded(CursorStatusSnapshot) + enum ImportedSessionFetchOutcome { + case succeeded(Value) case tryNextBrowser case failed(CursorStatusProbeError) } - enum ImportedSessionScanResult { - case succeeded(CursorStatusSnapshot) + enum ImportedSessionScanResult { + case succeeded(Value) case exhausted(CursorStatusProbeError?) } - func scanBrowsers( + func scanBrowsers( _ browsers: [Browser], importSessions: (Browser) -> [CursorCookieImporter.SessionInfo], - attemptFetch: (CursorCookieImporter.SessionInfo) async -> ImportedSessionFetchOutcome) async - -> ImportedSessionScanResult + attemptFetch: (CursorCookieImporter.SessionInfo) async -> ImportedSessionFetchOutcome) async + -> ImportedSessionScanResult { var firstFailure: CursorStatusProbeError? @@ -1046,8 +1127,8 @@ public struct CursorStatusProbe: Sendable { guard !sessions.isEmpty else { continue } for session in sessions { switch await attemptFetch(session) { - case let .succeeded(snapshot): - return .succeeded(snapshot) + case let .succeeded(value): + return .succeeded(value) case .tryNextBrowser: continue case let .failed(error): @@ -1059,17 +1140,17 @@ public struct CursorStatusProbe: Sendable { return .exhausted(firstFailure) } - func scanImportedSessions( + func scanImportedSessions( _ sessions: [CursorCookieImporter.SessionInfo], - attemptFetch: (CursorCookieImporter.SessionInfo) async -> ImportedSessionFetchOutcome) async - -> ImportedSessionScanResult + attemptFetch: (CursorCookieImporter.SessionInfo) async -> ImportedSessionFetchOutcome) async + -> ImportedSessionScanResult { var firstFailure: CursorStatusProbeError? for session in sessions { switch await attemptFetch(session) { - case let .succeeded(snapshot): - return .succeeded(snapshot) + case let .succeeded(value): + return .succeeded(value) case .tryNextBrowser: continue case let .failed(error): @@ -1080,18 +1161,19 @@ public struct CursorStatusProbe: Sendable { return .exhausted(firstFailure) } - private func fetchIfSessionAccepted( + private func attemptSession( _ session: CursorCookieImporter.SessionInfo, - log: @escaping (String) -> Void) async -> ImportedSessionFetchOutcome + perform: (_ cookieHeader: String, _ requestUsageUserIDFallback: String?) async throws -> Value, + log: @escaping (String) -> Void) async -> ImportedSessionFetchOutcome { log("Trying Cursor session from \(session.sourceLabel)") do { - let snapshot = try await self.fetchWithCookieHeader(session.cookieHeader) + let value = try await perform(session.cookieHeader, nil) CookieHeaderCache.store( provider: .cursor, cookieHeader: session.cookieHeader, sourceLabel: session.sourceLabel) - return .succeeded(snapshot) + return .succeeded(value) } catch let error as CursorStatusProbeError { if case .notLoggedIn = error { log("Cursor API rejected cookies from \(session.sourceLabel); trying next browser if any") diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorUsageEventsFetcher.swift b/Sources/CodexBarCore/Providers/Cursor/CursorUsageEventsFetcher.swift new file mode 100644 index 0000000000..7a19a7cb70 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Cursor/CursorUsageEventsFetcher.swift @@ -0,0 +1,451 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +#if os(macOS) + +// MARK: - Cursor Usage Event Models + +/// One page of `POST /api/dashboard/get-filtered-usage-events`. +/// +/// `totalUsageEventsCount` reports the total number of events matching the query +/// so pagination can stop once every page has been collected. +struct CursorUsageEventsPage: Decodable, Sendable { + let totalUsageEventsCount: Int? + let usageEventsDisplay: [CursorUsageEvent] + + private enum CodingKeys: String, CodingKey { + case totalUsageEventsCount + case usageEventsDisplay + } + + init(totalUsageEventsCount: Int?, usageEventsDisplay: [CursorUsageEvent]) { + self.totalUsageEventsCount = totalUsageEventsCount + self.usageEventsDisplay = usageEventsDisplay + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.totalUsageEventsCount = try? container.decode(Int.self, forKey: .totalUsageEventsCount) + self.usageEventsDisplay = + (try? container.decode([CursorUsageEvent].self, forKey: .usageEventsDisplay)) ?? [] + } +} + +/// A single account usage event as returned by the Cursor dashboard API. +struct CursorUsageEvent: Decodable, Sendable { + /// Event time in Unix milliseconds (the API serializes this as a string). + let timestampMS: Int64? + let model: String? + let tokenUsage: CursorEventTokenUsage? + /// What the plan actually deducts, in cents. Distinct from the notional token cost. + let chargedCents: Double? + + private enum CodingKeys: String, CodingKey { + case timestamp + case model + case tokenUsage + case chargedCents + } + + init(timestampMS: Int64?, model: String?, tokenUsage: CursorEventTokenUsage?, chargedCents: Double? = nil) { + self.timestampMS = timestampMS + self.model = model + self.tokenUsage = tokenUsage + self.chargedCents = chargedCents + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.timestampMS = CursorEventNumber.int64(container, .timestamp) + self.model = (try? container.decode(String.self, forKey: .model)).flatMap { $0.isEmpty ? nil : $0 } + self.tokenUsage = try? container.decode(CursorEventTokenUsage.self, forKey: .tokenUsage) + self.chargedCents = CursorEventNumber.double(container, .chargedCents) + } +} + +/// Token counts and the authoritative token-cost carried by each usage event. +/// +/// `totalCents` matches public vendor list pricing, so it is used directly as the +/// cost (converted to USD). Token counts mirror ccusage's mapping, with +/// `cacheWriteTokens` treated as cache-creation input. +struct CursorEventTokenUsage: Decodable, Sendable { + let inputTokens: Int + let outputTokens: Int + let cacheWriteTokens: Int + let cacheReadTokens: Int + let totalCents: Double? + + private enum CodingKeys: String, CodingKey { + case inputTokens + case outputTokens + case cacheWriteTokens + case cacheReadTokens + case totalCents + } + + init(inputTokens: Int, outputTokens: Int, cacheWriteTokens: Int, cacheReadTokens: Int, totalCents: Double?) { + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.cacheWriteTokens = cacheWriteTokens + self.cacheReadTokens = cacheReadTokens + self.totalCents = totalCents + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.inputTokens = CursorEventNumber.int(container, .inputTokens) + self.outputTokens = CursorEventNumber.int(container, .outputTokens) + self.cacheWriteTokens = CursorEventNumber.int(container, .cacheWriteTokens) + self.cacheReadTokens = CursorEventNumber.int(container, .cacheReadTokens) + self.totalCents = CursorEventNumber.double(container, .totalCents) + } + + var totalTokens: Int { + self.inputTokens + self.outputTokens + self.cacheWriteTokens + self.cacheReadTokens + } + + var hasTokens: Bool { + self.totalTokens > 0 + } +} + +/// Result of fetching Cursor usage for a window. +/// +/// `daily` carries the API-rate per-day, per-model breakdown (vendor list price from +/// `tokenUsage.totalCents`). `meteredCostUSD` is what Cursor's plan actually deducts over the +/// same window (sum of each event's `chargedCents`); it is `nil` when no event reported a +/// metered amount, so callers can tell "zero" apart from "unknown". +struct CursorCostFetchResult: Sendable { + let daily: CostUsageDailyReport + let meteredCostUSD: Double? +} + +/// Lenient numeric decoding because Cursor serializes some numbers as strings. +private enum CursorEventNumber { + static func int(_ container: KeyedDecodingContainer, _ key: K) -> Int { + if let value = try? container.decode(Int.self, forKey: key) { return value } + if let value = try? container.decode(Double.self, forKey: key) { return Int(value) } + if let value = try? container.decode(String.self, forKey: key) { + return Int(value) ?? Int(Double(value) ?? 0) + } + return 0 + } + + static func double(_ container: KeyedDecodingContainer, _ key: K) -> Double? { + if let value = try? container.decode(Double.self, forKey: key) { return value } + if let value = try? container.decode(Int.self, forKey: key) { return Double(value) } + if let value = try? container.decode(String.self, forKey: key) { return Double(value) } + return nil + } + + static func int64(_ container: KeyedDecodingContainer, _ key: K) -> Int64? { + if let value = try? container.decode(Int64.self, forKey: key) { return value } + if let value = try? container.decode(Double.self, forKey: key) { return Int64(value) } + if let value = try? container.decode(String.self, forKey: key) { + return Int64(value) ?? Int64(Double(value) ?? 0) + } + return nil + } +} + +// MARK: - Cursor Usage Events Fetcher + +/// Fetches Cursor token-cost data from the cookie-authenticated dashboard API. +/// +/// The caller supplies a resolved `Cookie` header (see ``CursorStatusProbe``); this +/// type only knows how to page the usage endpoints and shape them into a +/// ``CursorCostFetchResult``. Keeping the network surface separate from session +/// resolution makes the mapping unit-testable with a stubbed transport. +struct CursorUsageEventsFetcher: Sendable { + let baseURL: URL + let transport: any ProviderHTTPTransport + var timeout: TimeInterval + var pageSize: Int + /// Hard cap so a paging bug can never loop forever (200 * 1000 = 200k events). + var maxPages: Int + + init( + baseURL: URL = URL(string: "https://cursor.com")!, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + timeout: TimeInterval = 30, + pageSize: Int = 1000, + maxPages: Int = 200) + { + self.baseURL = baseURL + self.transport = transport + self.timeout = timeout + self.pageSize = pageSize + self.maxPages = maxPages + } + + /// Fetch usage events for the given window (or all history when both bounds are nil) + /// and shape them into the API-rate per-day report plus the Cursor-metered window total. + /// + /// A single fetch backs both numbers, so they always cover the exact same window. + func fetchUsage( + cookieHeader: String, + since: Date?, + until: Date?, + calendar: Calendar = .current, + logger: ((String) -> Void)? = nil) async throws -> CursorCostFetchResult + { + let events = try await self.fetchAllEvents( + cookieHeader: cookieHeader, + since: since, + until: until, + logger: logger) + return CursorCostFetchResult( + daily: Self.makeDailyReport(from: events, calendar: calendar), + meteredCostUSD: Self.meteredCostUSD(from: events)) + } + + private func fetchAllEvents( + cookieHeader: String, + since: Date?, + until: Date?, + logger: ((String) -> Void)?) async throws -> [CursorUsageEvent] + { + var events: [CursorUsageEvent] = [] + var seen = Set() + for page in 1...self.maxPages { + let response = try await self.fetchPage( + cookieHeader: cookieHeader, + page: page, + since: since, + until: until) + let pageEvents = response.usageEventsDisplay + if pageEvents.isEmpty { break } + for event in pageEvents where seen.insert(Self.dedupKey(event)).inserted { + events.append(event) + } + logger?("[cursor-cost] page \(page): \(pageEvents.count) events (\(events.count) total)") + if pageEvents.count < self.pageSize { break } + if let total = response.totalUsageEventsCount, events.count >= total { break } + } + return events + } + + private func fetchPage( + cookieHeader: String, + page: Int, + since: Date?, + until: Date?) async throws -> CursorUsageEventsPage + { + let request = try self.makeRequest( + path: "/api/dashboard/get-filtered-usage-events", + cookieHeader: cookieHeader, + body: FilteredUsageRequest( + teamId: 0, + page: page, + pageSize: self.pageSize, + startDate: Self.millisString(since), + endDate: Self.millisString(until))) + let (data, response) = try await self.transport.data(for: request) + try Self.validate(response) + return try JSONDecoder().decode(CursorUsageEventsPage.self, from: data) + } + + // MARK: Request Building + + private struct FilteredUsageRequest: Encodable { + let teamId: Int + let page: Int + let pageSize: Int + let startDate: String? + let endDate: String? + } + + private func makeRequest(path: String, cookieHeader: String, body: some Encodable) throws -> URLRequest { + var request = URLRequest(url: self.baseURL.appendingPathComponent(path)) + request.httpMethod = "POST" + request.timeoutInterval = self.timeout + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + // Cursor enforces CSRF on these POST endpoints: a matching Origin is required. + request.setValue(self.originHeader, forHTTPHeaderField: "Origin") + request.httpBody = try JSONEncoder().encode(body) + return request + } + + private var originHeader: String { + guard let scheme = self.baseURL.scheme, let host = self.baseURL.host else { + return "https://cursor.com" + } + return "\(scheme)://\(host)" + } + + private static func validate(_ response: URLResponse) throws { + guard let http = response as? HTTPURLResponse else { + throw CursorStatusProbeError.networkError("Invalid response") + } + if http.statusCode == 401 || http.statusCode == 403 { + throw CursorStatusProbeError.notLoggedIn + } + guard http.statusCode == 200 else { + throw CursorStatusProbeError.networkError("HTTP \(http.statusCode)") + } + } + + private static func millisString(_ date: Date?) -> String? { + date.map { String(Int64(($0.timeIntervalSince1970 * 1000).rounded())) } + } + + /// Cursor usage events carry no stable id, so dedupe pages on the natural key + /// of timestamp, model, and token counts. + private static func dedupKey(_ event: CursorUsageEvent) -> String { + let usage = event.tokenUsage + return [ + String(event.timestampMS ?? 0), + event.model ?? "", + String(usage?.inputTokens ?? 0), + String(usage?.outputTokens ?? 0), + String(usage?.cacheWriteTokens ?? 0), + String(usage?.cacheReadTokens ?? 0), + ].joined(separator: "-") + } + + // MARK: Mapping + + /// Cursor-metered spend in USD: the sum of each event's `chargedCents` (what the plan + /// deducts), distinct from the API-rate `tokenUsage.totalCents`. Returns `nil` when no + /// event carried a `chargedCents` value so callers can tell "zero" apart from "unknown". + static func meteredCostUSD(from events: [CursorUsageEvent]) -> Double? { + var totalCents = 0.0 + var sawCharged = false + for event in events { + guard let cents = event.chargedCents else { continue } + totalCents += cents + sawCharged = true + } + return sawCharged ? totalCents / 100.0 : nil + } + + /// Group usage events into per-day, per-model cost entries. + /// + /// Events without token usage (or with all-zero token counts) are skipped, matching + /// ccusage. `totalCents / 100` is the authoritative cost and `cacheWriteTokens` maps + /// to cache-creation input. + static func makeDailyReport( + from events: [CursorUsageEvent], + calendar: Calendar = .current) -> CostUsageDailyReport + { + var days: [String: [String: ModelAccumulator]] = [:] + for event in events { + guard let usage = event.tokenUsage, usage.hasTokens else { continue } + let date = Date(timeIntervalSince1970: Double(event.timestampMS ?? 0) / 1000.0) + let dayKey = CostUsageLocalDay.key(from: date, calendar: calendar) + let model = event.model ?? "unknown" + var modelsForDay = days[dayKey] ?? [:] + var accumulator = modelsForDay[model] ?? ModelAccumulator() + accumulator.add(usage) + modelsForDay[model] = accumulator + days[dayKey] = modelsForDay + } + + let entries = days.keys.sorted().map { dayKey in + Self.makeEntry(date: dayKey, models: days[dayKey] ?? [:]) + } + return CostUsageDailyReport(data: entries, summary: Self.makeSummary(from: entries)) + } + + private struct ModelAccumulator { + var inputTokens = 0 + var outputTokens = 0 + var cacheReadTokens = 0 + var cacheCreationTokens = 0 + var costUSD = 0.0 + var requestCount = 0 + + mutating func add(_ usage: CursorEventTokenUsage) { + self.inputTokens += usage.inputTokens + self.outputTokens += usage.outputTokens + self.cacheReadTokens += usage.cacheReadTokens + self.cacheCreationTokens += usage.cacheWriteTokens + self.costUSD += (usage.totalCents ?? 0) / 100.0 + self.requestCount += 1 + } + + var totalTokens: Int { + self.inputTokens + self.outputTokens + self.cacheReadTokens + self.cacheCreationTokens + } + } + + private static func makeEntry(date: String, models: [String: ModelAccumulator]) -> CostUsageDailyReport.Entry { + var inputTokens = 0 + var outputTokens = 0 + var cacheReadTokens = 0 + var cacheCreationTokens = 0 + var requestCount = 0 + var costUSD = 0.0 + var breakdowns: [CostUsageDailyReport.ModelBreakdown] = [] + + for (model, accumulator) in models { + inputTokens += accumulator.inputTokens + outputTokens += accumulator.outputTokens + cacheReadTokens += accumulator.cacheReadTokens + cacheCreationTokens += accumulator.cacheCreationTokens + requestCount += accumulator.requestCount + costUSD += accumulator.costUSD + breakdowns.append(CostUsageDailyReport.ModelBreakdown( + modelName: model, + costUSD: accumulator.costUSD, + totalTokens: accumulator.totalTokens, + requestCount: accumulator.requestCount)) + } + + return CostUsageDailyReport.Entry( + date: date, + inputTokens: inputTokens, + outputTokens: outputTokens, + cacheReadTokens: cacheReadTokens, + cacheCreationTokens: cacheCreationTokens, + totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens, + requestCount: requestCount, + costUSD: costUSD, + modelsUsed: models.keys.sorted(), + modelBreakdowns: Self.sortedBreakdowns(breakdowns)) + } + + private static func makeSummary(from entries: [CostUsageDailyReport.Entry]) -> CostUsageDailyReport.Summary { + var totalInput = 0 + var totalOutput = 0 + var totalCacheRead = 0 + var totalCacheCreation = 0 + var totalTokens = 0 + var totalCost = 0.0 + for entry in entries { + totalInput += entry.inputTokens ?? 0 + totalOutput += entry.outputTokens ?? 0 + totalCacheRead += entry.cacheReadTokens ?? 0 + totalCacheCreation += entry.cacheCreationTokens ?? 0 + totalTokens += entry.totalTokens ?? 0 + totalCost += entry.costUSD ?? 0 + } + return CostUsageDailyReport.Summary( + totalInputTokens: totalInput, + totalOutputTokens: totalOutput, + cacheReadTokens: totalCacheRead, + cacheCreationTokens: totalCacheCreation, + totalTokens: totalTokens, + totalCostUSD: totalCost) + } + + private static func sortedBreakdowns( + _ breakdowns: [CostUsageDailyReport.ModelBreakdown]) -> [CostUsageDailyReport.ModelBreakdown] + { + breakdowns.sorted { lhs, rhs in + let lhsCost = lhs.costUSD ?? -1 + let rhsCost = rhs.costUSD ?? -1 + if lhsCost != rhsCost { return lhsCost > rhsCost } + let lhsTokens = lhs.totalTokens ?? -1 + let rhsTokens = rhs.totalTokens ?? -1 + if lhsTokens != rhsTokens { return lhsTokens > rhsTokens } + return lhs.modelName < rhs.modelName + } + } +} + +#endif diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index e6bcc6a62f..37994563d8 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -214,6 +214,8 @@ public enum UsageFormatter { case .claude: "Estimated from local Claude logs at API rates; token totals include cache read/write tokens " + "and may differ from Claude Code /status." + case .cursor: + "From Cursor's usage dashboard at vendor token rates; may differ from your invoice." default: self.costEstimateHint } diff --git a/Tests/CodexBarTests/CursorStatusProbeTests.swift b/Tests/CodexBarTests/CursorStatusProbeTests.swift index 921a4ed8b9..a2365ecf92 100644 --- a/Tests/CodexBarTests/CursorStatusProbeTests.swift +++ b/Tests/CodexBarTests/CursorStatusProbeTests.swift @@ -607,7 +607,7 @@ struct CursorStatusProbeTests { let result = await probe.scanImportedSessions([ Self.makeSessionInfo(sourceLabel: "Chrome"), Self.makeSessionInfo(sourceLabel: "Safari"), - ]) { session in + ]) { session -> CursorStatusProbe.ImportedSessionFetchOutcome in switch session.sourceLabel { case "Chrome": .failed(.networkError("HTTP 500")) @@ -636,7 +636,7 @@ struct CursorStatusProbeTests { Self.makeSessionInfo(sourceLabel: "Chrome"), Self.makeSessionInfo(sourceLabel: "Safari"), Self.makeSessionInfo(sourceLabel: "Arc"), - ]) { session in + ]) { session -> CursorStatusProbe.ImportedSessionFetchOutcome in switch session.sourceLabel { case "Chrome": .failed(.networkError("HTTP 500")) @@ -700,7 +700,7 @@ struct CursorStatusProbeTests { return [] } }, - attemptFetch: { session in + attemptFetch: { session -> CursorStatusProbe.ImportedSessionFetchOutcome in switch session.sourceLabel { case "Chrome": .failed(.networkError("HTTP 500")) @@ -755,15 +755,15 @@ struct CursorStatusProbeTests { [] } }, - attemptFetch: { session in + attemptFetch: { session -> CursorStatusProbe.ImportedSessionFetchOutcome in attemptedSources.append(session.sourceLabel) switch session.sourceLabel { case "Chrome Profile 1": - return CursorStatusProbe.ImportedSessionFetchOutcome.failed(.networkError("HTTP 500")) + return .failed(.networkError("HTTP 500")) case "Chrome Profile 2 (domain cookies)": - return CursorStatusProbe.ImportedSessionFetchOutcome.succeeded(expected) + return .succeeded(expected) default: - return CursorStatusProbe.ImportedSessionFetchOutcome.tryNextBrowser + return .tryNextBrowser } }) diff --git a/Tests/CodexBarTests/CursorUsageEventsFetcherTests.swift b/Tests/CodexBarTests/CursorUsageEventsFetcherTests.swift new file mode 100644 index 0000000000..e7b67c7f44 --- /dev/null +++ b/Tests/CodexBarTests/CursorUsageEventsFetcherTests.swift @@ -0,0 +1,274 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct CursorUsageEventsFetcherTests { + // MARK: - Helpers + + private static let baseURL = URL(string: "https://cursor.test")! + + /// Calendar pinned to UTC so timestamp-to-day grouping is deterministic across machines. + private static var utcCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar + } + + /// Cost math runs through `cents / 100`, so compare with a tolerance rather than `==`. + private static func approxEqual(_ actual: Double?, _ expected: Double, tolerance: Double = 1e-9) -> Bool { + guard let actual else { return false } + return abs(actual - expected) < tolerance + } + + private static func httpResponse(_ body: String, statusCode: Int = 200) -> (Data, URLResponse) { + let response = HTTPURLResponse( + url: baseURL, + statusCode: statusCode, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"])! + return (Data(body.utf8), response) + } + + private static func event( + timestampMS: Int64, + model: String, + input: Int = 0, + output: Int = 0, + cacheWrite: Int = 0, + cacheRead: Int = 0, + totalCents: Double?, + chargedCents: Double? = nil) -> CursorUsageEvent + { + CursorUsageEvent( + timestampMS: timestampMS, + model: model, + tokenUsage: CursorEventTokenUsage( + inputTokens: input, + outputTokens: output, + cacheWriteTokens: cacheWrite, + cacheReadTokens: cacheRead, + totalCents: totalCents), + chargedCents: chargedCents) + } + + /// Reads the `page` field from a stubbed request body so the handler can return pages. + private struct PageProbe: Decodable { + let page: Int? + } + + private static func requestedPage(_ request: URLRequest) -> Int { + guard let body = request.httpBody, + let probe = try? JSONDecoder().decode(PageProbe.self, from: body) + else { return 1 } + return probe.page ?? 1 + } + + // MARK: - Mapping + + @Test + func `makeDailyReport groups events by local day and model with cents converted to USD`() { + // 2023-11-14T22:13:20Z and one hour later share a UTC day; the third event is two days later. + let day1 = Int64(1_700_000_000_000) + let day1Later = day1 + 3_600_000 + let day3 = day1 + 172_800_000 + + let events = [ + Self.event(timestampMS: day1, model: "claude-4.5-sonnet", input: 100, output: 50, totalCents: 100), + Self.event(timestampMS: day1Later, model: "claude-4.5-sonnet", input: 10, output: 5, totalCents: 23), + Self.event(timestampMS: day1, model: "gpt-5", input: 200, output: 20, totalCents: 500), + Self.event(timestampMS: day3, model: "claude-4.5-sonnet", input: 1, output: 1, totalCents: 9), + ] + + let report = CursorUsageEventsFetcher.makeDailyReport(from: events, calendar: Self.utcCalendar) + + #expect(report.data.count == 2) + + let firstDay = report.data[0] + #expect(firstDay.date == "2023-11-14") + // Two models on day one; the gpt-5 row is more expensive so it sorts first. + #expect(firstDay.modelBreakdowns?.count == 2) + #expect(firstDay.modelBreakdowns?.first?.modelName == "gpt-5") + #expect(firstDay.modelsUsed == ["claude-4.5-sonnet", "gpt-5"]) + // claude rows merge: (100 + 23) cents, gpt-5 row: 500 cents -> $6.23 total for the day. + #expect(Self.approxEqual(firstDay.costUSD, 6.23)) + #expect(firstDay.requestCount == 3) + #expect(firstDay.totalTokens == 100 + 50 + 10 + 5 + 200 + 20) + + let claudeBreakdown = firstDay.modelBreakdowns?.first { $0.modelName == "claude-4.5-sonnet" } + #expect(Self.approxEqual(claudeBreakdown?.costUSD, 1.23)) + #expect(claudeBreakdown?.requestCount == 2) + + let lastDay = report.data[1] + #expect(lastDay.date == "2023-11-16") + #expect(Self.approxEqual(lastDay.costUSD, 0.09)) + + // Summary aggregates every day. + #expect(Self.approxEqual(report.summary?.totalCostUSD, 6.32)) + } + + @Test + func `makeDailyReport skips events without token usage`() { + let events = [ + Self.event(timestampMS: 1_700_000_000_000, model: "claude-4.5-sonnet", totalCents: 0), + Self.event(timestampMS: 1_700_000_000_000, model: "claude-4.5-sonnet", input: 5, totalCents: 12), + ] + + let report = CursorUsageEventsFetcher.makeDailyReport(from: events, calendar: Self.utcCalendar) + + #expect(report.data.count == 1) + #expect(report.data[0].requestCount == 1) + #expect(Self.approxEqual(report.data[0].costUSD, 0.12)) + } + + @Test + func `meteredCostUSD sums chargedCents and ignores events without it`() { + // Two charged events (4c + 8c) plus one event with no chargedCents -> $0.12. + let events = [ + Self.event(timestampMS: 1_700_000_000_000, model: "claude", input: 5, totalCents: 994, chargedCents: 4), + Self.event(timestampMS: 1_700_000_001_000, model: "gpt-5", input: 5, totalCents: 500, chargedCents: 8), + Self.event(timestampMS: 1_700_000_002_000, model: "default", input: 5, totalCents: 12), + ] + + #expect(Self.approxEqual(CursorUsageEventsFetcher.meteredCostUSD(from: events), 0.12)) + } + + @Test + func `meteredCostUSD returns nil when no event reports chargedCents`() { + let events = [ + Self.event(timestampMS: 1_700_000_000_000, model: "claude", input: 5, totalCents: 994), + ] + + #expect(CursorUsageEventsFetcher.meteredCostUSD(from: events) == nil) + } + + // MARK: - Decoding + + @Test + func `decodes string-encoded numbers leniently`() throws { + let json = """ + { + "totalUsageEventsCount": "2", + "usageEventsDisplay": [ + { + "timestamp": "1700000000000", + "model": "claude-4.5-sonnet", + "tokenUsage": { + "inputTokens": "100", + "outputTokens": 50, + "cacheWriteTokens": "10", + "cacheReadTokens": "5", + "totalCents": "12.5" + } + } + ] + } + """ + let page = try JSONDecoder().decode(CursorUsageEventsPage.self, from: Data(json.utf8)) + + #expect(page.totalUsageEventsCount == 2) + let event = try #require(page.usageEventsDisplay.first) + #expect(event.timestampMS == 1_700_000_000_000) + #expect(event.tokenUsage?.inputTokens == 100) + #expect(event.tokenUsage?.cacheWriteTokens == 10) + #expect(Self.approxEqual(event.tokenUsage?.totalCents, 12.5)) + } + + // MARK: - Fetching + + @Test + func `fetchUsage paginates, dedupes, sums metered cents, and sends Origin and Cookie headers`() async throws { + let firstEvent = #""" + {"timestamp":"1700000000000","model":"claude-4.5-sonnet","tokenUsage":{"inputTokens":100,"outputTokens":50,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":100},"chargedCents":4} + """# + let secondEvent = #""" + {"timestamp":"1700003600000","model":"gpt-5","tokenUsage":{"inputTokens":10,"outputTokens":5,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":50},"chargedCents":4} + """# + let thirdEvent = #""" + {"timestamp":"1700007200000","model":"gpt-5","tokenUsage":{"inputTokens":1,"outputTokens":1,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":25},"chargedCents":8} + """# + + let transport = ProviderHTTPTransportStub { request in + switch Self.requestedPage(request) { + case 1: + // Full page of two distinct events; total signals one more remains. + Self.httpResponse(""" + {"totalUsageEventsCount":3,"usageEventsDisplay":[\(firstEvent),\(secondEvent)]} + """) + default: + // Second event repeats (must dedupe) alongside one new event. + Self.httpResponse(""" + {"totalUsageEventsCount":3,"usageEventsDisplay":[\(secondEvent),\(thirdEvent)]} + """) + } + } + + let fetcher = CursorUsageEventsFetcher( + baseURL: Self.baseURL, + transport: transport, + pageSize: 2) + let result = try await fetcher.fetchUsage( + cookieHeader: "WorkosCursorSessionToken=abc", + since: nil, + until: nil, + calendar: Self.utcCalendar) + + // Three unique events across one UTC day -> one entry with two models. + #expect(result.daily.data.count == 1) + #expect(result.daily.data[0].requestCount == 3) + #expect(Self.approxEqual(result.daily.data[0].costUSD, 1.75)) + // Metered total dedupes the same way: (4 + 4 + 8) cents -> $0.16. + #expect(Self.approxEqual(result.meteredCostUSD, 0.16)) + + let requests = await transport.requests() + #expect(requests.count == 2) + for request in requests { + #expect(request.httpMethod == "POST") + #expect(request.url?.path == "/api/dashboard/get-filtered-usage-events") + #expect(request.value(forHTTPHeaderField: "Origin") == "https://cursor.test") + #expect(request.value(forHTTPHeaderField: "Cookie") == "WorkosCursorSessionToken=abc") + } + } + + @Test + func `fetchUsage reports nil metered total when events omit chargedCents`() async throws { + let event = #""" + {"timestamp":"1700000000000","model":"gpt-5","tokenUsage":{"inputTokens":10,"outputTokens":5,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":50}} + """# + let transport = ProviderHTTPTransportStub { _ in + Self.httpResponse("{\"totalUsageEventsCount\":1,\"usageEventsDisplay\":[\(event)]}") + } + + let fetcher = CursorUsageEventsFetcher(baseURL: Self.baseURL, transport: transport, pageSize: 2) + let result = try await fetcher.fetchUsage( + cookieHeader: "WorkosCursorSessionToken=abc", + since: nil, + until: nil, + calendar: Self.utcCalendar) + + #expect(result.meteredCostUSD == nil) + #expect(Self.approxEqual(result.daily.data.first?.costUSD, 0.50)) + } + + @Test + func `fetchUsage surfaces not logged in on 401`() async { + let transport = ProviderHTTPTransportStub { _ in + Self.httpResponse(#"{"error":"unauthorized"}"#, statusCode: 401) + } + let fetcher = CursorUsageEventsFetcher(baseURL: Self.baseURL, transport: transport) + + let error = await #expect(throws: CursorStatusProbeError.self) { + _ = try await fetcher.fetchUsage(cookieHeader: "x=y", since: nil, until: nil) + } + let isNotLoggedIn = error.map { thrown in + if case .notLoggedIn = thrown { return true } + return false + } ?? false + #expect(isNotLoggedIn) + } + + @Test + func `cost fetcher reports Cursor as a supported token-snapshot provider`() { + #expect(CostUsageFetcher.supportsTokenSnapshot(.cursor)) + } +} From a3aaa7550f109b792d516e611957f509ac591a79 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 24 Jun 2026 14:46:44 -0700 Subject: [PATCH 02/17] style(cursor): wrap long Preferences subtitle to satisfy swiftformat --- Sources/CodexBar/PreferencesGeneralPane.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index b40309e827..2dbcff3a05 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -154,7 +154,8 @@ struct GeneralPane: View { } .toggleStyle(.checkbox) - Text(L("Cursor only: total all-time spend instead of the window above. Slower to load.")) + Text( + L("Cursor only: total all-time spend instead of the window above. Slower to load.")) .font(.footnote) .foregroundStyle(.tertiary) .fixedSize(horizontal: false, vertical: true) From 891c2fe2ba56d8198ddb6194c11192c0c73dd2a0 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 24 Jun 2026 15:19:32 -0700 Subject: [PATCH 03/17] fix(cursor): default TokenUsageSection.meteredLine to keep call sites compiling Adding meteredLine to TokenUsageSection without a default made the synthesized memberwise initializer require it at every call site, breaking existing callers and tests that predate the Cursor-metered line. Add an explicit initializer that defaults meteredLine to nil so those call sites keep compiling. --- Sources/CodexBar/MenuCardView.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index d5671f463f..a91e2c0cce 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -87,6 +87,24 @@ struct UsageMenuCardView: View { let hintLine: String? let errorLine: String? let errorCopyText: String? + + /// Explicit initializer so `meteredLine` defaults to nil: callers that predate the + /// Cursor-metered line (and providers that never report it) keep their call sites. + init( + sessionLine: String, + monthLine: String, + meteredLine: String? = nil, + hintLine: String?, + errorLine: String?, + errorCopyText: String?) + { + self.sessionLine = sessionLine + self.monthLine = monthLine + self.meteredLine = meteredLine + self.hintLine = hintLine + self.errorLine = errorLine + self.errorCopyText = errorCopyText + } } struct ProviderCostSection { From 070ec1b7d94ef9a07f99d55af7a90ecc94f774b2 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 24 Jun 2026 15:19:41 -0700 Subject: [PATCH 04/17] fix(cursor): honor cookie-source setting when fetching cost The Cursor cost fetch always auto-resolved cookies, ignoring the cookie-source setting the status path respects. Thread a cursorCookieHeaderOverride from UsageStore through CostUsageFetcher into the Cursor cost report: forward the manual header when the source is Manual, skip the fetch entirely when it is Off, and fall back to auto resolution otherwise. Include the cookie source in the cost scope signature so toggling re-fetches. --- Sources/CodexBar/UsageStore.swift | 24 +++++++++++++++++++-- Sources/CodexBarCore/CostUsageFetcher.swift | 14 +++++++++--- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 0caf5cf477..bc0ec360db 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1490,14 +1490,33 @@ extension UsageStore { return } + // Cursor cost honors the same cookie policy as status: when the user set the cookie source + // to Off, skip the network fetch entirely (mirrors CursorProviderDescriptor.checkStatus). + if provider == .cursor, self.settings.cursorCookieSource == .off { + self.tokenSnapshots.removeValue(forKey: provider) + self.tokenErrors[provider] = nil + self.tokenFailureGates[provider]?.reset() + self.lastTokenFetchAt.removeValue(forKey: provider) + self.lastTokenFetchScope.removeValue(forKey: provider) + return + } + guard !self.tokenRefreshInFlight.contains(provider) else { return } let now = Date() let historyDays = self.settings.costUsageHistoryDays // Cursor can pull its full account history (all-time) instead of the rolling window. let fetchAllHistory = provider == .cursor && self.settings.cursorFetchAllCostHistory + // Cursor cost reuses the status cookie policy: a Manual source forwards the manual header so + // cost and status share the same session; other sources fall back to auto resolution. + let cursorCookieSource = self.settings.cursorCookieSource + let cursorCookieHeaderOverride: String? = provider == .cursor && cursorCookieSource == .manual + ? CookieHeaderNormalizer.normalize(self.settings.cursorCookieHeader) + : nil let costScope = self.tokenCostScope(for: provider) - let costScopeSignature = "\(costScope.signature)|historyDays=\(historyDays)|fetchAll=\(fetchAllHistory)" + let cursorScopeSuffix = provider == .cursor ? "|cursorCookie=\(cursorCookieSource.rawValue)" : "" + let costScopeSignature = + "\(costScope.signature)|historyDays=\(historyDays)|fetchAll=\(fetchAllHistory)\(cursorScopeSuffix)" if !force, let last = self.lastTokenFetchAt[provider], self.lastTokenFetchScope[provider] == costScopeSignature, @@ -1549,7 +1568,8 @@ extension UsageStore { allowVertexClaudeFallback: !self.isEnabled(.claude), codexHomePath: costScope.codexHomePath, historyDays: historyDays, - fetchAllHistory: fetchAllHistory) + fetchAllHistory: fetchAllHistory, + cursorCookieHeaderOverride: cursorCookieHeaderOverride) } group.addTask { try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000)) diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 1b4aa1b6b2..2d75504cf4 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -49,6 +49,7 @@ public struct CostUsageFetcher: Sendable { codexHomePath: String? = nil, historyDays: Int = 30, fetchAllHistory: Bool = false, + cursorCookieHeaderOverride: String? = nil, refreshPricingInBackground: Bool = true) async throws -> CostUsageTokenSnapshot { try await Self.loadTokenSnapshot( @@ -60,6 +61,7 @@ public struct CostUsageFetcher: Sendable { codexHomePath: codexHomePath, historyDays: historyDays, fetchAllHistory: fetchAllHistory, + cursorCookieHeaderOverride: cursorCookieHeaderOverride, refreshPricingInBackground: refreshPricingInBackground, scannerOptions: self.scannerOptionsOverride()) } @@ -100,6 +102,7 @@ public struct CostUsageFetcher: Sendable { codexHomePath: String? = nil, historyDays: Int = 30, fetchAllHistory: Bool = false, + cursorCookieHeaderOverride: String? = nil, refreshPricingInBackground: Bool = true, scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil, piScannerOptions overridePiScannerOptions: PiSessionCostScanner @@ -132,7 +135,8 @@ public struct CostUsageFetcher: Sendable { now: now, since: fetchAllHistory ? nil : since, historyDays: clampedHistoryDays, - fetchAllHistory: fetchAllHistory) + fetchAllHistory: fetchAllHistory, + cookieHeaderOverride: cursorCookieHeaderOverride) } #endif @@ -317,10 +321,14 @@ public struct CostUsageFetcher: Sendable { now: Date, since: Date?, historyDays: Int, - fetchAllHistory: Bool) async throws -> CostUsageTokenSnapshot + fetchAllHistory: Bool, + cookieHeaderOverride: String? = nil) async throws -> CostUsageTokenSnapshot { let probe = CursorStatusProbe(browserDetection: BrowserDetection()) - let report = try await probe.fetchCostReport(since: since, until: now) + let report = try await probe.fetchCostReport( + since: since, + until: now, + cookieHeaderOverride: cookieHeaderOverride) let historyLabel = fetchAllHistory ? "All time" : nil return Self.tokenSnapshot( from: report.daily, From 8f650d9b6cfe3c0672eea17eca8e5ea40abf21eb Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 24 Jun 2026 15:31:57 -0700 Subject: [PATCH 05/17] fix(cursor): correct lenient count decoding and fetcher test day-grouping totalUsageEventsCount used a strict Int decode, so a string-encoded count (the API serializes some numbers as strings) became nil and could short-circuit pagination. Route it through the lenient CursorEventNumber decoder like every other numeric field. Also fix the paginate/dedupe test whose third event timestamp crossed into the next UTC day: it expected one day-entry while the correct grouping produced two. Move it onto the same UTC day so it exercises single-day grouping and dedup as intended. --- .../Providers/Cursor/CursorUsageEventsFetcher.swift | 2 +- Tests/CodexBarTests/CursorUsageEventsFetcherTests.swift | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorUsageEventsFetcher.swift b/Sources/CodexBarCore/Providers/Cursor/CursorUsageEventsFetcher.swift index 7a19a7cb70..c54bef8c19 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorUsageEventsFetcher.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorUsageEventsFetcher.swift @@ -27,7 +27,7 @@ struct CursorUsageEventsPage: Decodable, Sendable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - self.totalUsageEventsCount = try? container.decode(Int.self, forKey: .totalUsageEventsCount) + self.totalUsageEventsCount = CursorEventNumber.int64(container, .totalUsageEventsCount).map { Int($0) } self.usageEventsDisplay = (try? container.decode([CursorUsageEvent].self, forKey: .usageEventsDisplay)) ?? [] } diff --git a/Tests/CodexBarTests/CursorUsageEventsFetcherTests.swift b/Tests/CodexBarTests/CursorUsageEventsFetcherTests.swift index e7b67c7f44..adec803dcc 100644 --- a/Tests/CodexBarTests/CursorUsageEventsFetcherTests.swift +++ b/Tests/CodexBarTests/CursorUsageEventsFetcherTests.swift @@ -184,8 +184,9 @@ struct CursorUsageEventsFetcherTests { let secondEvent = #""" {"timestamp":"1700003600000","model":"gpt-5","tokenUsage":{"inputTokens":10,"outputTokens":5,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":50},"chargedCents":4} """# + // 1_700_005_400_000 is 2023-11-14T23:43:20Z: a distinct event still inside the same UTC day. let thirdEvent = #""" - {"timestamp":"1700007200000","model":"gpt-5","tokenUsage":{"inputTokens":1,"outputTokens":1,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":25},"chargedCents":8} + {"timestamp":"1700005400000","model":"gpt-5","tokenUsage":{"inputTokens":1,"outputTokens":1,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":25},"chargedCents":8} """# let transport = ProviderHTTPTransportStub { request in From baacfb506606a42e4afdfa43e50ee0d07e39cd85 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 24 Jun 2026 16:49:23 -0700 Subject: [PATCH 06/17] fix(cursor): render cost inline with a windowed history chart - Add Cursor to the inline cost-dashboard allowlist and key the Preferences cost-status line off supportsTokenCost so the Cursor card shows cost like Claude/Codex. - Drop the cursor-specific all-time history feature (the "Fetch full Cursor cost history" toggle, its settings plumbing, the CLI --all flag, the "All time" label, and the history-span sizing). Cursor now uses the shared "History window: N days" setting, so the inline chart draws legible windowed bars instead of the full account history. - Keep the Cursor-metered total, now scoped to the selected window. --- .../CodexBar/InlineUsageDashboardContent.swift | 2 +- Sources/CodexBar/PreferencesGeneralPane.swift | 16 +++------------- Sources/CodexBar/SettingsStore+Defaults.swift | 10 ---------- .../SettingsStore+MenuObservation.swift | 1 - Sources/CodexBar/SettingsStore.swift | 2 -- Sources/CodexBar/SettingsStoreState.swift | 1 - Sources/CodexBar/UsageStore.swift | 6 +----- Sources/CodexBarCLI/CLICostCommand.swift | 5 ----- Sources/CodexBarCore/CostUsageFetcher.swift | 17 ++++------------- 9 files changed, 9 insertions(+), 51 deletions(-) diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index 82656c7c15..f0e79e2b5c 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -202,7 +202,7 @@ extension UsageMenuCardView.Model { { return Self.poeInlineDashboard(usage, now: input.now) } - if [.codex, .claude, .vertexai, .bedrock].contains(input.provider), + if [.codex, .claude, .vertexai, .bedrock, .cursor].contains(input.provider), input.tokenCostUsageEnabled, let tokenSnapshot = input.tokenSnapshot, !tokenSnapshot.daily.isEmpty diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 2dbcff3a05..b686b124ec 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -148,18 +148,6 @@ struct GeneralPane: View { .font(.footnote) .foregroundStyle(.tertiary) - Toggle(isOn: self.$settings.cursorFetchAllCostHistory) { - Text(L("Fetch full Cursor cost history")) - .font(.footnote) - } - .toggleStyle(.checkbox) - - Text( - L("Cursor only: total all-time spend instead of the window above. Slower to load.")) - .font(.footnote) - .foregroundStyle(.tertiary) - .fixedSize(horizontal: false, vertical: true) - self.costStatusLine(provider: .claude) self.costStatusLine(provider: .codex) self.costStatusLine(provider: .cursor) @@ -237,7 +225,9 @@ struct GeneralPane: View { private func costStatusLine(provider: UsageProvider) -> some View { let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName - guard provider == .claude || provider == .codex else { + // Any provider whose descriptor reports token-cost support gets a real status line; only + // providers that genuinely cannot report cost fall through to "unsupported". + guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost else { return Text(String(format: L("cost_status_unsupported"), name)) .font(.footnote) .foregroundStyle(.tertiary) diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index f90e04ac36..c10b5592e5 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -321,16 +321,6 @@ extension SettingsStore { } } - /// When enabled, Cursor cost fetches the full account history (all-time) instead of the - /// `costUsageHistoryDays` window. Cursor-only; other providers ignore it. - var cursorFetchAllCostHistory: Bool { - get { self.defaultsState.cursorFetchAllCostHistory } - set { - self.defaultsState.cursorFetchAllCostHistory = newValue - self.userDefaults.set(newValue, forKey: "cursorFetchAllCostHistory") - } - } - var hidePersonalInfo: Bool { get { self.defaultsState.hidePersonalInfo } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 0a05bb0072..e688237df7 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -34,7 +34,6 @@ extension SettingsStore { _ = self.copilotIconSecondaryWindowIDRaw _ = self.costUsageEnabled _ = self.costUsageHistoryDays - _ = self.cursorFetchAllCostHistory _ = self.appLanguage _ = self.hidePersonalInfo _ = self.randomBlinkEnabled diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 8b2a49c934..e9a087ca1d 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -377,7 +377,6 @@ extension SettingsStore { let costUsageEnabled = userDefaults.object(forKey: "tokenCostUsageEnabled") as? Bool ?? false let rawCostUsageHistoryDays = userDefaults.object(forKey: "tokenCostUsageHistoryDays") as? Int ?? 30 let costUsageHistoryDays = max(1, min(365, rawCostUsageHistoryDays)) - let cursorFetchAllCostHistory = userDefaults.object(forKey: "cursorFetchAllCostHistory") as? Bool ?? false let hidePersonalInfo = userDefaults.object(forKey: "hidePersonalInfo") as? Bool ?? false let randomBlinkEnabled = userDefaults.object(forKey: "randomBlinkEnabled") as? Bool ?? false let confettiOnWeeklyLimitResetsEnabled = userDefaults.object( @@ -452,7 +451,6 @@ extension SettingsStore { copilotIconSecondaryWindowIDRaw: copilotIconSecondaryWindowIDRaw, costUsageEnabled: costUsageEnabled, costUsageHistoryDays: costUsageHistoryDays, - cursorFetchAllCostHistory: cursorFetchAllCostHistory, hidePersonalInfo: hidePersonalInfo, randomBlinkEnabled: randomBlinkEnabled, confettiOnWeeklyLimitResetsEnabled: confettiOnWeeklyLimitResetsEnabled, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index f97194a4b7..58f7a02af4 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -34,7 +34,6 @@ struct SettingsDefaultsState { var copilotIconSecondaryWindowIDRaw: String var costUsageEnabled: Bool var costUsageHistoryDays: Int - var cursorFetchAllCostHistory: Bool var hidePersonalInfo: Bool var randomBlinkEnabled: Bool var confettiOnWeeklyLimitResetsEnabled: Bool diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index bc0ec360db..62a796b7d9 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -81,7 +81,6 @@ extension UsageStore { _ = self.settings.usageBarsShowUsed _ = self.settings.costUsageEnabled _ = self.settings.costUsageHistoryDays - _ = self.settings.cursorFetchAllCostHistory _ = self.settings.randomBlinkEnabled _ = self.settings.configRevision for implementation in ProviderCatalog.all { @@ -1505,8 +1504,6 @@ extension UsageStore { let now = Date() let historyDays = self.settings.costUsageHistoryDays - // Cursor can pull its full account history (all-time) instead of the rolling window. - let fetchAllHistory = provider == .cursor && self.settings.cursorFetchAllCostHistory // Cursor cost reuses the status cookie policy: a Manual source forwards the manual header so // cost and status share the same session; other sources fall back to auto resolution. let cursorCookieSource = self.settings.cursorCookieSource @@ -1516,7 +1513,7 @@ extension UsageStore { let costScope = self.tokenCostScope(for: provider) let cursorScopeSuffix = provider == .cursor ? "|cursorCookie=\(cursorCookieSource.rawValue)" : "" let costScopeSignature = - "\(costScope.signature)|historyDays=\(historyDays)|fetchAll=\(fetchAllHistory)\(cursorScopeSuffix)" + "\(costScope.signature)|historyDays=\(historyDays)\(cursorScopeSuffix)" if !force, let last = self.lastTokenFetchAt[provider], self.lastTokenFetchScope[provider] == costScopeSignature, @@ -1568,7 +1565,6 @@ extension UsageStore { allowVertexClaudeFallback: !self.isEnabled(.claude), codexHomePath: costScope.codexHomePath, historyDays: historyDays, - fetchAllHistory: fetchAllHistory, cursorCookieHeaderOverride: cursorCookieHeaderOverride) } group.addTask { diff --git a/Sources/CodexBarCLI/CLICostCommand.swift b/Sources/CodexBarCLI/CLICostCommand.swift index a4db37dd77..8c9fbccc6e 100644 --- a/Sources/CodexBarCLI/CLICostCommand.swift +++ b/Sources/CodexBarCLI/CLICostCommand.swift @@ -36,7 +36,6 @@ extension CodexBarCLI { let forceRefresh = values.flags.contains("refresh") let useColor = Self.shouldUseColor(noColor: values.flags.contains("noColor"), format: format) let historyDays = Self.decodeCostHistoryDays(from: values) - let fetchAllHistory = values.flags.contains("all") let fetcher = CostUsageFetcher() var sections: [String] = [] @@ -51,7 +50,6 @@ extension CodexBarCLI { provider: provider, forceRefresh: forceRefresh, historyDays: historyDays, - fetchAllHistory: fetchAllHistory, refreshPricingInBackground: false) switch format { case .text: @@ -272,9 +270,6 @@ struct CostOptions: CommanderParsable { @Option(name: .long("days"), help: "Cost history window in days (1...365)") var days: Int? - - @Flag(name: .long("all"), help: "Fetch full cost history instead of the --days window (Cursor only)") - var all: Bool = false } struct CostPayload: Encodable { diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 2d75504cf4..19a80a8161 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -48,7 +48,6 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: Bool = false, codexHomePath: String? = nil, historyDays: Int = 30, - fetchAllHistory: Bool = false, cursorCookieHeaderOverride: String? = nil, refreshPricingInBackground: Bool = true) async throws -> CostUsageTokenSnapshot { @@ -60,7 +59,6 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: allowVertexClaudeFallback, codexHomePath: codexHomePath, historyDays: historyDays, - fetchAllHistory: fetchAllHistory, cursorCookieHeaderOverride: cursorCookieHeaderOverride, refreshPricingInBackground: refreshPricingInBackground, scannerOptions: self.scannerOptionsOverride()) @@ -101,7 +99,6 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: Bool = false, codexHomePath: String? = nil, historyDays: Int = 30, - fetchAllHistory: Bool = false, cursorCookieHeaderOverride: String? = nil, refreshPricingInBackground: Bool = true, scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil, @@ -133,9 +130,8 @@ public struct CostUsageFetcher: Sendable { if provider == .cursor { return try await Self.loadCursorTokenSnapshot( now: now, - since: fetchAllHistory ? nil : since, + since: since, historyDays: clampedHistoryDays, - fetchAllHistory: fetchAllHistory, cookieHeaderOverride: cursorCookieHeaderOverride) } #endif @@ -313,15 +309,12 @@ public struct CostUsageFetcher: Sendable { #if os(macOS) /// Fetch Cursor's per-day token-cost plus its Cursor-metered total via the cookie-authenticated - /// dashboard API, reusing the same session resolution as the Cursor status probe. - /// - /// With `fetchAllHistory`, the window is the full account history, so the windowed totals are - /// themselves the all-time figures and the display label switches to "All time". + /// dashboard API, reusing the same session resolution as the Cursor status probe. Like Codex and + /// Claude, the report covers the rolling `historyDays` window. private static func loadCursorTokenSnapshot( now: Date, since: Date?, historyDays: Int, - fetchAllHistory: Bool, cookieHeaderOverride: String? = nil) async throws -> CostUsageTokenSnapshot { let probe = CursorStatusProbe(browserDetection: BrowserDetection()) @@ -329,14 +322,12 @@ public struct CostUsageFetcher: Sendable { since: since, until: now, cookieHeaderOverride: cookieHeaderOverride) - let historyLabel = fetchAllHistory ? "All time" : nil return Self.tokenSnapshot( from: report.daily, now: now, historyDays: historyDays, useCurrentLocalDayForSession: false, - meteredCostUSD: report.meteredCostUSD, - historyLabel: historyLabel) + meteredCostUSD: report.meteredCostUSD) } #endif From 6729df8b40fa8ac1e238763a0ca9f03433b66875 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 24 Jun 2026 17:29:09 -0700 Subject: [PATCH 07/17] fix(cursor): tie cost session line to the current local day loadCursorTokenSnapshot now derives the session value from the current local day instead of the latest history entry, so a stale day is never labeled "Today" in the menu or CLI. This matches the Codex/Claude cost window behavior. Adds a focused test covering the stale-latest case. --- Sources/CodexBarCore/CostUsageFetcher.swift | 5 ++-- .../CursorUsageEventsFetcherTests.swift | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 19a80a8161..5c367837f2 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -310,7 +310,8 @@ public struct CostUsageFetcher: Sendable { #if os(macOS) /// Fetch Cursor's per-day token-cost plus its Cursor-metered total via the cookie-authenticated /// dashboard API, reusing the same session resolution as the Cursor status probe. Like Codex and - /// Claude, the report covers the rolling `historyDays` window. + /// Claude, the report covers the rolling `historyDays` window and the session line is tied to the + /// current local day (so a stale latest entry is never labeled as Today). private static func loadCursorTokenSnapshot( now: Date, since: Date?, @@ -326,7 +327,7 @@ public struct CostUsageFetcher: Sendable { from: report.daily, now: now, historyDays: historyDays, - useCurrentLocalDayForSession: false, + useCurrentLocalDayForSession: true, meteredCostUSD: report.meteredCostUSD) } #endif diff --git a/Tests/CodexBarTests/CursorUsageEventsFetcherTests.swift b/Tests/CodexBarTests/CursorUsageEventsFetcherTests.swift index adec803dcc..eee9c15b23 100644 --- a/Tests/CodexBarTests/CursorUsageEventsFetcherTests.swift +++ b/Tests/CodexBarTests/CursorUsageEventsFetcherTests.swift @@ -142,6 +142,31 @@ struct CursorUsageEventsFetcherTests { #expect(CursorUsageEventsFetcher.meteredCostUSD(from: events) == nil) } + // MARK: - Snapshot + + @Test + func `session cost tracks the current local day, not the latest entry`() throws { + // Cursor labels the session line "Today", so a stale latest day must not leak into it. This + // mirrors loadCursorTokenSnapshot, which builds the snapshot with current-local-day semantics. + let calendar = Calendar.current + let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 5, day: 18, hour: 12))) + let twoDaysAgo = try #require(calendar.date(byAdding: .day, value: -2, to: now)) + let event = Self.event( + timestampMS: Int64(twoDaysAgo.timeIntervalSince1970 * 1000), + model: "claude-4.5-sonnet", + input: 100, + output: 50, + totalCents: 150) + + let report = CursorUsageEventsFetcher.makeDailyReport(from: [event], calendar: calendar) + let snapshot = CostUsageFetcher.tokenSnapshot(from: report, now: now, useCurrentLocalDayForSession: true) + + // No usage today -> session is zero, while the window total still reflects the older day. + #expect(snapshot.sessionCostUSD == 0) + #expect(snapshot.sessionTokens == 0) + #expect(Self.approxEqual(snapshot.last30DaysCostUSD, 1.5)) + } + // MARK: - Decoding @Test From f92c3d6e57d7dfb2fbb6e91a23a8f26898404401 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 24 Jun 2026 17:29:19 -0700 Subject: [PATCH 08/17] fix(cursor): show dashboard cost hint on the menu and inline cards tokenUsageHint had no Cursor case, so the shared menu/inline cost card fell back to the generic local-logs estimate copy for dashboard-derived Cursor data. Return the Cursor dashboard hint instead. --- Sources/CodexBar/MenuCardView+Costs.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index e70194f325..94abcccbff 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -129,6 +129,8 @@ extension UsageMenuCardView.Model { L("Estimated from local Codex logs for the selected account.") case .claude: UsageFormatter.costEstimateHint(provider: provider) + case .cursor: + UsageFormatter.costEstimateHint(provider: provider) case .vertexai: L("cost_estimate_hint") case .bedrock: From 86325054178babd095576526a73cb739710c4688 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 24 Jun 2026 17:29:32 -0700 Subject: [PATCH 09/17] fix(cursor): honor cookie source in CLI cost and gate to macOS CLI `cost --provider cursor` now resolves the configured Cursor cookie source the same way the usage path does: it skips the fetch (with a notice) when cookies are Off and forwards the Manual header so the dashboard request uses the configured session instead of auto-resolving a different one. Also gates Cursor in costSupportedProviders to macOS, since supportsTokenSnapshot(.cursor) is macOS-only and the CLI otherwise advertised Cursor cost on platforms where it can only fail. --- Sources/CodexBarCLI/CLICostCommand.swift | 39 +++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCLI/CLICostCommand.swift b/Sources/CodexBarCLI/CLICostCommand.swift index 8c9fbccc6e..4a116b85e7 100644 --- a/Sources/CodexBarCLI/CLICostCommand.swift +++ b/Sources/CodexBarCLI/CLICostCommand.swift @@ -3,7 +3,15 @@ import Commander import Foundation extension CodexBarCLI { - private static let costSupportedProviders: Set = [.claude, .codex, .cursor] + private static let costSupportedProviders: Set = { + #if os(macOS) + [.claude, .codex, .cursor] + #else + // Cursor cost relies on the macOS-only dashboard fetch path; `supportsTokenSnapshot(.cursor)` + // is false elsewhere, so don't advertise Cursor cost where it can only fail. + [.claude, .codex] + #endif + }() static func runCost(_ values: ParsedValues) async { let output = CLIOutputPreferences.from(values: values) @@ -36,6 +44,10 @@ extension CodexBarCLI { let forceRefresh = values.flags.contains("refresh") let useColor = Self.shouldUseColor(noColor: values.flags.contains("noColor"), format: format) let historyDays = Self.decodeCostHistoryDays(from: values) + // Cursor cost reuses the same cookie-source policy as usage fetches: skip the fetch when the + // user set Cursor cookies to Off, and forward the Manual header so the dashboard request uses + // the configured session instead of auto-resolving a different one. + let cursorCookieSettings = Self.cursorCookieSettings(config: config, providers: providers) let fetcher = CostUsageFetcher() var sections: [String] = [] @@ -43,13 +55,24 @@ extension CodexBarCLI { var exitCode: ExitCode = .success for provider in providers { + if provider == .cursor, cursorCookieSettings?.cookieSource == .off { + if !output.jsonOnly { + Self.writeStderr("Cursor cost skipped: cookie source is set to Off.\n") + } + continue + } do { + let cursorCookieHeaderOverride: String? = + provider == .cursor && cursorCookieSettings?.cookieSource == .manual + ? CookieHeaderNormalizer.normalize(cursorCookieSettings?.manualCookieHeader) + : nil // Claude/Codex cost comes from local logs; Cursor cost is fetched from its // cookie-authenticated dashboard API via the shared session resolution. let snapshot = try await fetcher.loadTokenSnapshot( provider: provider, forceRefresh: forceRefresh, historyDays: historyDays, + cursorCookieHeaderOverride: cursorCookieHeaderOverride, refreshPricingInBackground: false) switch format { case .text: @@ -233,6 +256,20 @@ extension CodexBarCLI { else { return 30 } return max(1, min(365, parsed)) } + + /// Resolve the configured Cursor cookie settings (source + manual header) the same way the CLI + /// usage path does, so Cursor cost honors Off/Manual instead of always auto-resolving a session. + private static func cursorCookieSettings( + config: CodexBarConfig, + providers: [UsageProvider]) -> ProviderSettingsSnapshot.CursorProviderSettings? + { + guard providers.contains(.cursor) else { return nil } + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + guard let context = try? TokenAccountCLIContext(selection: selection, config: config, verbose: false) + else { return nil } + let account = (try? context.resolvedAccounts(for: .cursor))?.first + return context.settingsSnapshot(for: .cursor, account: account)?.cursor + } } struct CostOptions: CommanderParsable { From 51c8bab446696a8e81d09d9268bdb06a60037a2f Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 24 Jun 2026 17:46:07 -0700 Subject: [PATCH 10/17] fix(cursor): snap cost window start to the local day boundary loadTokenSnapshot derives `since` as the current instant N-1 days back, which the Cursor path sent verbatim as `startDate`: a 1-day window became startDate == endDate == now (dropping all earlier events today), and wider windows lost the early hours of the first day. Snap the Cursor window start to the local day boundary so the dashboard query covers full days. --- Sources/CodexBarCore/CostUsageFetcher.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 5c367837f2..7e880739f7 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -319,8 +319,12 @@ public struct CostUsageFetcher: Sendable { cookieHeaderOverride: String? = nil) async throws -> CostUsageTokenSnapshot { let probe = CursorStatusProbe(browserDetection: BrowserDetection()) + // `since` arrives as the current instant N-1 days back; snap it to the local day boundary so + // the dashboard query keeps the full first day (and all of today for a 1-day window) instead + // of filtering out earlier events at the same time-of-day. + let windowStart = since.map { Calendar.current.startOfDay(for: $0) } let report = try await probe.fetchCostReport( - since: since, + since: windowStart, until: now, cookieHeaderOverride: cookieHeaderOverride) return Self.tokenSnapshot( From 9a8c8e9efe90550660f97b9ef4ea0893c3aac2d7 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 24 Jun 2026 17:46:17 -0700 Subject: [PATCH 11/17] fix(cursor): key cost cache on the manual cookie header The token-cost cache signature only recorded the Cursor cookie source, so pasting a different Manual cookie within the TTL kept showing the snapshot from the previously configured account. Fold an in-process hash of the manual header into the scope signature so a new cookie invalidates the cache. --- Sources/CodexBar/UsageStore.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 62a796b7d9..43d66df496 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1511,7 +1511,16 @@ extension UsageStore { ? CookieHeaderNormalizer.normalize(self.settings.cursorCookieHeader) : nil let costScope = self.tokenCostScope(for: provider) - let cursorScopeSuffix = provider == .cursor ? "|cursorCookie=\(cursorCookieSource.rawValue)" : "" + // Fold the manual cookie into the cache key (via an in-process hash, never the raw header) so + // pasting a different Cursor cookie invalidates a snapshot fetched within the TTL instead of + // showing the previous account's data. + let cursorScopeSuffix: String = if provider != .cursor { + "" + } else if let override = cursorCookieHeaderOverride { + "|cursorCookie=manual:\(override.hashValue)" + } else { + "|cursorCookie=\(cursorCookieSource.rawValue)" + } let costScopeSignature = "\(costScope.signature)|historyDays=\(historyDays)\(cursorScopeSuffix)" if !force, From 05fd50ba11ed8d50146883ccbe8d124642ca083e Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 24 Jun 2026 17:54:02 -0700 Subject: [PATCH 12/17] test(cursor): cover the cost window day-boundary snap Extracts the window-start normalization into CostUsageFetcher.cursorWindowStart and adds a focused regression test, including the historyDays == 1 case where `since` equals now and the window must still cover all of today rather than an empty exact-instant range. --- Sources/CodexBarCore/CostUsageFetcher.swift | 10 ++++++++- ...tUsageTokenSnapshotDaySelectionTests.swift | 21 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 7e880739f7..799fe9352b 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -307,6 +307,14 @@ public struct CostUsageFetcher: Sendable { environment: environment) } + /// Snap a Cursor window start to the local day boundary so the dashboard query keeps full days. + /// `since` arrives as the current instant N-1 days back, so a 1-day window would otherwise become + /// an empty exact-instant range; snapping to 00:00 keeps all of today (and the first day's early + /// hours for wider windows). + static func cursorWindowStart(_ since: Date?, calendar: Calendar = .current) -> Date? { + since.map { calendar.startOfDay(for: $0) } + } + #if os(macOS) /// Fetch Cursor's per-day token-cost plus its Cursor-metered total via the cookie-authenticated /// dashboard API, reusing the same session resolution as the Cursor status probe. Like Codex and @@ -322,7 +330,7 @@ public struct CostUsageFetcher: Sendable { // `since` arrives as the current instant N-1 days back; snap it to the local day boundary so // the dashboard query keeps the full first day (and all of today for a 1-day window) instead // of filtering out earlier events at the same time-of-day. - let windowStart = since.map { Calendar.current.startOfDay(for: $0) } + let windowStart = Self.cursorWindowStart(since) let report = try await probe.fetchCostReport( since: windowStart, until: now, diff --git a/Tests/CodexBarTests/CostUsageTokenSnapshotDaySelectionTests.swift b/Tests/CodexBarTests/CostUsageTokenSnapshotDaySelectionTests.swift index d587151c1e..fca7f96cd9 100644 --- a/Tests/CodexBarTests/CostUsageTokenSnapshotDaySelectionTests.swift +++ b/Tests/CodexBarTests/CostUsageTokenSnapshotDaySelectionTests.swift @@ -85,6 +85,27 @@ struct CostUsageTokenSnapshotDaySelectionTests { #expect(snapshot.sessionTokens == 300) } + @Test + func `cursor window start snaps to the local day boundary`() throws { + let calendar = Calendar.current + + // historyDays > 1: a midday instant several days back snaps to that day's 00:00. + let midday = try Self.localNoon(year: 2026, month: 5, day: 15) + let snapped = try #require(CostUsageFetcher.cursorWindowStart(midday, calendar: calendar)) + #expect(snapped == calendar.startOfDay(for: midday)) + #expect(snapped <= midday) + + // historyDays == 1: `since` is `now`, so the window must still cover all of today (00:00 today), + // not collapse to the current instant. + let now = try Self.localNoon(year: 2026, month: 5, day: 18) + let today = try #require(CostUsageFetcher.cursorWindowStart(now, calendar: calendar)) + #expect(today == calendar.startOfDay(for: now)) + #expect(calendar.isDate(today, inSameDayAs: now)) + #expect(today <= now) + + #expect(CostUsageFetcher.cursorWindowStart(nil, calendar: calendar) == nil) + } + @Test func `latest entry ignores invalid calendar dates`() { let latest = CostUsageTokenSnapshot.latestEntry(in: [ From 51dd178c98ff90f86d8863eb85d0b08dc3bf983b Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 24 Jun 2026 18:06:15 -0700 Subject: [PATCH 13/17] refactor(cursor): extract shared cost cookie-gating helpers Pull the Cursor cookie-policy logic out of runCost into reusable type-level helpers so the serve /cost route can apply the same rules: - costSupportedProviderNames(): one source of truth for the supported provider list used in user-facing messages - cursorCostShouldSkip(_:settings:): true when the cookie source is Off - cursorCostHeaderOverride(_:settings:): normalized Manual header or nil - cursorCookieSettings(...) is now internal and documented as shared No behavior change for the cost command; this only reshapes the code so the serve route can reuse it without duplication. --- Sources/CodexBarCLI/CLICostCommand.swift | 42 +++++++++++++++++------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/Sources/CodexBarCLI/CLICostCommand.swift b/Sources/CodexBarCLI/CLICostCommand.swift index 4a116b85e7..afd2207fd0 100644 --- a/Sources/CodexBarCLI/CLICostCommand.swift +++ b/Sources/CodexBarCLI/CLICostCommand.swift @@ -29,13 +29,9 @@ extension CodexBarCLI { } } guard !providers.isEmpty else { - let supportedNames = Self.costSupportedProviders - .map { ProviderDescriptorRegistry.descriptor(for: $0).metadata.displayName } - .sorted() - .joined(separator: ", ") Self.exit( code: .failure, - message: "Error: cost is only supported for \(supportedNames).", + message: "Error: cost is only supported for \(Self.costSupportedProviderNames()).", output: output, kind: .args) } @@ -55,24 +51,20 @@ extension CodexBarCLI { var exitCode: ExitCode = .success for provider in providers { - if provider == .cursor, cursorCookieSettings?.cookieSource == .off { + if Self.cursorCostShouldSkip(provider, settings: cursorCookieSettings) { if !output.jsonOnly { Self.writeStderr("Cursor cost skipped: cookie source is set to Off.\n") } continue } do { - let cursorCookieHeaderOverride: String? = - provider == .cursor && cursorCookieSettings?.cookieSource == .manual - ? CookieHeaderNormalizer.normalize(cursorCookieSettings?.manualCookieHeader) - : nil // Claude/Codex cost comes from local logs; Cursor cost is fetched from its // cookie-authenticated dashboard API via the shared session resolution. let snapshot = try await fetcher.loadTokenSnapshot( provider: provider, forceRefresh: forceRefresh, historyDays: historyDays, - cursorCookieHeaderOverride: cursorCookieHeaderOverride, + cursorCookieHeaderOverride: Self.cursorCostHeaderOverride(provider, settings: cursorCookieSettings), refreshPricingInBackground: false) switch format { case .text: @@ -257,9 +249,18 @@ extension CodexBarCLI { return max(1, min(365, parsed)) } + /// Human-readable list of providers that support a cost report, used by both `cost` and serve. + static func costSupportedProviderNames() -> String { + Self.costSupportedProviders + .map { ProviderDescriptorRegistry.descriptor(for: $0).metadata.displayName } + .sorted() + .joined(separator: ", ") + } + /// Resolve the configured Cursor cookie settings (source + manual header) the same way the CLI /// usage path does, so Cursor cost honors Off/Manual instead of always auto-resolving a session. - private static func cursorCookieSettings( + /// Shared by `cost` and the serve `/cost` route. + static func cursorCookieSettings( config: CodexBarConfig, providers: [UsageProvider]) -> ProviderSettingsSnapshot.CursorProviderSettings? { @@ -270,6 +271,23 @@ extension CodexBarCLI { let account = (try? context.resolvedAccounts(for: .cursor))?.first return context.settingsSnapshot(for: .cursor, account: account)?.cursor } + + /// Whether a Cursor cost fetch must be skipped because the user set the cookie source to Off. + static func cursorCostShouldSkip( + _ provider: UsageProvider, + settings: ProviderSettingsSnapshot.CursorProviderSettings?) -> Bool + { + provider == .cursor && settings?.cookieSource == .off + } + + /// Manual cookie header to forward for a Cursor cost fetch, or nil for auto/non-cursor sources. + static func cursorCostHeaderOverride( + _ provider: UsageProvider, + settings: ProviderSettingsSnapshot.CursorProviderSettings?) -> String? + { + guard provider == .cursor, settings?.cookieSource == .manual else { return nil } + return CookieHeaderNormalizer.normalize(settings?.manualCookieHeader) + } } struct CostOptions: CommanderParsable { From 933e16d6aef322264614a36fa75cb7ee4ce7d374 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 24 Jun 2026 18:06:23 -0700 Subject: [PATCH 14/17] fix(cursor): honor cookie source in serve /cost route The local serve /cost route fetched Cursor cost unconditionally, so it ignored the configured cookie source. Apply the shared gating helpers: - skip Cursor when the cookie source is Off - forward the Manual cookie header so served data matches the session - report supported providers dynamically via costSupportedProviderNames() This closes the merge-gate finding that serve /cost bypassed the same policy already enforced by the cost command. --- Sources/CodexBarCLI/CLIServeCommand.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCLI/CLIServeCommand.swift b/Sources/CodexBarCLI/CLIServeCommand.swift index c9ae89b097..751b0159f0 100644 --- a/Sources/CodexBarCLI/CLIServeCommand.swift +++ b/Sources/CodexBarCLI/CLIServeCommand.swift @@ -758,16 +758,23 @@ extension CodexBarCLI { let providers = Self.costProviders(from: selection) guard !providers.isEmpty else { - return Self.serveError(status: .badRequest, message: "cost is only supported for Claude and Codex") + return Self.serveError( + status: .badRequest, + message: "cost is only supported for \(Self.costSupportedProviderNames())") } + // Cursor cost honors the same cookie policy here as the `cost` command: skip when the source + // is Off and forward the Manual header so served data matches the configured session. + let cursorCookieSettings = Self.cursorCookieSettings(config: config, providers: providers) let fetcher = CostUsageFetcher() var payload: [CostPayload] = [] for provider in providers { + if Self.cursorCostShouldSkip(provider, settings: cursorCookieSettings) { continue } do { let snapshot = try await fetcher.loadTokenSnapshot( provider: provider, - forceRefresh: false) + forceRefresh: false, + cursorCookieHeaderOverride: Self.cursorCostHeaderOverride(provider, settings: cursorCookieSettings)) payload.append(Self.makeCostPayload(provider: provider, snapshot: snapshot, error: nil)) } catch { payload.append(Self.makeCostPayload(provider: provider, snapshot: nil, error: error)) From 43c87553e123ebd68d7bb472f8f374464bd2a61c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 1 Jul 2026 07:49:00 +0100 Subject: [PATCH 15/17] fix: report disabled Cursor cost source --- Sources/CodexBarCLI/CLICostCommand.swift | 31 +++++++++++++++++------ Sources/CodexBarCLI/CLIServeCommand.swift | 9 ++++--- Tests/CodexBarTests/CLICostTests.swift | 17 +++++++++++++ 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/Sources/CodexBarCLI/CLICostCommand.swift b/Sources/CodexBarCLI/CLICostCommand.swift index a87e2200ac..1b9a3443f5 100644 --- a/Sources/CodexBarCLI/CLICostCommand.swift +++ b/Sources/CodexBarCLI/CLICostCommand.swift @@ -40,7 +40,7 @@ extension CodexBarCLI { let forceRefresh = values.flags.contains("refresh") let useColor = Self.shouldUseColor(noColor: values.flags.contains("noColor"), format: format) let historyDays = Self.decodeCostHistoryDays(from: values) - // Cursor cost reuses the same cookie-source policy as usage fetches: skip the fetch when the + // Cursor cost reuses the same cookie-source policy as usage fetches: reject the fetch when the // user set Cursor cookies to Off, and forward the Manual header so the dashboard request uses // the configured session instead of auto-resolving a different one. let cursorCookieSettings = Self.cursorCookieSettings(config: config, providers: providers) @@ -51,9 +51,12 @@ extension CodexBarCLI { var exitCode: ExitCode = .success for provider in providers { - if Self.cursorCostShouldSkip(provider, settings: cursorCookieSettings) { - if !output.jsonOnly { - Self.writeStderr("Cursor cost skipped: cookie source is set to Off.\n") + if let error = Self.cursorCostAvailabilityError(provider, settings: cursorCookieSettings) { + exitCode = Self.mapError(error) + if format == .json { + payload.append(Self.makeCostPayload(provider: provider, snapshot: nil, error: error)) + } else if !output.jsonOnly { + Self.writeStderr("Error: \(error.localizedDescription)\n") } continue } @@ -272,12 +275,13 @@ extension CodexBarCLI { return context.settingsSnapshot(for: .cursor, account: account)?.cursor } - /// Whether a Cursor cost fetch must be skipped because the user set the cookie source to Off. - static func cursorCostShouldSkip( + /// Return the actionable error for a Cursor cost fetch disabled by cookie-source policy. + static func cursorCostAvailabilityError( _ provider: UsageProvider, - settings: ProviderSettingsSnapshot.CursorProviderSettings?) -> Bool + settings: ProviderSettingsSnapshot.CursorProviderSettings?) -> Error? { - provider == .cursor && settings?.cookieSource == .off + guard provider == .cursor, settings?.cookieSource == .off else { return nil } + return CursorCostAvailabilityError.cookieSourceOff } /// Manual cookie header to forward for a Cursor cost fetch, or nil for auto/non-cursor sources. @@ -290,6 +294,17 @@ extension CodexBarCLI { } } +enum CursorCostAvailabilityError: LocalizedError { + case cookieSourceOff + + var errorDescription: String? { + switch self { + case .cookieSourceOff: + "Cursor cost is unavailable because the Cursor cookie source is set to Off." + } + } +} + struct CostOptions: CommanderParsable { @Flag(names: [.short("v"), .long("verbose")], help: "Enable verbose logging") var verbose: Bool = false diff --git a/Sources/CodexBarCLI/CLIServeCommand.swift b/Sources/CodexBarCLI/CLIServeCommand.swift index ccc6497caf..22ab4df6ad 100644 --- a/Sources/CodexBarCLI/CLIServeCommand.swift +++ b/Sources/CodexBarCLI/CLIServeCommand.swift @@ -855,13 +855,16 @@ extension CodexBarCLI { message: "cost is only supported for \(Self.costSupportedProviderNames())") } - // Cursor cost honors the same cookie policy here as the `cost` command: skip when the source - // is Off and forward the Manual header so served data matches the configured session. + // Cursor cost honors the same cookie policy here as the `cost` command: return a provider + // error when the source is Off and forward the Manual header for an enabled fetch. let cursorCookieSettings = Self.cursorCookieSettings(config: config, providers: providers) let fetcher = CostUsageFetcher() var payload: [CostPayload] = [] for provider in providers { - if Self.cursorCostShouldSkip(provider, settings: cursorCookieSettings) { continue } + if let error = Self.cursorCostAvailabilityError(provider, settings: cursorCookieSettings) { + payload.append(Self.makeCostPayload(provider: provider, snapshot: nil, error: error)) + continue + } do { let snapshot = try await fetcher.loadTokenSnapshot( provider: provider, diff --git a/Tests/CodexBarTests/CLICostTests.swift b/Tests/CodexBarTests/CLICostTests.swift index 1b223bce56..522216fa60 100644 --- a/Tests/CodexBarTests/CLICostTests.swift +++ b/Tests/CodexBarTests/CLICostTests.swift @@ -152,4 +152,21 @@ struct CLICostTests { #expect(hint.contains("Estimated")) #expect(UsageFormatter.costEstimateHint(provider: .claude).contains("cache read/write tokens")) } + + @Test + func `cursor cookie source off produces a failed JSON payload`() throws { + let settings = ProviderSettingsSnapshot.CursorProviderSettings( + cookieSource: .off, + manualCookieHeader: nil) + let error = try #require(CodexBarCLI.cursorCostAvailabilityError(.cursor, settings: settings)) + let payload = CodexBarCLI.makeCostPayload(provider: .cursor, snapshot: nil, error: error) + let json = try #require(CodexBarCLI.encodeJSON([payload], pretty: false)) + + #expect(CodexBarCLI.mapError(error) == .failure) + #expect(json.contains("\"provider\":\"cursor\"")) + #expect(json.contains("\"code\":1")) + #expect(json.contains("cookie source is set to Off")) + #expect(CodexBarCLI.cursorCostAvailabilityError(.cursor, settings: nil) == nil) + #expect(CodexBarCLI.cursorCostAvailabilityError(.codex, settings: settings) == nil) + } } From 718963b3a2756e463c20a04c606b52f7f079c456 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 6 Jul 2026 11:21:25 -0700 Subject: [PATCH 16/17] Include Cursor auto session identity in cost cache scope --- Sources/CodexBar/UsageStore+TokenCost.swift | 12 ++++++++ Sources/CodexBar/UsageStore.swift | 7 ++++- .../UsageStoreCoverageTests.swift | 28 +++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 46f8d241f1..5cfa1ce67b 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -82,6 +82,18 @@ extension UsageStore { } } + /// Fingerprint of the Cursor account behind auto/cached cookie resolution, derived from the + /// latest status snapshot's identity (via an in-process hash, never the raw email). Used in the + /// cost cache scope so a silent account switch invalidates the TTL. + nonisolated static func cursorAutoIdentityFingerprint(_ snapshot: UsageSnapshot?) -> String { + guard let email = snapshot?.identity?.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines), + !email.isEmpty + else { + return "none" + } + return "\(email.lowercased().hashValue)" + } + nonisolated static func tokenCostRequiresProviderSnapshot(_ provider: UsageProvider) -> Bool { switch provider { case .mistral, .openai: diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 19f60cc233..cd14654b76 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1561,7 +1561,12 @@ extension UsageStore { } else if let override = cursorCookieHeaderOverride { "|cursorCookie=manual:\(override.hashValue)" } else { - "|cursorCookie=\(cursorCookieSource.rawValue)" + // Auto/cached resolution can silently switch Cursor accounts (e.g. the old cookie is + // rejected and a status refresh imports a new browser session). Fold the resolved + // account identity into the scope so an account change invalidates the TTL instead of + // returning the previous account's cost snapshot. + "|cursorCookie=\(cursorCookieSource.rawValue):" + + Self.cursorAutoIdentityFingerprint(self.snapshots[.cursor]) } let costScopeSignature = "\(costScope.signature)|historyDays=\(historyDays)\(cursorScopeSuffix)" diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index cb0e311892..1f8c5b821a 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -66,6 +66,34 @@ struct UsageStoreCoverageTests { #expect(store.isStale) } + @Test + func `cursor auto identity fingerprint tracks the resolved account`() { + func snapshot(email: String?) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .cursor, + accountEmail: email, + accountOrganization: nil, + loginMethod: nil)) + } + + // No snapshot or no resolved account: a stable sentinel, so the scope stays cacheable. + #expect(UsageStore.cursorAutoIdentityFingerprint(nil) == "none") + #expect(UsageStore.cursorAutoIdentityFingerprint(snapshot(email: nil)) == "none") + #expect(UsageStore.cursorAutoIdentityFingerprint(snapshot(email: " ")) == "none") + + // Same account (modulo case/whitespace) keeps the fingerprint; a different account changes + // it, which is what invalidates the cost TTL after a silent session switch. + let first = UsageStore.cursorAutoIdentityFingerprint(snapshot(email: "a@example.com")) + #expect(first == UsageStore.cursorAutoIdentityFingerprint(snapshot(email: " A@Example.com "))) + #expect(first != UsageStore.cursorAutoIdentityFingerprint(snapshot(email: "b@example.com"))) + // The raw email must never leak into the scope signature. + #expect(!first.contains("example.com")) + } + @Test func `source label adds open AI web`() { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-source") From 613342a2f7383c627211ecd5c991b97734505c61 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 6 Jul 2026 11:21:25 -0700 Subject: [PATCH 17/17] docs: describe Cursor dashboard cost source and CLI output --- docs/cli.md | 11 ++++++++--- docs/cursor.md | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 6e4e245884..7d7d032195 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -44,7 +44,9 @@ See `docs/configuration.md` for the schema. ## Command - `codexbar` defaults to the `usage` command. - `--format text|json` (default: text). -- `codexbar cost` prints local token cost usage for Claude + Codex without web/CLI access. +- `codexbar cost` prints token cost usage for Claude, Codex, and Cursor. + - Claude and Codex are scanned from local session logs without web/CLI access. + - Cursor is fetched from the cookie-authenticated cursor.com dashboard API (macOS only; see `docs/cursor.md`) and honors the configured cookie source: Manual headers are forwarded, and Off fails explicitly instead of silently omitting Cursor. - `--format text|json` (default: text). - `--refresh` ignores cached scans. - `codexbar serve` starts a foreground localhost-only HTTP server for usage and cost JSON. @@ -111,12 +113,14 @@ payloads include the visible account label in `account`. ### Cost JSON payload `codexbar cost --format json` emits an array of payloads (one per provider). -- `provider`, `source`, `updatedAt` +- `provider`, `source` (`local` for Claude/Codex log scans, `web` for Cursor dashboard data), `updatedAt` - `sessionTokens`, `sessionCostUSD` - `last30DaysTokens`, `last30DaysCostUSD` +- Cursor only: `meteredCostUSD` — what Cursor's plan actually deducts over the window, alongside the API-rate estimate in `last30DaysCostUSD`. - `daily[]`: `date`, `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheCreationTokens`, `totalTokens`, `totalCost`, `modelsUsed`, `modelBreakdowns[]` (`modelName`, `cost`) - Codex only: `projects[]`: `name`, `path`, `totalTokens`, `totalCost`, `daily[]`, `modelBreakdowns[]`, `sources[]` - `totals`: `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheCreationTokens`, `totalTokens`, `totalCost` +- `error`: structured provider error when a fetch fails (for example Cursor requested while its cookie source is Off). ## Example usage ``` @@ -125,10 +129,11 @@ codexbar --provider claude # force Claude codexbar --provider all # query all registered providers codexbar --format json --pretty # machine output codexbar --format json --provider both -codexbar cost # local cost usage (default 30-day window + today) +codexbar cost # cost usage (default 30-day window + today) codexbar cost --days 90 # choose a 1...365 day cost window codexbar cost --provider codex --group-by project codexbar cost --provider claude --format json --pretty +codexbar cost --provider cursor # Cursor dashboard cost (API-rate + Cursor-metered) codexbar serve --port 8080 # localhost HTTP JSON server codexbar serve --request-timeout 0 # disable serve request deadlines COPILOT_API_TOKEN=... codexbar --provider copilot --format json --pretty diff --git a/docs/cursor.md b/docs/cursor.md index 24be1e7b59..8b5a8a0039 100644 --- a/docs/cursor.md +++ b/docs/cursor.md @@ -74,6 +74,26 @@ When **Settings → Advanced → Track provider local storage** is enabled on ma The storage detail lists measured paths and their sizes. CodexBar does not delete Cursor data. +## Token cost (dashboard API) +The cost summary's Cursor section is opt-in: it only fetches when **Show cost summary** is enabled and the Cursor provider is on. +Unlike Claude and Codex cost (scanned from local session logs on this machine), Cursor cost is remote, account-wide data from the cursor.com dashboard, so it covers usage from every machine on the account. + +Auth reuses the exact status-probe session resolution and cookie-source policy: +- **Auto**: cached cookie header → browser cookie import → stored WebKit session → Cursor.app local auth. +- **Manual**: the pasted cookie header is forwarded as-is, so cost and status share the same session. +- **Off**: the fetch is skipped in the app; `codexbar cost --provider cursor` fails explicitly and `/cost` returns a provider error row. + +Fetch behavior: +- `POST https://cursor.com/api/dashboard/get-filtered-usage-events` (cookie-authenticated; requires a matching `Origin` for CSRF). +- Pages of 1000 events (up to 200 pages), deduplicated across pages before aggregation. +- The window start is snapped to the local day boundary so a 1-day window covers all of today and wider windows keep their full first day. + +Two totals are reported from the same events: +- **API-rate estimate**: vendor list price from each event's `tokenUsage` cents, aggregated per day/model (comparable to the Claude/Codex estimates). +- **Cursor-metered** (`meteredCostUSD`): what Cursor's plan actually deducts over the window, shown as its own "Cursor-metered:" line. + +Caching: the app holds the snapshot for an in-memory hourly TTL, keyed by the history window plus the cookie source and resolved account (manual-cookie hash or auto-mode account fingerprint), so switching accounts or pasting a new cookie invalidates it immediately. + ## Snapshot mapping - Primary: plan usage percent (included plan). - Secondary: Auto + Composer usage percent.