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.
- package/README.md +2 -0
- package/THIRD_PARTY_NOTICES.md +10 -0
- package/dashboard/dist/THIRD_PARTY_NOTICES.txt +10 -0
- package/dashboard/dist/assets/{AchievementBadge-BYxdWhS1.js → AchievementBadge-DIHKXKgm.js} +1 -1
- package/dashboard/dist/assets/{AchievementsPage-Bv9o7Aj8.js → AchievementsPage-U66jjKFf.js} +2 -2
- package/dashboard/dist/assets/{AchievementsSection-DE5bZGxg.js → AchievementsSection-BAW79xKg.js} +1 -1
- package/dashboard/dist/assets/{BadgeDetailModal-CgZaSVTp.js → BadgeDetailModal-IfomOw9B.js} +1 -1
- package/dashboard/dist/assets/{BotAnimated-CwhPfUBI.js → BotAnimated-B33gSUM3.js} +1 -1
- package/dashboard/dist/assets/{Card-Y7qaxMuE.js → Card-dtrkef-y.js} +1 -1
- package/dashboard/dist/assets/{CloudCheckoutPage-DVl7jk6b.js → CloudCheckoutPage-jYPgHJYE.js} +1 -1
- package/dashboard/dist/assets/{CloudPage-BnIvpHGH.js → CloudPage-BumdgM5w.js} +2 -2
- package/dashboard/dist/assets/{CommandPalette-CzMv2Zxn.js → CommandPalette-CFEeoIo3.js} +1 -1
- package/dashboard/dist/assets/{CommunityStatsModal-D8sOZqUc.js → CommunityStatsModal-DL3MrPmb.js} +1 -1
- package/dashboard/dist/assets/{ConfirmModal-Bun5xKMm.js → ConfirmModal-BRdtCv2g.js} +1 -1
- package/dashboard/dist/assets/{Controls-ltfnvkFW.js → Controls-Bb-_ZAsT.js} +1 -1
- package/dashboard/dist/assets/DashboardPage-GXwt6DoC.js +1 -0
- package/dashboard/dist/assets/{DateRangePopover-e3gJYqFJ.js → DateRangePopover-CHkFCFkE.js} +1 -1
- package/dashboard/dist/assets/{DevicePage-JgT16dEp.js → DevicePage-Da753sLS.js} +1 -1
- package/dashboard/dist/assets/{DialogClose-DeWert7h.js → DialogClose-4A_jISLc.js} +1 -1
- package/dashboard/dist/assets/DialogDescription-CiEhDvt9.js +1 -0
- package/dashboard/dist/assets/{DialogTitle-BfK_yOeR.js → DialogTitle-C0oQdI9n.js} +1 -1
- package/dashboard/dist/assets/{ElectricLogo-Bbev0Y8Q.js → ElectricLogo-Bv-sB-O-.js} +1 -1
- package/dashboard/dist/assets/{FadeIn-fypYWLDK.js → FadeIn-Ch2AuGxY.js} +1 -1
- package/dashboard/dist/assets/{HeaderGithubStar-Cnil2Lbo.js → HeaderGithubStar-C_dy8a5K.js} +1 -1
- package/dashboard/dist/assets/{HoverTooltip-Bw_8tru-.js → HoverTooltip-iuzo5YeN.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-CHnhUgA2.js → IpCheckPage-BKEID9fw.js} +1 -1
- package/dashboard/dist/assets/{LandingPage-Bq4iQS6S.js → LandingPage-DZt5JWZ7.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardPage-D42lUdw-.js → LeaderboardPage-CYrCQF5P.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardProBadge-eTKceT90.js → LeaderboardProBadge-B8vG2SAW.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardProfileModal-D-3tRrOR.js → LeaderboardProfileModal-DpAvBJPT.js} +3 -3
- package/dashboard/dist/assets/{LeaderboardProfilePage-DkRCJoiA.js → LeaderboardProfilePage-CofOk8GO.js} +1 -1
- package/dashboard/dist/assets/{LimitsPage-dUXjP3_1.js → LimitsPage-DXknvxtI.js} +6 -6
- package/dashboard/dist/assets/{LocalOnlyNotice-CtpjB6Z2.js → LocalOnlyNotice-BBVCEm5Q.js} +1 -1
- package/dashboard/dist/assets/{LoginCard-zrRd8o54.js → LoginCard-DcPazHo1.js} +1 -1
- package/dashboard/dist/assets/{LoginModal-DNl5Rt1e.js → LoginModal-CGXPaeWD.js} +1 -1
- package/dashboard/dist/assets/{LoginPage-BuB707vV.js → LoginPage-Ct6Al1pz.js} +1 -1
- package/dashboard/dist/assets/{PetPage-BpDriXkp.js → PetPage-8NPEAa-c.js} +2 -2
- package/dashboard/dist/assets/{PopoverPopup-rQNSs8R6.js → PopoverPopup-R69ks89h.js} +1 -1
- package/dashboard/dist/assets/{ProviderIcon-CumyO371.js → ProviderIcon-svF44YE0.js} +1 -1
- package/dashboard/dist/assets/{ResetPasswordPage-ecUHfJAk.js → ResetPasswordPage-DJIYKu8C.js} +1 -1
- package/dashboard/dist/assets/{Select-YVEqEhk4.js → Select-BVK4XZD5.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-D1NBc4uc.js → SelectItemText-Bh74D8lr.js} +1 -1
- package/dashboard/dist/assets/{SelfHostPage-8nAin68c.js → SelfHostPage-8gj2lAFp.js} +1 -1
- package/dashboard/dist/assets/{ServiceStatusPage-D1zxuzOg.js → ServiceStatusPage-CWPV_Mou.js} +1 -1
- package/dashboard/dist/assets/SessionsPage-Bo5plK6O.js +3 -0
- package/dashboard/dist/assets/{SettingsPage-COqP8ZbJ.js → SettingsPage-BptJrWn-.js} +3 -3
- package/dashboard/dist/assets/{SkillsPage-DCX7xTj0.js → SkillsPage-CRgGXnJS.js} +1 -1
- package/dashboard/dist/assets/{TokenGalaxy-D4n7AR-R.js → TokenGalaxy-dgOy0Eo7.js} +1 -1
- package/dashboard/dist/assets/{TooltipPopup-DvSoB_eS.js → TooltipPopup-DpGnyoFz.js} +1 -1
- package/dashboard/dist/assets/{TrendMonitor-5GVzh0Ye.js → TrendMonitor-BEy_GbMg.js} +1 -1
- package/dashboard/dist/assets/{WidgetsPage-BIc4MBiu.js → WidgetsPage-ChUelFrP.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-BpQk4g0o.js → WrappedPage-n63RsX8z.js} +1 -1
- package/dashboard/dist/assets/{__vite-browser-external-B_rKFTTN.js → __vite-browser-external-D_JnEwiu.js} +3 -3
- package/dashboard/dist/assets/{agent-logos-DVg-sTm4.js → agent-logos-DQ1h5uQF.js} +1 -1
- package/dashboard/dist/assets/{arrow-left-Ch6Q7eU5.js → arrow-left-Bo3Sg_cg.js} +1 -1
- package/dashboard/dist/assets/{arrow-right-B2B9GvSI.js → arrow-right-DdxkL8Ul.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-DwNO_uSb.js → arrow-up-right-CpvelPD2.js} +1 -1
- package/dashboard/dist/assets/{calendar-days---NjJlBi.js → calendar-days-BvNVya0e.js} +1 -1
- package/dashboard/dist/assets/{check-D3mhgRrW.js → check-tO40Glrg.js} +1 -1
- package/dashboard/dist/assets/{chevron-down-x-b39Zto.js → chevron-down-Dnrra3mC.js} +1 -1
- package/dashboard/dist/assets/{cloud-checkout-CycWyTsg.js → cloud-checkout-COC4zB8s.js} +1 -1
- package/dashboard/dist/assets/{composite-CnA1qtYn.js → composite-C0NgnRTl.js} +1 -1
- package/dashboard/dist/assets/{copy-JRnRMgdX.js → copy-DTKN2SO4.js} +1 -1
- package/dashboard/dist/assets/{download-CRLqGD62.js → download-CSq2Yd3D.js} +1 -1
- package/dashboard/dist/assets/{external-link-BrmaWV_t.js → external-link-BtGedmuQ.js} +1 -1
- package/dashboard/dist/assets/{flame-meQ5-vRD.js → flame-ChjXIFSx.js} +1 -1
- package/dashboard/dist/assets/{format-tokens-D8mIJGz1.js → format-tokens-CXqZY114.js} +1 -1
- package/dashboard/dist/assets/{gift-Cd0FPXgO.js → gift-YFwBvsUA.js} +1 -1
- package/dashboard/dist/assets/{icons-D-PTKw7b.js → icons-DCiBpbK0.js} +1 -1
- package/dashboard/dist/assets/{index-C_6gEM83.js → index-B7sRX2EC.js} +1 -1
- package/dashboard/dist/assets/{index-HLYcwmPD.js → index-DMqIbTL4.js} +1 -1
- package/dashboard/dist/assets/{index-DCIZaLNC.js → index-Dpmdg98T.js} +1 -1
- package/dashboard/dist/assets/{index-CMV9UKNf.js → index-XSya-vft.js} +1 -1
- package/dashboard/dist/assets/{info-e0tP9P3D.js → info-PYzcoobJ.js} +1 -1
- package/dashboard/dist/assets/{link-2-DZ2TQhER.js → link-2-BxSrdKyR.js} +1 -1
- package/dashboard/dist/assets/{loader-circle-DjflRntu.js → loader-circle-CgvCCDTH.js} +1 -1
- package/dashboard/dist/assets/{local-usage-transfer-XTZLA3XH.js → local-usage-transfer-eTzMdhbs.js} +1 -1
- package/dashboard/dist/assets/{modifiers.esm-BpEQ8-Gt.js → modifiers.esm-B796CRz-.js} +1 -1
- package/dashboard/dist/assets/{monitor-smartphone-VVBcI30G.js → monitor-smartphone-M5nbcGRy.js} +1 -1
- package/dashboard/dist/assets/{play-DN4Sb3W_.js → play-12ZzPH9r.js} +1 -1
- package/dashboard/dist/assets/{plus-Cz_k7arH.js → plus-OqDA7xZy.js} +1 -1
- package/dashboard/dist/assets/{provider-display-S1mSaydF.js → provider-display-JlEBv1VB.js} +1 -1
- package/dashboard/dist/assets/{react-MQBcjrn_.js → react-CeDcnQho.js} +1 -1
- package/dashboard/dist/assets/{safePolygon-InKxYcXd.js → safePolygon-B_SGhQbo.js} +1 -1
- package/dashboard/dist/assets/{search-C-N0RDi0.js → search-D9wz4rWp.js} +1 -1
- package/dashboard/dist/assets/{server-CZkn8vIZ.js → server-UCMw4AQF.js} +1 -1
- package/dashboard/dist/assets/{shield-check-DPrGFouZ.js → shield-check-lJ0-Z5Ot.js} +1 -1
- package/dashboard/dist/assets/{skills-api-wMwM-GpF.js → skills-api-Da-WZbvW.js} +1 -1
- package/dashboard/dist/assets/{sortable.esm-CUPP1ihT.js → sortable.esm-Bj1ZkbqW.js} +1 -1
- package/dashboard/dist/assets/{store-BpVdRYnQ.js → store-Bpwwxxsy.js} +1 -1
- package/dashboard/dist/assets/{terminal-C-7TFeKg.js → terminal-BHQMC4hm.js} +1 -1
- package/dashboard/dist/assets/{trash-Be8cd4HD.js → trash-yTV6vhGX.js} +1 -1
- package/dashboard/dist/assets/{use-community-stats-cG0WrB4Y.js → use-community-stats-rrE4Ohic.js} +1 -1
- package/dashboard/dist/assets/{use-latest-request-guard-C91enyPD.js → use-latest-request-guard-D4Y1aCyd.js} +1 -1
- package/dashboard/dist/assets/{use-limits-display-prefs-DdwoLqPD.js → use-limits-display-prefs-ChHr4XEF.js} +1 -1
- package/dashboard/dist/assets/{use-native-settings-BR-NiMKh.js → use-native-settings-BmoCls3D.js} +1 -1
- package/dashboard/dist/assets/{use-ordered-list-oqV724BP.js → use-ordered-list-BZSSYcLx.js} +1 -1
- package/dashboard/dist/assets/use-reduced-motion-CKTIXp4-.js +1 -0
- package/dashboard/dist/assets/{use-transform-BsNSgVD6.js → use-transform-CJO1b0Iu.js} +1 -1
- package/dashboard/dist/assets/{use-usage-limits-Dk8LcMU-.js → use-usage-limits-BbAC_LWP.js} +1 -1
- package/dashboard/dist/assets/{useCurrency-FxerRfd2.js → useCurrency-B2enOMwJ.js} +1 -1
- package/dashboard/dist/assets/{usePositioner-D2J-rSfX.js → usePositioner-BaoW4QRL.js} +1 -1
- package/dashboard/dist/assets/{useTokenFormat-CaI2nl2C.js → useTokenFormat-B2bbeEu-.js} +1 -1
- package/dashboard/dist/assets/{zap-D3C37XAj.js → zap-CT5a8-_j.js} +1 -1
- package/dashboard/dist/brand-logos/factory.svg +8 -0
- package/dashboard/dist/index.html +1 -1
- package/dashboard/dist/ip-check.html +1 -1
- package/dashboard/dist/leaderboard.html +1 -1
- package/dashboard/dist/share.html +1 -1
- package/package.json +1 -1
- package/src/commands/init.js +9 -0
- package/src/commands/status.js +12 -6
- package/src/commands/sync.js +22 -4
- package/src/lib/claude-backend.js +291 -0
- package/src/lib/claude-config.js +1 -1
- package/src/lib/cursor-config.js +34 -9
- package/src/lib/cursor-safe-storage.js +119 -0
- package/src/lib/devin-limits.js +65 -25
- package/src/lib/diagnostics.js +3 -0
- package/src/lib/doctor.js +13 -0
- package/src/lib/local-api.js +2 -0
- package/src/lib/local-usage-transfer.js +22 -1
- package/src/lib/pricing/index.js +12 -0
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/rollout.js +70 -15
- package/src/lib/session-analytics.js +20 -8
- package/src/lib/trae-cn-config.js +32 -2
- package/src/lib/usage-limits.js +601 -35
- package/dashboard/dist/assets/DashboardPage-C3Xu6_3t.js +0 -1
- package/dashboard/dist/assets/DialogDescription-Ca6W3nCT.js +0 -1
- package/dashboard/dist/assets/SessionsPage-B6ksEER_.js +0 -3
- 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.
|
|
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": {
|
package/src/commands/init.js
CHANGED
|
@@ -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');
|
package/src/commands/status.js
CHANGED
|
@@ -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
|
-
//
|
|
860
|
-
//
|
|
861
|
-
//
|
|
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
|
-
|
|
864
|
-
|
|
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 {
|
package/src/commands/sync.js
CHANGED
|
@@ -185,7 +185,7 @@ const {
|
|
|
185
185
|
const { purgeProjectUsage } = require("../lib/project-usage-purge");
|
|
186
186
|
const {
|
|
187
187
|
fetchTraeCnUsageWithAuth,
|
|
188
|
-
|
|
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
|
-
//
|
|
2319
|
-
|
|
2320
|
-
|
|
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 };
|
package/src/lib/claude-config.js
CHANGED
|
@@ -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] });
|
package/src/lib/cursor-config.js
CHANGED
|
@@ -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
|
|
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 →
|
|
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
|
-
|
|
119
|
+
const jwt = decodeCursorAccessToken(readCursorAccessTokenFromStateDb(stateDbPath, deps), {
|
|
120
|
+
platform, appDir, deps,
|
|
121
|
+
});
|
|
122
|
+
if (!jwt) return null;
|
|
101
123
|
|
|
102
|
-
// 2.
|
|
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 };
|