Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ See [CLI configuration](docs/cli-configuration.md) for the full flow.
- [OpenAI](docs/openai.md) — Admin API key usage/cost graphs with legacy credit-balance fallback.
- [Azure OpenAI](docs/azure-openai.md) — API key, endpoint, and deployment validation probe.
- [Claude](docs/claude.md) — OAuth API, browser cookies, or CLI PTY fallback; session and weekly usage where available.
- [Cursor](docs/cursor.md) — Browser session cookies for plan + usage + billing resets.
- [Cursor](docs/cursor.md) — Cursor app token or browser session cookies for plan + usage + billing resets.
- [OpenCode](docs/opencode.md) — Browser cookies for workspace subscription usage.
- [OpenCode Go](docs/opencode.md) — Browser or local SQLite data for Go usage windows.
- [Alibaba Coding Plan](docs/alibaba-coding-plan.md) — Web cookies or API key for coding-plan quotas.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,31 @@ struct CursorProviderImplementation: ProviderImplementation {

@MainActor
func presentation(context _: ProviderPresentationContext) -> ProviderPresentation {
ProviderPresentation { _ in "web" }
ProviderPresentation { context in
switch context.settings.cursorUsageDataSource {
case .app: "app"
case .web: "web"
case .auto: context.store.sourceLabel(for: .cursor)
}
}
}

@MainActor
func observeSettings(_ settings: SettingsStore) {
_ = settings.cursorUsageDataSource
_ = settings.cursorCookieSource
_ = settings.cursorCookieHeader
}

@MainActor
func sourceMode(context: ProviderSourceModeContext) -> ProviderSourceMode {
switch context.settings.cursorUsageDataSource {
case .auto: .auto
case .app: .oauth
Comment thread
markmay marked this conversation as resolved.
Comment thread
markmay marked this conversation as resolved.
case .web: .web
}
}

@MainActor
func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? {
.cursor(context.settings.cursorSettingsSnapshot(tokenOverride: context.tokenOverride))
Expand All @@ -34,10 +50,24 @@ struct CursorProviderImplementation: ProviderImplementation {
if settings.cursorCookieSource != .manual {
settings.cursorCookieSource = .manual
}
// Selecting a saved account is an explicit choice; leave app-token
// mode so the reactivated manual cookie actually runs.
if settings.cursorUsageDataSource == .app {
settings.cursorUsageDataSource = .auto
}
}

@MainActor
func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] {
let usageBinding = Binding(
get: { context.settings.cursorUsageDataSource.rawValue },
set: { raw in
context.settings.cursorUsageDataSource = CursorUsageDataSource(rawValue: raw) ?? .auto
})
let usageOptions = CursorUsageDataSource.allCases.map {
ProviderSettingsPickerOption(id: $0.rawValue, title: $0.displayName)
}

let cookieBinding = Binding(
get: { context.settings.cursorCookieSource.rawValue },
set: { raw in
Expand All @@ -57,14 +87,27 @@ struct CursorProviderImplementation: ProviderImplementation {
}

return [
ProviderSettingsPickerDescriptor(
id: "cursor-usage-source",
title: "Usage source",
subtitle: "Auto prefers the Cursor app's local sign-in and falls back to browser cookies.",
binding: usageBinding,
options: usageOptions,
isVisible: nil,
onChange: nil,
trailingText: {
guard context.settings.cursorUsageDataSource == .auto else { return nil }
let label = context.store.sourceLabel(for: .cursor)
return label == "auto" ? nil : label
}),
ProviderSettingsPickerDescriptor(
id: "cursor-cookie-source",
title: "Cookie source",
subtitle: "Automatic imports browser cookies or stored sessions.",
dynamicSubtitle: cookieSubtitle,
binding: cookieBinding,
options: cookieOptions,
isVisible: nil,
isVisible: { context.settings.cursorUsageDataSource != .app },
onChange: nil,
trailingText: {
ProviderCookieSourceUI.cachedTrailingText(provider: .cursor)
Expand Down
39 changes: 39 additions & 0 deletions Sources/CodexBar/Providers/Cursor/CursorSettingsStore.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
import CodexBarCore
import Foundation

enum CursorUsageDataSource: String, CaseIterable, Identifiable, Sendable {
case auto
case app
case web

var id: String {
self.rawValue
}

var displayName: String {
switch self {
case .auto: "Auto"
case .app: "Cursor App Token"
case .web: "Browser Cookies"
}
}
}

extension SettingsStore {
var cursorCookieHeader: String {
get { self.configSnapshot.providerConfig(for: .cursor)?.sanitizedCookieHeader ?? "" }
Expand All @@ -22,6 +40,27 @@ extension SettingsStore {
}
}

var cursorUsageDataSource: CursorUsageDataSource {
get {
switch self.configSnapshot.providerConfig(for: .cursor)?.source {
case .oauth: .app
case .web, .cli: .web
default: .auto
}
}
set {
let source: ProviderSourceMode? = switch newValue {
case .auto: .auto
case .app: .oauth
case .web: .web
}
self.updateProviderConfig(provider: .cursor) { entry in
entry.source = source
}
self.logProviderModeChange(provider: .cursor, field: "usageSource", value: newValue.rawValue)
}
}

func ensureCursorCookieLoaded() {}
}

Expand Down
5 changes: 5 additions & 0 deletions Sources/CodexBar/SettingsStore+TokenAccounts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ extension SettingsStore {
{
return nil
}
// App-token usage bypasses cookie and token accounts entirely; its
// snapshots must never be attributed to a saved account.
if provider == .cursor, self.cursorUsageDataSource == .app {
return nil
}
return self.selectedTokenAccount(for: provider)
}

Expand Down
98 changes: 87 additions & 11 deletions Sources/CodexBar/UsageStore+TokenCost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,25 +21,81 @@ struct TokenSnapshotPublication: Sendable, Equatable {
extension UsageStore {
enum CursorCostCookiePreparation {
case proceed(String?)
case skip
case reject
}

/// `prepareCursorCostCookie` plus the refresh-loop side effect: a skipped
/// fetch (cookie ladder Off, no app-token session) clears any stale state.
func prepareCursorCostCookieForRefresh(_ provider: UsageProvider) -> CursorCostCookiePreparation {
let preparation = self.prepareCursorCostCookie(for: provider)
if case .skip = preparation {
self.resetTokenUsageState(for: provider)
}
return preparation
}

func prepareCursorCostCookie(for provider: UsageProvider) -> CursorCostCookiePreparation {
// Provider-specific by design: Cursor's dashboard cost fetch consumes its manually selected browser cookie.
guard provider == .cursor, self.settings.cursorCookieSource == .manual else {
guard provider == .cursor else {
return .proceed(nil)
}
guard let header = CookieHeaderNormalizer.normalize(self.settings.cursorCookieHeader) else {
self.lastTokenFetchAt.removeValue(forKey: provider.instanceID)
self.lastTokenFetchScope.removeValue(forKey: provider.instanceID)
self.clearTokenSnapshot(for: provider)
self.tokenErrors[provider.instanceID] = "Cursor cost requires a non-empty Manual cookie header."
self.tokenFailureGates[provider.instanceID]?.reset()
return .reject
// App-token mode: cost must use the same app-token session as usage —
// never manual, cached, or browser cookies for a possibly different account.
if self.settings.cursorUsageDataSource == .app {
guard let header = CursorStatusProbe.appAuthCookieHeader() else {
return self.rejectCursorCost(
provider,
message: "Cursor cost requires the Cursor app to be signed in.")
}
return .proceed(header)
}
// Resolve the same account-aware settings the usage fetch context uses,
// so a selected token account defers the app token here exactly like it
// does for the usage refresh.
let resolved = self.cursorCostCookieSettings()
// Auto mode: when the usage pipeline would win with the app token, pin
// cost to that same session instead of resolving cached/browser cookies
// that may belong to a different account.
if self.settings.cursorUsageDataSource == .auto,
let header = CursorStatusProbe.autoModeAppAuthCookieHeader(cursorSettings: resolved)
{
return .proceed(header)
}
// Off disables only the cookie ladder (the app-token branches above
// bypass it, matching the CLI); without an app-token session there is
// no account left to fetch cost with, so stay off silently.
guard resolved.cookieSource != .off else {
return .skip
}
guard resolved.cookieSource == .manual else {
return .proceed(nil)
}
guard let header = CookieHeaderNormalizer.normalize(resolved.manualCookieHeader) else {
return self.rejectCursorCost(
provider,
message: "Cursor cost requires a non-empty Manual cookie header.")
}
return .proceed(header)
}

private func rejectCursorCost(
_ provider: UsageProvider,
message: String) -> CursorCostCookiePreparation
{
self.lastTokenFetchAt.removeValue(forKey: provider.instanceID)
self.lastTokenFetchScope.removeValue(forKey: provider.instanceID)
self.clearTokenSnapshot(for: provider)
self.tokenErrors[provider.instanceID] = message
self.tokenFailureGates[provider.instanceID]?.reset()
return .reject
}

/// Account-aware Cursor cookie settings, resolved exactly like the usage
/// fetch context (a selected token account becomes the manual header).
private func cursorCostCookieSettings() -> ProviderSettingsSnapshot.CursorProviderSettings {
self.settings.cursorSettingsSnapshot(tokenOverride: nil)
}

func loadTokenUsageSnapshot(
provider: UsageProvider,
force: Bool,
Expand Down Expand Up @@ -325,9 +381,28 @@ extension UsageStore {
return base
}

let source = self.settings.cursorCookieSource
if self.settings.cursorUsageDataSource == .app {
let headerFingerprint = CursorStatusProbe.appAuthCookieHeader()
.map(CookieHeaderCache.credentialFingerprint) ?? "missing"
return "\(base)|cursorCookie=app:\(headerFingerprint)"
}

// Mirror prepareCursorCostCookie: resolve the same account-aware
// settings, and cache auto-mode cost pinned to the app token under
// that credential, not the cookie ladder's.
let resolved = self.cursorCostCookieSettings()
if self.settings.cursorUsageDataSource == .auto,
let appHeader = CursorStatusProbe.autoModeAppAuthCookieHeader(cursorSettings: resolved)
{
return self.cursorCostScopeSignature(
historyDays: historyDays,
source: .auto,
credentialFingerprint: CookieHeaderCache.credentialFingerprint(appHeader))
}

let source = resolved.cookieSource
if source == .manual {
let headerFingerprint = CookieHeaderNormalizer.normalize(self.settings.cursorCookieHeader)
let headerFingerprint = CookieHeaderNormalizer.normalize(resolved.manualCookieHeader)
.map(CookieHeaderCache.credentialFingerprint) ?? "missing"
return "\(base)|cursorCookie=manual:\(headerFingerprint)"
}
Expand Down Expand Up @@ -404,6 +479,7 @@ extension UsageStore {
snapshot: CostUsageTokenSnapshot) -> String
{
guard provider == .cursor,
self.settings.cursorUsageDataSource != .app,
self.settings.cursorCookieSource == .auto,
let fingerprint = snapshot.credentialScopeFingerprint
else { return initialSignature }
Expand Down
19 changes: 8 additions & 11 deletions Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1429,23 +1429,20 @@ extension UsageStore {
return
}

// Provider-specific by design: Cursor cost shares the dashboard-cookie source policy with status fetching.
// 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.resetTokenUsageState(for: provider)
// 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. Decided
// before the in-flight guard so switching to a skipped or rejected configuration clears stale
// cost state even while an older fetch is still running.
guard case let .proceed(cursorCookieHeaderOverride) =
self.prepareCursorCostCookieForRefresh(provider)
Comment thread
markmay marked this conversation as resolved.
else {
return
}

guard !self.tokenRefreshInFlight.contains(provider.instanceID) else { return }

let now = Date()
let historyDays = self.settings.costUsageHistoryDays
// 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.
guard case let .proceed(cursorCookieHeaderOverride) = self.prepareCursorCostCookie(for: provider) else {
return
}
let costScope = self.tokenCostScope(for: provider)
let costScopeSignature = self.tokenSnapshotScopeSignature(for: provider)
let publicationRevision = self.providerPublicationRevision(for: provider)
Expand Down Expand Up @@ -1569,7 +1566,7 @@ extension UsageStore {
}
}

private func resetTokenUsageState(for provider: UsageProvider) {
func resetTokenUsageState(for provider: UsageProvider) {
// Provider-specific by design: resetting Codex token state also cancels its two ledger catch-up workflows.
if provider == .codex {
self.cancelCodexCostCatchUp()
Expand Down
Loading