@tokscale/cli 1.0.17 → 1.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/cli.js +214 -91
  2. package/dist/cli.js.map +1 -1
  3. package/dist/graph-types.d.ts +1 -1
  4. package/dist/graph-types.d.ts.map +1 -1
  5. package/dist/native-runner.js +5 -5
  6. package/dist/native-runner.js.map +1 -1
  7. package/dist/native.d.ts +9 -30
  8. package/dist/native.d.ts.map +1 -1
  9. package/dist/native.js +18 -134
  10. package/dist/native.js.map +1 -1
  11. package/dist/sessions/types.d.ts +1 -1
  12. package/dist/sessions/types.d.ts.map +1 -1
  13. package/dist/submit.d.ts +2 -0
  14. package/dist/submit.d.ts.map +1 -1
  15. package/dist/submit.js +32 -16
  16. package/dist/submit.js.map +1 -1
  17. package/dist/tui/App.d.ts.map +1 -1
  18. package/dist/tui/App.js +13 -6
  19. package/dist/tui/App.js.map +1 -1
  20. package/dist/tui/components/DailyView.d.ts.map +1 -1
  21. package/dist/tui/components/DailyView.js +25 -8
  22. package/dist/tui/components/DailyView.js.map +1 -1
  23. package/dist/tui/components/DateBreakdownPanel.js +2 -2
  24. package/dist/tui/components/DateBreakdownPanel.js.map +1 -1
  25. package/dist/tui/components/Footer.d.ts.map +1 -1
  26. package/dist/tui/components/Footer.js +2 -3
  27. package/dist/tui/components/Footer.js.map +1 -1
  28. package/dist/tui/components/LoadingSpinner.d.ts.map +1 -1
  29. package/dist/tui/components/LoadingSpinner.js +1 -2
  30. package/dist/tui/components/LoadingSpinner.js.map +1 -1
  31. package/dist/tui/components/ModelView.js +2 -2
  32. package/dist/tui/components/ModelView.js.map +1 -1
  33. package/dist/tui/config/settings.d.ts +4 -4
  34. package/dist/tui/config/settings.d.ts.map +1 -1
  35. package/dist/tui/config/settings.js +11 -4
  36. package/dist/tui/config/settings.js.map +1 -1
  37. package/dist/tui/hooks/useData.d.ts.map +1 -1
  38. package/dist/tui/hooks/useData.js +29 -42
  39. package/dist/tui/hooks/useData.js.map +1 -1
  40. package/dist/tui/types/index.d.ts +2 -2
  41. package/dist/tui/types/index.d.ts.map +1 -1
  42. package/dist/tui/types/index.js +3 -1
  43. package/dist/tui/types/index.js.map +1 -1
  44. package/dist/tui/utils/colors.d.ts +1 -0
  45. package/dist/tui/utils/colors.d.ts.map +1 -1
  46. package/dist/tui/utils/colors.js +7 -0
  47. package/dist/tui/utils/colors.js.map +1 -1
  48. package/dist/wrapped.d.ts.map +1 -1
  49. package/dist/wrapped.js +20 -48
  50. package/dist/wrapped.js.map +1 -1
  51. package/package.json +2 -2
  52. package/src/cli.ts +232 -97
  53. package/src/graph-types.ts +1 -1
  54. package/src/native-runner.ts +5 -5
  55. package/src/native.ts +35 -200
  56. package/src/sessions/types.ts +1 -1
  57. package/src/submit.ts +36 -22
  58. package/src/tui/App.tsx +9 -6
  59. package/src/tui/components/DailyView.tsx +29 -11
  60. package/src/tui/components/DateBreakdownPanel.tsx +2 -2
  61. package/src/tui/components/Footer.tsx +7 -2
  62. package/src/tui/components/LoadingSpinner.tsx +1 -2
  63. package/src/tui/components/ModelView.tsx +2 -2
  64. package/src/tui/config/settings.ts +18 -9
  65. package/src/tui/hooks/useData.ts +36 -47
  66. package/src/tui/types/index.ts +5 -4
  67. package/src/tui/utils/colors.ts +7 -0
  68. package/src/wrapped.ts +21 -54
  69. package/dist/graph.d.ts +0 -29
  70. package/dist/graph.d.ts.map +0 -1
  71. package/dist/graph.js +0 -383
  72. package/dist/graph.js.map +0 -1
  73. package/dist/pricing.d.ts +0 -58
  74. package/dist/pricing.d.ts.map +0 -1
  75. package/dist/pricing.js +0 -232
  76. package/dist/pricing.js.map +0 -1
  77. package/dist/sessions/claudecode.d.ts +0 -8
  78. package/dist/sessions/claudecode.d.ts.map +0 -1
  79. package/dist/sessions/claudecode.js +0 -84
  80. package/dist/sessions/claudecode.js.map +0 -1
  81. package/dist/sessions/codex.d.ts +0 -8
  82. package/dist/sessions/codex.d.ts.map +0 -1
  83. package/dist/sessions/codex.js +0 -158
  84. package/dist/sessions/codex.js.map +0 -1
  85. package/dist/sessions/gemini.d.ts +0 -8
  86. package/dist/sessions/gemini.d.ts.map +0 -1
  87. package/dist/sessions/gemini.js +0 -66
  88. package/dist/sessions/gemini.js.map +0 -1
  89. package/dist/sessions/index.d.ts +0 -32
  90. package/dist/sessions/index.d.ts.map +0 -1
  91. package/dist/sessions/index.js +0 -96
  92. package/dist/sessions/index.js.map +0 -1
  93. package/dist/sessions/opencode.d.ts +0 -9
  94. package/dist/sessions/opencode.d.ts.map +0 -1
  95. package/dist/sessions/opencode.js +0 -69
  96. package/dist/sessions/opencode.js.map +0 -1
  97. package/dist/sessions/reports.d.ts +0 -58
  98. package/dist/sessions/reports.d.ts.map +0 -1
  99. package/dist/sessions/reports.js +0 -337
  100. package/dist/sessions/reports.js.map +0 -1
  101. package/src/graph.ts +0 -485
  102. package/src/pricing.ts +0 -309
  103. package/src/sessions/claudecode.ts +0 -119
  104. package/src/sessions/codex.ts +0 -227
  105. package/src/sessions/gemini.ts +0 -108
  106. package/src/sessions/index.ts +0 -126
  107. package/src/sessions/opencode.ts +0 -117
  108. package/src/sessions/reports.ts +0 -475
