cursor-cli-usage 0.1.1__tar.gz → 0.4.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,4 @@
1
+ __pycache__/
2
+ .venv/
3
+ dist/
4
+ dist-wrappers/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mihai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,25 @@
1
+ Metadata-Version: 2.5
2
+ Name: cursor-cli-usage
3
+ Version: 0.4.0
4
+ Summary: Cursor plan usage monitor
5
+ Project-URL: Homepage, https://github.com/wakamex/clanker-usage
6
+ Project-URL: Source, https://github.com/wakamex/clanker-usage
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: cli,cursor,cursor-agent,quota,statusline,usage
10
+ Requires-Python: >=3.12
11
+ Requires-Dist: clanker-usage==0.4.0
12
+ Description-Content-Type: text/markdown
13
+
14
+ # cursor-cli-usage
15
+
16
+ Monitor your Cursor quota from the terminal: current usage, a one-line statusline, and a daemon that keeps a cache fresh.
17
+
18
+ ```sh
19
+ uv tool install cursor-cli-usage
20
+ cursor-cli-usage # current quotas
21
+ cursor-cli-usage statusline # one line, from a cache less than five minutes old
22
+ cursor-cli-usage json # the usage-v1 document
23
+ ```
24
+
25
+ `cursor-cli-usage` is the Cursor command of [clanker-usage](https://github.com/wakamex/clanker-usage), so it installs clanker-usage at the same version. The documentation lives there.
@@ -0,0 +1,12 @@
1
+ # cursor-cli-usage
2
+
3
+ Monitor your Cursor quota from the terminal: current usage, a one-line statusline, and a daemon that keeps a cache fresh.
4
+
5
+ ```sh
6
+ uv tool install cursor-cli-usage
7
+ cursor-cli-usage # current quotas
8
+ cursor-cli-usage statusline # one line, from a cache less than five minutes old
9
+ cursor-cli-usage json # the usage-v1 document
10
+ ```
11
+
12
+ `cursor-cli-usage` is the Cursor command of [clanker-usage](https://github.com/wakamex/clanker-usage), so it installs clanker-usage at the same version. The documentation lives there.
@@ -0,0 +1,7 @@
1
+ """cursor-cli-usage: the Cursor command of clanker-usage."""
2
+
3
+ from clanker_usage import run
4
+
5
+
6
+ def main():
7
+ return run("cursor")
@@ -0,0 +1,3 @@
1
+ from . import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["hatchling==1.32.4"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "cursor-cli-usage"
7
+ version = "0.4.0"
8
+ description = "Cursor plan usage monitor"
9
+ readme = "README.md"
10
+ keywords = ["cursor", "cursor-agent", "usage", "quota", "statusline", "cli"]
11
+ requires-python = ">=3.12"
12
+ license = "MIT"
13
+ license-files = ["LICENSE"]
14
+ dependencies = ["clanker-usage==0.4.0"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://github.com/wakamex/clanker-usage"
18
+ Source = "https://github.com/wakamex/clanker-usage"
19
+
20
+ [project.scripts]
21
+ cursor-cli-usage = "cursor_cli_usage:main"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["cursor_cli_usage"]
@@ -1,29 +0,0 @@
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
@@ -1,6 +0,0 @@
1
- .venv/
2
- __pycache__/
3
- *.py[cod]
4
- .ruff_cache/
5
- dist/*
6
- !dist/.gitignore
@@ -1,106 +0,0 @@
1
- Metadata-Version: 2.5
2
- Name: cursor-cli-usage
3
- Version: 0.1.1
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`. On Linux, the fallback follows `XDG_CONFIG_HOME` when it is set.
63
- It sends only the current access token to Cursor's dashboard usage service. It
64
- never refreshes, rotates, or modifies Cursor-owned 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
- ```
@@ -1,97 +0,0 @@
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`. On Linux, the fallback follows `XDG_CONFIG_HOME` when it is set.
54
- It sends only the current access token to Cursor's dashboard usage service. It
55
- never refreshes, rotates, or modifies Cursor-owned 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
- ```
@@ -1,2 +0,0 @@
1
- *
2
- !.gitignore
@@ -1,24 +0,0 @@
1
- [project]
2
- name = "cursor-cli-usage"
3
- version = "0.1.1"
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"
@@ -1,408 +0,0 @@
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 math
9
- import os
10
- import platform
11
- import shutil
12
- import signal
13
- import sqlite3
14
- import subprocess
15
- import sys
16
- import tempfile
17
- import time
18
- import urllib.error
19
- import urllib.request
20
- from datetime import UTC, datetime
21
- from pathlib import Path
22
-
23
- DEFAULT_USAGE_FILE = Path.home() / ".cursor" / "usage-limits.json"
24
- USAGE_URL = "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage"
25
- CACHE_MAX_AGE = 300
26
- DAEMON_INTERVAL = 300
27
-
28
-
29
- def _iso_now() -> str:
30
- return datetime.now(UTC).isoformat()
31
-
32
-
33
- def _parse_iso(value: object) -> datetime | None:
34
- if not isinstance(value, str) or not value:
35
- return None
36
- try:
37
- parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
38
- except ValueError:
39
- return None
40
- return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed
41
-
42
-
43
- def _format_reset(value: object) -> str:
44
- if not isinstance(value, str | int | float):
45
- return ""
46
- try:
47
- reset = datetime.fromtimestamp(float(value) / 1000, UTC)
48
- except (TypeError, ValueError, OSError):
49
- parsed = _parse_iso(value)
50
- if parsed is None:
51
- return ""
52
- reset = parsed
53
- minutes = max(0, int((reset - datetime.now(UTC)).total_seconds()) // 60)
54
- if minutes >= 1440:
55
- return f"{minutes // 1440}d{(minutes % 1440) // 60}h"
56
- if minutes >= 60:
57
- return f"{minutes // 60}h{minutes % 60}m"
58
- return f"{minutes}m"
59
-
60
-
61
- def get_state_db() -> Path:
62
- override = os.environ.get("CURSOR_USAGE_STATE_DB")
63
- if override:
64
- return Path(override).expanduser()
65
- if platform.system() == "Darwin":
66
- return (
67
- Path.home()
68
- / "Library"
69
- / "Application Support"
70
- / "Cursor"
71
- / "User"
72
- / "globalStorage"
73
- / "state.vscdb"
74
- )
75
- if platform.system() == "Windows":
76
- app_data = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
77
- return app_data / "Cursor" / "User" / "globalStorage" / "state.vscdb"
78
- config_home = os.environ.get("XDG_CONFIG_HOME")
79
- base = Path(config_home).expanduser() if config_home else Path.home() / ".config"
80
- return base / "Cursor" / "User" / "globalStorage" / "state.vscdb"
81
-
82
-
83
- def get_usage_file() -> Path:
84
- override = os.environ.get("CURSOR_USAGE_FILE")
85
- return Path(override).expanduser() if override else DEFAULT_USAGE_FILE
86
-
87
-
88
- def _find_cursor_cli() -> str | None:
89
- override = os.environ.get("CURSOR_USAGE_AGENT")
90
- if override:
91
- return str(Path(override).expanduser())
92
- for name in ("agent", "cursor-agent"):
93
- if executable := shutil.which(name):
94
- return executable
95
- return None
96
-
97
-
98
- def _get_cli_access_token() -> str | None:
99
- executable = _find_cursor_cli()
100
- if not executable:
101
- return None
102
- try:
103
- result = subprocess.run(
104
- [executable, "status", "--format", "json"],
105
- check=False,
106
- capture_output=True,
107
- text=True,
108
- timeout=10,
109
- )
110
- except (OSError, subprocess.SubprocessError):
111
- return None
112
- if result.returncode:
113
- return None
114
- try:
115
- payload = json.loads(result.stdout)
116
- except json.JSONDecodeError:
117
- return None
118
- auth = payload.get("auth") if isinstance(payload, dict) else None
119
- token = auth.get("accessToken") if isinstance(auth, dict) else None
120
- return token if isinstance(token, str) and token else None
121
-
122
-
123
- def _get_ide_access_token() -> str | None:
124
- path = get_state_db()
125
- if not path.is_file():
126
- return None
127
- try:
128
- connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
129
- try:
130
- row = connection.execute(
131
- "SELECT value FROM ItemTable WHERE key = ? LIMIT 1",
132
- ("cursorAuth/accessToken",),
133
- ).fetchone()
134
- finally:
135
- connection.close()
136
- except sqlite3.Error as exc:
137
- raise RuntimeError(f"Could not read Cursor authentication from {path}") from exc
138
- return row[0] if row and isinstance(row[0], str) and row[0] else None
139
-
140
-
141
- def get_access_token() -> str:
142
- """Read the current access token without refreshing or modifying Cursor state."""
143
- if token := _get_cli_access_token():
144
- return token
145
- if token := _get_ide_access_token():
146
- return token
147
- raise RuntimeError(
148
- "Cursor CLI is not logged in and no Cursor IDE session was found"
149
- )
150
-
151
-
152
- def _number(value: object) -> int | float | None:
153
- if isinstance(value, bool):
154
- return None
155
- if isinstance(value, int):
156
- return value
157
- if isinstance(value, float):
158
- return value if math.isfinite(value) else None
159
- if isinstance(value, str):
160
- try:
161
- parsed = float(value)
162
- except ValueError:
163
- return None
164
- if not math.isfinite(parsed):
165
- return None
166
- return int(parsed) if parsed.is_integer() else parsed
167
- return None
168
-
169
-
170
- def _usage_bucket(value: object) -> dict | None:
171
- if value is None:
172
- return None
173
- if not isinstance(value, dict):
174
- raise RuntimeError("Cursor usage response contained invalid usage data")
175
- bucket = {}
176
- for key, source in (
177
- ("limit", "limit"),
178
- ("used", "used"),
179
- ("remaining", "remaining"),
180
- ("percent_used", "totalPercentUsed"),
181
- ):
182
- raw = value.get(source)
183
- if raw is None:
184
- continue
185
- parsed = _number(raw)
186
- if parsed is None:
187
- raise RuntimeError("Cursor usage response contained invalid numeric usage")
188
- bucket[key] = parsed
189
- return bucket
190
-
191
-
192
- def fetch_usage() -> dict:
193
- token = get_access_token()
194
- request = urllib.request.Request(
195
- USAGE_URL,
196
- data=b"{}",
197
- method="POST",
198
- headers={
199
- "Authorization": f"Bearer {token}",
200
- "Content-Type": "application/json",
201
- "Connect-Protocol-Version": "1",
202
- "User-Agent": "cursor-cli-usage/0.1",
203
- },
204
- )
205
- try:
206
- with urllib.request.urlopen(request, timeout=15) as response:
207
- payload = json.loads(response.read())
208
- except urllib.error.HTTPError as exc:
209
- if exc.code in (401, 403):
210
- raise RuntimeError(
211
- "Cursor access token is no longer accepted; sign in with Cursor again"
212
- ) from exc
213
- raise RuntimeError(f"Cursor usage API returned HTTP {exc.code}") from exc
214
- except (urllib.error.URLError, TimeoutError) as exc:
215
- raise RuntimeError("Cursor usage API is unavailable") from exc
216
- except json.JSONDecodeError as exc:
217
- raise RuntimeError("Cursor usage API returned invalid JSON") from exc
218
- if not isinstance(payload, dict):
219
- raise RuntimeError("Cursor usage API returned an unexpected response")
220
- return payload
221
-
222
-
223
- def _normalize(payload: dict) -> dict:
224
- models = payload.get("autoBucketModels")
225
- plan_usage = _usage_bucket(payload.get("planUsage"))
226
- if not plan_usage or plan_usage.get("percent_used") is None:
227
- raise RuntimeError("Cursor usage response did not contain plan usage")
228
- return {
229
- "billing_cycle": {
230
- "start": payload.get("billingCycleStart"),
231
- "end": payload.get("billingCycleEnd"),
232
- },
233
- "plan_usage": plan_usage,
234
- "spend_limit_usage": _usage_bucket(payload.get("spendLimitUsage")),
235
- "auto_models": models if isinstance(models, list) else [],
236
- }
237
-
238
-
239
- def build_usage_json() -> dict:
240
- updated_at = _iso_now()
241
- try:
242
- usage = _normalize(fetch_usage())
243
- except Exception as exc:
244
- return {
245
- "provider": "cursor",
246
- "status": "unavailable",
247
- "source": "cursor_dashboard_api",
248
- "retrieved_at": updated_at,
249
- "error": str(exc),
250
- }
251
- return {
252
- "provider": "cursor",
253
- "status": "live",
254
- "source": "cursor_dashboard_api",
255
- "retrieved_at": updated_at,
256
- **usage,
257
- }
258
-
259
-
260
- def write_usage_file(data: dict) -> None:
261
- path = get_usage_file()
262
- path.parent.mkdir(parents=True, exist_ok=True)
263
- fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
264
- temporary = Path(temporary_name)
265
- try:
266
- with os.fdopen(fd, "w") as handle:
267
- json.dump(data, handle, indent=2)
268
- handle.write("\n")
269
- os.replace(temporary, path)
270
- except Exception:
271
- temporary.unlink(missing_ok=True)
272
- raise
273
-
274
-
275
- def _read_cache() -> dict | None:
276
- try:
277
- data = json.loads(get_usage_file().read_text())
278
- except (OSError, json.JSONDecodeError):
279
- return None
280
- return data if isinstance(data, dict) else None
281
-
282
-
283
- def get_cached_usage(max_age: int = CACHE_MAX_AGE, force_refresh: bool = False) -> dict:
284
- cached = _read_cache()
285
- if not force_refresh and cached:
286
- retrieved = _parse_iso(cached.get("retrieved_at"))
287
- if retrieved and (datetime.now(UTC) - retrieved).total_seconds() < max_age:
288
- return {**cached, "status": "cached"}
289
- fresh = build_usage_json()
290
- if fresh["status"] == "live":
291
- write_usage_file(fresh)
292
- return fresh
293
- if cached:
294
- return {**cached, "status": "stale", "refresh_error": fresh.get("error")}
295
- return fresh
296
-
297
-
298
- def _summary_usage(data: dict) -> dict | None:
299
- for key in ("plan_usage", "spend_limit_usage"):
300
- bucket = data.get(key)
301
- if isinstance(bucket, dict) and bucket.get("percent_used") is not None:
302
- return bucket
303
- return None
304
-
305
-
306
- def _statusline_text(data: dict) -> str:
307
- bucket = _summary_usage(data)
308
- if not bucket:
309
- return "cursor:q:unavailable"
310
- used = float(bucket["percent_used"])
311
- reset = _format_reset((data.get("billing_cycle") or {}).get("end"))
312
- parts = [f"cursor:q:{used:.1f}%used"]
313
- if reset:
314
- parts.append(f"reset:{reset}")
315
- if data.get("status") in {"cached", "stale"}:
316
- parts.append(str(data["status"]))
317
- return " ".join(parts)
318
-
319
-
320
- def _print_status(data: dict) -> None:
321
- print("Cursor usage")
322
- print(f"Status: {data['status']}")
323
- bucket = data.get("plan_usage")
324
- if isinstance(bucket, dict):
325
- if bucket.get("percent_used") is not None:
326
- print(f"Plan: {float(bucket['percent_used']):g}% used")
327
- if bucket.get("used") is not None and bucket.get("limit") is not None:
328
- print(f"Usage: {bucket['used']} of {bucket['limit']}")
329
- spend = data.get("spend_limit_usage")
330
- if isinstance(spend, dict) and spend.get("percent_used") is not None:
331
- print(f"Spend limit: {float(spend['percent_used']):g}% used")
332
- cycle = data.get("billing_cycle") or {}
333
- if cycle.get("end"):
334
- print(f"Resets: {cycle['end']}")
335
- if data.get("error"):
336
- print(f"Error: {data['error']}")
337
- if data.get("refresh_error"):
338
- print(f"Refresh error: {data['refresh_error']}")
339
-
340
-
341
- def cmd_status(_args: argparse.Namespace) -> None:
342
- _print_status(build_usage_json())
343
-
344
-
345
- def cmd_json(_args: argparse.Namespace) -> None:
346
- print(json.dumps(build_usage_json(), indent=2))
347
-
348
-
349
- def cmd_statusline(args: argparse.Namespace) -> None:
350
- print(_statusline_text(get_cached_usage(args.max_age, args.refresh)))
351
-
352
-
353
- def cmd_refresh(_args: argparse.Namespace) -> None:
354
- data = build_usage_json()
355
- if data["status"] == "live":
356
- write_usage_file(data)
357
- _print_status(data)
358
-
359
-
360
- def cmd_daemon(args: argparse.Namespace) -> None:
361
- signal.signal(signal.SIGINT, lambda *_: sys.exit(0))
362
- if hasattr(signal, "SIGTERM"):
363
- signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
364
- print(f"cursor-cli-usage daemon started (refreshing every {args.interval}s)")
365
- print(f"Writing to {get_usage_file()}")
366
- while True:
367
- data = build_usage_json()
368
- if data["status"] == "live":
369
- write_usage_file(data)
370
- print(f"[{datetime.now().strftime('%H:%M:%S')}] {_statusline_text(data)}")
371
- time.sleep(args.interval)
372
-
373
-
374
- def cmd_install(_args: argparse.Namespace) -> None:
375
- print(
376
- "Install with:\n uv tool install cursor-cli-usage\n\nThen run:\n cursor-cli-usage"
377
- )
378
-
379
-
380
- def _build_parser() -> argparse.ArgumentParser:
381
- parser = argparse.ArgumentParser(description="Cursor account usage monitor")
382
- parser.add_argument(
383
- "command",
384
- nargs="?",
385
- default="status",
386
- choices=["status", "json", "daemon", "statusline", "refresh", "install"],
387
- )
388
- parser.add_argument("-i", "--interval", type=int, default=DAEMON_INTERVAL)
389
- parser.add_argument("--max-age", type=int, default=CACHE_MAX_AGE)
390
- parser.add_argument("--refresh", action="store_true")
391
- return parser
392
-
393
-
394
- def main() -> None:
395
- args = _build_parser().parse_args()
396
- commands = {
397
- "status": cmd_status,
398
- "json": cmd_json,
399
- "daemon": cmd_daemon,
400
- "statusline": cmd_statusline,
401
- "refresh": cmd_refresh,
402
- "install": cmd_install,
403
- }
404
- commands[args.command](args)
405
-
406
-
407
- if __name__ == "__main__":
408
- main()
@@ -1,3 +0,0 @@
1
- from cursor_cli_usage import main
2
-
3
- main()
@@ -1,187 +0,0 @@
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_linux_state_database_follows_xdg_config_home(self):
30
- with (
31
- tempfile.TemporaryDirectory() as temporary,
32
- mock.patch.dict(
33
- cursor_usage.os.environ,
34
- {"XDG_CONFIG_HOME": temporary},
35
- clear=True,
36
- ),
37
- mock.patch.object(cursor_usage.platform, "system", return_value="Linux"),
38
- ):
39
- self.assertEqual(
40
- cursor_usage.get_state_db(),
41
- Path(temporary)
42
- / "Cursor"
43
- / "User"
44
- / "globalStorage"
45
- / "state.vscdb",
46
- )
47
-
48
- def test_access_token_is_read_from_database_without_writes(self):
49
- with tempfile.TemporaryDirectory() as temporary:
50
- database = Path(temporary) / "state.vscdb"
51
- connection = sqlite3.connect(database)
52
- try:
53
- connection.execute("CREATE TABLE ItemTable (key TEXT, value TEXT)")
54
- connection.execute(
55
- "INSERT INTO ItemTable VALUES (?, ?)",
56
- ("cursorAuth/accessToken", "local-token"),
57
- )
58
- connection.commit()
59
- finally:
60
- connection.close()
61
- before = database.read_bytes()
62
- with mock.patch.dict(
63
- cursor_usage.os.environ, {"CURSOR_USAGE_STATE_DB": str(database)}
64
- ):
65
- with mock.patch.object(
66
- cursor_usage, "_get_cli_access_token", return_value=None
67
- ):
68
- self.assertEqual(cursor_usage.get_access_token(), "local-token")
69
- self.assertEqual(database.read_bytes(), before)
70
-
71
- def test_cli_status_is_preferred_and_only_access_token_is_returned(self):
72
- completed = mock.Mock(
73
- returncode=0,
74
- stdout=json.dumps(
75
- {
76
- "authenticated": True,
77
- "auth": {
78
- "accessToken": "cli-access",
79
- "refreshToken": "must-not-be-returned",
80
- },
81
- }
82
- ),
83
- )
84
- with (
85
- mock.patch.object(cursor_usage, "_find_cursor_cli", return_value="agent"),
86
- mock.patch.object(
87
- cursor_usage.subprocess, "run", return_value=completed
88
- ) as run,
89
- mock.patch.object(cursor_usage, "_get_ide_access_token") as ide,
90
- ):
91
- self.assertEqual(cursor_usage.get_access_token(), "cli-access")
92
- run.assert_called_once_with(
93
- ["agent", "status", "--format", "json"],
94
- check=False,
95
- capture_output=True,
96
- text=True,
97
- timeout=10,
98
- )
99
- ide.assert_not_called()
100
-
101
- def test_fetch_uses_bearer_token_and_connect_protocol(self):
102
- payload = {"planUsage": {"limit": 2000, "used": 500, "totalPercentUsed": 25}}
103
-
104
- def fake_urlopen(request, timeout):
105
- self.assertEqual(request.get_header("Authorization"), "Bearer token")
106
- self.assertEqual(request.get_header("Connect-protocol-version"), "1")
107
- self.assertEqual(timeout, 15)
108
- return FakeResponse(payload)
109
-
110
- with (
111
- mock.patch.object(cursor_usage, "get_access_token", return_value="token"),
112
- mock.patch.object(
113
- cursor_usage.urllib.request, "urlopen", side_effect=fake_urlopen
114
- ),
115
- ):
116
- self.assertEqual(cursor_usage.fetch_usage(), payload)
117
-
118
- def test_build_normalizes_current_period_usage(self):
119
- payload = {
120
- "billingCycleStart": "1000",
121
- "billingCycleEnd": "2000",
122
- "planUsage": {"limit": 2000, "remaining": 1500, "totalPercentUsed": 25},
123
- "spendLimitUsage": {"limit": "1000", "used": "100"},
124
- "autoBucketModels": ["auto"],
125
- }
126
- with mock.patch.object(cursor_usage, "fetch_usage", return_value=payload):
127
- data = cursor_usage.build_usage_json()
128
- self.assertEqual(data["status"], "live")
129
- self.assertEqual(data["plan_usage"]["percent_used"], 25)
130
- self.assertEqual(data["spend_limit_usage"]["used"], 100)
131
- self.assertEqual(data["auto_models"], ["auto"])
132
-
133
- def test_missing_plan_usage_is_unavailable(self):
134
- with mock.patch.object(cursor_usage, "fetch_usage", return_value={}):
135
- data = cursor_usage.build_usage_json()
136
- self.assertEqual(data["status"], "unavailable")
137
- self.assertIn("plan usage", data["error"])
138
-
139
- def test_nonfinite_usage_is_unavailable(self):
140
- for value in (float("nan"), "Infinity"):
141
- with self.subTest(value=value):
142
- payload = {"planUsage": {"totalPercentUsed": value}}
143
- with mock.patch.object(
144
- cursor_usage, "fetch_usage", return_value=payload
145
- ):
146
- data = cursor_usage.build_usage_json()
147
- self.assertEqual(data["status"], "unavailable")
148
- json.dumps(data, allow_nan=False)
149
-
150
- def test_unauthorized_never_attempts_refresh(self):
151
- error = urllib.error.HTTPError(cursor_usage.USAGE_URL, 401, "", {}, None)
152
- with (
153
- mock.patch.object(cursor_usage, "get_access_token", return_value="expired"),
154
- mock.patch.object(
155
- cursor_usage.urllib.request, "urlopen", side_effect=error
156
- ),
157
- ):
158
- data = cursor_usage.build_usage_json()
159
- self.assertEqual(data["status"], "unavailable")
160
- self.assertIn("sign in", data["error"])
161
-
162
- def test_stale_cache_is_returned_when_refresh_fails(self):
163
- cached = {
164
- "provider": "cursor",
165
- "status": "live",
166
- "source": "cursor_dashboard_api",
167
- "retrieved_at": "2020-01-01T00:00:00+00:00",
168
- "plan_usage": {"percent_used": 50},
169
- }
170
- with tempfile.TemporaryDirectory() as temporary:
171
- cache = Path(temporary) / "usage.json"
172
- cache.write_text(json.dumps(cached))
173
- with (
174
- mock.patch.object(cursor_usage, "DEFAULT_USAGE_FILE", cache),
175
- mock.patch.object(
176
- cursor_usage,
177
- "build_usage_json",
178
- return_value={"status": "unavailable", "error": "offline"},
179
- ),
180
- ):
181
- result = cursor_usage.get_cached_usage()
182
- self.assertEqual(result["status"], "stale")
183
- self.assertEqual(result["refresh_error"], "offline")
184
-
185
-
186
- if __name__ == "__main__":
187
- unittest.main()
@@ -1,8 +0,0 @@
1
- version = 1
2
- revision = 3
3
- requires-python = ">=3.12"
4
-
5
- [[package]]
6
- name = "cursor-cli-usage"
7
- version = "0.1.1"
8
- source = { editable = "." }