1use crate::models::{CursorQuotaSnapshot, CursorUsageSummary, QuotaSource, QuotaWindow};
16use crate::quota::http::{detect_cli_version, iso_to_unix_secs};
17use crate::quota::provider::QuotaOutcome;
18use crate::utils::get_cursor_auth_path;
19use anyhow::{Context, Result};
20use base64::Engine;
21use reqwest::blocking::Client;
22use std::path::Path;
23use std::sync::OnceLock;
24
25const CURSOR_USAGE_URL: &str = "https://cursor.com/api/usage-summary";
27const CURSOR_FALLBACK_VERSION: &str = "2026.07.07";
30pub const CURSOR_LOGIN_HINT: &str = "run: cursor-agent login";
33
34pub(crate) fn cursor_ua() -> &'static str {
40 static UA: OnceLock<String> = OnceLock::new();
41 UA.get_or_init(|| {
42 format!(
43 "cursor-agent/{}",
44 detect_cli_version(
45 "cursor-agent",
46 "cursor_version.json",
47 CURSOR_FALLBACK_VERSION
48 )
49 )
50 })
51 .as_str()
52}
53
54pub(crate) struct CursorSession {
59 pub(crate) cookie: String,
60 pub(crate) exp: i64,
61}
62
63fn decode_jwt_payload(token: &str) -> Option<serde_json::Value> {
65 let payload = token.split('.').nth(1)?;
66 let trimmed = payload.trim_end_matches('=');
68 let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
69 .decode(trimmed)
70 .ok()?;
71 serde_json::from_slice(&bytes).ok()
72}
73
74pub(crate) fn read_cursor_session(body: &str) -> Option<CursorSession> {
79 let root: serde_json::Value = serde_json::from_str(body).ok()?;
80 let access = root.get("accessToken")?.as_str()?;
81 if access.is_empty() {
82 return None;
83 }
84 let claims = decode_jwt_payload(access)?;
85 let sub = claims.get("sub")?.as_str()?;
86 let uid = sub.rsplit('|').next().unwrap_or(sub);
87 if uid.is_empty() {
88 return None;
89 }
90 let exp = claims.get("exp").and_then(|v| v.as_i64()).unwrap_or(0);
91 Some(CursorSession {
92 cookie: format!("WorkosCursorSessionToken={uid}%3A%3A{access}"),
93 exp,
94 })
95}
96
97pub fn map_cursor_usage(body: &str, now: i64) -> Result<CursorQuotaSnapshot> {
103 let resp: CursorUsageSummary =
104 serde_json::from_str(body).context("Failed to parse Cursor usage summary")?;
105
106 let reset = resp.billing_cycle_end.as_deref().and_then(iso_to_unix_secs);
107 let win = |pct: Option<f64>| {
113 pct.map(|p| QuotaWindow {
114 used_percent: p.clamp(0.0, 100.0),
115 resets_at_unix: reset,
116 })
117 };
118
119 let plan = resp.individual_usage.as_ref().and_then(|u| u.plan.as_ref());
120 let total = win(plan.and_then(|p| p.total_percent_used));
121 let auto = win(plan.and_then(|p| p.auto_percent_used));
122 let api = win(plan.and_then(|p| p.api_percent_used));
123
124 let individual_od = resp
127 .individual_usage
128 .as_ref()
129 .and_then(|u| u.on_demand.as_ref())
130 .filter(|d| d.enabled == Some(true))
131 .and_then(|d| d.used);
132 let team_od = resp
133 .team_usage
134 .as_ref()
135 .and_then(|t| t.on_demand.as_ref())
136 .and_then(|d| d.used);
137 let on_demand_dollars = individual_od.or(team_od).map(|cents| cents / 100.0);
138
139 let is_unlimited = resp.is_unlimited.unwrap_or(false);
140 let limit_reached = !is_unlimited
141 && total
142 .as_ref()
143 .map(|w| w.used_percent >= 100.0)
144 .unwrap_or(false);
145
146 Ok(CursorQuotaSnapshot {
147 source: QuotaSource::Api,
148 fetched_at: now,
149 plan_type: resp.membership_type.filter(|s| !s.is_empty()),
150 total,
151 auto,
152 api,
153 on_demand_dollars,
154 limit_reached,
155 needs_login: false,
156 })
157}
158
159enum FetchResult {
161 Ok(CursorQuotaSnapshot),
162 Unauthorized,
164 Transient,
166}
167
168fn fetch_cursor_usage(client: &Client, cookie: &str, now: i64, usage_url: &str) -> FetchResult {
170 let resp = match client
171 .get(usage_url)
172 .header(reqwest::header::COOKIE, cookie)
173 .header(reqwest::header::ACCEPT, "application/json")
174 .header(reqwest::header::USER_AGENT, cursor_ua())
175 .send()
176 {
177 Ok(r) => r,
178 Err(e) => {
179 log::warn!("cursor quota fetch: request failed: {e}");
180 return FetchResult::Transient;
181 }
182 };
183 let status = resp.status();
184 if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
185 return FetchResult::Unauthorized;
186 }
187 if !status.is_success() {
188 log::warn!("cursor quota fetch: HTTP {status}");
189 return FetchResult::Transient;
190 }
191 match resp.text() {
192 Ok(text) => match map_cursor_usage(&text, now) {
193 Ok(snap) => FetchResult::Ok(snap),
194 Err(e) => {
195 log::warn!("cursor quota fetch: failed to parse usage response: {e}");
196 FetchResult::Transient
197 }
198 },
199 Err(e) => {
200 log::warn!("cursor quota fetch: failed to read response body: {e}");
201 FetchResult::Transient
202 }
203 }
204}
205
206pub fn fetch_cursor_raw(client: &Client) -> Result<(u16, String)> {
218 fetch_cursor_raw_from(client, CURSOR_USAGE_URL, &get_cursor_auth_path()?)
219}
220
221pub(crate) fn fetch_cursor_raw_from(
226 client: &Client,
227 usage_url: &str,
228 auth_path: &Path,
229) -> Result<(u16, String)> {
230 let path = auth_path;
231 let body = std::fs::read_to_string(path).with_context(|| {
232 format!(
233 "no Cursor credentials at {} ({CURSOR_LOGIN_HINT})",
234 path.display()
235 )
236 })?;
237 let session = read_cursor_session(&body).with_context(|| {
238 format!(
239 "no usable Cursor session in {} ({CURSOR_LOGIN_HINT})",
240 path.display()
241 )
242 })?;
243 let resp = client
244 .get(usage_url)
245 .header(reqwest::header::COOKIE, session.cookie.as_str())
246 .header(reqwest::header::ACCEPT, "application/json")
247 .header(reqwest::header::USER_AGENT, cursor_ua())
248 .send()
249 .context("Failed to send Cursor usage request")?;
250 let status = resp.status().as_u16();
251 let text = resp
252 .text()
253 .context("Failed to read Cursor usage response body")?;
254 Ok((status, text))
255}
256
257#[derive(Default)]
260pub struct CursorState;
261
262impl CursorState {
263 pub fn resolve(&mut self, client: &Client) -> QuotaOutcome<CursorQuotaSnapshot> {
265 let now = chrono::Local::now().timestamp();
266 let path = match get_cursor_auth_path() {
267 Ok(p) => p,
268 Err(_) => return QuotaOutcome::Transient,
269 };
270 let body = match std::fs::read_to_string(&path) {
271 Ok(b) => b,
272 Err(_) => return QuotaOutcome::Transient,
273 };
274 let session = match read_cursor_session(&body) {
278 Some(s) => s,
279 None => return QuotaOutcome::NeedsLogin,
280 };
281 if session.exp > 0 && session.exp <= now {
283 return QuotaOutcome::NeedsLogin;
284 }
285 match fetch_cursor_usage(client, &session.cookie, now, CURSOR_USAGE_URL) {
286 FetchResult::Ok(snap) => QuotaOutcome::Data(snap),
287 FetchResult::Unauthorized => QuotaOutcome::NeedsLogin,
288 FetchResult::Transient => QuotaOutcome::Transient,
289 }
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296 use base64::Engine;
297
298 fn fake_jwt(payload: &serde_json::Value) -> String {
300 let enc = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
301 format!(
302 "{}.{}.{}",
303 enc(b"{\"alg\":\"none\"}"),
304 enc(payload.to_string().as_bytes()),
305 "sig"
306 )
307 }
308
309 #[test]
310 fn builds_cookie_from_jwt_sub() {
311 let jwt = fake_jwt(&serde_json::json!({
312 "sub": "github|user_01ABC",
313 "exp": 9_999_999_999i64,
314 }));
315 let body = format!(r#"{{ "accessToken": "{jwt}", "refreshToken": "rt" }}"#);
316 let session = read_cursor_session(&body).unwrap();
317 assert_eq!(
318 session.cookie,
319 format!("WorkosCursorSessionToken=user_01ABC%3A%3A{jwt}")
320 );
321 assert_eq!(session.exp, 9_999_999_999);
322 }
323
324 #[test]
325 fn sub_without_pipe_uses_whole_value() {
326 let jwt = fake_jwt(&serde_json::json!({ "sub": "user_only", "exp": 1 }));
327 let body = format!(r#"{{ "accessToken": "{jwt}" }}"#);
328 let session = read_cursor_session(&body).unwrap();
329 assert!(session.cookie.contains("=user_only%3A%3A"));
330 }
331
332 #[test]
333 fn missing_access_token_is_none() {
334 assert!(read_cursor_session(r#"{ "refreshToken": "rt" }"#).is_none());
335 }
336
337 const SUMMARY: &str = r#"{
338 "billingCycleStart": "2026-06-23T17:36:23.480Z",
339 "billingCycleEnd": "2026-07-23T17:36:23.480Z",
340 "membershipType": "free",
341 "isUnlimited": false,
342 "individualUsage": {
343 "plan": { "autoPercentUsed": 100, "apiPercentUsed": 44, "totalPercentUsed": 94 },
344 "onDemand": { "enabled": false, "used": 0, "limit": null }
345 }
346 }"#;
347
348 #[test]
349 fn maps_cursor_usage() {
350 let snap = map_cursor_usage(SUMMARY, 1_000_000).unwrap();
351 assert_eq!(snap.source, QuotaSource::Api);
352 assert_eq!(snap.plan_type.as_deref(), Some("free"));
353 assert_eq!(snap.total.as_ref().unwrap().used_percent, 94.0);
355 assert_eq!(snap.auto.as_ref().unwrap().used_percent, 100.0);
356 assert_eq!(snap.api.as_ref().unwrap().used_percent, 44.0);
357 assert!(snap.total.as_ref().unwrap().resets_at_unix.unwrap() > 0);
358 assert!(snap.on_demand_dollars.is_none());
360 assert!(!snap.limit_reached);
361 }
362
363 #[test]
364 fn on_demand_dollars_from_cents_when_enabled() {
365 let body = r#"{ "membershipType": "pro", "individualUsage": { "onDemand": { "enabled": true, "used": 1840 } } }"#;
366 let snap = map_cursor_usage(body, 1).unwrap();
367 assert_eq!(snap.on_demand_dollars, Some(18.40));
368 }
369
370 #[test]
371 fn on_demand_falls_back_to_team_usage() {
372 let body = r#"{
374 "membershipType": "enterprise",
375 "individualUsage": { "onDemand": { "enabled": false } },
376 "teamUsage": { "onDemand": { "used": 5000 } }
377 }"#;
378 let snap = map_cursor_usage(body, 1).unwrap();
379 assert_eq!(snap.on_demand_dollars, Some(50.0));
380 }
381
382 #[test]
383 fn flags_limit_when_total_maxed() {
384 let body = r#"{ "isUnlimited": false, "individualUsage": { "plan": { "totalPercentUsed": 100 } } }"#;
385 let snap = map_cursor_usage(body, 1).unwrap();
386 assert!(snap.limit_reached);
387 }
388
389 #[test]
390 fn unlimited_never_flags_limit() {
391 let body = r#"{ "isUnlimited": true, "individualUsage": { "plan": { "totalPercentUsed": 100 } } }"#;
393 let snap = map_cursor_usage(body, 1).unwrap();
394 assert!(!snap.limit_reached);
395 }
396
397 #[test]
398 fn idle_account_reads_as_unused() {
399 let body = r#"{
402 "membershipType": "free",
403 "isUnlimited": false,
404 "individualUsage": {
405 "plan": {
406 "used": 0, "limit": 0, "remaining": 0,
407 "autoPercentUsed": 0, "apiPercentUsed": 0, "totalPercentUsed": 0
408 }
409 }
410 }"#;
411 let snap = map_cursor_usage(body, 1).unwrap();
412 assert_eq!(snap.total.as_ref().unwrap().used_percent, 0.0);
413 assert_eq!(snap.auto.as_ref().unwrap().used_percent, 0.0);
414 assert_eq!(snap.api.as_ref().unwrap().used_percent, 0.0);
415 assert!(!snap.limit_reached);
416 }
417
418 #[test]
419 fn out_of_range_percentages_are_clamped() {
420 let body = r#"{ "individualUsage": { "plan": { "totalPercentUsed": 150, "autoPercentUsed": -5 } } }"#;
423 let snap = map_cursor_usage(body, 1).unwrap();
424 assert_eq!(snap.total.as_ref().unwrap().used_percent, 100.0);
425 assert_eq!(snap.auto.as_ref().unwrap().used_percent, 0.0);
426 assert!(snap.limit_reached);
427 }
428
429 #[test]
430 fn missing_usage_is_tolerated() {
431 let snap = map_cursor_usage(r#"{ "membershipType": "free" }"#, 1).unwrap();
432 assert!(snap.total.is_none());
433 assert!(snap.auto.is_none());
434 assert!(snap.api.is_none());
435 assert!(!snap.limit_reached);
436 }
437
438 #[test]
441 fn fetch_cursor_usage_maps_200_and_401() {
442 use crate::quota::http::build_client;
443 use httpmock::prelude::*;
444
445 let server = MockServer::start();
446 let ok = server.mock(|when, then| {
447 when.method(GET).path("/ok");
448 then.status(200).body(SUMMARY);
449 });
450 server.mock(|when, then| {
451 when.method(GET).path("/forbidden");
452 then.status(403);
453 });
454 let client = build_client().unwrap();
455
456 match fetch_cursor_usage(&client, "cookie", 1_000_000, &server.url("/ok")) {
457 FetchResult::Ok(snap) => {
458 assert_eq!(snap.plan_type.as_deref(), Some("free"));
459 assert_eq!(snap.total.as_ref().unwrap().used_percent, 94.0);
460 }
461 _ => panic!("expected Ok"),
462 }
463 ok.assert();
464 assert!(matches!(
465 fetch_cursor_usage(&client, "cookie", 0, &server.url("/forbidden")),
466 FetchResult::Unauthorized
467 ));
468 }
469
470 #[test]
471 fn fetch_cursor_raw_from_reads_session_and_returns_body() {
472 use crate::quota::http::build_client;
473 use httpmock::prelude::*;
474
475 let server = MockServer::start();
476 server.mock(|when, then| {
477 when.method(GET).path("/usage");
478 then.status(200).body(SUMMARY);
479 });
480 let jwt = fake_jwt(&serde_json::json!({ "sub": "github|u1", "exp": 9_999_999_999i64 }));
481 let dir = tempfile::tempdir().unwrap();
482 let auth = dir.path().join("auth.json");
483 std::fs::write(&auth, format!(r#"{{ "accessToken": "{jwt}" }}"#)).unwrap();
484
485 let client = build_client().unwrap();
486 let (status, body) =
487 fetch_cursor_raw_from(&client, &server.url("/usage"), &auth).expect("raw fetch");
488 assert_eq!(status, 200);
489 assert!(body.contains("membershipType"));
490 }
491}