@tokscale/cli 1.0.24 → 1.1.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/dist/cli.js +127 -41
- package/dist/cli.js.map +1 -1
- package/dist/cursor.d.ts +38 -3
- package/dist/cursor.d.ts.map +1 -1
- package/dist/cursor.js +520 -46
- package/dist/cursor.js.map +1 -1
- package/dist/submit.d.ts.map +1 -1
- package/dist/submit.js +33 -5
- package/dist/submit.js.map +1 -1
- package/dist/tui/hooks/useData.d.ts.map +1 -1
- package/dist/tui/hooks/useData.js +9 -4
- package/dist/tui/hooks/useData.js.map +1 -1
- package/dist/wrapped.d.ts.map +1 -1
- package/dist/wrapped.js +8 -4
- package/dist/wrapped.js.map +1 -1
- package/package.json +2 -5
- package/src/cli.ts +143 -43
- package/src/cursor.ts +537 -51
- package/src/submit.ts +37 -5
- package/src/tui/hooks/useData.ts +10 -4
- package/src/wrapped.ts +9 -4
package/src/cursor.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import * as fs from "node:fs";
|
|
13
13
|
import * as path from "node:path";
|
|
14
14
|
import * as os from "node:os";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
15
16
|
import { parse as parseCsv } from "csv-parse/sync";
|
|
16
17
|
|
|
17
18
|
// ============================================================================
|
|
@@ -23,6 +24,13 @@ export interface CursorCredentials {
|
|
|
23
24
|
userId?: string;
|
|
24
25
|
createdAt: string;
|
|
25
26
|
expiresAt?: string;
|
|
27
|
+
label?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface CursorCredentialsStoreV1 {
|
|
31
|
+
version: 1;
|
|
32
|
+
activeAccountId: string;
|
|
33
|
+
accounts: Record<string, CursorCredentials>;
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
export interface CursorUsageRow {
|
|
@@ -106,34 +114,395 @@ function migrateCursorFromOldPath(): void {
|
|
|
106
114
|
}
|
|
107
115
|
}
|
|
108
116
|
|
|
109
|
-
export function
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
117
|
+
export function ensureCursorMigration(): void {
|
|
118
|
+
// Best-effort: never throw
|
|
119
|
+
try {
|
|
120
|
+
migrateCursorFromOldPath();
|
|
121
|
+
} catch {}
|
|
122
|
+
try {
|
|
123
|
+
migrateCursorCacheFromOldPath();
|
|
124
|
+
} catch {}
|
|
125
|
+
try {
|
|
126
|
+
// Triggers legacy schema -> v1 store migration if needed
|
|
127
|
+
loadCursorCredentialsStoreInternal();
|
|
128
|
+
} catch {}
|
|
115
129
|
}
|
|
116
130
|
|
|
117
|
-
|
|
118
|
-
|
|
131
|
+
function isStoreV1(data: unknown): data is CursorCredentialsStoreV1 {
|
|
132
|
+
if (!data || typeof data !== "object") return false;
|
|
133
|
+
const obj = data as Record<string, unknown>;
|
|
134
|
+
return obj.version === 1 && typeof obj.activeAccountId === "string" && typeof obj.accounts === "object" && obj.accounts !== null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function extractUserIdFromSessionToken(sessionToken: string): string | null {
|
|
138
|
+
if (!sessionToken) return null;
|
|
139
|
+
const token = sessionToken.trim();
|
|
140
|
+
if (token.includes("%3A%3A")) {
|
|
141
|
+
const userId = token.split("%3A%3A")[0]?.trim();
|
|
142
|
+
return userId ? userId : null;
|
|
143
|
+
}
|
|
144
|
+
if (token.includes("::")) {
|
|
145
|
+
const userId = token.split("::")[0]?.trim();
|
|
146
|
+
return userId ? userId : null;
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function sanitizeAccountIdForFilename(accountId: string): string {
|
|
152
|
+
return accountId
|
|
153
|
+
.trim()
|
|
154
|
+
.toLowerCase()
|
|
155
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
156
|
+
.replace(/^-+|-+$/g, "")
|
|
157
|
+
.slice(0, 80) || "account";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isCursorUsageCsvFilename(fileName: string): boolean {
|
|
161
|
+
if (fileName === "usage.csv") return true;
|
|
162
|
+
if (!fileName.startsWith("usage.")) return false;
|
|
163
|
+
if (!fileName.endsWith(".csv")) return false;
|
|
164
|
+
// Exclude legacy backups (were previously written as usage.backup-<ts>.csv)
|
|
165
|
+
if (fileName.startsWith("usage.backup")) return false;
|
|
166
|
+
|
|
167
|
+
const stem = fileName.slice("usage.".length, -".csv".length);
|
|
168
|
+
if (!stem) return false;
|
|
169
|
+
return /^[a-z0-9._-]+$/i.test(stem);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function deriveAccountId(sessionToken: string): string {
|
|
173
|
+
const userId = extractUserIdFromSessionToken(sessionToken);
|
|
174
|
+
if (userId) return userId;
|
|
175
|
+
const hash = createHash("sha256").update(sessionToken).digest("hex").slice(0, 12);
|
|
176
|
+
return `anon-${hash}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function atomicWriteFile(filePath: string, data: string, mode: number): void {
|
|
180
|
+
const dir = path.dirname(filePath);
|
|
181
|
+
const base = path.basename(filePath);
|
|
182
|
+
const tmp = path.join(dir, `.${base}.tmp-${process.pid}`);
|
|
183
|
+
fs.writeFileSync(tmp, data, { encoding: "utf-8", mode });
|
|
119
184
|
try {
|
|
120
|
-
|
|
121
|
-
|
|
185
|
+
fs.renameSync(tmp, filePath);
|
|
186
|
+
} catch {
|
|
187
|
+
// Best-effort for platforms where rename over an existing file can fail.
|
|
188
|
+
try {
|
|
189
|
+
if (fs.existsSync(filePath)) fs.rmSync(filePath);
|
|
190
|
+
} catch {
|
|
191
|
+
// ignore
|
|
122
192
|
}
|
|
193
|
+
fs.renameSync(tmp, filePath);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function loadCursorCredentialsStoreInternal(): CursorCredentialsStoreV1 | null {
|
|
198
|
+
migrateCursorFromOldPath();
|
|
199
|
+
try {
|
|
200
|
+
if (!fs.existsSync(CURSOR_CREDENTIALS_FILE)) return null;
|
|
123
201
|
const data = fs.readFileSync(CURSOR_CREDENTIALS_FILE, "utf-8");
|
|
124
|
-
const parsed = JSON.parse(data);
|
|
202
|
+
const parsed: unknown = JSON.parse(data);
|
|
203
|
+
|
|
204
|
+
if (isStoreV1(parsed)) {
|
|
205
|
+
const store = parsed;
|
|
206
|
+
if (!store.activeAccountId || !store.accounts[store.activeAccountId]) {
|
|
207
|
+
const firstId = Object.keys(store.accounts)[0];
|
|
208
|
+
if (!firstId) return null;
|
|
209
|
+
store.activeAccountId = firstId;
|
|
210
|
+
ensureConfigDir();
|
|
211
|
+
atomicWriteFile(CURSOR_CREDENTIALS_FILE, JSON.stringify(store, null, 2), 0o600);
|
|
212
|
+
}
|
|
213
|
+
return store;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Legacy single-account schema: { sessionToken, createdAt, ... }
|
|
217
|
+
if (parsed && typeof parsed === "object") {
|
|
218
|
+
const obj = parsed as Record<string, unknown>;
|
|
219
|
+
const sessionToken = typeof obj.sessionToken === "string" ? obj.sessionToken : "";
|
|
220
|
+
if (!sessionToken) return null;
|
|
221
|
+
|
|
222
|
+
const accountId = deriveAccountId(sessionToken);
|
|
223
|
+
const migrated: CursorCredentialsStoreV1 = {
|
|
224
|
+
version: 1,
|
|
225
|
+
activeAccountId: accountId,
|
|
226
|
+
accounts: {
|
|
227
|
+
[accountId]: {
|
|
228
|
+
sessionToken,
|
|
229
|
+
userId: typeof obj.userId === "string" ? obj.userId : extractUserIdFromSessionToken(sessionToken) || undefined,
|
|
230
|
+
createdAt: typeof obj.createdAt === "string" ? obj.createdAt : new Date().toISOString(),
|
|
231
|
+
expiresAt: typeof obj.expiresAt === "string" ? obj.expiresAt : undefined,
|
|
232
|
+
label: typeof obj.label === "string" ? obj.label : undefined,
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
};
|
|
125
236
|
|
|
126
|
-
|
|
127
|
-
|
|
237
|
+
ensureConfigDir();
|
|
238
|
+
atomicWriteFile(CURSOR_CREDENTIALS_FILE, JSON.stringify(migrated, null, 2), 0o600);
|
|
239
|
+
return migrated;
|
|
128
240
|
}
|
|
129
241
|
|
|
130
|
-
return
|
|
242
|
+
return null;
|
|
131
243
|
} catch {
|
|
132
244
|
return null;
|
|
133
245
|
}
|
|
134
246
|
}
|
|
135
247
|
|
|
248
|
+
function saveCursorCredentialsStoreInternal(store: CursorCredentialsStoreV1): void {
|
|
249
|
+
ensureConfigDir();
|
|
250
|
+
atomicWriteFile(CURSOR_CREDENTIALS_FILE, JSON.stringify(store, null, 2), 0o600);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function resolveAccountId(store: CursorCredentialsStoreV1, nameOrId: string): string | null {
|
|
254
|
+
const needle = nameOrId.trim();
|
|
255
|
+
if (!needle) return null;
|
|
256
|
+
if (store.accounts[needle]) return needle;
|
|
257
|
+
|
|
258
|
+
const needleLower = needle.toLowerCase();
|
|
259
|
+
for (const [id, acct] of Object.entries(store.accounts)) {
|
|
260
|
+
if (acct.label && acct.label.toLowerCase() === needleLower) return id;
|
|
261
|
+
}
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function listCursorAccounts(): Array<{ id: string; label?: string; userId?: string; createdAt: string; isActive: boolean }> {
|
|
266
|
+
const store = loadCursorCredentialsStoreInternal();
|
|
267
|
+
if (!store) return [];
|
|
268
|
+
|
|
269
|
+
const accounts = Object.entries(store.accounts).map(([id, acct]) => ({
|
|
270
|
+
id,
|
|
271
|
+
label: acct.label,
|
|
272
|
+
userId: acct.userId,
|
|
273
|
+
createdAt: acct.createdAt,
|
|
274
|
+
isActive: id === store.activeAccountId,
|
|
275
|
+
}));
|
|
276
|
+
|
|
277
|
+
accounts.sort((a, b) => {
|
|
278
|
+
if (a.isActive !== b.isActive) return a.isActive ? -1 : 1;
|
|
279
|
+
const la = (a.label || a.id).toLowerCase();
|
|
280
|
+
const lb = (b.label || b.id).toLowerCase();
|
|
281
|
+
return la.localeCompare(lb);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
return accounts;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function setActiveCursorAccount(nameOrId: string): { ok: boolean; error?: string } {
|
|
288
|
+
const store = loadCursorCredentialsStoreInternal();
|
|
289
|
+
if (!store) return { ok: false, error: "Not authenticated" };
|
|
290
|
+
const resolved = resolveAccountId(store, nameOrId);
|
|
291
|
+
if (!resolved) return { ok: false, error: `Account not found: ${nameOrId}` };
|
|
292
|
+
const prev = store.activeAccountId;
|
|
293
|
+
store.activeAccountId = resolved;
|
|
294
|
+
saveCursorCredentialsStoreInternal(store);
|
|
295
|
+
|
|
296
|
+
// Best-effort cache reconcile (avoid double-counting)
|
|
297
|
+
try {
|
|
298
|
+
migrateCursorCacheFromOldPath();
|
|
299
|
+
ensureCacheDir();
|
|
300
|
+
|
|
301
|
+
const archiveDir = path.join(CURSOR_CACHE_DIR, "archive");
|
|
302
|
+
const ensureArchiveDir = (): void => {
|
|
303
|
+
ensureCacheDir();
|
|
304
|
+
if (!fs.existsSync(archiveDir)) {
|
|
305
|
+
fs.mkdirSync(archiveDir, { recursive: true, mode: 0o700 });
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
const archiveFile = (filePath: string, label: string): void => {
|
|
309
|
+
ensureArchiveDir();
|
|
310
|
+
const safeLabel = sanitizeAccountIdForFilename(label);
|
|
311
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
312
|
+
const dest = path.join(archiveDir, `${safeLabel}-${ts}.csv`);
|
|
313
|
+
fs.renameSync(filePath, dest);
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// Move current active cache to previous account file (preserve any existing file by archiving).
|
|
317
|
+
if (prev && fs.existsSync(CURSOR_CACHE_FILE)) {
|
|
318
|
+
const prevFile = getCursorCacheFilePathForAccount(prev, false);
|
|
319
|
+
if (fs.existsSync(prevFile)) {
|
|
320
|
+
try {
|
|
321
|
+
archiveFile(prevFile, `usage.${prev}.previous`);
|
|
322
|
+
} catch {
|
|
323
|
+
// ignore
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
fs.renameSync(CURSOR_CACHE_FILE, prevFile);
|
|
328
|
+
} catch {
|
|
329
|
+
// ignore
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Promote next account cache file into usage.csv.
|
|
334
|
+
const nextFile = getCursorCacheFilePathForAccount(resolved, false);
|
|
335
|
+
if (fs.existsSync(nextFile)) {
|
|
336
|
+
if (fs.existsSync(CURSOR_CACHE_FILE)) {
|
|
337
|
+
try {
|
|
338
|
+
archiveFile(CURSOR_CACHE_FILE, `usage.active.pre-switch`);
|
|
339
|
+
} catch {
|
|
340
|
+
// ignore
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
fs.renameSync(nextFile, CURSOR_CACHE_FILE);
|
|
345
|
+
} catch {
|
|
346
|
+
// ignore
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// If a per-account cache exists, it was promoted into usage.csv above.
|
|
351
|
+
} catch {
|
|
352
|
+
// ignore cache reconcile errors
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return { ok: true };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function saveCursorCredentials(credentials: CursorCredentials, options?: { label?: string; setActive?: boolean }): { accountId: string } {
|
|
359
|
+
const sessionToken = credentials.sessionToken;
|
|
360
|
+
const accountId = deriveAccountId(sessionToken);
|
|
361
|
+
const store = loadCursorCredentialsStoreInternal() || {
|
|
362
|
+
version: 1 as const,
|
|
363
|
+
activeAccountId: accountId,
|
|
364
|
+
accounts: {},
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
if (options?.label) {
|
|
368
|
+
const needle = options.label.trim().toLowerCase();
|
|
369
|
+
if (needle) {
|
|
370
|
+
for (const [id, acct] of Object.entries(store.accounts)) {
|
|
371
|
+
if (id === accountId) continue;
|
|
372
|
+
if (acct.label && acct.label.trim().toLowerCase() === needle) {
|
|
373
|
+
throw new Error(`Cursor account label already exists: ${options.label}`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const next: CursorCredentials = {
|
|
380
|
+
...credentials,
|
|
381
|
+
userId: credentials.userId || extractUserIdFromSessionToken(sessionToken) || undefined,
|
|
382
|
+
label: options?.label ?? credentials.label,
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
store.accounts[accountId] = next;
|
|
386
|
+
if (options?.setActive !== false) {
|
|
387
|
+
store.activeAccountId = accountId;
|
|
388
|
+
}
|
|
389
|
+
saveCursorCredentialsStoreInternal(store);
|
|
390
|
+
return { accountId };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function loadCursorCredentials(nameOrId?: string): CursorCredentials | null {
|
|
394
|
+
const store = loadCursorCredentialsStoreInternal();
|
|
395
|
+
if (!store) return null;
|
|
396
|
+
|
|
397
|
+
if (nameOrId) {
|
|
398
|
+
const resolved = resolveAccountId(store, nameOrId);
|
|
399
|
+
return resolved ? store.accounts[resolved] : null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return store.accounts[store.activeAccountId] || null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function loadCursorCredentialsStore(): CursorCredentialsStoreV1 | null {
|
|
406
|
+
return loadCursorCredentialsStoreInternal();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// NOTE: implementation moved below to support cache archiving by default.
|
|
410
|
+
|
|
411
|
+
export function removeCursorAccount(
|
|
412
|
+
nameOrId: string,
|
|
413
|
+
options?: { purgeCache?: boolean }
|
|
414
|
+
): { removed: boolean; error?: string } {
|
|
415
|
+
const store = loadCursorCredentialsStoreInternal();
|
|
416
|
+
if (!store) return { removed: false, error: "Not authenticated" };
|
|
417
|
+
|
|
418
|
+
const resolved = resolveAccountId(store, nameOrId);
|
|
419
|
+
if (!resolved) return { removed: false, error: `Account not found: ${nameOrId}` };
|
|
420
|
+
|
|
421
|
+
const wasActive = resolved === store.activeAccountId;
|
|
422
|
+
|
|
423
|
+
// Cache behavior:
|
|
424
|
+
// - Default: keep history but remove from aggregation by archiving out of cursor-cache/.
|
|
425
|
+
// - purgeCache: delete cache files.
|
|
426
|
+
const CURSOR_CACHE_ARCHIVE_DIR = path.join(CURSOR_CACHE_DIR, "archive");
|
|
427
|
+
const ensureCacheArchiveDir = (): void => {
|
|
428
|
+
ensureCacheDir();
|
|
429
|
+
if (!fs.existsSync(CURSOR_CACHE_ARCHIVE_DIR)) {
|
|
430
|
+
fs.mkdirSync(CURSOR_CACHE_ARCHIVE_DIR, { recursive: true, mode: 0o700 });
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
const archiveFile = (filePath: string, label: string): void => {
|
|
434
|
+
ensureCacheArchiveDir();
|
|
435
|
+
const safeLabel = sanitizeAccountIdForFilename(label);
|
|
436
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
437
|
+
const dest = path.join(CURSOR_CACHE_ARCHIVE_DIR, `${safeLabel}-${ts}.csv`);
|
|
438
|
+
fs.renameSync(filePath, dest);
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
try {
|
|
442
|
+
migrateCursorCacheFromOldPath();
|
|
443
|
+
if (fs.existsSync(CURSOR_CACHE_DIR)) {
|
|
444
|
+
const perAccount = getCursorCacheFilePathForAccount(resolved, false);
|
|
445
|
+
if (fs.existsSync(perAccount)) {
|
|
446
|
+
if (options?.purgeCache) {
|
|
447
|
+
fs.rmSync(perAccount);
|
|
448
|
+
} else {
|
|
449
|
+
archiveFile(perAccount, `usage.${resolved}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (wasActive && fs.existsSync(CURSOR_CACHE_FILE)) {
|
|
453
|
+
if (options?.purgeCache) {
|
|
454
|
+
fs.rmSync(CURSOR_CACHE_FILE);
|
|
455
|
+
} else {
|
|
456
|
+
archiveFile(CURSOR_CACHE_FILE, `usage.active.${resolved}`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
} catch {
|
|
461
|
+
// ignore
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
delete store.accounts[resolved];
|
|
465
|
+
|
|
466
|
+
const remaining = Object.keys(store.accounts);
|
|
467
|
+
if (remaining.length === 0) {
|
|
468
|
+
try {
|
|
469
|
+
fs.unlinkSync(CURSOR_CREDENTIALS_FILE);
|
|
470
|
+
} catch {}
|
|
471
|
+
return { removed: true };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (wasActive) {
|
|
475
|
+
store.activeAccountId = remaining[0];
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
saveCursorCredentialsStoreInternal(store);
|
|
479
|
+
|
|
480
|
+
if (wasActive) {
|
|
481
|
+
// Best-effort: reconcile usage.csv for the new active account.
|
|
482
|
+
try {
|
|
483
|
+
migrateCursorCacheFromOldPath();
|
|
484
|
+
ensureCacheDir();
|
|
485
|
+
const nextId = store.activeAccountId;
|
|
486
|
+
const nextFile = getCursorCacheFilePathForAccount(nextId, false);
|
|
487
|
+
if (fs.existsSync(nextFile)) {
|
|
488
|
+
if (fs.existsSync(CURSOR_CACHE_FILE)) {
|
|
489
|
+
try {
|
|
490
|
+
fs.rmSync(CURSOR_CACHE_FILE);
|
|
491
|
+
} catch {}
|
|
492
|
+
}
|
|
493
|
+
fs.renameSync(nextFile, CURSOR_CACHE_FILE);
|
|
494
|
+
}
|
|
495
|
+
// If nextFile existed, it was promoted into usage.csv above.
|
|
496
|
+
} catch {
|
|
497
|
+
// ignore
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return { removed: true };
|
|
502
|
+
}
|
|
503
|
+
|
|
136
504
|
export function clearCursorCredentials(): boolean {
|
|
505
|
+
// Backward compatible: clears ALL accounts
|
|
137
506
|
try {
|
|
138
507
|
if (fs.existsSync(CURSOR_CREDENTIALS_FILE)) {
|
|
139
508
|
fs.unlinkSync(CURSOR_CREDENTIALS_FILE);
|
|
@@ -145,8 +514,50 @@ export function clearCursorCredentials(): boolean {
|
|
|
145
514
|
}
|
|
146
515
|
}
|
|
147
516
|
|
|
517
|
+
export function clearCursorCredentialsAndCache(options?: { purgeCache?: boolean }): boolean {
|
|
518
|
+
const cleared = clearCursorCredentials();
|
|
519
|
+
if (!cleared) return false;
|
|
520
|
+
|
|
521
|
+
try {
|
|
522
|
+
migrateCursorCacheFromOldPath();
|
|
523
|
+
if (!fs.existsSync(CURSOR_CACHE_DIR)) return true;
|
|
524
|
+
|
|
525
|
+
const archiveDir = path.join(CURSOR_CACHE_DIR, "archive");
|
|
526
|
+
const ensureArchiveDir = (): void => {
|
|
527
|
+
ensureCacheDir();
|
|
528
|
+
if (!fs.existsSync(archiveDir)) {
|
|
529
|
+
fs.mkdirSync(archiveDir, { recursive: true, mode: 0o700 });
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
const archiveFile = (filePath: string, label: string): void => {
|
|
533
|
+
ensureArchiveDir();
|
|
534
|
+
const safeLabel = sanitizeAccountIdForFilename(label);
|
|
535
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
536
|
+
const dest = path.join(archiveDir, `${safeLabel}-${ts}.csv`);
|
|
537
|
+
fs.renameSync(filePath, dest);
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
for (const f of fs.readdirSync(CURSOR_CACHE_DIR)) {
|
|
541
|
+
if (!f.startsWith("usage") || !f.endsWith(".csv")) continue;
|
|
542
|
+
const filePath = path.join(CURSOR_CACHE_DIR, f);
|
|
543
|
+
try {
|
|
544
|
+
if (options?.purgeCache) {
|
|
545
|
+
fs.rmSync(filePath);
|
|
546
|
+
} else {
|
|
547
|
+
archiveFile(filePath, `usage.all.${f}`);
|
|
548
|
+
}
|
|
549
|
+
} catch {}
|
|
550
|
+
}
|
|
551
|
+
} catch {
|
|
552
|
+
// ignore
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
|
|
148
558
|
export function isCursorLoggedIn(): boolean {
|
|
149
|
-
|
|
559
|
+
const store = loadCursorCredentialsStoreInternal();
|
|
560
|
+
return !!store && Object.keys(store.accounts).length > 0;
|
|
150
561
|
}
|
|
151
562
|
|
|
152
563
|
// ============================================================================
|
|
@@ -394,12 +805,12 @@ export function cursorRowsToMessages(rows: CursorUsageRow[]): CursorMessageWithT
|
|
|
394
805
|
* Fetch and parse Cursor usage data
|
|
395
806
|
* Requires valid credentials to be stored
|
|
396
807
|
*/
|
|
397
|
-
export async function readCursorUsage(): Promise<{
|
|
808
|
+
export async function readCursorUsage(nameOrId?: string): Promise<{
|
|
398
809
|
rows: CursorUsageRow[];
|
|
399
810
|
byModel: CursorUsageData[];
|
|
400
811
|
messages: CursorMessageWithTimestamp[];
|
|
401
812
|
}> {
|
|
402
|
-
const credentials = loadCursorCredentials();
|
|
813
|
+
const credentials = loadCursorCredentials(nameOrId);
|
|
403
814
|
if (!credentials) {
|
|
404
815
|
throw new Error("Cursor not authenticated. Run 'tokscale cursor login' first.");
|
|
405
816
|
}
|
|
@@ -427,6 +838,12 @@ const OLD_CURSOR_CACHE_DIR = path.join(os.homedir(), ".tokscale", "cursor-cache"
|
|
|
427
838
|
const CURSOR_CACHE_DIR = path.join(CONFIG_DIR, "cursor-cache");
|
|
428
839
|
const CURSOR_CACHE_FILE = path.join(CURSOR_CACHE_DIR, "usage.csv");
|
|
429
840
|
|
|
841
|
+
function getCursorCacheFilePathForAccount(accountId: string, isActive: boolean): string {
|
|
842
|
+
if (isActive) return CURSOR_CACHE_FILE;
|
|
843
|
+
const safe = sanitizeAccountIdForFilename(accountId);
|
|
844
|
+
return path.join(CURSOR_CACHE_DIR, `usage.${safe}.csv`);
|
|
845
|
+
}
|
|
846
|
+
|
|
430
847
|
function ensureCacheDir(): void {
|
|
431
848
|
if (!fs.existsSync(CURSOR_CACHE_DIR)) {
|
|
432
849
|
fs.mkdirSync(CURSOR_CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
@@ -461,19 +878,49 @@ function migrateCursorCacheFromOldPath(): void {
|
|
|
461
878
|
*/
|
|
462
879
|
export async function syncCursorCache(): Promise<{ synced: boolean; rows: number; error?: string }> {
|
|
463
880
|
migrateCursorCacheFromOldPath();
|
|
464
|
-
const
|
|
465
|
-
if (!
|
|
466
|
-
|
|
467
|
-
}
|
|
881
|
+
const store = loadCursorCredentialsStoreInternal();
|
|
882
|
+
if (!store) return { synced: false, rows: 0, error: "Not authenticated" };
|
|
883
|
+
const accounts = Object.entries(store.accounts);
|
|
884
|
+
if (accounts.length === 0) return { synced: false, rows: 0, error: "Not authenticated" };
|
|
468
885
|
|
|
469
886
|
try {
|
|
470
|
-
const csvText = await fetchCursorUsageCsv(credentials.sessionToken);
|
|
471
887
|
ensureCacheDir();
|
|
472
|
-
fs.writeFileSync(CURSOR_CACHE_FILE, csvText, { encoding: "utf-8", mode: 0o600 });
|
|
473
888
|
|
|
474
|
-
//
|
|
475
|
-
const
|
|
476
|
-
|
|
889
|
+
// Ensure we don't double-count active account (usage.csv + usage.<active>.csv)
|
|
890
|
+
const activeId = store.activeAccountId;
|
|
891
|
+
if (activeId) {
|
|
892
|
+
const dup = getCursorCacheFilePathForAccount(activeId, false);
|
|
893
|
+
if (fs.existsSync(dup)) {
|
|
894
|
+
try { fs.rmSync(dup); } catch {}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
let totalRows = 0;
|
|
899
|
+
let successCount = 0;
|
|
900
|
+
const errors: string[] = [];
|
|
901
|
+
|
|
902
|
+
for (const [accountId, credentials] of accounts) {
|
|
903
|
+
const isActive = accountId === store.activeAccountId;
|
|
904
|
+
try {
|
|
905
|
+
const csvText = await fetchCursorUsageCsv(credentials.sessionToken);
|
|
906
|
+
const filePath = getCursorCacheFilePathForAccount(accountId, isActive);
|
|
907
|
+
atomicWriteFile(filePath, csvText, 0o600);
|
|
908
|
+
totalRows += parseCursorCsv(csvText).length;
|
|
909
|
+
successCount += 1;
|
|
910
|
+
} catch (e) {
|
|
911
|
+
errors.push(`${accountId}: ${(e as Error).message}`);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
if (successCount === 0) {
|
|
916
|
+
return { synced: false, rows: 0, error: errors[0] || "Cursor sync failed" };
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
return {
|
|
920
|
+
synced: true,
|
|
921
|
+
rows: totalRows,
|
|
922
|
+
error: errors.length > 0 ? `Some accounts failed to sync (${errors.length}/${accounts.length})` : undefined,
|
|
923
|
+
};
|
|
477
924
|
} catch (error) {
|
|
478
925
|
return { synced: false, rows: 0, error: (error as Error).message };
|
|
479
926
|
}
|
|
@@ -483,6 +930,8 @@ export async function syncCursorCache(): Promise<{ synced: boolean; rows: number
|
|
|
483
930
|
* Get the cache file path
|
|
484
931
|
*/
|
|
485
932
|
export function getCursorCachePath(): string {
|
|
933
|
+
// Ensure legacy cache is migrated before reporting paths
|
|
934
|
+
migrateCursorCacheFromOldPath();
|
|
486
935
|
return CURSOR_CACHE_FILE;
|
|
487
936
|
}
|
|
488
937
|
|
|
@@ -490,6 +939,7 @@ export function getCursorCachePath(): string {
|
|
|
490
939
|
* Check if cache exists and when it was last updated
|
|
491
940
|
*/
|
|
492
941
|
export function getCursorCacheStatus(): { exists: boolean; lastModified?: Date; path: string } {
|
|
942
|
+
migrateCursorCacheFromOldPath();
|
|
493
943
|
const exists = fs.existsSync(CURSOR_CACHE_FILE);
|
|
494
944
|
let lastModified: Date | undefined;
|
|
495
945
|
|
|
@@ -505,6 +955,17 @@ export function getCursorCacheStatus(): { exists: boolean; lastModified?: Date;
|
|
|
505
955
|
return { exists, lastModified, path: CURSOR_CACHE_FILE };
|
|
506
956
|
}
|
|
507
957
|
|
|
958
|
+
export function hasCursorUsageCache(): boolean {
|
|
959
|
+
migrateCursorCacheFromOldPath();
|
|
960
|
+
try {
|
|
961
|
+
if (!fs.existsSync(CURSOR_CACHE_DIR)) return false;
|
|
962
|
+
const files = fs.readdirSync(CURSOR_CACHE_DIR);
|
|
963
|
+
return files.some((f) => isCursorUsageCsvFilename(f));
|
|
964
|
+
} catch {
|
|
965
|
+
return false;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
508
969
|
export interface CursorUnifiedMessage {
|
|
509
970
|
source: "cursor";
|
|
510
971
|
modelId: string;
|
|
@@ -523,36 +984,61 @@ export interface CursorUnifiedMessage {
|
|
|
523
984
|
}
|
|
524
985
|
|
|
525
986
|
export function readCursorMessagesFromCache(): CursorUnifiedMessage[] {
|
|
526
|
-
|
|
987
|
+
migrateCursorCacheFromOldPath();
|
|
988
|
+
if (!fs.existsSync(CURSOR_CACHE_DIR)) {
|
|
527
989
|
return [];
|
|
528
990
|
}
|
|
529
991
|
|
|
992
|
+
let files: string[];
|
|
530
993
|
try {
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
return rows.map((row) => {
|
|
535
|
-
const cacheWrite = Math.max(0, row.inputWithCacheWrite - row.inputWithoutCacheWrite);
|
|
536
|
-
const input = row.inputWithoutCacheWrite;
|
|
537
|
-
|
|
538
|
-
return {
|
|
539
|
-
source: "cursor" as const,
|
|
540
|
-
modelId: row.model,
|
|
541
|
-
providerId: inferProvider(row.model),
|
|
542
|
-
sessionId: `cursor-${row.date}-${row.model}`,
|
|
543
|
-
timestamp: row.timestamp,
|
|
544
|
-
date: row.date,
|
|
545
|
-
tokens: {
|
|
546
|
-
input,
|
|
547
|
-
output: row.outputTokens,
|
|
548
|
-
cacheRead: row.cacheRead,
|
|
549
|
-
cacheWrite,
|
|
550
|
-
reasoning: 0,
|
|
551
|
-
},
|
|
552
|
-
cost: row.costToYou || row.apiCost,
|
|
553
|
-
};
|
|
554
|
-
});
|
|
994
|
+
files = fs
|
|
995
|
+
.readdirSync(CURSOR_CACHE_DIR)
|
|
996
|
+
.filter((f) => isCursorUsageCsvFilename(f));
|
|
555
997
|
} catch {
|
|
556
998
|
return [];
|
|
557
999
|
}
|
|
1000
|
+
|
|
1001
|
+
const store = loadCursorCredentialsStoreInternal();
|
|
1002
|
+
const activeId = store?.activeAccountId;
|
|
1003
|
+
|
|
1004
|
+
const all: CursorUnifiedMessage[] = [];
|
|
1005
|
+
for (const file of files) {
|
|
1006
|
+
const filePath = path.join(CURSOR_CACHE_DIR, file);
|
|
1007
|
+
let accountId = "unknown";
|
|
1008
|
+
if (file === "usage.csv") {
|
|
1009
|
+
accountId = activeId || "active";
|
|
1010
|
+
} else if (file.startsWith("usage.") && file.endsWith(".csv")) {
|
|
1011
|
+
accountId = file.slice("usage.".length, -".csv".length);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
try {
|
|
1015
|
+
const csvText = fs.readFileSync(filePath, "utf-8");
|
|
1016
|
+
const rows = parseCursorCsv(csvText);
|
|
1017
|
+
|
|
1018
|
+
for (const row of rows) {
|
|
1019
|
+
const cacheWrite = Math.max(0, row.inputWithCacheWrite - row.inputWithoutCacheWrite);
|
|
1020
|
+
const input = row.inputWithoutCacheWrite;
|
|
1021
|
+
all.push({
|
|
1022
|
+
source: "cursor" as const,
|
|
1023
|
+
modelId: row.model,
|
|
1024
|
+
providerId: inferProvider(row.model),
|
|
1025
|
+
sessionId: `cursor-${accountId}-${row.date}-${row.model}`,
|
|
1026
|
+
timestamp: row.timestamp,
|
|
1027
|
+
date: row.date,
|
|
1028
|
+
tokens: {
|
|
1029
|
+
input,
|
|
1030
|
+
output: row.outputTokens,
|
|
1031
|
+
cacheRead: row.cacheRead,
|
|
1032
|
+
cacheWrite,
|
|
1033
|
+
reasoning: 0,
|
|
1034
|
+
},
|
|
1035
|
+
cost: row.costToYou || row.apiCost,
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
} catch {
|
|
1039
|
+
// ignore file
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
return all;
|
|
558
1044
|
}
|