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/// Built and consumed only by this fetcher. The session-data reader
57/// (`crate::session::cursor`) reads local chat stores, so it needs neither a
58/// cookie nor the network.
59pub(crate) struct CursorSession {
60    pub(crate) cookie: String,
61    pub(crate) exp: i64,
62}
63
64/// Decodes a JWT payload segment (base64url, no padding) into JSON.
65fn decode_jwt_payload(token: &str) -> Option<serde_json::Value> {
66    let payload = token.split('.').nth(1)?;
67    // JWT segments are base64url without padding; tolerate any stray padding.
68    let trimmed = payload.trim_end_matches('=');
69    let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
70        .decode(trimmed)
71        .ok()?;
72    serde_json::from_slice(&bytes).ok()
73}
74
75/// Builds the `WorkosCursorSessionToken` cookie + reads `exp` from `auth.json`.
76///
77/// The cookie value is `<userID>::<accessToken>` with `::` percent-encoded,
78/// where `userID` is the JWT `sub` claim after the final `|`.
79pub(crate) fn read_cursor_session(body: &str) -> Option<CursorSession> {
80    let root: serde_json::Value = serde_json::from_str(body).ok()?;
81    let access = root.get("accessToken")?.as_str()?;
82    if access.is_empty() {
83        return None;
84    }
85    let claims = decode_jwt_payload(access)?;
86    let sub = claims.get("sub")?.as_str()?;
87    let uid = sub.rsplit('|').next().unwrap_or(sub);
88    if uid.is_empty() {
89        return None;
90    }
91    let exp = claims.get("exp").and_then(|v| v.as_i64()).unwrap_or(0);
92    Some(CursorSession {
93        cookie: format!("WorkosCursorSessionToken={uid}%3A%3A{access}"),
94        exp,
95    })
96}
97
98/// Maps a `/api/usage-summary` body into a [`CursorQuotaSnapshot`] (pure).
99///
100/// # Errors
101///
102/// Returns an error if the body is not valid JSON in the expected shape.
103pub fn map_cursor_usage(body: &str, now: i64) -> Result<CursorQuotaSnapshot> {
104    let resp: CursorUsageSummary =
105        serde_json::from_str(body).context("Failed to parse Cursor usage summary")?;
106
107    let reset = resp.billing_cycle_end.as_deref().and_then(iso_to_unix_secs);
108    // `*PercentUsed` is percent consumed: the same response restates those very
109    // numbers in English via `autoModelSelectedDisplayMessage` /
110    // `namedModelSelectedDisplayMessage` ("You've used N% of your included …").
111    // Take them verbatim, clamped only as an out-of-range guard. Do not invert:
112    // #69 did, which reported an idle account as a full gauge with LIMIT set.
113    let win = |pct: Option<f64>| {
114        pct.map(|p| QuotaWindow {
115            used_percent: p.clamp(0.0, 100.0),
116            resets_at_unix: reset,
117        })
118    };
119
120    let plan = resp.individual_usage.as_ref().and_then(|u| u.plan.as_ref());
121    let total = win(plan.and_then(|p| p.total_percent_used));
122    let auto = win(plan.and_then(|p| p.auto_percent_used));
123    let api = win(plan.and_then(|p| p.api_percent_used));
124
125    // Prefer the individual on-demand spend; team/enterprise accounts bill it
126    // under `teamUsage.onDemand` while the individual branch is disabled.
127    let individual_od = resp
128        .individual_usage
129        .as_ref()
130        .and_then(|u| u.on_demand.as_ref())
131        .filter(|d| d.enabled == Some(true))
132        .and_then(|d| d.used);
133    let team_od = resp
134        .team_usage
135        .as_ref()
136        .and_then(|t| t.on_demand.as_ref())
137        .and_then(|d| d.used);
138    let on_demand_dollars = individual_od.or(team_od).map(|cents| cents / 100.0);
139
140    let is_unlimited = resp.is_unlimited.unwrap_or(false);
141    let limit_reached = !is_unlimited
142        && total
143            .as_ref()
144            .map(|w| w.used_percent >= 100.0)
145            .unwrap_or(false);
146
147    Ok(CursorQuotaSnapshot {
148        source: QuotaSource::Api,
149        fetched_at: now,
150        plan_type: resp.membership_type.filter(|s| !s.is_empty()),
151        total,
152        auto,
153        api,
154        on_demand_dollars,
155        limit_reached,
156        needs_login: false,
157    })
158}
159
160/// Outcome of a single usage request.
161enum FetchResult {
162    Ok(CursorQuotaSnapshot),
163    /// 401/403 → the session is rejected; re-login.
164    Unauthorized,
165    /// Network / other non-success error → keep last-known-good.
166    Transient,
167}
168
169/// Calls the Cursor usage API at `usage_url` with the synthesized session cookie.
170fn fetch_cursor_usage(client: &Client, cookie: &str, now: i64, usage_url: &str) -> FetchResult {
171    let resp = match client
172        .get(usage_url)
173        .header(reqwest::header::COOKIE, cookie)
174        .header(reqwest::header::ACCEPT, "application/json")
175        .header(reqwest::header::USER_AGENT, cursor_ua())
176        .send()
177    {
178        Ok(r) => r,
179        Err(e) => {
180            log::warn!("cursor quota fetch: request failed: {e}");
181            return FetchResult::Transient;
182        }
183    };
184    let status = resp.status();
185    if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
186        return FetchResult::Unauthorized;
187    }
188    if !status.is_success() {
189        log::warn!("cursor quota fetch: HTTP {status}");
190        return FetchResult::Transient;
191    }
192    match resp.text() {
193        Ok(text) => match map_cursor_usage(&text, now) {
194            Ok(snap) => FetchResult::Ok(snap),
195            Err(e) => {
196                log::warn!("cursor quota fetch: failed to parse usage response: {e}");
197                FetchResult::Transient
198            }
199        },
200        Err(e) => {
201            log::warn!("cursor quota fetch: failed to read response body: {e}");
202            FetchResult::Transient
203        }
204    }
205}
206
207/// Fetches the raw `/api/usage-summary` response for `vct fetch cursor`.
208///
209/// Synthesizes the session cookie from the stored JWT and sends one request. It
210/// does **not** check the JWT `exp` (an expired token just 401s) and never
211/// writes the file back. Returns `(status_code, body)`; a non-2xx status is
212/// left for the caller to surface.
213///
214/// # Errors
215///
216/// Returns an error if `auth.json` is missing, has no usable session, or the
217/// request cannot be sent.
218pub fn fetch_cursor_raw(client: &Client) -> Result<(u16, String)> {
219    fetch_cursor_raw_from(client, CURSOR_USAGE_URL, &get_cursor_auth_path()?)
220}
221
222/// The injectable core of [`fetch_cursor_raw`]: reads the session from an
223/// explicit `auth.json` path and fetches the raw usage body from an explicit
224/// `usage_url`. Production passes [`CURSOR_USAGE_URL`] + `~/.config/cursor/auth.json`;
225/// tests point them at a local mock server and a temp auth file.
226pub(crate) fn fetch_cursor_raw_from(
227    client: &Client,
228    usage_url: &str,
229    auth_path: &Path,
230) -> Result<(u16, String)> {
231    let path = auth_path;
232    let body = std::fs::read_to_string(path).with_context(|| {
233        format!(
234            "no Cursor credentials at {} ({CURSOR_LOGIN_HINT})",
235            path.display()
236        )
237    })?;
238    let session = read_cursor_session(&body).with_context(|| {
239        format!(
240            "no usable Cursor session in {} ({CURSOR_LOGIN_HINT})",
241            path.display()
242        )
243    })?;
244    let resp = client
245        .get(usage_url)
246        .header(reqwest::header::COOKIE, session.cookie.as_str())
247        .header(reqwest::header::ACCEPT, "application/json")
248        .header(reqwest::header::USER_AGENT, cursor_ua())
249        .send()
250        .context("Failed to send Cursor usage request")?;
251    let status = resp.status().as_u16();
252    let text = resp
253        .text()
254        .context("Failed to read Cursor usage response body")?;
255    Ok((status, text))
256}
257
258/// Per-worker Cursor state. Refresh is reactive (the official CLI keeps
259/// `auth.json` fresh), so nothing is cached between ticks.
260#[derive(Default)]
261pub struct CursorState;
262
263impl CursorState {
264    /// One worker tick: re-read the session, call the usage API.
265    pub fn resolve(&mut self, client: &Client) -> QuotaOutcome<CursorQuotaSnapshot> {
266        let now = chrono::Local::now().timestamp();
267        let path = match get_cursor_auth_path() {
268            Ok(p) => p,
269            Err(_) => return QuotaOutcome::Transient,
270        };
271        let body = match std::fs::read_to_string(&path) {
272            Ok(b) => b,
273            Err(_) => return QuotaOutcome::Transient,
274        };
275        // The file exists but yields no usable session (cleared on logout, no
276        // `accessToken`, or a malformed JWT) — an auth failure, not a network
277        // blip — so nudge login instead of silently showing "no Cursor quota".
278        let session = match read_cursor_session(&body) {
279            Some(s) => s,
280            None => return QuotaOutcome::NeedsLogin,
281        };
282        // The token is expired and we cannot refresh it ourselves; nudge login.
283        if session.exp > 0 && session.exp <= now {
284            return QuotaOutcome::NeedsLogin;
285        }
286        match fetch_cursor_usage(client, &session.cookie, now, CURSOR_USAGE_URL) {
287            FetchResult::Ok(snap) => QuotaOutcome::Data(snap),
288            FetchResult::Unauthorized => QuotaOutcome::NeedsLogin,
289            FetchResult::Transient => QuotaOutcome::Transient,
290        }
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use base64::Engine;
298
299    /// Builds a fake JWT (`header.payload.sig`) from a payload JSON object.
300    fn fake_jwt(payload: &serde_json::Value) -> String {
301        let enc = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
302        format!(
303            "{}.{}.{}",
304            enc(b"{\"alg\":\"none\"}"),
305            enc(payload.to_string().as_bytes()),
306            "sig"
307        )
308    }
309
310    #[test]
311    fn builds_cookie_from_jwt_sub() {
312        let jwt = fake_jwt(&serde_json::json!({
313            "sub": "github|user_01ABC",
314            "exp": 9_999_999_999i64,
315        }));
316        let body = format!(r#"{{ "accessToken": "{jwt}", "refreshToken": "rt" }}"#);
317        let session = read_cursor_session(&body).unwrap();
318        assert_eq!(
319            session.cookie,
320            format!("WorkosCursorSessionToken=user_01ABC%3A%3A{jwt}")
321        );
322        assert_eq!(session.exp, 9_999_999_999);
323    }
324
325    #[test]
326    fn sub_without_pipe_uses_whole_value() {
327        let jwt = fake_jwt(&serde_json::json!({ "sub": "user_only", "exp": 1 }));
328        let body = format!(r#"{{ "accessToken": "{jwt}" }}"#);
329        let session = read_cursor_session(&body).unwrap();
330        assert!(session.cookie.contains("=user_only%3A%3A"));
331    }
332
333    #[test]
334    fn missing_access_token_is_none() {
335        assert!(read_cursor_session(r#"{ "refreshToken": "rt" }"#).is_none());
336    }
337
338    const SUMMARY: &str = r#"{
339      "billingCycleStart": "2026-06-23T17:36:23.480Z",
340      "billingCycleEnd": "2026-07-23T17:36:23.480Z",
341      "membershipType": "free",
342      "isUnlimited": false,
343      "individualUsage": {
344        "plan": { "autoPercentUsed": 100, "apiPercentUsed": 44, "totalPercentUsed": 94 },
345        "onDemand": { "enabled": false, "used": 0, "limit": null }
346      }
347    }"#;
348
349    #[test]
350    fn maps_cursor_usage() {
351        let snap = map_cursor_usage(SUMMARY, 1_000_000).unwrap();
352        assert_eq!(snap.source, QuotaSource::Api);
353        assert_eq!(snap.plan_type.as_deref(), Some("free"));
354        // The API reports percent used; the gauge shows it verbatim.
355        assert_eq!(snap.total.as_ref().unwrap().used_percent, 94.0);
356        assert_eq!(snap.auto.as_ref().unwrap().used_percent, 100.0);
357        assert_eq!(snap.api.as_ref().unwrap().used_percent, 44.0);
358        assert!(snap.total.as_ref().unwrap().resets_at_unix.unwrap() > 0);
359        // On-demand disabled → no dollar figure.
360        assert!(snap.on_demand_dollars.is_none());
361        assert!(!snap.limit_reached);
362    }
363
364    #[test]
365    fn on_demand_dollars_from_cents_when_enabled() {
366        let body = r#"{ "membershipType": "pro", "individualUsage": { "onDemand": { "enabled": true, "used": 1840 } } }"#;
367        let snap = map_cursor_usage(body, 1).unwrap();
368        assert_eq!(snap.on_demand_dollars, Some(18.40));
369    }
370
371    #[test]
372    fn on_demand_falls_back_to_team_usage() {
373        // Individual on-demand disabled, but the team pool carries the spend.
374        let body = r#"{
375          "membershipType": "enterprise",
376          "individualUsage": { "onDemand": { "enabled": false } },
377          "teamUsage": { "onDemand": { "used": 5000 } }
378        }"#;
379        let snap = map_cursor_usage(body, 1).unwrap();
380        assert_eq!(snap.on_demand_dollars, Some(50.0));
381    }
382
383    #[test]
384    fn flags_limit_when_total_maxed() {
385        let body = r#"{ "isUnlimited": false, "individualUsage": { "plan": { "totalPercentUsed": 100 } } }"#;
386        let snap = map_cursor_usage(body, 1).unwrap();
387        assert!(snap.limit_reached);
388    }
389
390    #[test]
391    fn unlimited_never_flags_limit() {
392        // Even at 100% used, the unlimited flag suppresses LIMIT.
393        let body = r#"{ "isUnlimited": true, "individualUsage": { "plan": { "totalPercentUsed": 100 } } }"#;
394        let snap = map_cursor_usage(body, 1).unwrap();
395        assert!(!snap.limit_reached);
396    }
397
398    #[test]
399    fn idle_account_reads_as_unused() {
400        // The shape a never-used plan actually returns. Inverting it renders
401        // three full gauges and a spurious LIMIT flag.
402        let body = r#"{
403          "membershipType": "free",
404          "isUnlimited": false,
405          "individualUsage": {
406            "plan": {
407              "used": 0, "limit": 0, "remaining": 0,
408              "autoPercentUsed": 0, "apiPercentUsed": 0, "totalPercentUsed": 0
409            }
410          }
411        }"#;
412        let snap = map_cursor_usage(body, 1).unwrap();
413        assert_eq!(snap.total.as_ref().unwrap().used_percent, 0.0);
414        assert_eq!(snap.auto.as_ref().unwrap().used_percent, 0.0);
415        assert_eq!(snap.api.as_ref().unwrap().used_percent, 0.0);
416        assert!(!snap.limit_reached);
417    }
418
419    #[test]
420    fn out_of_range_percentages_are_clamped() {
421        // The clamp is the whole mapping now, so pin both arms: a gauge outside
422        // 0..=100 would break the bar width and the limit comparison.
423        let body = r#"{ "individualUsage": { "plan": { "totalPercentUsed": 150, "autoPercentUsed": -5 } } }"#;
424        let snap = map_cursor_usage(body, 1).unwrap();
425        assert_eq!(snap.total.as_ref().unwrap().used_percent, 100.0);
426        assert_eq!(snap.auto.as_ref().unwrap().used_percent, 0.0);
427        assert!(snap.limit_reached);
428    }
429
430    #[test]
431    fn missing_usage_is_tolerated() {
432        let snap = map_cursor_usage(r#"{ "membershipType": "free" }"#, 1).unwrap();
433        assert!(snap.total.is_none());
434        assert!(snap.auto.is_none());
435        assert!(snap.api.is_none());
436        assert!(!snap.limit_reached);
437    }
438
439    // ---- HTTP-layer tests against a local mock server (no real API) ----
440
441    #[test]
442    fn fetch_cursor_usage_maps_200_and_401() {
443        use crate::quota::http::build_client;
444        use httpmock::prelude::*;
445
446        let server = MockServer::start();
447        let ok = server.mock(|when, then| {
448            when.method(GET).path("/ok");
449            then.status(200).body(SUMMARY);
450        });
451        server.mock(|when, then| {
452            when.method(GET).path("/forbidden");
453            then.status(403);
454        });
455        let client = build_client().unwrap();
456
457        match fetch_cursor_usage(&client, "cookie", 1_000_000, &server.url("/ok")) {
458            FetchResult::Ok(snap) => {
459                assert_eq!(snap.plan_type.as_deref(), Some("free"));
460                assert_eq!(snap.total.as_ref().unwrap().used_percent, 94.0);
461            }
462            _ => panic!("expected Ok"),
463        }
464        ok.assert();
465        assert!(matches!(
466            fetch_cursor_usage(&client, "cookie", 0, &server.url("/forbidden")),
467            FetchResult::Unauthorized
468        ));
469    }
470
471    #[test]
472    fn fetch_cursor_raw_from_reads_session_and_returns_body() {
473        use crate::quota::http::build_client;
474        use httpmock::prelude::*;
475
476        let server = MockServer::start();
477        server.mock(|when, then| {
478            when.method(GET).path("/usage");
479            then.status(200).body(SUMMARY);
480        });
481        let jwt = fake_jwt(&serde_json::json!({ "sub": "github|u1", "exp": 9_999_999_999i64 }));
482        let dir = tempfile::tempdir().unwrap();
483        let auth = dir.path().join("auth.json");
484        std::fs::write(&auth, format!(r#"{{ "accessToken": "{jwt}" }}"#)).unwrap();
485
486        let client = build_client().unwrap();
487        let (status, body) =
488            fetch_cursor_raw_from(&client, &server.url("/usage"), &auth).expect("raw fetch");
489        assert_eq!(status, 200);
490        assert!(body.contains("membershipType"));
491    }
492}