@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.
- package/LICENSE +21 -0
- package/README.ja.md +464 -0
- package/README.ko.md +464 -0
- package/README.md +476 -0
- package/README.zh-CN.md +469 -0
- package/bin/tracker.js +52 -0
- package/dashboard/dist/app-icon.png +0 -0
- package/dashboard/dist/apple-touch-icon.png +0 -0
- package/dashboard/dist/assets/ActivityHeatmap-BxBGHY1z.js +42 -0
- package/dashboard/dist/assets/Card-GROxUc_6.js +1 -0
- package/dashboard/dist/assets/DashboardPage-BZ5VFoBk.js +19 -0
- package/dashboard/dist/assets/DevicePage-CQts7ivB.js +1 -0
- package/dashboard/dist/assets/DialogTitle-DqFsk26U.js +1 -0
- package/dashboard/dist/assets/FadeIn-HBBXW_3q.js +1 -0
- package/dashboard/dist/assets/HeaderGithubStar-COD2HfaF.js +1 -0
- package/dashboard/dist/assets/IpCheckPage-Cn0l8-BQ.js +15 -0
- package/dashboard/dist/assets/LandingPage-CmV1BMU4.js +4356 -0
- package/dashboard/dist/assets/LeaderboardAvatar-CiIpWZwb.js +1 -0
- package/dashboard/dist/assets/LeaderboardPage-BU0mlR3g.js +6 -0
- package/dashboard/dist/assets/LeaderboardProfileModal-C49_3Xm9.js +72 -0
- package/dashboard/dist/assets/LeaderboardProfilePage-Bh1n0BAM.js +1 -0
- package/dashboard/dist/assets/LimitsPage-fZAmwLWT.js +2 -0
- package/dashboard/dist/assets/LocalOnlyNotice-D3rQdA-P.js +1 -0
- package/dashboard/dist/assets/LoginPage-enO4XP-c.js +1 -0
- package/dashboard/dist/assets/PopoverPopup-pitJEpN1.js +1 -0
- package/dashboard/dist/assets/Select-tT9x4ntD.js +1 -0
- package/dashboard/dist/assets/SelectItemText-C7F7eIm0.js +1 -0
- package/dashboard/dist/assets/SettingsPage-pDasZNU2.js +1 -0
- package/dashboard/dist/assets/SkillsPage-DEu7FY-2.js +1 -0
- package/dashboard/dist/assets/WidgetsPage-DGcO7rbp.js +1 -0
- package/dashboard/dist/assets/WrappedPage-D_1ID213.js +1 -0
- package/dashboard/dist/assets/agent-logos-DXQVg1xy.js +1 -0
- package/dashboard/dist/assets/arrow-up-right-DNLo92Ba.js +1 -0
- package/dashboard/dist/assets/download-DbBBBpJF.js +1 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-400-normal-BPBWmzPh.woff +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-400-normal-Ce5q_31Z.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-500-normal-CJBLNVQT.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-500-normal-mNhfPmgl.woff +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-700-normal-DH5Q319x.woff +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-700-normal-VCNRadI3.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-900-normal-BKC3PZkM.woff +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-900-normal-DdrQ8KBw.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-400-normal-CoULgQGM.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-400-normal-LC9RFr9I.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-500-normal-D3o2eNa9.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-500-normal-DOxI7kZ4.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-700-normal-D6izGJRP.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-700-normal-QGw08Lff.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-900-normal-CmoKXrdK.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-900-normal-Cu5MFKsu.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-400-normal-Cgks_Qgx.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-400-normal-CxNRRMGd.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-500-normal-CQcGuCNt.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-500-normal-diTenJ8L.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-700-normal-BX9f1BHp.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-700-normal-YOllDaLV.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-900-normal-Br6WNUGb.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-900-normal-CrmBTzU2.woff2 +0 -0
- package/dashboard/dist/assets/index-BeoRn2gJ.js +2 -0
- package/dashboard/dist/assets/info-Bh5qTz6k.js +1 -0
- package/dashboard/dist/assets/main-BIY3Nj6I.js +969 -0
- package/dashboard/dist/assets/main-G_F9Iu_x.css +1 -0
- package/dashboard/dist/assets/use-limits-display-prefs-B4iJ-bBc.js +1 -0
- package/dashboard/dist/assets/use-native-settings-DHjPFDv8.js +1 -0
- package/dashboard/dist/assets/use-usage-limits-odqe9OY7.js +1 -0
- package/dashboard/dist/assets/useCurrency-BsSZiZMP.js +1 -0
- package/dashboard/dist/assets/useOpenInteractionType-BQhgq4rv.js +12 -0
- package/dashboard/dist/brand-logos/antigravity.svg +1 -0
- package/dashboard/dist/brand-logos/claude-code.svg +1 -0
- package/dashboard/dist/brand-logos/codebuddy.svg +1 -0
- package/dashboard/dist/brand-logos/codex.svg +15 -0
- package/dashboard/dist/brand-logos/copilot.svg +1 -0
- package/dashboard/dist/brand-logos/cursor.svg +1 -0
- package/dashboard/dist/brand-logos/gemini.svg +1 -0
- package/dashboard/dist/brand-logos/grok.svg +1 -0
- package/dashboard/dist/brand-logos/hermes.svg +1 -0
- package/dashboard/dist/brand-logos/kilo.svg +4 -0
- package/dashboard/dist/brand-logos/kimi.svg +1 -0
- package/dashboard/dist/brand-logos/kiro.svg +1 -0
- package/dashboard/dist/brand-logos/openclaw.svg +22 -0
- package/dashboard/dist/brand-logos/opencode.svg +3 -0
- package/dashboard/dist/clawd/happy.svg +161 -0
- package/dashboard/dist/clawd/idle/collapse.svg +101 -0
- package/dashboard/dist/clawd/idle/doze.svg +72 -0
- package/dashboard/dist/clawd/idle/follow.svg +64 -0
- package/dashboard/dist/clawd/idle/living.svg +196 -0
- package/dashboard/dist/clawd/idle/look.svg +115 -0
- package/dashboard/dist/clawd/idle/yawn.svg +158 -0
- package/dashboard/dist/clawd/mini/alert.svg +129 -0
- package/dashboard/dist/clawd/mini/crabwalk.svg +76 -0
- package/dashboard/dist/clawd/mini/enter-sleep.svg +65 -0
- package/dashboard/dist/clawd/mini/enter.svg +115 -0
- package/dashboard/dist/clawd/mini/happy.svg +124 -0
- package/dashboard/dist/clawd/mini/idle-tight.svg +15 -0
- package/dashboard/dist/clawd/mini/idle.svg +83 -0
- package/dashboard/dist/clawd/mini/peek.svg +82 -0
- package/dashboard/dist/clawd/mini/sleep.svg +112 -0
- package/dashboard/dist/clawd/react/double.svg +108 -0
- package/dashboard/dist/clawd/react/drag.svg +102 -0
- package/dashboard/dist/clawd/react/left.svg +102 -0
- package/dashboard/dist/clawd/react/right.svg +102 -0
- package/dashboard/dist/clawd/sleep/collapse-sleep.svg +247 -0
- package/dashboard/dist/clawd/sleep/sleeping.svg +118 -0
- package/dashboard/dist/clawd/sleep/wake.svg +277 -0
- package/dashboard/dist/clawd/static-base.svg +21 -0
- package/dashboard/dist/clawd/status/disconnected.svg +136 -0
- package/dashboard/dist/clawd/status/error.svg +94 -0
- package/dashboard/dist/clawd/status/notification.svg +134 -0
- package/dashboard/dist/clawd/working/building.svg +231 -0
- package/dashboard/dist/clawd/working/carrying.svg +107 -0
- package/dashboard/dist/clawd/working/conducting.svg +118 -0
- package/dashboard/dist/clawd/working/confused.svg +113 -0
- package/dashboard/dist/clawd/working/debugger.svg +91 -0
- package/dashboard/dist/clawd/working/juggling.svg +82 -0
- package/dashboard/dist/clawd/working/overheated.svg +75 -0
- package/dashboard/dist/clawd/working/pushing.svg +125 -0
- package/dashboard/dist/clawd/working/sweeping.svg +79 -0
- package/dashboard/dist/clawd/working/thinking.svg +119 -0
- package/dashboard/dist/clawd/working/typing.svg +153 -0
- package/dashboard/dist/clawd/working/ultrathink.svg +166 -0
- package/dashboard/dist/clawd/working/wizard.svg +98 -0
- package/dashboard/dist/dashboard-dark.png +0 -0
- package/dashboard/dist/favicon-16.png +0 -0
- package/dashboard/dist/favicon-32.png +0 -0
- package/dashboard/dist/favicon.ico +0 -0
- package/dashboard/dist/feed.xml +34 -0
- package/dashboard/dist/icon-192.png +0 -0
- package/dashboard/dist/icon-512.png +0 -0
- package/dashboard/dist/index.html +355 -0
- package/dashboard/dist/llms.txt +53 -0
- package/dashboard/dist/neon_bg.png +0 -0
- package/dashboard/dist/robots.txt +7 -0
- package/dashboard/dist/share.html +140 -0
- package/dashboard/dist/sitemap.xml +27 -0
- package/dashboard/dist/widgets-overview.png +0 -0
- package/package.json +78 -0
- package/scripts/install-local-service.sh +140 -0
- package/scripts/uninstall-local-service.sh +21 -0
- package/src/cli.js +95 -0
- package/src/commands/device-login.js +161 -0
- package/src/commands/diagnostics.js +38 -0
- package/src/commands/doctor.js +96 -0
- package/src/commands/init.js +1195 -0
- package/src/commands/serve.js +351 -0
- package/src/commands/status.js +609 -0
- package/src/commands/sync.js +2285 -0
- package/src/commands/uninstall.js +189 -0
- package/src/commands/wrapped.js +150 -0
- package/src/lib/antigravity-paths.js +48 -0
- package/src/lib/browser-auth.js +281 -0
- package/src/lib/categorizer-utils.js +232 -0
- package/src/lib/claude-categorizer.js +1319 -0
- package/src/lib/claude-config.js +204 -0
- package/src/lib/cli-ui.js +179 -0
- package/src/lib/codex-config.js +350 -0
- package/src/lib/codex-context-breakdown.js +400 -0
- package/src/lib/codex-rollout-parser.js +623 -0
- package/src/lib/codex-token-refresh.js +112 -0
- package/src/lib/cursor-config.js +425 -0
- package/src/lib/debug-flags.js +7 -0
- package/src/lib/diagnostics.js +272 -0
- package/src/lib/doctor.js +338 -0
- package/src/lib/fs.js +91 -0
- package/src/lib/gemini-config.js +288 -0
- package/src/lib/grok-hook.js +351 -0
- package/src/lib/init-flow.js +42 -0
- package/src/lib/local-api.js +1861 -0
- package/src/lib/openclaw-hook.js +420 -0
- package/src/lib/openclaw-session-plugin.js +520 -0
- package/src/lib/opencode-config.js +99 -0
- package/src/lib/passive-mode.js +185 -0
- package/src/lib/pricing/curated-overrides.json +79 -0
- package/src/lib/pricing/index.js +143 -0
- package/src/lib/pricing/litellm-fetcher.js +172 -0
- package/src/lib/pricing/matcher.js +255 -0
- package/src/lib/pricing/seed-snapshot.json +1 -0
- package/src/lib/progress.js +76 -0
- package/src/lib/project-usage-purge.js +106 -0
- package/src/lib/prompt.js +24 -0
- package/src/lib/proxy-env.js +131 -0
- package/src/lib/rollout.js +8442 -0
- package/src/lib/runtime-config.js +122 -0
- package/src/lib/skill-usage.js +267 -0
- package/src/lib/skills-manager.js +1127 -0
- package/src/lib/source-metadata.js +48 -0
- package/src/lib/sqlite-reader.js +136 -0
- package/src/lib/static-server.js +59 -0
- package/src/lib/subscriptions.js +453 -0
- package/src/lib/tracker-paths.js +16 -0
- package/src/lib/upload-throttle.js +148 -0
- package/src/lib/usage-limits.js +1806 -0
- 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 };
|