tokentracker-cli 1.5.5 → 1.5.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.
Files changed (132) hide show
  1. package/README.md +2 -0
  2. package/THIRD_PARTY_NOTICES.md +10 -0
  3. package/dashboard/dist/THIRD_PARTY_NOTICES.txt +10 -0
  4. package/dashboard/dist/assets/{AchievementBadge-BYxdWhS1.js → AchievementBadge-DIHKXKgm.js} +1 -1
  5. package/dashboard/dist/assets/{AchievementsPage-Bv9o7Aj8.js → AchievementsPage-U66jjKFf.js} +2 -2
  6. package/dashboard/dist/assets/{AchievementsSection-DE5bZGxg.js → AchievementsSection-BAW79xKg.js} +1 -1
  7. package/dashboard/dist/assets/{BadgeDetailModal-CgZaSVTp.js → BadgeDetailModal-IfomOw9B.js} +1 -1
  8. package/dashboard/dist/assets/{BotAnimated-CwhPfUBI.js → BotAnimated-B33gSUM3.js} +1 -1
  9. package/dashboard/dist/assets/{Card-Y7qaxMuE.js → Card-dtrkef-y.js} +1 -1
  10. package/dashboard/dist/assets/{CloudCheckoutPage-DVl7jk6b.js → CloudCheckoutPage-jYPgHJYE.js} +1 -1
  11. package/dashboard/dist/assets/{CloudPage-BnIvpHGH.js → CloudPage-BumdgM5w.js} +2 -2
  12. package/dashboard/dist/assets/{CommandPalette-CzMv2Zxn.js → CommandPalette-CFEeoIo3.js} +1 -1
  13. package/dashboard/dist/assets/{CommunityStatsModal-D8sOZqUc.js → CommunityStatsModal-DL3MrPmb.js} +1 -1
  14. package/dashboard/dist/assets/{ConfirmModal-Bun5xKMm.js → ConfirmModal-BRdtCv2g.js} +1 -1
  15. package/dashboard/dist/assets/{Controls-ltfnvkFW.js → Controls-Bb-_ZAsT.js} +1 -1
  16. package/dashboard/dist/assets/DashboardPage-GXwt6DoC.js +1 -0
  17. package/dashboard/dist/assets/{DateRangePopover-e3gJYqFJ.js → DateRangePopover-CHkFCFkE.js} +1 -1
  18. package/dashboard/dist/assets/{DevicePage-JgT16dEp.js → DevicePage-Da753sLS.js} +1 -1
  19. package/dashboard/dist/assets/{DialogClose-DeWert7h.js → DialogClose-4A_jISLc.js} +1 -1
  20. package/dashboard/dist/assets/DialogDescription-CiEhDvt9.js +1 -0
  21. package/dashboard/dist/assets/{DialogTitle-BfK_yOeR.js → DialogTitle-C0oQdI9n.js} +1 -1
  22. package/dashboard/dist/assets/{ElectricLogo-Bbev0Y8Q.js → ElectricLogo-Bv-sB-O-.js} +1 -1
  23. package/dashboard/dist/assets/{FadeIn-fypYWLDK.js → FadeIn-Ch2AuGxY.js} +1 -1
  24. package/dashboard/dist/assets/{HeaderGithubStar-Cnil2Lbo.js → HeaderGithubStar-C_dy8a5K.js} +1 -1
  25. package/dashboard/dist/assets/{HoverTooltip-Bw_8tru-.js → HoverTooltip-iuzo5YeN.js} +1 -1
  26. package/dashboard/dist/assets/{IpCheckPage-CHnhUgA2.js → IpCheckPage-BKEID9fw.js} +1 -1
  27. package/dashboard/dist/assets/{LandingPage-Bq4iQS6S.js → LandingPage-DZt5JWZ7.js} +2 -2
  28. package/dashboard/dist/assets/{LeaderboardPage-D42lUdw-.js → LeaderboardPage-CYrCQF5P.js} +2 -2
  29. package/dashboard/dist/assets/{LeaderboardProBadge-eTKceT90.js → LeaderboardProBadge-B8vG2SAW.js} +1 -1
  30. package/dashboard/dist/assets/{LeaderboardProfileModal-D-3tRrOR.js → LeaderboardProfileModal-DpAvBJPT.js} +3 -3
  31. package/dashboard/dist/assets/{LeaderboardProfilePage-DkRCJoiA.js → LeaderboardProfilePage-CofOk8GO.js} +1 -1
  32. package/dashboard/dist/assets/{LimitsPage-dUXjP3_1.js → LimitsPage-DXknvxtI.js} +6 -6
  33. package/dashboard/dist/assets/{LocalOnlyNotice-CtpjB6Z2.js → LocalOnlyNotice-BBVCEm5Q.js} +1 -1
  34. package/dashboard/dist/assets/{LoginCard-zrRd8o54.js → LoginCard-DcPazHo1.js} +1 -1
  35. package/dashboard/dist/assets/{LoginModal-DNl5Rt1e.js → LoginModal-CGXPaeWD.js} +1 -1
  36. package/dashboard/dist/assets/{LoginPage-BuB707vV.js → LoginPage-Ct6Al1pz.js} +1 -1
  37. package/dashboard/dist/assets/{PetPage-BpDriXkp.js → PetPage-8NPEAa-c.js} +2 -2
  38. package/dashboard/dist/assets/{PopoverPopup-rQNSs8R6.js → PopoverPopup-R69ks89h.js} +1 -1
  39. package/dashboard/dist/assets/{ProviderIcon-CumyO371.js → ProviderIcon-svF44YE0.js} +1 -1
  40. package/dashboard/dist/assets/{ResetPasswordPage-ecUHfJAk.js → ResetPasswordPage-DJIYKu8C.js} +1 -1
  41. package/dashboard/dist/assets/{Select-YVEqEhk4.js → Select-BVK4XZD5.js} +1 -1
  42. package/dashboard/dist/assets/{SelectItemText-D1NBc4uc.js → SelectItemText-Bh74D8lr.js} +1 -1
  43. package/dashboard/dist/assets/{SelfHostPage-8nAin68c.js → SelfHostPage-8gj2lAFp.js} +1 -1
  44. package/dashboard/dist/assets/{ServiceStatusPage-D1zxuzOg.js → ServiceStatusPage-CWPV_Mou.js} +1 -1
  45. package/dashboard/dist/assets/SessionsPage-Bo5plK6O.js +3 -0
  46. package/dashboard/dist/assets/{SettingsPage-COqP8ZbJ.js → SettingsPage-BptJrWn-.js} +3 -3
  47. package/dashboard/dist/assets/{SkillsPage-DCX7xTj0.js → SkillsPage-CRgGXnJS.js} +1 -1
  48. package/dashboard/dist/assets/{TokenGalaxy-D4n7AR-R.js → TokenGalaxy-dgOy0Eo7.js} +1 -1
  49. package/dashboard/dist/assets/{TooltipPopup-DvSoB_eS.js → TooltipPopup-DpGnyoFz.js} +1 -1
  50. package/dashboard/dist/assets/{TrendMonitor-5GVzh0Ye.js → TrendMonitor-BEy_GbMg.js} +1 -1
  51. package/dashboard/dist/assets/{WidgetsPage-BIc4MBiu.js → WidgetsPage-ChUelFrP.js} +1 -1
  52. package/dashboard/dist/assets/{WrappedPage-BpQk4g0o.js → WrappedPage-n63RsX8z.js} +1 -1
  53. package/dashboard/dist/assets/{__vite-browser-external-B_rKFTTN.js → __vite-browser-external-D_JnEwiu.js} +3 -3
  54. package/dashboard/dist/assets/{agent-logos-DVg-sTm4.js → agent-logos-DQ1h5uQF.js} +1 -1
  55. package/dashboard/dist/assets/{arrow-left-Ch6Q7eU5.js → arrow-left-Bo3Sg_cg.js} +1 -1
  56. package/dashboard/dist/assets/{arrow-right-B2B9GvSI.js → arrow-right-DdxkL8Ul.js} +1 -1
  57. package/dashboard/dist/assets/{arrow-up-right-DwNO_uSb.js → arrow-up-right-CpvelPD2.js} +1 -1
  58. package/dashboard/dist/assets/{calendar-days---NjJlBi.js → calendar-days-BvNVya0e.js} +1 -1
  59. package/dashboard/dist/assets/{check-D3mhgRrW.js → check-tO40Glrg.js} +1 -1
  60. package/dashboard/dist/assets/{chevron-down-x-b39Zto.js → chevron-down-Dnrra3mC.js} +1 -1
  61. package/dashboard/dist/assets/{cloud-checkout-CycWyTsg.js → cloud-checkout-COC4zB8s.js} +1 -1
  62. package/dashboard/dist/assets/{composite-CnA1qtYn.js → composite-C0NgnRTl.js} +1 -1
  63. package/dashboard/dist/assets/{copy-JRnRMgdX.js → copy-DTKN2SO4.js} +1 -1
  64. package/dashboard/dist/assets/{download-CRLqGD62.js → download-CSq2Yd3D.js} +1 -1
  65. package/dashboard/dist/assets/{external-link-BrmaWV_t.js → external-link-BtGedmuQ.js} +1 -1
  66. package/dashboard/dist/assets/{flame-meQ5-vRD.js → flame-ChjXIFSx.js} +1 -1
  67. package/dashboard/dist/assets/{format-tokens-D8mIJGz1.js → format-tokens-CXqZY114.js} +1 -1
  68. package/dashboard/dist/assets/{gift-Cd0FPXgO.js → gift-YFwBvsUA.js} +1 -1
  69. package/dashboard/dist/assets/{icons-D-PTKw7b.js → icons-DCiBpbK0.js} +1 -1
  70. package/dashboard/dist/assets/{index-C_6gEM83.js → index-B7sRX2EC.js} +1 -1
  71. package/dashboard/dist/assets/{index-HLYcwmPD.js → index-DMqIbTL4.js} +1 -1
  72. package/dashboard/dist/assets/{index-DCIZaLNC.js → index-Dpmdg98T.js} +1 -1
  73. package/dashboard/dist/assets/{index-CMV9UKNf.js → index-XSya-vft.js} +1 -1
  74. package/dashboard/dist/assets/{info-e0tP9P3D.js → info-PYzcoobJ.js} +1 -1
  75. package/dashboard/dist/assets/{link-2-DZ2TQhER.js → link-2-BxSrdKyR.js} +1 -1
  76. package/dashboard/dist/assets/{loader-circle-DjflRntu.js → loader-circle-CgvCCDTH.js} +1 -1
  77. package/dashboard/dist/assets/{local-usage-transfer-XTZLA3XH.js → local-usage-transfer-eTzMdhbs.js} +1 -1
  78. package/dashboard/dist/assets/{modifiers.esm-BpEQ8-Gt.js → modifiers.esm-B796CRz-.js} +1 -1
  79. package/dashboard/dist/assets/{monitor-smartphone-VVBcI30G.js → monitor-smartphone-M5nbcGRy.js} +1 -1
  80. package/dashboard/dist/assets/{play-DN4Sb3W_.js → play-12ZzPH9r.js} +1 -1
  81. package/dashboard/dist/assets/{plus-Cz_k7arH.js → plus-OqDA7xZy.js} +1 -1
  82. package/dashboard/dist/assets/{provider-display-S1mSaydF.js → provider-display-JlEBv1VB.js} +1 -1
  83. package/dashboard/dist/assets/{react-MQBcjrn_.js → react-CeDcnQho.js} +1 -1
  84. package/dashboard/dist/assets/{safePolygon-InKxYcXd.js → safePolygon-B_SGhQbo.js} +1 -1
  85. package/dashboard/dist/assets/{search-C-N0RDi0.js → search-D9wz4rWp.js} +1 -1
  86. package/dashboard/dist/assets/{server-CZkn8vIZ.js → server-UCMw4AQF.js} +1 -1
  87. package/dashboard/dist/assets/{shield-check-DPrGFouZ.js → shield-check-lJ0-Z5Ot.js} +1 -1
  88. package/dashboard/dist/assets/{skills-api-wMwM-GpF.js → skills-api-Da-WZbvW.js} +1 -1
  89. package/dashboard/dist/assets/{sortable.esm-CUPP1ihT.js → sortable.esm-Bj1ZkbqW.js} +1 -1
  90. package/dashboard/dist/assets/{store-BpVdRYnQ.js → store-Bpwwxxsy.js} +1 -1
  91. package/dashboard/dist/assets/{terminal-C-7TFeKg.js → terminal-BHQMC4hm.js} +1 -1
  92. package/dashboard/dist/assets/{trash-Be8cd4HD.js → trash-yTV6vhGX.js} +1 -1
  93. package/dashboard/dist/assets/{use-community-stats-cG0WrB4Y.js → use-community-stats-rrE4Ohic.js} +1 -1
  94. package/dashboard/dist/assets/{use-latest-request-guard-C91enyPD.js → use-latest-request-guard-D4Y1aCyd.js} +1 -1
  95. package/dashboard/dist/assets/{use-limits-display-prefs-DdwoLqPD.js → use-limits-display-prefs-ChHr4XEF.js} +1 -1
  96. package/dashboard/dist/assets/{use-native-settings-BR-NiMKh.js → use-native-settings-BmoCls3D.js} +1 -1
  97. package/dashboard/dist/assets/{use-ordered-list-oqV724BP.js → use-ordered-list-BZSSYcLx.js} +1 -1
  98. package/dashboard/dist/assets/use-reduced-motion-CKTIXp4-.js +1 -0
  99. package/dashboard/dist/assets/{use-transform-BsNSgVD6.js → use-transform-CJO1b0Iu.js} +1 -1
  100. package/dashboard/dist/assets/{use-usage-limits-Dk8LcMU-.js → use-usage-limits-BbAC_LWP.js} +1 -1
  101. package/dashboard/dist/assets/{useCurrency-FxerRfd2.js → useCurrency-B2enOMwJ.js} +1 -1
  102. package/dashboard/dist/assets/{usePositioner-D2J-rSfX.js → usePositioner-BaoW4QRL.js} +1 -1
  103. package/dashboard/dist/assets/{useTokenFormat-CaI2nl2C.js → useTokenFormat-B2bbeEu-.js} +1 -1
  104. package/dashboard/dist/assets/{zap-D3C37XAj.js → zap-CT5a8-_j.js} +1 -1
  105. package/dashboard/dist/brand-logos/factory.svg +8 -0
  106. package/dashboard/dist/index.html +1 -1
  107. package/dashboard/dist/ip-check.html +1 -1
  108. package/dashboard/dist/leaderboard.html +1 -1
  109. package/dashboard/dist/share.html +1 -1
  110. package/package.json +1 -1
  111. package/src/commands/init.js +9 -0
  112. package/src/commands/status.js +12 -6
  113. package/src/commands/sync.js +22 -4
  114. package/src/lib/claude-backend.js +291 -0
  115. package/src/lib/claude-config.js +1 -1
  116. package/src/lib/cursor-config.js +34 -9
  117. package/src/lib/cursor-safe-storage.js +119 -0
  118. package/src/lib/devin-limits.js +65 -25
  119. package/src/lib/diagnostics.js +3 -0
  120. package/src/lib/doctor.js +13 -0
  121. package/src/lib/local-api.js +2 -0
  122. package/src/lib/local-usage-transfer.js +22 -1
  123. package/src/lib/pricing/index.js +12 -0
  124. package/src/lib/pricing/seed-snapshot.json +1 -1
  125. package/src/lib/rollout.js +70 -15
  126. package/src/lib/session-analytics.js +20 -8
  127. package/src/lib/trae-cn-config.js +32 -2
  128. package/src/lib/usage-limits.js +601 -35
  129. package/dashboard/dist/assets/DashboardPage-C3Xu6_3t.js +0 -1
  130. package/dashboard/dist/assets/DialogDescription-Ca6W3nCT.js +0 -1
  131. package/dashboard/dist/assets/SessionsPage-B6ksEER_.js +0 -3
  132. package/dashboard/dist/assets/use-reduced-motion-DW5YHBBI.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokentracker-cli",
