diff --git a/Sources/App/ClaudeBarApp.swift b/Sources/App/ClaudeBarApp.swift
index 8d1e4a75..904c33fd 100644
--- a/Sources/App/ClaudeBarApp.swift
+++ b/Sources/App/ClaudeBarApp.swift
@@ -85,6 +85,7 @@ struct ClaudeBarApp: App {
settingsRepository: settingsRepository
),
KiroProvider(probe: KiroUsageProbe(), settingsRepository: settingsRepository),
+ CursorProvider(probe: CursorUsageProbe(), settingsRepository: settingsRepository),
MiniMaxProvider(
probe: MiniMaxUsageProbe(settingsRepository: settingsRepository),
settingsRepository: settingsRepository
diff --git a/Sources/App/Resources/Assets.xcassets/CursorIcon.imageset/Contents.json b/Sources/App/Resources/Assets.xcassets/CursorIcon.imageset/Contents.json
new file mode 100644
index 00000000..d53c7bf3
--- /dev/null
+++ b/Sources/App/Resources/Assets.xcassets/CursorIcon.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "CursorIcon.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true
+ }
+}
diff --git a/Sources/App/Resources/Assets.xcassets/CursorIcon.imageset/CursorIcon.svg b/Sources/App/Resources/Assets.xcassets/CursorIcon.imageset/CursorIcon.svg
new file mode 100644
index 00000000..2eb7c8bb
--- /dev/null
+++ b/Sources/App/Resources/Assets.xcassets/CursorIcon.imageset/CursorIcon.svg
@@ -0,0 +1,4 @@
+
diff --git a/Sources/App/Resources/CursorIcon.svg b/Sources/App/Resources/CursorIcon.svg
new file mode 100644
index 00000000..2eb7c8bb
--- /dev/null
+++ b/Sources/App/Resources/CursorIcon.svg
@@ -0,0 +1,4 @@
+
diff --git a/Sources/App/Views/ProviderVisualIdentity.swift b/Sources/App/Views/ProviderVisualIdentity.swift
index cb31499a..7fe942b4 100644
--- a/Sources/App/Views/ProviderVisualIdentity.swift
+++ b/Sources/App/Views/ProviderVisualIdentity.swift
@@ -296,6 +296,34 @@ extension KiroProvider: ProviderVisualIdentity {
}
}
+// MARK: - CursorProvider Visual Identity
+
+extension CursorProvider: ProviderVisualIdentity {
+ public var symbolIcon: String { "cursorarrow.rays" }
+
+ public var iconAssetName: String { "CursorIcon" }
+
+ public func themeColor(for scheme: ColorScheme) -> Color {
+ // Cursor brand teal/cyan
+ scheme == .dark
+ ? Color(red: 0.20, green: 0.78, blue: 0.82)
+ : Color(red: 0.12, green: 0.62, blue: 0.66)
+ }
+
+ public func themeGradient(for scheme: ColorScheme) -> LinearGradient {
+ LinearGradient(
+ colors: [
+ themeColor(for: scheme),
+ scheme == .dark
+ ? Color(red: 0.15, green: 0.55, blue: 0.75)
+ : Color(red: 0.08, green: 0.45, blue: 0.60)
+ ],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ }
+}
+
// MARK: - MiniMaxProvider Visual Identity
extension MiniMaxProvider: ProviderVisualIdentity {
@@ -412,6 +440,10 @@ enum ProviderVisualIdentityLookup {
return scheme == .dark
? Color(red: 0.91, green: 0.27, blue: 0.42)
: Color(red: 0.82, green: 0.20, blue: 0.35)
+ case "cursor":
+ return scheme == .dark
+ ? Color(red: 0.20, green: 0.78, blue: 0.82)
+ : Color(red: 0.12, green: 0.62, blue: 0.66)
default:
return BaseTheme.purpleVibrant
}
@@ -467,6 +499,10 @@ enum ProviderVisualIdentityLookup {
secondaryColor = scheme == .dark
? Color(red: 0.96, green: 0.53, blue: 0.24)
: Color(red: 0.86, green: 0.43, blue: 0.14)
+ case "cursor":
+ secondaryColor = scheme == .dark
+ ? Color(red: 0.15, green: 0.55, blue: 0.75)
+ : Color(red: 0.08, green: 0.45, blue: 0.60)
default:
return LinearGradient(
colors: [BaseTheme.coralAccent, BaseTheme.pinkHot],
@@ -496,6 +532,7 @@ enum ProviderVisualIdentityLookup {
case "kimi": return "KimiIcon"
case "kiro": return "KiroIcon"
case "minimax": return "MiniMaxIcon"
+ case "cursor": return "CursorIcon"
default: return "QuestionIcon"
}
}
@@ -514,6 +551,7 @@ enum ProviderVisualIdentityLookup {
case "kimi": return "Kimi"
case "kiro": return "Kiro"
case "minimax": return "MiniMax"
+ case "cursor": return "Cursor"
default: return providerId.capitalized
}
}
@@ -532,6 +570,7 @@ enum ProviderVisualIdentityLookup {
case "kimi": return "k.square.fill"
case "kiro": return "wand.and.stars.inverse"
case "minimax": return "waveform"
+ case "cursor": return "cursorarrow.rays"
default: return "questionmark.circle.fill"
}
}
diff --git a/Sources/Domain/Provider/Cursor/CursorProvider.swift b/Sources/Domain/Provider/Cursor/CursorProvider.swift
new file mode 100644
index 00000000..2c4e77a0
--- /dev/null
+++ b/Sources/Domain/Provider/Cursor/CursorProvider.swift
@@ -0,0 +1,83 @@
+import Foundation
+import Observation
+
+/// Cursor AI provider - a rich domain model.
+/// Observable class with its own state (isSyncing, snapshot, error).
+@Observable
+public final class CursorProvider: AIProvider, @unchecked Sendable {
+ // MARK: - Identity (Protocol Requirement)
+
+ public let id: String = "cursor"
+ public let name: String = "Cursor"
+ public let cliCommand: String = "cursor"
+
+ public var dashboardURL: URL? {
+ URL(string: "https://www.cursor.com/settings")
+ }
+
+ public var statusPageURL: URL? {
+ nil
+ }
+
+ /// Whether the provider is enabled (persisted via settingsRepository)
+ public var isEnabled: Bool {
+ didSet {
+ settingsRepository.setEnabled(isEnabled, forProvider: id)
+ }
+ }
+
+ // MARK: - State (Observable)
+
+ /// Whether the provider is currently syncing data
+ public private(set) var isSyncing: Bool = false
+
+ /// The current usage snapshot (nil if never refreshed or unavailable)
+ public private(set) var snapshot: UsageSnapshot?
+
+ /// The last error that occurred during refresh
+ public private(set) var lastError: Error?
+
+ // MARK: - Internal
+
+ /// The probe for fetching usage data via Cursor's API
+ private let probe: any UsageProbe
+
+ /// The settings repository for persisting provider settings
+ private let settingsRepository: any ProviderSettingsRepository
+
+ // MARK: - Initialization
+
+ /// Creates a Cursor provider
+ /// - Parameters:
+ /// - probe: The probe to use for fetching usage data
+ /// - settingsRepository: The repository for persisting settings
+ public init(probe: any UsageProbe, settingsRepository: any ProviderSettingsRepository) {
+ self.probe = probe
+ self.settingsRepository = settingsRepository
+ self.isEnabled = settingsRepository.isEnabled(forProvider: "cursor")
+ }
+
+ // MARK: - AIProvider Protocol
+
+ public func isAvailable() async -> Bool {
+ await probe.isAvailable()
+ }
+
+ /// Refreshes the usage data and updates the snapshot.
+ /// Sets isSyncing during refresh and captures any errors.
+ @discardableResult
+ public func refresh() async throws -> UsageSnapshot {
+ isSyncing = true
+ defer { isSyncing = false }
+
+ do {
+ let newSnapshot = try await probe.probe()
+ snapshot = newSnapshot
+ lastError = nil
+ return newSnapshot
+ } catch {
+ lastError = error
+ throw error
+ }
+ }
+}
diff --git a/Sources/Infrastructure/Cursor/CursorUsageProbe.swift b/Sources/Infrastructure/Cursor/CursorUsageProbe.swift
new file mode 100644
index 00000000..af12f303
--- /dev/null
+++ b/Sources/Infrastructure/Cursor/CursorUsageProbe.swift
@@ -0,0 +1,315 @@
+import Foundation
+import Domain
+
+/// Infrastructure adapter that probes Cursor's usage API to fetch quota data.
+///
+/// Cursor stores its authentication in a local SQLite database. This probe:
+/// 1. Reads the access token from `state.vscdb`
+/// 2. Decodes the JWT to extract the user ID
+/// 3. Calls `https://cursor.com/api/usage-summary` with cookie auth
+/// 4. Parses the response into quota percentages
+///
+/// The auth cookie format is: `WorkosCursorSessionToken={userId}::{accessToken}`
+///
+/// Actual API response shape (usage-summary):
+/// ```json
+/// {
+/// "membershipType": "ultra",
+/// "isUnlimited": false,
+/// "billingCycleStart": "2026-02-06T03:34:49.000Z",
+/// "billingCycleEnd": "2026-03-06T03:34:49.000Z",
+/// "individualUsage": {
+/// "plan": {
+/// "enabled": true,
+/// "used": 326,
+/// "limit": 40000,
+/// "remaining": 39674
+/// },
+/// "onDemand": {
+/// "enabled": false,
+/// "used": 0,
+/// "limit": null,
+/// "remaining": null
+/// }
+/// }
+/// }
+/// ```
+public struct CursorUsageProbe: UsageProbe {
+ private let networkClient: any NetworkClient
+ private let timeout: TimeInterval
+ private let dbPathOverride: String?
+
+ private static let usageSummaryURL = "https://cursor.com/api/usage-summary"
+
+ /// The default path to Cursor's SQLite database on macOS
+ static let defaultDatabasePath: String = {
+ let home = FileManager.default.homeDirectoryForCurrentUser.path
+ return "\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb"
+ }()
+
+ public init(
+ networkClient: any NetworkClient = URLSession.shared,
+ timeout: TimeInterval = 15.0,
+ dbPathOverride: String? = nil
+ ) {
+ self.networkClient = networkClient
+ self.timeout = timeout
+ self.dbPathOverride = dbPathOverride
+ }
+
+ // MARK: - UsageProbe
+
+ public func isAvailable() async -> Bool {
+ let dbPath = dbPathOverride ?? Self.defaultDatabasePath
+ let dbExists = FileManager.default.fileExists(atPath: dbPath)
+ if !dbExists {
+ AppLog.probes.debug("Cursor: Database not found at \(dbPath)")
+ }
+ return dbExists
+ }
+
+ public func probe() async throws -> UsageSnapshot {
+ let dbPath = dbPathOverride ?? Self.defaultDatabasePath
+
+ guard FileManager.default.fileExists(atPath: dbPath) else {
+ AppLog.probes.error("Cursor: Database not found at \(dbPath)")
+ throw ProbeError.cliNotFound("Cursor (database not found)")
+ }
+
+ AppLog.probes.info("Cursor: Reading auth token from database...")
+
+ let accessToken = try readAccessToken(from: dbPath)
+ let userId = try Self.extractUserIdFromJWT(accessToken)
+ let cookie = "WorkosCursorSessionToken=\(userId)::\(accessToken)"
+
+ AppLog.probes.info("Cursor: Fetching usage summary...")
+
+ let response = try await fetchUsageSummary(cookie: cookie)
+ let snapshot = try Self.parseUsageSummary(response)
+
+ AppLog.probes.info("Cursor: Probe success - \(snapshot.quotas.count) quotas found")
+ return snapshot
+ }
+
+ // MARK: - Token Extraction
+
+ /// Reads the access token from Cursor's SQLite database using the sqlite3 CLI.
+ private func readAccessToken(from dbPath: String) throws -> String {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/sqlite3")
+ process.arguments = [dbPath, "SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken'"]
+
+ let pipe = Pipe()
+ process.standardOutput = pipe
+ process.standardError = Pipe()
+
+ do {
+ try process.run()
+ process.waitUntilExit()
+ } catch {
+ AppLog.probes.error("Cursor: Failed to run sqlite3 - \(error.localizedDescription)")
+ throw ProbeError.executionFailed("Failed to read Cursor database: \(error.localizedDescription)")
+ }
+
+ guard process.terminationStatus == 0 else {
+ AppLog.probes.error("Cursor: sqlite3 exited with status \(process.terminationStatus)")
+ throw ProbeError.executionFailed("sqlite3 exited with status \(process.terminationStatus)")
+ }
+
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
+ let token = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+
+ guard !token.isEmpty else {
+ AppLog.probes.error("Cursor: No access token found in database (not logged in?)")
+ throw ProbeError.authenticationRequired
+ }
+
+ return token
+ }
+
+ /// Extracts the user ID (`sub` claim) from a JWT token by base64-decoding the payload.
+ static func extractUserIdFromJWT(_ token: String) throws -> String {
+ let parts = token.split(separator: ".")
+ guard parts.count >= 2 else {
+ throw ProbeError.parseFailed("Invalid JWT format")
+ }
+
+ // JWT payload is base64url-encoded
+ var base64 = String(parts[1])
+ .replacingOccurrences(of: "-", with: "+")
+ .replacingOccurrences(of: "_", with: "/")
+
+ // Pad to multiple of 4
+ let remainder = base64.count % 4
+ if remainder > 0 {
+ base64 += String(repeating: "=", count: 4 - remainder)
+ }
+
+ guard let payloadData = Data(base64Encoded: base64) else {
+ throw ProbeError.parseFailed("Failed to decode JWT payload")
+ }
+
+ guard let json = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any],
+ let sub = json["sub"] as? String, !sub.isEmpty else {
+ throw ProbeError.parseFailed("JWT payload missing 'sub' claim")
+ }
+
+ return sub
+ }
+
+ // MARK: - API Call
+
+ private func fetchUsageSummary(cookie: String) async throws -> Data {
+ guard let url = URL(string: Self.usageSummaryURL) else {
+ throw ProbeError.executionFailed("Invalid URL")
+ }
+
+ var request = URLRequest(url: url)
+ request.httpMethod = "GET"
+ request.setValue(cookie, forHTTPHeaderField: "Cookie")
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.timeoutInterval = timeout
+
+ let (data, response) = try await networkClient.request(request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw ProbeError.executionFailed("Invalid response")
+ }
+
+ AppLog.probes.debug("Cursor: API response status \(httpResponse.statusCode)")
+
+ switch httpResponse.statusCode {
+ case 200:
+ return data
+ case 401:
+ AppLog.probes.error("Cursor: Authentication failed (401) - token may be expired")
+ throw ProbeError.sessionExpired
+ case 403:
+ AppLog.probes.error("Cursor: Forbidden (403)")
+ throw ProbeError.authenticationRequired
+ default:
+ AppLog.probes.error("Cursor: HTTP error \(httpResponse.statusCode)")
+ throw ProbeError.executionFailed("HTTP error: \(httpResponse.statusCode)")
+ }
+ }
+
+ // MARK: - Response Parsing (static for testability)
+
+ /// Parses the Cursor usage-summary API response into a UsageSnapshot.
+ ///
+ /// The API returns usage nested under `individualUsage.plan` and `individualUsage.onDemand`.
+ public static func parseUsageSummary(_ data: Data) throws -> UsageSnapshot {
+ let json: [String: Any]
+ do {
+ guard let parsed = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ throw ProbeError.parseFailed("Response is not a JSON object")
+ }
+ json = parsed
+ } catch let error as ProbeError {
+ throw error
+ } catch {
+ throw ProbeError.parseFailed("Invalid JSON: \(error.localizedDescription)")
+ }
+
+ var quotas: [UsageQuota] = []
+
+ let membershipType = json["membershipType"] as? String ?? "unknown"
+
+ // Parse billing cycle dates for reset time
+ var resetsAt: Date?
+ if let cycleEnd = json["billingCycleEnd"] as? String {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let date = formatter.date(from: cycleEnd) {
+ resetsAt = date
+ } else {
+ // Try without fractional seconds
+ formatter.formatOptions = [.withInternetDateTime]
+ resetsAt = formatter.date(from: cycleEnd)
+ }
+ }
+
+ // The API nests usage under "individualUsage" with "plan" and "onDemand" sub-objects
+ let individualUsage = json["individualUsage"] as? [String: Any]
+
+ // Parse plan usage (included requests)
+ if let planUsage = individualUsage?["plan"] as? [String: Any],
+ let enabled = planUsage["enabled"] as? Bool, enabled {
+ let used = Self.intValue(from: planUsage, key: "used") ?? 0
+ let limit = Self.intValue(from: planUsage, key: "limit") ?? 0
+
+ if limit > 0 {
+ let percentRemaining = Double(limit - used) / Double(limit) * 100
+ let requestsText = "\(used)/\(limit) requests"
+
+ quotas.append(UsageQuota(
+ percentRemaining: max(0, percentRemaining),
+ quotaType: .timeLimit("Monthly"),
+ providerId: "cursor",
+ resetsAt: resetsAt,
+ resetText: requestsText
+ ))
+ }
+ }
+
+ // Parse on-demand usage (usage-based pricing)
+ if let onDemand = individualUsage?["onDemand"] as? [String: Any],
+ let enabled = onDemand["enabled"] as? Bool, enabled {
+ let used = Self.intValue(from: onDemand, key: "used") ?? 0
+ let limit = Self.intValue(from: onDemand, key: "limit") ?? 0
+
+ if limit > 0 {
+ let percentRemaining = Double(limit - used) / Double(limit) * 100
+ quotas.append(UsageQuota(
+ percentRemaining: max(0, percentRemaining),
+ quotaType: .timeLimit("On-Demand"),
+ providerId: "cursor",
+ resetsAt: resetsAt,
+ resetText: "\(used)/\(limit) on-demand"
+ ))
+ }
+ }
+
+ // Check for unlimited plans
+ if let isUnlimited = json["isUnlimited"] as? Bool, isUnlimited {
+ quotas.append(UsageQuota(
+ percentRemaining: 100,
+ quotaType: .timeLimit("Monthly"),
+ providerId: "cursor",
+ resetText: "Unlimited"
+ ))
+ }
+
+ // If no quotas found, the user might be on a free plan with no data
+ guard !quotas.isEmpty else {
+ throw ProbeError.parseFailed("No usage data found in Cursor response")
+ }
+
+ // Determine account tier from membership type
+ let tier: AccountTier? = switch membershipType.lowercased() {
+ case "pro": .custom("PRO")
+ case "business": .custom("BUSINESS")
+ case "free": .custom("FREE")
+ case "ultra": .custom("ULTRA")
+ default: membershipType.isEmpty ? nil : .custom(membershipType.uppercased())
+ }
+
+ return UsageSnapshot(
+ providerId: "cursor",
+ quotas: quotas,
+ capturedAt: Date(),
+ accountTier: tier
+ )
+ }
+
+ /// Safely extracts an Int from a JSON dictionary value that could be Int, Double, or NSNumber.
+ private static func intValue(from dict: [String: Any], key: String) -> Int? {
+ if let intVal = dict[key] as? Int {
+ return intVal
+ }
+ if let doubleVal = dict[key] as? Double {
+ return Int(doubleVal)
+ }
+ return nil
+ }
+}
diff --git a/Tests/InfrastructureTests/Cursor/CursorUsageProbeParsingTests.swift b/Tests/InfrastructureTests/Cursor/CursorUsageProbeParsingTests.swift
new file mode 100644
index 00000000..740ba836
--- /dev/null
+++ b/Tests/InfrastructureTests/Cursor/CursorUsageProbeParsingTests.swift
@@ -0,0 +1,411 @@
+import Foundation
+import Testing
+@testable import Infrastructure
+@testable import Domain
+
+@Suite("CursorUsageProbe Parsing Tests")
+struct CursorUsageProbeParsingTests {
+
+ // MARK: - Real API Response
+
+ @Test
+ func `parse real ultra plan response`() throws {
+ // Actual response from cursor.com/api/usage-summary
+ let json = """
+ {
+ "billingCycleStart": "2026-02-06T03:34:49.000Z",
+ "billingCycleEnd": "2026-03-06T03:34:49.000Z",
+ "membershipType": "ultra",
+ "limitType": "user",
+ "isUnlimited": false,
+ "autoModelSelectedDisplayMessage": "You've used 1% of your included total usage",
+ "namedModelSelectedDisplayMessage": "You've used 1% of your included API usage",
+ "individualUsage": {
+ "plan": {
+ "enabled": true,
+ "used": 326,
+ "limit": 40000,
+ "remaining": 39674,
+ "breakdown": { "included": 326, "bonus": 0, "total": 326 },
+ "autoPercentUsed": 0.033,
+ "apiPercentUsed": 0.586,
+ "totalPercentUsed": 0.217
+ },
+ "onDemand": {
+ "enabled": false,
+ "used": 0,
+ "limit": null,
+ "remaining": null
+ }
+ },
+ "teamUsage": {}
+ }
+ """.data(using: .utf8)!
+
+ let snapshot = try CursorUsageProbe.parseUsageSummary(json)
+
+ #expect(snapshot.providerId == "cursor")
+ #expect(snapshot.quotas.count == 1)
+ #expect(snapshot.accountTier == .custom("ULTRA"))
+
+ let quota = snapshot.quotas[0]
+ #expect(quota.quotaType == .timeLimit("Monthly"))
+ #expect(abs(quota.percentRemaining - 99.185) < 0.01)
+ #expect(quota.resetText == "326/40000 requests")
+ #expect(quota.resetsAt != nil)
+ }
+
+ // MARK: - Plan Usage
+
+ @Test
+ func `parse pro plan with plan usage`() throws {
+ let json = """
+ {
+ "membershipType": "pro",
+ "billingCycleEnd": "2025-02-01T00:00:00Z",
+ "isUnlimited": false,
+ "individualUsage": {
+ "plan": {
+ "enabled": true,
+ "used": 123,
+ "limit": 500,
+ "remaining": 377
+ },
+ "onDemand": { "enabled": false, "used": 0, "limit": null, "remaining": null }
+ }
+ }
+ """.data(using: .utf8)!
+
+ let snapshot = try CursorUsageProbe.parseUsageSummary(json)
+
+ #expect(snapshot.providerId == "cursor")
+ #expect(snapshot.quotas.count == 1)
+ #expect(snapshot.accountTier == .custom("PRO"))
+
+ let quota = snapshot.quotas[0]
+ #expect(quota.quotaType == .timeLimit("Monthly"))
+ #expect(abs(quota.percentRemaining - 75.4) < 0.1)
+ #expect(quota.resetText == "123/500 requests")
+ #expect(quota.resetsAt != nil)
+ }
+
+ @Test
+ func `parse plan with on-demand usage enabled`() throws {
+ let json = """
+ {
+ "membershipType": "pro",
+ "isUnlimited": false,
+ "individualUsage": {
+ "plan": {
+ "enabled": true,
+ "used": 400,
+ "limit": 500,
+ "remaining": 100
+ },
+ "onDemand": {
+ "enabled": true,
+ "used": 25,
+ "limit": 100,
+ "remaining": 75
+ }
+ }
+ }
+ """.data(using: .utf8)!
+
+ let snapshot = try CursorUsageProbe.parseUsageSummary(json)
+
+ #expect(snapshot.quotas.count == 2)
+
+ let plan = snapshot.quotas.first { $0.quotaType == .timeLimit("Monthly") }
+ #expect(plan != nil)
+ #expect(abs(plan!.percentRemaining - 20.0) < 0.1)
+ #expect(plan!.resetText == "400/500 requests")
+
+ let onDemand = snapshot.quotas.first { $0.quotaType == .timeLimit("On-Demand") }
+ #expect(onDemand != nil)
+ #expect(abs(onDemand!.percentRemaining - 75.0) < 0.1)
+ }
+
+ @Test
+ func `parse depleted plan usage`() throws {
+ let json = """
+ {
+ "membershipType": "pro",
+ "isUnlimited": false,
+ "individualUsage": {
+ "plan": {
+ "enabled": true,
+ "used": 500,
+ "limit": 500,
+ "remaining": 0
+ },
+ "onDemand": { "enabled": false, "used": 0, "limit": null, "remaining": null }
+ }
+ }
+ """.data(using: .utf8)!
+
+ let snapshot = try CursorUsageProbe.parseUsageSummary(json)
+
+ #expect(snapshot.quotas.count == 1)
+ #expect(snapshot.quotas[0].percentRemaining == 0)
+ #expect(snapshot.quotas[0].resetText == "500/500 requests")
+ }
+
+ @Test
+ func `parse over-limit usage clamps to zero`() throws {
+ let json = """
+ {
+ "membershipType": "pro",
+ "isUnlimited": false,
+ "individualUsage": {
+ "plan": {
+ "enabled": true,
+ "used": 550,
+ "limit": 500,
+ "remaining": -50
+ },
+ "onDemand": { "enabled": false, "used": 0, "limit": null, "remaining": null }
+ }
+ }
+ """.data(using: .utf8)!
+
+ let snapshot = try CursorUsageProbe.parseUsageSummary(json)
+
+ #expect(snapshot.quotas.count == 1)
+ #expect(snapshot.quotas[0].percentRemaining == 0)
+ }
+
+ // MARK: - Unlimited & Special Cases
+
+ @Test
+ func `parse unlimited plan`() throws {
+ let json = """
+ {
+ "membershipType": "business",
+ "isUnlimited": true,
+ "individualUsage": {
+ "plan": { "enabled": false },
+ "onDemand": { "enabled": false, "used": 0, "limit": null, "remaining": null }
+ }
+ }
+ """.data(using: .utf8)!
+
+ let snapshot = try CursorUsageProbe.parseUsageSummary(json)
+
+ #expect(snapshot.quotas.count == 1)
+ #expect(snapshot.quotas[0].percentRemaining == 100)
+ #expect(snapshot.quotas[0].resetText == "Unlimited")
+ #expect(snapshot.accountTier == .custom("BUSINESS"))
+ }
+
+ @Test
+ func `parse free plan`() throws {
+ let json = """
+ {
+ "membershipType": "free",
+ "isUnlimited": false,
+ "individualUsage": {
+ "plan": {
+ "enabled": true,
+ "used": 30,
+ "limit": 50,
+ "remaining": 20
+ },
+ "onDemand": { "enabled": false, "used": 0, "limit": null, "remaining": null }
+ }
+ }
+ """.data(using: .utf8)!
+
+ let snapshot = try CursorUsageProbe.parseUsageSummary(json)
+
+ #expect(snapshot.accountTier == .custom("FREE"))
+ #expect(snapshot.quotas.count == 1)
+ #expect(abs(snapshot.quotas[0].percentRemaining - 40.0) < 0.1)
+ }
+
+ // MARK: - Error Cases
+
+ @Test
+ func `parse empty response throws error`() {
+ let json = "{}".data(using: .utf8)!
+
+ #expect(throws: ProbeError.self) {
+ try CursorUsageProbe.parseUsageSummary(json)
+ }
+ }
+
+ @Test
+ func `parse invalid json throws error`() {
+ let json = "not json".data(using: .utf8)!
+
+ #expect(throws: ProbeError.self) {
+ try CursorUsageProbe.parseUsageSummary(json)
+ }
+ }
+
+ @Test
+ func `parse response with no individualUsage and not unlimited throws error`() {
+ let json = """
+ {
+ "membershipType": "pro",
+ "isUnlimited": false
+ }
+ """.data(using: .utf8)!
+
+ #expect(throws: ProbeError.self) {
+ try CursorUsageProbe.parseUsageSummary(json)
+ }
+ }
+
+ // MARK: - Billing Cycle
+
+ @Test
+ func `parse billing cycle end with fractional seconds`() throws {
+ let json = """
+ {
+ "membershipType": "pro",
+ "isUnlimited": false,
+ "billingCycleEnd": "2025-03-01T00:00:00.000Z",
+ "individualUsage": {
+ "plan": {
+ "enabled": true,
+ "used": 100,
+ "limit": 500,
+ "remaining": 400
+ },
+ "onDemand": { "enabled": false, "used": 0, "limit": null, "remaining": null }
+ }
+ }
+ """.data(using: .utf8)!
+
+ let snapshot = try CursorUsageProbe.parseUsageSummary(json)
+ #expect(snapshot.quotas[0].resetsAt != nil)
+ }
+
+ @Test
+ func `parse billing cycle end without fractional seconds`() throws {
+ let json = """
+ {
+ "membershipType": "pro",
+ "isUnlimited": false,
+ "billingCycleEnd": "2025-03-01T00:00:00Z",
+ "individualUsage": {
+ "plan": {
+ "enabled": true,
+ "used": 100,
+ "limit": 500,
+ "remaining": 400
+ },
+ "onDemand": { "enabled": false, "used": 0, "limit": null, "remaining": null }
+ }
+ }
+ """.data(using: .utf8)!
+
+ let snapshot = try CursorUsageProbe.parseUsageSummary(json)
+ #expect(snapshot.quotas[0].resetsAt != nil)
+ }
+
+ // MARK: - JWT Parsing
+
+ @Test
+ func `extract user ID from valid JWT`() throws {
+ // JWT with payload: {"sub": "user_abc123", "iat": 1234567890}
+ let header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
+ let payload = "eyJzdWIiOiJ1c2VyX2FiYzEyMyIsImlhdCI6MTIzNDU2Nzg5MH0"
+ let signature = "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
+ let jwt = "\(header).\(payload).\(signature)"
+
+ let userId = try CursorUsageProbe.extractUserIdFromJWT(jwt)
+ #expect(userId == "user_abc123")
+ }
+
+ @Test
+ func `extract user ID with pipe character like real Cursor JWTs`() throws {
+ // Cursor JWTs have sub like "github|user_01J6BBEPT2KSQKPPRGXDY8M1F4"
+ // Payload: {"sub": "github|user_01ABC", "type": "session"}
+ // base64url of {"sub":"github|user_01ABC","type":"session"} =
+ let payloadJson = #"{"sub":"github|user_01ABC","type":"session"}"#
+ let payloadBase64 = Data(payloadJson.utf8).base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+ let jwt = "eyJhbGciOiJIUzI1NiJ9.\(payloadBase64).sig"
+
+ let userId = try CursorUsageProbe.extractUserIdFromJWT(jwt)
+ #expect(userId == "github|user_01ABC")
+ }
+
+ @Test
+ func `extract user ID from JWT with padding needed`() throws {
+ // Payload: {"sub": "u1"}
+ let header = "eyJhbGciOiJIUzI1NiJ9"
+ let payload = "eyJzdWIiOiJ1MSJ9"
+ let jwt = "\(header).\(payload).sig"
+
+ let userId = try CursorUsageProbe.extractUserIdFromJWT(jwt)
+ #expect(userId == "u1")
+ }
+
+ @Test
+ func `extract user ID from invalid JWT throws`() {
+ #expect(throws: ProbeError.self) {
+ try CursorUsageProbe.extractUserIdFromJWT("not-a-jwt")
+ }
+ }
+
+ @Test
+ func `extract user ID from JWT without sub claim throws`() {
+ // Payload: {"iat": 123} (no sub)
+ let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJpYXQiOjEyM30.sig"
+
+ #expect(throws: ProbeError.self) {
+ try CursorUsageProbe.extractUserIdFromJWT(jwt)
+ }
+ }
+
+ // MARK: - Numeric Type Handling
+
+ @Test
+ func `parse usage values as doubles`() throws {
+ // Some API responses return numbers as doubles
+ let json = """
+ {
+ "membershipType": "pro",
+ "isUnlimited": false,
+ "individualUsage": {
+ "plan": {
+ "enabled": true,
+ "used": 123.0,
+ "limit": 500.0,
+ "remaining": 377.0
+ },
+ "onDemand": { "enabled": false, "used": 0, "limit": null, "remaining": null }
+ }
+ }
+ """.data(using: .utf8)!
+
+ let snapshot = try CursorUsageProbe.parseUsageSummary(json)
+
+ #expect(snapshot.quotas.count == 1)
+ #expect(abs(snapshot.quotas[0].percentRemaining - 75.4) < 0.1)
+ }
+
+ // MARK: - Account Tier Detection
+
+ @Test
+ func `detect ultra tier`() throws {
+ let json = """
+ {
+ "membershipType": "ultra",
+ "isUnlimited": false,
+ "individualUsage": {
+ "plan": { "enabled": true, "used": 1, "limit": 40000, "remaining": 39999 },
+ "onDemand": { "enabled": false, "used": 0, "limit": null, "remaining": null }
+ }
+ }
+ """.data(using: .utf8)!
+
+ let snapshot = try CursorUsageProbe.parseUsageSummary(json)
+ #expect(snapshot.accountTier == .custom("ULTRA"))
+ }
+}