cursor-usage 0.1.0__py3-none-any.whl
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_usage/__init__.py +3 -0
- cursor_usage/__main__.py +6 -0
- cursor_usage/api.py +97 -0
- cursor_usage/auth.py +194 -0
- cursor_usage/cli.py +127 -0
- cursor_usage/report.py +134 -0
- cursor_usage-0.1.0.dist-info/METADATA +202 -0
- cursor_usage-0.1.0.dist-info/RECORD +12 -0
- cursor_usage-0.1.0.dist-info/WHEEL +5 -0
- cursor_usage-0.1.0.dist-info/entry_points.txt +2 -0
- cursor_usage-0.1.0.dist-info/licenses/LICENSE +21 -0
- cursor_usage-0.1.0.dist-info/top_level.txt +1 -0
cursor_usage/__init__.py
ADDED
cursor_usage/__main__.py
ADDED
cursor_usage/api.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Minimal client for the Cursor dashboard usage API (stdlib only).
|
|
2
|
+
|
|
3
|
+
Endpoints used (all on ``https://cursor.com``):
|
|
4
|
+
GET /api/auth/me -> {email, id, sub, ...}
|
|
5
|
+
GET /api/usage?user=<id> -> legacy counter + startOfMonth
|
|
6
|
+
POST /api/dashboard/get-aggregated-usage-events -> per-model tokens + cents
|
|
7
|
+
POST /api/dashboard/get-filtered-usage-events -> per-event log (paginated)
|
|
8
|
+
|
|
9
|
+
State-changing POSTs require an ``Origin: https://cursor.com`` header (CSRF guard).
|
|
10
|
+
Auth is the ``WorkosCursorSessionToken`` cookie, value ``<sub>::<jwt>`` (the ``::``
|
|
11
|
+
is sent URL-encoded as ``%3A%3A``).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
|
|
20
|
+
BASE = "https://cursor.com"
|
|
21
|
+
USER_AGENT = "cursor-usage/%s" % __version__
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CursorAPIError(RuntimeError):
|
|
25
|
+
def __init__(self, status, body):
|
|
26
|
+
self.status = status
|
|
27
|
+
self.body = body
|
|
28
|
+
super().__init__("HTTP %s: %s" % (status, body[:300]))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CursorClient:
|
|
32
|
+
def __init__(self, cookie_value, timeout=30):
|
|
33
|
+
self._cookie = "WorkosCursorSessionToken=" + cookie_value.replace("::", "%3A%3A")
|
|
34
|
+
self._timeout = timeout
|
|
35
|
+
|
|
36
|
+
def _request(self, path, method="GET", body=None):
|
|
37
|
+
headers = {
|
|
38
|
+
"Cookie": self._cookie,
|
|
39
|
+
"Accept": "application/json",
|
|
40
|
+
"User-Agent": USER_AGENT,
|
|
41
|
+
}
|
|
42
|
+
data = None
|
|
43
|
+
if body is not None:
|
|
44
|
+
data = json.dumps(body).encode("utf-8")
|
|
45
|
+
headers["Content-Type"] = "application/json"
|
|
46
|
+
headers["Origin"] = BASE # required: dashboard CSRF check
|
|
47
|
+
req = urllib.request.Request(BASE + path, data=data, headers=headers, method=method)
|
|
48
|
+
try:
|
|
49
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
50
|
+
raw = resp.read().decode("utf-8", "ignore")
|
|
51
|
+
except urllib.error.HTTPError as exc:
|
|
52
|
+
raise CursorAPIError(exc.code, exc.read().decode("utf-8", "ignore"))
|
|
53
|
+
return json.loads(raw) if raw else {}
|
|
54
|
+
|
|
55
|
+
# -- endpoints ---------------------------------------------------------
|
|
56
|
+
def me(self):
|
|
57
|
+
return self._request("/api/auth/me")
|
|
58
|
+
|
|
59
|
+
def usage(self, user_id):
|
|
60
|
+
return self._request("/api/usage?user=%s" % user_id)
|
|
61
|
+
|
|
62
|
+
def aggregated_usage(self, user_id, start_ms, end_ms):
|
|
63
|
+
return self._request(
|
|
64
|
+
"/api/dashboard/get-aggregated-usage-events", "POST",
|
|
65
|
+
{"teamId": 0, "startDate": str(start_ms), "endDate": str(end_ms),
|
|
66
|
+
"userId": user_id},
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def _events_page(self, user_id, start_ms, end_ms, page, page_size):
|
|
70
|
+
return self._request(
|
|
71
|
+
"/api/dashboard/get-filtered-usage-events", "POST",
|
|
72
|
+
{"teamId": 0, "startDate": str(start_ms), "endDate": str(end_ms),
|
|
73
|
+
"userId": user_id, "page": page, "pageSize": page_size},
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def all_events(self, user_id, start_ms, end_ms, page_size=1000, progress=None):
|
|
77
|
+
"""Fetch every usage event in the window by paginating.
|
|
78
|
+
|
|
79
|
+
Returns ``(events, total_reported)``. ``progress(fetched, total)`` is
|
|
80
|
+
called after each page if provided.
|
|
81
|
+
"""
|
|
82
|
+
first = self._events_page(user_id, start_ms, end_ms, 1, page_size)
|
|
83
|
+
total = int(first.get("totalUsageEventsCount", 0) or 0)
|
|
84
|
+
events = list(first.get("usageEventsDisplay", []))
|
|
85
|
+
if progress:
|
|
86
|
+
progress(len(events), total)
|
|
87
|
+
page = 2
|
|
88
|
+
while len(events) < total and page <= 1000: # 1000-page safety cap
|
|
89
|
+
chunk = self._events_page(user_id, start_ms, end_ms, page, page_size)
|
|
90
|
+
rows = chunk.get("usageEventsDisplay", [])
|
|
91
|
+
if not rows:
|
|
92
|
+
break
|
|
93
|
+
events.extend(rows)
|
|
94
|
+
if progress:
|
|
95
|
+
progress(len(events), total)
|
|
96
|
+
page += 1
|
|
97
|
+
return events, total
|
cursor_usage/auth.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Cross-platform resolution of the Cursor web session token.
|
|
2
|
+
|
|
3
|
+
Why this exists
|
|
4
|
+
---------------
|
|
5
|
+
A personal Cursor API key (``crsr_...``) only reaches the Agent API
|
|
6
|
+
(``api.cursor.com/v1/*``). It CANNOT read usage/spend -- those endpoints reject
|
|
7
|
+
it with "Invalid Team API Key". The data the web dashboard shows comes from
|
|
8
|
+
``cursor.com/api/*`` and is gated by a WorkOS *session* cookie
|
|
9
|
+
(``WorkosCursorSessionToken``), not the API key.
|
|
10
|
+
|
|
11
|
+
The Cursor app/CLI stores that session JWT locally. We read it from whichever of
|
|
12
|
+
these is available, in priority order (all are local-only; nothing is sent
|
|
13
|
+
anywhere except cursor.com):
|
|
14
|
+
|
|
15
|
+
1. ``$CURSOR_SESSION_TOKEN`` -- explicit override (raw JWT, or full ``sub::jwt``).
|
|
16
|
+
2. macOS Keychain service ``cursor-access-token`` (used by ``cursor-agent``).
|
|
17
|
+
3. OS keyring via the optional ``keyring`` package (Linux Secret Service /
|
|
18
|
+
Windows Credential Locker / macOS Keychain).
|
|
19
|
+
4. The Cursor IDE SQLite state DB (``state.vscdb`` -> ``cursorAuth/accessToken``),
|
|
20
|
+
whose layout is identical on macOS, Linux and Windows.
|
|
21
|
+
|
|
22
|
+
The dashboard cookie value is ``<workos_sub>::<jwt>``; ``sub`` is the ``sub``
|
|
23
|
+
claim of the JWT, so we can derive the whole cookie from just the token.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import base64
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import sqlite3
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
KEYCHAIN_SERVICE = "cursor-access-token"
|
|
35
|
+
KEYCHAIN_ACCOUNT = "cursor-user"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SessionNotFound(RuntimeError):
|
|
39
|
+
"""Raised when no Cursor session token can be located."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _jwt_claims(token):
|
|
43
|
+
"""Decode a JWT payload to a dict, or ``{}`` if it can't be parsed."""
|
|
44
|
+
try:
|
|
45
|
+
payload = token.split(".")[1]
|
|
46
|
+
payload += "=" * (-len(payload) % 4) # restore base64url padding
|
|
47
|
+
return json.loads(base64.urlsafe_b64decode(payload))
|
|
48
|
+
except Exception:
|
|
49
|
+
return {}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _cookie_id(claims):
|
|
53
|
+
"""Return the cookie id from a token's ``sub`` claim, or ``None``.
|
|
54
|
+
|
|
55
|
+
WorkOS subjects look like ``<connection>|<user_id>`` (e.g.
|
|
56
|
+
``github|user_01ABC...``). The dashboard cookie wants the bare ``user_...``
|
|
57
|
+
part (what ``/api/auth/me`` reports as ``sub``), so strip any connection
|
|
58
|
+
prefix before the last ``|``.
|
|
59
|
+
"""
|
|
60
|
+
sub = claims.get("sub")
|
|
61
|
+
return sub.split("|")[-1] if sub else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _from_env():
|
|
65
|
+
return os.environ.get("CURSOR_SESSION_TOKEN")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _from_macos_keychain():
|
|
69
|
+
if sys.platform != "darwin":
|
|
70
|
+
return None
|
|
71
|
+
try:
|
|
72
|
+
out = subprocess.run(
|
|
73
|
+
["security", "find-generic-password", "-s", KEYCHAIN_SERVICE, "-w"],
|
|
74
|
+
capture_output=True, text=True, timeout=15,
|
|
75
|
+
)
|
|
76
|
+
return out.stdout.strip() or None
|
|
77
|
+
except Exception:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _from_keyring():
|
|
82
|
+
try:
|
|
83
|
+
import keyring # optional dependency
|
|
84
|
+
except Exception:
|
|
85
|
+
return None
|
|
86
|
+
try:
|
|
87
|
+
return keyring.get_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)
|
|
88
|
+
except Exception:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _state_db_candidates():
|
|
93
|
+
"""Yield possible Cursor IDE ``state.vscdb`` paths for the current OS."""
|
|
94
|
+
home = Path.home()
|
|
95
|
+
if sys.platform == "darwin":
|
|
96
|
+
base = home / "Library" / "Application Support"
|
|
97
|
+
elif sys.platform.startswith("win"):
|
|
98
|
+
base = Path(os.environ.get("APPDATA", home / "AppData" / "Roaming"))
|
|
99
|
+
else: # linux / other unix
|
|
100
|
+
base = Path(os.environ.get("XDG_CONFIG_HOME", home / ".config"))
|
|
101
|
+
for app in ("Cursor", "Cursor Nightly"):
|
|
102
|
+
yield base / app / "User" / "globalStorage" / "state.vscdb"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _from_state_db():
|
|
106
|
+
for path in _state_db_candidates():
|
|
107
|
+
if not path.exists():
|
|
108
|
+
continue
|
|
109
|
+
try:
|
|
110
|
+
con = sqlite3.connect("file:%s?mode=ro" % path, uri=True)
|
|
111
|
+
try:
|
|
112
|
+
row = con.execute(
|
|
113
|
+
"SELECT value FROM ItemTable WHERE key = ?",
|
|
114
|
+
("cursorAuth/accessToken",),
|
|
115
|
+
).fetchone()
|
|
116
|
+
finally:
|
|
117
|
+
con.close()
|
|
118
|
+
except Exception:
|
|
119
|
+
continue
|
|
120
|
+
if row and row[0]:
|
|
121
|
+
value = row[0]
|
|
122
|
+
if isinstance(value, bytes):
|
|
123
|
+
value = value.decode("utf-8", "ignore")
|
|
124
|
+
return value.strip().strip('"')
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# Local credential stores, tried in order (env override is handled separately).
|
|
129
|
+
_LOCAL_SOURCES = (
|
|
130
|
+
("macOS keychain", _from_macos_keychain),
|
|
131
|
+
("OS keyring", _from_keyring),
|
|
132
|
+
("Cursor state DB", _from_state_db),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _log(verbose, name):
|
|
137
|
+
if verbose:
|
|
138
|
+
print("[auth] using session from %s" % name, file=sys.stderr)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def resolve_cookie_value(verbose=False):
|
|
142
|
+
"""Return ``"<id>::<jwt>"`` suitable for the dashboard cookie.
|
|
143
|
+
|
|
144
|
+
Only a WorkOS *session* token authenticates the dashboard; an
|
|
145
|
+
``api_key_token`` (which ``cursor-agent`` may store in the keychain) returns
|
|
146
|
+
HTTP 204 and is skipped. Raises :class:`SessionNotFound` if none is found.
|
|
147
|
+
"""
|
|
148
|
+
# 1. Explicit override always wins (raw JWT, or full ``id::jwt`` cookie).
|
|
149
|
+
env = _from_env()
|
|
150
|
+
if env:
|
|
151
|
+
env = env.strip().replace("%3A%3A", "::")
|
|
152
|
+
if "::" in env:
|
|
153
|
+
_log(verbose, "$CURSOR_SESSION_TOKEN")
|
|
154
|
+
return env
|
|
155
|
+
cid = _cookie_id(_jwt_claims(env))
|
|
156
|
+
if cid:
|
|
157
|
+
_log(verbose, "$CURSOR_SESSION_TOKEN")
|
|
158
|
+
return "%s::%s" % (cid, env)
|
|
159
|
+
|
|
160
|
+
# 2. Local stores -- require a session token; skip api_key_token.
|
|
161
|
+
tried = ["$CURSOR_SESSION_TOKEN"]
|
|
162
|
+
saw_api_key = False
|
|
163
|
+
for name, source in _LOCAL_SOURCES:
|
|
164
|
+
tried.append(name)
|
|
165
|
+
token = source()
|
|
166
|
+
if not token:
|
|
167
|
+
continue
|
|
168
|
+
token = token.strip().replace("%3A%3A", "::")
|
|
169
|
+
if "::" in token: # already a full "id::jwt" cookie value
|
|
170
|
+
_log(verbose, name)
|
|
171
|
+
return token
|
|
172
|
+
claims = _jwt_claims(token)
|
|
173
|
+
cid = _cookie_id(claims)
|
|
174
|
+
if not cid:
|
|
175
|
+
continue
|
|
176
|
+
if claims.get("type") == "api_key_token":
|
|
177
|
+
saw_api_key = True # valid token, but not a *web* session
|
|
178
|
+
continue
|
|
179
|
+
_log(verbose, name)
|
|
180
|
+
return "%s::%s" % (cid, token)
|
|
181
|
+
|
|
182
|
+
hint = ""
|
|
183
|
+
if saw_api_key:
|
|
184
|
+
hint = ("\nNote: found an api_key_token (Agent API), but that does not "
|
|
185
|
+
"authenticate the\nusage dashboard. A *web session* is needed.")
|
|
186
|
+
raise SessionNotFound(
|
|
187
|
+
"Could not find a usable Cursor session token.\n"
|
|
188
|
+
"Tried: %s.%s\n\n"
|
|
189
|
+
"Fix one of:\n"
|
|
190
|
+
" - Sign in via the Cursor app (this stores a web session), or\n"
|
|
191
|
+
" - Set CURSOR_SESSION_TOKEN to your WorkosCursorSessionToken cookie\n"
|
|
192
|
+
" (cursor.com -> DevTools -> Application -> Cookies)."
|
|
193
|
+
% (", ".join(tried), hint)
|
|
194
|
+
)
|
cursor_usage/cli.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Command-line entry point for cursor-usage."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .api import CursorAPIError, CursorClient
|
|
10
|
+
from .auth import SessionNotFound, resolve_cookie_value
|
|
11
|
+
from .report import render_by_day, render_summary, write_csv
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _ms(dt):
|
|
15
|
+
return int(dt.timestamp() * 1000)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _parse_date(s):
|
|
19
|
+
return datetime.strptime(s, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _resolve_window(args, client, user_id):
|
|
23
|
+
"""Return (start_ms, end_ms, start_label, end_label)."""
|
|
24
|
+
now = datetime.now(timezone.utc)
|
|
25
|
+
end = _parse_date(args.end) + timedelta(days=1) if args.end else now
|
|
26
|
+
if args.start:
|
|
27
|
+
start = _parse_date(args.start)
|
|
28
|
+
elif args.days:
|
|
29
|
+
start = now - timedelta(days=args.days)
|
|
30
|
+
elif args.month:
|
|
31
|
+
y, m = (int(x) for x in args.month.split("-"))
|
|
32
|
+
start = datetime(y, m, 1, tzinfo=timezone.utc)
|
|
33
|
+
if not args.end:
|
|
34
|
+
nm = datetime(y + (m == 12), (m % 12) + 1, 1, tzinfo=timezone.utc)
|
|
35
|
+
end = min(now, nm)
|
|
36
|
+
else:
|
|
37
|
+
# default: current billing month, per /api/usage startOfMonth
|
|
38
|
+
som = client.usage(user_id).get("startOfMonth")
|
|
39
|
+
start = (datetime.fromisoformat(som.replace("Z", "+00:00"))
|
|
40
|
+
if som else now.replace(day=1, hour=0, minute=0, second=0, microsecond=0))
|
|
41
|
+
return _ms(start), _ms(end), start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _progress(fetched, total):
|
|
45
|
+
if total:
|
|
46
|
+
sys.stderr.write("\r[events] %d/%d" % (fetched, total))
|
|
47
|
+
sys.stderr.flush()
|
|
48
|
+
if fetched >= total:
|
|
49
|
+
sys.stderr.write("\n")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_parser():
|
|
53
|
+
p = argparse.ArgumentParser(
|
|
54
|
+
prog="cursor-usage",
|
|
55
|
+
description="Show Cursor (cursor.com) usage, spend and per-event logs "
|
|
56
|
+
"for the locally signed-in account.",
|
|
57
|
+
)
|
|
58
|
+
p.add_argument("--by-day", action="store_true",
|
|
59
|
+
help="break usage down by calendar day (fetches per-event data)")
|
|
60
|
+
p.add_argument("--csv", metavar="FILE",
|
|
61
|
+
help="write the per-event log to FILE as CSV")
|
|
62
|
+
p.add_argument("--json", action="store_true",
|
|
63
|
+
help="print the raw aggregated-usage JSON and exit")
|
|
64
|
+
g = p.add_argument_group("time window (default: current billing month)")
|
|
65
|
+
g.add_argument("--start", metavar="YYYY-MM-DD", help="window start (inclusive)")
|
|
66
|
+
g.add_argument("--end", metavar="YYYY-MM-DD", help="window end (inclusive)")
|
|
67
|
+
g.add_argument("--days", type=int, metavar="N", help="last N days")
|
|
68
|
+
g.add_argument("--month", metavar="YYYY-MM", help="a specific calendar month")
|
|
69
|
+
p.add_argument("--page-size", type=int, default=1000,
|
|
70
|
+
help="events per API page when paginating (default: 1000)")
|
|
71
|
+
p.add_argument("-v", "--verbose", action="store_true", help="log the token source")
|
|
72
|
+
p.add_argument("-V", "--version", action="version",
|
|
73
|
+
version="cursor-usage %s" % __version__)
|
|
74
|
+
return p
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main(argv=None):
|
|
78
|
+
args = build_parser().parse_args(argv)
|
|
79
|
+
try:
|
|
80
|
+
cookie = resolve_cookie_value(verbose=args.verbose)
|
|
81
|
+
except SessionNotFound as exc:
|
|
82
|
+
print(str(exc), file=sys.stderr)
|
|
83
|
+
return 2
|
|
84
|
+
|
|
85
|
+
client = CursorClient(cookie)
|
|
86
|
+
try:
|
|
87
|
+
me = client.me()
|
|
88
|
+
user_id = me.get("id")
|
|
89
|
+
email = me.get("email", "unknown")
|
|
90
|
+
if not user_id:
|
|
91
|
+
print("Session token is invalid or expired (auth/me returned no id).\n"
|
|
92
|
+
"Re-authenticate: cursor-agent login", file=sys.stderr)
|
|
93
|
+
return 2
|
|
94
|
+
|
|
95
|
+
start_ms, end_ms, start_label, end_label = _resolve_window(args, client, user_id)
|
|
96
|
+
meta = {"email": email, "start": start_label, "end": end_label}
|
|
97
|
+
|
|
98
|
+
if args.json:
|
|
99
|
+
print(json.dumps(client.aggregated_usage(user_id, start_ms, end_ms), indent=2))
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
# The aggregated endpoint powers the summary (one cheap call).
|
|
103
|
+
print(render_summary(client.aggregated_usage(user_id, start_ms, end_ms), meta))
|
|
104
|
+
|
|
105
|
+
# Per-event data powers --by-day and --csv; fetch it once if needed.
|
|
106
|
+
if args.by_day or args.csv:
|
|
107
|
+
events, total = client.all_events(
|
|
108
|
+
user_id, start_ms, end_ms, page_size=args.page_size, progress=_progress)
|
|
109
|
+
if args.by_day:
|
|
110
|
+
print()
|
|
111
|
+
print(render_by_day(events, meta))
|
|
112
|
+
if args.csv:
|
|
113
|
+
n = write_csv(events, args.csv)
|
|
114
|
+
print("\nWrote %d events to %s" % (n, args.csv))
|
|
115
|
+
except CursorAPIError as exc:
|
|
116
|
+
print("\nCursor API error (HTTP %s): %s" % (exc.status, exc.body[:300]), file=sys.stderr)
|
|
117
|
+
if exc.status in (401, 403):
|
|
118
|
+
print("Session may be expired. Re-authenticate: cursor-agent login", file=sys.stderr)
|
|
119
|
+
return 1
|
|
120
|
+
except OSError as exc:
|
|
121
|
+
print("\nNetwork error talking to cursor.com: %s" % exc, file=sys.stderr)
|
|
122
|
+
return 1
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
sys.exit(main())
|
cursor_usage/report.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Turn raw API payloads into summaries, day breakdowns and CSV rows."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
from collections import OrderedDict
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# --- field extraction -----------------------------------------------------
|
|
9
|
+
def _i(d, k):
|
|
10
|
+
try:
|
|
11
|
+
return int(d.get(k, 0) or 0)
|
|
12
|
+
except (TypeError, ValueError):
|
|
13
|
+
return 0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _f(d, k):
|
|
17
|
+
try:
|
|
18
|
+
return float(d.get(k, 0) or 0)
|
|
19
|
+
except (TypeError, ValueError):
|
|
20
|
+
return 0.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def event_row(ev):
|
|
24
|
+
"""Flatten one ``usageEventsDisplay`` entry into a plain dict."""
|
|
25
|
+
tu = ev.get("tokenUsage") or {}
|
|
26
|
+
ts_ms = _i(ev, "timestamp")
|
|
27
|
+
local = datetime.fromtimestamp(ts_ms / 1000).astimezone() if ts_ms else None
|
|
28
|
+
return OrderedDict([
|
|
29
|
+
("datetime_local", local.isoformat() if local else ""),
|
|
30
|
+
("timestamp_ms", ts_ms),
|
|
31
|
+
("date", local.strftime("%Y-%m-%d") if local else ""),
|
|
32
|
+
("model", ev.get("model", "")),
|
|
33
|
+
("kind", ev.get("kind", "")),
|
|
34
|
+
("input_tokens", _i(tu, "inputTokens")),
|
|
35
|
+
("output_tokens", _i(tu, "outputTokens")),
|
|
36
|
+
("cache_read_tokens", _i(tu, "cacheReadTokens")),
|
|
37
|
+
("cache_write_tokens", _i(tu, "cacheWriteTokens")),
|
|
38
|
+
("value_cents", round(_f(tu, "totalCents"), 6)),
|
|
39
|
+
("charged_cents", round(_f(ev, "chargedCents"), 6)),
|
|
40
|
+
("requests_costs", ev.get("requestsCosts", 0)),
|
|
41
|
+
("is_headless", bool(ev.get("isHeadless", False))),
|
|
42
|
+
("owning_user", ev.get("owningUser", "")),
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# --- formatting helpers ---------------------------------------------------
|
|
47
|
+
def _money(cents):
|
|
48
|
+
return "$%s" % format(cents / 100.0, ",.2f")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _n(x):
|
|
52
|
+
return format(int(x), ",")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _rule(width=78):
|
|
56
|
+
return "-" * width
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# --- summary (from aggregated endpoint) -----------------------------------
|
|
60
|
+
def render_summary(agg, meta):
|
|
61
|
+
rows = agg.get("aggregations", []) or []
|
|
62
|
+
cents = lambda r: _f(r, "totalCents")
|
|
63
|
+
total = sum(cents(r) for r in rows)
|
|
64
|
+
ti = sum(_i(r, "inputTokens") for r in rows)
|
|
65
|
+
to = sum(_i(r, "outputTokens") for r in rows)
|
|
66
|
+
tcr = sum(_i(r, "cacheReadTokens") for r in rows)
|
|
67
|
+
tcw = sum(_i(r, "cacheWriteTokens") for r in rows)
|
|
68
|
+
|
|
69
|
+
out = ["=" * 78]
|
|
70
|
+
out.append("CURSOR USAGE | %s | %s -> %s"
|
|
71
|
+
% (meta["email"], meta["start"], meta["end"]))
|
|
72
|
+
out.append("=" * 78)
|
|
73
|
+
out.append("Included value used : %s (compute consumed; included in plan)" % _money(total))
|
|
74
|
+
out.append("Tokens in=%s out=%s" % (_n(ti), _n(to)))
|
|
75
|
+
out.append(" cacheRead=%s cacheWrite=%s" % (_n(tcr), _n(tcw)))
|
|
76
|
+
out.append(_rule())
|
|
77
|
+
out.append("%-36s%10s%14s%12s" % ("model", "$ value", "in tok", "out tok"))
|
|
78
|
+
out.append(_rule())
|
|
79
|
+
for r in sorted(rows, key=lambda x: -cents(x)):
|
|
80
|
+
out.append("%-36s%10s%14s%12s" % (
|
|
81
|
+
r.get("modelIntent", "?"),
|
|
82
|
+
format(cents(r) / 100.0, ",.2f"),
|
|
83
|
+
_n(_i(r, "inputTokens")),
|
|
84
|
+
_n(_i(r, "outputTokens")),
|
|
85
|
+
))
|
|
86
|
+
return "\n".join(out)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --- per-day breakdown (from events) --------------------------------------
|
|
90
|
+
def render_by_day(events, meta):
|
|
91
|
+
days = OrderedDict()
|
|
92
|
+
for ev in events:
|
|
93
|
+
row = event_row(ev)
|
|
94
|
+
d = row["date"] or "unknown"
|
|
95
|
+
b = days.setdefault(d, {"n": 0, "in": 0, "out": 0, "cents": 0.0})
|
|
96
|
+
b["n"] += 1
|
|
97
|
+
b["in"] += row["input_tokens"]
|
|
98
|
+
b["out"] += row["output_tokens"]
|
|
99
|
+
b["cents"] += row["value_cents"]
|
|
100
|
+
|
|
101
|
+
out = ["=" * 78]
|
|
102
|
+
out.append("CURSOR USAGE BY DAY | %s | %s -> %s"
|
|
103
|
+
% (meta["email"], meta["start"], meta["end"]))
|
|
104
|
+
out.append("=" * 78)
|
|
105
|
+
out.append("%-12s%9s%12s%15s%13s" % ("date", "events", "$ value", "in tok", "out tok"))
|
|
106
|
+
out.append(_rule())
|
|
107
|
+
tot = {"n": 0, "in": 0, "out": 0, "cents": 0.0}
|
|
108
|
+
for d in sorted(days):
|
|
109
|
+
b = days[d]
|
|
110
|
+
out.append("%-12s%9s%12s%15s%13s" % (
|
|
111
|
+
d, _n(b["n"]), format(b["cents"] / 100.0, ",.2f"), _n(b["in"]), _n(b["out"])))
|
|
112
|
+
for k in tot:
|
|
113
|
+
tot[k] += b[k]
|
|
114
|
+
out.append(_rule())
|
|
115
|
+
out.append("%-12s%9s%12s%15s%13s" % (
|
|
116
|
+
"TOTAL", _n(tot["n"]), format(tot["cents"] / 100.0, ",.2f"),
|
|
117
|
+
_n(tot["in"]), _n(tot["out"])))
|
|
118
|
+
return "\n".join(out)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# --- CSV ------------------------------------------------------------------
|
|
122
|
+
def write_csv(events, path):
|
|
123
|
+
rows = [event_row(e) for e in events]
|
|
124
|
+
rows.sort(key=lambda r: r["timestamp_ms"])
|
|
125
|
+
fields = list(event_row({}).keys())
|
|
126
|
+
with open(path, "w", newline="", encoding="utf-8") as fh:
|
|
127
|
+
writer = csv.DictWriter(fh, fieldnames=fields)
|
|
128
|
+
writer.writeheader()
|
|
129
|
+
writer.writerows(rows)
|
|
130
|
+
return len(rows)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def to_iso(ms):
|
|
134
|
+
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).strftime("%Y-%m-%d")
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cursor-usage
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cross-platform CLI to read your Cursor (cursor.com) usage, spend, and per-event logs.
|
|
5
|
+
Author: cursor-usage contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/javaisbetterthanpython/cursor-usage
|
|
8
|
+
Project-URL: Issues, https://github.com/javaisbetterthanpython/cursor-usage/issues
|
|
9
|
+
Keywords: cursor,usage,spend,cli,ai,tokens,llm
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Utilities
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Provides-Extra: keyring
|
|
21
|
+
Requires-Dist: keyring>=24; extra == "keyring"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
<h1 align="center">๐ cursor-usage</h1>
|
|
25
|
+
|
|
26
|
+
<p align="center">
|
|
27
|
+
<b>See your <a href="https://cursor.com">Cursor</a> usage, spend, and per-event logs โ right from your terminal.</b>
|
|
28
|
+
</p>
|
|
29
|
+
|
|
30
|
+
<p align="center">
|
|
31
|
+
<img alt="Python" src="https://img.shields.io/badge/python-3.8%2B-blue">
|
|
32
|
+
<img alt="Platforms" src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey">
|
|
33
|
+
<img alt="Dependencies" src="https://img.shields.io/badge/dependencies-zero-brightgreen">
|
|
34
|
+
<img alt="License" src="https://img.shields.io/badge/license-MIT-green">
|
|
35
|
+
</p>
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
Cursor shows your usage on its web dashboard, but there's no official way to get
|
|
40
|
+
it from the command line. **`cursor-usage` gives you that** โ a clean summary, a
|
|
41
|
+
per-day breakdown, and a full per-event CSV export โ using the session your
|
|
42
|
+
Cursor app already has. No API key to manage, nothing to configure.
|
|
43
|
+
|
|
44
|
+
```text
|
|
45
|
+
==============================================================================
|
|
46
|
+
CURSOR USAGE | you@example.com | 2026-06-04 -> 2026-06-09
|
|
47
|
+
==============================================================================
|
|
48
|
+
Included value used : $875.81 (compute consumed; included in plan)
|
|
49
|
+
Tokens in=126,033,860 out=18,989,068
|
|
50
|
+
cacheRead=2,492,781,450 cacheWrite=8,322,520
|
|
51
|
+
------------------------------------------------------------------------------
|
|
52
|
+
model $ value in tok out tok
|
|
53
|
+
------------------------------------------------------------------------------
|
|
54
|
+
composer-2.5 460.14 95,638,983 13,315,470
|
|
55
|
+
claude-4.6-opus-high 233.71 786,434 1,059,451
|
|
56
|
+
gemini-3.5-flash 45.77 16,097,065 1,075,904
|
|
57
|
+
gpt-5.4-high 43.65 4,069,043 1,041,967
|
|
58
|
+
...
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## โจ Features
|
|
62
|
+
|
|
63
|
+
- **One command, real numbers** โ per-model tokens and compute value for the
|
|
64
|
+
current billing month.
|
|
65
|
+
- **๐
Per-day breakdown** โ `--by-day` shows how much you burned each day.
|
|
66
|
+
- **๐งพ CSV export** โ `--csv` dumps every usage event (timestamp, model, tokens,
|
|
67
|
+
cost) for your own spreadsheets and charts.
|
|
68
|
+
- **๐ Cross-platform** โ macOS, Linux, and Windows.
|
|
69
|
+
- **๐ Zero dependencies** โ pure Python standard library.
|
|
70
|
+
- **๐ Local & private** โ reads the session your Cursor app already stored;
|
|
71
|
+
talks only to `cursor.com`. No telemetry, no third parties.
|
|
72
|
+
|
|
73
|
+
## ๐ Quickstart
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install cursor-usage # or: pip install . from a clone
|
|
77
|
+
cursor-usage # summary for the current billing month
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
That's it โ if you're signed in to Cursor on this machine, it just works.
|
|
81
|
+
|
|
82
|
+
## ๐งโ๐ป Usage
|
|
83
|
+
|
|
84
|
+
| Command | What it does |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `cursor-usage` | Summary for the current billing month |
|
|
87
|
+
| `cursor-usage --by-day` | Add a per-day breakdown |
|
|
88
|
+
| `cursor-usage --csv usage.csv` | Export every usage event to CSV |
|
|
89
|
+
| `cursor-usage --days 7` | Window: the last 7 days |
|
|
90
|
+
| `cursor-usage --month 2026-05` | Window: a specific month |
|
|
91
|
+
| `cursor-usage --start 2026-06-01 --end 2026-06-07` | Window: an explicit range |
|
|
92
|
+
| `cursor-usage --json` | Raw aggregated JSON (for scripting) |
|
|
93
|
+
| `cursor-usage -v` | Also print which session source was used |
|
|
94
|
+
|
|
95
|
+
Flags combine โ e.g. `cursor-usage --by-day --csv june.csv --month 2026-06`.
|
|
96
|
+
|
|
97
|
+
<details>
|
|
98
|
+
<summary><b>๐
Example: <code>--by-day</code></b></summary>
|
|
99
|
+
|
|
100
|
+
```text
|
|
101
|
+
==============================================================================
|
|
102
|
+
CURSOR USAGE BY DAY | you@example.com | 2026-06-02 -> 2026-06-09
|
|
103
|
+
==============================================================================
|
|
104
|
+
date events $ value in tok out tok
|
|
105
|
+
------------------------------------------------------------------------------
|
|
106
|
+
2026-06-04 433 324.46 38,602,275 6,080,617
|
|
107
|
+
2026-06-05 368 252.58 40,616,543 6,469,309
|
|
108
|
+
2026-06-06 416 121.06 29,686,247 4,497,010
|
|
109
|
+
------------------------------------------------------------------------------
|
|
110
|
+
TOTAL 1,661 929.46 128,631,599 20,101,078
|
|
111
|
+
```
|
|
112
|
+
</details>
|
|
113
|
+
|
|
114
|
+
<details>
|
|
115
|
+
<summary><b>๐งพ CSV columns</b></summary>
|
|
116
|
+
|
|
117
|
+
`datetime_local, timestamp_ms, date, model, kind, input_tokens, output_tokens,
|
|
118
|
+
cache_read_tokens, cache_write_tokens, value_cents, charged_cents,
|
|
119
|
+
requests_costs, is_headless, owning_user` โ one row per usage event, sorted by
|
|
120
|
+
time.
|
|
121
|
+
</details>
|
|
122
|
+
|
|
123
|
+
## ๐ค How it works
|
|
124
|
+
|
|
125
|
+
A Cursor **API key** (`crsr_โฆ`) can't read usage โ that data lives behind your
|
|
126
|
+
web **session**, the same one your browser/app uses on `cursor.com`. This tool
|
|
127
|
+
finds that session locally and asks Cursor's dashboard API for your numbers.
|
|
128
|
+
|
|
129
|
+
It looks for the session in this order (all **local-only**):
|
|
130
|
+
|
|
131
|
+
1. `CURSOR_SESSION_TOKEN` environment variable (manual override)
|
|
132
|
+
2. macOS Keychain (written by the `cursor-agent` CLI)
|
|
133
|
+
3. Your OS keyring, if the optional `keyring` package is installed
|
|
134
|
+
4. The Cursor app's local state database (works on every OS)
|
|
135
|
+
|
|
136
|
+
If it can't find one, sign in to the Cursor app and run it again.
|
|
137
|
+
|
|
138
|
+
<details>
|
|
139
|
+
<summary><b>๐ Where exactly the session lives (per OS)</b></summary>
|
|
140
|
+
|
|
141
|
+
The Cursor IDE stores the session token in a small SQLite file
|
|
142
|
+
(`state.vscdb` โ key `cursorAuth/accessToken`), in the same place on every OS:
|
|
143
|
+
|
|
144
|
+
| OS | Path |
|
|
145
|
+
|---|---|
|
|
146
|
+
| macOS | `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` |
|
|
147
|
+
| Linux | `~/.config/Cursor/User/globalStorage/state.vscdb` |
|
|
148
|
+
| Windows | `%APPDATA%\Cursor\User\globalStorage\state.vscdb` |
|
|
149
|
+
|
|
150
|
+
Want the full reverse-engineering story (and a recipe to rebuild this tool)? See
|
|
151
|
+
**[docs/HOW_THIS_WAS_BUILT.md](docs/HOW_THIS_WAS_BUILT.md)**.
|
|
152
|
+
</details>
|
|
153
|
+
|
|
154
|
+
### Manual override
|
|
155
|
+
|
|
156
|
+
On any OS you can skip auto-detection entirely:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
# cursor.com โ DevTools โ Application โ Cookies โ copy WorkosCursorSessionToken
|
|
160
|
+
export CURSOR_SESSION_TOKEN='user_โฆ::eyJhbGciโฆ'
|
|
161
|
+
cursor-usage
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## ๐ Privacy & security
|
|
165
|
+
|
|
166
|
+
- The tool only **reads** your existing local session โ it never writes,
|
|
167
|
+
refreshes, or sends it anywhere except `cursor.com`.
|
|
168
|
+
- **No telemetry. No third-party calls.**
|
|
169
|
+
- CSV exports contain your own usage data; they're git-ignored by default so you
|
|
170
|
+
don't commit them by accident.
|
|
171
|
+
|
|
172
|
+
## โ ๏ธ Good to know
|
|
173
|
+
|
|
174
|
+
- **`$ value` is compute consumed, not money owed.** On plans where usage-based
|
|
175
|
+
pricing is off, your bill is just the flat subscription โ these figures show
|
|
176
|
+
the value of the compute included in your plan.
|
|
177
|
+
- This uses Cursor's **internal, undocumented** dashboard API. It works great
|
|
178
|
+
today, but Cursor could change it at any time. If something breaks, please open
|
|
179
|
+
an issue.
|
|
180
|
+
- If your session has expired, sign back in to Cursor and run the command again.
|
|
181
|
+
|
|
182
|
+
## ๐ ๏ธ Install options
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
pip install cursor-usage # from PyPI (once published)
|
|
186
|
+
pip install . # from a local clone
|
|
187
|
+
pip install "cursor-usage[keyring]" # + OS-keyring lookup on Linux/Windows
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Requires Python 3.8+.
|
|
191
|
+
|
|
192
|
+
## ๐ค Contributing
|
|
193
|
+
|
|
194
|
+
Issues and PRs are welcome. Run the tests with:
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
pip install pytest && pytest -q
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## ๐ License
|
|
201
|
+
|
|
202
|
+
[MIT](LICENSE) โ do whatever you like.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
cursor_usage/__init__.py,sha256=BzumsGS8tLLc5jmkNughJ75hqeGiDNmWyE7wDPUq77E,107
|
|
2
|
+
cursor_usage/__main__.py,sha256=4JMK66Wj4uLZTKbF-sT3LAxOsr6buig77PmOkJCRRxw,83
|
|
3
|
+
cursor_usage/api.py,sha256=dm1p7JBEDrB9sxIwL0LzilB4FjXs6_gJbaa6XCDlnZE,3853
|
|
4
|
+
cursor_usage/auth.py,sha256=BvNdntC9Svpj1TbIzsO3u12Hizwk1A8DnoU457ksDSc,6615
|
|
5
|
+
cursor_usage/cli.py,sha256=4rMOsL_BclElj8jKXAJExdWk5I6dt0WaogUaFi19T6o,5018
|
|
6
|
+
cursor_usage/report.py,sha256=Ii6Tf2atfzx6XhYy6H_6DFtdXYUCKhXS7MZafRTE0ME,4725
|
|
7
|
+
cursor_usage-0.1.0.dist-info/licenses/LICENSE,sha256=QPMxh-V7v3qMLQLCYqWtJ_UW7L9wiYK709SGi285LJM,1082
|
|
8
|
+
cursor_usage-0.1.0.dist-info/METADATA,sha256=0eIJZHztrsfSWKaK9YUsWiZbaXFxUFkRi9yrGAsQfbM,8027
|
|
9
|
+
cursor_usage-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
cursor_usage-0.1.0.dist-info/entry_points.txt,sha256=CUDRlZjULmG8yccbcKMy6rWurDgYGvwQtHxHhVevYVQ,55
|
|
11
|
+
cursor_usage-0.1.0.dist-info/top_level.txt,sha256=bVAUdvpuBuSZWi-CowqosY8EmoHueGwrC3w79xjxOh4,13
|
|
12
|
+
cursor_usage-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cursor-usage contributors
|
|
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 @@
|
|
|
1
|
+
cursor_usage
|