cursor-cli-usage 0.1.0__tar.gz → 0.1.1__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.
- {cursor_cli_usage-0.1.0 → cursor_cli_usage-0.1.1}/PKG-INFO +5 -5
- {cursor_cli_usage-0.1.0 → cursor_cli_usage-0.1.1}/README.md +3 -3
- {cursor_cli_usage-0.1.0 → cursor_cli_usage-0.1.1}/pyproject.toml +1 -1
- {cursor_cli_usage-0.1.0 → cursor_cli_usage-0.1.1}/src/cursor_cli_usage/__init__.py +31 -14
- {cursor_cli_usage-0.1.0 → cursor_cli_usage-0.1.1}/tests/test_cursor_cli_usage.py +36 -0
- {cursor_cli_usage-0.1.0 → cursor_cli_usage-0.1.1}/uv.lock +1 -1
- {cursor_cli_usage-0.1.0 → cursor_cli_usage-0.1.1}/.github/workflows/publish.yml +0 -0
- {cursor_cli_usage-0.1.0 → cursor_cli_usage-0.1.1}/.gitignore +0 -0
- {cursor_cli_usage-0.1.0 → cursor_cli_usage-0.1.1}/dist/.gitignore +0 -0
- {cursor_cli_usage-0.1.0 → cursor_cli_usage-0.1.1}/src/cursor_cli_usage/__main__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: cursor-cli-usage
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Cursor CLI account quota monitor using local Cursor authentication
|
|
5
5
|
Project-URL: Homepage, https://github.com/wakamex/cursor-cli-usage
|
|
6
6
|
Project-URL: Source, https://github.com/wakamex/cursor-cli-usage
|
|
@@ -59,9 +59,9 @@ uv tool install .
|
|
|
59
59
|
For every live request, the tool first asks the installed Cursor CLI for its
|
|
60
60
|
current authentication state with `agent status --format json`. If the CLI is
|
|
61
61
|
unavailable, it falls back to `cursorAuth/accessToken` in Cursor IDE's local
|
|
62
|
-
`state.vscdb`.
|
|
63
|
-
|
|
64
|
-
credentials.
|
|
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
65
|
|
|
66
66
|
The normalized JSON includes:
|
|
67
67
|
|
|
@@ -50,9 +50,9 @@ uv tool install .
|
|
|
50
50
|
For every live request, the tool first asks the installed Cursor CLI for its
|
|
51
51
|
current authentication state with `agent status --format json`. If the CLI is
|
|
52
52
|
unavailable, it falls back to `cursorAuth/accessToken` in Cursor IDE's local
|
|
53
|
-
`state.vscdb`.
|
|
54
|
-
|
|
55
|
-
credentials.
|
|
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
56
|
|
|
57
57
|
The normalized JSON includes:
|
|
58
58
|
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
|
|
6
6
|
import argparse
|
|
7
7
|
import json
|
|
8
|
+
import math
|
|
8
9
|
import os
|
|
9
10
|
import platform
|
|
10
11
|
import shutil
|
|
@@ -74,7 +75,9 @@ def get_state_db() -> Path:
|
|
|
74
75
|
if platform.system() == "Windows":
|
|
75
76
|
app_data = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
|
|
76
77
|
return app_data / "Cursor" / "User" / "globalStorage" / "state.vscdb"
|
|
77
|
-
|
|
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"
|
|
78
81
|
|
|
79
82
|
|
|
80
83
|
def get_usage_file() -> Path:
|
|
@@ -149,30 +152,41 @@ def get_access_token() -> str:
|
|
|
149
152
|
def _number(value: object) -> int | float | None:
|
|
150
153
|
if isinstance(value, bool):
|
|
151
154
|
return None
|
|
152
|
-
if isinstance(value, int
|
|
155
|
+
if isinstance(value, int):
|
|
153
156
|
return value
|
|
157
|
+
if isinstance(value, float):
|
|
158
|
+
return value if math.isfinite(value) else None
|
|
154
159
|
if isinstance(value, str):
|
|
155
160
|
try:
|
|
156
161
|
parsed = float(value)
|
|
157
162
|
except ValueError:
|
|
158
163
|
return None
|
|
164
|
+
if not math.isfinite(parsed):
|
|
165
|
+
return None
|
|
159
166
|
return int(parsed) if parsed.is_integer() else parsed
|
|
160
167
|
return None
|
|
161
168
|
|
|
162
169
|
|
|
163
170
|
def _usage_bucket(value: object) -> dict | None:
|
|
164
|
-
if
|
|
171
|
+
if value is None:
|
|
165
172
|
return None
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
)
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
176
190
|
|
|
177
191
|
|
|
178
192
|
def fetch_usage() -> dict:
|
|
@@ -208,12 +222,15 @@ def fetch_usage() -> dict:
|
|
|
208
222
|
|
|
209
223
|
def _normalize(payload: dict) -> dict:
|
|
210
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")
|
|
211
228
|
return {
|
|
212
229
|
"billing_cycle": {
|
|
213
230
|
"start": payload.get("billingCycleStart"),
|
|
214
231
|
"end": payload.get("billingCycleEnd"),
|
|
215
232
|
},
|
|
216
|
-
"plan_usage":
|
|
233
|
+
"plan_usage": plan_usage,
|
|
217
234
|
"spend_limit_usage": _usage_bucket(payload.get("spendLimitUsage")),
|
|
218
235
|
"auto_models": models if isinstance(models, list) else [],
|
|
219
236
|
}
|
|
@@ -26,6 +26,25 @@ class FakeResponse:
|
|
|
26
26
|
|
|
27
27
|
|
|
28
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
|
+
|
|
29
48
|
def test_access_token_is_read_from_database_without_writes(self):
|
|
30
49
|
with tempfile.TemporaryDirectory() as temporary:
|
|
31
50
|
database = Path(temporary) / "state.vscdb"
|
|
@@ -111,6 +130,23 @@ class CursorUsageTests(unittest.TestCase):
|
|
|
111
130
|
self.assertEqual(data["spend_limit_usage"]["used"], 100)
|
|
112
131
|
self.assertEqual(data["auto_models"], ["auto"])
|
|
113
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
|
+
|
|
114
150
|
def test_unauthorized_never_attempts_refresh(self):
|
|
115
151
|
error = urllib.error.HTTPError(cursor_usage.USAGE_URL, 401, "", {}, None)
|
|
116
152
|
with (
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|