From 0e6c963a820018d871ec480eb161add92eca2545 Mon Sep 17 00:00:00 2001 From: DEVARAJ K Date: Sat, 29 Aug 2026 17:29:11 +0000 Subject: [PATCH] feat(adapter): cursor quota via dashboard endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against a live capture. Two cookie-authed POST endpoints back the cursor.com dashboard: get-current-period-usage for the included usage pools (Cursor Models auto bucket + Other Models API usage, reset at billingCycleEnd) and get-sand-usage-status for the Grok Bot weekly window. Team accounts require a teamId in the request body, so the adapter tries the bare request first, discovers the team id via /api/dashboard/teams on refusal, and caches whichever strategy worked. Plans without the Grok feature simply omit that lane (4xx on the sand endpoint is absence, not an error); everything else keeps the contract — never throws, never coerces an unknown shape to a number, no Authorization header, no cookie access. Third provider proves the single-file contribution story: adapter file, registry line, manifest origin, fixtures, tests. Version 0.2.0. --- CONTRIBUTING.md | 7 +- README.md | 2 +- package.json | 2 +- public/manifest.json | 5 +- src/adapters/cursor.ts | 275 ++++++++++++++++++++++++ src/adapters/index.ts | 3 +- tests/cursor.test.ts | 128 +++++++++++ tests/fixtures/cursor/period-usage.json | 22 ++ tests/fixtures/cursor/sand-usage.json | 13 ++ tests/fixtures/cursor/teams.json | 8 + 10 files changed, 457 insertions(+), 8 deletions(-) create mode 100644 src/adapters/cursor.ts create mode 100644 tests/cursor.test.ts create mode 100644 tests/fixtures/cursor/period-usage.json create mode 100644 tests/fixtures/cursor/sand-usage.json create mode 100644 tests/fixtures/cursor/teams.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 867d8a3..46a98cd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -79,7 +79,7 @@ We read undocumented endpoints as guests: Also record (or hand-write) at least: a logged-out response, and a deliberately wrong shape (for the `schema_mismatch` test). -### Worked example: a hypothetical Cursor adapter +### Worked example: sketching an adapter ```ts // src/adapters/cursor.ts @@ -121,8 +121,9 @@ import { cursorAdapter } from './cursor'; export const adapters = [claudeAdapter, codexAdapter, cursorAdapter] as const; ``` -Look at `src/adapters/codex.ts` (simpler) and `src/adapters/claude.ts` -(endpoint probing, per-provider cache) for complete, tested references, and +Look at `src/adapters/codex.ts` (session-token auth), `src/adapters/claude.ts` +(endpoint probing, per-provider cache), and `src/adapters/cursor.ts` +(POST endpoints, team-id discovery) for complete, tested references, and mirror `tests/codex.test.ts` for the test checklist: happy path per variant, unauthenticated, rate-limited, unknown shape → `schema_mismatch`, never-throws, and no `Authorization` header. diff --git a/README.md b/README.md index cf1dd6f..a39e9aa 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ dashboards already live. |---|---|---| | Claude (claude.ai) | ✅ v0.1 | Your existing claude.ai browser session | | Codex (chatgpt.com) | ✅ v0.1 | Your existing chatgpt.com browser session | -| Cursor | 🙏 PRs welcome | — | +| Cursor (cursor.com) | ✅ v0.2 | Your existing cursor.com browser session | | Gemini | 🙏 PRs welcome | — | | Grok | 🙏 PRs welcome | — | | GitHub Copilot | 🙏 PRs welcome | — | diff --git a/package.json b/package.json index c380afa..8bc477c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ration", - "version": "0.1.1", + "version": "0.2.0", "private": true, "description": "Ration — AI Quota Tracker. A browser extension showing remaining quota across your AI subscriptions in one glance.", "type": "module", diff --git a/public/manifest.json b/public/manifest.json index e129208..19348ff 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,13 +1,14 @@ { "manifest_version": 3, "name": "Ration — AI Quota Tracker", - "version": "0.1.1", + "version": "0.2.0", "description": "One glance at remaining quota across your AI subscriptions. No accounts, no telemetry, no credential access.", "minimum_chrome_version": "120", "permissions": ["storage", "alarms"], "optional_host_permissions": [ "https://claude.ai/*", - "https://chatgpt.com/*" + "https://chatgpt.com/*", + "https://cursor.com/*" ], "host_permissions": [], "background": { diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts new file mode 100644 index 0000000..a045135 --- /dev/null +++ b/src/adapters/cursor.ts @@ -0,0 +1,275 @@ +/** + * Cursor adapter (Tier A). + * + * Rides the user's existing cursor.com browser session via + * `credentials: 'include'` — no cookie values are read, no tokens handled. + * + * Verified against a live capture (2026-08). The dashboard uses two + * cookie-authed POST endpoints: + * + * - /api/dashboard/get-current-period-usage — the "Included usage" pools. + * Team accounts must send {"teamId": N}; we first try {} (individual + * accounts), and on failure discover the team id via + * /api/dashboard/teams, caching whichever strategy worked. + * - /api/dashboard/get-sand-usage-status — the Grok Bot weekly window. + * Not present on all plans; a 404/400 simply omits that lane. + * + * Lanes: Cursor Models (auto bucket), Other Models (named/API models), + * and Grok Bot weekly — matching the rows on cursor.com/dashboard. + */ +import type { FetchContext, ProviderAdapter, ProviderSnapshot, QuotaLane } from '../types'; +import { clampPct } from '../lib/headroom'; +import { + arr, + matchVariant, + nullable, + num, + obj, + optional, + str, + type Result, + type Schema, +} from '../lib/validate'; + +export const CURSOR_ADAPTER_VERSION = 1; +const ORIGIN = 'https://cursor.com'; +const PERIOD_USAGE_URL = `${ORIGIN}/api/dashboard/get-current-period-usage`; +const SAND_USAGE_URL = `${ORIGIN}/api/dashboard/get-sand-usage-status`; +const TEAMS_URL = `${ORIGIN}/api/dashboard/teams`; + +interface CursorCache { + /** null = individual account ({} body works); a number = team id to send. */ + teamId?: number | null; +} + +const numOrStr: Schema = (v, path) => + typeof v === 'number' || typeof v === 'string' + ? ({ ok: true, value: v } as Result) + : { ok: false, path, expected: 'number|string' }; + +interface PlanUsage { + autoPercentUsed: number; + apiPercentUsed: number; +} + +interface PeriodUsageShape { + billingCycleEnd?: number | string | null; + planUsage: PlanUsage; +} + +const periodVariants = { + dashboard_period_usage: obj({ + billingCycleEnd: optional(nullable(numOrStr)), + planUsage: obj({ + autoPercentUsed: num, + apiPercentUsed: num, + }), + }), +}; + +interface SandUsageShape { + usagePercent: number; + nextResetTimestampUtc?: string | null; + grokPlanLabel?: string | null; +} + +const sandSchema = obj({ + usagePercent: num, + nextResetTimestampUtc: optional(nullable(str)), + grokPlanLabel: optional(nullable(str)), +}); + +const teamsSchema = obj<{ teams: { id: number }[] }>({ + teams: arr(obj({ id: num })), +}); + +/** Accepts ISO strings and epoch values (seconds or milliseconds). */ +function parseResetsAt(value: number | string | null | undefined): string | null { + if (value === null || value === undefined) return null; + const asNumber = typeof value === 'number' ? value : /^\d+$/.test(value) ? Number(value) : NaN; + if (Number.isFinite(asNumber)) { + const ms = asNumber > 1e12 ? asNumber : asNumber * 1000; + return new Date(ms).toISOString(); + } + const parsed = Date.parse(String(value)); + return Number.isNaN(parsed) ? null : new Date(parsed).toISOString(); +} + +function percentLane(id: string, label: string, usedPct: number, resetsAt: string | null): QuotaLane { + return { + id, + label, + kind: 'percent', + used: clampPct(usedPct), + limit: 100, + resetsAt, + headroomPct: clampPct(100 - usedPct), + }; +} + +async function readJson(response: Response): Promise { + try { + return await response.json(); + } catch { + return undefined; + } +} + +export const cursorAdapter: ProviderAdapter = { + id: 'cursor', + displayName: 'Cursor', + tier: 'A', + hostPermissions: ['https://cursor.com/*'], + dashboardUrl: 'https://cursor.com/dashboard', + minRefreshMs: 60_000, + + async fetch(ctx: FetchContext): Promise { + const base = { + providerId: this.id, + displayName: this.displayName, + adapterVersion: CURSOR_ADAPTER_VERSION, + fetchedAt: ctx.now().toISOString(), + }; + const failed = ( + status: 'unauthenticated' | 'rate_limited' | 'error', + code: string, + message: string, + ): ProviderSnapshot => ({ ...base, status, lanes: [], error: { code, message } }); + + const post = (url: string, body: Record) => + ctx.fetch(url, { + method: 'POST', + credentials: 'include', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + // Common status handling; returns a snapshot to surface, or null to go on. + const gate = (response: Response): ProviderSnapshot | null => { + if (response.status === 401 || response.status === 403) { + return failed('unauthenticated', 'not_logged_in', 'Log in at cursor.com'); + } + if (response.status === 429) { + return failed('rate_limited', 'rate_limited', 'cursor.com rate-limited us; backing off'); + } + return null; + }; + + const cache = (await ctx.cache.get()) ?? {}; + + // Step 1: included-usage pools, discovering the team id if needed. + // Order: cached strategy first, then {} (individual), then team discovery. + const bodies: Record[] = []; + if (cache.teamId != null) bodies.push({ teamId: cache.teamId }); + if (cache.teamId !== null || bodies.length === 0) bodies.push({}); + + let period: PeriodUsageShape | undefined; + let usedTeamId: number | null | undefined; + let lastFailure = 'unknown'; + + for (let attempt = 0; attempt < 2 && !period; attempt++) { + for (const body of bodies.splice(0)) { + if (period) break; + let response: Response; + try { + response = await post(PERIOD_USAGE_URL, body); + } catch (err) { + return failed('error', 'network', err instanceof Error ? err.message : String(err)); + } + const gated = gate(response); + if (gated) return gated; + if (!response.ok) { + lastFailure = `http_${response.status}`; + continue; + } + const json = await readJson(response); + if (json === undefined) { + lastFailure = 'not_json'; + continue; + } + const match = matchVariant(periodVariants, json); + if (!match.ok) { + const first = match.errors[0]; + lastFailure = `schema_mismatch at '${first?.path}'`; + continue; + } + period = match.value; + usedTeamId = 'teamId' in body ? (body.teamId as number) : null; + } + + // Nothing worked yet: discover a team id once, then retry with it. + if (!period && attempt === 0) { + let response: Response; + try { + response = await post(TEAMS_URL, {}); + } catch (err) { + return failed('error', 'network', err instanceof Error ? err.message : String(err)); + } + const gated = gate(response); + if (gated) return gated; + if (response.ok) { + const json = await readJson(response); + const teams = json === undefined ? undefined : teamsSchema(json, 'teams'); + const teamId = teams?.ok ? teams.value.teams[0]?.id : undefined; + if (teamId !== undefined) bodies.push({ teamId }); + } + } + } + + if (!period) { + return failed( + 'error', + 'endpoint_not_verified', + `cursor.com usage endpoint did not match (${lastFailure}) — help pin it via the "adapter broken" issue template`, + ); + } + + if (cache.teamId !== usedTeamId) { + await ctx.cache.set({ teamId: usedTeamId }); + } + + const cycleResetsAt = parseResetsAt(period.billingCycleEnd); + const lanes: QuotaLane[] = [ + percentLane('cursor_models', 'Cursor Models', period.planUsage.autoPercentUsed, cycleResetsAt), + percentLane('other_models', 'Other Models', period.planUsage.apiPercentUsed, cycleResetsAt), + ]; + + // Step 2: Grok Bot weekly window — absent on some plans (4xx = no lane). + let sandResponse: Response; + try { + sandResponse = await post(SAND_USAGE_URL, {}); + } catch (err) { + return failed('error', 'network', err instanceof Error ? err.message : String(err)); + } + const sandGated = gate(sandResponse); + if (sandGated) return sandGated; + if (sandResponse.ok) { + const json = await readJson(sandResponse); + if (json === undefined) { + return failed('error', 'not_json', 'get-sand-usage-status response was not JSON'); + } + const sand = sandSchema(json, ''); + if (!sand.ok) { + return failed( + 'error', + 'schema_mismatch', + `Unrecognized get-sand-usage-status shape at '${sand.path}'`, + ); + } + const label = sand.value.grokPlanLabel ? 'Weekly (Grok)' : 'Weekly bot usage'; + lanes.push( + percentLane( + 'grok_weekly', + label, + sand.value.usagePercent, + parseResetsAt(sand.value.nextResetTimestampUtc), + ), + ); + } else if (sandResponse.status >= 500) { + return failed('error', `http_${sandResponse.status}`, 'cursor.com returned a server error'); + } + // 404/400 etc.: plan without the Grok Bot feature — no lane, not an error. + + return { ...base, status: 'ok', schemaVariant: 'dashboard_period_usage', lanes }; + }, +}; diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 2691c90..0b90203 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -7,8 +7,9 @@ import type { ProviderAdapter } from '../types'; import { claudeAdapter } from './claude'; import { codexAdapter } from './codex'; +import { cursorAdapter } from './cursor'; -export const adapters: readonly ProviderAdapter[] = [claudeAdapter, codexAdapter]; +export const adapters: readonly ProviderAdapter[] = [claudeAdapter, codexAdapter, cursorAdapter]; export const getAdapter = (id: string): ProviderAdapter | undefined => adapters.find((adapter) => adapter.id === id); diff --git a/tests/cursor.test.ts b/tests/cursor.test.ts new file mode 100644 index 0000000..b2bff60 --- /dev/null +++ b/tests/cursor.test.ts @@ -0,0 +1,128 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { cursorAdapter } from '../src/adapters/cursor'; +import { htmlResponse, jsonResponse, makeCtx } from './helpers'; + +const fixture = (name: string): unknown => + JSON.parse(readFileSync(new URL(`./fixtures/cursor/${name}`, import.meta.url), 'utf8')); + +const PERIOD_URL = 'https://cursor.com/api/dashboard/get-current-period-usage'; +const SAND_URL = 'https://cursor.com/api/dashboard/get-sand-usage-status'; +const TEAMS_URL = 'https://cursor.com/api/dashboard/teams'; + +/** Individual account: {} works directly on the period endpoint. */ +const individualResponder = (url: string): Response => { + if (url === PERIOD_URL) return jsonResponse(fixture('period-usage.json')); + if (url === SAND_URL) return jsonResponse(fixture('sand-usage.json')); + return jsonResponse({}, 404); +}; + +const bodyOf = (init: RequestInit | undefined): Record => + JSON.parse((init?.body as string) ?? '{}'); + +describe('cursor adapter', () => { + it('parses the dashboard usage pools and the Grok weekly window', async () => { + const ctx = makeCtx(individualResponder); + const snap = await cursorAdapter.fetch(ctx); + + expect(snap.status).toBe('ok'); + expect(snap.schemaVariant).toBe('dashboard_period_usage'); + expect(snap.lanes.map((l) => [l.id, l.label, Math.round(l.headroomPct)])).toEqual([ + ['cursor_models', 'Cursor Models', 100], + ['other_models', 'Other Models', 90], + ['grok_weekly', 'Weekly (Grok)', 97], + ]); + // billingCycleEnd is an epoch-milliseconds string + expect(snap.lanes[0]?.resetsAt).toBe(new Date(1789911772000).toISOString()); + expect(snap.lanes[2]?.resetsAt).toBe('2026-08-31T17:23:51.777Z'); + }); + + it('caches the individual (no team) strategy', async () => { + const ctx = makeCtx(individualResponder); + await cursorAdapter.fetch(ctx); + expect(ctx.cached()).toEqual({ teamId: null }); + }); + + it('discovers the team id when the bare request is refused', async () => { + // Team accounts: the period endpoint answers only with the right teamId. + const ctx = makeCtx(() => jsonResponse({}, 404)); + ctx.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + ctx.requests.push({ url, init }); + if (url === PERIOD_URL) { + return bodyOf(init).teamId === 11000001 + ? jsonResponse(fixture('period-usage.json')) + : jsonResponse({ error: 'teamId required' }, 400); + } + if (url === TEAMS_URL) return jsonResponse(fixture('teams.json')); + if (url === SAND_URL) return jsonResponse(fixture('sand-usage.json')); + return jsonResponse({}, 404); + }) as typeof globalThis.fetch; + + const snap = await cursorAdapter.fetch(ctx); + expect(snap.status).toBe('ok'); + expect(ctx.cached()).toEqual({ teamId: 11000001 }); + // subsequent fetches lead with the cached team id + await cursorAdapter.fetch(ctx); + const periodCalls = ctx.requests.filter((r) => r.url === PERIOD_URL); + expect(bodyOf(periodCalls[periodCalls.length - 1]?.init).teamId).toBe(11000001); + }); + + it('omits the Grok lane when the sand endpoint is absent (404)', async () => { + const ctx = makeCtx((url) => + url === PERIOD_URL ? jsonResponse(fixture('period-usage.json')) : jsonResponse({}, 404), + ); + const snap = await cursorAdapter.fetch(ctx); + expect(snap.status).toBe('ok'); + expect(snap.lanes.map((l) => l.id)).toEqual(['cursor_models', 'other_models']); + }); + + it('reports endpoint_not_verified when no strategy matches', async () => { + const ctx = makeCtx(() => jsonResponse({ unexpected: true }, 200)); + const snap = await cursorAdapter.fetch(ctx); + expect(snap.status).toBe('error'); + expect(snap.error?.code).toBe('endpoint_not_verified'); + expect(snap.lanes).toEqual([]); + }); + + it('maps 401/403 to unauthenticated and 429 to rate_limited', async () => { + for (const [status, expected] of [ + [401, 'unauthenticated'], + [403, 'unauthenticated'], + [429, 'rate_limited'], + ] as const) { + const ctx = makeCtx(() => jsonResponse({}, status)); + const snap = await cursorAdapter.fetch(ctx); + expect(snap.status).toBe(expected); + } + }); + + it('treats an HTML body as a failed strategy, never a number', async () => { + const ctx = makeCtx(() => htmlResponse(200)); + const snap = await cursorAdapter.fetch(ctx); + expect(snap.status).toBe('error'); + expect(snap.error?.code).toBe('endpoint_not_verified'); + }); + + it('never throws on network failure', async () => { + const ctx = makeCtx(() => { + throw new Error('offline'); + }); + const snap = await cursorAdapter.fetch(ctx); + expect(snap.status).toBe('error'); + expect(snap.error?.code).toBe('network'); + }); + + it('rides the browser session: credentials include, no Authorization header', async () => { + const ctx = makeCtx(individualResponder); + await cursorAdapter.fetch(ctx); + + expect(ctx.requests.length).toBeGreaterThan(0); + for (const req of ctx.requests) { + expect(req.init?.credentials).toBe('include'); + expect(req.init?.method).toBe('POST'); + const headers = Object.keys((req.init?.headers as Record) ?? {}); + expect(headers.map((h) => h.toLowerCase())).not.toContain('authorization'); + } + }); +}); diff --git a/tests/fixtures/cursor/period-usage.json b/tests/fixtures/cursor/period-usage.json new file mode 100644 index 0000000..7fb7409 --- /dev/null +++ b/tests/fixtures/cursor/period-usage.json @@ -0,0 +1,22 @@ +{ + "billingCycleStart": "1787233372000", + "billingCycleEnd": "1789911772000", + "planUsage": { + "totalSpend": 2270, + "includedSpend": 2000, + "bonusSpend": 270, + "limit": 2000, + "remainingBonus": false, + "autoPercentUsed": 0.1895238095238095, + "apiPercentUsed": 10.355, + "totalPercentUsed": 1.8159999999999998 + }, + "spendLimitUsage": { + "pooledUsed": 0, + "limitType": "team" + }, + "displayThreshold": 200, + "enabled": true, + "autoModelSelectedDisplayMessage": "You've used 2% of your included total usage", + "namedModelSelectedDisplayMessage": "You've used 10% of your included API usage" +} diff --git a/tests/fixtures/cursor/sand-usage.json b/tests/fixtures/cursor/sand-usage.json new file mode 100644 index 0000000..b3b302d --- /dev/null +++ b/tests/fixtures/cursor/sand-usage.json @@ -0,0 +1,13 @@ +{ + "currentPeriodStart": "2026-08-26T17:22:03.913Z", + "grokPlanLabel": "Grok Bot Plan", + "hasAvailableUsage": true, + "hasNonZeroIncludedLimit": true, + "nextResetTimestampUtc": "2026-08-31T17:23:51.777Z", + "onDemandSettings": { + "visible": true, + "eligible": true, + "dashboardUrl": "https://cursor.com/dashboard/spending" + }, + "usagePercent": 2.948175 +} diff --git a/tests/fixtures/cursor/teams.json b/tests/fixtures/cursor/teams.json new file mode 100644 index 0000000..f1a8ea2 --- /dev/null +++ b/tests/fixtures/cursor/teams.json @@ -0,0 +1,8 @@ +{ + "teams": [ + { + "id": 11000001, + "name": "Example Team" + } + ] +}