cursor-cli-usage 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ environment: pypi
12
+ permissions:
13
+ contents: read
14
+ id-token: write
15
+ steps:
16
+ - name: Checkout
17
+ uses: actions/checkout@v7.0.1
18
+ - name: Install uv
19
+ uses: astral-sh/setup-uv@v9.0.0
20
+ with:
21
+ enable-cache: false
22
+ - name: Verify version tag
23
+ run: test "$GITHUB_REF_NAME" = "v$(uv --no-config version --short)"
24
+ - name: Test
25
+ run: uv --no-config run --locked python -m unittest discover -s tests
26
+ - name: Build
27
+ run: uv --no-config build --no-sources
28
+ - name: Publish
29
+ run: uv publish --trusted-publishing always
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .ruff_cache/
5
+ dist/*
6
+ !dist/.gitignore
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: cursor-cli-usage
3
+ Version: 0.1.0
4
+ Summary: Cursor CLI account quota monitor using local Cursor authentication
5
+ Project-URL: Homepage, https://github.com/wakamex/cursor-cli-usage
6
+ Project-URL: Source, https://github.com/wakamex/cursor-cli-usage
7
+ Requires-Python: >=3.12
8
+ Description-Content-Type: text/markdown
9
+
10
+ # cursor-cli-usage
11
+
12
+ Cursor account usage and quota monitor. It follows the same dependency-free
13
+ interface as the other `/code/*usage` tools.
14
+
15
+ The tool targets the Cursor CLI harness, but reports account-wide Cursor quota.
16
+ It does not estimate usage from local CLI sessions.
17
+
18
+ ## Example output
19
+
20
+ ```text
21
+ Cursor usage
22
+ Status: live
23
+ Plan: 6.1% used
24
+ Resets: 1787506237000
25
+ ```
26
+
27
+ Statusline:
28
+
29
+ ```text
30
+ cursor:q:6.1%used reset:12d20h
31
+ ```
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ uv tool install cursor-cli-usage
37
+ ```
38
+
39
+ For local development:
40
+
41
+ ```bash
42
+ uv tool install .
43
+ ```
44
+
45
+ ## Commands
46
+
47
+ | Command | Description |
48
+ | --- | --- |
49
+ | `cursor-cli-usage` | Show current account usage |
50
+ | `cursor-cli-usage status` | Same as above |
51
+ | `cursor-cli-usage json` | Print normalized JSON |
52
+ | `cursor-cli-usage statusline` | Print compact cached output |
53
+ | `cursor-cli-usage refresh` | Refresh the cache and print status |
54
+ | `cursor-cli-usage daemon [-i SECS]` | Keep the cache fresh |
55
+ | `cursor-cli-usage install` | Print installation instructions |
56
+
57
+ ## Authentication and data
58
+
59
+ For every live request, the tool first asks the installed Cursor CLI for its
60
+ current authentication state with `agent status --format json`. If the CLI is
61
+ unavailable, it falls back to `cursorAuth/accessToken` in Cursor IDE's local
62
+ `state.vscdb`. It sends only the current access token to Cursor's dashboard
63
+ usage service. It never refreshes, rotates, or modifies Cursor-owned
64
+ credentials.
65
+
66
+ The normalized JSON includes:
67
+
68
+ - `provider`, `source`, `retrieved_at`, and `status`
69
+ - billing-cycle start and end
70
+ - plan and spend-limit usage
71
+ - models assigned to Cursor's automatic usage bucket
72
+
73
+ `status` is `live`, `cached`, `stale`, or `unavailable`. Errors and caches do
74
+ not include the access token or Cursor account identity.
75
+
76
+ The tool writes only its own non-secret cache at
77
+ `~/.cursor/usage-limits.json`. JSON results report whether they are `live`,
78
+ `cached`, `stale`, or `unavailable`.
79
+
80
+ Environment overrides:
81
+
82
+ - `CURSOR_USAGE_AGENT`: alternate Cursor CLI executable
83
+ - `CURSOR_USAGE_STATE_DB`: alternate Cursor `state.vscdb` path
84
+ - `CURSOR_USAGE_FILE`: alternate cache path
85
+
86
+ The usage endpoint is undocumented and may change when Cursor changes its
87
+ dashboard implementation.
88
+
89
+ ## Options
90
+
91
+ ```text
92
+ usage: cursor-cli-usage [-h] [-i INTERVAL] [--max-age MAX_AGE] [--refresh]
93
+ {status,json,daemon,statusline,refresh,install}
94
+ ```
95
+
96
+ - `--max-age SECS`: maximum cache age used by `statusline`
97
+ - `--refresh`: bypass the cache for `statusline`
98
+ - `-i SECS`: daemon refresh interval
99
+
100
+ ## Development
101
+
102
+ ```bash
103
+ uv --no-config lock --check
104
+ uv --no-config run --locked python -m unittest discover -s tests
105
+ uv --no-config build --no-sources
106
+ ```
@@ -0,0 +1,97 @@
1
+ # cursor-cli-usage
2
+
3
+ Cursor account usage and quota monitor. It follows the same dependency-free
4
+ interface as the other `/code/*usage` tools.
5
+
6
+ The tool targets the Cursor CLI harness, but reports account-wide Cursor quota.
7
+ It does not estimate usage from local CLI sessions.
8
+
9
+ ## Example output
10
+
11
+ ```text
12
+ Cursor usage
13
+ Status: live
14
+ Plan: 6.1% used
15
+ Resets: 1787506237000
16
+ ```
17
+
18
+ Statusline:
19
+
20
+ ```text
21
+ cursor:q:6.1%used reset:12d20h
22
+ ```
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ uv tool install cursor-cli-usage
28
+ ```
29
+
30
+ For local development:
31
+
32
+ ```bash
33
+ uv tool install .
34
+ ```
35
+
36
+ ## Commands
37
+
38
+ | Command | Description |
39
+ | --- | --- |
40
+ | `cursor-cli-usage` | Show current account usage |
41
+ | `cursor-cli-usage status` | Same as above |
42
+ | `cursor-cli-usage json` | Print normalized JSON |
43
+ | `cursor-cli-usage statusline` | Print compact cached output |
44
+ | `cursor-cli-usage refresh` | Refresh the cache and print status |
45
+ | `cursor-cli-usage daemon [-i SECS]` | Keep the cache fresh |
46
+ | `cursor-cli-usage install` | Print installation instructions |
47
+
48
+ ## Authentication and data
49
+
50
+ For every live request, the tool first asks the installed Cursor CLI for its
51
+ current authentication state with `agent status --format json`. If the CLI is
52
+ unavailable, it falls back to `cursorAuth/accessToken` in Cursor IDE's local
53
+ `state.vscdb`. It sends only the current access token to Cursor's dashboard
54
+ usage service. It never refreshes, rotates, or modifies Cursor-owned
55
+ credentials.
56
+
57
+ The normalized JSON includes:
58
+
59
+ - `provider`, `source`, `retrieved_at`, and `status`
60
+ - billing-cycle start and end
61
+ - plan and spend-limit usage
62
+ - models assigned to Cursor's automatic usage bucket
63
+
64
+ `status` is `live`, `cached`, `stale`, or `unavailable`. Errors and caches do
65
+ not include the access token or Cursor account identity.
66
+
67
+ The tool writes only its own non-secret cache at
68
+ `~/.cursor/usage-limits.json`. JSON results report whether they are `live`,
69
+ `cached`, `stale`, or `unavailable`.
70
+
71
+ Environment overrides:
72
+
73
+ - `CURSOR_USAGE_AGENT`: alternate Cursor CLI executable
74
+ - `CURSOR_USAGE_STATE_DB`: alternate Cursor `state.vscdb` path
75
+ - `CURSOR_USAGE_FILE`: alternate cache path
76
+
77
+ The usage endpoint is undocumented and may change when Cursor changes its
78
+ dashboard implementation.
79
+
80
+ ## Options
81
+
82
+ ```text
83
+ usage: cursor-cli-usage [-h] [-i INTERVAL] [--max-age MAX_AGE] [--refresh]
84
+ {status,json,daemon,statusline,refresh,install}
85
+ ```
86
+
87
+ - `--max-age SECS`: maximum cache age used by `statusline`
88
+ - `--refresh`: bypass the cache for `statusline`
89
+ - `-i SECS`: daemon refresh interval
90
+
91
+ ## Development
92
+
93
+ ```bash
94
+ uv --no-config lock --check
95
+ uv --no-config run --locked python -m unittest discover -s tests
96
+ uv --no-config build --no-sources
97
+ ```
@@ -0,0 +1,2 @@
1
+ *
2
+ !.gitignore
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "cursor-cli-usage"
3
+ version = "0.1.0"
4
+ description = "Cursor CLI account quota monitor using local Cursor authentication"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = []
8
+
9
+ [tool.uv]
10
+ package = true
11
+
12
+ [build-system]
13
+ requires = ["hatchling"]
14
+ build-backend = "hatchling.build"
15
+
16
+ [tool.hatch.build.targets.wheel]
17
+ packages = ["src/cursor_cli_usage"]
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/wakamex/cursor-cli-usage"
21
+ Source = "https://github.com/wakamex/cursor-cli-usage"
22
+
23
+ [project.scripts]
24
+ cursor-cli-usage = "cursor_cli_usage:main"
@@ -0,0 +1,391 @@
1
+ #!/usr/bin/env python3
2
+ """Cursor account usage monitor using Cursor-owned local authentication."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import platform
10
+ import shutil
11
+ import signal
12
+ import sqlite3
13
+ import subprocess
14
+ import sys
15
+ import tempfile
16
+ import time
17
+ import urllib.error
18
+ import urllib.request
19
+ from datetime import UTC, datetime
20
+ from pathlib import Path
21
+
22
+ DEFAULT_USAGE_FILE = Path.home() / ".cursor" / "usage-limits.json"
23
+ USAGE_URL = "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage"
24
+ CACHE_MAX_AGE = 300
25
+ DAEMON_INTERVAL = 300
26
+
27
+
28
+ def _iso_now() -> str:
29
+ return datetime.now(UTC).isoformat()
30
+
31
+
32
+ def _parse_iso(value: object) -> datetime | None:
33
+ if not isinstance(value, str) or not value:
34
+ return None
35
+ try:
36
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
37
+ except ValueError:
38
+ return None
39
+ return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed
40
+
41
+
42
+ def _format_reset(value: object) -> str:
43
+ if not isinstance(value, str | int | float):
44
+ return ""
45
+ try:
46
+ reset = datetime.fromtimestamp(float(value) / 1000, UTC)
47
+ except (TypeError, ValueError, OSError):
48
+ parsed = _parse_iso(value)
49
+ if parsed is None:
50
+ return ""
51
+ reset = parsed
52
+ minutes = max(0, int((reset - datetime.now(UTC)).total_seconds()) // 60)
53
+ if minutes >= 1440:
54
+ return f"{minutes // 1440}d{(minutes % 1440) // 60}h"
55
+ if minutes >= 60:
56
+ return f"{minutes // 60}h{minutes % 60}m"
57
+ return f"{minutes}m"
58
+
59
+
60
+ def get_state_db() -> Path:
61
+ override = os.environ.get("CURSOR_USAGE_STATE_DB")
62
+ if override:
63
+ return Path(override).expanduser()
64
+ if platform.system() == "Darwin":
65
+ return (
66
+ Path.home()
67
+ / "Library"
68
+ / "Application Support"
69
+ / "Cursor"
70
+ / "User"
71
+ / "globalStorage"
72
+ / "state.vscdb"
73
+ )
74
+ if platform.system() == "Windows":
75
+ app_data = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
76
+ return app_data / "Cursor" / "User" / "globalStorage" / "state.vscdb"
77
+ return Path.home() / ".config" / "Cursor" / "User" / "globalStorage" / "state.vscdb"
78
+
79
+
80
+ def get_usage_file() -> Path:
81
+ override = os.environ.get("CURSOR_USAGE_FILE")
82
+ return Path(override).expanduser() if override else DEFAULT_USAGE_FILE
83
+
84
+
85
+ def _find_cursor_cli() -> str | None:
86
+ override = os.environ.get("CURSOR_USAGE_AGENT")
87
+ if override:
88
+ return str(Path(override).expanduser())
89
+ for name in ("agent", "cursor-agent"):
90
+ if executable := shutil.which(name):
91
+ return executable
92
+ return None
93
+
94
+
95
+ def _get_cli_access_token() -> str | None:
96
+ executable = _find_cursor_cli()
97
+ if not executable:
98
+ return None
99
+ try:
100
+ result = subprocess.run(
101
+ [executable, "status", "--format", "json"],
102
+ check=False,
103
+ capture_output=True,
104
+ text=True,
105
+ timeout=10,
106
+ )
107
+ except (OSError, subprocess.SubprocessError):
108
+ return None
109
+ if result.returncode:
110
+ return None
111
+ try:
112
+ payload = json.loads(result.stdout)
113
+ except json.JSONDecodeError:
114
+ return None
115
+ auth = payload.get("auth") if isinstance(payload, dict) else None
116
+ token = auth.get("accessToken") if isinstance(auth, dict) else None
117
+ return token if isinstance(token, str) and token else None
118
+
119
+
120
+ def _get_ide_access_token() -> str | None:
121
+ path = get_state_db()
122
+ if not path.is_file():
123
+ return None
124
+ try:
125
+ connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
126
+ try:
127
+ row = connection.execute(
128
+ "SELECT value FROM ItemTable WHERE key = ? LIMIT 1",
129
+ ("cursorAuth/accessToken",),
130
+ ).fetchone()
131
+ finally:
132
+ connection.close()
133
+ except sqlite3.Error as exc:
134
+ raise RuntimeError(f"Could not read Cursor authentication from {path}") from exc
135
+ return row[0] if row and isinstance(row[0], str) and row[0] else None
136
+
137
+
138
+ def get_access_token() -> str:
139
+ """Read the current access token without refreshing or modifying Cursor state."""
140
+ if token := _get_cli_access_token():
141
+ return token
142
+ if token := _get_ide_access_token():
143
+ return token
144
+ raise RuntimeError(
145
+ "Cursor CLI is not logged in and no Cursor IDE session was found"
146
+ )
147
+
148
+
149
+ def _number(value: object) -> int | float | None:
150
+ if isinstance(value, bool):
151
+ return None
152
+ if isinstance(value, int | float):
153
+ return value
154
+ if isinstance(value, str):
155
+ try:
156
+ parsed = float(value)
157
+ except ValueError:
158
+ return None
159
+ return int(parsed) if parsed.is_integer() else parsed
160
+ return None
161
+
162
+
163
+ def _usage_bucket(value: object) -> dict | None:
164
+ if not isinstance(value, dict):
165
+ return None
166
+ return {
167
+ key: parsed
168
+ for key, source in (
169
+ ("limit", "limit"),
170
+ ("used", "used"),
171
+ ("remaining", "remaining"),
172
+ ("percent_used", "totalPercentUsed"),
173
+ )
174
+ if (parsed := _number(value.get(source))) is not None
175
+ }
176
+
177
+
178
+ def fetch_usage() -> dict:
179
+ token = get_access_token()
180
+ request = urllib.request.Request(
181
+ USAGE_URL,
182
+ data=b"{}",
183
+ method="POST",
184
+ headers={
185
+ "Authorization": f"Bearer {token}",
186
+ "Content-Type": "application/json",
187
+ "Connect-Protocol-Version": "1",
188
+ "User-Agent": "cursor-cli-usage/0.1",
189
+ },
190
+ )
191
+ try:
192
+ with urllib.request.urlopen(request, timeout=15) as response:
193
+ payload = json.loads(response.read())
194
+ except urllib.error.HTTPError as exc:
195
+ if exc.code in (401, 403):
196
+ raise RuntimeError(
197
+ "Cursor access token is no longer accepted; sign in with Cursor again"
198
+ ) from exc
199
+ raise RuntimeError(f"Cursor usage API returned HTTP {exc.code}") from exc
200
+ except (urllib.error.URLError, TimeoutError) as exc:
201
+ raise RuntimeError("Cursor usage API is unavailable") from exc
202
+ except json.JSONDecodeError as exc:
203
+ raise RuntimeError("Cursor usage API returned invalid JSON") from exc
204
+ if not isinstance(payload, dict):
205
+ raise RuntimeError("Cursor usage API returned an unexpected response")
206
+ return payload
207
+
208
+
209
+ def _normalize(payload: dict) -> dict:
210
+ models = payload.get("autoBucketModels")
211
+ return {
212
+ "billing_cycle": {
213
+ "start": payload.get("billingCycleStart"),
214
+ "end": payload.get("billingCycleEnd"),
215
+ },
216
+ "plan_usage": _usage_bucket(payload.get("planUsage")),
217
+ "spend_limit_usage": _usage_bucket(payload.get("spendLimitUsage")),
218
+ "auto_models": models if isinstance(models, list) else [],
219
+ }
220
+
221
+
222
+ def build_usage_json() -> dict:
223
+ updated_at = _iso_now()
224
+ try:
225
+ usage = _normalize(fetch_usage())
226
+ except Exception as exc:
227
+ return {
228
+ "provider": "cursor",
229
+ "status": "unavailable",
230
+ "source": "cursor_dashboard_api",
231
+ "retrieved_at": updated_at,
232
+ "error": str(exc),
233
+ }
234
+ return {
235
+ "provider": "cursor",
236
+ "status": "live",
237
+ "source": "cursor_dashboard_api",
238
+ "retrieved_at": updated_at,
239
+ **usage,
240
+ }
241
+
242
+
243
+ def write_usage_file(data: dict) -> None:
244
+ path = get_usage_file()
245
+ path.parent.mkdir(parents=True, exist_ok=True)
246
+ fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
247
+ temporary = Path(temporary_name)
248
+ try:
249
+ with os.fdopen(fd, "w") as handle:
250
+ json.dump(data, handle, indent=2)
251
+ handle.write("\n")
252
+ os.replace(temporary, path)
253
+ except Exception:
254
+ temporary.unlink(missing_ok=True)
255
+ raise
256
+
257
+
258
+ def _read_cache() -> dict | None:
259
+ try:
260
+ data = json.loads(get_usage_file().read_text())
261
+ except (OSError, json.JSONDecodeError):
262
+ return None
263
+ return data if isinstance(data, dict) else None
264
+
265
+
266
+ def get_cached_usage(max_age: int = CACHE_MAX_AGE, force_refresh: bool = False) -> dict:
267
+ cached = _read_cache()
268
+ if not force_refresh and cached:
269
+ retrieved = _parse_iso(cached.get("retrieved_at"))
270
+ if retrieved and (datetime.now(UTC) - retrieved).total_seconds() < max_age:
271
+ return {**cached, "status": "cached"}
272
+ fresh = build_usage_json()
273
+ if fresh["status"] == "live":
274
+ write_usage_file(fresh)
275
+ return fresh
276
+ if cached:
277
+ return {**cached, "status": "stale", "refresh_error": fresh.get("error")}
278
+ return fresh
279
+
280
+
281
+ def _summary_usage(data: dict) -> dict | None:
282
+ for key in ("plan_usage", "spend_limit_usage"):
283
+ bucket = data.get(key)
284
+ if isinstance(bucket, dict) and bucket.get("percent_used") is not None:
285
+ return bucket
286
+ return None
287
+
288
+
289
+ def _statusline_text(data: dict) -> str:
290
+ bucket = _summary_usage(data)
291
+ if not bucket:
292
+ return "cursor:q:unavailable"
293
+ used = float(bucket["percent_used"])
294
+ reset = _format_reset((data.get("billing_cycle") or {}).get("end"))
295
+ parts = [f"cursor:q:{used:.1f}%used"]
296
+ if reset:
297
+ parts.append(f"reset:{reset}")
298
+ if data.get("status") in {"cached", "stale"}:
299
+ parts.append(str(data["status"]))
300
+ return " ".join(parts)
301
+
302
+
303
+ def _print_status(data: dict) -> None:
304
+ print("Cursor usage")
305
+ print(f"Status: {data['status']}")
306
+ bucket = data.get("plan_usage")
307
+ if isinstance(bucket, dict):
308
+ if bucket.get("percent_used") is not None:
309
+ print(f"Plan: {float(bucket['percent_used']):g}% used")
310
+ if bucket.get("used") is not None and bucket.get("limit") is not None:
311
+ print(f"Usage: {bucket['used']} of {bucket['limit']}")
312
+ spend = data.get("spend_limit_usage")
313
+ if isinstance(spend, dict) and spend.get("percent_used") is not None:
314
+ print(f"Spend limit: {float(spend['percent_used']):g}% used")
315
+ cycle = data.get("billing_cycle") or {}
316
+ if cycle.get("end"):
317
+ print(f"Resets: {cycle['end']}")
318
+ if data.get("error"):
319
+ print(f"Error: {data['error']}")
320
+ if data.get("refresh_error"):
321
+ print(f"Refresh error: {data['refresh_error']}")
322
+
323
+
324
+ def cmd_status(_args: argparse.Namespace) -> None:
325
+ _print_status(build_usage_json())
326
+
327
+
328
+ def cmd_json(_args: argparse.Namespace) -> None:
329
+ print(json.dumps(build_usage_json(), indent=2))
330
+
331
+
332
+ def cmd_statusline(args: argparse.Namespace) -> None:
333
+ print(_statusline_text(get_cached_usage(args.max_age, args.refresh)))
334
+
335
+
336
+ def cmd_refresh(_args: argparse.Namespace) -> None:
337
+ data = build_usage_json()
338
+ if data["status"] == "live":
339
+ write_usage_file(data)
340
+ _print_status(data)
341
+
342
+
343
+ def cmd_daemon(args: argparse.Namespace) -> None:
344
+ signal.signal(signal.SIGINT, lambda *_: sys.exit(0))
345
+ if hasattr(signal, "SIGTERM"):
346
+ signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
347
+ print(f"cursor-cli-usage daemon started (refreshing every {args.interval}s)")
348
+ print(f"Writing to {get_usage_file()}")
349
+ while True:
350
+ data = build_usage_json()
351
+ if data["status"] == "live":
352
+ write_usage_file(data)
353
+ print(f"[{datetime.now().strftime('%H:%M:%S')}] {_statusline_text(data)}")
354
+ time.sleep(args.interval)
355
+
356
+
357
+ def cmd_install(_args: argparse.Namespace) -> None:
358
+ print(
359
+ "Install with:\n uv tool install cursor-cli-usage\n\nThen run:\n cursor-cli-usage"
360
+ )
361
+
362
+
363
+ def _build_parser() -> argparse.ArgumentParser:
364
+ parser = argparse.ArgumentParser(description="Cursor account usage monitor")
365
+ parser.add_argument(
366
+ "command",
367
+ nargs="?",
368
+ default="status",
369
+ choices=["status", "json", "daemon", "statusline", "refresh", "install"],
370
+ )
371
+ parser.add_argument("-i", "--interval", type=int, default=DAEMON_INTERVAL)
372
+ parser.add_argument("--max-age", type=int, default=CACHE_MAX_AGE)
373
+ parser.add_argument("--refresh", action="store_true")
374
+ return parser
375
+
376
+
377
+ def main() -> None:
378
+ args = _build_parser().parse_args()
379
+ commands = {
380
+ "status": cmd_status,
381
+ "json": cmd_json,
382
+ "daemon": cmd_daemon,
383
+ "statusline": cmd_statusline,
384
+ "refresh": cmd_refresh,
385
+ "install": cmd_install,
386
+ }
387
+ commands[args.command](args)
388
+
389
+
390
+ if __name__ == "__main__":
391
+ main()
@@ -0,0 +1,3 @@
1
+ from cursor_cli_usage import main
2
+
3
+ main()
@@ -0,0 +1,151 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ import tempfile
6
+ import unittest
7
+ import urllib.error
8
+ from pathlib import Path
9
+ from unittest import mock
10
+
11
+ import cursor_cli_usage as cursor_usage
12
+
13
+
14
+ class FakeResponse:
15
+ def __init__(self, payload: dict):
16
+ self.payload = payload
17
+
18
+ def __enter__(self):
19
+ return self
20
+
21
+ def __exit__(self, *_args):
22
+ return False
23
+
24
+ def read(self):
25
+ return json.dumps(self.payload).encode()
26
+
27
+
28
+ class CursorUsageTests(unittest.TestCase):
29
+ def test_access_token_is_read_from_database_without_writes(self):
30
+ with tempfile.TemporaryDirectory() as temporary:
31
+ database = Path(temporary) / "state.vscdb"
32
+ connection = sqlite3.connect(database)
33
+ try:
34
+ connection.execute("CREATE TABLE ItemTable (key TEXT, value TEXT)")
35
+ connection.execute(
36
+ "INSERT INTO ItemTable VALUES (?, ?)",
37
+ ("cursorAuth/accessToken", "local-token"),
38
+ )
39
+ connection.commit()
40
+ finally:
41
+ connection.close()
42
+ before = database.read_bytes()
43
+ with mock.patch.dict(
44
+ cursor_usage.os.environ, {"CURSOR_USAGE_STATE_DB": str(database)}
45
+ ):
46
+ with mock.patch.object(
47
+ cursor_usage, "_get_cli_access_token", return_value=None
48
+ ):
49
+ self.assertEqual(cursor_usage.get_access_token(), "local-token")
50
+ self.assertEqual(database.read_bytes(), before)
51
+
52
+ def test_cli_status_is_preferred_and_only_access_token_is_returned(self):
53
+ completed = mock.Mock(
54
+ returncode=0,
55
+ stdout=json.dumps(
56
+ {
57
+ "authenticated": True,
58
+ "auth": {
59
+ "accessToken": "cli-access",
60
+ "refreshToken": "must-not-be-returned",
61
+ },
62
+ }
63
+ ),
64
+ )
65
+ with (
66
+ mock.patch.object(cursor_usage, "_find_cursor_cli", return_value="agent"),
67
+ mock.patch.object(
68
+ cursor_usage.subprocess, "run", return_value=completed
69
+ ) as run,
70
+ mock.patch.object(cursor_usage, "_get_ide_access_token") as ide,
71
+ ):
72
+ self.assertEqual(cursor_usage.get_access_token(), "cli-access")
73
+ run.assert_called_once_with(
74
+ ["agent", "status", "--format", "json"],
75
+ check=False,
76
+ capture_output=True,
77
+ text=True,
78
+ timeout=10,
79
+ )
80
+ ide.assert_not_called()
81
+
82
+ def test_fetch_uses_bearer_token_and_connect_protocol(self):
83
+ payload = {"planUsage": {"limit": 2000, "used": 500, "totalPercentUsed": 25}}
84
+
85
+ def fake_urlopen(request, timeout):
86
+ self.assertEqual(request.get_header("Authorization"), "Bearer token")
87
+ self.assertEqual(request.get_header("Connect-protocol-version"), "1")
88
+ self.assertEqual(timeout, 15)
89
+ return FakeResponse(payload)
90
+
91
+ with (
92
+ mock.patch.object(cursor_usage, "get_access_token", return_value="token"),
93
+ mock.patch.object(
94
+ cursor_usage.urllib.request, "urlopen", side_effect=fake_urlopen
95
+ ),
96
+ ):
97
+ self.assertEqual(cursor_usage.fetch_usage(), payload)
98
+
99
+ def test_build_normalizes_current_period_usage(self):
100
+ payload = {
101
+ "billingCycleStart": "1000",
102
+ "billingCycleEnd": "2000",
103
+ "planUsage": {"limit": 2000, "remaining": 1500, "totalPercentUsed": 25},
104
+ "spendLimitUsage": {"limit": "1000", "used": "100"},
105
+ "autoBucketModels": ["auto"],
106
+ }
107
+ with mock.patch.object(cursor_usage, "fetch_usage", return_value=payload):
108
+ data = cursor_usage.build_usage_json()
109
+ self.assertEqual(data["status"], "live")
110
+ self.assertEqual(data["plan_usage"]["percent_used"], 25)
111
+ self.assertEqual(data["spend_limit_usage"]["used"], 100)
112
+ self.assertEqual(data["auto_models"], ["auto"])
113
+
114
+ def test_unauthorized_never_attempts_refresh(self):
115
+ error = urllib.error.HTTPError(cursor_usage.USAGE_URL, 401, "", {}, None)
116
+ with (
117
+ mock.patch.object(cursor_usage, "get_access_token", return_value="expired"),
118
+ mock.patch.object(
119
+ cursor_usage.urllib.request, "urlopen", side_effect=error
120
+ ),
121
+ ):
122
+ data = cursor_usage.build_usage_json()
123
+ self.assertEqual(data["status"], "unavailable")
124
+ self.assertIn("sign in", data["error"])
125
+
126
+ def test_stale_cache_is_returned_when_refresh_fails(self):
127
+ cached = {
128
+ "provider": "cursor",
129
+ "status": "live",
130
+ "source": "cursor_dashboard_api",
131
+ "retrieved_at": "2020-01-01T00:00:00+00:00",
132
+ "plan_usage": {"percent_used": 50},
133
+ }
134
+ with tempfile.TemporaryDirectory() as temporary:
135
+ cache = Path(temporary) / "usage.json"
136
+ cache.write_text(json.dumps(cached))
137
+ with (
138
+ mock.patch.object(cursor_usage, "DEFAULT_USAGE_FILE", cache),
139
+ mock.patch.object(
140
+ cursor_usage,
141
+ "build_usage_json",
142
+ return_value={"status": "unavailable", "error": "offline"},
143
+ ),
144
+ ):
145
+ result = cursor_usage.get_cached_usage()
146
+ self.assertEqual(result["status"], "stale")
147
+ self.assertEqual(result["refresh_error"], "offline")
148
+
149
+
150
+ if __name__ == "__main__":
151
+ unittest.main()
@@ -0,0 +1,8 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.12"
4
+
5
+ [[package]]
6
+ name = "cursor-cli-usage"
7
+ version = "0.1.0"
8
+ source = { editable = "." }