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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: cursor-cli-usage
3
- Version: 0.1.0
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`. It sends only the current access token to Cursor's dashboard
63
- usage service. It never refreshes, rotates, or modifies Cursor-owned
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`. It sends only the current access token to Cursor's dashboard
54
- usage service. It never refreshes, rotates, or modifies Cursor-owned
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cursor-cli-usage"
3
- version = "0.1.0"
3
+ version = "0.1.1"
4
4
  description = "Cursor CLI account quota monitor using local Cursor authentication"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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
- return Path.home() / ".config" / "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"
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 | float):
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 not isinstance(value, dict):
171
+ if value is None:
165
172
  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
- }
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": _usage_bucket(payload.get("planUsage")),
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 (
@@ -4,5 +4,5 @@ requires-python = ">=3.12"
4
4
 
5
5
  [[package]]
6
6
  name = "cursor-cli-usage"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  source = { editable = "." }