3
- "version": "1.5.5",
3
+ "version": "1.5.7",
4
4
  "description": "Local-first AI coding token usage and cost tracker for 47 tools, with a dashboard, desktop pet, widgets, achievements, and native macOS/Windows apps.",
5
5
  "main": "src/cli.js",
6
6
  "bin": {
@@ -1453,6 +1453,7 @@ function normalizePlatform(value) {
1453
1453
  }
1454
1454
 
1455
1455
  function buildNotifyHandler({ trackerDir, packageName }) {
1456
+ const { backendFromUrl, captureClaudeBackend, hashClaudeRange } = require("../lib/claude-backend");
1456
1457
  // Keep this file dependency-free: Node built-ins only.
1457
1458
  // It must never block Codex; it spawns sync in the background and exits 0.
1458
1459
  const queueSignalPath = path.join(trackerDir, "notify.signal");
@@ -1526,6 +1527,14 @@ if (debugEnabled) {
1526
1527
  } catch (_) {}
1527
1528
  }
1528
1529
 
1530
+ // Capture before the throttle and before spawning sync. Never persist stdin.
1531
+ if (source === 'claude') {
1532
+ try { captureClaudeBackend(trackerDir, JSON.parse(fs.readFileSync(0, 'utf8'))); } catch (_) {}
1533
+ }
1534
+ ${backendFromUrl.toString()}
1535
+ ${hashClaudeRange.toString()}
1536
+ ${captureClaudeBackend.toString()}
1537
+
1529
1538
  // Throttle spawn: at most once per 20 seconds.
1530
1539
  try {
1531
1540
  const throttlePath = path.join(trackerDir, 'sync.throttle');
@@ -856,13 +856,19 @@ async function cmdStatus(argv = []) {
856
856
  // official API, so status surfaces the opt-in flag, the resolved storage
857
857
  // path, and whether the auth blob decrypts — the three inputs a "why is my
858
858
  // TRAE CN data missing" diagnosis needs.
859
- // A resolvable default path does NOT mean the app is installed - the
860
- // macOS/Windows resolver always derives one. Match the sync path semantics
861
- // exactly: installed iff the storage file actually exists on disk.
859
+ // Follow junctions with stat. Only missing paths mean absent; access/IO
860
+ // errors must reach the auth reader and be shown as unreadable, not hidden
861
+ // behind existsSync's false result. Explicit overrides can also be absent.
862
862
  const traeCnStoragePath = resolveTraeCnStoragePath({ env: process.env, home });
863
- const traeCnInstalled = Boolean(
864
- traeCnStoragePath && fssync.existsSync(traeCnStoragePath),
865
- );
863
+ let traeCnInstalled = false;
864
+ if (traeCnStoragePath) {
865
+ try {
866
+ fssync.statSync(traeCnStoragePath);
867
+ traeCnInstalled = true;
868
+ } catch (error) {
869
+ traeCnInstalled = error?.code !== "ENOENT" && error?.code !== "ENOTDIR";
870
+ }
871
+ }
866
872
  let traeCnAuthState = "not-signed-in";
867
873
  if (traeCnInstalled) {
868
874
  try {
@@ -185,7 +185,7 @@ const {
185
185
  const { purgeProjectUsage } = require("../lib/project-usage-purge");
186
186
  const {
187
187
  fetchTraeCnUsageWithAuth,
188
- resolveTraeCnStoragePath,
188
+ readTraeCnAuthFromStorage,
189
189
  isTraeCnUsageEnabled,
190
190
  } = require("../lib/trae-cn-config");
191
191
  const {
@@ -2315,9 +2315,10 @@ async function cmdSync(argv, context = {}) {
2315
2315
  Math.floor((endTime - 30 * 24 * 60 * 60) / HALF_HOUR_SEC) * HALF_HOUR_SEC,
2316
2316
  );
2317
2317
  try {
2318
- // Absent storage means "not signed in" — a silent skip, not an error.
2319
- const traeCnStoragePath = resolveTraeCnStoragePath({ env: process.env, home });
2320
- if (traeCnStoragePath && fssync.existsSync(traeCnStoragePath)) {
2318
+ // Read through junctions and let the credential-safe reader distinguish
2319
+ // missing auth from access/IO errors. existsSync masks both as absent.
2320
+ const traeCnAuth = readTraeCnAuthFromStorage({ env: process.env, home });
2321
+ if (traeCnAuth) {
2321
2322
  if (progress?.enabled) {
2322
2323
  progress.start(`Fetching TRAE Work CN usage...`);
2323
2324
  }
@@ -4630,6 +4631,12 @@ async function readQueueBatch(queuePath, startOffset, maxBuckets) {
4630
4631
  // rows whose input_tokens still include cached tokens (6-7x inflated) —
4631
4632
  // while the local dashboard showed corrected numbers.
4632
4633
  bucket = require("../lib/local-api").normalizeQueueRow(bucket);
4634
+ // Backend origins are local provenance; the current cloud accounting
4635
+ // schema has no backend dimension. Do not disclose gateway hosts there.
4636
+ if (bucket.backend_usage) {
4637
+ bucket = { ...bucket };
4638
+ delete bucket.backend_usage;
4639
+ }
4633
4640
  bucketMap.set(`${source}|${model}|${hourStart}`, bucket);
4634
4641
  linesRead += 1;
4635
4642
  if (linesRead >= maxBuckets) break;
@@ -6372,8 +6379,10 @@ async function repairCodexRescanInflation({
6372
6379
  }
6373
6380
  }
6374
6381
  rebuilt = {
6382
+ version: tmpCursors.hourly.version,
6375
6383
  buckets: tmpCursors.hourly.buckets || {},
6376
6384
  groupQueued: tmpCursors.hourly.groupQueued || {},
6385
+ modelAttributionPolicy: tmpCursors.hourly.modelAttributionPolicy,
6377
6386
  codexHashes: Array.isArray(tmpCursors.codexHashes) ? tmpCursors.codexHashes : [],
6378
6387
  codexCompactionResponseIds: Array.isArray(tmpCursors.codexCompactionResponseIds)
6379
6388
  ? tmpCursors.codexCompactionResponseIds
@@ -6510,6 +6519,9 @@ async function repairCodexRescanInflation({
6510
6519
  // rebuilt per-file cursors (offset at EOF) so the later parse in THIS sync
6511
6520
  // does not re-read codex (which would re-inflate project buckets).
6512
6521
  const hourly = (cursors.hourly ||= { buckets: {}, groupQueued: {} });
6522
+ // Without the rebuilt schema version, the next normalization treats these
6523
+ // per-model keys as v1 and overwrites them all with an unknown bucket (#927).
6524
+ hourly.version = rebuilt.version;
6513
6525
  hourly.buckets ||= {};
6514
6526
  hourly.groupQueued ||= {};
6515
6527
  for (const k of Object.keys(hourly.buckets)) {
@@ -6524,6 +6536,12 @@ async function repairCodexRescanInflation({
6524
6536
  for (const [k, v] of Object.entries(rebuilt.groupQueued)) {
6525
6537
  if (k.startsWith("codex|")) hourly.groupQueued[k] = v;
6526
6538
  }
6539
+ // The rebuilt queue already uses the parser's current attribution policy.
6540
+ // Preserve it so the following parse does not re-emit every repaired row.
6541
+ hourly.modelAttributionPolicy = {
6542
+ ...hourly.modelAttributionPolicy,
6543
+ codex: rebuilt.modelAttributionPolicy.codex,
6544
+ };
6527
6545
  cursors.files ||= {};
6528
6546
  for (const fp of Object.keys(cursors.files)) {
6529
6547
  if (isCodexSessionCursorPath(fp)) delete cursors.files[fp];
@@ -0,0 +1,291 @@
1
+ // Local-only accounting provenance. Never infer historical routing from current config.
2
+ const fs = require("node:fs");
3
+ const path = require("node:path");
4
+ const crypto = require("node:crypto");
5
+
6
+ function backendFromUrl(value) {
7
+ try {
8
+ if (typeof value !== "string" || !value.trim()) return { backend: "unknown" };
9
+ const url = new URL(value);
10
+ if (!["http:", "https:"].includes(url.protocol)) return { backend: "unknown" };
11
+ const backend_origin = url.origin;
12
+ return { backend: backend_origin === "https://api.anthropic.com" ? "anthropic" : "custom", backend_origin };
13
+ } catch { return { backend: "unknown" }; }
14
+ }
15
+
16
+ // Hash only the enclosed bytes, in bounded chunks. Persist no transcript text.
17
+ function hashClaudeRange(filePath, start, end) {
18
+ const fs = require("node:fs");
19
+ const hash = require("node:crypto").createHash("sha256");
20
+ const fd = fs.openSync(filePath, "r");
21
+ try {
22
+ const buffer = Buffer.alloc(64 * 1024);
23
+ for (let offset = start; offset < end;) {
24
+ const read = fs.readSync(fd, buffer, 0, Math.min(buffer.length, end - offset), offset);
25
+ if (!read) return null;
26
+ hash.update(buffer.subarray(0, read));
27
+ offset += read;
28
+ }
29
+ return hash.digest("hex");
30
+ } finally { fs.closeSync(fd); }
31
+ }
32
+
33
+ // Embedded verbatim in the dependency-free notify script. Read ONLY the endpoint
34
+ // env var; never credentials. Hash paths/session IDs and discard the hook body.
35
+ function captureClaudeBackend(trackerDir, payload, env = process.env) {
36
+ const fs = require("node:fs");
37
+ const path = require("node:path");
38
+ const crypto = require("node:crypto");
39
+ const event = payload?.hook_event_name;
40
+ if (!["SessionStart", "Stop", "SessionEnd"].includes(event)) return;
41
+ if (typeof payload.transcript_path !== "string" || typeof payload.session_id !== "string" || !payload.session_id) return;
42
+ const identity = backendFromUrl(env.ANTHROPIC_BASE_URL);
43
+ const dir = path.join(trackerDir, "claude-backends");
44
+ const file = crypto.createHash("sha256").update(path.resolve(payload.transcript_path)).digest("hex");
45
+ const session = crypto.createHash("sha256").update(String(payload.session_id)).digest("hex");
46
+ let stat;
47
+ try { stat = fs.statSync(payload.transcript_path); } catch {
48
+ if (event !== "SessionStart") return;
49
+ }
50
+ const observation = { event, session, inode: stat?.ino || 0, offset: stat?.size || 0, ...identity };
51
+ const anchorPath = path.join(dir, file + ".current.json");
52
+ let previous;
53
+ try { previous = JSON.parse(fs.readFileSync(anchorPath, "utf8")); } catch { /* first observation */ }
54
+ if (event === "Stop" && previous?.session === session && previous.event !== "SessionEnd"
55
+ && previous.backend_origin && previous.backend_origin === identity.backend_origin
56
+ && previous.offset < observation.offset) {
57
+ observation.range_hash = hashClaudeRange(payload.transcript_path, previous.offset, observation.offset);
58
+ }
59
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
60
+ fs.appendFileSync(path.join(dir, file + ".jsonl"), JSON.stringify(observation) + "\n", { mode: 0o600 });
61
+ fs.writeFileSync(anchorPath, JSON.stringify(observation), { mode: 0o600 });
62
+ fs.writeFileSync(path.join(trackerDir, "claude-backend-current.json"), JSON.stringify(identity), { mode: 0o600 });
63
+ }
64
+
65
+ function readLines(file) {
66
+ try { return fs.readFileSync(file, "utf8").split("\n").flatMap((line) => {
67
+ try { return [JSON.parse(line)]; } catch { return []; }
68
+ }); } catch { return []; }
69
+ }
70
+
71
+ // Parse shared upgrade metadata once per file revision, rather than once per
72
+ // transcript in a large installation. Failed reads are retried on the next call.
73
+ const legacyMetadataCache = new Map();
74
+ function readLegacyMetadata(file) {
75
+ const fd = fs.openSync(file, "r");
76
+ try {
77
+ // Bind the revision stamp and contents to the same open file, even if the
78
+ // metadata pathname is replaced while we read it.
79
+ const stat = fs.fstatSync(fd);
80
+ const stamp = `${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`;
81
+ const cached = legacyMetadataCache.get(file);
82
+ if (cached?.stamp === stamp) return cached.value;
83
+ const value = JSON.parse(fs.readFileSync(fd, "utf8"));
84
+ legacyMetadataCache.set(file, { stamp, value });
85
+ if (legacyMetadataCache.size > 8) legacyMetadataCache.delete(legacyMetadataCache.keys().next().value);
86
+ return value;
87
+ } finally { fs.closeSync(fd); }
88
+ }
89
+
90
+ // Sync receives flat paths while session metadata keys ordered cross-root groups.
91
+ // Try the scanner's root order and its reverse, accepting ONLY an exact stored
92
+ // hash. Equal basenames alone never establish shared history.
93
+ function preserveLegacyClaudeGroups(files, trackerDir) {
94
+ let meta;
95
+ try { meta = readLegacyMetadata(path.join(trackerDir, "session.queue.jsonl.meta.json")); } catch { return; }
96
+ const groups = new Map();
97
+ for (const file of files) {
98
+ if (typeof file !== "string" || !file) continue;
99
+ const id = path.basename(file).match(/^([0-9a-f-]{36})\.jsonl$/i)?.[1];
100
+ if (!id) continue;
101
+ const group = groups.get(id) || [];
102
+ if (!group.includes(file)) group.push(file);
103
+ groups.set(id, group);
104
+ }
105
+ for (const group of groups.values()) {
106
+ if (group.length < 2) continue;
107
+ for (const ordered of [group, [...group].reverse()]) {
108
+ const key = crypto.createHash("sha256").update(`claude\0${ordered.map((file) => path.resolve(file)).join("\0")}`).digest("hex").slice(0, 24);
109
+ const stats = meta.files?.[key]?.stat_key?.split("|");
110
+ if (!stats || stats.length !== ordered.length) continue;
111
+ ordered.forEach((file, index) => {
112
+ try { preserveLegacyClaudePrefix(file, trackerDir, Number(stats[index].split(":")[0])); }
113
+ catch (error) {
114
+ // Discovery can race a removed/unreadable mirror. Let the parser's
115
+ // normal per-file handling continue with the surviving paths.
116
+ if (!["ENOENT", "EACCES", "EPERM", "ESTALE"].includes(error.code)) throw error;
117
+ }
118
+ });
119
+ break;
120
+ }
121
+ }
122
+ }
123
+
124
+ // Preserve the already-accounted prefix when upgrading. The boundary is frozen
125
+ // before either scanner advances its cache; existing per-record decisions still
126
+ // win. Store only byte counts, file identity and a digest, never transcript text.
127
+ function preserveLegacyClaudePrefix(filePath, trackerDir, offset = 0) {
128
+ const key = crypto.createHash("sha256").update(path.resolve(filePath)).digest("hex");
129
+ const dir = path.join(trackerDir, "claude-backends");
130
+ const boundaryPath = path.join(dir, key + ".legacy.json");
131
+ try { return JSON.parse(fs.readFileSync(boundaryPath, "utf8")); } catch { /* first scan */ }
132
+ const stat = fs.statSync(filePath);
133
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > stat.size) offset = 0;
134
+ try {
135
+ const cursor = readLegacyMetadata(path.join(trackerDir, "cursors.json"))?.files?.[filePath];
136
+ if (cursor?.inode === stat.ino && cursor.offset <= stat.size) offset = Math.max(offset, cursor.offset || 0);
137
+ } catch { /* no previously ingested queue */ }
138
+ try {
139
+ const cacheKey = crypto.createHash("sha256").update(`claude\0${path.resolve(filePath)}`).digest("hex").slice(0, 24);
140
+ const meta = readLegacyMetadata(path.join(trackerDir, "session.queue.jsonl.meta.json"));
141
+ const size = Number(meta.files?.[cacheKey]?.stat_key?.split(":")[0]);
142
+ if (size <= stat.size) offset = Math.max(offset, size || 0);
143
+ } catch { /* no previously ingested session */ }
144
+ offset = Math.min(Math.max(0, offset), stat.size);
145
+ const boundary = { inode: stat.ino, offset, hash: hashClaudeRange(filePath, 0, offset) };
146
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
147
+ try { fs.writeFileSync(boundaryPath, JSON.stringify(boundary), { mode: 0o600, flag: "wx" }); }
148
+ catch (error) { if (error.code !== "EEXIST") throw error; }
149
+ return JSON.parse(fs.readFileSync(boundaryPath, "utf8"));
150
+ }
151
+
152
+ function createBackendResolver(filePath, trackerDir, { legacyOffset = 0 } = {}) {
153
+ const key = crypto.createHash("sha256").update(path.resolve(filePath)).digest("hex");
154
+ const dir = path.join(trackerDir, "claude-backends");
155
+ const ledger = path.join(dir, key + ".usage.jsonl");
156
+ const decisions = new Map();
157
+ let decisionOffset = 0;
158
+ function refreshDecisions() {
159
+ let fd;
160
+ try {
161
+ fd = fs.openSync(ledger, "r");
162
+ const size = fs.fstatSync(fd).size;
163
+ if (size <= decisionOffset) return;
164
+ const bytes = Buffer.alloc(size - decisionOffset);
165
+ fs.readSync(fd, bytes, 0, bytes.length, decisionOffset);
166
+ // Only consume complete append records, including another scanner's.
167
+ const boundary = bytes.lastIndexOf(10) + 1;
168
+ for (const line of bytes.subarray(0, boundary).toString("utf8").split("\n")) {
169
+ try {
170
+ const row = JSON.parse(line);
171
+ if (!decisions.has(row.id)) decisions.set(row.id, row);
172
+ } catch { /* incomplete/corrupt record */ }
173
+ }
174
+ decisionOffset += boundary;
175
+ } catch (error) { if (error.code !== "ENOENT") throw error; }
176
+ finally { if (fd !== undefined) fs.closeSync(fd); }
177
+ }
178
+ refreshDecisions();
179
+ const observations = readLines(path.join(dir, key + ".jsonl"));
180
+ const fileStat = fs.statSync(filePath);
181
+ const inode = fileStat.ino;
182
+ const legacy = preserveLegacyClaudePrefix(filePath, trackerDir, legacyOffset);
183
+ const legacyEnd = legacy.inode === inode && legacy.offset <= fileStat.size
184
+ && hashClaudeRange(filePath, 0, legacy.offset) === legacy.hash ? legacy.offset : 0;
185
+ const intervals = [];
186
+ let previous = null;
187
+ for (const current of observations) {
188
+ if (current.event === "Stop" && previous && previous.event !== "SessionEnd"
189
+ && current.session === previous.session && current.inode === inode
190
+ && (previous.inode === inode || (previous.event === "SessionStart" && previous.offset === 0))
191
+ && current.offset > previous.offset && current.offset <= fileStat.size) {
192
+ const a = backendFromUrl(previous.backend_origin);
193
+ const b = backendFromUrl(current.backend_origin);
194
+ if (a.backend_origin && a.backend_origin === b.backend_origin) {
195
+ intervals.push({ start: previous.offset, end: current.offset, session: current.session, hash: current.range_hash, ...a });
196
+ }
197
+ }
198
+ previous = current;
199
+ }
200
+ // A missed Stop must not bridge multiple turns. Only inspect ranges actually
201
+ // bracketed by observations; no prompts or message bodies leave this function.
202
+ function validInterval(interval) {
203
+ if (interval.valid !== undefined) return interval.valid;
204
+ interval.valid = false;
205
+ const fd = fs.openSync(filePath, "r");
206
+ try {
207
+ const bytes = Buffer.alloc(interval.end - interval.start);
208
+ fs.readSync(fd, bytes, 0, bytes.length, interval.start);
209
+ if (!interval.hash || crypto.createHash("sha256").update(bytes).digest("hex") !== interval.hash) return false;
210
+ let turns = 0;
211
+ for (const line of bytes.toString("utf8").split("\n")) {
212
+ try {
213
+ const row = JSON.parse(line);
214
+ const content = row.message?.content;
215
+ if (row.type === "user" && (typeof content === "string" || content?.some?.((b) => b.type === "text"))) turns++;
216
+ } catch { /* partial boundary / non-JSON */ }
217
+ }
218
+ interval.valid = turns === 1;
219
+ return interval.valid;
220
+ } finally { fs.closeSync(fd); }
221
+ }
222
+ return (obj, start, end) => {
223
+ const id = crypto.createHash("sha256").update(JSON.stringify([
224
+ obj.sessionId, obj.uuid, obj.requestId, obj.message?.id, obj.timestamp, start, end,
225
+ ])).digest("hex");
226
+ const saved = decisions.get(id);
227
+ if (saved) return saved.legacy ? null : backendFromUrl(saved.backend_origin);
228
+ const session = typeof obj.sessionId === "string"
229
+ ? crypto.createHash("sha256").update(obj.sessionId).digest("hex") : null;
230
+ const matches = intervals.filter((i) => i.session === session && start >= i.start && end <= i.end && validInterval(i));
231
+ const identity = end <= legacyEnd ? { legacy: true } : matches.length === 1 ? backendFromUrl(matches[0].backend_origin) : { backend: "unknown" };
232
+ // First ingestion wins, including unknown. Late hooks cannot relabel usage.
233
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
234
+ fs.appendFileSync(ledger, JSON.stringify({ id, ...identity }) + "\n", { mode: 0o600 });
235
+ // Sync and session analytics can run concurrently. O_APPEND orders these
236
+ // small records; read that order so the first durable decision always wins.
237
+ refreshDecisions();
238
+ return decisions.get(id)?.legacy ? null : backendFromUrl(decisions.get(id)?.backend_origin);
239
+ };
240
+ }
241
+
242
+ // Byte offsets, including CRLF and multibyte text, must agree with hook stat sizes.
243
+ async function* claudeLines(filePath, start = 0, end = undefined) {
244
+ const input = fs.createReadStream(filePath, { start, ...(end > start ? { end: end - 1 } : {}) });
245
+ let pending = Buffer.alloc(0);
246
+ let offset = start;
247
+ for await (const chunk of input) {
248
+ pending = Buffer.concat([pending, chunk]);
249
+ let newline;
250
+ while ((newline = pending.indexOf(10)) !== -1) {
251
+ const next = offset + newline + 1;
252
+ yield { line: pending.subarray(0, newline).toString("utf8"), start: offset, end: next };
253
+ offset = next;
254
+ pending = pending.subarray(newline + 1);
255
+ }
256
+ }
257
+ // Preserve complete legacy files without a final newline; defer partial JSON.
258
+ if (pending.length) {
259
+ try { JSON.parse(pending.toString("utf8")); } catch { return; }
260
+ yield { line: pending.toString("utf8"), start: offset, end: offset + pending.length };
261
+ }
262
+ }
263
+
264
+ const TOKEN_FIELDS = ["input_tokens", "cached_input_tokens", "cache_creation_input_tokens", "output_tokens", "reasoning_output_tokens", "total_tokens"];
265
+ function mergeBackendUsage(target, delta) {
266
+ if (!Array.isArray(delta?.backend_usage)) return;
267
+ target.backend_usage ||= [];
268
+ for (const part of delta.backend_usage) {
269
+ const identity = backendFromUrl(part.backend_origin);
270
+ let row = target.backend_usage.find((r) => r.backend === identity.backend && r.backend_origin === identity.backend_origin);
271
+ if (!row) { row = { ...identity }; target.backend_usage.push(row); }
272
+ for (const field of TOKEN_FIELDS) row[field] = (row[field] || 0) + (Number(part[field]) || 0);
273
+ }
274
+ }
275
+ function hasUnknownBackendCost(row) {
276
+ return row?.source === "claude" && row.backend_usage?.some((p) => p.backend !== "anthropic" && p.total_tokens > 0);
277
+ }
278
+ function readCustomBackend({ home, env = process.env }) {
279
+ // Current configuration is ONLY for the limits card, never usage attribution.
280
+ let configured = env.ANTHROPIC_BASE_URL;
281
+ if (!configured) {
282
+ try { configured = JSON.parse(fs.readFileSync(path.join(env.CLAUDE_CONFIG_DIR || path.join(home, ".claude"), "settings.json"), "utf8"))?.env?.ANTHROPIC_BASE_URL; } catch { /* absent */ }
283
+ }
284
+ if (configured) return backendFromUrl(configured);
285
+ try {
286
+ const observed = JSON.parse(fs.readFileSync(path.join(home, ".tokentracker", "tracker", "claude-backend-current.json"), "utf8"));
287
+ return backendFromUrl(observed.backend_origin);
288
+ } catch { return { backend: "unknown" }; }
289
+ }
290
+
291
+ module.exports = { preserveLegacyClaudeGroups, preserveLegacyClaudePrefix, hashClaudeRange, claudeLines, backendFromUrl, captureClaudeBackend, createBackendResolver, mergeBackendUsage, hasUnknownBackendCost, readCustomBackend, TOKEN_FIELDS };
@@ -5,7 +5,7 @@ const { ensureDir, readJson, writeJson } = require("./fs");
5
5
  const { buildNotifyCommand, notifyIdentity } = require("./notify-command");
6
6
 
7
7
  const DEFAULT_EVENT = "SessionEnd";
8
- const CLAUDE_USAGE_EVENTS = ["Stop", DEFAULT_EVENT];
8
+ const CLAUDE_USAGE_EVENTS = ["SessionStart", "Stop", DEFAULT_EVENT];
9
9
 
10
10
  async function upsertClaudeHook({ settingsPath, hookCommand, event = DEFAULT_EVENT }) {
11
11
  return upsertClaudeHooks({ settingsPath, hookCommand, events: [event] });
@@ -4,6 +4,7 @@ const fs = require("node:fs");
4
4
 
5
5
  const { readJson } = require("./fs");
6
6
  const { readSqliteFirstValue } = require("./sqlite-reader");
7
+ const { decodeCursorAccessToken } = require("./cursor-safe-storage");
7
8
 
8
9
  // ── Path resolution ──
9
10
 
@@ -54,7 +55,11 @@ function cursorDebugLog(message, env = process.env) {
54
55
  }
55
56
 
56
57
  function readCursorAccessTokenFromStateDb(stateDbPath, deps = {}) {
57
- return readSqliteFirstValue(stateDbPath, CURSOR_ACCESS_TOKEN_SQL, "value", {
58
+ return readCursorStateValue(stateDbPath, CURSOR_ACCESS_TOKEN_SQL, deps);
59
+ }
60
+
61
+ function readCursorStateValue(stateDbPath, sql, deps = {}) {
62
+ return readSqliteFirstValue(stateDbPath, sql, "value", {
58
63
  execFileSync: deps.execFileSync,
59
64
  requireFn: deps.requireFn,
60
65
  env: deps.env,
@@ -65,13 +70,28 @@ function readCursorAccessTokenFromStateDb(stateDbPath, deps = {}) {
65
70
  });
66
71
  }
67
72
 
73
+ // Identity lookup does not access the OS secret store or decrypt the token.
74
+ function readCursorUserIdFromStateDb(stateDbPath, deps = {}) {
75
+ const profile = readCursorStateValue(stateDbPath,
76
+ "SELECT value FROM ItemTable WHERE key = 'cursorAuth/cachedScopedProfile';", deps);
77
+ try {
78
+ const userId = normalizeCursorSubject(JSON.parse(profile)?.authId);
79
+ if (userId) return userId;
80
+ } catch {
81
+ // A missing or malformed cache must not prevent the membership fallback.
82
+ }
83
+ const membership = readCursorStateValue(stateDbPath,
84
+ "SELECT value FROM ItemTable WHERE key = 'cursorAuth/stripeMembershipAuthId';", deps);
85
+ return normalizeCursorSubject(membership);
86
+ }
87
+
68
88
  /**
69
89
  * Extract Cursor session cookie from local SQLite + cli-config.json.
70
90
  * Returns { cookie, userId } or null on failure.
71
91
  *
72
92
  * Cookie format: WorkosCursorSessionToken=<userId>%3A%3A<jwt>
73
- * - JWT from state.vscdb → ItemTable → cursorAuth/accessToken
74
- * - userId from cli-config.json → authInfo.authId
93
+ * - JWT (plaintext or safeStorage) from state.vscdb → cursorAuth/accessToken
94
+ * - userId from cli-config.json → authInfo.authId, state.vscdb, or JWT subject
75
95
  * - native Cursor email/password: "auth0|user_XXXXX" → "user_XXXXX"
76
96
  * - Google sign-in via WorkOS: "google-oauth2|<numeric>" → kept verbatim
77
97
  * - other WorkOS subjects: "github|…", "oidc|…" → kept verbatim
@@ -89,18 +109,21 @@ function extractCursorSessionToken({ home, platform, env, deps } = {}) {
89
109
  * extractCursorSessionToken so the token is not propagated unnecessarily.
90
110
  */
91
111
  function extractCursorAuth({ home, platform, env, deps } = {}) {
92
- const { stateDbPath, cliConfigPath } = resolveCursorPaths({ home, platform, env });
112
+ const { appDir, stateDbPath, cliConfigPath } = resolveCursorPaths({ home, platform, env });
93
113
 
94
114
  // 1. Extract JWT from SQLite
95
115
  if (!fs.existsSync(stateDbPath)) {
96
116
  cursorDebugLog(`Cursor state DB not found at ${stateDbPath}`, deps?.env);
97
117
  return null;
98
118
  }
99
- const jwt = readCursorAccessTokenFromStateDb(stateDbPath, deps);
100
- if (!jwt || jwt.length < 10) return null;
119
+ const jwt = decodeCursorAccessToken(readCursorAccessTokenFromStateDb(stateDbPath, deps), {
120
+ platform, appDir, deps,
121
+ });
122
+ if (!jwt) return null;
101
123
 
102
- // 2. Extract userId — try cli-config.json first, fall back to JWT decode
103
- let userId = extractUserIdFromCliConfig(cliConfigPath);
124
+ // 2. New Cursor versions keep identity in SQLite instead of cli-config.json.
125
+ let userId = extractUserIdFromCliConfig(cliConfigPath)
126
+ || readCursorUserIdFromStateDb(stateDbPath, deps);
104
127
  if (!userId) {
105
128
  userId = extractUserIdFromJwt(jwt);
106
129
  }
@@ -116,7 +139,8 @@ function extractCursorAuth({ home, platform, env, deps } = {}) {
116
139
  const WORKOS_OAUTH_SUBJECT_RE = /^(google-oauth2|github|oidc|auth0)\|[^|]+$/;
117
140
 
118
141
  function normalizeCursorSubject(subject) {
119
- if (!subject) return null;
142
+ if (typeof subject !== "string" || !subject || /[\s;\x00-\x1f\x7f]/.test(subject)) return null;
143
+ if (/^user_[A-Za-z0-9_]+$/.test(subject)) return subject;
120
144
  // Native Cursor: "auth0|user_XXXXX" → strip provider prefix, return "user_XXXXX"
121
145
  const native = subject.match(/\|(user_[A-Za-z0-9_]+)$/);
122
146
  if (native) return native[1];
@@ -607,6 +631,7 @@ module.exports = {
607
631
  resolveCursorPaths,
608
632
  isCursorInstalled,
609
633
  readCursorAccessTokenFromStateDb,
634
+ readCursorUserIdFromStateDb,
610
635
  extractCursorAuth,
611
636
  extractCursorSessionToken,
612
637
  fetchCursorUsageCsv,
@@ -0,0 +1,119 @@
1
+ const crypto = require("node:crypto");
2
+ const cp = require("node:child_process");
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+
6
+ const PREFIX = "cursor-safe-storage:v1:";
7
+
8
+ function decodeBase64(value) {
9
+ if (typeof value !== "string" || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
10
+ const bytes = Buffer.from(value, "base64");
11
+ return bytes.toString("base64").replace(/=+$/, "") === value.replace(/=+$/, "")
12
+ ? bytes : null;
13
+ }
14
+
15
+ function isJwt(value) {
16
+ if (typeof value !== "string" || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value)) return false;
17
+ try {
18
+ const payload = JSON.parse(Buffer.from(value.split(".")[1], "base64url").toString("utf8"));
19
+ return payload !== null && typeof payload === "object" && !Array.isArray(payload);
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ // Capture every stream: execFileSync otherwise forwards a failing child's stderr.
26
+ // Never include secrets in argv, errors, logs, or temporary files.
27
+ function runSecretCommand(command, args, deps, input) {
28
+ return (deps.execFileSync || cp.execFileSync)(command, args, {
29
+ encoding: "utf8",
30
+ timeout: 5000,
31
+ maxBuffer: 1024 * 1024,
32
+ windowsHide: true,
33
+ stdio: ["pipe", "pipe", "pipe"],
34
+ ...(input === undefined ? {} : { input }),
35
+ }).replace(/\r?\n$/, "");
36
+ }
37
+
38
+ function unprotectWindows(bytes, deps) {
39
+ // A fixed script keeps the encrypted data out of process listings as well.
40
+ const script = [
41
+ "$ErrorActionPreference = 'Stop'",
42
+ "Add-Type -AssemblyName System.Security",
43
+ "$data = [Convert]::FromBase64String([Console]::In.ReadToEnd())",
44
+ "$plain = [System.Security.Cryptography.ProtectedData]::Unprotect($data, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)",
45
+ "[Console]::Out.Write([Convert]::ToBase64String($plain))",
46
+ ].join("; ");
47
+ return decodeBase64(runSecretCommand("powershell.exe", [
48
+ "-NoProfile", "-NonInteractive", "-Command", script,
49
+ ], deps, bytes.toString("base64")));
50
+ }
51
+
52
+ function decryptWindows(bytes, appDir, deps) {
53
+ const unprotect = deps.unprotectWindows || ((data) => unprotectWindows(data, deps));
54
+ if (bytes.subarray(0, 3).toString() !== "v10") {
55
+ // Older OSCrypt used DPAPI directly, with no version prefix.
56
+ return unprotect(bytes)?.toString("utf8");
57
+ }
58
+ // Chromium v10: DPAPI-protected AES-256-GCM key in Cursor's Local State.
59
+ if (!appDir || bytes.length < 3 + 12 + 16) return null;
60
+ const localState = JSON.parse((deps.readFileSync || fs.readFileSync)(
61
+ path.win32.join(appDir, "Local State"), "utf8",
62
+ ));
63
+ const wrappedKey = decodeBase64(localState?.os_crypt?.encrypted_key);
64
+ if (!wrappedKey || wrappedKey.subarray(0, 5).toString() !== "DPAPI") return null;
65
+ const key = unprotect(wrappedKey.subarray(5));
66
+ if (!Buffer.isBuffer(key) || key.length !== 32) return null;
67
+ const decipher = crypto.createDecipheriv("aes-256-gcm", key, bytes.subarray(3, 15));
68
+ decipher.setAuthTag(bytes.subarray(-16));
69
+ return Buffer.concat([decipher.update(bytes.subarray(15, -16)), decipher.final()]).toString("utf8");
70
+ }
71
+
72
+ function decryptCbc(bytes, password, iterations) {
73
+ const key = crypto.pbkdf2Sync(password, "saltysalt", iterations, 16, "sha1");
74
+ const decipher = crypto.createDecipheriv("aes-128-cbc", key, Buffer.alloc(16, 0x20));
75
+ return Buffer.concat([decipher.update(bytes.subarray(3)), decipher.final()]).toString("utf8");
76
+ }
77
+
78
+ /** Decode Cursor's base64 Electron safeStorage envelope, or retain a legacy JWT.
79
+ * OS access is injectable. Unavailable/locked stores and unknown formats fail
80
+ * closed, without exposing child-process errors (which can contain secrets).
81
+ */
82
+ function decodeCursorAccessToken(value, { platform = process.platform, appDir, deps = {} } = {}) {
83
+ if (isJwt(value)) return value;
84
+ if (typeof value !== "string" || !value.startsWith(PREFIX)) return null;
85
+ const bytes = decodeBase64(value.slice(PREFIX.length));
86
+ if (!bytes?.length) return null;
87
+ try {
88
+ let token;
89
+ const version = bytes.subarray(0, 3).toString();
90
+ if (platform === "win32") {
91
+ token = decryptWindows(bytes, appDir, deps);
92
+ } else if (platform === "darwin" || platform === "linux") {
93
+ if (bytes.length <= 3 || (bytes.length - 3) % 16 !== 0) return null;
94
+ let password;
95
+ if (platform === "darwin") {
96
+ if (version !== "v10") return null;
97
+ password = runSecretCommand("/usr/bin/security", [
98
+ "find-generic-password", "-s", "Cursor Safe Storage", "-w",
99
+ ], deps);
100
+ if (!password) return null;
101
+ } else if (version === "v10") {
102
+ password = "peanuts"; // Chromium's basic_text backend.
103
+ } else if (version === "v11") {
104
+ // Best effort for Secret Service stores. KWallet-only/locked stores
105
+ // and hosts without secret-tool are intentionally unavailable.
106
+ password = runSecretCommand("secret-tool", ["lookup", "application", "cursor"], deps);
107
+ if (!password) return null;
108
+ } else {
109
+ return null;
110
+ }
111
+ token = decryptCbc(bytes, password, platform === "darwin" ? 1003 : 1);
112
+ }
113
+ return isJwt(token) ? token : null;
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ module.exports = { decodeCursorAccessToken };