feat: add Cursor provider for tracking Cursor AI usage quotas#119
feat: add Cursor provider for tracking Cursor AI usage quotas#119alxDisplayr wants to merge 2 commits intotddworks:mainfrom
Conversation
Adds a new Cursor provider that monitors Cursor IDE subscription usage (included requests and on-demand spending) directly from the menu bar. The probe works by: 1. Reading the auth token from Cursor's local SQLite database (~/.../Cursor/User/globalStorage/state.vscdb) 2. Decoding the JWT to extract the user ID 3. Calling https://cursor.com/api/usage-summary with cookie auth 4. Parsing plan usage (monthly included requests) and on-demand usage Supports Pro, Business, and Free plans with automatic tier detection. Files added: - Domain: CursorProvider (follows Kiro/AmpCode pattern) - Infrastructure: CursorUsageProbe (HTTP API + SQLite token extraction) - Tests: 15 parsing tests covering all response formats - App: Visual identity, icon assets, provider registration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
📝 WalkthroughWalkthroughAdds support for the Cursor AI provider: a new observable Changes
Sequence DiagramsequenceDiagram
participant User
participant CursorProvider
participant CursorUsageProbe
participant SQLiteDB as Cursor<br/>SQLite DB
participant CursorAPI as Cursor<br/>Usage API
User->>CursorProvider: refresh()
activate CursorProvider
CursorProvider->>CursorProvider: isSyncing = true
CursorProvider->>CursorUsageProbe: probe()
activate CursorUsageProbe
CursorUsageProbe->>SQLiteDB: Read auth token
activate SQLiteDB
SQLiteDB-->>CursorUsageProbe: JWT token
deactivate SQLiteDB
CursorUsageProbe->>CursorUsageProbe: extractUserIdFromJWT(token)
CursorUsageProbe->>CursorAPI: GET /v1/usage/summary (Cookie header)
activate CursorAPI
CursorAPI-->>CursorUsageProbe: Usage JSON response
deactivate CursorAPI
CursorUsageProbe->>CursorUsageProbe: parseUsageSummary(response)
CursorUsageProbe-->>CursorProvider: UsageSnapshot
deactivate CursorUsageProbe
CursorProvider->>CursorProvider: snapshot = UsageSnapshot\nisSyncing = false
CursorProvider-->>User: Updated snapshot
deactivate CursorProvider
Estimated code review effort🎯 4 (Complex) | ⏱️ ~40 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Fix all issues with AI agents
In `@Sources/App/Resources/Assets.xcassets/CursorIcon.imageset/Contents.json`:
- Around line 1-15: The CursorIcon imageset currently points to CursorIcon.svg
instead of the project's PNG runtime convention; replace the vector reference in
CursorIcon.imageset/Contents.json by generating PNG variants from the SVG source
(e.g., CursorIcon_1x.png, CursorIcon_2x.png, CursorIcon_3x.png or sizes like
CursorIcon_64.png, CursorIcon_128.png, CursorIcon_192.png) and update the
imageset entries to reference those PNG filenames, ensuring
"preserves-vector-representation" is removed or set appropriately so the asset
catalog uses the PNGs at runtime.
In `@Sources/Infrastructure/Cursor/CursorUsageProbe.swift`:
- Around line 222-240: The planUsage block can append a Monthly quota even when
isUnlimited is true, causing duplicate Monthly quotas; update the logic in the
CursorUsageProbe parsing so you check the isUnlimited flag before processing
json["planUsage"] and skip the planUsage quota when isUnlimited is true (i.e.,
only append the UsageQuota with quotaType .timeLimit("Monthly") from the
planUsage branch when isUnlimited is false), ensuring unique quotas for
UsageQuota creation (percentRemaining, providerId "cursor", resetsAt,
resetText).
- Around line 191-289: In parseUsageSummary, percentRemaining is computed in the
planUsage and onDemand blocks (currently Double(limit - used) / Double(limit) *
100) and can exceed 100 if used is negative; update both calculations to clamp
the value between 0 and 100 (e.g., percentRemaining = min(100, max(0,
percentRemaining))) before creating the UsageQuota instances so percentRemaining
is always within [0,100]; ensure you apply this change in the planUsage block
and the onDemand block where percentRemaining is set.
🧹 Nitpick comments (4)
Sources/Infrastructure/Cursor/CursorUsageProbe.swift (3)
89-120:waitUntilExit()blocks the cooperative thread pool.
Process.waitUntilExit()is a synchronous blocking call. When called from anasyncfunction, it holds a cooperative thread pool thread hostage, potentially starving other async tasks. For a single short-livedsqlite3query with a local DB this is unlikely to cause visible issues, but it's still an anti-pattern in structured concurrency.Consider wrapping the blocking work in a detached task or dispatching to a non-cooperative queue:
♻️ Suggested approach
- private func readAccessToken(from dbPath: String) throws -> String { + private func readAccessToken(from dbPath: String) async throws -> String { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global().async { + do { 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)") + continuation.resume(throwing: ProbeError.executionFailed("sqlite3 exited with status \(process.terminationStatus)")) + return } 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 + continuation.resume(throwing: ProbeError.authenticationRequired) + return } - return token + continuation.resume(returning: token) + } catch { + continuation.resume(throwing: ProbeError.executionFailed("Failed to read Cursor database: \(error.localizedDescription)")) + } + } + } }
159-163:Content-Typeheader is inappropriate for a GET request.Line 162 sets
Content-Type: application/json, but this is a GET request with no body. UseAcceptinstead to indicate the desired response format.♻️ Proposed fix
request.httpMethod = "GET" request.setValue(cookie, forHTTPHeaderField: "Cookie") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept")
66-68: File path containing the user's home directory is persisted to file log at error level.
dbPathincludes the home directory path (e.g.,/Users/janedoe/...). At.errorlevel this is written to the file log. While likely acceptable for a local desktop app, be aware this could surface the macOS username in exported diagnostic logs.As per coding guidelines: "Do not expose secrets or sensitive data in file logs; manually redact sensitive information before logging."
Tests/InfrastructureTests/Cursor/CursorUsageProbeParsingTests.swift (1)
199-216: Consider adding a test forplanUsage.enabled: truecombined withisUnlimited: true.This would validate behavior when both flags are set simultaneously—currently the parser would emit two "Monthly" quotas, which relates to the potential duplicate quota issue flagged in
CursorUsageProbe.swift.
| { | ||
| "images" : [ | ||
| { | ||
| "filename" : "CursorIcon.svg", | ||
| "idiom" : "universal" | ||
| } | ||
| ], | ||
| "info" : { | ||
| "author" : "xcode", | ||
| "version" : 1 | ||
| }, | ||
| "properties" : { | ||
| "preserves-vector-representation" : true | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Find all Contents.json files in Assets.xcassets and check what image formats they reference
find . -path '*/Assets.xcassets/*.imageset/Contents.json' -type f 2>/dev/null | head -20 | while read file; do
echo "=== $file ==="
grep '"filename"' "$file" | head -3
done
echo -e "\n--- Summary of image formats used in imagesets ---"
find . -path '*/Assets.xcassets/*.imageset/Contents.json' -type f 2>/dev/null | xargs grep -h '"filename"' | sed 's/.*"\([^"]*\)".*/\1/' | sed 's/.*\.//' | sort | uniq -c | sort -rnRepository: tddworks/ClaudeBar
Length of output: 2222
🏁 Script executed:
#!/bin/bash
# Search for CursorIcon and AmpCodeIcon files in various formats
echo "=== CursorIcon files ==="
find . -name '*CursorIcon*' -type f 2>/dev/null | sort
echo -e "\n=== AmpCodeIcon files ==="
find . -name '*AmpCodeIcon*' -type f 2>/dev/null | sort
echo -e "\n=== Check if CursorIcon.svg has preserves-vector-representation ==="
grep -r 'preserves-vector-representation' Sources/App/Resources/Assets.xcassets/*.imageset/Contents.json 2>/dev/null | grep -i cursor
echo -e "\n=== Check AmpCodeIcon Contents.json ==="
cat ./Sources/App/Resources/Assets.xcassets/AmpCodeIcon.imageset/Contents.jsonRepository: tddworks/ClaudeBar
Length of output: 761
CursorIcon asset deviates from project PNG convention.
The imageset references CursorIcon.svg directly instead of PNG variants, inconsistent with the project pattern where 28 of 30 assets use PNG at runtime (at 1×/2×/3× scales). While the SVG source file is properly maintained at Sources/App/Resources/CursorIcon.svg for regeneration, the runtime asset should follow the established convention by generating and using PNG variants like other icons (e.g., CursorIcon_64.png, CursorIcon_128.png, CursorIcon_192.png or similar). Consider converting the SVG source to PNG variants for consistency.
🤖 Prompt for AI Agents
In `@Sources/App/Resources/Assets.xcassets/CursorIcon.imageset/Contents.json`
around lines 1 - 15, The CursorIcon imageset currently points to CursorIcon.svg
instead of the project's PNG runtime convention; replace the vector reference in
CursorIcon.imageset/Contents.json by generating PNG variants from the SVG source
(e.g., CursorIcon_1x.png, CursorIcon_2x.png, CursorIcon_3x.png or sizes like
CursorIcon_64.png, CursorIcon_128.png, CursorIcon_192.png) and update the
imageset entries to reference those PNG filenames, ensuring
"preserves-vector-representation" is removed or set appropriately so the asset
catalog uses the PNGs at runtime.
| 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) | ||
| } | ||
| } | ||
|
|
||
| // Parse plan usage (included requests) | ||
| if let planUsage = json["planUsage"] as? [String: Any], | ||
| let enabled = planUsage["enabled"] as? Bool, enabled { | ||
| let used = (planUsage["used"] as? Int) ?? (planUsage["used"] as? Double).map { Int($0) } ?? 0 | ||
| let limit = (planUsage["limit"] as? Int) ?? (planUsage["limit"] as? Double).map { Int($0) } ?? 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 = json["onDemandUsage"] as? [String: Any], | ||
| let enabled = onDemand["enabled"] as? Bool, enabled { | ||
| let used = (onDemand["used"] as? Int) ?? (onDemand["used"] as? Double).map { Int($0) } ?? 0 | ||
| let limit = (onDemand["limit"] as? Int) ?? (onDemand["limit"] as? Double).map { Int($0) } ?? 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") | ||
| default: membershipType.isEmpty ? nil : .custom(membershipType.uppercased()) | ||
| } | ||
|
|
||
| return UsageSnapshot( | ||
| providerId: "cursor", | ||
| quotas: quotas, | ||
| capturedAt: Date(), | ||
| accountTier: tier | ||
| ) | ||
| } |
There was a problem hiding this comment.
percentRemaining can exceed 100 when used is negative.
Line 229: Double(limit - used) / Double(limit) * 100 — you clamp the lower bound to 0 via max(0, ...) on line 233, but there's no upper-bound clamp. If the API ever returns a negative used value (e.g., credits/adjustments), percentRemaining could exceed 100. Consider min(100, max(0, percentRemaining)).
🤖 Prompt for AI Agents
In `@Sources/Infrastructure/Cursor/CursorUsageProbe.swift` around lines 191 - 289,
In parseUsageSummary, percentRemaining is computed in the planUsage and onDemand
blocks (currently Double(limit - used) / Double(limit) * 100) and can exceed 100
if used is negative; update both calculations to clamp the value between 0 and
100 (e.g., percentRemaining = min(100, max(0, percentRemaining))) before
creating the UsageQuota instances so percentRemaining is always within [0,100];
ensure you apply this change in the planUsage block and the onDemand block where
percentRemaining is set.
| // Parse plan usage (included requests) | ||
| if let planUsage = json["planUsage"] as? [String: Any], | ||
| let enabled = planUsage["enabled"] as? Bool, enabled { | ||
| let used = (planUsage["used"] as? Int) ?? (planUsage["used"] as? Double).map { Int($0) } ?? 0 | ||
| let limit = (planUsage["limit"] as? Int) ?? (planUsage["limit"] as? Double).map { Int($0) } ?? 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 | ||
| )) | ||
| } | ||
| } |
There was a problem hiding this comment.
Possible duplicate "Monthly" quota when planUsage.enabled and isUnlimited are both true.
If the API returns planUsage.enabled: true with a valid limit and isUnlimited: true, both blocks will append a quota with .timeLimit("Monthly"). This would produce two Monthly quotas in the snapshot. Consider making these mutually exclusive—check isUnlimited first and skip planUsage when unlimited.
♻️ Suggested guard
+ let isUnlimited = (json["isUnlimited"] as? Bool) ?? false
+
// Parse plan usage (included requests)
- if let planUsage = json["planUsage"] as? [String: Any],
+ if !isUnlimited,
+ let planUsage = json["planUsage"] as? [String: Any],
let enabled = planUsage["enabled"] as? Bool, enabled {
...
}
// Check for unlimited plans
- if let isUnlimited = json["isUnlimited"] as? Bool, isUnlimited {
+ if isUnlimited {Also applies to: 260-268
🤖 Prompt for AI Agents
In `@Sources/Infrastructure/Cursor/CursorUsageProbe.swift` around lines 222 - 240,
The planUsage block can append a Monthly quota even when isUnlimited is true,
causing duplicate Monthly quotas; update the logic in the CursorUsageProbe
parsing so you check the isUnlimited flag before processing json["planUsage"]
and skip the planUsage quota when isUnlimited is true (i.e., only append the
UsageQuota with quotaType .timeLimit("Monthly") from the planUsage branch when
isUnlimited is false), ensuring unique quotas for UsageQuota creation
(percentRemaining, providerId "cursor", resetsAt, resetText).
The actual cursor.com/api/usage-summary response nests usage data under `individualUsage.plan` and `individualUsage.onDemand`, not top-level `planUsage` and `onDemandUsage` as initially assumed. Also adds "ultra" tier detection and a test using the real API response. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@Sources/Infrastructure/Cursor/CursorUsageProbe.swift`:
- Line 216: The code sets let membershipType = json["membershipType"] as? String
?? "unknown", which makes the later membershipType.isEmpty branch unreachable
and always falls back to .custom("UNKNOWN"); fix by either defaulting the parsed
value to an empty string (let membershipType = json["membershipType"] as? String
?? "") so the .isEmpty check can work as intended, or keep the "unknown" default
but update the mapping logic that produces .custom("UNKNOWN") to explicitly
treat "unknown" (case-insensitively) as the empty/missing case; update the code
that maps membershipType -> membershipTier (the block producing
.custom("UNKNOWN")) accordingly.
🧹 Nitpick comments (2)
Sources/Infrastructure/Cursor/CursorUsageProbe.swift (1)
97-112: BlockingProcesscall on the cooperative thread pool.
readAccessTokenruns a synchronousProcess(withwaitUntilExit()) and is called fromprobe(), which isasync. This blocks one of Swift's limited cooperative thread-pool threads. If several providers probe concurrently, this could starve the pool.Wrap the blocking work so it doesn't occupy a cooperative thread:
♻️ Suggested fix
- private func readAccessToken(from dbPath: String) throws -> String { + private func readAccessToken(from dbPath: String) async throws -> String { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + do { + let token = try self._readAccessTokenSync(from: dbPath) + continuation.resume(returning: token) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + private func _readAccessTokenSync(from dbPath: String) throws -> String { let process = Process() ... }Tests/InfrastructureTests/Cursor/CursorUsageProbeParsingTests.swift (1)
180-199: Missing test forisUnlimited: truewithplanUsage.enabled: true(duplicate quota edge case).The unlimited test uses
plan.enabled: false, which sidesteps the duplicate "Monthly" quota bug flagged in the probe. Add a test where bothisUnlimited: trueandplan.enabled: truewith a valid limit to document the expected behavior and guard against regressions.🧪 Suggested test
`@Test` func `parse unlimited with enabled plan does not duplicate monthly quota`() throws { let json = """ { "membershipType": "business", "isUnlimited": true, "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) // Should only have ONE Monthly quota, not two let monthlyQuotas = snapshot.quotas.filter { $0.quotaType == .timeLimit("Monthly") } `#expect`(monthlyQuotas.count == 1) `#expect`(monthlyQuotas[0].resetText == "Unlimited") }
|
|
||
| var quotas: [UsageQuota] = [] | ||
|
|
||
| let membershipType = json["membershipType"] as? String ?? "unknown" |
There was a problem hiding this comment.
Dead branch: membershipType.isEmpty is unreachable.
Line 216 defaults membershipType to "unknown" when the key is missing, so the .isEmpty check on Line 294 can never be true — it will always produce .custom("UNKNOWN") for unrecognized tiers. If returning nil for missing membership is intended, default to "" instead of "unknown".
♻️ Suggested fix
- let membershipType = json["membershipType"] as? String ?? "unknown"
+ let membershipType = json["membershipType"] as? String ?? ""Also applies to: 289-295
🤖 Prompt for AI Agents
In `@Sources/Infrastructure/Cursor/CursorUsageProbe.swift` at line 216, The code
sets let membershipType = json["membershipType"] as? String ?? "unknown", which
makes the later membershipType.isEmpty branch unreachable and always falls back
to .custom("UNKNOWN"); fix by either defaulting the parsed value to an empty
string (let membershipType = json["membershipType"] as? String ?? "") so the
.isEmpty check can work as intended, or keep the "unknown" default but update
the mapping logic that produces .custom("UNKNOWN") to explicitly treat "unknown"
(case-insensitively) as the empty/missing case; update the code that maps
membershipType -> membershipTier (the block producing .custom("UNKNOWN"))
accordingly.
|
Superseded by #120 — recreated from the correct GitHub account. |
Summary
How It Works
~/Library/Application Support/Cursor/User/globalStorage/state.vscdb)subclaim)https://cursor.com/api/usage-summarywith cookie authentication (WorkosCursorSessionToken={userId}::{accessToken})UsageQuotaitemsFeatures
billingCycleEndfor reset time displayisUnlimited: trueresponsesFiles Changed
Sources/Domain/Provider/Cursor/CursorProvider.swiftSources/Infrastructure/Cursor/CursorUsageProbe.swiftSources/App/ClaudeBarApp.swiftSources/App/Views/ProviderVisualIdentity.swiftSources/App/Resources/Assets.xcassets/CursorIcon.imageset/Tests/InfrastructureTests/Cursor/CursorUsageProbeParsingTests.swiftTest Plan
xcodebuild buildNotes
cursor.com/api/usage-summary) is the same endpoint used by community extensions like cursor-stats and cursor-usage-statssqlite3CLI (available on all macOS systems) rather than a Swift SQLite library, keeping dependencies unchangedisAvailable()returnsfalse🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Tests