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.
- cursor_cli_usage-0.1.0/.github/workflows/publish.yml +29 -0
- cursor_cli_usage-0.1.0/.gitignore +6 -0
- cursor_cli_usage-0.1.0/PKG-INFO +106 -0
- cursor_cli_usage-0.1.0/README.md +97 -0
- cursor_cli_usage-0.1.0/dist/.gitignore +2 -0
- cursor_cli_usage-0.1.0/pyproject.toml +24 -0
- cursor_cli_usage-0.1.0/src/cursor_cli_usage/__init__.py +391 -0
- cursor_cli_usage-0.1.0/src/cursor_cli_usage/__main__.py +3 -0
- cursor_cli_usage-0.1.0/tests/test_cursor_cli_usage.py +151 -0
- cursor_cli_usage-0.1.0/uv.lock +8 -0
|
@@ -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,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,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,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()
|