Skip to main content

vct_core/quota/
cursor.rs

1//! Cursor quota fetcher.
2//!
3//! Reads the WorkOS session JWT from the Cursor CLI's `auth.json`, synthesizes
4//! the `WorkosCursorSessionToken` cookie the way the official client does, and
5//! calls `GET /api/usage-summary`, impersonating the Cursor CLI via a
6//! `cursor-agent/<version>` User-Agent (version detected from
7//! `cursor-agent --version`, cached for the day).
8//!
9//! The access token is valid ~60 days and the official Cursor CLI / IDE keeps
10//! `auth.json` fresh in the background, so refresh is **reactive**: the file is
11//! re-read every tick and its token used while the JWT `exp` is still in the
12//! future. We never write the file back. An expired token or a 401/403 surfaces
13//! a `cursor-agent login` hint.
14
15use 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
25/// The Cursor usage-summary endpoint.
26const CURSOR_USAGE_URL: &str = "https://cursor.com/api/usage-summary";
27/// Fallback Cursor CLI version for the User-Agent when `cursor-agent --version`
28/// cannot be resolved (CLI absent or unreadable). Bump occasionally.
29const CURSOR_FALLBACK_VERSION: &str = "2026.07.07";
30/// Login hint shown when the Cursor session is expired / rejected. The CLI that
31/// manages `auth.json` is `cursor-agent` (`cursor` is the editor launcher).
32pub const CURSOR_LOGIN_HINT: &str = "run: cursor-agent login";
33
34/// Builds the Cursor CLI's request User-Agent, e.g. `cursor-agent/2026.07.07`.
35///
36/// The version is detected from the installed CLI (see
37/// [`crate::quota::http::detect_cli_version`]) so the UA tracks the real client
38/// rather than drifting from a hardcoded constant.
39pub(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
54/// A usable Cursor session: the synthesized cookie header + the JWT expiry.
55///
56/// Shared with the session-data reader (`crate::session::cursor`), which reuses
57/// the same cookie to reach the dashboard usage-events API.
58pub(crate) struct CursorSession {
59    pub(crate) cookie: String,
60    pub(crate) exp: i64,
61}
62
63/// Decodes a JWT payload segment (base64url, no padding) into JSON.
64fn decode_jwt_payload(token: &str) -> Option<serde_json::Value> {
65    let payload = token.split('.').nth(1)?;
66    // JWT segments are base64url without padding; tolerate any stray padding.
67    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
74/// Builds the `WorkosCursorSessionToken` cookie + reads `exp` from `auth.json`.
75///
76/// The cookie value is `<userID>::<accessToken>` with `::` percent-encoded,
77/// where `userID` is the JWT `sub` claim after the final `|`.
78pub(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
97/// Maps a `/api/usage-summary` body into a [`CursorQuotaSnapshot`] (pure).
98///
99/// # Errors
100///
101/// Returns an error if the body is not valid JSON in the expected shape.
102pub 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    // `*PercentUsed` is percent consumed: the same response restates those very
108    // numbers in English via `autoModelSelectedDisplayMessage` /
109    // `namedModelSelectedDisplayMessage` ("You've used N% of your included …").
110    // Take them verbatim, clamped only as an out-of-range guard. Do not invert:
111    // #69 did, which reported an idle account as a full gauge with LIMIT set.
112    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    // Prefer the individual on-demand spend; team/enterprise accounts bill it
125    // under `teamUsage.onDemand` while the individual branch is disabled.
126    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
159/// Outcome of a single usage request.
160enum FetchResult {
161    Ok(CursorQuotaSnapshot),
162    /// 401/403 → the session is rejected; re-login.
163    Unauthorized,
164    /// Network / other non-success error → keep last-known-good.
165    Transient,
166}
167
168/// Calls the Cursor usage API at `usage_url` with the synthesized session cookie.
169fn 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
206/// Fetches the raw `/api/usage-summary` response for `vct fetch cursor`.
207///
208/// Synthesizes the session cookie from the stored JWT and sends one request. It
209/// does **not** check the JWT `exp` (an expired token just 401s) and never
210/// writes the file back. Returns `(status_code, body)`; a non-2xx status is
211/// left for the caller to surface.
212///
213/// # Errors
214///
215/// Returns an error if `auth.json` is missing, has no usable session, or the
216/// request cannot be sent.
217pub fn fetch_cursor_raw(client: &Client) -> Result<(u16, String)> {
218    fetch_cursor_raw_from(client, CURSOR_USAGE_URL, &get_cursor_auth_path()?)
219}
220
221/// The injectable core of [`fetch_cursor_raw`]: reads the session from an
222/// explicit `auth.json` path and fetches the raw usage body from an explicit
223/// `usage_url`. Production passes [`CURSOR_USAGE_URL`] + `~/.config/cursor/auth.json`;
224/// tests point them at a local mock server and a temp auth file.
225pub(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/// Per-worker Cursor state. Refresh is reactive (the official CLI keeps
258/// `auth.json` fresh), so nothing is cached between ticks.
259#[derive(Default)]
260pub struct CursorState;
261
262impl CursorState {
263    /// One worker tick: re-read the session, call the usage API.
264    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        // The file exists but yields no usable session (cleared on logout, no
275        // `accessToken`, or a malformed JWT) — an auth failure, not a network
276        // blip — so nudge login instead of silently showing "no Cursor quota".
277        let session = match read_cursor_session(&body) {
278            Some(s) => s,
279            None => return QuotaOutcome::NeedsLogin,
280        };
281        // The token is expired and we cannot refresh it ourselves; nudge login.
282        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    /// Builds a fake JWT (`header.payload.sig`) from a payload JSON object.
299    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        // The API reports percent used; the gauge shows it verbatim.
354        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        // On-demand disabled → no dollar figure.
359        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        // Individual on-demand disabled, but the team pool carries the spend.
373        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        // Even at 100% used, the unlimited flag suppresses LIMIT.
392        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        // The shape a never-used plan actually returns. Inverting it renders
400        // three full gauges and a spurious LIMIT flag.
401        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        // The clamp is the whole mapping now, so pin both arms: a gauge outside
421        // 0..=100 would break the bar width and the limit comparison.
422        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    // ---- HTTP-layer tests against a local mock server (no real API) ----
439
440    #[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}