@tokscale/cli 1.0.6 → 1.0.7
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 +4 -1
- package/dist/cli.js.map +1 -1
- package/package.json +3 -3
- package/src/auth.ts +211 -0
- package/src/cli.ts +1040 -0
- package/src/credentials.ts +123 -0
- package/src/cursor.ts +558 -0
- package/src/graph-types.ts +188 -0
- package/src/graph.ts +485 -0
- package/src/native-runner.ts +105 -0
- package/src/native.ts +938 -0
- package/src/pricing.ts +309 -0
- package/src/sessions/claudecode.ts +119 -0
- package/src/sessions/codex.ts +227 -0
- package/src/sessions/gemini.ts +108 -0
- package/src/sessions/index.ts +126 -0
- package/src/sessions/opencode.ts +94 -0
- package/src/sessions/reports.ts +475 -0
- package/src/sessions/types.ts +59 -0
- package/src/spinner.ts +283 -0
- package/src/submit.ts +175 -0
- package/src/table.ts +233 -0
- package/src/types.d.ts +28 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,1040 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Tokscale CLI
|
|
4
|
+
* Display OpenCode, Claude Code, Codex, Gemini, and Cursor usage with dynamic width tables
|
|
5
|
+
*
|
|
6
|
+
* All heavy computation is done in the native Rust module.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Command } from "commander";
|
|
10
|
+
import { createRequire } from "module";
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const pkg = require("../package.json") as { version: string };
|
|
13
|
+
import pc from "picocolors";
|
|
14
|
+
import { login, logout, whoami } from "./auth.js";
|
|
15
|
+
import { submit } from "./submit.js";
|
|
16
|
+
import { PricingFetcher } from "./pricing.js";
|
|
17
|
+
import {
|
|
18
|
+
loadCursorCredentials,
|
|
19
|
+
saveCursorCredentials,
|
|
20
|
+
clearCursorCredentials,
|
|
21
|
+
validateCursorSession,
|
|
22
|
+
readCursorUsage,
|
|
23
|
+
getCursorCredentialsPath,
|
|
24
|
+
syncCursorCache,
|
|
25
|
+
} from "./cursor.js";
|
|
26
|
+
import {
|
|
27
|
+
createUsageTable,
|
|
28
|
+
formatUsageRow,
|
|
29
|
+
formatTotalsRow,
|
|
30
|
+
formatNumber,
|
|
31
|
+
formatCurrency,
|
|
32
|
+
formatModelName,
|
|
33
|
+
} from "./table.js";
|
|
34
|
+
import {
|
|
35
|
+
isNativeAvailable,
|
|
36
|
+
getNativeVersion,
|
|
37
|
+
parseLocalSourcesAsync,
|
|
38
|
+
finalizeReportAsync,
|
|
39
|
+
finalizeMonthlyReportAsync,
|
|
40
|
+
finalizeGraphAsync,
|
|
41
|
+
type ModelReport,
|
|
42
|
+
type MonthlyReport,
|
|
43
|
+
type ParsedMessages,
|
|
44
|
+
} from "./native.js";
|
|
45
|
+
import { createSpinner } from "./spinner.js";
|
|
46
|
+
import * as fs from "node:fs";
|
|
47
|
+
import { performance } from "node:perf_hooks";
|
|
48
|
+
import type { SourceType } from "./graph-types.js";
|
|
49
|
+
import type { TUIOptions, TabType } from "./tui/types/index.js";
|
|
50
|
+
|
|
51
|
+
type LaunchTUIFunction = (options?: TUIOptions) => Promise<void>;
|
|
52
|
+
|
|
53
|
+
let cachedTUILoader: LaunchTUIFunction | null = null;
|
|
54
|
+
let tuiLoadAttempted = false;
|
|
55
|
+
|
|
56
|
+
async function tryLoadTUI(): Promise<LaunchTUIFunction | null> {
|
|
57
|
+
if (tuiLoadAttempted) return cachedTUILoader;
|
|
58
|
+
tuiLoadAttempted = true;
|
|
59
|
+
|
|
60
|
+
const isBun = typeof (globalThis as Record<string, unknown>).Bun !== "undefined";
|
|
61
|
+
|
|
62
|
+
if (!isBun) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const currentDir = new URL(".", import.meta.url).pathname;
|
|
67
|
+
const isDevMode = currentDir.includes("/src/");
|
|
68
|
+
const tuiPath = isDevMode
|
|
69
|
+
? new URL("./tui/index.tsx", import.meta.url).href
|
|
70
|
+
: new URL("../src/tui/index.tsx", import.meta.url).href;
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const tuiModule = await import(tuiPath) as { launchTUI: LaunchTUIFunction };
|
|
74
|
+
cachedTUILoader = tuiModule.launchTUI;
|
|
75
|
+
return cachedTUILoader;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (process.env.DEBUG) {
|
|
78
|
+
console.error("TUI load error:", error);
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function showTUIUnavailableMessage(): void {
|
|
85
|
+
console.log(pc.yellow("\n TUI mode requires Bun runtime."));
|
|
86
|
+
console.log(pc.gray(" OpenTUI's native modules are not compatible with Node.js."));
|
|
87
|
+
console.log();
|
|
88
|
+
console.log(pc.white(" Options:"));
|
|
89
|
+
console.log(pc.gray(" • Use 'bunx tokscale' instead of 'npx tokscale'"));
|
|
90
|
+
// console.log(pc.gray(" • Use '--light' flag for legacy CLI table output"));
|
|
91
|
+
console.log(pc.gray(" • Use '--json' flag for JSON output"));
|
|
92
|
+
console.log();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface FilterOptions {
|
|
96
|
+
opencode?: boolean;
|
|
97
|
+
claude?: boolean;
|
|
98
|
+
codex?: boolean;
|
|
99
|
+
gemini?: boolean;
|
|
100
|
+
cursor?: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface DateFilterOptions {
|
|
104
|
+
since?: string;
|
|
105
|
+
until?: string;
|
|
106
|
+
year?: string;
|
|
107
|
+
today?: boolean;
|
|
108
|
+
week?: boolean;
|
|
109
|
+
month?: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface CursorSyncResult {
|
|
113
|
+
/** Whether a sync was attempted (true if credentials exist) */
|
|
114
|
+
attempted: boolean;
|
|
115
|
+
/** Whether the sync succeeded */
|
|
116
|
+
synced: boolean;
|
|
117
|
+
/** Number of usage events fetched */
|
|
118
|
+
rows: number;
|
|
119
|
+
/** Error message if sync failed */
|
|
120
|
+
error?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// =============================================================================
|
|
124
|
+
// Date Helpers
|
|
125
|
+
// =============================================================================
|
|
126
|
+
|
|
127
|
+
function formatDate(date: Date): string {
|
|
128
|
+
return date.toISOString().split("T")[0];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function getDateFilters(options: DateFilterOptions): { since?: string; until?: string; year?: string } {
|
|
132
|
+
const today = new Date();
|
|
133
|
+
|
|
134
|
+
// --today: just today
|
|
135
|
+
if (options.today) {
|
|
136
|
+
const todayStr = formatDate(today);
|
|
137
|
+
return { since: todayStr, until: todayStr };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --week: last 7 days
|
|
141
|
+
if (options.week) {
|
|
142
|
+
const weekAgo = new Date(today);
|
|
143
|
+
weekAgo.setDate(weekAgo.getDate() - 6); // Include today = 7 days
|
|
144
|
+
return { since: formatDate(weekAgo), until: formatDate(today) };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// --month: current calendar month
|
|
148
|
+
if (options.month) {
|
|
149
|
+
const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
|
150
|
+
return { since: formatDate(startOfMonth), until: formatDate(today) };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Explicit filters
|
|
154
|
+
return {
|
|
155
|
+
since: options.since,
|
|
156
|
+
until: options.until,
|
|
157
|
+
year: options.year,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function getDateRangeLabel(options: DateFilterOptions): string | null {
|
|
162
|
+
if (options.today) return "Today";
|
|
163
|
+
if (options.week) return "Last 7 days";
|
|
164
|
+
if (options.month) {
|
|
165
|
+
const today = new Date();
|
|
166
|
+
return today.toLocaleString("en-US", { month: "long", year: "numeric" } as Intl.DateTimeFormatOptions);
|
|
167
|
+
}
|
|
168
|
+
if (options.year) return options.year;
|
|
169
|
+
if (options.since || options.until) {
|
|
170
|
+
const parts: string[] = [];
|
|
171
|
+
if (options.since) parts.push(`from ${options.since}`);
|
|
172
|
+
if (options.until) parts.push(`to ${options.until}`);
|
|
173
|
+
return parts.join(" ");
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function buildTUIOptions(
|
|
179
|
+
options: FilterOptions & DateFilterOptions,
|
|
180
|
+
initialTab?: TabType
|
|
181
|
+
): TUIOptions {
|
|
182
|
+
const dateFilters = getDateFilters(options);
|
|
183
|
+
const enabledSources = getEnabledSources(options);
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
initialTab,
|
|
187
|
+
enabledSources: enabledSources as TUIOptions["enabledSources"],
|
|
188
|
+
since: dateFilters.since,
|
|
189
|
+
until: dateFilters.until,
|
|
190
|
+
year: dateFilters.year,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function main() {
|
|
195
|
+
const program = new Command();
|
|
196
|
+
|
|
197
|
+
program
|
|
198
|
+
.name("tokscale")
|
|
199
|
+
.description("Token Usage Leaderboard CLI - Track AI coding costs across OpenCode, Claude Code, Codex, Gemini, and Cursor")
|
|
200
|
+
.version(pkg.version);
|
|
201
|
+
|
|
202
|
+
program
|
|
203
|
+
.command("monthly")
|
|
204
|
+
.description("Show monthly usage report (launches TUI by default)")
|
|
205
|
+
.option("--light", "Use legacy CLI table output instead of TUI")
|
|
206
|
+
.option("--json", "Output as JSON (for scripting)")
|
|
207
|
+
.option("--opencode", "Show only OpenCode usage")
|
|
208
|
+
.option("--claude", "Show only Claude Code usage")
|
|
209
|
+
.option("--codex", "Show only Codex CLI usage")
|
|
210
|
+
.option("--gemini", "Show only Gemini CLI usage")
|
|
211
|
+
.option("--cursor", "Show only Cursor IDE usage")
|
|
212
|
+
.option("--today", "Show only today's usage")
|
|
213
|
+
.option("--week", "Show last 7 days")
|
|
214
|
+
.option("--month", "Show current month")
|
|
215
|
+
.option("--since <date>", "Start date (YYYY-MM-DD)")
|
|
216
|
+
.option("--until <date>", "End date (YYYY-MM-DD)")
|
|
217
|
+
.option("--year <year>", "Filter to specific year")
|
|
218
|
+
.option("--benchmark", "Show processing time")
|
|
219
|
+
.action(async (options) => {
|
|
220
|
+
if (options.json) {
|
|
221
|
+
await outputJsonReport("monthly", options);
|
|
222
|
+
} else if (options.light) {
|
|
223
|
+
await showMonthlyReport(options);
|
|
224
|
+
} else {
|
|
225
|
+
const launchTUI = await tryLoadTUI();
|
|
226
|
+
if (launchTUI) {
|
|
227
|
+
await launchTUI(buildTUIOptions(options, "daily"));
|
|
228
|
+
} else {
|
|
229
|
+
showTUIUnavailableMessage();
|
|
230
|
+
await showMonthlyReport(options);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
program
|
|
236
|
+
.command("models")
|
|
237
|
+
.description("Show usage breakdown by model (launches TUI by default)")
|
|
238
|
+
.option("--light", "Use legacy CLI table output instead of TUI")
|
|
239
|
+
.option("--json", "Output as JSON (for scripting)")
|
|
240
|
+
.option("--opencode", "Show only OpenCode usage")
|
|
241
|
+
.option("--claude", "Show only Claude Code usage")
|
|
242
|
+
.option("--codex", "Show only Codex CLI usage")
|
|
243
|
+
.option("--gemini", "Show only Gemini CLI usage")
|
|
244
|
+
.option("--cursor", "Show only Cursor IDE usage")
|
|
245
|
+
.option("--today", "Show only today's usage")
|
|
246
|
+
.option("--week", "Show last 7 days")
|
|
247
|
+
.option("--month", "Show current month")
|
|
248
|
+
.option("--since <date>", "Start date (YYYY-MM-DD)")
|
|
249
|
+
.option("--until <date>", "End date (YYYY-MM-DD)")
|
|
250
|
+
.option("--year <year>", "Filter to specific year")
|
|
251
|
+
.option("--benchmark", "Show processing time")
|
|
252
|
+
.action(async (options) => {
|
|
253
|
+
if (options.json) {
|
|
254
|
+
await outputJsonReport("models", options);
|
|
255
|
+
} else if (options.light) {
|
|
256
|
+
await showModelReport(options);
|
|
257
|
+
} else {
|
|
258
|
+
const launchTUI = await tryLoadTUI();
|
|
259
|
+
if (launchTUI) {
|
|
260
|
+
await launchTUI(buildTUIOptions(options, "model"));
|
|
261
|
+
} else {
|
|
262
|
+
showTUIUnavailableMessage();
|
|
263
|
+
await showModelReport(options);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
program
|
|
269
|
+
.command("graph")
|
|
270
|
+
.description("Export contribution graph data as JSON")
|
|
271
|
+
.option("--output <file>", "Write to file instead of stdout")
|
|
272
|
+
.option("--opencode", "Include only OpenCode data")
|
|
273
|
+
.option("--claude", "Include only Claude Code data")
|
|
274
|
+
.option("--codex", "Include only Codex CLI data")
|
|
275
|
+
.option("--gemini", "Include only Gemini CLI data")
|
|
276
|
+
.option("--cursor", "Include only Cursor IDE data")
|
|
277
|
+
.option("--today", "Show only today's usage")
|
|
278
|
+
.option("--week", "Show last 7 days")
|
|
279
|
+
.option("--month", "Show current month")
|
|
280
|
+
.option("--since <date>", "Start date (YYYY-MM-DD)")
|
|
281
|
+
.option("--until <date>", "End date (YYYY-MM-DD)")
|
|
282
|
+
.option("--year <year>", "Filter to specific year")
|
|
283
|
+
.option("--benchmark", "Show processing time")
|
|
284
|
+
.action(async (options) => {
|
|
285
|
+
await handleGraphCommand(options);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// =========================================================================
|
|
289
|
+
// Authentication Commands
|
|
290
|
+
// =========================================================================
|
|
291
|
+
|
|
292
|
+
program
|
|
293
|
+
.command("login")
|
|
294
|
+
.description("Login to Tokscale (opens browser for GitHub auth)")
|
|
295
|
+
.action(async () => {
|
|
296
|
+
await login();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
program
|
|
300
|
+
.command("logout")
|
|
301
|
+
.description("Logout from Tokscale")
|
|
302
|
+
.action(async () => {
|
|
303
|
+
await logout();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
program
|
|
307
|
+
.command("whoami")
|
|
308
|
+
.description("Show current logged in user")
|
|
309
|
+
.action(async () => {
|
|
310
|
+
await whoami();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// =========================================================================
|
|
314
|
+
// Submit Command
|
|
315
|
+
// =========================================================================
|
|
316
|
+
|
|
317
|
+
program
|
|
318
|
+
.command("submit")
|
|
319
|
+
.description("Submit your usage data to Tokscale")
|
|
320
|
+
.option("--opencode", "Include only OpenCode data")
|
|
321
|
+
.option("--claude", "Include only Claude Code data")
|
|
322
|
+
.option("--codex", "Include only Codex CLI data")
|
|
323
|
+
.option("--gemini", "Include only Gemini CLI data")
|
|
324
|
+
.option("--cursor", "Include only Cursor IDE data")
|
|
325
|
+
.option("--since <date>", "Start date (YYYY-MM-DD)")
|
|
326
|
+
.option("--until <date>", "End date (YYYY-MM-DD)")
|
|
327
|
+
.option("--year <year>", "Filter to specific year")
|
|
328
|
+
.option("--dry-run", "Show what would be submitted without actually submitting")
|
|
329
|
+
.action(async (options) => {
|
|
330
|
+
await submit({
|
|
331
|
+
opencode: options.opencode,
|
|
332
|
+
claude: options.claude,
|
|
333
|
+
codex: options.codex,
|
|
334
|
+
gemini: options.gemini,
|
|
335
|
+
cursor: options.cursor,
|
|
336
|
+
since: options.since,
|
|
337
|
+
until: options.until,
|
|
338
|
+
year: options.year,
|
|
339
|
+
dryRun: options.dryRun,
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// =========================================================================
|
|
344
|
+
// Interactive TUI Command
|
|
345
|
+
// =========================================================================
|
|
346
|
+
|
|
347
|
+
program
|
|
348
|
+
.command("tui")
|
|
349
|
+
.description("Launch interactive terminal UI")
|
|
350
|
+
.option("--opencode", "Show only OpenCode usage")
|
|
351
|
+
.option("--claude", "Show only Claude Code usage")
|
|
352
|
+
.option("--codex", "Show only Codex CLI usage")
|
|
353
|
+
.option("--gemini", "Show only Gemini CLI usage")
|
|
354
|
+
.option("--cursor", "Show only Cursor IDE usage")
|
|
355
|
+
.option("--today", "Show only today's usage")
|
|
356
|
+
.option("--week", "Show last 7 days")
|
|
357
|
+
.option("--month", "Show current month")
|
|
358
|
+
.option("--since <date>", "Start date (YYYY-MM-DD)")
|
|
359
|
+
.option("--until <date>", "End date (YYYY-MM-DD)")
|
|
360
|
+
.option("--year <year>", "Filter to specific year")
|
|
361
|
+
.action(async (options) => {
|
|
362
|
+
const launchTUI = await tryLoadTUI();
|
|
363
|
+
if (launchTUI) {
|
|
364
|
+
await launchTUI(buildTUIOptions(options));
|
|
365
|
+
} else {
|
|
366
|
+
showTUIUnavailableMessage();
|
|
367
|
+
process.exit(1);
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// =========================================================================
|
|
372
|
+
// Cursor IDE Authentication Commands
|
|
373
|
+
// =========================================================================
|
|
374
|
+
|
|
375
|
+
const cursorCommand = program
|
|
376
|
+
.command("cursor")
|
|
377
|
+
.description("Cursor IDE integration commands");
|
|
378
|
+
|
|
379
|
+
cursorCommand
|
|
380
|
+
.command("login")
|
|
381
|
+
.description("Login to Cursor (paste your session token)")
|
|
382
|
+
.action(async () => {
|
|
383
|
+
await cursorLogin();
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
cursorCommand
|
|
387
|
+
.command("logout")
|
|
388
|
+
.description("Logout from Cursor")
|
|
389
|
+
.action(async () => {
|
|
390
|
+
await cursorLogout();
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
cursorCommand
|
|
394
|
+
.command("status")
|
|
395
|
+
.description("Check Cursor authentication status")
|
|
396
|
+
.action(async () => {
|
|
397
|
+
await cursorStatus();
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// Check if a subcommand was provided
|
|
401
|
+
const args = process.argv.slice(2);
|
|
402
|
+
const firstArg = args[0] || '';
|
|
403
|
+
// Global flags should go to main program
|
|
404
|
+
const isGlobalFlag = ['--help', '-h', '--version', '-V'].includes(firstArg);
|
|
405
|
+
const hasSubcommand = args.length > 0 && !firstArg.startsWith('-');
|
|
406
|
+
const knownCommands = ['monthly', 'models', 'graph', 'login', 'logout', 'whoami', 'submit', 'cursor', 'tui', 'help'];
|
|
407
|
+
const isKnownCommand = hasSubcommand && knownCommands.includes(firstArg);
|
|
408
|
+
|
|
409
|
+
if (isKnownCommand || isGlobalFlag) {
|
|
410
|
+
// Run the specified subcommand or show full help/version
|
|
411
|
+
await program.parseAsync();
|
|
412
|
+
} else {
|
|
413
|
+
// No subcommand - launch TUI by default, or legacy CLI with --light, or JSON with --json
|
|
414
|
+
const defaultProgram = new Command();
|
|
415
|
+
defaultProgram
|
|
416
|
+
.option("--light", "Use legacy CLI table output instead of TUI")
|
|
417
|
+
.option("--json", "Output as JSON (for scripting)")
|
|
418
|
+
.option("--opencode", "Show only OpenCode usage")
|
|
419
|
+
.option("--claude", "Show only Claude Code usage")
|
|
420
|
+
.option("--codex", "Show only Codex CLI usage")
|
|
421
|
+
.option("--gemini", "Show only Gemini CLI usage")
|
|
422
|
+
.option("--cursor", "Show only Cursor IDE usage")
|
|
423
|
+
.option("--today", "Show only today's usage")
|
|
424
|
+
.option("--week", "Show last 7 days")
|
|
425
|
+
.option("--month", "Show current month")
|
|
426
|
+
.option("--since <date>", "Start date (YYYY-MM-DD)")
|
|
427
|
+
.option("--until <date>", "End date (YYYY-MM-DD)")
|
|
428
|
+
.option("--year <year>", "Filter to specific year")
|
|
429
|
+
.option("--benchmark", "Show processing time")
|
|
430
|
+
.parse();
|
|
431
|
+
|
|
432
|
+
const opts = defaultProgram.opts();
|
|
433
|
+
if (opts.json) {
|
|
434
|
+
await outputJsonReport("models", opts);
|
|
435
|
+
} else if (opts.light) {
|
|
436
|
+
await showModelReport(opts);
|
|
437
|
+
} else {
|
|
438
|
+
const launchTUI = await tryLoadTUI();
|
|
439
|
+
if (launchTUI) {
|
|
440
|
+
await launchTUI(buildTUIOptions(opts));
|
|
441
|
+
} else {
|
|
442
|
+
showTUIUnavailableMessage();
|
|
443
|
+
await showModelReport(opts);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function getEnabledSources(options: FilterOptions): SourceType[] | undefined {
|
|
450
|
+
const hasFilter = options.opencode || options.claude || options.codex || options.gemini || options.cursor;
|
|
451
|
+
if (!hasFilter) return undefined; // All sources
|
|
452
|
+
|
|
453
|
+
const sources: SourceType[] = [];
|
|
454
|
+
if (options.opencode) sources.push("opencode");
|
|
455
|
+
if (options.claude) sources.push("claude");
|
|
456
|
+
if (options.codex) sources.push("codex");
|
|
457
|
+
if (options.gemini) sources.push("gemini");
|
|
458
|
+
if (options.cursor) sources.push("cursor");
|
|
459
|
+
return sources;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function logNativeStatus(): void {
|
|
463
|
+
if (!isNativeAvailable()) {
|
|
464
|
+
console.log(pc.yellow(" Note: Using TypeScript fallback (native module not available)"));
|
|
465
|
+
console.log(pc.gray(" Run 'bun run build:core' for ~10x faster processing.\n"));
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function fetchPricingData(): Promise<PricingFetcher> {
|
|
470
|
+
const fetcher = new PricingFetcher();
|
|
471
|
+
await fetcher.fetchPricing();
|
|
472
|
+
return fetcher;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Sync Cursor usage data from API to local cache.
|
|
477
|
+
* Only attempts sync if user is authenticated with Cursor.
|
|
478
|
+
*/
|
|
479
|
+
async function syncCursorData(): Promise<CursorSyncResult> {
|
|
480
|
+
const credentials = loadCursorCredentials();
|
|
481
|
+
if (!credentials) {
|
|
482
|
+
return { attempted: false, synced: false, rows: 0 };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const result = await syncCursorCache();
|
|
486
|
+
return {
|
|
487
|
+
attempted: true,
|
|
488
|
+
synced: result.synced,
|
|
489
|
+
rows: result.rows,
|
|
490
|
+
error: result.error,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
interface LoadedDataSources {
|
|
495
|
+
fetcher: PricingFetcher;
|
|
496
|
+
cursorSync: CursorSyncResult;
|
|
497
|
+
localMessages: ParsedMessages | null;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Load all data sources in parallel (two-phase optimization):
|
|
502
|
+
* - Cursor API sync (network)
|
|
503
|
+
* - Pricing fetch (network)
|
|
504
|
+
* - Local file parsing (CPU/IO) - OpenCode, Claude, Codex, Gemini
|
|
505
|
+
*
|
|
506
|
+
* This overlaps network I/O with local file parsing for better performance.
|
|
507
|
+
*/
|
|
508
|
+
async function loadDataSourcesParallel(
|
|
509
|
+
localSources: SourceType[],
|
|
510
|
+
dateFilters: { since?: string; until?: string; year?: string }
|
|
511
|
+
): Promise<LoadedDataSources> {
|
|
512
|
+
// Skip local parsing if no local sources requested (e.g., cursor-only mode)
|
|
513
|
+
const shouldParseLocal = localSources.length > 0;
|
|
514
|
+
|
|
515
|
+
// Use Promise.allSettled for graceful degradation
|
|
516
|
+
const [cursorResult, pricingResult, localResult] = await Promise.allSettled([
|
|
517
|
+
syncCursorData(),
|
|
518
|
+
fetchPricingData(),
|
|
519
|
+
// Parse local sources in parallel (excludes Cursor) - skip if empty
|
|
520
|
+
shouldParseLocal
|
|
521
|
+
? parseLocalSourcesAsync({
|
|
522
|
+
sources: localSources.filter(s => s !== 'cursor'),
|
|
523
|
+
since: dateFilters.since,
|
|
524
|
+
until: dateFilters.until,
|
|
525
|
+
year: dateFilters.year,
|
|
526
|
+
})
|
|
527
|
+
: Promise.resolve(null),
|
|
528
|
+
]);
|
|
529
|
+
|
|
530
|
+
// Handle partial failures gracefully
|
|
531
|
+
const cursorSync: CursorSyncResult = cursorResult.status === 'fulfilled'
|
|
532
|
+
? cursorResult.value
|
|
533
|
+
: { attempted: true, synced: false, rows: 0, error: 'Cursor sync failed' };
|
|
534
|
+
|
|
535
|
+
const fetcher: PricingFetcher = pricingResult.status === 'fulfilled'
|
|
536
|
+
? pricingResult.value
|
|
537
|
+
: new PricingFetcher(); // Empty pricing → costs = 0
|
|
538
|
+
|
|
539
|
+
const localMessages: ParsedMessages | null = localResult.status === 'fulfilled'
|
|
540
|
+
? localResult.value
|
|
541
|
+
: null;
|
|
542
|
+
|
|
543
|
+
return { fetcher, cursorSync, localMessages };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function showModelReport(options: FilterOptions & DateFilterOptions & { benchmark?: boolean }) {
|
|
547
|
+
logNativeStatus();
|
|
548
|
+
|
|
549
|
+
const dateFilters = getDateFilters(options);
|
|
550
|
+
const enabledSources = getEnabledSources(options);
|
|
551
|
+
const onlyCursor = enabledSources?.length === 1 && enabledSources[0] === 'cursor';
|
|
552
|
+
const includeCursor = !enabledSources || enabledSources.includes('cursor');
|
|
553
|
+
|
|
554
|
+
// Check cursor auth early if cursor-only mode
|
|
555
|
+
if (onlyCursor) {
|
|
556
|
+
const credentials = loadCursorCredentials();
|
|
557
|
+
if (!credentials) {
|
|
558
|
+
console.log(pc.red("\n Error: Cursor authentication required."));
|
|
559
|
+
console.log(pc.gray(" Run 'tokscale cursor login' to authenticate with Cursor.\n"));
|
|
560
|
+
process.exit(1);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const dateRange = getDateRangeLabel(options);
|
|
565
|
+
const title = dateRange
|
|
566
|
+
? `Token Usage Report by Model (${dateRange})`
|
|
567
|
+
: "Token Usage Report by Model";
|
|
568
|
+
|
|
569
|
+
console.log(pc.cyan(`\n ${title}`));
|
|
570
|
+
if (options.benchmark) {
|
|
571
|
+
console.log(pc.gray(` Using: Rust native module v${getNativeVersion()}`));
|
|
572
|
+
}
|
|
573
|
+
console.log();
|
|
574
|
+
|
|
575
|
+
// Start spinner for loading phase
|
|
576
|
+
const spinner = createSpinner({ color: "cyan" });
|
|
577
|
+
spinner.start(pc.gray("Loading data sources..."));
|
|
578
|
+
|
|
579
|
+
// Filter out cursor for local parsing (it's synced separately via network)
|
|
580
|
+
const localSources: SourceType[] = (enabledSources || ['opencode', 'claude', 'codex', 'gemini', 'cursor'])
|
|
581
|
+
.filter(s => s !== 'cursor');
|
|
582
|
+
|
|
583
|
+
// Two-phase parallel loading: network (Cursor + pricing) overlaps with local file parsing
|
|
584
|
+
// If cursor-only, skip local parsing entirely
|
|
585
|
+
const { fetcher, cursorSync, localMessages } = await loadDataSourcesParallel(
|
|
586
|
+
onlyCursor ? [] : localSources,
|
|
587
|
+
dateFilters
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
if (!localMessages && !onlyCursor) {
|
|
591
|
+
spinner.error('Failed to parse local session files');
|
|
592
|
+
process.exit(1);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
spinner.update(pc.gray("Finalizing report..."));
|
|
596
|
+
const startTime = performance.now();
|
|
597
|
+
|
|
598
|
+
let report: ModelReport;
|
|
599
|
+
try {
|
|
600
|
+
const emptyMessages: ParsedMessages = { messages: [], opencodeCount: 0, claudeCount: 0, codexCount: 0, geminiCount: 0, processingTimeMs: 0 };
|
|
601
|
+
report = await finalizeReportAsync({
|
|
602
|
+
localMessages: localMessages || emptyMessages,
|
|
603
|
+
pricing: fetcher.toPricingEntries(),
|
|
604
|
+
includeCursor: includeCursor && cursorSync.synced,
|
|
605
|
+
since: dateFilters.since,
|
|
606
|
+
until: dateFilters.until,
|
|
607
|
+
year: dateFilters.year,
|
|
608
|
+
});
|
|
609
|
+
} catch (e) {
|
|
610
|
+
spinner.error(`Error: ${(e as Error).message}`);
|
|
611
|
+
process.exit(1);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const processingTime = performance.now() - startTime;
|
|
615
|
+
spinner.stop();
|
|
616
|
+
|
|
617
|
+
if (report.entries.length === 0) {
|
|
618
|
+
if (onlyCursor && !cursorSync.synced) {
|
|
619
|
+
console.log(pc.yellow(" No Cursor data available."));
|
|
620
|
+
console.log(pc.gray(" Run 'tokscale cursor login' to authenticate with Cursor.\n"));
|
|
621
|
+
} else {
|
|
622
|
+
console.log(pc.yellow(" No usage data found.\n"));
|
|
623
|
+
}
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Create table
|
|
628
|
+
const table = createUsageTable("Source/Model");
|
|
629
|
+
|
|
630
|
+
for (const entry of report.entries) {
|
|
631
|
+
const sourceLabel = getSourceLabel(entry.source);
|
|
632
|
+
const modelDisplay = `${pc.dim(sourceLabel)} ${formatModelName(entry.model)}`;
|
|
633
|
+
table.push(
|
|
634
|
+
formatUsageRow(
|
|
635
|
+
modelDisplay,
|
|
636
|
+
[entry.model],
|
|
637
|
+
entry.input,
|
|
638
|
+
entry.output,
|
|
639
|
+
entry.cacheWrite,
|
|
640
|
+
entry.cacheRead,
|
|
641
|
+
entry.cost
|
|
642
|
+
)
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Add totals row
|
|
647
|
+
table.push(
|
|
648
|
+
formatTotalsRow(
|
|
649
|
+
report.totalInput,
|
|
650
|
+
report.totalOutput,
|
|
651
|
+
report.totalCacheWrite,
|
|
652
|
+
report.totalCacheRead,
|
|
653
|
+
report.totalCost
|
|
654
|
+
)
|
|
655
|
+
);
|
|
656
|
+
|
|
657
|
+
console.log(table.toString());
|
|
658
|
+
|
|
659
|
+
// Summary stats
|
|
660
|
+
console.log(
|
|
661
|
+
pc.gray(
|
|
662
|
+
`\n Total: ${formatNumber(report.totalMessages)} messages, ` +
|
|
663
|
+
`${formatNumber(report.totalInput + report.totalOutput + report.totalCacheRead + report.totalCacheWrite)} tokens, ` +
|
|
664
|
+
`${pc.green(formatCurrency(report.totalCost))}`
|
|
665
|
+
)
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
if (options.benchmark) {
|
|
669
|
+
console.log(pc.gray(` Processing time: ${processingTime.toFixed(0)}ms (Rust) + ${report.processingTimeMs}ms (parsing)`));
|
|
670
|
+
if (cursorSync.attempted) {
|
|
671
|
+
if (cursorSync.synced) {
|
|
672
|
+
console.log(pc.gray(` Cursor: ${cursorSync.rows} usage events synced (full lifetime data)`));
|
|
673
|
+
} else {
|
|
674
|
+
console.log(pc.yellow(` Cursor: sync failed - ${cursorSync.error}`));
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
console.log();
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async function showMonthlyReport(options: FilterOptions & DateFilterOptions & { benchmark?: boolean }) {
|
|
683
|
+
logNativeStatus();
|
|
684
|
+
|
|
685
|
+
const dateRange = getDateRangeLabel(options);
|
|
686
|
+
const title = dateRange
|
|
687
|
+
? `Monthly Token Usage Report (${dateRange})`
|
|
688
|
+
: "Monthly Token Usage Report";
|
|
689
|
+
|
|
690
|
+
console.log(pc.cyan(`\n ${title}`));
|
|
691
|
+
if (options.benchmark) {
|
|
692
|
+
console.log(pc.gray(` Using: Rust native module v${getNativeVersion()}`));
|
|
693
|
+
}
|
|
694
|
+
console.log();
|
|
695
|
+
|
|
696
|
+
// Start spinner for loading phase
|
|
697
|
+
const spinner = createSpinner({ color: "cyan" });
|
|
698
|
+
spinner.start(pc.gray("Loading data sources..."));
|
|
699
|
+
|
|
700
|
+
const dateFilters = getDateFilters(options);
|
|
701
|
+
const enabledSources = getEnabledSources(options);
|
|
702
|
+
// Filter out cursor for local parsing (it's synced separately via network)
|
|
703
|
+
const localSources: SourceType[] = (enabledSources || ['opencode', 'claude', 'codex', 'gemini', 'cursor'])
|
|
704
|
+
.filter(s => s !== 'cursor');
|
|
705
|
+
const includeCursor = !enabledSources || enabledSources.includes('cursor');
|
|
706
|
+
|
|
707
|
+
// Two-phase parallel loading: network (Cursor + pricing) overlaps with local file parsing
|
|
708
|
+
const { fetcher, cursorSync, localMessages } = await loadDataSourcesParallel(localSources, dateFilters);
|
|
709
|
+
|
|
710
|
+
if (!localMessages) {
|
|
711
|
+
spinner.error('Failed to parse local session files');
|
|
712
|
+
process.exit(1);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
spinner.update(pc.gray("Finalizing report..."));
|
|
716
|
+
const startTime = performance.now();
|
|
717
|
+
|
|
718
|
+
let report: MonthlyReport;
|
|
719
|
+
try {
|
|
720
|
+
report = await finalizeMonthlyReportAsync({
|
|
721
|
+
localMessages,
|
|
722
|
+
pricing: fetcher.toPricingEntries(),
|
|
723
|
+
includeCursor: includeCursor && cursorSync.synced,
|
|
724
|
+
since: dateFilters.since,
|
|
725
|
+
until: dateFilters.until,
|
|
726
|
+
year: dateFilters.year,
|
|
727
|
+
});
|
|
728
|
+
} catch (e) {
|
|
729
|
+
spinner.error(`Error: ${(e as Error).message}`);
|
|
730
|
+
process.exit(1);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const processingTime = performance.now() - startTime;
|
|
734
|
+
spinner.stop();
|
|
735
|
+
|
|
736
|
+
if (report.entries.length === 0) {
|
|
737
|
+
console.log(pc.yellow(" No usage data found.\n"));
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Create table
|
|
742
|
+
const table = createUsageTable("Month");
|
|
743
|
+
|
|
744
|
+
for (const entry of report.entries) {
|
|
745
|
+
table.push(
|
|
746
|
+
formatUsageRow(
|
|
747
|
+
entry.month,
|
|
748
|
+
entry.models,
|
|
749
|
+
entry.input,
|
|
750
|
+
entry.output,
|
|
751
|
+
entry.cacheWrite,
|
|
752
|
+
entry.cacheRead,
|
|
753
|
+
entry.cost
|
|
754
|
+
)
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Add totals row
|
|
759
|
+
const totalInput = report.entries.reduce((sum, e) => sum + e.input, 0);
|
|
760
|
+
const totalOutput = report.entries.reduce((sum, e) => sum + e.output, 0);
|
|
761
|
+
const totalCacheRead = report.entries.reduce((sum, e) => sum + e.cacheRead, 0);
|
|
762
|
+
const totalCacheWrite = report.entries.reduce((sum, e) => sum + e.cacheWrite, 0);
|
|
763
|
+
|
|
764
|
+
table.push(
|
|
765
|
+
formatTotalsRow(totalInput, totalOutput, totalCacheWrite, totalCacheRead, report.totalCost)
|
|
766
|
+
);
|
|
767
|
+
|
|
768
|
+
console.log(table.toString());
|
|
769
|
+
console.log(pc.gray(`\n Total Cost: ${pc.green(formatCurrency(report.totalCost))}`));
|
|
770
|
+
|
|
771
|
+
if (options.benchmark) {
|
|
772
|
+
console.log(pc.gray(` Processing time: ${processingTime.toFixed(0)}ms (Rust) + ${report.processingTimeMs}ms (parsing)`));
|
|
773
|
+
if (cursorSync.attempted) {
|
|
774
|
+
if (cursorSync.synced) {
|
|
775
|
+
console.log(pc.gray(` Cursor: ${cursorSync.rows} usage events synced (full lifetime data)`));
|
|
776
|
+
} else {
|
|
777
|
+
console.log(pc.yellow(` Cursor: sync failed - ${cursorSync.error}`));
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
console.log();
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
type JsonReportType = "models" | "monthly";
|
|
786
|
+
|
|
787
|
+
async function outputJsonReport(
|
|
788
|
+
reportType: JsonReportType,
|
|
789
|
+
options: FilterOptions & DateFilterOptions
|
|
790
|
+
) {
|
|
791
|
+
logNativeStatus();
|
|
792
|
+
|
|
793
|
+
const dateFilters = getDateFilters(options);
|
|
794
|
+
const enabledSources = getEnabledSources(options);
|
|
795
|
+
const onlyCursor = enabledSources?.length === 1 && enabledSources[0] === 'cursor';
|
|
796
|
+
const includeCursor = !enabledSources || enabledSources.includes('cursor');
|
|
797
|
+
const localSources: SourceType[] = (enabledSources || ['opencode', 'claude', 'codex', 'gemini', 'cursor'])
|
|
798
|
+
.filter(s => s !== 'cursor');
|
|
799
|
+
|
|
800
|
+
const { fetcher, cursorSync, localMessages } = await loadDataSourcesParallel(
|
|
801
|
+
onlyCursor ? [] : localSources,
|
|
802
|
+
dateFilters
|
|
803
|
+
);
|
|
804
|
+
|
|
805
|
+
if (!localMessages && !onlyCursor) {
|
|
806
|
+
console.error(JSON.stringify({ error: "Failed to parse local session files" }));
|
|
807
|
+
process.exit(1);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const emptyMessages: ParsedMessages = { messages: [], opencodeCount: 0, claudeCount: 0, codexCount: 0, geminiCount: 0, processingTimeMs: 0 };
|
|
811
|
+
|
|
812
|
+
if (reportType === "models") {
|
|
813
|
+
const report = await finalizeReportAsync({
|
|
814
|
+
localMessages: localMessages || emptyMessages,
|
|
815
|
+
pricing: fetcher.toPricingEntries(),
|
|
816
|
+
includeCursor: includeCursor && cursorSync.synced,
|
|
817
|
+
since: dateFilters.since,
|
|
818
|
+
until: dateFilters.until,
|
|
819
|
+
year: dateFilters.year,
|
|
820
|
+
});
|
|
821
|
+
console.log(JSON.stringify(report, null, 2));
|
|
822
|
+
} else {
|
|
823
|
+
const report = await finalizeMonthlyReportAsync({
|
|
824
|
+
localMessages: localMessages || emptyMessages,
|
|
825
|
+
pricing: fetcher.toPricingEntries(),
|
|
826
|
+
includeCursor: includeCursor && cursorSync.synced,
|
|
827
|
+
since: dateFilters.since,
|
|
828
|
+
until: dateFilters.until,
|
|
829
|
+
year: dateFilters.year,
|
|
830
|
+
});
|
|
831
|
+
console.log(JSON.stringify(report, null, 2));
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
interface GraphCommandOptions extends FilterOptions, DateFilterOptions {
|
|
836
|
+
output?: string;
|
|
837
|
+
benchmark?: boolean;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
async function handleGraphCommand(options: GraphCommandOptions) {
|
|
841
|
+
logNativeStatus();
|
|
842
|
+
|
|
843
|
+
// Start spinner for loading phase (only if outputting to file, not stdout)
|
|
844
|
+
const spinner = options.output ? createSpinner({ color: "cyan" }) : null;
|
|
845
|
+
spinner?.start(pc.gray("Loading data sources..."));
|
|
846
|
+
|
|
847
|
+
const dateFilters = getDateFilters(options);
|
|
848
|
+
const enabledSources = getEnabledSources(options);
|
|
849
|
+
// Filter out cursor for local parsing (it's synced separately via network)
|
|
850
|
+
const localSources: SourceType[] = (enabledSources || ['opencode', 'claude', 'codex', 'gemini', 'cursor'])
|
|
851
|
+
.filter(s => s !== 'cursor');
|
|
852
|
+
const includeCursor = !enabledSources || enabledSources.includes('cursor');
|
|
853
|
+
|
|
854
|
+
// Two-phase parallel loading: network (Cursor + pricing) overlaps with local file parsing
|
|
855
|
+
const { fetcher, cursorSync, localMessages } = await loadDataSourcesParallel(localSources, dateFilters);
|
|
856
|
+
|
|
857
|
+
if (!localMessages) {
|
|
858
|
+
spinner?.error('Failed to parse local session files');
|
|
859
|
+
process.exit(1);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
spinner?.update(pc.gray("Generating graph data..."));
|
|
863
|
+
const startTime = performance.now();
|
|
864
|
+
|
|
865
|
+
const data = await finalizeGraphAsync({
|
|
866
|
+
localMessages,
|
|
867
|
+
pricing: fetcher.toPricingEntries(),
|
|
868
|
+
includeCursor: includeCursor && cursorSync.synced,
|
|
869
|
+
since: dateFilters.since,
|
|
870
|
+
until: dateFilters.until,
|
|
871
|
+
year: dateFilters.year,
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
const processingTime = performance.now() - startTime;
|
|
875
|
+
spinner?.stop();
|
|
876
|
+
|
|
877
|
+
const jsonOutput = JSON.stringify(data, null, 2);
|
|
878
|
+
|
|
879
|
+
// Output to file or stdout
|
|
880
|
+
if (options.output) {
|
|
881
|
+
fs.writeFileSync(options.output, jsonOutput, "utf-8");
|
|
882
|
+
console.error(pc.green(`✓ Graph data written to ${options.output}`));
|
|
883
|
+
console.error(
|
|
884
|
+
pc.gray(
|
|
885
|
+
` ${data.contributions.length} days, ${data.summary.sources.length} sources, ${data.summary.models.length} models`
|
|
886
|
+
)
|
|
887
|
+
);
|
|
888
|
+
console.error(pc.gray(` Total: ${formatCurrency(data.summary.totalCost)}`));
|
|
889
|
+
if (options.benchmark) {
|
|
890
|
+
console.error(pc.gray(` Processing time: ${processingTime.toFixed(0)}ms (Rust native)`));
|
|
891
|
+
if (cursorSync.attempted) {
|
|
892
|
+
if (cursorSync.synced) {
|
|
893
|
+
console.error(pc.gray(` Cursor: ${cursorSync.rows} usage events synced (full lifetime data)`));
|
|
894
|
+
} else {
|
|
895
|
+
console.error(pc.yellow(` Cursor: sync failed - ${cursorSync.error}`));
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
} else {
|
|
900
|
+
console.log(jsonOutput);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function getSourceLabel(source: string): string {
|
|
905
|
+
switch (source) {
|
|
906
|
+
case "opencode":
|
|
907
|
+
return "OpenCode";
|
|
908
|
+
case "claude":
|
|
909
|
+
return "Claude";
|
|
910
|
+
case "codex":
|
|
911
|
+
return "Codex";
|
|
912
|
+
case "gemini":
|
|
913
|
+
return "Gemini";
|
|
914
|
+
case "cursor":
|
|
915
|
+
return "Cursor";
|
|
916
|
+
default:
|
|
917
|
+
return source;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// =============================================================================
|
|
922
|
+
// Cursor IDE Authentication
|
|
923
|
+
// =============================================================================
|
|
924
|
+
|
|
925
|
+
async function cursorLogin(): Promise<void> {
|
|
926
|
+
const credentials = loadCursorCredentials();
|
|
927
|
+
if (credentials) {
|
|
928
|
+
console.log(pc.yellow("\n Already logged in to Cursor."));
|
|
929
|
+
console.log(pc.gray(" Run 'tokscale cursor logout' to sign out first.\n"));
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
console.log(pc.cyan("\n Cursor IDE - Login\n"));
|
|
934
|
+
console.log(pc.white(" To get your session token:"));
|
|
935
|
+
console.log(pc.gray(" 1. Open https://www.cursor.com/settings in your browser"));
|
|
936
|
+
console.log(pc.gray(" 2. Open Developer Tools (F12) > Network tab"));
|
|
937
|
+
console.log(pc.gray(" 3. Find any request to cursor.com/api"));
|
|
938
|
+
console.log(pc.gray(" 4. Copy the 'WorkosCursorSessionToken' cookie value"));
|
|
939
|
+
console.log();
|
|
940
|
+
|
|
941
|
+
// Read token from stdin
|
|
942
|
+
const readline = await import("node:readline");
|
|
943
|
+
const rl = readline.createInterface({
|
|
944
|
+
input: process.stdin,
|
|
945
|
+
output: process.stdout,
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
const token = await new Promise<string>((resolve) => {
|
|
949
|
+
rl.question(pc.white(" Paste your session token: "), (answer) => {
|
|
950
|
+
rl.close();
|
|
951
|
+
resolve(answer.trim());
|
|
952
|
+
});
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
if (!token) {
|
|
956
|
+
console.log(pc.red("\n No token provided. Login cancelled.\n"));
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Validate the token
|
|
961
|
+
console.log(pc.gray("\n Validating token..."));
|
|
962
|
+
const validation = await validateCursorSession(token);
|
|
963
|
+
|
|
964
|
+
if (!validation.valid) {
|
|
965
|
+
console.log(pc.red(`\n Invalid token: ${validation.error}`));
|
|
966
|
+
console.log(pc.gray(" Please try again with a valid session token.\n"));
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// Save credentials
|
|
971
|
+
saveCursorCredentials({
|
|
972
|
+
sessionToken: token,
|
|
973
|
+
createdAt: new Date().toISOString(),
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
console.log(pc.green("\n Success! Logged in to Cursor."));
|
|
977
|
+
if (validation.membershipType) {
|
|
978
|
+
console.log(pc.gray(` Membership: ${validation.membershipType}`));
|
|
979
|
+
}
|
|
980
|
+
console.log(pc.gray(" Your usage data will now be included in reports.\n"));
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
async function cursorLogout(): Promise<void> {
|
|
984
|
+
const credentials = loadCursorCredentials();
|
|
985
|
+
|
|
986
|
+
if (!credentials) {
|
|
987
|
+
console.log(pc.yellow("\n Not logged in to Cursor.\n"));
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
const cleared = clearCursorCredentials();
|
|
992
|
+
|
|
993
|
+
if (cleared) {
|
|
994
|
+
console.log(pc.green("\n Logged out from Cursor.\n"));
|
|
995
|
+
} else {
|
|
996
|
+
console.error(pc.red("\n Failed to clear Cursor credentials.\n"));
|
|
997
|
+
process.exit(1);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
async function cursorStatus(): Promise<void> {
|
|
1002
|
+
const credentials = loadCursorCredentials();
|
|
1003
|
+
|
|
1004
|
+
if (!credentials) {
|
|
1005
|
+
console.log(pc.yellow("\n Not logged in to Cursor."));
|
|
1006
|
+
console.log(pc.gray(" Run 'tokscale cursor login' to authenticate.\n"));
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
console.log(pc.cyan("\n Cursor IDE - Status\n"));
|
|
1011
|
+
console.log(pc.gray(" Checking session validity..."));
|
|
1012
|
+
|
|
1013
|
+
const validation = await validateCursorSession(credentials.sessionToken);
|
|
1014
|
+
|
|
1015
|
+
if (validation.valid) {
|
|
1016
|
+
console.log(pc.green(" ✓ Session is valid"));
|
|
1017
|
+
if (validation.membershipType) {
|
|
1018
|
+
console.log(pc.white(` Membership: ${validation.membershipType}`));
|
|
1019
|
+
}
|
|
1020
|
+
console.log(pc.gray(` Logged in: ${new Date(credentials.createdAt).toLocaleDateString()}`));
|
|
1021
|
+
|
|
1022
|
+
// Try to fetch usage to show summary
|
|
1023
|
+
try {
|
|
1024
|
+
const usage = await readCursorUsage();
|
|
1025
|
+
const totalCost = usage.byModel.reduce((sum, m) => sum + m.cost, 0);
|
|
1026
|
+
console.log(pc.gray(` Models used: ${usage.byModel.length}`));
|
|
1027
|
+
console.log(pc.gray(` Total usage events: ${usage.rows.length}`));
|
|
1028
|
+
console.log(pc.gray(` Total cost: $${totalCost.toFixed(2)}`));
|
|
1029
|
+
} catch (e) {
|
|
1030
|
+
// Ignore fetch errors for status check
|
|
1031
|
+
}
|
|
1032
|
+
} else {
|
|
1033
|
+
console.log(pc.red(` ✗ Session invalid: ${validation.error}`));
|
|
1034
|
+
console.log(pc.gray(" Run 'tokscale cursor login' to re-authenticate."));
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
console.log(pc.gray(`\n Credentials: ${getCursorCredentialsPath()}\n`));
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
main().catch(console.error);
|