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    // NOTE: despite the `*PercentUsed` names, Cursor reports these inversely to
108    // absolute usage. Observed on a fresh free plan: `plan.used == 0` yet
109    // `totalPercentUsed == 94`, so the field is *not* percent used. We invert
110    // (100 - value) so a barely-used account reads as a near-empty gauge and
111    // matches what cursor.com shows, consistent with the other panels. Do not
112    // "simplify" this back to `p` — that regresses the gauge to show ~full for
113    // an unused account.
114    let win = |pct: Option<f64>| {
115        pct.map(|p| QuotaWindow {
116            used_percent: (100.0 - p).clamp(0.0, 100.0),
117            resets_at_unix: reset,
118        })
119    };
120
121    let plan = resp.individual_usage.as_ref().and_then(|u| u.plan.as_ref());
122    let total = win(plan.and_then(|p| p.total_percent_used));
123    let auto = win(plan.and_then(|p| p.auto_percent_used));
124    let api = win(plan.and_then(|p| p.api_percent_used));
125
126    // Prefer the individual on-demand spend; team/enterprise accounts bill it
127    // under `teamUsage.onDemand` while the individual branch is disabled.
128    let individual_od = resp
129        .individual_usage
130        .as_ref()
131        .and_then(|u| u.on_demand.as_ref())
132        .filter(|d| d.enabled == Some(true))
133        .and_then(|d| d.used);
134    let team_od = resp
135        .team_usage
136        .as_ref()
137        .and_then(|t| t.on_demand.as_ref())
138        .and_then(|d| d.used);
139    let on_demand_dollars = individual_od.or(team_od).map(|cents| cents / 100.0);
140
141    let is_unlimited = resp.is_unlimited.unwrap_or(false);
142    let limit_reached = !is_unlimited
143        && total
144            .as_ref()
145            .map(|w| w.used_percent >= 100.0)
146            .unwrap_or(false);
147
148    Ok(CursorQuotaSnapshot {
149        source: QuotaSource::Api,
150        fetched_at: now,
151        plan_type: resp.membership_type.filter(|s| !s.is_empty()),
152        total,
153        auto,
154        api,
155        on_demand_dollars,
156        limit_reached,
157        needs_login: false,
158    })
159}
160
161/// Outcome of a single usage request.
162enum FetchResult {
163    Ok(CursorQuotaSnapshot),
164    /// 401/403 → the session is rejected; re-login.
165    Unauthorized,
166    /// Network / other non-success error → keep last-known-good.
167    Transient,
168}
169
170/// Calls the Cursor usage API at `usage_url` with the synthesized session cookie.
171fn fetch_cursor_usage(client: &Client, cookie: &str, now: i64, usage_url: &str) -> FetchResult {
172    let resp = match client
173        .get(usage_url)
174        .header(reqwest::header::COOKIE, cookie)
175        .header(reqwest::header::ACCEPT, "application/json")
176        .header(reqwest::header::USER_AGENT, cursor_ua())
177        .send()
178    {
179        Ok(r) => r,
180        Err(e) => {
181            log::warn!("cursor quota fetch: request failed: {e}");
182            return FetchResult::Transient;
183        }
184    };
185    let status = resp.status();
186    if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
187        return FetchResult::Unauthorized;
188    }
189    if !status.is_success() {
190        log::warn!("cursor quota fetch: HTTP {status}");
191        return FetchResult::Transient;
192    }
193    match resp.text() {
194        Ok(text) => match map_cursor_usage(&text, now) {
195            Ok(snap) => FetchResult::Ok(snap),
196            Err(e) => {
197                log::warn!("cursor quota fetch: failed to parse usage response: {e}");
198                FetchResult::Transient
199            }
200        },
201        Err(e) => {
202            log::warn!("cursor quota fetch: failed to read response body: {e}");
203            FetchResult::Transient
204        }
205    }
206}
207
208/// Fetches the raw `/api/usage-summary` response for `vct fetch cursor`.
209///
210/// Synthesizes the session cookie from the stored JWT and sends one request. It
211/// does **not** check the JWT `exp` (an expired token just 401s) and never
212/// writes the file back. Returns `(status_code, body)`; a non-2xx status is
213/// left for the caller to surface.
214///
215/// # Errors
216///
217/// Returns an error if `auth.json` is missing, has no usable session, or the
218/// request cannot be sent.
219pub fn fetch_cursor_raw(client: &Client) -> Result<(u16, String)> {
220    fetch_cursor_raw_from(client, CURSOR_USAGE_URL, &get_cursor_auth_path()?)
221}
222
223/// The injectable core of [`fetch_cursor_raw`]: reads the session from an
224/// explicit `auth.json` path and fetches the raw usage body from an explicit
225/// `usage_url`. Production passes [`CURSOR_USAGE_URL`] + `~/.config/cursor/auth.json`;
226/// tests point them at a local mock server and a temp auth file.
227pub(crate) fn fetch_cursor_raw_from(
228    client: &Client,
229    usage_url: &str,
230    auth_path: &Path,
231) -> Result<(u16, String)> {
232    let path = auth_path;
233    let body = std::fs::read_to_string(path).with_context(|| {
234        format!(
235            "no Cursor credentials at {} ({CURSOR_LOGIN_HINT})",
236            path.display()
237        )
238    })?;
239    let session = read_cursor_session(&body).with_context(|| {
240        format!(
241            "no usable Cursor session in {} ({CURSOR_LOGIN_HINT})",
242            path.display()
243        )
244    })?;
245    let resp = client
246        .get(usage_url)
247        .header(reqwest::header::COOKIE, session.cookie.as_str())
248        .header(reqwest::header::ACCEPT, "application/json")
249        .header(reqwest::header::USER_AGENT, cursor_ua())
250        .send()
251        .context("Failed to send Cursor usage request")?;
252    let status = resp.status().as_u16();
253    let text = resp
254        .text()
255        .context("Failed to read Cursor usage response body")?;
256    Ok((status, text))
257}
258
259/// Per-worker Cursor state. Refresh is reactive (the official CLI keeps
260/// `auth.json` fresh), so nothing is cached between ticks.
261#[derive(Default)]
262pub struct CursorState;
263
264impl CursorState {
265    /// One worker tick: re-read the session, call the usage API.
266    pub fn resolve(&mut self, client: &Client) -> QuotaOutcome<CursorQuotaSnapshot> {
267        let now = chrono::Local::now().timestamp();
268        let path = match get_cursor_auth_path() {
269            Ok(p) => p,
270            Err(_) => return QuotaOutcome::Transient,
271        };
272        let body = match std::fs::read_to_string(&path) {
273            Ok(b) => b,
274            Err(_) => return QuotaOutcome::Transient,
275        };
276        // The file exists but yields no usable session (cleared on logout, no
277        // `accessToken`, or a malformed JWT) — an auth failure, not a network
278        // blip — so nudge login instead of silently showing "no Cursor quota".
279        let session = match read_cursor_session(&body) {
280            Some(s) => s,
281            None => return QuotaOutcome::NeedsLogin,
282        };
283        // The token is expired and we cannot refresh it ourselves; nudge login.
284        if session.exp > 0 && session.exp <= now {
285            return QuotaOutcome::NeedsLogin;
286        }
287        match fetch_cursor_usage(client, &session.cookie, now, CURSOR_USAGE_URL) {
288            FetchResult::Ok(snap) => QuotaOutcome::Data(snap),
289            FetchResult::Unauthorized => QuotaOutcome::NeedsLogin,
290            FetchResult::Transient => QuotaOutcome::Transient,
291        }
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use base64::Engine;
299
300    /// Builds a fake JWT (`header.payload.sig`) from a payload JSON object.
301    fn fake_jwt(payload: &serde_json::Value) -> String {
302        let enc = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
303        format!(
304            "{}.{}.{}",
305            enc(b"{\"alg\":\"none\"}"),
306            enc(payload.to_string().as_bytes()),
307            "sig"
308        )
309    }
310
311    #[test]
312    fn builds_cookie_from_jwt_sub() {
313        let jwt = fake_jwt(&serde_json::json!({
314            "sub": "github|user_01ABC",
315            "exp": 9_999_999_999i64,
316        }));
317        let body = format!(r#"{{ "accessToken": "{jwt}", "refreshToken": "rt" }}"#);
318        let session = read_cursor_session(&body).unwrap();
319        assert_eq!(
320            session.cookie,
321            format!("WorkosCursorSessionToken=user_01ABC%3A%3A{jwt}")
322        );
323        assert_eq!(session.exp, 9_999_999_999);
324    }
325
326    #[test]
327    fn sub_without_pipe_uses_whole_value() {
328        let jwt = fake_jwt(&serde_json::json!({ "sub": "user_only", "exp": 1 }));
329        let body = format!(r#"{{ "accessToken": "{jwt}" }}"#);
330        let session = read_cursor_session(&body).unwrap();
331        assert!(session.cookie.contains("=user_only%3A%3A"));
332    }
333
334    #[test]
335    fn missing_access_token_is_none() {
336        assert!(read_cursor_session(r#"{ "refreshToken": "rt" }"#).is_none());
337    }
338
339    const SUMMARY: &str = r#"{
340      "billingCycleStart": "2026-06-23T17:36:23.480Z",
341      "billingCycleEnd": "2026-07-23T17:36:23.480Z",
342      "membershipType": "free",
343      "isUnlimited": false,
344      "individualUsage": {
345        "plan": { "autoPercentUsed": 100, "apiPercentUsed": 44, "totalPercentUsed": 94 },
346        "onDemand": { "enabled": false, "used": 0, "limit": null }
347      }
348    }"#;
349
350    #[test]
351    fn maps_cursor_usage() {
352        let snap = map_cursor_usage(SUMMARY, 1_000_000).unwrap();
353        assert_eq!(snap.source, QuotaSource::Api);
354        assert_eq!(snap.plan_type.as_deref(), Some("free"));
355        // API reports percent remaining; the gauge shows used (100 - remaining).
356        assert_eq!(snap.total.as_ref().unwrap().used_percent, 6.0);
357        assert_eq!(snap.auto.as_ref().unwrap().used_percent, 0.0);
358        assert_eq!(snap.api.as_ref().unwrap().used_percent, 56.0);
359        assert!(snap.total.as_ref().unwrap().resets_at_unix.unwrap() > 0);
360        // On-demand disabled → no dollar figure.
361        assert!(snap.on_demand_dollars.is_none());
362        assert!(!snap.limit_reached);
363    }
364
365    #[test]
366    fn on_demand_dollars_from_cents_when_enabled() {
367        let body = r#"{ "membershipType": "pro", "individualUsage": { "onDemand": { "enabled": true, "used": 1840 } } }"#;
368        let snap = map_cursor_usage(body, 1).unwrap();
369        assert_eq!(snap.on_demand_dollars, Some(18.40));
370    }
371
372    #[test]
373    fn on_demand_falls_back_to_team_usage() {
374        // Individual on-demand disabled, but the team pool carries the spend.
375        let body = r#"{
376          "membershipType": "enterprise",
377          "individualUsage": { "onDemand": { "enabled": false } },
378          "teamUsage": { "onDemand": { "used": 5000 } }
379        }"#;
380        let snap = map_cursor_usage(body, 1).unwrap();
381        assert_eq!(snap.on_demand_dollars, Some(50.0));
382    }
383
384    #[test]
385    fn flags_limit_when_total_maxed() {
386        // 0% remaining -> 100% used -> limit reached.
387        let body =
388            r#"{ "isUnlimited": false, "individualUsage": { "plan": { "totalPercentUsed": 0 } } }"#;
389        let snap = map_cursor_usage(body, 1).unwrap();
390        assert!(snap.limit_reached);
391    }
392
393    #[test]
394    fn unlimited_never_flags_limit() {
395        // Even at 0% remaining (fully used), the unlimited flag suppresses LIMIT.
396        let body =
397            r#"{ "isUnlimited": true, "individualUsage": { "plan": { "totalPercentUsed": 0 } } }"#;
398        let snap = map_cursor_usage(body, 1).unwrap();
399        assert!(!snap.limit_reached);
400    }
401
402    #[test]
403    fn missing_usage_is_tolerated() {
404        let snap = map_cursor_usage(r#"{ "membershipType": "free" }"#, 1).unwrap();
405        assert!(snap.total.is_none());
406        assert!(snap.auto.is_none());
407        assert!(snap.api.is_none());
408        assert!(!snap.limit_reached);
409    }
410
411    // ---- HTTP-layer tests against a local mock server (no real API) ----
412
413    #[test]
414    fn fetch_cursor_usage_maps_200_and_401() {
415        use crate::quota::http::build_client;
416        use httpmock::prelude::*;
417
418        let server = MockServer::start();
419        let ok = server.mock(|when, then| {
420            when.method(GET).path("/ok");
421            then.status(200).body(SUMMARY);
422        });
423        server.mock(|when, then| {
424            when.method(GET).path("/forbidden");
425            then.status(403);
426        });
427        let client = build_client().unwrap();
428
429        match fetch_cursor_usage(&client, "cookie", 1_000_000, &server.url("/ok")) {
430            FetchResult::Ok(snap) => assert_eq!(snap.plan_type.as_deref(), Some("free")),
431            _ => panic!("expected Ok"),
432        }
433        ok.assert();
434        assert!(matches!(
435            fetch_cursor_usage(&client, "cookie", 0, &server.url("/forbidden")),
436            FetchResult::Unauthorized
437        ));
438    }
439
440    #[test]
441    fn fetch_cursor_raw_from_reads_session_and_returns_body() {
442        use crate::quota::http::build_client;
443        use httpmock::prelude::*;
444
445        let server = MockServer::start();
446        server.mock(|when, then| {
447            when.method(GET).path("/usage");
448            then.status(200).body(SUMMARY);
449        });
450        let jwt = fake_jwt(&serde_json::json!({ "sub": "github|u1", "exp": 9_999_999_999i64 }));
451        let dir = tempfile::tempdir().unwrap();
452        let auth = dir.path().join("auth.json");
453        std::fs::write(&auth, format!(r#"{{ "accessToken": "{jwt}" }}"#)).unwrap();
454
455        let client = build_client().unwrap();
456        let (status, body) =
457            fetch_cursor_raw_from(&client, &server.url("/usage"), &auth).expect("raw fetch");
458        assert_eq!(status, 200);
459        assert!(body.contains("membershipType"));
460    }
461}