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.
- cursor_cli_usage-0.4.0/.gitignore +4 -0
- cursor_cli_usage-0.4.0/LICENSE +21 -0
- cursor_cli_usage-0.4.0/PKG-INFO +25 -0
- cursor_cli_usage-0.4.0/README.md +12 -0
- cursor_cli_usage-0.4.0/cursor_cli_usage/__init__.py +7 -0
- cursor_cli_usage-0.4.0/cursor_cli_usage/__main__.py +3 -0
- cursor_cli_usage-0.4.0/pyproject.toml +24 -0
- cursor_cli_usage-0.1.1/.github/workflows/publish.yml +0 -29
- cursor_cli_usage-0.1.1/.gitignore +0 -6
- cursor_cli_usage-0.1.1/PKG-INFO +0 -106
- cursor_cli_usage-0.1.1/README.md +0 -97
- cursor_cli_usage-0.1.1/dist/.gitignore +0 -2
- cursor_cli_usage-0.1.1/pyproject.toml +0 -24
- cursor_cli_usage-0.1.1/src/cursor_cli_usage/__init__.py +0 -408
- cursor_cli_usage-0.1.1/src/cursor_cli_usage/__main__.py +0 -3
- cursor_cli_usage-0.1.1/tests/test_cursor_cli_usage.py +0 -187
- cursor_cli_usage-0.1.1/uv.lock +0 -8
|
@@ -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,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
|
cursor_cli_usage-0.1.1/PKG-INFO
DELETED
|
@@ -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
|
-
```
|
cursor_cli_usage-0.1.1/README.md
DELETED
|
@@ -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,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,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()
|