package/src/cli.ts CHANGED
@@ -14,7 +14,7 @@ import pc from "picocolors";
14
14
  import { login, logout, whoami } from "./auth.js";
15
15
  import { submit } from "./submit.js";
16
16
  import { generateWrapped } from "./wrapped.js";
17
- import { PricingFetcher } from "./pricing.js";
17
+
18
18
  import {
19
19
  loadCursorCredentials,
20
20
  saveCursorCredentials,
@@ -48,6 +48,7 @@ import * as fs from "node:fs";
48
48
  import { performance } from "node:perf_hooks";
49
49
  import type { SourceType } from "./graph-types.js";
50
50
  import type { TUIOptions, TabType } from "./tui/types/index.js";
51
+ import { loadSettings } from "./tui/config/settings.js";
51
52
 
52
53
  type LaunchTUIFunction = (options?: TUIOptions) => Promise<void>;
53
54
 
@@ -106,6 +107,8 @@ interface FilterOptions {
106
107
  codex?: boolean;
107
108
  gemini?: boolean;
108
109
  cursor?: boolean;
110
+ amp?: boolean;
111
+ droid?: boolean;
109
112
  }
110
113
 
111
114
  interface DateFilterOptions {
@@ -204,7 +207,7 @@ async function main() {
204
207
 
205
208
  program
206
209
  .name("tokscale")
207
- .description("Tokscale - Track AI coding costs across OpenCode, Claude Code, Codex, Gemini, and Cursor")
210
+ .description("Tokscale - Track AI coding costs across OpenCode, Claude Code, Codex, Gemini, Cursor, and Amp")
208
211
  .version(pkg.version);
209
212
 
210
213
  program
@@ -217,6 +220,8 @@ async function main() {
217
220
  .option("--codex", "Show only Codex CLI usage")
218
221
  .option("--gemini", "Show only Gemini CLI usage")
219
222
  .option("--cursor", "Show only Cursor IDE usage")
223
+ .option("--amp", "Show only Amp usage")
224
+ .option("--droid", "Show only Factory Droid usage")
220
225
  .option("--today", "Show only today's usage")
221
226
  .option("--week", "Show last 7 days")
222
227
  .option("--month", "Show current month")
@@ -224,18 +229,19 @@ async function main() {
224
229
  .option("--until <date>", "End date (YYYY-MM-DD)")
225
230
  .option("--year <year>", "Filter to specific year")
226
231
  .option("--benchmark", "Show processing time")
232
+ .option("--no-spinner", "Disable spinner (for AI agents and scripts - keeps stdout clean)")
227
233
  .action(async (options) => {
228
234
  if (options.json) {
229
235
  await outputJsonReport("monthly", options);
230
236
  } else if (options.light) {
231
- await showMonthlyReport(options);
237
+ await showMonthlyReport(options, { spinner: options.spinner });
232
238
  } else {
233
239
  const launchTUI = await tryLoadTUI();
234
240
  if (launchTUI) {
235
241
  await launchTUI(buildTUIOptions(options, "daily"));
236
242
  } else {
237
243
  showTUIUnavailableMessage();
238
- await showMonthlyReport(options);
244
+ await showMonthlyReport(options, { spinner: options.spinner });
239
245
  }
240
246
  }
241
247
  });
@@ -250,6 +256,8 @@ async function main() {
250
256
  .option("--codex", "Show only Codex CLI usage")
251
257
  .option("--gemini", "Show only Gemini CLI usage")
252
258
  .option("--cursor", "Show only Cursor IDE usage")
259
+ .option("--amp", "Show only Amp usage")
260
+ .option("--droid", "Show only Factory Droid usage")
253
261
  .option("--today", "Show only today's usage")
254
262
  .option("--week", "Show last 7 days")
255
263
  .option("--month", "Show current month")
@@ -257,18 +265,19 @@ async function main() {
257
265
  .option("--until <date>", "End date (YYYY-MM-DD)")
258
266
  .option("--year <year>", "Filter to specific year")
259
267
  .option("--benchmark", "Show processing time")
268
+ .option("--no-spinner", "Disable spinner (for AI agents and scripts - keeps stdout clean)")
260
269
  .action(async (options) => {
261
270
  if (options.json) {
262
271
  await outputJsonReport("models", options);
263
272
  } else if (options.light) {
264
- await showModelReport(options);
273
+ await showModelReport(options, { spinner: options.spinner });
265
274
  } else {
266
275
  const launchTUI = await tryLoadTUI();
267
276
  if (launchTUI) {
268
277
  await launchTUI(buildTUIOptions(options, "model"));
269
278
  } else {
270
279
  showTUIUnavailableMessage();
271
- await showModelReport(options);
280
+ await showModelReport(options, { spinner: options.spinner });
272
281
  }
273
282
  }
274
283
  });
@@ -282,6 +291,8 @@ async function main() {
282
291
  .option("--codex", "Include only Codex CLI data")
283
292
  .option("--gemini", "Include only Gemini CLI data")
284
293
  .option("--cursor", "Include only Cursor IDE data")
294
+ .option("--amp", "Include only Amp data")
295
+ .option("--droid", "Include only Factory Droid data")
285
296
  .option("--today", "Show only today's usage")
286
297
  .option("--week", "Show last 7 days")
287
298
  .option("--month", "Show current month")
@@ -289,6 +300,7 @@ async function main() {
289
300
  .option("--until <date>", "End date (YYYY-MM-DD)")
290
301
  .option("--year <year>", "Filter to specific year")
291
302
  .option("--benchmark", "Show processing time")
303
+ .option("--no-spinner", "Disable spinner (for AI agents and scripts - keeps stdout clean)")
292
304
  .action(async (options) => {
293
305
  await handleGraphCommand(options);
294
306
  });
@@ -303,6 +315,8 @@ async function main() {
303
315
  .option("--codex", "Include only Codex CLI data")
304
316
  .option("--gemini", "Include only Gemini CLI data")
305
317
  .option("--cursor", "Include only Cursor IDE data")
318
+ .option("--amp", "Include only Amp data")
319
+ .option("--droid", "Include only Factory Droid data")
306
320
  .option("--no-spinner", "Disable loading spinner (for scripting)")
307
321
  .option("--short", "Display total tokens in abbreviated format (e.g., 7.14B)")
308
322
  .addOption(new Option("--agents", "Show Top OpenCode Agents (default)").conflicts("clients"))
@@ -345,6 +359,8 @@ async function main() {
345
359
  .option("--codex", "Include only Codex CLI data")
346
360
  .option("--gemini", "Include only Gemini CLI data")
347
361
  .option("--cursor", "Include only Cursor IDE data")
362
+ .option("--amp", "Include only Amp data")
363
+ .option("--droid", "Include only Factory Droid data")
348
364
  .option("--since <date>", "Start date (YYYY-MM-DD)")
349
365
  .option("--until <date>", "End date (YYYY-MM-DD)")
350
366
  .option("--year <year>", "Filter to specific year")
@@ -356,6 +372,8 @@ async function main() {
356
372
  codex: options.codex,
357
373
  gemini: options.gemini,
358
374
  cursor: options.cursor,
375
+ amp: options.amp,
376
+ droid: options.droid,
359
377
  since: options.since,
360
378
  until: options.until,
361
379
  year: options.year,
@@ -375,6 +393,8 @@ async function main() {
375
393
  .option("--codex", "Show only Codex CLI usage")
376
394
  .option("--gemini", "Show only Gemini CLI usage")
377
395
  .option("--cursor", "Show only Cursor IDE usage")
396
+ .option("--amp", "Show only Amp usage")
397
+ .option("--droid", "Show only Factory Droid usage")
378
398
  .option("--today", "Show only today's usage")
379
399
  .option("--week", "Show last 7 days")
380
400
  .option("--month", "Show current month")
@@ -391,9 +411,15 @@ async function main() {
391
411
  }
392
412
  });
393
413
 
394
- // =========================================================================
395
- // Cursor IDE Authentication Commands
396
- // =========================================================================
414
+ program
415
+ .command("pricing <model-id>")
416
+ .description("Look up pricing for a model")
417
+ .option("--json", "Output as JSON")
418
+ .option("--provider <source>", "Force pricing source: 'litellm' or 'openrouter'")
419
+ .option("--no-spinner", "Disable spinner (for AI agents and scripts - keeps stdout clean)")
420
+ .action(async (modelId: string, options: { json?: boolean; provider?: string; spinner?: boolean }) => {
421
+ await handlePricingCommand(modelId, options);
422
+ });
397
423
 
398
424
  const cursorCommand = program
399
425
  .command("cursor")
@@ -426,7 +452,7 @@ async function main() {
426
452
  // Global flags should go to main program
427
453
  const isGlobalFlag = ['--help', '-h', '--version', '-V'].includes(firstArg);
428
454
  const hasSubcommand = args.length > 0 && !firstArg.startsWith('-');
429
- const knownCommands = ['monthly', 'models', 'graph', 'wrapped', 'login', 'logout', 'whoami', 'submit', 'cursor', 'tui', 'help'];
455
+ const knownCommands = ['monthly', 'models', 'graph', 'wrapped', 'login', 'logout', 'whoami', 'submit', 'cursor', 'tui', 'pricing', 'help'];
430
456
  const isKnownCommand = hasSubcommand && knownCommands.includes(firstArg);
431
457
 
432
458
  if (isKnownCommand || isGlobalFlag) {
@@ -443,6 +469,7 @@ async function main() {
443
469
  .option("--codex", "Show only Codex CLI usage")
444
470
  .option("--gemini", "Show only Gemini CLI usage")
445
471
  .option("--cursor", "Show only Cursor IDE usage")
472
+ .option("--amp", "Show only Amp usage")
446
473
  .option("--today", "Show only today's usage")
447
474
  .option("--week", "Show last 7 days")
448
475
  .option("--month", "Show current month")
@@ -450,27 +477,28 @@ async function main() {
450
477
  .option("--until <date>", "End date (YYYY-MM-DD)")
451
478
  .option("--year <year>", "Filter to specific year")
452
479
  .option("--benchmark", "Show processing time")
480
+ .option("--no-spinner", "Disable spinner (for AI agents and scripts - keeps stdout clean)")
453
481
  .parse();
454
482
 
455
483
  const opts = defaultProgram.opts();
456
484
  if (opts.json) {
457
485
  await outputJsonReport("models", opts);
458
486
  } else if (opts.light) {
459
- await showModelReport(opts);
487
+ await showModelReport(opts, { spinner: opts.spinner });
460
488
  } else {
461
489
  const launchTUI = await tryLoadTUI();
462
490
  if (launchTUI) {
463
491
  await launchTUI(buildTUIOptions(opts));
464
492
  } else {
465
493
  showTUIUnavailableMessage();
466
- await showModelReport(opts);
494
+ await showModelReport(opts, { spinner: opts.spinner });
467
495
  }
468
496
  }
469
497
  }
470
498
  }
471
499
 
472
500
  function getEnabledSources(options: FilterOptions): SourceType[] | undefined {
473
- const hasFilter = options.opencode || options.claude || options.codex || options.gemini || options.cursor;
501
+ const hasFilter = options.opencode || options.claude || options.codex || options.gemini || options.cursor || options.amp || options.droid;
474
502
  if (!hasFilter) return undefined; // All sources
475
503
 
476
504
  const sources: SourceType[] = [];
@@ -479,21 +507,14 @@ function getEnabledSources(options: FilterOptions): SourceType[] | undefined {
479
507
  if (options.codex) sources.push("codex");
480
508
  if (options.gemini) sources.push("gemini");
481
509
  if (options.cursor) sources.push("cursor");
510
+ if (options.amp) sources.push("amp");
511
+ if (options.droid) sources.push("droid");
482
512
  return sources;
483
513
  }
484
514
 
485
- function logNativeStatus(): void {
486
- if (!isNativeAvailable()) {
487
- console.log(pc.yellow(" Note: Using TypeScript fallback (native module not available)"));
488
- console.log(pc.gray(" Run 'bun run build:core' for ~10x faster processing.\n"));
489
- }
490
- }
491
515
 
492
- async function fetchPricingData(): Promise<PricingFetcher> {
493
- const fetcher = new PricingFetcher();
494
- await fetcher.fetchPricing();
495
- return fetcher;
496
- }
516
+
517
+
497
518
 
498
519
  /**
499
520
  * Sync Cursor usage data from API to local cache.
@@ -515,31 +536,19 @@ async function syncCursorData(): Promise<CursorSyncResult> {
515
536
  }
516
537
 
517
538
  interface LoadedDataSources {
518
- fetcher: PricingFetcher;
519
539
  cursorSync: CursorSyncResult;
520
540
  localMessages: ParsedMessages | null;
521
541
  }
522
542
 
523
- /**
524
- * Load all data sources in parallel (two-phase optimization):
525
- * - Cursor API sync (network)
526
- * - Pricing fetch (network)
527
- * - Local file parsing (CPU/IO) - OpenCode, Claude, Codex, Gemini
528
- *
529
- * This overlaps network I/O with local file parsing for better performance.
530
- */
531
543
  async function loadDataSourcesParallel(
532
544
  localSources: SourceType[],
533
- dateFilters: { since?: string; until?: string; year?: string }
545
+ dateFilters: { since?: string; until?: string; year?: string },
546
+ onPhase?: (phase: string) => void
534
547
  ): Promise<LoadedDataSources> {
535
- // Skip local parsing if no local sources requested (e.g., cursor-only mode)
536
548
  const shouldParseLocal = localSources.length > 0;
537
549
 
538
- // Use Promise.allSettled for graceful degradation
539
- const [cursorResult, pricingResult, localResult] = await Promise.allSettled([
550
+ const [cursorResult, localResult] = await Promise.allSettled([
540
551
  syncCursorData(),
541
- fetchPricingData(),
542
- // Parse local sources in parallel (excludes Cursor) - skip if empty
543
552
  shouldParseLocal
544
553
  ? parseLocalSourcesAsync({
545
554
  sources: localSources.filter(s => s !== 'cursor'),
@@ -550,25 +559,18 @@ async function loadDataSourcesParallel(
550
559
  : Promise.resolve(null),
551
560
  ]);
552
561
 
553
- // Handle partial failures gracefully
554
562
  const cursorSync: CursorSyncResult = cursorResult.status === 'fulfilled'
555
563
  ? cursorResult.value
556
564
  : { attempted: true, synced: false, rows: 0, error: 'Cursor sync failed' };
557
565
 
558
- const fetcher: PricingFetcher = pricingResult.status === 'fulfilled'
559
- ? pricingResult.value
560
- : new PricingFetcher(); // Empty pricing → costs = 0
561
-
562
566
  const localMessages: ParsedMessages | null = localResult.status === 'fulfilled'
563
567
  ? localResult.value
564
568
  : null;
565
569
 
566
- return { fetcher, cursorSync, localMessages };
570
+ return { cursorSync, localMessages };
567
571
  }
568
572
 
569
- async function showModelReport(options: FilterOptions & DateFilterOptions & { benchmark?: boolean }) {
570
- logNativeStatus();
571
-
573
+ async function showModelReport(options: FilterOptions & DateFilterOptions & { benchmark?: boolean }, extraOptions?: { spinner?: boolean }) {
572
574
  const dateFilters = getDateFilters(options);
573
575
  const enabledSources = getEnabledSources(options);
574
576
  const onlyCursor = enabledSources?.length === 1 && enabledSources[0] === 'cursor';
@@ -595,47 +597,53 @@ async function showModelReport(options: FilterOptions & DateFilterOptions & { be
595
597
  }
596
598
  console.log();
597
599
 
598
- // Start spinner for loading phase
599
- const spinner = createSpinner({ color: "cyan" });
600
- spinner.start(pc.gray("Loading data sources..."));
600
+ const useSpinner = extraOptions?.spinner !== false;
601
+ const spinner = useSpinner ? createSpinner({ color: "cyan" }) : null;
601
602
 
602
- // Filter out cursor for local parsing (it's synced separately via network)
603
- const localSources: SourceType[] = (enabledSources || ['opencode', 'claude', 'codex', 'gemini', 'cursor'])
603
+ const localSources: SourceType[] = (enabledSources || ['opencode', 'claude', 'codex', 'gemini', 'cursor', 'amp', 'droid'])
604
604
  .filter(s => s !== 'cursor');
605
605
 
606
- // Two-phase parallel loading: network (Cursor + pricing) overlaps with local file parsing
607
- // If cursor-only, skip local parsing entirely
608
- const { fetcher, cursorSync, localMessages } = await loadDataSourcesParallel(
606
+ spinner?.start(pc.gray("Scanning session data..."));
607
+
608
+ const { cursorSync, localMessages } = await loadDataSourcesParallel(
609
609
  onlyCursor ? [] : localSources,
610
- dateFilters
610
+ dateFilters,
611
+ (phase) => spinner?.update(phase)
611
612
  );
612
613
 
613
614
  if (!localMessages && !onlyCursor) {
614
- spinner.error('Failed to parse local session files');
615
+ if (spinner) {
616
+ spinner.error('Failed to parse local session files');
617
+ } else {
618
+ console.error('Failed to parse local session files');
619
+ }
615
620
  process.exit(1);
616
621
  }
617
622
 
618
- spinner.update(pc.gray("Finalizing report..."));
623
+ spinner?.update(pc.gray("Finalizing report..."));
619
624
  const startTime = performance.now();
620
625
 
621
626
  let report: ModelReport;
622
627
  try {
623
- const emptyMessages: ParsedMessages = { messages: [], opencodeCount: 0, claudeCount: 0, codexCount: 0, geminiCount: 0, processingTimeMs: 0 };
628
+ const emptyMessages: ParsedMessages = { messages: [], opencodeCount: 0, claudeCount: 0, codexCount: 0, geminiCount: 0, ampCount: 0, droidCount: 0, processingTimeMs: 0 };
624
629
  report = await finalizeReportAsync({
625
630
  localMessages: localMessages || emptyMessages,
626
- pricing: fetcher.toPricingEntries(),
627
631
  includeCursor: includeCursor && cursorSync.synced,
628
632
  since: dateFilters.since,
629
633
  until: dateFilters.until,
630
634
  year: dateFilters.year,
631
635
  });
632
636
  } catch (e) {
633
- spinner.error(`Error: ${(e as Error).message}`);
637
+ if (spinner) {
638
+ spinner.error(`Error: ${(e as Error).message}`);
639
+ } else {
640
+ console.error(`Error: ${(e as Error).message}`);
641
+ }
634
642
  process.exit(1);
635
643
  }
636
644
 
637
645
  const processingTime = performance.now() - startTime;
638
- spinner.stop();
646
+ spinner?.stop();
639
647
 
640
648
  if (report.entries.length === 0) {
641
649
  if (onlyCursor && !cursorSync.synced) {
@@ -649,8 +657,13 @@ async function showModelReport(options: FilterOptions & DateFilterOptions & { be
649
657
 
650
658
  // Create table
651
659
  const table = createUsageTable("Source/Model");
660
+
661
+ const settings = loadSettings();
662
+ const filteredEntries = settings.includeUnusedModels
663
+ ? report.entries
664
+ : report.entries.filter(e => e.input + e.output + e.cacheRead + e.cacheWrite > 0);
652
665
 
653
- for (const entry of report.entries) {
666
+ for (const entry of filteredEntries) {
654
667
  const sourceLabel = getSourceLabel(entry.source);
655
668
  const modelDisplay = `${pc.dim(sourceLabel)} ${formatModelName(entry.model)}`;
656
669
  table.push(
@@ -702,9 +715,7 @@ async function showModelReport(options: FilterOptions & DateFilterOptions & { be
702
715
  console.log();
703
716
  }
704
717
 
705
- async function showMonthlyReport(options: FilterOptions & DateFilterOptions & { benchmark?: boolean }) {
706
- logNativeStatus();
707
-
718
+ async function showMonthlyReport(options: FilterOptions & DateFilterOptions & { benchmark?: boolean }, extraOptions?: { spinner?: boolean }) {
708
719
  const dateRange = getDateRangeLabel(options);
709
720
  const title = dateRange
710
721
  ? `Monthly Token Usage Report (${dateRange})`
@@ -716,45 +727,55 @@ async function showMonthlyReport(options: FilterOptions & DateFilterOptions & {
716
727
  }
717
728
  console.log();
718
729
 
719
- // Start spinner for loading phase
720
- const spinner = createSpinner({ color: "cyan" });
721
- spinner.start(pc.gray("Loading data sources..."));
730
+ const useSpinner = extraOptions?.spinner !== false;
731
+ const spinner = useSpinner ? createSpinner({ color: "cyan" }) : null;
722
732
 
723
733
  const dateFilters = getDateFilters(options);
724
734
  const enabledSources = getEnabledSources(options);
725
- // Filter out cursor for local parsing (it's synced separately via network)
726
- const localSources: SourceType[] = (enabledSources || ['opencode', 'claude', 'codex', 'gemini', 'cursor'])
735
+ const localSources: SourceType[] = (enabledSources || ['opencode', 'claude', 'codex', 'gemini', 'cursor', 'amp', 'droid'])
727
736
  .filter(s => s !== 'cursor');
728
737
  const includeCursor = !enabledSources || enabledSources.includes('cursor');
729
738
 
730
- // Two-phase parallel loading: network (Cursor + pricing) overlaps with local file parsing
731
- const { fetcher, cursorSync, localMessages } = await loadDataSourcesParallel(localSources, dateFilters);
739
+ spinner?.start(pc.gray("Scanning session data..."));
740
+
741
+ const { cursorSync, localMessages } = await loadDataSourcesParallel(
742
+ localSources,
743
+ dateFilters,
744
+ (phase) => spinner?.update(phase)
745
+ );
732
746
 
733
747
  if (!localMessages) {
734
- spinner.error('Failed to parse local session files');
748
+ if (spinner) {
749
+ spinner.error('Failed to parse local session files');
750
+ } else {
751
+ console.error('Failed to parse local session files');
752
+ }
735
753
  process.exit(1);
736
754
  }
737
755
 
738
- spinner.update(pc.gray("Finalizing report..."));
756
+ spinner?.update(pc.gray("Finalizing report..."));
739
757
  const startTime = performance.now();
740
758
 
741
759
  let report: MonthlyReport;
742
760
  try {
743
761
  report = await finalizeMonthlyReportAsync({
744
762
  localMessages,
745
- pricing: fetcher.toPricingEntries(),
746
763
  includeCursor: includeCursor && cursorSync.synced,
747
764
  since: dateFilters.since,
748
765
  until: dateFilters.until,
749
766
  year: dateFilters.year,
750
767
  });
751
768
  } catch (e) {
752
- spinner.error(`Error: ${(e as Error).message}`);
769
+ if (spinner) {
770
+ spinner.error(`Error: ${(e as Error).message}`);
771
+ } else {
772
+ console.error(`Error: ${(e as Error).message}`);
773
+ }
753
774
  process.exit(1);
754
775
  }
755
776
 
756
777
  const processingTime = performance.now() - startTime;
757
- spinner.stop();
778
+ spinner?.stop();
758
779
 
759
780
  if (report.entries.length === 0) {
760
781
  console.log(pc.yellow(" No usage data found.\n"));
@@ -764,7 +785,12 @@ async function showMonthlyReport(options: FilterOptions & DateFilterOptions & {
764
785
  // Create table
765
786
  const table = createUsageTable("Month");
766
787
 
767
- for (const entry of report.entries) {
788
+ const settings = loadSettings();
789
+ const filteredEntries = settings.includeUnusedModels
790
+ ? report.entries
791
+ : report.entries.filter(e => e.input + e.output + e.cacheRead + e.cacheWrite > 0);
792
+
793
+ for (const entry of filteredEntries) {
768
794
  table.push(
769
795
  formatUsageRow(
770
796
  entry.month,
@@ -811,16 +837,14 @@ async function outputJsonReport(
811
837
  reportType: JsonReportType,
812
838
  options: FilterOptions & DateFilterOptions
813
839
  ) {
814
- logNativeStatus();
815
-
816
840
  const dateFilters = getDateFilters(options);
817
841
  const enabledSources = getEnabledSources(options);
818
842
  const onlyCursor = enabledSources?.length === 1 && enabledSources[0] === 'cursor';
819
843
  const includeCursor = !enabledSources || enabledSources.includes('cursor');
820
- const localSources: SourceType[] = (enabledSources || ['opencode', 'claude', 'codex', 'gemini', 'cursor'])
844
+ const localSources: SourceType[] = (enabledSources || ['opencode', 'claude', 'codex', 'gemini', 'cursor', 'amp', 'droid'])
821
845
  .filter(s => s !== 'cursor');
822
846
 
823
- const { fetcher, cursorSync, localMessages } = await loadDataSourcesParallel(
847
+ const { cursorSync, localMessages } = await loadDataSourcesParallel(
824
848
  onlyCursor ? [] : localSources,
825
849
  dateFilters
826
850
  );
@@ -830,12 +854,11 @@ async function outputJsonReport(
830
854
  process.exit(1);
831
855
  }
832
856
 
833
- const emptyMessages: ParsedMessages = { messages: [], opencodeCount: 0, claudeCount: 0, codexCount: 0, geminiCount: 0, processingTimeMs: 0 };
857
+ const emptyMessages: ParsedMessages = { messages: [], opencodeCount: 0, claudeCount: 0, codexCount: 0, geminiCount: 0, ampCount: 0, droidCount: 0, processingTimeMs: 0 };
834
858
 
835
859
  if (reportType === "models") {
836
860
  const report = await finalizeReportAsync({
837
861
  localMessages: localMessages || emptyMessages,
838
- pricing: fetcher.toPricingEntries(),
839
862
  includeCursor: includeCursor && cursorSync.synced,
840
863
  since: dateFilters.since,
841
864
  until: dateFilters.until,
@@ -845,7 +868,6 @@ async function outputJsonReport(
845
868
  } else {
846
869
  const report = await finalizeMonthlyReportAsync({
847
870
  localMessages: localMessages || emptyMessages,
848
- pricing: fetcher.toPricingEntries(),
849
871
  includeCursor: includeCursor && cursorSync.synced,
850
872
  since: dateFilters.since,
851
873
  until: dateFilters.until,
@@ -858,24 +880,26 @@ async function outputJsonReport(
858
880
  interface GraphCommandOptions extends FilterOptions, DateFilterOptions {
859
881
  output?: string;
860
882
  benchmark?: boolean;
883
+ spinner?: boolean;
861
884
  }
862
885
 
863
886
  async function handleGraphCommand(options: GraphCommandOptions) {
864
- logNativeStatus();
865
-
866
- // Start spinner for loading phase (only if outputting to file, not stdout)
867
- const spinner = options.output ? createSpinner({ color: "cyan" }) : null;
868
- spinner?.start(pc.gray("Loading data sources..."));
887
+ const useSpinner = options.output && options.spinner !== false;
888
+ const spinner = useSpinner ? createSpinner({ color: "cyan" }) : null;
869
889
 
870
890
  const dateFilters = getDateFilters(options);
871
891
  const enabledSources = getEnabledSources(options);
872
- // Filter out cursor for local parsing (it's synced separately via network)
873
- const localSources: SourceType[] = (enabledSources || ['opencode', 'claude', 'codex', 'gemini', 'cursor'])
892
+ const localSources: SourceType[] = (enabledSources || ['opencode', 'claude', 'codex', 'gemini', 'cursor', 'amp', 'droid'])
874
893
  .filter(s => s !== 'cursor');
875
894
  const includeCursor = !enabledSources || enabledSources.includes('cursor');
876
895
 
877
- // Two-phase parallel loading: network (Cursor + pricing) overlaps with local file parsing
878
- const { fetcher, cursorSync, localMessages } = await loadDataSourcesParallel(localSources, dateFilters);
896
+ spinner?.start(pc.gray("Scanning session data..."));
897
+
898
+ const { cursorSync, localMessages } = await loadDataSourcesParallel(
899
+ localSources,
900
+ dateFilters,
901
+ (phase) => spinner?.update(phase)
902
+ );
879
903
 
880
904
  if (!localMessages) {
881
905
  spinner?.error('Failed to parse local session files');
@@ -887,7 +911,6 @@ async function handleGraphCommand(options: GraphCommandOptions) {
887
911
 
888
912
  const data = await finalizeGraphAsync({
889
913
  localMessages,
890
- pricing: fetcher.toPricingEntries(),
891
914
  includeCursor: includeCursor && cursorSync.synced,
892
915
  since: dateFilters.since,
893
916
  until: dateFilters.until,
@@ -968,6 +991,114 @@ async function handleWrappedCommand(options: WrappedCommandOptions) {
968
991
  }
969
992
  }
970
993
 
994
+ async function handlePricingCommand(modelId: string, options: { json?: boolean; provider?: string; spinner?: boolean }) {
995
+ const validProviders = ["litellm", "openrouter"];
996
+ if (options.provider && !validProviders.includes(options.provider.toLowerCase())) {
997
+ console.log(pc.red(`\n Invalid provider: ${options.provider}`));
998
+ console.log(pc.gray(` Valid providers: ${validProviders.join(", ")}\n`));
999
+ process.exit(1);
1000
+ }
1001
+
1002
+ const useSpinner = options.spinner !== false;
1003
+ const spinner = useSpinner ? createSpinner({ color: "cyan" }) : null;
1004
+ const providerLabel = options.provider ? ` from ${options.provider}` : "";
1005
+ spinner?.start(pc.gray(`Fetching pricing data${providerLabel}...`));
1006
+
1007
+ let core: typeof import("@tokscale/core");
1008
+ try {
1009
+ const mod = await import("@tokscale/core");
1010
+ core = (mod.default ?? mod) as typeof import("@tokscale/core");
1011
+ } catch (importErr) {
1012
+ spinner?.stop();
1013
+ const errorMsg = (importErr as Error).message || "Unknown error";
1014
+ if (options.json) {
1015
+ console.log(JSON.stringify({ error: "Native module not available", details: errorMsg }, null, 2));
1016
+ } else {
1017
+ console.log(pc.red(`\n Native module not available: ${errorMsg}`));
1018
+ console.log(pc.gray(" Run 'bun run build:core' to build the native module.\n"));
1019
+ }
1020
+ process.exit(1);
1021
+ }
1022
+
1023
+ try {
1024
+ const provider = options.provider?.toLowerCase() || undefined;
1025
+ const nativeResult = await core.lookupPricing(modelId, provider);
1026
+ spinner?.stop();
1027
+
1028
+ const result = {
1029
+ matchedKey: nativeResult.matchedKey,
1030
+ source: nativeResult.source as "litellm" | "openrouter",
1031
+ pricing: {
1032
+ input_cost_per_token: nativeResult.pricing.inputCostPerToken,
1033
+ output_cost_per_token: nativeResult.pricing.outputCostPerToken,
1034
+ cache_read_input_token_cost: nativeResult.pricing.cacheReadInputTokenCost,
1035
+ cache_creation_input_token_cost: nativeResult.pricing.cacheCreationInputTokenCost,
1036
+ },
1037
+ };
1038
+
1039
+ if (options.json) {
1040
+ console.log(JSON.stringify({
1041
+ modelId,
1042
+ matchedKey: result.matchedKey,
1043
+ source: result.source,
1044
+ pricing: {
1045
+ inputCostPerToken: result.pricing.input_cost_per_token ?? 0,
1046
+ outputCostPerToken: result.pricing.output_cost_per_token ?? 0,
1047
+ cacheReadInputTokenCost: result.pricing.cache_read_input_token_cost,
1048
+ cacheCreationInputTokenCost: result.pricing.cache_creation_input_token_cost,
1049
+ },
1050
+ }, null, 2));
1051
+ } else {
1052
+ const sourceLabel = result.source.toLowerCase() === "litellm" ? pc.blue("LiteLLM") : pc.magenta("OpenRouter");
1053
+ const inputCost = result.pricing.input_cost_per_token ?? 0;
1054
+ const outputCost = result.pricing.output_cost_per_token ?? 0;
1055
+ const cacheReadCost = result.pricing.cache_read_input_token_cost;
1056
+ const cacheWriteCost = result.pricing.cache_creation_input_token_cost;
1057
+
1058
+ console.log(pc.cyan(`\n Pricing for: ${pc.white(modelId)}`));
1059
+ console.log(pc.gray(` Matched key: ${result.matchedKey}`));
1060
+ console.log(pc.gray(` Source: `) + sourceLabel);
1061
+ console.log();
1062
+ console.log(pc.white(` Input: `) + formatPricePerMillion(inputCost));
1063
+ console.log(pc.white(` Output: `) + formatPricePerMillion(outputCost));
1064
+ if (cacheReadCost !== undefined) {
1065
+ console.log(pc.white(` Cache Read: `) + formatPricePerMillion(cacheReadCost));
1066
+ }
1067
+ if (cacheWriteCost !== undefined) {
1068
+ console.log(pc.white(` Cache Write: `) + formatPricePerMillion(cacheWriteCost));
1069
+ }
1070
+ console.log();
1071
+ }
1072
+ } catch (err) {
1073
+ spinner?.stop();
1074
+ const errorMsg = (err as Error).message || "Unknown error";
1075
+
1076
+ // Check if this is a "model not found" error from Rust or a different error
1077
+ const isModelNotFound = errorMsg.toLowerCase().includes("not found") ||
1078
+ errorMsg.toLowerCase().includes("no pricing");
1079
+
1080
+ if (options.json) {
1081
+ if (isModelNotFound) {
1082
+ console.log(JSON.stringify({ error: "Model not found", modelId }, null, 2));
1083
+ } else {
1084
+ console.log(JSON.stringify({ error: errorMsg, modelId }, null, 2));
1085
+ }
1086
+ } else {
1087
+ if (isModelNotFound) {
1088
+ console.log(pc.red(`\n Model not found: ${modelId}\n`));
1089
+ } else {
1090
+ console.log(pc.red(`\n Error looking up pricing: ${errorMsg}\n`));
1091
+ }
1092
+ }
1093
+ process.exit(1);
1094
+ }
1095
+ }
1096
+
1097
+ function formatPricePerMillion(costPerToken: number): string {
1098
+ const perMillion = costPerToken * 1_000_000;
1099
+ return pc.green(`$${perMillion.toFixed(2)}`) + pc.gray(" / 1M tokens");
1100
+ }
1101
+
971
1102
  function getSourceLabel(source: string): string {
972
1103
  switch (source) {
973
1104
  case "opencode":
@@ -980,6 +1111,10 @@ function getSourceLabel(source: string): string {
980
1111
  return "Gemini";
981
1112
  case "cursor":
982
1113
  return "Cursor";
1114
+ case "amp":
1115
+ return "Amp";
1116
+ case "droid":
1117
+ return "Droid";
983
1118
  default:
984
1119
  return source;
985
1120
  }