Includes provider integrations, settings, NSStatusItem UI, tests, Makefile, and README with screenshot. Co-authored-by: Cursor <cursoragent@cursor.com>
326 lines
12 KiB
Swift
326 lines
12 KiB
Swift
import Foundation
|
|
|
|
enum CursorAPIError: Error, LocalizedError {
|
|
case notAuthenticated
|
|
case invalidResponse
|
|
case httpError(statusCode: Int)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .notAuthenticated:
|
|
"Not authenticated. Please log in to Cursor."
|
|
case .invalidResponse:
|
|
"Could not parse usage data from Cursor."
|
|
case .httpError(let statusCode):
|
|
"Cursor API returned HTTP \(statusCode)."
|
|
}
|
|
}
|
|
}
|
|
|
|
struct CursorUsageBreakdown: Decodable, Sendable {
|
|
let included: Int
|
|
let bonus: Int
|
|
let total: Int
|
|
}
|
|
|
|
struct CursorPlanUsage: Decodable, Sendable {
|
|
let enabled: Bool
|
|
let used: Int
|
|
let limit: Int
|
|
let remaining: Int
|
|
let breakdown: CursorUsageBreakdown
|
|
let autoPercentUsed: Double
|
|
let apiPercentUsed: Double
|
|
let totalPercentUsed: Double
|
|
}
|
|
|
|
struct CursorOnDemandUsage: Decodable, Sendable {
|
|
let enabled: Bool
|
|
let used: Int
|
|
let limit: Int?
|
|
let remaining: Int?
|
|
}
|
|
|
|
struct CursorIndividualUsage: Decodable, Sendable {
|
|
let plan: CursorPlanUsage
|
|
let onDemand: CursorOnDemandUsage
|
|
}
|
|
|
|
struct CursorUsageSummary: Decodable, Sendable {
|
|
let billingCycleStart: String
|
|
let billingCycleEnd: String
|
|
let membershipType: String
|
|
let limitType: String
|
|
let isUnlimited: Bool
|
|
let individualUsage: CursorIndividualUsage
|
|
}
|
|
|
|
struct CursorTeamMember: Decodable, Sendable {
|
|
let email: String?
|
|
let name: String?
|
|
}
|
|
|
|
struct CursorTeamMembersResponse: Decodable, Sendable {
|
|
let teamMembers: [CursorTeamMember]?
|
|
}
|
|
|
|
struct CursorSpendEntry: Decodable, Sendable {
|
|
let spendCents: Int?
|
|
let fastPremiumRequests: Int?
|
|
}
|
|
|
|
struct CursorSpendResponse: Decodable, Sendable {
|
|
let teamMemberSpend: [CursorSpendEntry]?
|
|
}
|
|
|
|
enum CursorAPI {
|
|
private static let usageSummaryURL = URL(string: "https://cursor.com/api/usage-summary")!
|
|
private static let spendURL = URL(string: "https://api.cursor.com/teams/spend")!
|
|
private static let membersURL = URL(string: "https://api.cursor.com/teams/members")!
|
|
|
|
static func fetchUsageSummary(cookieValue: String) async throws -> CursorUsageSummary {
|
|
var request = URLRequest(url: usageSummaryURL)
|
|
request.httpMethod = "GET"
|
|
request.setValue("WorkosCursorSessionToken=\(cookieValue)", forHTTPHeaderField: "Cookie")
|
|
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
try validateHTTPResponse(response)
|
|
|
|
do {
|
|
return try JSONDecoder().decode(CursorUsageSummary.self, from: data)
|
|
} catch {
|
|
throw CursorAPIError.invalidResponse
|
|
}
|
|
}
|
|
|
|
static func fetchTeamSpend(apiKey: String) async throws -> CursorSpendResponse {
|
|
var request = URLRequest(url: spendURL)
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.httpBody = Data("{}".utf8)
|
|
request.setValue(
|
|
"Basic \(Data("\(apiKey):".utf8).base64EncodedString())",
|
|
forHTTPHeaderField: "Authorization"
|
|
)
|
|
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
try validateAdminHTTPResponse(response)
|
|
|
|
do {
|
|
return try JSONDecoder().decode(CursorSpendResponse.self, from: data)
|
|
} catch {
|
|
throw CursorAPIError.invalidResponse
|
|
}
|
|
}
|
|
|
|
static func fetchTeamMembers(apiKey: String) async throws -> CursorTeamMembersResponse {
|
|
var request = URLRequest(url: membersURL)
|
|
request.httpMethod = "GET"
|
|
request.setValue(
|
|
"Basic \(Data("\(apiKey):".utf8).base64EncodedString())",
|
|
forHTTPHeaderField: "Authorization"
|
|
)
|
|
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
try validateAdminHTTPResponse(response)
|
|
|
|
do {
|
|
return try JSONDecoder().decode(CursorTeamMembersResponse.self, from: data)
|
|
} catch {
|
|
throw CursorAPIError.invalidResponse
|
|
}
|
|
}
|
|
|
|
private static func validateHTTPResponse(_ response: URLResponse) throws {
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
throw CursorAPIError.invalidResponse
|
|
}
|
|
switch httpResponse.statusCode {
|
|
case 200:
|
|
return
|
|
case 401:
|
|
throw CursorAPIError.notAuthenticated
|
|
default:
|
|
throw CursorAPIError.httpError(statusCode: httpResponse.statusCode)
|
|
}
|
|
}
|
|
|
|
private static func validateAdminHTTPResponse(_ response: URLResponse) throws {
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
throw CursorAPIError.invalidResponse
|
|
}
|
|
switch httpResponse.statusCode {
|
|
case 200:
|
|
return
|
|
case 401, 403:
|
|
throw CursorAPIError.notAuthenticated
|
|
default:
|
|
throw CursorAPIError.httpError(statusCode: httpResponse.statusCode)
|
|
}
|
|
}
|
|
|
|
static func snapshot(from summary: CursorUsageSummary, fetchedAt: Date, isStale: Bool) -> ProviderUsageSnapshot {
|
|
let isoFormatter = ISO8601DateFormatter()
|
|
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
|
|
var values: [UsageMetric: MetricValue] = [
|
|
.cursorTotalPercentUsed: .percent(summary.individualUsage.plan.totalPercentUsed),
|
|
.cursorApiPercentUsed: .percent(summary.individualUsage.plan.apiPercentUsed),
|
|
.cursorAutoPercentUsed: .percent(summary.individualUsage.plan.autoPercentUsed),
|
|
.cursorPlanUsed: .integer(summary.individualUsage.plan.used),
|
|
.cursorPlanLimit: .integer(summary.individualUsage.plan.limit),
|
|
.cursorPlanRemaining: .integer(summary.individualUsage.plan.remaining),
|
|
.cursorOnDemandUsed: .currency(Double(summary.individualUsage.onDemand.used) / 100.0),
|
|
.cursorMembershipType: .text(summary.membershipType),
|
|
]
|
|
|
|
if let start = isoFormatter.date(from: summary.billingCycleStart)
|
|
?? ISO8601DateFormatter().date(from: summary.billingCycleStart) {
|
|
values[.cursorBillingCycleStart] = .date(start)
|
|
}
|
|
if let end = isoFormatter.date(from: summary.billingCycleEnd)
|
|
?? ISO8601DateFormatter().date(from: summary.billingCycleEnd) {
|
|
values[.cursorBillingCycleEnd] = .date(end)
|
|
}
|
|
|
|
return ProviderUsageSnapshot(
|
|
providerID: .cursor,
|
|
fetchedAt: fetchedAt,
|
|
values: values,
|
|
isStale: isStale
|
|
)
|
|
}
|
|
|
|
static func adminSnapshot(
|
|
spend: CursorSpendResponse,
|
|
members: CursorTeamMembersResponse,
|
|
fetchedAt: Date,
|
|
isStale: Bool
|
|
) -> ProviderUsageSnapshot {
|
|
let totalSpend = spend.teamMemberSpend?.reduce(0) { $0 + ($1.spendCents ?? 0) } ?? 0
|
|
let memberCount = members.teamMembers?.count ?? 0
|
|
|
|
return ProviderUsageSnapshot(
|
|
providerID: .cursor,
|
|
fetchedAt: fetchedAt,
|
|
values: [
|
|
.cursorAdminSpendCents: .currency(Double(totalSpend) / 100.0),
|
|
.cursorAdminMemberCount: .integer(memberCount),
|
|
],
|
|
isStale: isStale
|
|
)
|
|
}
|
|
}
|
|
|
|
struct CursorProvider: UsageProvider {
|
|
let id: ProviderID = .cursor
|
|
let displayName = "Cursor"
|
|
|
|
func resolveCredentials(authMode: AuthMode) throws -> AuthCredentials {
|
|
switch authMode {
|
|
case .auto:
|
|
let session = try CursorAuthService.loadSessionCredentials()
|
|
return .cursorSession(cookieValue: session.cookieValue)
|
|
case .manualSession:
|
|
let account = KeychainService.keychainAccount(provider: .cursor, authMode: .manualSession)
|
|
guard let token = KeychainService.load(account: account), !token.isEmpty else {
|
|
throw CursorAPIError.notAuthenticated
|
|
}
|
|
return .cursorSession(cookieValue: CursorAuthService.normalizeSessionCookie(token))
|
|
case .adminAPI:
|
|
let account = KeychainService.keychainAccount(provider: .cursor, authMode: .adminAPI)
|
|
guard let apiKey = KeychainService.load(account: account), !apiKey.isEmpty else {
|
|
throw CursorAPIError.notAuthenticated
|
|
}
|
|
return .cursorAdminAPI(apiKey: apiKey)
|
|
}
|
|
}
|
|
|
|
func fetchUsage(credentials: AuthCredentials, cache: UsageCache) async throws -> ProviderUsageSnapshot {
|
|
switch credentials {
|
|
case .cursorSession(let cookieValue):
|
|
return try await fetchSessionUsage(cookieValue: cookieValue, cache: cache)
|
|
case .cursorAdminAPI(let apiKey):
|
|
return try await fetchAdminUsage(apiKey: apiKey, cache: cache)
|
|
default:
|
|
throw CursorAPIError.invalidResponse
|
|
}
|
|
}
|
|
|
|
private func fetchSessionUsage(cookieValue: String, cache: UsageCache) async throws -> ProviderUsageSnapshot {
|
|
if let cached = await cache.load(provider: .cursor, maxAge: 60),
|
|
let summary = try? JSONDecoder().decode(CursorUsageSummary.self, from: cached.payload) {
|
|
return CursorAPI.snapshot(from: summary, fetchedAt: cached.fetchedAt, isStale: false)
|
|
}
|
|
|
|
do {
|
|
let summary = try await CursorAPI.fetchUsageSummary(cookieValue: cookieValue)
|
|
if let data = try? JSONEncoder().encode(summary) {
|
|
await cache.save(provider: .cursor, payload: data)
|
|
}
|
|
return CursorAPI.snapshot(from: summary, fetchedAt: Date(), isStale: false)
|
|
} catch {
|
|
if let stale = await cache.loadStale(provider: .cursor),
|
|
let summary = try? JSONDecoder().decode(CursorUsageSummary.self, from: stale.payload) {
|
|
return CursorAPI.snapshot(from: summary, fetchedAt: stale.fetchedAt, isStale: true)
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private func fetchAdminUsage(apiKey: String, cache: UsageCache) async throws -> ProviderUsageSnapshot {
|
|
struct AdminPayload: Codable {
|
|
let spend: CursorSpendResponse
|
|
let members: CursorTeamMembersResponse
|
|
}
|
|
|
|
if let cached = await cache.load(provider: .cursor, maxAge: 300),
|
|
let payload = try? JSONDecoder().decode(AdminPayload.self, from: cached.payload) {
|
|
return CursorAPI.adminSnapshot(
|
|
spend: payload.spend,
|
|
members: payload.members,
|
|
fetchedAt: cached.fetchedAt,
|
|
isStale: false
|
|
)
|
|
}
|
|
|
|
do {
|
|
async let spend = CursorAPI.fetchTeamSpend(apiKey: apiKey)
|
|
async let members = CursorAPI.fetchTeamMembers(apiKey: apiKey)
|
|
let result = try await (spend: spend, members: members)
|
|
|
|
let payload = AdminPayload(spend: result.spend, members: result.members)
|
|
if let data = try? JSONEncoder().encode(payload) {
|
|
await cache.save(provider: .cursor, payload: data)
|
|
}
|
|
|
|
return CursorAPI.adminSnapshot(
|
|
spend: result.spend,
|
|
members: result.members,
|
|
fetchedAt: Date(),
|
|
isStale: false
|
|
)
|
|
} catch {
|
|
if let stale = await cache.loadStale(provider: .cursor),
|
|
let payload = try? JSONDecoder().decode(AdminPayload.self, from: stale.payload) {
|
|
return CursorAPI.adminSnapshot(
|
|
spend: payload.spend,
|
|
members: payload.members,
|
|
fetchedAt: stale.fetchedAt,
|
|
isStale: true
|
|
)
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
extension CursorUsageSummary: Encodable {}
|
|
extension CursorSpendResponse: Encodable {}
|
|
extension CursorTeamMembersResponse: Encodable {}
|
|
extension CursorUsageBreakdown: Encodable {}
|
|
extension CursorPlanUsage: Encodable {}
|
|
extension CursorOnDemandUsage: Encodable {}
|
|
extension CursorIndividualUsage: Encodable {}
|
|
extension CursorTeamMember: Encodable {}
|
|
extension CursorSpendEntry: Encodable {}
|