@ipv9/tokentracker-cli 0.39.0

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.
Files changed (192) hide show
  1. package/LICENSE +21 -0
  2. package/README.ja.md +464 -0
  3. package/README.ko.md +464 -0
  4. package/README.md +476 -0
  5. package/README.zh-CN.md +469 -0
  6. package/bin/tracker.js +52 -0
  7. package/dashboard/dist/app-icon.png +0 -0
  8. package/dashboard/dist/apple-touch-icon.png +0 -0
  9. package/dashboard/dist/assets/ActivityHeatmap-BxBGHY1z.js +42 -0
  10. package/dashboard/dist/assets/Card-GROxUc_6.js +1 -0
  11. package/dashboard/dist/assets/DashboardPage-BZ5VFoBk.js +19 -0
  12. package/dashboard/dist/assets/DevicePage-CQts7ivB.js +1 -0
  13. package/dashboard/dist/assets/DialogTitle-DqFsk26U.js +1 -0
  14. package/dashboard/dist/assets/FadeIn-HBBXW_3q.js +1 -0
  15. package/dashboard/dist/assets/HeaderGithubStar-COD2HfaF.js +1 -0
  16. package/dashboard/dist/assets/IpCheckPage-Cn0l8-BQ.js +15 -0
  17. package/dashboard/dist/assets/LandingPage-CmV1BMU4.js +4356 -0
  18. package/dashboard/dist/assets/LeaderboardAvatar-CiIpWZwb.js +1 -0
  19. package/dashboard/dist/assets/LeaderboardPage-BU0mlR3g.js +6 -0
  20. package/dashboard/dist/assets/LeaderboardProfileModal-C49_3Xm9.js +72 -0
  21. package/dashboard/dist/assets/LeaderboardProfilePage-Bh1n0BAM.js +1 -0
  22. package/dashboard/dist/assets/LimitsPage-fZAmwLWT.js +2 -0
  23. package/dashboard/dist/assets/LocalOnlyNotice-D3rQdA-P.js +1 -0
  24. package/dashboard/dist/assets/LoginPage-enO4XP-c.js +1 -0
  25. package/dashboard/dist/assets/PopoverPopup-pitJEpN1.js +1 -0
  26. package/dashboard/dist/assets/Select-tT9x4ntD.js +1 -0
  27. package/dashboard/dist/assets/SelectItemText-C7F7eIm0.js +1 -0
  28. package/dashboard/dist/assets/SettingsPage-pDasZNU2.js +1 -0
  29. package/dashboard/dist/assets/SkillsPage-DEu7FY-2.js +1 -0
  30. package/dashboard/dist/assets/WidgetsPage-DGcO7rbp.js +1 -0
  31. package/dashboard/dist/assets/WrappedPage-D_1ID213.js +1 -0
  32. package/dashboard/dist/assets/agent-logos-DXQVg1xy.js +1 -0
  33. package/dashboard/dist/assets/arrow-up-right-DNLo92Ba.js +1 -0
  34. package/dashboard/dist/assets/download-DbBBBpJF.js +1 -0
  35. package/dashboard/dist/assets/geist-mono-cyrillic-400-normal-BPBWmzPh.woff +0 -0
  36. package/dashboard/dist/assets/geist-mono-cyrillic-400-normal-Ce5q_31Z.woff2 +0 -0
  37. package/dashboard/dist/assets/geist-mono-cyrillic-500-normal-CJBLNVQT.woff2 +0 -0
  38. package/dashboard/dist/assets/geist-mono-cyrillic-500-normal-mNhfPmgl.woff +0 -0
  39. package/dashboard/dist/assets/geist-mono-cyrillic-700-normal-DH5Q319x.woff +0 -0
  40. package/dashboard/dist/assets/geist-mono-cyrillic-700-normal-VCNRadI3.woff2 +0 -0
  41. package/dashboard/dist/assets/geist-mono-cyrillic-900-normal-BKC3PZkM.woff +0 -0
  42. package/dashboard/dist/assets/geist-mono-cyrillic-900-normal-DdrQ8KBw.woff2 +0 -0
  43. package/dashboard/dist/assets/geist-mono-latin-400-normal-CoULgQGM.woff +0 -0
  44. package/dashboard/dist/assets/geist-mono-latin-400-normal-LC9RFr9I.woff2 +0 -0
  45. package/dashboard/dist/assets/geist-mono-latin-500-normal-D3o2eNa9.woff2 +0 -0
  46. package/dashboard/dist/assets/geist-mono-latin-500-normal-DOxI7kZ4.woff +0 -0
  47. package/dashboard/dist/assets/geist-mono-latin-700-normal-D6izGJRP.woff2 +0 -0
  48. package/dashboard/dist/assets/geist-mono-latin-700-normal-QGw08Lff.woff +0 -0
  49. package/dashboard/dist/assets/geist-mono-latin-900-normal-CmoKXrdK.woff +0 -0
  50. package/dashboard/dist/assets/geist-mono-latin-900-normal-Cu5MFKsu.woff2 +0 -0
  51. package/dashboard/dist/assets/geist-mono-latin-ext-400-normal-Cgks_Qgx.woff2 +0 -0
  52. package/dashboard/dist/assets/geist-mono-latin-ext-400-normal-CxNRRMGd.woff +0 -0
  53. package/dashboard/dist/assets/geist-mono-latin-ext-500-normal-CQcGuCNt.woff2 +0 -0
  54. package/dashboard/dist/assets/geist-mono-latin-ext-500-normal-diTenJ8L.woff +0 -0
  55. package/dashboard/dist/assets/geist-mono-latin-ext-700-normal-BX9f1BHp.woff +0 -0
  56. package/dashboard/dist/assets/geist-mono-latin-ext-700-normal-YOllDaLV.woff2 +0 -0
  57. package/dashboard/dist/assets/geist-mono-latin-ext-900-normal-Br6WNUGb.woff +0 -0
  58. package/dashboard/dist/assets/geist-mono-latin-ext-900-normal-CrmBTzU2.woff2 +0 -0
  59. package/dashboard/dist/assets/index-BeoRn2gJ.js +2 -0
  60. package/dashboard/dist/assets/info-Bh5qTz6k.js +1 -0
  61. package/dashboard/dist/assets/main-BIY3Nj6I.js +969 -0
  62. package/dashboard/dist/assets/main-G_F9Iu_x.css +1 -0
  63. package/dashboard/dist/assets/use-limits-display-prefs-B4iJ-bBc.js +1 -0
  64. package/dashboard/dist/assets/use-native-settings-DHjPFDv8.js +1 -0
  65. package/dashboard/dist/assets/use-usage-limits-odqe9OY7.js +1 -0
  66. package/dashboard/dist/assets/useCurrency-BsSZiZMP.js +1 -0
  67. package/dashboard/dist/assets/useOpenInteractionType-BQhgq4rv.js +12 -0
  68. package/dashboard/dist/brand-logos/antigravity.svg +1 -0
  69. package/dashboard/dist/brand-logos/claude-code.svg +1 -0
  70. package/dashboard/dist/brand-logos/codebuddy.svg +1 -0
  71. package/dashboard/dist/brand-logos/codex.svg +15 -0
  72. package/dashboard/dist/brand-logos/copilot.svg +1 -0
  73. package/dashboard/dist/brand-logos/cursor.svg +1 -0
  74. package/dashboard/dist/brand-logos/gemini.svg +1 -0
  75. package/dashboard/dist/brand-logos/grok.svg +1 -0
  76. package/dashboard/dist/brand-logos/hermes.svg +1 -0
  77. package/dashboard/dist/brand-logos/kilo.svg +4 -0
  78. package/dashboard/dist/brand-logos/kimi.svg +1 -0
  79. package/dashboard/dist/brand-logos/kiro.svg +1 -0
  80. package/dashboard/dist/brand-logos/openclaw.svg +22 -0
  81. package/dashboard/dist/brand-logos/opencode.svg +3 -0
  82. package/dashboard/dist/clawd/happy.svg +161 -0
  83. package/dashboard/dist/clawd/idle/collapse.svg +101 -0
  84. package/dashboard/dist/clawd/idle/doze.svg +72 -0
  85. package/dashboard/dist/clawd/idle/follow.svg +64 -0
  86. package/dashboard/dist/clawd/idle/living.svg +196 -0
  87. package/dashboard/dist/clawd/idle/look.svg +115 -0
  88. package/dashboard/dist/clawd/idle/yawn.svg +158 -0
  89. package/dashboard/dist/clawd/mini/alert.svg +129 -0
  90. package/dashboard/dist/clawd/mini/crabwalk.svg +76 -0
  91. package/dashboard/dist/clawd/mini/enter-sleep.svg +65 -0
  92. package/dashboard/dist/clawd/mini/enter.svg +115 -0
  93. package/dashboard/dist/clawd/mini/happy.svg +124 -0
  94. package/dashboard/dist/clawd/mini/idle-tight.svg +15 -0
  95. package/dashboard/dist/clawd/mini/idle.svg +83 -0
  96. package/dashboard/dist/clawd/mini/peek.svg +82 -0
  97. package/dashboard/dist/clawd/mini/sleep.svg +112 -0
  98. package/dashboard/dist/clawd/react/double.svg +108 -0
  99. package/dashboard/dist/clawd/react/drag.svg +102 -0
  100. package/dashboard/dist/clawd/react/left.svg +102 -0
  101. package/dashboard/dist/clawd/react/right.svg +102 -0
  102. package/dashboard/dist/clawd/sleep/collapse-sleep.svg +247 -0
  103. package/dashboard/dist/clawd/sleep/sleeping.svg +118 -0
  104. package/dashboard/dist/clawd/sleep/wake.svg +277 -0
  105. package/dashboard/dist/clawd/static-base.svg +21 -0
  106. package/dashboard/dist/clawd/status/disconnected.svg +136 -0
  107. package/dashboard/dist/clawd/status/error.svg +94 -0
  108. package/dashboard/dist/clawd/status/notification.svg +134 -0
  109. package/dashboard/dist/clawd/working/building.svg +231 -0
  110. package/dashboard/dist/clawd/working/carrying.svg +107 -0
  111. package/dashboard/dist/clawd/working/conducting.svg +118 -0
  112. package/dashboard/dist/clawd/working/confused.svg +113 -0
  113. package/dashboard/dist/clawd/working/debugger.svg +91 -0
  114. package/dashboard/dist/clawd/working/juggling.svg +82 -0
  115. package/dashboard/dist/clawd/working/overheated.svg +75 -0
  116. package/dashboard/dist/clawd/working/pushing.svg +125 -0
  117. package/dashboard/dist/clawd/working/sweeping.svg +79 -0
  118. package/dashboard/dist/clawd/working/thinking.svg +119 -0
  119. package/dashboard/dist/clawd/working/typing.svg +153 -0
  120. package/dashboard/dist/clawd/working/ultrathink.svg +166 -0
  121. package/dashboard/dist/clawd/working/wizard.svg +98 -0
  122. package/dashboard/dist/dashboard-dark.png +0 -0
  123. package/dashboard/dist/favicon-16.png +0 -0
  124. package/dashboard/dist/favicon-32.png +0 -0
  125. package/dashboard/dist/favicon.ico +0 -0
  126. package/dashboard/dist/feed.xml +34 -0
  127. package/dashboard/dist/icon-192.png +0 -0
  128. package/dashboard/dist/icon-512.png +0 -0
  129. package/dashboard/dist/index.html +355 -0
  130. package/dashboard/dist/llms.txt +53 -0
  131. package/dashboard/dist/neon_bg.png +0 -0
  132. package/dashboard/dist/robots.txt +7 -0
  133. package/dashboard/dist/share.html +140 -0
  134. package/dashboard/dist/sitemap.xml +27 -0
  135. package/dashboard/dist/widgets-overview.png +0 -0
  136. package/package.json +78 -0
  137. package/scripts/install-local-service.sh +140 -0
  138. package/scripts/uninstall-local-service.sh +21 -0
  139. package/src/cli.js +95 -0
  140. package/src/commands/device-login.js +161 -0
  141. package/src/commands/diagnostics.js +38 -0
  142. package/src/commands/doctor.js +96 -0
  143. package/src/commands/init.js +1195 -0
  144. package/src/commands/serve.js +351 -0
  145. package/src/commands/status.js +609 -0
  146. package/src/commands/sync.js +2285 -0
  147. package/src/commands/uninstall.js +189 -0
  148. package/src/commands/wrapped.js +150 -0
  149. package/src/lib/antigravity-paths.js +48 -0
  150. package/src/lib/browser-auth.js +281 -0
  151. package/src/lib/categorizer-utils.js +232 -0
  152. package/src/lib/claude-categorizer.js +1319 -0
  153. package/src/lib/claude-config.js +204 -0
  154. package/src/lib/cli-ui.js +179 -0
  155. package/src/lib/codex-config.js +350 -0
  156. package/src/lib/codex-context-breakdown.js +400 -0
  157. package/src/lib/codex-rollout-parser.js +623 -0
  158. package/src/lib/codex-token-refresh.js +112 -0
  159. package/src/lib/cursor-config.js +425 -0
  160. package/src/lib/debug-flags.js +7 -0
  161. package/src/lib/diagnostics.js +272 -0
  162. package/src/lib/doctor.js +338 -0
  163. package/src/lib/fs.js +91 -0
  164. package/src/lib/gemini-config.js +288 -0
  165. package/src/lib/grok-hook.js +351 -0
  166. package/src/lib/init-flow.js +42 -0
  167. package/src/lib/local-api.js +1861 -0
  168. package/src/lib/openclaw-hook.js +420 -0
  169. package/src/lib/openclaw-session-plugin.js +520 -0
  170. package/src/lib/opencode-config.js +99 -0
  171. package/src/lib/passive-mode.js +185 -0
  172. package/src/lib/pricing/curated-overrides.json +79 -0
  173. package/src/lib/pricing/index.js +143 -0
  174. package/src/lib/pricing/litellm-fetcher.js +172 -0
  175. package/src/lib/pricing/matcher.js +255 -0
  176. package/src/lib/pricing/seed-snapshot.json +1 -0
  177. package/src/lib/progress.js +76 -0
  178. package/src/lib/project-usage-purge.js +106 -0
  179. package/src/lib/prompt.js +24 -0
  180. package/src/lib/proxy-env.js +131 -0
  181. package/src/lib/rollout.js +8442 -0
  182. package/src/lib/runtime-config.js +122 -0
  183. package/src/lib/skill-usage.js +267 -0
  184. package/src/lib/skills-manager.js +1127 -0
  185. package/src/lib/source-metadata.js +48 -0
  186. package/src/lib/sqlite-reader.js +136 -0
  187. package/src/lib/static-server.js +59 -0
  188. package/src/lib/subscriptions.js +453 -0
  189. package/src/lib/tracker-paths.js +16 -0
  190. package/src/lib/upload-throttle.js +148 -0
  191. package/src/lib/usage-limits.js +1806 -0
  192. package/src/lib/wrapped-aggregator.js +225 -0
