Skip to content

Commit 9238796

Browse files
cursor-usage-cliclaude
andcommitted
Add current-cycle limit reporting (Composer/Auto vs other models)
The default summary now shows how much of the plan's included usage has been consumed this billing cycle — and how much is left before hitting the limit — as two buckets, matching the dashboard's split: - Composer / Auto (composer-2.5, composer-2.5-fast, Auto) - Other models (explicitly selected / API models) Data comes from POST /api/dashboard/get-current-period-usage (the endpoint the web dashboard uses), with GET /api/usage-summary as a fallback; server-computed percentages are shown verbatim. A new --limits-json flag dumps the raw payload for scripting. The section degrades gracefully (silently skipped) on accounts where the endpoints are unavailable, so existing behavior is unchanged. Bump version to 0.2.0 (new backward-compatible feature). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a7b0093 commit 9238796

8 files changed

Lines changed: 342 additions & 3 deletions

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,22 @@ claude-4.6-opus-high 233.71 786,434 1,059,451
3838
gemini-3.5-flash 45.77 16,097,065 1,075,904
3939
gpt-5.4-high 43.65 4,069,043 1,041,967
4040
...
41+
42+
==============================================================================
43+
CURSOR CYCLE LIMITS | resets 2026-07-04
44+
==============================================================================
45+
Composer / Auto [###########...................] 36.7% used, 63.3% left
46+
Other models [####..........................] 12.4% used, 87.6% left
47+
Included usage $146.80 of $400.00 used ($253.20 left)
4148
```
4249

4350
## ✨ Features
4451

4552
- **One command, real numbers** — per-model tokens and compute value for the
4653
current billing month.
54+
- **📊 Cycle limits** — the default summary also shows % used / % left before
55+
hitting your plan's rate limit, split into **Composer / Auto** (composer-2.5,
56+
composer-2.5-fast, Auto) vs **Other models**.
4757
- **📅 Per-day breakdown**`--by-day` shows how much you burned each day.
4858
- **🧾 CSV export**`--csv` dumps every usage event (timestamp, model, tokens,
4959
cost) for your own spreadsheets and charts.
@@ -72,6 +82,7 @@ That's it — if you're signed in to Cursor on this machine, it just works.
7282
| `cursor-usage --month 2026-05` | Window: a specific month |
7383
| `cursor-usage --start 2026-06-01 --end 2026-06-07` | Window: an explicit range |
7484
| `cursor-usage --json` | Raw aggregated JSON (for scripting) |
85+
| `cursor-usage --limits-json` | Raw cycle-limits JSON (for scripting) |
7586
| `cursor-usage -v` | Also print which session source was used |
7687

7788
Flags combine — e.g. `cursor-usage --by-day --csv june.csv --month 2026-06`.

docs/HOW_THIS_WAS_BUILT.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ All on `https://cursor.com`, cookie `WorkosCursorSessionToken=<sub>::<jwt>`:
8989
| POST | `/api/dashboard/get-aggregated-usage-events` | per-model totals: `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheWriteTokens`, `totalCents` |
9090
| POST | `/api/dashboard/get-filtered-usage-events` | per-event log + `totalUsageEventsCount` (paginate with `page`/`pageSize`) |
9191
| POST | `/api/dashboard/get-hard-limit` | `{"noUsageBasedAllowed":true}` ⇒ overage off ⇒ everything is included |
92+
| POST | `/api/dashboard/get-current-period-usage` | current-cycle limits: `autoPercentUsed`, `apiPercentUsed`, included spend (`planUsage`) |
93+
| GET | `/api/usage-summary` | fallback cycle limits (older shape via `individualUsage.plan`) |
9294

9395
POST body shape:
9496
```json

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "cursor-usage"
7-
version = "0.1.0"
7+
version = "0.2.0"
88
description = "Cross-platform CLI to read your Cursor (cursor.com) usage, spend, and per-event logs."
99
readme = "README.md"
1010
license = { text = "MIT" }

src/cursor_usage/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""cursor-usage: read your Cursor (cursor.com) usage, spend, and per-event logs."""
22

3-
__version__ = "0.1.0"
3+
__version__ = "0.2.0"

src/cursor_usage/api.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
GET /api/usage?user=<id> -> legacy counter + startOfMonth
66
POST /api/dashboard/get-aggregated-usage-events -> per-model tokens + cents
77
POST /api/dashboard/get-filtered-usage-events -> per-event log (paginated)
8+
POST /api/dashboard/get-current-period-usage -> cycle limits (primary)
9+
GET /api/usage-summary -> cycle limits (fallback)
810
911
State-changing POSTs require an ``Origin: https://cursor.com`` header (CSRF guard).
1012
Auth is the ``WorkosCursorSessionToken`` cookie, value ``<sub>::<jwt>`` (the ``::``
@@ -73,6 +75,12 @@ def _events_page(self, user_id, start_ms, end_ms, page, page_size):
7375
"userId": user_id, "page": page, "pageSize": page_size},
7476
)
7577

78+
def current_period_usage(self):
79+
return self._request("/api/dashboard/get-current-period-usage", "POST", {})
80+
81+
def usage_summary(self):
82+
return self._request("/api/usage-summary")
83+
7684
def all_events(self, user_id, start_ms, end_ms, page_size=1000, progress=None):
7785
"""Fetch every usage event in the window by paginating.
7886

src/cursor_usage/cli.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from . import __version__
99
from .api import CursorAPIError, CursorClient
1010
from .auth import SessionNotFound, resolve_cookie_value
11-
from .report import render_by_day, render_summary, write_csv
11+
from .report import render_by_day, render_limits, render_summary, write_csv
1212

1313

1414
def _ms(dt):
@@ -41,6 +41,21 @@ def _resolve_window(args, client, user_id):
4141
return _ms(start), _ms(end), start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d")
4242

4343

44+
def _fetch_limits(client):
45+
"""Return cycle-limits payload; primary endpoint first, then fallback."""
46+
last_err = None
47+
for fn in (client.current_period_usage, client.usage_summary):
48+
try:
49+
payload = fn()
50+
if isinstance(payload, dict):
51+
return payload
52+
except (CursorAPIError, OSError, ValueError) as exc:
53+
last_err = exc
54+
if last_err is not None:
55+
raise last_err
56+
return None
57+
58+
4459
def _progress(fetched, total):
4560
if total:
4661
sys.stderr.write("\r[events] %d/%d" % (fetched, total))
@@ -61,6 +76,8 @@ def build_parser():
6176
help="write the per-event log to FILE as CSV")
6277
p.add_argument("--json", action="store_true",
6378
help="print the raw aggregated-usage JSON and exit")
79+
p.add_argument("--limits-json", action="store_true",
80+
help="print the raw cycle-limits JSON and exit")
6481
g = p.add_argument_group("time window (default: current billing month)")
6582
g.add_argument("--start", metavar="YYYY-MM-DD", help="window start (inclusive)")
6683
g.add_argument("--end", metavar="YYYY-MM-DD", help="window end (inclusive)")
@@ -92,6 +109,21 @@ def main(argv=None):
92109
"Re-authenticate: cursor-agent login", file=sys.stderr)
93110
return 2
94111

112+
if args.limits_json:
113+
# No time window needed; the endpoints always report the current cycle.
114+
try:
115+
payload = _fetch_limits(client)
116+
if payload is None:
117+
print("Cycle-limits endpoints returned no usable data.",
118+
file=sys.stderr)
119+
return 1
120+
print(json.dumps(payload, indent=2))
121+
except (CursorAPIError, OSError, ValueError) as exc:
122+
print("Cycle-limits endpoints returned no usable JSON: %s" % exc,
123+
file=sys.stderr)
124+
return 1
125+
return 0
126+
95127
start_ms, end_ms, start_label, end_label = _resolve_window(args, client, user_id)
96128
meta = {"email": email, "start": start_label, "end": end_label}
97129

@@ -102,6 +134,15 @@ def main(argv=None):
102134
# The aggregated endpoint powers the summary (one cheap call).
103135
print(render_summary(client.aggregated_usage(user_id, start_ms, end_ms), meta))
104136

137+
try:
138+
section = render_limits(_fetch_limits(client))
139+
if section:
140+
print()
141+
print(section)
142+
except (CursorAPIError, OSError, ValueError) as exc:
143+
if args.verbose:
144+
print("[limits] unavailable: %s" % exc, file=sys.stderr)
145+
105146
# Per-event data powers --by-day and --csv; fetch it once if needed.
106147
if args.by_day or args.csv:
107148
events, total = client.all_events(

src/cursor_usage/report.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,129 @@ def write_csv(events, path):
132132

133133
def to_iso(ms):
134134
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).strftime("%Y-%m-%d")
135+
136+
137+
# --- cycle limits (from get-current-period-usage / usage-summary) --------
138+
def _optional_f(d, k):
139+
if k not in d:
140+
return None
141+
try:
142+
v = d[k]
143+
if v is None:
144+
return None
145+
return float(v or 0)
146+
except (TypeError, ValueError):
147+
return None
148+
149+
150+
def _optional_i(d, k):
151+
if k not in d:
152+
return None
153+
try:
154+
v = d[k]
155+
if v is None:
156+
return None
157+
return int(v or 0)
158+
except (TypeError, ValueError):
159+
return None
160+
161+
162+
def _parse_reset_date(payload):
163+
raw = payload.get("billingCycleEnd")
164+
if raw is None:
165+
return None
166+
try:
167+
s = str(raw).strip()
168+
if s.isdigit():
169+
ms = int(s)
170+
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).strftime(
171+
"%Y-%m-%d")
172+
return datetime.fromisoformat(s.replace("Z", "+00:00")).strftime(
173+
"%Y-%m-%d")
174+
except (TypeError, ValueError, OSError):
175+
return None
176+
177+
178+
def _limit_bar(pct, width=30):
179+
filled = int(round(pct / 100.0 * width))
180+
filled = max(0, min(width, filled))
181+
return "#" * filled + "." * (width - filled)
182+
183+
184+
def _limit_bucket_line(label, pct):
185+
if pct is None:
186+
return None
187+
bar = _limit_bar(pct)
188+
left = max(0.0, 100.0 - pct)
189+
return "%-16s[%s] %6.1f%% used, %6.1f%% left" % (label, bar, pct, left)
190+
191+
192+
def render_limits(payload):
193+
"""Render cycle-limit % bars and included-usage dollars, or None."""
194+
if not payload or not isinstance(payload, dict):
195+
return None
196+
197+
plan = (payload.get("planUsage")
198+
or (payload.get("individualUsage") or {}).get("plan")
199+
or {})
200+
201+
enabled = payload.get("enabled", plan.get("enabled", True))
202+
if not enabled:
203+
return None
204+
205+
is_unlimited = bool(payload.get("isUnlimited"))
206+
207+
auto_pct = _optional_f(plan, "autoPercentUsed")
208+
api_pct = _optional_f(plan, "apiPercentUsed")
209+
210+
used = None
211+
if "totalSpend" in plan:
212+
used = _optional_i(plan, "totalSpend")
213+
if used is None and "used" in plan:
214+
used = _optional_i(plan, "used")
215+
216+
limit = _optional_i(plan, "limit") if "limit" in plan else None
217+
218+
remaining = None
219+
if "remaining" in plan:
220+
remaining = _optional_i(plan, "remaining")
221+
if remaining is None and limit is not None and used is not None:
222+
remaining = max(0, limit - used)
223+
224+
money_line = None
225+
if limit is not None and limit > 0 and used is not None and remaining is not None:
226+
money_line = "%-16s%s of %s used (%s left)" % (
227+
"Included usage", _money(used), _money(limit), _money(remaining))
228+
229+
if (not is_unlimited and auto_pct is None and api_pct is None
230+
and not money_line):
231+
return None
232+
233+
reset = _parse_reset_date(payload)
234+
235+
out = ["=" * 78]
236+
header = "CURSOR CYCLE LIMITS"
237+
if reset:
238+
header += " | resets %s" % reset
239+
out.append(header)
240+
out.append("=" * 78)
241+
242+
if is_unlimited:
243+
out.append("Plan reports unlimited usage for this cycle.")
244+
if money_line:
245+
out.append(money_line)
246+
return "\n".join(out)
247+
248+
for label, pct in [("Composer / Auto", auto_pct),
249+
("Other models", api_pct)]:
250+
line = _limit_bucket_line(label, pct)
251+
if line:
252+
out.append(line)
253+
254+
if money_line:
255+
out.append(money_line)
256+
257+
if len(out) <= 3:
258+
return None
259+
260+
return "\n".join(out)

0 commit comments

Comments
 (0)