tokentracker-cli 0.2.27 → 0.3.3

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.
@@ -0,0 +1,328 @@
1
+ const os = require("node:os");
2
+ const path = require("node:path");
3
+ const fs = require("node:fs");
4
+ const cp = require("node:child_process");
5
+ const https = require("node:https");
6
+
7
+ const { readJson } = require("./fs");
8
+
9
+ // ── Path resolution ──
10
+
11
+ function resolveCursorPaths({ home } = {}) {
12
+ const h = home || os.homedir();
13
+ const appSupport = path.join(h, "Library", "Application Support", "Cursor");
14
+ return {
15
+ appDir: appSupport,
16
+ stateDbPath: path.join(appSupport, "User", "globalStorage", "state.vscdb"),
17
+ cliConfigPath: path.join(h, ".cursor", "cli-config.json"),
18
+ };
19
+ }
20
+
21
+ function isCursorInstalled({ home } = {}) {
22
+ const { appDir } = resolveCursorPaths({ home });
23
+ try {
24
+ return fs.statSync(appDir).isDirectory();
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ // ── Auth token extraction ──
31
+
32
+ /**
33
+ * Extract Cursor session cookie from local SQLite + cli-config.json.
34
+ * Returns { cookie, userId } or null on failure.
35
+ *
36
+ * Cookie format: WorkosCursorSessionToken=user_XXXXX%3A%3A<jwt>
37
+ * - JWT from state.vscdb → ItemTable → cursorAuth/accessToken
38
+ * - userId from cli-config.json → authInfo.authId → "auth0|user_XXXXX"
39
+ */
40
+ function extractCursorSessionToken({ home } = {}) {
41
+ const { stateDbPath, cliConfigPath } = resolveCursorPaths({ home });
42
+
43
+ // 1. Extract JWT from SQLite
44
+ let jwt;
45
+ try {
46
+ jwt = cp
47
+ .execSync(
48
+ `sqlite3 -readonly "${stateDbPath}" "SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken';"`,
49
+ { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
50
+ )
51
+ .trim();
52
+ } catch {
53
+ return null;
54
+ }
55
+ if (!jwt || jwt.length < 10) return null;
56
+
57
+ // 2. Extract userId — try cli-config.json first, fall back to JWT decode
58
+ let userId = extractUserIdFromCliConfig(cliConfigPath);
59
+ if (!userId) {
60
+ userId = extractUserIdFromJwt(jwt);
61
+ }
62
+ if (!userId) return null;
63
+
64
+ // 3. Build cookie
65
+ const cookie = `WorkosCursorSessionToken=${userId}%3A%3A${jwt}`;
66
+ return { cookie, userId };
67
+ }
68
+
69
+ function extractUserIdFromCliConfig(configPath) {
70
+ try {
71
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
72
+ const authId = config?.authInfo?.authId || "";
73
+ const match = authId.match(/\|(user_[A-Za-z0-9_]+)/);
74
+ return match ? match[1] : null;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ function extractUserIdFromJwt(jwt) {
81
+ try {
82
+ const parts = jwt.split(".");
83
+ if (parts.length !== 3) return null;
84
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString());
85
+ const sub = payload.sub || "";
86
+ const match = sub.match(/(user_[A-Za-z0-9_]+)/);
87
+ return match ? match[1] : null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ // ── API client ──
94
+
95
+ const CURSOR_CSV_URL = "https://cursor.com/api/dashboard/export-usage-events-csv?strategy=tokens";
96
+ const CURSOR_SUMMARY_URL = "https://cursor.com/api/usage-summary";
97
+
98
+ /**
99
+ * Fetch full usage CSV from Cursor API.
100
+ * Returns raw CSV string or throws on error.
101
+ */
102
+ function fetchCursorUsageCsv({ cookie, timeoutMs = 30000 }) {
103
+ return new Promise((resolve, reject) => {
104
+ const url = new URL(CURSOR_CSV_URL);
105
+ const req = https.request(
106
+ {
107
+ hostname: url.hostname,
108
+ path: url.pathname + url.search,
109
+ method: "GET",
110
+ headers: {
111
+ Accept: "*/*",
112
+ Cookie: cookie,
113
+ Referer: "https://www.cursor.com/settings",
114
+ "User-Agent":
115
+ "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",
116
+ },
117
+ timeout: timeoutMs,
118
+ },
119
+ (res) => {
120
+ if (res.statusCode === 401 || res.statusCode === 403) {
121
+ res.resume();
122
+ return reject(new Error("Cursor session expired — re-login in Cursor to refresh"));
123
+ }
124
+ if (res.statusCode === 308 || res.statusCode === 301 || res.statusCode === 302) {
125
+ // Follow redirect once
126
+ const location = res.headers.location;
127
+ res.resume();
128
+ if (!location) return reject(new Error(`Cursor API redirect without Location header`));
129
+ return fetchUrlRaw({ urlStr: location, cookie, timeoutMs }).then(resolve, reject);
130
+ }
131
+ if (res.statusCode !== 200) {
132
+ res.resume();
133
+ return reject(new Error(`Cursor API returned ${res.statusCode}`));
134
+ }
135
+ let data = "";
136
+ res.on("data", (chunk) => {
137
+ data += chunk;
138
+ });
139
+ res.on("end", () => resolve(data));
140
+ res.on("error", reject);
141
+ },
142
+ );
143
+ req.on("error", reject);
144
+ req.on("timeout", () => {
145
+ req.destroy();
146
+ reject(new Error("Cursor API request timed out"));
147
+ });
148
+ req.end();
149
+ });
150
+ }
151
+
152
+ function fetchUrlRaw({ urlStr, cookie, timeoutMs }) {
153
+ return new Promise((resolve, reject) => {
154
+ const url = new URL(urlStr);
155
+ const req = https.request(
156
+ {
157
+ hostname: url.hostname,
158
+ path: url.pathname + url.search,
159
+ method: "GET",
160
+ headers: {
161
+ Accept: "*/*",
162
+ Cookie: cookie,
163
+ Referer: "https://www.cursor.com/settings",
164
+ "User-Agent":
165
+ "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",
166
+ },
167
+ timeout: timeoutMs,
168
+ },
169
+ (res) => {
170
+ if (res.statusCode !== 200) {
171
+ res.resume();
172
+ return reject(new Error(`Cursor API returned ${res.statusCode} from ${urlStr}`));
173
+ }
174
+ let data = "";
175
+ res.on("data", (chunk) => {
176
+ data += chunk;
177
+ });
178
+ res.on("end", () => resolve(data));
179
+ res.on("error", reject);
180
+ },
181
+ );
182
+ req.on("error", reject);
183
+ req.on("timeout", () => {
184
+ req.destroy();
185
+ reject(new Error("Cursor API request timed out"));
186
+ });
187
+ req.end();
188
+ });
189
+ }
190
+
191
+ // ── CSV parsing ──
192
+
193
+ /**
194
+ * Parse Cursor usage CSV into structured records.
195
+ *
196
+ * New format columns:
197
+ * Date, Kind, Model, Max Mode, Input (w/ Cache Write), Input (w/o Cache Write),
198
+ * Cache Read, Output Tokens, Total Tokens, Cost
199
+ *
200
+ * Old format columns:
201
+ * Date, Model, Input (w/ Cache Write), Input (w/o Cache Write),
202
+ * Cache Read, Output Tokens, Total Tokens, Cost, Cost to you
203
+ */
204
+ function parseCursorCsv(csvText) {
205
+ const lines = csvText.split("\n").filter((l) => l.trim().length > 0);
206
+ if (lines.length < 2) return [];
207
+
208
+ const header = lines[0];
209
+ const isNewFormat = header.includes("Kind");
210
+
211
+ const records = [];
212
+ for (let i = 1; i < lines.length; i++) {
213
+ const fields = parseCsvLine(lines[i]);
214
+ if (!fields || fields.length < 8) continue;
215
+
216
+ let record;
217
+ if (isNewFormat) {
218
+ // Date,Kind,Model,Max Mode,Input (w/ Cache Write),Input (w/o Cache Write),Cache Read,Output Tokens,Total Tokens,Cost
219
+ const inputWithCache = toNum(fields[4]);
220
+ const inputWithoutCache = toNum(fields[5]);
221
+ record = {
222
+ date: stripQuotes(fields[0]),
223
+ kind: stripQuotes(fields[1]),
224
+ model: stripQuotes(fields[2]),
225
+ maxMode: stripQuotes(fields[3]),
226
+ inputTokens: inputWithoutCache,
227
+ cacheWriteTokens: Math.max(0, inputWithCache - inputWithoutCache),
228
+ cacheReadTokens: toNum(fields[6]),
229
+ outputTokens: toNum(fields[7]),
230
+ totalTokens: toNum(fields[8]),
231
+ cost: toFloat(fields[9]),
232
+ };
233
+ } else {
234
+ // Date,Model,Input (w/ Cache Write),Input (w/o Cache Write),Cache Read,Output Tokens,Total Tokens,Cost,Cost to you
235
+ const inputWithCache = toNum(fields[2]);
236
+ const inputWithoutCache = toNum(fields[3]);
237
+ record = {
238
+ date: stripQuotes(fields[0]),
239
+ kind: "unknown",
240
+ model: stripQuotes(fields[1]),
241
+ maxMode: "No",
242
+ inputTokens: inputWithoutCache,
243
+ cacheWriteTokens: Math.max(0, inputWithCache - inputWithoutCache),
244
+ cacheReadTokens: toNum(fields[4]),
245
+ outputTokens: toNum(fields[5]),
246
+ totalTokens: toNum(fields[6]),
247
+ cost: toFloat(fields[7]),
248
+ };
249
+ }
250
+
251
+ // Skip records with no tokens
252
+ if (record.totalTokens <= 0 && record.inputTokens <= 0 && record.outputTokens <= 0) continue;
253
+
254
+ records.push(record);
255
+ }
256
+
257
+ return records;
258
+ }
259
+
260
+ /**
261
+ * Normalize a Cursor CSV record to TokenTracker's standard token format.
262
+ */
263
+ function normalizeCursorUsage(record) {
264
+ const inputTokens = Math.max(0, Math.floor(record.inputTokens || 0));
265
+ const cacheWrite = Math.max(0, Math.floor(record.cacheWriteTokens || 0));
266
+ const cacheRead = Math.max(0, Math.floor(record.cacheReadTokens || 0));
267
+ const outputTokens = Math.max(0, Math.floor(record.outputTokens || 0));
268
+ const totalTokens = inputTokens + outputTokens + cacheWrite + cacheRead;
269
+ return {
270
+ input_tokens: inputTokens,
271
+ cached_input_tokens: cacheRead,
272
+ cache_creation_input_tokens: cacheWrite,
273
+ output_tokens: outputTokens,
274
+ reasoning_output_tokens: 0,
275
+ total_tokens: totalTokens,
276
+ };
277
+ }
278
+
279
+ // ── CSV helpers ──
280
+
281
+ function parseCsvLine(line) {
282
+ const fields = [];
283
+ let current = "";
284
+ let inQuotes = false;
285
+ for (let i = 0; i < line.length; i++) {
286
+ const ch = line[i];
287
+ if (ch === '"') {
288
+ inQuotes = !inQuotes;
289
+ current += ch;
290
+ } else if (ch === "," && !inQuotes) {
291
+ fields.push(current.trim());
292
+ current = "";
293
+ } else {
294
+ current += ch;
295
+ }
296
+ }
297
+ fields.push(current.trim());
298
+ return fields;
299
+ }
300
+
301
+ function stripQuotes(s) {
302
+ if (!s) return "";
303
+ const trimmed = s.trim();
304
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
305
+ return trimmed.slice(1, -1);
306
+ }
307
+ return trimmed;
308
+ }
309
+
310
+ function toNum(s) {
311
+ const n = Number(stripQuotes(s));
312
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
313
+ }
314
+
315
+ function toFloat(s) {
316
+ const cleaned = stripQuotes(s).replace(/[$,]/g, "");
317
+ const n = Number(cleaned);
318
+ return Number.isFinite(n) ? n : 0;
319
+ }
320
+
321
+ module.exports = {
322
+ resolveCursorPaths,
323
+ isCursorInstalled,
324
+ extractCursorSessionToken,
325
+ fetchCursorUsageCsv,
326
+ parseCursorCsv,
327
+ normalizeCursorUsage,
328
+ };
@@ -57,6 +57,111 @@ function aggregateByDay(rows) {
57
57
  return Array.from(byDay.values()).sort((a, b) => a.day.localeCompare(b.day));
58
58
  }
59
59
 
60
+ function getTimeZoneContext(url) {
61
+ const tz = String(url.searchParams.get("tz") || "").trim();
62
+ const rawOffset = Number(url.searchParams.get("tz_offset_minutes"));
63
+ return {
64
+ timeZone: tz || null,
65
+ offsetMinutes: Number.isFinite(rawOffset) ? Math.trunc(rawOffset) : null,
66
+ };
67
+ }
68
+
69
+ function getZonedParts(date, { timeZone, offsetMinutes } = {}) {
70
+ const dt = date instanceof Date ? date : new Date(date);
71
+ if (!Number.isFinite(dt.getTime())) return null;
72
+
73
+ if (timeZone && typeof Intl !== "undefined" && Intl.DateTimeFormat) {
74
+ try {
75
+ const formatter = new Intl.DateTimeFormat("en-CA", {
76
+ timeZone,
77
+ year: "numeric",
78
+ month: "2-digit",
79
+ day: "2-digit",
80
+ hour: "2-digit",
81
+ minute: "2-digit",
82
+ second: "2-digit",
83
+ hourCycle: "h23",
84
+ });
85
+ const parts = formatter.formatToParts(dt);
86
+ const values = parts.reduce((acc, part) => {
87
+ if (part.type && part.value) acc[part.type] = part.value;
88
+ return acc;
89
+ }, {});
90
+ const year = Number(values.year);
91
+ const month = Number(values.month);
92
+ const day = Number(values.day);
93
+ const hour = Number(values.hour);
94
+ const minute = Number(values.minute);
95
+ const second = Number(values.second);
96
+ if ([year, month, day, hour, minute, second].every(Number.isFinite)) {
97
+ return { year, month, day, hour, minute, second };
98
+ }
99
+ } catch (_e) {
100
+ // fall through
101
+ }
102
+ }
103
+
104
+ if (Number.isFinite(offsetMinutes)) {
105
+ const shifted = new Date(dt.getTime() + offsetMinutes * 60 * 1000);
106
+ return {
107
+ year: shifted.getUTCFullYear(),
108
+ month: shifted.getUTCMonth() + 1,
109
+ day: shifted.getUTCDate(),
110
+ hour: shifted.getUTCHours(),
111
+ minute: shifted.getUTCMinutes(),
112
+ second: shifted.getUTCSeconds(),
113
+ };
114
+ }
115
+
116
+ return {
117
+ year: dt.getFullYear(),
118
+ month: dt.getMonth() + 1,
119
+ day: dt.getDate(),
120
+ hour: dt.getHours(),
121
+ minute: dt.getMinutes(),
122
+ second: dt.getSeconds(),
123
+ };
124
+ }
125
+
126
+ function formatPartsDayKey(parts) {
127
+ if (!parts) return "";
128
+ return `${parts.year}-${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")}`;
129
+ }
130
+
131
+ function aggregateHourlyByDay(rows, dayKey, timeZoneContext) {
132
+ const byHour = new Map();
133
+ for (const row of rows) {
134
+ if (!row.hour_start) continue;
135
+ const parts = getZonedParts(new Date(row.hour_start), timeZoneContext);
136
+ if (!parts) continue;
137
+ if (formatPartsDayKey(parts) !== dayKey) continue;
138
+ const hourKey = `${dayKey}T${String(parts.hour).padStart(2, "0")}:00:00`;
139
+ if (!byHour.has(hourKey)) {
140
+ byHour.set(hourKey, {
141
+ hour: hourKey,
142
+ total_tokens: 0,
143
+ billable_total_tokens: 0,
144
+ input_tokens: 0,
145
+ output_tokens: 0,
146
+ cached_input_tokens: 0,
147
+ cache_creation_input_tokens: 0,
148
+ reasoning_output_tokens: 0,
149
+ conversation_count: 0,
150
+ });
151
+ }
152
+ const bucket = byHour.get(hourKey);
153
+ bucket.total_tokens += row.total_tokens || 0;
154
+ bucket.billable_total_tokens += row.total_tokens || 0;
155
+ bucket.input_tokens += row.input_tokens || 0;
156
+ bucket.output_tokens += row.output_tokens || 0;
157
+ bucket.cached_input_tokens += row.cached_input_tokens || 0;
158
+ bucket.cache_creation_input_tokens += row.cache_creation_input_tokens || 0;
159
+ bucket.reasoning_output_tokens += row.reasoning_output_tokens || 0;
160
+ bucket.conversation_count += row.conversation_count || 0;
161
+ }
162
+ return Array.from(byHour.values()).sort((a, b) => a.hour.localeCompare(b.hour));
163
+ }
164
+
60
165
  // ---------------------------------------------------------------------------
61
166
  // Sync helper
62
167
  // ---------------------------------------------------------------------------
@@ -461,18 +566,9 @@ function createLocalApiHandler({ queuePath }) {
461
566
  // --- usage-hourly (stub for day-view) ---
462
567
  if (p === "/functions/vibeusage-usage-hourly") {
463
568
  const day = url.searchParams.get("day") || new Date().toISOString().slice(0, 10);
464
- const rows = readQueueData(qp).filter((r) => r.hour_start && r.hour_start.startsWith(day));
465
- const data = rows.map((r) => ({
466
- hour: r.hour_start,
467
- total_tokens: r.total_tokens || 0,
468
- billable_total_tokens: r.total_tokens || 0,
469
- input_tokens: r.input_tokens || 0,
470
- output_tokens: r.output_tokens || 0,
471
- cached_input_tokens: r.cached_input_tokens || 0,
472
- cache_creation_input_tokens: r.cache_creation_input_tokens || 0,
473
- reasoning_output_tokens: r.reasoning_output_tokens || 0,
474
- conversation_count: r.conversation_count || 0,
475
- }));
569
+ const timeZoneContext = getTimeZoneContext(url);
570
+ const rows = readQueueData(qp);
571
+ const data = aggregateHourlyByDay(rows, day, timeZoneContext);
476
572
  json(res, { day, data });
477
573
  return true;
478
574
  }