@@ -0,0 +1,112 @@
1
+ const fs = require("node:fs");
2
+ const path = require("node:path");
3
+
4
+ // Public Codex OAuth client. Same id used by the official `codex` CLI. Aligned with
5
+ // steipete/CodexBar's CodexTokenRefresher.swift — neither is sensitive (it's a public client).
6
+ const REFRESH_ENDPOINT = "https://auth.openai.com/oauth/token";
7
+ const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
8
+
9
+ // CodexBar refreshes when last_refresh > 8 days. We mirror that — actual TTL is shorter so
10
+ // we always refresh before the access token can expire while users have the app running.
11
+ const REFRESH_THRESHOLD_MS = 8 * 24 * 60 * 60 * 1000;
12
+
13
+ function isTokenStale(lastRefreshIso, nowMs = Date.now()) {
14
+ if (!lastRefreshIso) return true;
15
+ const ts = Date.parse(lastRefreshIso);
16
+ if (!Number.isFinite(ts)) return true;
17
+ return nowMs - ts > REFRESH_THRESHOLD_MS;
18
+ }
19
+
20
+ async function refreshCodexTokens({ refreshToken, fetchImpl = fetch }) {
21
+ if (typeof refreshToken !== "string" || refreshToken.length === 0) {
22
+ const err = new Error("Codex refresh skipped: no refresh_token in auth.json");
23
+ err.code = "NO_REFRESH_TOKEN";
24
+ throw err;
25
+ }
26
+
27
+ const res = await fetchImpl(REFRESH_ENDPOINT, {
28
+ method: "POST",
29
+ headers: {
30
+ "Content-Type": "application/json",
31
+ Accept: "application/json",
32
+ },
33
+ body: JSON.stringify({
34
+ client_id: CODEX_CLIENT_ID,
35
+ grant_type: "refresh_token",
36
+ refresh_token: refreshToken,
37
+ scope: "openid profile email",
38
+ }),
39
+ });
40
+
41
+ if (res.status === 401) {
42
+ let openaiErrorCode = null;
43
+ try {
44
+ const body = await res.json();
45
+ openaiErrorCode =
46
+ (body && typeof body.error === "object" && body.error.code) ||
47
+ (typeof body?.error === "string" ? body.error : null) ||
48
+ body?.code ||
49
+ null;
50
+ } catch (_e) {
51
+ // Ignore parse failure — surface the generic reason.
52
+ }
53
+ const err = new Error(
54
+ "Codex refresh token expired or revoked. Run `codex` to re-authenticate.",
55
+ );
56
+ err.code = "REFRESH_TOKEN_EXPIRED";
57
+ err.openaiErrorCode = openaiErrorCode;
58
+ throw err;
59
+ }
60
+
61
+ if (!res.ok) {
62
+ const err = new Error(`Codex token refresh failed: ${res.status}`);
63
+ err.code = "REFRESH_HTTP_ERROR";
64
+ err.status = res.status;
65
+ throw err;
66
+ }
67
+
68
+ const body = await res.json();
69
+ if (!body || typeof body.access_token !== "string" || body.access_token.length === 0) {
70
+ const err = new Error("Codex token refresh response missing access_token");
71
+ err.code = "REFRESH_INVALID_RESPONSE";
72
+ throw err;
73
+ }
74
+
75
+ return {
76
+ access_token: body.access_token,
77
+ refresh_token:
78
+ typeof body.refresh_token === "string" && body.refresh_token.length > 0
79
+ ? body.refresh_token
80
+ : refreshToken,
81
+ id_token: typeof body.id_token === "string" ? body.id_token : null,
82
+ };
83
+ }
84
+
85
+ // Atomic write so a process kill mid-write doesn't corrupt auth.json (which would force the
86
+ // user to re-run `codex` login).
87
+ async function persistRefreshedAuth(authPath, currentAuth, newTokens) {
88
+ const merged = {
89
+ ...currentAuth,
90
+ tokens: {
91
+ ...(currentAuth.tokens || {}),
92
+ access_token: newTokens.access_token,
93
+ refresh_token: newTokens.refresh_token,
94
+ id_token: newTokens.id_token || currentAuth?.tokens?.id_token || null,
95
+ },
96
+ last_refresh: new Date().toISOString(),
97
+ };
98
+
99
+ const tmp = `${authPath}.tmp.${process.pid}.${Date.now()}`;
100
+ await fs.promises.writeFile(tmp, JSON.stringify(merged, null, 2), { mode: 0o600 });
101
+ await fs.promises.rename(tmp, authPath);
102
+ return merged;
103
+ }
104
+
105
+ module.exports = {
106
+ REFRESH_ENDPOINT,
107
+ CODEX_CLIENT_ID,
108
+ REFRESH_THRESHOLD_MS,
109
+ isTokenStale,
110
+ refreshCodexTokens,
111
+ persistRefreshedAuth,
112
+ };
@@ -0,0 +1,425 @@
1
+ const os = require("node:os");
2
+ const path = require("node:path");
3
+ const fs = require("node:fs");
4
+ const https = require("node:https");
5
+
6
+ const { readJson } = require("./fs");
7
+ const { readSqliteFirstValue } = require("./sqlite-reader");
8
+
9
+ // ── Path resolution ──
10
+
11
+ function resolveCursorPaths({ home, platform = process.platform, env = process.env } = {}) {
12
+ const h = home || os.homedir();
13
+ const pathForPlatform = platform === "win32" ? path.win32 : path.posix;
14
+ let appDir;
15
+ if (platform === "darwin") {
16
+ appDir = pathForPlatform.join(h, "Library", "Application Support", "Cursor");
17
+ } else if (platform === "win32") {
18
+ const appData =
19
+ (typeof env.APPDATA === "string" && env.APPDATA.trim()) ||
20
+ pathForPlatform.join(h, "AppData", "Roaming");
21
+ appDir = pathForPlatform.join(appData, "Cursor");
22
+ } else {
23
+ const xdg =
24
+ (typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.trim()) ||
25
+ pathForPlatform.join(h, ".config");
26
+ appDir = pathForPlatform.join(xdg, "Cursor");
27
+ }
28
+ return {
29
+ appDir,
30
+ stateDbPath: pathForPlatform.join(appDir, "User", "globalStorage", "state.vscdb"),
31
+ cliConfigPath: pathForPlatform.join(h, ".cursor", "cli-config.json"),
32
+ };
33
+ }
34
+
35
+ function isCursorInstalled({ home, platform, env } = {}) {
36
+ const { appDir } = resolveCursorPaths({ home, platform, env });
37
+ try {
38
+ return fs.statSync(appDir).isDirectory();
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ // ── Auth token extraction ──
45
+
46
+ const CURSOR_ACCESS_TOKEN_SQL =
47
+ "SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken';";
48
+
49
+ function cursorDebugLog(message, env = process.env) {
50
+ const dbg = String((env && env.TOKENTRACKER_DEBUG) || "").toLowerCase();
51
+ if (dbg === "1" || dbg === "true") {
52
+ process.stderr.write(`[cursor] ${message}\n`);
53
+ }
54
+ }
55
+
56
+ function readCursorAccessTokenFromStateDb(stateDbPath, deps = {}) {
57
+ return readSqliteFirstValue(stateDbPath, CURSOR_ACCESS_TOKEN_SQL, "value", {
58
+ execFileSync: deps.execFileSync,
59
+ requireFn: deps.requireFn,
60
+ env: deps.env,
61
+ stderr: deps.stderr,
62
+ label: "Cursor",
63
+ timeout: 5000,
64
+ maxBuffer: 1024 * 1024,
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Extract Cursor session cookie from local SQLite + cli-config.json.
70
+ * Returns { cookie, userId } or null on failure.
71
+ *
72
+ * Cookie format: WorkosCursorSessionToken=<userId>%3A%3A<jwt>
73
+ * - JWT from state.vscdb → ItemTable → cursorAuth/accessToken
74
+ * - userId from cli-config.json → authInfo.authId
75
+ * - native Cursor email/password: "auth0|user_XXXXX" → "user_XXXXX"
76
+ * - Google sign-in via WorkOS: "google-oauth2|<numeric>" → kept verbatim
77
+ * - other WorkOS subjects: "github|…", "oidc|…" → kept verbatim
78
+ */
79
+ function extractCursorSessionToken({ home, platform, env, deps } = {}) {
80
+ const { stateDbPath, cliConfigPath } = resolveCursorPaths({ home, platform, env });
81
+
82
+ // 1. Extract JWT from SQLite
83
+ if (!fs.existsSync(stateDbPath)) {
84
+ cursorDebugLog(`Cursor state DB not found at ${stateDbPath}`, deps?.env);
85
+ return null;
86
+ }
87
+ const jwt = readCursorAccessTokenFromStateDb(stateDbPath, deps);
88
+ if (!jwt || jwt.length < 10) return null;
89
+
90
+ // 2. Extract userId — try cli-config.json first, fall back to JWT decode
91
+ let userId = extractUserIdFromCliConfig(cliConfigPath);
92
+ if (!userId) {
93
+ userId = extractUserIdFromJwt(jwt);
94
+ }
95
+ if (!userId) return null;
96
+
97
+ // 3. Build cookie
98
+ const cookie = `WorkosCursorSessionToken=${userId}%3A%3A${jwt}`;
99
+ return { cookie, userId };
100
+ }
101
+
102
+ // WorkOS OAuth subject prefixes Cursor accepts as-is in the session cookie.
103
+ // Verified against cursor.com/api/usage-summary (issue #88).
104
+ const WORKOS_OAUTH_SUBJECT_RE = /^(google-oauth2|github|oidc|auth0)\|[^|]+$/;
105
+
106
+ function normalizeCursorSubject(subject) {
107
+ if (!subject) return null;
108
+ // Native Cursor: "auth0|user_XXXXX" → strip provider prefix, return "user_XXXXX"
109
+ const native = subject.match(/\|(user_[A-Za-z0-9_]+)$/);
110
+ if (native) return native[1];
111
+ // WorkOS-bridged OAuth: keep the full "<provider>|<id>" subject
112
+ if (WORKOS_OAUTH_SUBJECT_RE.test(subject)) return subject;
113
+ return null;
114
+ }
115
+
116
+ function extractUserIdFromCliConfig(configPath) {
117
+ try {
118
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
119
+ return normalizeCursorSubject(config?.authInfo?.authId || "");
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ function extractUserIdFromJwt(jwt) {
126
+ try {
127
+ const parts = jwt.split(".");
128
+ if (parts.length !== 3) return null;
129
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString());
130
+ return normalizeCursorSubject(payload.sub || "");
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ // ── API client ──
137
+
138
+ const CURSOR_CSV_URL = "https://cursor.com/api/dashboard/export-usage-events-csv?strategy=tokens";
139
+ const CURSOR_SUMMARY_URL = "https://cursor.com/api/usage-summary";
140
+ const CURSOR_SOURCE_SCOPE = "account";
141
+
142
+ /**
143
+ * Fetch full usage CSV from Cursor API.
144
+ * Returns raw CSV string or throws on error.
145
+ */
146
+ function fetchCursorUsageCsv({ cookie, timeoutMs = 30000 }) {
147
+ return new Promise((resolve, reject) => {
148
+ const url = new URL(CURSOR_CSV_URL);
149
+ const req = https.request(
150
+ {
151
+ hostname: url.hostname,
152
+ path: url.pathname + url.search,
153
+ method: "GET",
154
+ headers: {
155
+ Accept: "*/*",
156
+ Cookie: cookie,
157
+ Referer: "https://www.cursor.com/settings",
158
+ "User-Agent":
159
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
160
+ },
161
+ timeout: timeoutMs,
162
+ },
163
+ (res) => {
164
+ if (res.statusCode === 401 || res.statusCode === 403) {
165
+ res.resume();
166
+ return reject(new Error("Cursor session expired — re-login in Cursor to refresh"));
167
+ }
168
+ if (res.statusCode === 308 || res.statusCode === 301 || res.statusCode === 302) {
169
+ // Follow redirect once
170
+ const location = res.headers.location;
171
+ res.resume();
172
+ if (!location) return reject(new Error(`Cursor API redirect without Location header`));
173
+ return fetchUrlRaw({ urlStr: location, cookie, timeoutMs }).then(resolve, reject);
174
+ }
175
+ if (res.statusCode !== 200) {
176
+ res.resume();
177
+ return reject(new Error(`Cursor API returned ${res.statusCode}`));
178
+ }
179
+ let data = "";
180
+ res.on("data", (chunk) => {
181
+ data += chunk;
182
+ });
183
+ res.on("end", () => resolve(data));
184
+ res.on("error", reject);
185
+ },
186
+ );
187
+ req.on("error", reject);
188
+ req.on("timeout", () => {
189
+ req.destroy();
190
+ reject(new Error("Cursor API request timed out"));
191
+ });
192
+ req.end();
193
+ });
194
+ }
195
+
196
+ /**
197
+ * Fetch Cursor usage summary JSON.
198
+ * Returns parsed JSON body or throws on error.
199
+ */
200
+ function fetchCursorUsageSummary({ cookie, timeoutMs = 30000, fetchImpl = fetch }) {
201
+ return fetchImpl(CURSOR_SUMMARY_URL, {
202
+ method: "GET",
203
+ headers: {
204
+ Accept: "application/json",
205
+ Cookie: cookie,
206
+ Referer: "https://www.cursor.com/settings",
207
+ "User-Agent":
208
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
209
+ },
210
+ redirect: "follow",
211
+ signal: AbortSignal.timeout(timeoutMs),
212
+ }).then(async (res) => {
213
+ if (res.status === 401 || res.status === 403) {
214
+ throw new Error("Cursor session expired — re-login in Cursor to refresh");
215
+ }
216
+ if (!res.ok) {
217
+ throw new Error(`Cursor API returned ${res.status}`);
218
+ }
219
+ return res.json();
220
+ });
221
+ }
222
+
223
+ function fetchUrlRaw({ urlStr, cookie, timeoutMs }) {
224
+ return new Promise((resolve, reject) => {
225
+ const url = new URL(urlStr);
226
+ const req = https.request(
227
+ {
228
+ hostname: url.hostname,
229
+ path: url.pathname + url.search,
230
+ method: "GET",
231
+ headers: {
232
+ Accept: "*/*",
233
+ Cookie: cookie,
234
+ Referer: "https://www.cursor.com/settings",
235
+ "User-Agent":
236
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
237
+ },
238
+ timeout: timeoutMs,
239
+ },
240
+ (res) => {
241
+ if (res.statusCode !== 200) {
242
+ res.resume();
243
+ return reject(new Error(`Cursor API returned ${res.statusCode} from ${urlStr}`));
244
+ }
245
+ let data = "";
246
+ res.on("data", (chunk) => {
247
+ data += chunk;
248
+ });
249
+ res.on("end", () => resolve(data));
250
+ res.on("error", reject);
251
+ },
252
+ );
253
+ req.on("error", reject);
254
+ req.on("timeout", () => {
255
+ req.destroy();
256
+ reject(new Error("Cursor API request timed out"));
257
+ });
258
+ req.end();
259
+ });
260
+ }
261
+
262
+ // ── CSV parsing ──
263
+
264
+ /**
265
+ * Parse Cursor usage CSV into structured records.
266
+ *
267
+ * Column order has changed multiple times (e.g. new "Cloud Agent ID",
268
+ * "Automation ID" columns inserted before "Kind"). Resolve columns by
269
+ * header name instead of fixed index so the parser keeps working across
270
+ * future Cursor updates.
271
+ *
272
+ * Known required columns: Date, Model, Input (w/ Cache Write),
273
+ * Input (w/o Cache Write), Cache Read, Output Tokens, Total Tokens, Cost.
274
+ * Optional: Kind, Max Mode.
275
+ */
276
+ function parseCursorCsv(csvText) {
277
+ const lines = csvText.split("\n").filter((l) => l.trim().length > 0);
278
+ if (lines.length < 2) return [];
279
+
280
+ const headerFields = parseCsvLine(lines[0]).map((f) => stripQuotes(f));
281
+ const columnIndex = new Map();
282
+ for (let i = 0; i < headerFields.length; i++) {
283
+ columnIndex.set(headerFields[i], i);
284
+ }
285
+
286
+ const dateIdx = columnIndex.get("Date");
287
+ const modelIdx = columnIndex.get("Model");
288
+ const inputWithIdx = columnIndex.get("Input (w/ Cache Write)");
289
+ const inputWithoutIdx = columnIndex.get("Input (w/o Cache Write)");
290
+ const cacheReadIdx = columnIndex.get("Cache Read");
291
+ const outputIdx = columnIndex.get("Output Tokens");
292
+ const totalIdx = columnIndex.get("Total Tokens");
293
+ const costIdx = columnIndex.get("Cost");
294
+ const kindIdx = columnIndex.get("Kind");
295
+ const maxModeIdx = columnIndex.get("Max Mode");
296
+
297
+ const required = [dateIdx, modelIdx, inputWithIdx, inputWithoutIdx, cacheReadIdx, outputIdx, totalIdx, costIdx];
298
+ if (required.some((idx) => idx === undefined)) return [];
299
+
300
+ const minFields = Math.max(...required) + 1;
301
+
302
+ const records = [];
303
+ for (let i = 1; i < lines.length; i++) {
304
+ const fields = parseCsvLine(lines[i]);
305
+ if (!fields || fields.length < minFields) continue;
306
+
307
+ const inputWithCache = toNum(fields[inputWithIdx]);
308
+ const inputWithoutCache = toNum(fields[inputWithoutIdx]);
309
+ const record = {
310
+ date: stripQuotes(fields[dateIdx]),
311
+ kind: kindIdx !== undefined ? stripQuotes(fields[kindIdx]) : "unknown",
312
+ model: stripQuotes(fields[modelIdx]),
313
+ maxMode: maxModeIdx !== undefined ? stripQuotes(fields[maxModeIdx]) : "No",
314
+ sourceScope: CURSOR_SOURCE_SCOPE,
315
+ billableKind: isCursorBillableKind(kindIdx !== undefined ? fields[kindIdx] : "unknown")
316
+ ? "billable"
317
+ : "non_billable",
318
+ inputTokens: inputWithoutCache,
319
+ cacheWriteTokens: Math.max(0, inputWithCache - inputWithoutCache),
320
+ cacheReadTokens: toNum(fields[cacheReadIdx]),
321
+ outputTokens: toNum(fields[outputIdx]),
322
+ totalTokens: toNum(fields[totalIdx]),
323
+ cost: toFloat(fields[costIdx]),
324
+ };
325
+
326
+ if (record.totalTokens <= 0 && record.inputTokens <= 0 && record.outputTokens <= 0) continue;
327
+
328
+ records.push(record);
329
+ }
330
+
331
+ return records;
332
+ }
333
+
334
+ /**
335
+ * Normalize a Cursor CSV record to TokenTracker's standard token format.
336
+ */
337
+ function normalizeCursorUsage(record) {
338
+ const inputTokens = Math.max(0, Math.floor(record.inputTokens || 0));
339
+ const cacheWrite = Math.max(0, Math.floor(record.cacheWriteTokens || 0));
340
+ const cacheRead = Math.max(0, Math.floor(record.cacheReadTokens || 0));
341
+ const outputTokens = Math.max(0, Math.floor(record.outputTokens || 0));
342
+ const totalTokens = inputTokens + outputTokens + cacheWrite + cacheRead;
343
+ return {
344
+ input_tokens: inputTokens,
345
+ cached_input_tokens: cacheRead,
346
+ cache_creation_input_tokens: cacheWrite,
347
+ output_tokens: outputTokens,
348
+ reasoning_output_tokens: 0,
349
+ total_tokens: totalTokens,
350
+ // Usage tracking and billing are orthogonal: a Cursor Enterprise /
351
+ // Included-in-Pro request still consumes tokens even when the user
352
+ // pays nothing for them. Cost is computed independently from
353
+ // per-column tokens × MODEL_PRICING (see computeRowCost — it never
354
+ // reads this field), while the dashboard headline reads
355
+ // billable_total_tokens. Writing 0 here silently hides non-billable
356
+ // Cursor usage from the headline once any other source pushes the
357
+ // aggregate billable above 0 (see GitHub issue #106).
358
+ // isCursorBillableKind is retained for a future paid-vs-included
359
+ // breakdown via a separate is_billable field, not by overloading the
360
+ // token count.
361
+ billable_total_tokens: totalTokens,
362
+ };
363
+ }
364
+
365
+ function isCursorBillableKind(kind) {
366
+ const normalized = String(kind || "").trim().toLowerCase();
367
+ if (!normalized) return true;
368
+ if (normalized.includes("no charge")) return false;
369
+ if (normalized === "free") return false;
370
+ return true;
371
+ }
372
+
373
+ // ── CSV helpers ──
374
+
375
+ function parseCsvLine(line) {
376
+ const fields = [];
377
+ let current = "";
378
+ let inQuotes = false;
379
+ for (let i = 0; i < line.length; i++) {
380
+ const ch = line[i];
381
+ if (ch === '"') {
382
+ inQuotes = !inQuotes;
383
+ current += ch;
384
+ } else if (ch === "," && !inQuotes) {
385
+ fields.push(current.trim());
386
+ current = "";
387
+ } else {
388
+ current += ch;
389
+ }
390
+ }
391
+ fields.push(current.trim());
392
+ return fields;
393
+ }
394
+
395
+ function stripQuotes(s) {
396
+ if (!s) return "";
397
+ const trimmed = s.trim();
398
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
399
+ return trimmed.slice(1, -1);
400
+ }
401
+ return trimmed;
402
+ }
403
+
404
+ function toNum(s) {
405
+ const n = Number(stripQuotes(s));
406
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
407
+ }
408
+
409
+ function toFloat(s) {
410
+ const cleaned = stripQuotes(s).replace(/[$,]/g, "");
411
+ const n = Number(cleaned);
412
+ return Number.isFinite(n) ? n : 0;
413
+ }
414
+
415
+ module.exports = {
416
+ resolveCursorPaths,
417
+ isCursorInstalled,
418
+ readCursorAccessTokenFromStateDb,
419
+ extractCursorSessionToken,
420
+ fetchCursorUsageCsv,
421
+ fetchCursorUsageSummary,
422
+ parseCursorCsv,
423
+ isCursorBillableKind,
424
+ normalizeCursorUsage,
425
+ };
@@ -0,0 +1,7 @@
1
+ function stripDebugFlag(argv, env = process.env) {
2
+ const filtered = Array.isArray(argv) ? argv.filter((arg) => arg !== "--debug") : [];
3
+ const debugEnv = String(env?.TOKENTRACKER_DEBUG || "") === "1";
4
+ return { argv: filtered, debug: filtered.length !== (argv || []).length || debugEnv };
5
+ }
6
+
7
+ module.exports = { stripDebugFlag };