token-rats 0.2.0 → 0.3.1

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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +16 -0
  3. package/dist/index.js +541 -526
  4. package/package.json +4 -5
package/dist/index.js CHANGED
@@ -11,12 +11,12 @@ async function installCursorCommand(opts = {}) {
11
11
  const args = ["install", "-g", "better-sqlite3@^9.4.3"];
12
12
  console.log(`\x1B[2m$ ${pm} ${args.join(" ")}\x1B[0m
13
13
  `);
14
- const exitCode = await new Promise((resolve) => {
14
+ const exitCode = await new Promise((resolve2) => {
15
15
  const child = spawn(pm, args, { stdio: "inherit" });
16
- child.on("close", (code) => resolve(code ?? 1));
16
+ child.on("close", (code) => resolve2(code ?? 1));
17
17
  child.on("error", (err) => {
18
18
  console.error(`\x1B[31mFailed to launch ${pm}: ${err.message}\x1B[0m`);
19
- resolve(1);
19
+ resolve2(1);
20
20
  });
21
21
  });
22
22
  if (exitCode === 0) {
@@ -35,6 +35,7 @@ async function installCursorCommand(opts = {}) {
35
35
  // src/commands/install-daemon.ts
36
36
  import { execFile } from "node:child_process";
37
37
  import * as fs2 from "node:fs";
38
+ import { createRequire } from "node:module";
38
39
  import * as os2 from "node:os";
39
40
  import * as path2 from "node:path";
40
41
  import { promisify } from "node:util";
@@ -150,6 +151,9 @@ function error(msg) {
150
151
  function dim(msg) {
151
152
  console.log(color(c.dim, ` ${msg}`));
152
153
  }
154
+ function bold(msg) {
155
+ console.log(isTTY() ? `${c.bold}${msg}${c.reset}` : msg);
156
+ }
153
157
  function spinner(label) {
154
158
  if (!isTTY()) {
155
159
  process.stdout.write(` ${label}...
@@ -181,8 +185,38 @@ var LABEL = "com.tokenrats.watch";
181
185
  var LINUX_UNIT = "token-rats-watch.service";
182
186
  var WINDOWS_TASK = "TokenRatsWatch";
183
187
  function resolveCliPath() {
184
- const script = process.argv[1] ?? "token-rats";
185
- return { node: process.execPath, script };
188
+ const entry = path2.resolve(process.argv[1] ?? "");
189
+ if (!entry.endsWith(".js")) throw new Error("Build the CLI before installing the daemon.");
190
+ const config = process.env.XDG_CONFIG_HOME ?? path2.join(os2.homedir(), ".config");
191
+ const runtime = path2.join(config, "token-rats", "runtime");
192
+ fs2.mkdirSync(runtime, { recursive: true });
193
+ const script = path2.join(runtime, "index.js");
194
+ if (entry !== script) fs2.copyFileSync(entry, script);
195
+ fs2.writeFileSync(path2.join(runtime, "package.json"), '{"type":"module"}\n');
196
+ const require2 = createRequire(import.meta.url);
197
+ const sqlPackage = path2.dirname(require2.resolve("sql.js/package.json"));
198
+ const destination = path2.join(runtime, "node_modules", "sql.js");
199
+ if (sqlPackage !== destination) fs2.cpSync(sqlPackage, destination, { recursive: true });
200
+ const runner = path2.join(runtime, "runner.mjs");
201
+ const settings = Object.fromEntries(
202
+ ["XDG_CONFIG_HOME", "CLAUDE_CONFIG_DIR", "CODEX_HOME", "APPDATA"].flatMap(
203
+ (key) => process.env[key] ? [[key, process.env[key]]] : []
204
+ )
205
+ );
206
+ fs2.writeFileSync(
207
+ runner,
208
+ `Object.assign(process.env, ${JSON.stringify(settings)});
209
+ await import("./index.js");
210
+ `
211
+ );
212
+ return { node: process.execPath, script: runner };
213
+ }
214
+ function xml(value) {
215
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
216
+ }
217
+ function systemdArg(value) {
218
+ const escaped = value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%").replaceAll("$", "$$");
219
+ return `"${escaped}"`;
186
220
  }
187
221
  function darwinPlistPath() {
188
222
  return path2.join(os2.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
@@ -190,7 +224,7 @@ function darwinPlistPath() {
190
224
  function darwinLogDir() {
191
225
  return path2.join(os2.homedir(), "Library", "Logs", "token-rats");
192
226
  }
193
- function darwinPlist(node, script) {
227
+ function darwinPlist(node, script, apiUrl) {
194
228
  const logDir = darwinLogDir();
195
229
  const stdout = path2.join(logDir, "watch.out.log");
196
230
  const stderr = path2.join(logDir, "watch.err.log");
@@ -202,28 +236,32 @@ function darwinPlist(node, script) {
202
236
  <string>${LABEL}</string>
203
237
  <key>ProgramArguments</key>
204
238
  <array>
205
- <string>${node}</string>
206
- <string>${script}</string>
239
+ <string>${xml(node)}</string>
240
+ <string>${xml(script)}</string>
207
241
  <string>watch</string>
242
+ ${apiUrl ? `<string>--api-url</string><string>${xml(apiUrl)}</string>` : ""}
208
243
  </array>
209
244
  <key>RunAtLoad</key>
210
245
  <true/>
211
246
  <key>KeepAlive</key>
212
247
  <true/>
213
248
  <key>StandardOutPath</key>
214
- <string>${stdout}</string>
249
+ <string>${xml(stdout)}</string>
215
250
  <key>StandardErrorPath</key>
216
- <string>${stderr}</string>
251
+ <string>${xml(stderr)}</string>
217
252
  </dict>
218
253
  </plist>
219
254
  `;
220
255
  }
221
- async function darwinInstall() {
256
+ async function darwinInstall(apiUrl) {
222
257
  const { node, script } = resolveCliPath();
223
258
  fs2.mkdirSync(darwinLogDir(), { recursive: true });
224
259
  const plistPath = darwinPlistPath();
225
260
  fs2.mkdirSync(path2.dirname(plistPath), { recursive: true });
226
- fs2.writeFileSync(plistPath, darwinPlist(node, script), { mode: 420 });
261
+ fs2.writeFileSync(plistPath, darwinPlist(node, script, apiUrl), { mode: 420 });
262
+ await exec("launchctl", ["bootout", `gui/${process.getuid?.() ?? 0}/${LABEL}`]).catch(
263
+ () => void 0
264
+ );
227
265
  try {
228
266
  await exec("launchctl", ["bootstrap", `gui/${process.getuid?.() ?? 0}`, plistPath]);
229
267
  } catch {
@@ -255,7 +293,8 @@ async function darwinStatus() {
255
293
  if (!fs2.existsSync(darwinPlistPath())) return "not-installed";
256
294
  try {
257
295
  const { stdout } = await exec("launchctl", ["list"]);
258
- if (stdout.split("\n").some((line) => line.endsWith(LABEL))) return "running";
296
+ if (stdout.split("\n").some((line) => line.endsWith(LABEL) && /^\d+\s/.test(line)))
297
+ return "running";
259
298
  return "stopped";
260
299
  } catch {
261
300
  return "stopped";
@@ -265,7 +304,7 @@ function linuxUnitPath() {
265
304
  const xdgConfig = process.env.XDG_CONFIG_HOME ?? path2.join(os2.homedir(), ".config");
266
305
  return path2.join(xdgConfig, "systemd", "user", LINUX_UNIT);
267
306
  }
268
- function linuxUnit(node, script) {
307
+ function linuxUnit(node, script, apiUrl) {
269
308
  return `[Unit]
270
309
  Description=Token Rats watcher \u2014 live AI usage sync
271
310
  After=network-online.target
@@ -273,7 +312,7 @@ Wants=network-online.target
273
312
 
274
313
  [Service]
275
314
  Type=simple
276
- ExecStart=${node} ${script} watch
315
+ ExecStart=${systemdArg(node)} ${systemdArg(script)} watch${apiUrl ? ` --api-url ${systemdArg(apiUrl)}` : ""}
277
316
  Restart=on-failure
278
317
  RestartSec=10s
279
318
  Environment=NODE_ENV=production
@@ -282,14 +321,15 @@ Environment=NODE_ENV=production
282
321
  WantedBy=default.target
283
322
  `;
284
323
  }
285
- async function linuxInstall() {
324
+ async function linuxInstall(apiUrl) {
286
325
  const { node, script } = resolveCliPath();
287
326
  const unitPath = linuxUnitPath();
288
327
  fs2.mkdirSync(path2.dirname(unitPath), { recursive: true });
289
- fs2.writeFileSync(unitPath, linuxUnit(node, script), { mode: 420 });
328
+ fs2.writeFileSync(unitPath, linuxUnit(node, script, apiUrl), { mode: 420 });
290
329
  try {
291
330
  await exec("systemctl", ["--user", "daemon-reload"]);
292
331
  await exec("systemctl", ["--user", "enable", "--now", LINUX_UNIT]);
332
+ await exec("systemctl", ["--user", "restart", LINUX_UNIT]);
293
333
  } catch (err) {
294
334
  throw new Error(
295
335
  `Wrote ${unitPath} but failed to enable+start it: ${err instanceof Error ? err.message : String(err)}`
@@ -319,7 +359,7 @@ async function linuxStatus() {
319
359
  return "stopped";
320
360
  }
321
361
  }
322
- async function windowsInstall() {
362
+ async function windowsInstall(apiUrl) {
323
363
  const { node, script } = resolveCliPath();
324
364
  await exec("schtasks", [
325
365
  "/Create",
@@ -328,7 +368,7 @@ async function windowsInstall() {
328
368
  "/TN",
329
369
  WINDOWS_TASK,
330
370
  "/TR",
331
- `"${node}" "${script}" watch`,
371
+ `"${node}" "${script}" watch${apiUrl ? ` --api-url "${apiUrl}"` : ""}`,
332
372
  "/RL",
333
373
  "LIMITED",
334
374
  "/F"
@@ -357,21 +397,21 @@ async function windowsStatus() {
357
397
  return "not-installed";
358
398
  }
359
399
  }
360
- async function installDaemonCommand() {
400
+ async function installDaemonCommand(apiUrl) {
361
401
  clearDisconnected();
362
402
  try {
363
403
  if (process.platform === "darwin") {
364
- await darwinInstall();
404
+ await darwinInstall(apiUrl);
365
405
  } else if (process.platform === "linux") {
366
- await linuxInstall();
406
+ await linuxInstall(apiUrl);
367
407
  } else if (process.platform === "win32") {
368
- await windowsInstall();
408
+ await windowsInstall(apiUrl);
369
409
  } else {
370
410
  warn(`No daemon installer for platform ${process.platform}; skipping.`);
371
411
  return;
372
412
  }
373
413
  success("Background watcher installed and running.");
374
- dim("It will pick up new sessions from Claude Code logs in real time.");
414
+ dim("It will pick up sessions from Claude Code, Codex, and Cursor every 30 seconds.");
375
415
  dim("Manage it with `token-rats daemon-status` and `token-rats uninstall-daemon`.");
376
416
  } catch (err) {
377
417
  error(`Failed to install daemon: ${err instanceof Error ? err.message : String(err)}`);
@@ -758,8 +798,8 @@ function getErrorMap() {
758
798
  return overrideErrorMap;
759
799
  }
760
800
  var makeIssue = (params) => {
761
- const { data, path: path5, errorMaps, issueData } = params;
762
- const fullPath = [...path5, ...issueData.path || []];
801
+ const { data, path: path4, errorMaps, issueData } = params;
802
+ const fullPath = [...path4, ...issueData.path || []];
763
803
  const fullIssue = {
764
804
  ...issueData,
765
805
  path: fullPath
@@ -881,11 +921,11 @@ var errorUtil;
881
921
  var _ZodEnum_cache;
882
922
  var _ZodNativeEnum_cache;
883
923
  var ParseInputLazyPath = class {
884
- constructor(parent, value, path5, key) {
924
+ constructor(parent, value, path4, key) {
885
925
  this._cachedPath = [];
886
926
  this.parent = parent;
887
927
  this.data = value;
888
- this._path = path5;
928
+ this._path = path4;
889
929
  this._key = key;
890
930
  }
891
931
  get path() {
@@ -4320,7 +4360,7 @@ var z = /* @__PURE__ */ Object.freeze({
4320
4360
  });
4321
4361
 
4322
4362
  // ../contracts/src/session.ts
4323
- var Source = z.enum(["claude-code", "cursor", "codex"]);
4363
+ var Source = z.enum(["claude-code", "cursor", "codex", "openrouter", "openai"]);
4324
4364
  var Provider = z.enum([
4325
4365
  "anthropic",
4326
4366
  "openai",
@@ -4330,13 +4370,18 @@ var Provider = z.enum([
4330
4370
  "unknown"
4331
4371
  ]);
4332
4372
  var SessionChannel = z.enum(["cli", "ide", "api", "proxy", "local", "unknown"]);
4373
+ var ID_RE = /^[A-Za-z0-9._:/+ -]+$/;
4374
+ var MODEL_RE = /^[A-Za-z0-9._:/+ -]+$/;
4375
+ var DEDUPE_KEY_RE = /^[A-Za-z0-9._:-]+$/;
4376
+ var CLIENT_RE = /^[A-Za-z0-9._-]+$/;
4333
4377
  var SessionRecord = z.object({
4334
- id: z.string().min(1),
4378
+ accountingVersion: z.literal(2).optional(),
4379
+ id: z.string().min(1).max(256).regex(ID_RE),
4335
4380
  source: Source,
4336
4381
  provider: Provider.optional(),
4337
- client: z.string().min(1).max(64).optional(),
4382
+ client: z.string().min(1).max(64).regex(CLIENT_RE).optional(),
4338
4383
  channel: SessionChannel.optional(),
4339
- model: z.string().min(1),
4384
+ model: z.string().min(1).max(128).regex(MODEL_RE),
4340
4385
  inTokens: z.number().int().nonnegative(),
4341
4386
  outTokens: z.number().int().nonnegative(),
4342
4387
  /** Anthropic cache reads / OpenAI `cached_input_tokens`. Billed cheap-or-free. */
@@ -4348,7 +4393,7 @@ var SessionRecord = z.object({
4348
4393
  costUsdCents: z.number().int().nonnegative(),
4349
4394
  startedAt: z.number().int().positive(),
4350
4395
  endedAt: z.number().int().positive(),
4351
- dedupeKey: z.string().min(1)
4396
+ dedupeKey: z.string().min(1).max(128).regex(DEDUPE_KEY_RE)
4352
4397
  });
4353
4398
 
4354
4399
  // ../contracts/src/user.ts
@@ -4358,6 +4403,27 @@ var ProfileAttributionEntry = z.object({
4358
4403
  costUsdCents: z.number().int().nonnegative(),
4359
4404
  sessions: z.number().int().nonnegative()
4360
4405
  });
4406
+ var GithubProject = z.object({
4407
+ name: z.string().min(1).max(100),
4408
+ fullName: z.string().min(3).max(201).regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/),
4409
+ url: z.string().url().startsWith("https://github.com/"),
4410
+ description: z.string().max(500).nullable()
4411
+ }).superRefine((project, context) => {
4412
+ const repositoryName = project.fullName.split("/")[1];
4413
+ const canonicalUrl = `https://github.com/${project.fullName}`;
4414
+ if (repositoryName?.toLowerCase() !== project.name.toLowerCase()) {
4415
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "GitHub project name is invalid" });
4416
+ }
4417
+ if (project.url.toLowerCase() !== canonicalUrl.toLowerCase()) {
4418
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "GitHub project URL is invalid" });
4419
+ }
4420
+ });
4421
+ var GithubProjects = z.array(GithubProject).max(3).refine(
4422
+ (projects) => new Set(projects.map((project) => project.fullName.toLowerCase())).size === projects.length,
4423
+ {
4424
+ message: "GitHub projects must be unique"
4425
+ }
4426
+ );
4361
4427
  var User = z.object({
4362
4428
  id: z.string(),
4363
4429
  handle: z.string(),
@@ -4377,13 +4443,20 @@ var User = z.object({
4377
4443
  * (other readers never see this field). `null` when GitHub didn't return a
4378
4444
  * verified email — the user sees a banner asking them to add one.
4379
4445
  */
4380
- email: z.string().email().nullable().optional()
4446
+ email: z.string().email().nullable().optional(),
4447
+ /** Full text is self-only; public profiles receive agentInstructionsPreview instead. */
4448
+ agentInstructions: z.string().max(2e4).nullable().optional(),
4449
+ githubProjects: GithubProjects.optional()
4381
4450
  });
4382
4451
  var PublicProfileSettings = z.object({
4383
4452
  publicProfile: z.boolean().optional(),
4384
- bio: z.string().max(200).nullable().optional()
4453
+ bio: z.string().max(200).nullable().optional(),
4454
+ agentInstructions: z.string().max(2e4).nullable().optional(),
4455
+ githubProjects: GithubProjects.optional()
4385
4456
  });
4386
4457
  var Profile = User.extend({
4458
+ /** The first ten lines of the owner's saved agent instructions. */
4459
+ agentInstructionsPreview: z.string().max(4e3).nullable().optional(),
4387
4460
  totals: z.object({
4388
4461
  today: z.object({ tokens: z.number().int(), costUsdCents: z.number().int() }),
4389
4462
  week: z.object({ tokens: z.number().int(), costUsdCents: z.number().int() }),
@@ -5013,13 +5086,43 @@ var CliVersionResponse = z.object({
5013
5086
  upgradeCommand: z.string().min(1)
5014
5087
  });
5015
5088
 
5089
+ // ../contracts/src/community.ts
5090
+ var PostKind = z.enum(["idea", "agents-md", "showcase", "question"]);
5091
+ var CreatePostRequest = z.object({
5092
+ kind: PostKind,
5093
+ title: z.string().trim().min(3).max(160),
5094
+ body: z.string().trim().min(1).max(3e4)
5095
+ });
5096
+ var CreateReplyRequest = z.object({ body: z.string().trim().min(1).max(1e4) });
5097
+ var CommunityPost = CreatePostRequest.extend({
5098
+ id: z.string(),
5099
+ handle: z.string(),
5100
+ createdAt: z.number(),
5101
+ replies: z.number()
5102
+ });
5103
+ var CommunityReply = z.object({
5104
+ id: z.string(),
5105
+ body: z.string(),
5106
+ handle: z.string(),
5107
+ createdAt: z.number()
5108
+ });
5109
+
5110
+ // ../contracts/src/comparison.ts
5111
+ var UsageMonth = z.string().regex(/^20\d{2}-(0[1-9]|1[0-2])$/);
5112
+ var SubscriptionSpendRequest = z.object({
5113
+ month: UsageMonth,
5114
+ source: z.enum(["claude-code", "codex", "cursor"]),
5115
+ label: z.string().trim().min(1).max(80),
5116
+ paidUsdCents: z.number().int().min(0).max(1e8)
5117
+ });
5118
+
5016
5119
  // src/lib/api.ts
5017
5120
  var DEFAULT_API_URL = "https://api.tokenrats.com";
5018
5121
  function isTransient(status) {
5019
5122
  return status >= 500 || status === 408 || status === 429;
5020
5123
  }
5021
5124
  function sleep(ms) {
5022
- return new Promise((resolve) => setTimeout(resolve, ms));
5125
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
5023
5126
  }
5024
5127
  var ApiError2 = class extends Error {
5025
5128
  constructor(status, body) {
@@ -5061,7 +5164,7 @@ var ApiClient = class {
5061
5164
  let lastErr;
5062
5165
  while (attempt <= maxRetries) {
5063
5166
  try {
5064
- const res = await fetch(url, init);
5167
+ const res = await fetch(url, { ...init, signal: AbortSignal.timeout(3e4) });
5065
5168
  if (res.ok || !isTransient(res.status)) return res;
5066
5169
  lastErr = new ApiError2(res.status, await res.text());
5067
5170
  } catch (err) {
@@ -5081,8 +5184,8 @@ var ApiClient = class {
5081
5184
  }
5082
5185
  return new ApiError2(res.status, body);
5083
5186
  }
5084
- async post(path5, body) {
5085
- const url = `${this.apiUrl}${path5}`;
5187
+ async post(path4, body) {
5188
+ const url = `${this.apiUrl}${path4}`;
5086
5189
  const res = await this.fetchWithRetry(url, {
5087
5190
  method: "POST",
5088
5191
  headers: this.headers(),
@@ -5091,8 +5194,8 @@ var ApiClient = class {
5091
5194
  if (!res.ok) throw await this.failedResponseToError(res);
5092
5195
  return res.json();
5093
5196
  }
5094
- async get(path5) {
5095
- const url = `${this.apiUrl}${path5}`;
5197
+ async get(path4) {
5198
+ const url = `${this.apiUrl}${path4}`;
5096
5199
  const res = await this.fetchWithRetry(url, {
5097
5200
  method: "GET",
5098
5201
  headers: this.headers()
@@ -5128,6 +5231,18 @@ var ApiClient = class {
5128
5231
  async uploadSessions(sessions) {
5129
5232
  return this.post(ENDPOINTS.sessions, { sessions });
5130
5233
  }
5234
+ /** GET /v1/u/:handle */
5235
+ async getProfile(handle) {
5236
+ return this.get(ENDPOINTS.profile(handle));
5237
+ }
5238
+ /** GET /v1/u/:handle/heatmap?range=30d — used to derive the current streak. */
5239
+ async getHeatmap(handle) {
5240
+ return this.get(ENDPOINTS.profileHeatmap(handle));
5241
+ }
5242
+ /** GET /v1/trending?range — global public leaderboard, used to derive rank. */
5243
+ async getTrending(range = "30d") {
5244
+ return this.get(`${ENDPOINTS.trending}?range=${range}`);
5245
+ }
5131
5246
  /** GET /v1/me/devices */
5132
5247
  async getDevices() {
5133
5248
  return this.get(ENDPOINTS.meDevices);
@@ -5138,8 +5253,56 @@ var ApiClient = class {
5138
5253
  }
5139
5254
  };
5140
5255
 
5256
+ // package.json
5257
+ var package_default = {
5258
+ name: "token-rats",
5259
+ version: "0.3.1",
5260
+ description: "Track Claude Code, Codex, and Cursor usage and compare AI subscriptions.",
5261
+ license: "MIT",
5262
+ type: "module",
5263
+ bin: {
5264
+ "token-rats": "./dist/index.js"
5265
+ },
5266
+ files: ["dist", "README.md", "LICENSE"],
5267
+ repository: {
5268
+ type: "git",
5269
+ url: "git+https://github.com/hsalberti/token-rats.git",
5270
+ directory: "packages/cli"
5271
+ },
5272
+ homepage: "https://tokenrats.com",
5273
+ keywords: ["claude", "claude-code", "cursor", "tokens", "leaderboard", "ai-usage"],
5274
+ publishConfig: {
5275
+ access: "public"
5276
+ },
5277
+ scripts: {
5278
+ build: "tsc --noEmit -p tsconfig.json && node build.mjs",
5279
+ typecheck: "tsc --noEmit",
5280
+ dev: "tsx src/index.ts",
5281
+ test: "vitest run",
5282
+ prepublishOnly: "node build.mjs"
5283
+ },
5284
+ optionalDependencies: {
5285
+ clipboardy: "^4.0.0",
5286
+ open: "^10.1.0"
5287
+ },
5288
+ devDependencies: {
5289
+ "@token-rats/contracts": "workspace:*",
5290
+ "@token-rats/parsers": "workspace:*",
5291
+ "@types/better-sqlite3": "^7.6.12",
5292
+ "@types/node": "22.10.2",
5293
+ "@types/sql.js": "^1.4.11",
5294
+ esbuild: "^0.24.2",
5295
+ tsx: "4.19.2",
5296
+ typescript: "5.7.2",
5297
+ vitest: "2.1.8"
5298
+ },
5299
+ dependencies: {
5300
+ "sql.js": "^1.14.1"
5301
+ }
5302
+ };
5303
+
5141
5304
  // src/lib/cli-version.ts
5142
- var CLI_VERSION = "0.2.0";
5305
+ var CLI_VERSION = package_default.version;
5143
5306
 
5144
5307
  // src/commands/login.ts
5145
5308
  async function openBrowser(url) {
@@ -5235,7 +5398,7 @@ async function loginCommand(opts) {
5235
5398
  return;
5236
5399
  }
5237
5400
  info("Installing background watcher so usage uploads automatically\u2026");
5238
- await installDaemonCommand();
5401
+ await installDaemonCommand(opts.apiUrl);
5239
5402
  }
5240
5403
 
5241
5404
  // src/commands/logout.ts
@@ -5248,8 +5411,10 @@ function logoutCommand() {
5248
5411
  info("Logged out. Your local token has been removed.");
5249
5412
  }
5250
5413
 
5251
- // src/commands/sync.ts
5414
+ // src/lib/collect.ts
5415
+ import { createHash } from "node:crypto";
5252
5416
  import * as fs4 from "node:fs";
5417
+ import { createInterface } from "node:readline";
5253
5418
 
5254
5419
  // ../parsers/src/hash.ts
5255
5420
  var FNV_PRIME = 16777619;
@@ -5273,29 +5438,24 @@ function computeDedupeKey(source, model, startedAt, inTokens, outTokens) {
5273
5438
  }
5274
5439
 
5275
5440
  // ../parsers/src/claude-code.ts
5276
- function parseClaudeCode(input) {
5277
- let text;
5278
- if (typeof input === "string") {
5279
- text = input;
5280
- } else {
5281
- text = new TextDecoder().decode(input instanceof ArrayBuffer ? new Uint8Array(input) : input);
5282
- }
5441
+ function createClaudeCodeParser() {
5283
5442
  const sessions = /* @__PURE__ */ new Map();
5284
- for (const rawLine of text.split("\n")) {
5443
+ const messages = /* @__PURE__ */ new Map();
5444
+ function push(rawLine) {
5285
5445
  const line = rawLine.trim();
5286
- if (line.length === 0) continue;
5446
+ if (line.length === 0) return;
5287
5447
  let event;
5288
5448
  try {
5289
5449
  event = JSON.parse(line);
5290
5450
  } catch {
5291
- continue;
5451
+ return;
5292
5452
  }
5293
- if (typeof event !== "object" || event === null) continue;
5453
+ if (typeof event !== "object" || event === null) return;
5294
5454
  const ev = event;
5295
5455
  const sidCamel = ev.sessionId;
5296
5456
  const sidSnake = ev.session_id;
5297
5457
  const sessionId = typeof sidCamel === "string" ? sidCamel : typeof sidSnake === "string" ? sidSnake : null;
5298
- if (!sessionId) continue;
5458
+ if (!sessionId) return;
5299
5459
  const rawTs = ev.timestamp;
5300
5460
  let timestamp = 0;
5301
5461
  if (typeof rawTs === "number" && Number.isFinite(rawTs)) {
@@ -5314,7 +5474,8 @@ function parseClaudeCode(input) {
5314
5474
  outTokens: 0,
5315
5475
  cacheReadTokens: 0,
5316
5476
  cacheWriteTokens: 0,
5317
- model: ""
5477
+ model: "",
5478
+ modelAt: 0
5318
5479
  };
5319
5480
  sessions.set(sessionId, acc);
5320
5481
  }
@@ -5322,54 +5483,73 @@ function parseClaudeCode(input) {
5322
5483
  if (acc.startedAt === 0 || timestamp < acc.startedAt) acc.startedAt = timestamp;
5323
5484
  if (timestamp > acc.endedAt) acc.endedAt = timestamp;
5324
5485
  }
5325
- if (ev.type !== "assistant") continue;
5486
+ if (ev.type !== "assistant") return;
5326
5487
  const message = ev.message;
5327
- if (typeof message !== "object" || message === null) continue;
5488
+ if (typeof message !== "object" || message === null) return;
5328
5489
  const msg = message;
5329
- if (typeof msg.model === "string" && msg.model.length > 0) {
5490
+ if (typeof msg.model === "string" && msg.model.length > 0 && timestamp >= acc.modelAt) {
5330
5491
  acc.model = msg.model;
5492
+ acc.modelAt = timestamp;
5331
5493
  }
5332
5494
  const usage = msg.usage;
5333
5495
  if (typeof usage === "object" && usage !== null) {
5334
5496
  const u = usage;
5335
- acc.inTokens += toNonNegInt(u.input_tokens);
5336
- acc.outTokens += toNonNegInt(u.output_tokens);
5337
- acc.cacheReadTokens += toNonNegInt(u.cache_read_input_tokens);
5338
- acc.cacheWriteTokens += toNonNegInt(u.cache_creation_input_tokens);
5497
+ const counts = {
5498
+ input: toNonNegInt(u.input_tokens),
5499
+ output: toNonNegInt(u.output_tokens),
5500
+ read: toNonNegInt(u.cache_read_input_tokens),
5501
+ write: toNonNegInt(u.cache_creation_input_tokens)
5502
+ };
5503
+ const key = typeof msg.id === "string" ? `${sessionId}:${msg.id}` : null;
5504
+ const old = key ? messages.get(key) : void 0;
5505
+ const next = {
5506
+ input: Math.max(old?.input ?? 0, counts.input),
5507
+ output: Math.max(old?.output ?? 0, counts.output),
5508
+ read: Math.max(old?.read ?? 0, counts.read),
5509
+ write: Math.max(old?.write ?? 0, counts.write)
5510
+ };
5511
+ acc.inTokens += next.input - (old?.input ?? 0);
5512
+ acc.outTokens += next.output - (old?.output ?? 0);
5513
+ acc.cacheReadTokens += next.read - (old?.read ?? 0);
5514
+ acc.cacheWriteTokens += next.write - (old?.write ?? 0);
5515
+ if (key) messages.set(key, next);
5516
+ }
5517
+ }
5518
+ function finish() {
5519
+ const results = [];
5520
+ for (const acc of sessions.values()) {
5521
+ const startedAt = acc.startedAt > 0 ? acc.startedAt : acc.endedAt;
5522
+ const endedAt = acc.endedAt > 0 ? acc.endedAt : acc.startedAt;
5523
+ if (startedAt <= 0 || endedAt <= 0) continue;
5524
+ const model = acc.model.length > 0 ? acc.model : "unknown";
5525
+ const costUsdCents = 0;
5526
+ const dedupeKey = computeDedupeKey(
5527
+ "claude-code",
5528
+ model,
5529
+ startedAt,
5530
+ acc.inTokens,
5531
+ acc.outTokens
5532
+ );
5533
+ results.push({
5534
+ id: `claude-code:${acc.sessionId}`,
5535
+ source: "claude-code",
5536
+ provider: "anthropic",
5537
+ client: "claude-code",
5538
+ channel: "cli",
5539
+ model,
5540
+ inTokens: acc.inTokens,
5541
+ outTokens: acc.outTokens,
5542
+ cacheReadTokens: acc.cacheReadTokens,
5543
+ cacheWriteTokens: acc.cacheWriteTokens,
5544
+ costUsdCents,
5545
+ startedAt,
5546
+ endedAt,
5547
+ dedupeKey
5548
+ });
5339
5549
  }
5550
+ return results;
5340
5551
  }
5341
- const results = [];
5342
- for (const acc of sessions.values()) {
5343
- const startedAt = acc.startedAt > 0 ? acc.startedAt : acc.endedAt;
5344
- const endedAt = acc.endedAt > 0 ? acc.endedAt : acc.startedAt;
5345
- if (startedAt <= 0 || endedAt <= 0) continue;
5346
- const model = acc.model.length > 0 ? acc.model : "unknown";
5347
- const costUsdCents = 0;
5348
- const dedupeKey = computeDedupeKey(
5349
- "claude-code",
5350
- model,
5351
- startedAt,
5352
- acc.inTokens,
5353
- acc.outTokens
5354
- );
5355
- results.push({
5356
- id: `claude-code:${acc.sessionId}`,
5357
- source: "claude-code",
5358
- provider: "anthropic",
5359
- client: "claude-code",
5360
- channel: "cli",
5361
- model,
5362
- inTokens: acc.inTokens,
5363
- outTokens: acc.outTokens,
5364
- cacheReadTokens: acc.cacheReadTokens,
5365
- cacheWriteTokens: acc.cacheWriteTokens,
5366
- costUsdCents,
5367
- startedAt,
5368
- endedAt,
5369
- dedupeKey
5370
- });
5371
- }
5372
- return results;
5552
+ return { push, finish };
5373
5553
  }
5374
5554
  function toNonNegInt(v) {
5375
5555
  if (typeof v !== "number" || !Number.isFinite(v)) return 0;
@@ -5377,96 +5557,107 @@ function toNonNegInt(v) {
5377
5557
  }
5378
5558
 
5379
5559
  // ../parsers/src/codex.ts
5380
- function parseCodex(input) {
5381
- let text;
5382
- if (typeof input === "string") {
5383
- text = input;
5384
- } else {
5385
- text = new TextDecoder().decode(input instanceof ArrayBuffer ? new Uint8Array(input) : input);
5386
- }
5560
+ function createCodexParser() {
5387
5561
  const sessions = /* @__PURE__ */ new Map();
5388
5562
  let currentSessionId = null;
5389
- for (const rawLine of text.split("\n")) {
5563
+ function push(rawLine) {
5390
5564
  const line = rawLine.trim();
5391
- if (line.length === 0) continue;
5565
+ if (line.length === 0) return;
5392
5566
  let event;
5393
5567
  try {
5394
5568
  event = JSON.parse(line);
5395
5569
  } catch {
5396
- continue;
5570
+ return;
5397
5571
  }
5398
- if (typeof event !== "object" || event === null) continue;
5572
+ if (typeof event !== "object" || event === null) return;
5399
5573
  const ev = event;
5400
5574
  const ts = parseTimestamp(ev.timestamp);
5401
5575
  const type = typeof ev.type === "string" ? ev.type : null;
5402
5576
  const payload = typeof ev.payload === "object" && ev.payload !== null ? ev.payload : null;
5403
5577
  if (type === "session_meta" && payload) {
5404
5578
  const id = typeof payload.id === "string" ? payload.id : null;
5405
- if (!id) continue;
5579
+ if (!id) {
5580
+ currentSessionId = null;
5581
+ return;
5582
+ }
5406
5583
  currentSessionId = id;
5407
5584
  const metaTs = parseTimestamp(payload.timestamp) || ts;
5408
5585
  const acc2 = upsert(sessions, id);
5409
5586
  const metaSource = typeof payload.source === "string" ? payload.source : null;
5410
- acc2.channel = metaSource === "cli" ? "cli" : metaSource === "api" ? "api" : "unknown";
5587
+ acc2.channel = metaSource === "api" ? "api" : metaSource ? "cli" : "unknown";
5411
5588
  acc2.client = acc2.channel === "cli" ? "codex-cli" : "codex";
5412
5589
  if (metaTs > 0 && (acc2.startedAt === 0 || metaTs < acc2.startedAt)) {
5413
5590
  acc2.startedAt = metaTs;
5414
5591
  }
5415
5592
  if (metaTs > acc2.endedAt) acc2.endedAt = metaTs;
5416
- continue;
5593
+ return;
5417
5594
  }
5418
- if (!currentSessionId) continue;
5595
+ if (!currentSessionId) return;
5419
5596
  const acc = upsert(sessions, currentSessionId);
5420
5597
  if (ts > 0) {
5421
5598
  if (acc.startedAt === 0) acc.startedAt = ts;
5422
5599
  if (ts > acc.endedAt) acc.endedAt = ts;
5423
5600
  }
5424
5601
  if (type === "turn_context" && payload && typeof payload.model === "string") {
5425
- acc.model = payload.model;
5426
- continue;
5602
+ if (ts >= acc.modelAt) {
5603
+ acc.model = payload.model;
5604
+ acc.modelAt = ts;
5605
+ }
5606
+ return;
5427
5607
  }
5428
5608
  if (type === "event_msg" && payload && payload.type === "token_count") {
5429
5609
  const info2 = payload.info;
5430
- if (typeof info2 !== "object" || info2 === null) continue;
5610
+ if (typeof info2 !== "object" || info2 === null) return;
5431
5611
  const total = info2.total_token_usage;
5432
- if (typeof total !== "object" || total === null) continue;
5612
+ if (typeof total !== "object" || total === null) return;
5433
5613
  const t = total;
5434
5614
  const inputTotal = toNonNegInt2(t.input_tokens);
5435
5615
  const cachedInput = toNonNegInt2(t.cached_input_tokens);
5436
5616
  const output = toNonNegInt2(t.output_tokens);
5437
5617
  const reasoning = toNonNegInt2(t.reasoning_output_tokens);
5618
+ if (ts < acc.usageAt) return;
5619
+ acc.usageAt = ts;
5438
5620
  acc.inTokens = Math.max(0, inputTotal - cachedInput);
5439
- acc.outTokens = output + reasoning;
5621
+ acc.outTokens = output;
5440
5622
  acc.cacheReadTokens = cachedInput;
5441
5623
  acc.reasoningTokens = reasoning;
5442
5624
  }
5443
5625
  }
5444
- const results = [];
5445
- for (const acc of sessions.values()) {
5446
- const startedAt = acc.startedAt > 0 ? acc.startedAt : acc.endedAt;
5447
- const endedAt = acc.endedAt > 0 ? acc.endedAt : acc.startedAt;
5448
- if (startedAt <= 0 || endedAt <= 0) continue;
5449
- const model = acc.model.length > 0 ? acc.model : "unknown";
5450
- const costUsdCents = 0;
5451
- const dedupeKey = computeDedupeKey("codex", model, startedAt, acc.inTokens, acc.outTokens);
5452
- results.push({
5453
- id: `codex:${acc.sessionId}`,
5454
- source: "codex",
5455
- provider: "openai",
5456
- client: acc.client || "codex-cli",
5457
- channel: acc.channel,
5458
- model,
5459
- inTokens: acc.inTokens,
5460
- outTokens: acc.outTokens,
5461
- cacheReadTokens: acc.cacheReadTokens,
5462
- reasoningTokens: acc.reasoningTokens,
5463
- costUsdCents,
5464
- startedAt,
5465
- endedAt,
5466
- dedupeKey
5467
- });
5626
+ function finish() {
5627
+ const results = [];
5628
+ for (const acc of sessions.values()) {
5629
+ const startedAt = acc.startedAt > 0 ? acc.startedAt : acc.endedAt;
5630
+ const endedAt = acc.endedAt > 0 ? acc.endedAt : acc.startedAt;
5631
+ if (startedAt <= 0 || endedAt <= 0) continue;
5632
+ const model = acc.model.length > 0 ? acc.model : "unknown";
5633
+ const costUsdCents = 0;
5634
+ const dedupeKey = computeDedupeKey("codex", model, startedAt, acc.inTokens, acc.outTokens);
5635
+ results.push({
5636
+ id: `codex:${acc.sessionId}`,
5637
+ source: "codex",
5638
+ provider: "openai",
5639
+ client: acc.client || "codex-cli",
5640
+ channel: acc.channel,
5641
+ model,
5642
+ inTokens: acc.inTokens,
5643
+ outTokens: acc.outTokens,
5644
+ cacheReadTokens: acc.cacheReadTokens,
5645
+ reasoningTokens: acc.reasoningTokens,
5646
+ costUsdCents,
5647
+ startedAt,
5648
+ endedAt,
5649
+ dedupeKey
5650
+ });
5651
+ }
5652
+ return results;
5468
5653
  }
5469
- return results;
5654
+ return {
5655
+ push,
5656
+ finish,
5657
+ startFile() {
5658
+ currentSessionId = null;
5659
+ }
5660
+ };
5470
5661
  }
5471
5662
  function upsert(map, sessionId) {
5472
5663
  let acc = map.get(sessionId);
@@ -5480,6 +5671,8 @@ function upsert(map, sessionId) {
5480
5671
  cacheReadTokens: 0,
5481
5672
  reasoningTokens: 0,
5482
5673
  model: "",
5674
+ usageAt: 0,
5675
+ modelAt: 0,
5483
5676
  client: "codex-cli",
5484
5677
  channel: "unknown"
5485
5678
  };
@@ -5680,7 +5873,7 @@ async function extractCursorGenerations() {
5680
5873
  dbCount: 0
5681
5874
  };
5682
5875
  }
5683
- const seen2 = /* @__PURE__ */ new Set();
5876
+ const seen = /* @__PURE__ */ new Set();
5684
5877
  const rows = [];
5685
5878
  let openFailures = 0;
5686
5879
  for (const p of dbPaths) {
@@ -5692,8 +5885,8 @@ async function extractCursorGenerations() {
5692
5885
  continue;
5693
5886
  }
5694
5887
  for (const r of perDb) {
5695
- if (seen2.has(r.id)) continue;
5696
- seen2.add(r.id);
5888
+ if (seen.has(r.id)) continue;
5889
+ seen.add(r.id);
5697
5890
  rows.push(r);
5698
5891
  }
5699
5892
  }
@@ -5727,6 +5920,7 @@ function findJsonlFiles(dir) {
5727
5920
  }
5728
5921
  function claudeCodeProjectsDir() {
5729
5922
  const home = os3.homedir();
5923
+ if (process.env.CLAUDE_CONFIG_DIR) return path3.join(process.env.CLAUDE_CONFIG_DIR, "projects");
5730
5924
  if (process.platform === "win32") {
5731
5925
  const profile = process.env.USERPROFILE ?? home;
5732
5926
  return path3.join(profile, ".claude", "projects");
@@ -5740,13 +5934,19 @@ function discoverClaudeCodeFiles() {
5740
5934
  function codexSessionsDirs() {
5741
5935
  const home = os3.homedir();
5742
5936
  const candidates = [];
5937
+ if (process.env.CODEX_HOME) {
5938
+ candidates.push(path3.join(process.env.CODEX_HOME, "sessions"));
5939
+ candidates.push(path3.join(process.env.CODEX_HOME, "archived_sessions"));
5940
+ }
5743
5941
  if (process.platform === "win32") {
5744
5942
  const profile = process.env.USERPROFILE ?? home;
5745
5943
  candidates.push(path3.join(profile, ".codex", "sessions"));
5944
+ candidates.push(path3.join(profile, ".codex", "archived_sessions"));
5746
5945
  const appData = process.env.APPDATA ?? path3.join(profile, "AppData", "Roaming");
5747
5946
  candidates.push(path3.join(appData, "Codex", "sessions"));
5748
5947
  } else {
5749
5948
  candidates.push(path3.join(home, ".codex", "sessions"));
5949
+ candidates.push(path3.join(home, ".codex", "archived_sessions"));
5750
5950
  const snapRoot = path3.join(home, "snap", "codex");
5751
5951
  if (fs3.existsSync(snapRoot)) {
5752
5952
  for (const entry of fs3.readdirSync(snapRoot, { withFileTypes: true })) {
@@ -5756,13 +5956,13 @@ function codexSessionsDirs() {
5756
5956
  }
5757
5957
  }
5758
5958
  }
5759
- const seen2 = /* @__PURE__ */ new Set();
5959
+ const seen = /* @__PURE__ */ new Set();
5760
5960
  const dirs = [];
5761
5961
  for (const c2 of candidates) {
5762
5962
  try {
5763
5963
  const real = fs3.existsSync(c2) ? fs3.realpathSync(c2) : null;
5764
- if (real && !seen2.has(real)) {
5765
- seen2.add(real);
5964
+ if (real && !seen.has(real)) {
5965
+ seen.add(real);
5766
5966
  dirs.push(c2);
5767
5967
  }
5768
5968
  } catch {
@@ -5778,8 +5978,137 @@ function discoverCodexFiles() {
5778
5978
  return out;
5779
5979
  }
5780
5980
 
5981
+ // src/lib/collect.ts
5982
+ async function parseSessionFiles(files, parser) {
5983
+ for (const file of files) {
5984
+ parser.startFile?.();
5985
+ const input = fs4.createReadStream(file, { encoding: "utf8" });
5986
+ const lines = createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY });
5987
+ try {
5988
+ for await (const line of lines) parser.push(line);
5989
+ } finally {
5990
+ lines.close();
5991
+ input.destroy();
5992
+ }
5993
+ }
5994
+ return parser.finish();
5995
+ }
5996
+ async function collectSessions() {
5997
+ const claude = await parseSessionFiles(discoverClaudeCodeFiles(), createClaudeCodeParser());
5998
+ const codex = await parseSessionFiles(discoverCodexFiles(), createCodexParser());
5999
+ const { rows } = await extractCursorGenerations();
6000
+ const cursor = parseCursor(JSON.stringify(rows));
6001
+ return [...claude, ...codex, ...cursor].map((record) => ({
6002
+ ...record,
6003
+ accountingVersion: 2,
6004
+ dedupeKey: createHash("sha256").update(
6005
+ JSON.stringify([
6006
+ record.id,
6007
+ record.source,
6008
+ record.model,
6009
+ record.startedAt,
6010
+ record.inTokens,
6011
+ record.outTokens,
6012
+ record.cacheReadTokens ?? 0,
6013
+ record.cacheWriteTokens ?? 0,
6014
+ record.reasoningTokens ?? 0
6015
+ ])
6016
+ ).digest("hex")
6017
+ }));
6018
+ }
6019
+ var SyncQueue = class {
6020
+ acknowledged = /* @__PURE__ */ new Map();
6021
+ async flush(records, upload) {
6022
+ const fresh = records.filter((r) => this.acknowledged.get(r.id) !== JSON.stringify(r));
6023
+ for (let i = 0; i < fresh.length; i += 90) {
6024
+ const batch = fresh.slice(i, i + 90);
6025
+ await upload(batch);
6026
+ for (const record of batch) this.acknowledged.set(record.id, JSON.stringify(record));
6027
+ }
6028
+ return fresh.length;
6029
+ }
6030
+ };
6031
+ function createCollector() {
6032
+ let signature = "";
6033
+ let records = [];
6034
+ return async () => {
6035
+ const files = [
6036
+ ...discoverClaudeCodeFiles(),
6037
+ ...discoverCodexFiles(),
6038
+ ...discoverWorkspaceDbs().flatMap((file) => [file, `${file}-wal`])
6039
+ ].sort();
6040
+ const next = JSON.stringify(
6041
+ files.map((file) => {
6042
+ try {
6043
+ const stat = fs4.statSync(file);
6044
+ return [file, stat.size, stat.mtimeMs];
6045
+ } catch {
6046
+ return [file, null];
6047
+ }
6048
+ })
6049
+ );
6050
+ if (next !== signature) {
6051
+ records = await collectSessions();
6052
+ signature = next;
6053
+ }
6054
+ return records;
6055
+ };
6056
+ }
6057
+
5781
6058
  // src/commands/sync.ts
5782
- var BATCH_SIZE = 500;
6059
+ var BATCH_SIZE = 90;
6060
+ var WEB_ORIGIN = "https://tokenrats.com";
6061
+ function currentStreakFromDays(days, todayUtc) {
6062
+ const active = days.slice().sort();
6063
+ if (active.length === 0) return 0;
6064
+ const last = active[active.length - 1];
6065
+ const yesterday = (() => {
6066
+ const d = /* @__PURE__ */ new Date(`${todayUtc}T00:00:00Z`);
6067
+ d.setUTCDate(d.getUTCDate() - 1);
6068
+ return d.toISOString().slice(0, 10);
6069
+ })();
6070
+ if (last !== todayUtc && last !== yesterday) return 0;
6071
+ let streak = 1;
6072
+ for (let i = active.length - 1; i >= 1; i--) {
6073
+ const prev = (/* @__PURE__ */ new Date(`${active[i - 1]}T00:00:00Z`)).getTime();
6074
+ const curr = (/* @__PURE__ */ new Date(`${active[i]}T00:00:00Z`)).getTime();
6075
+ if (Math.round((curr - prev) / 864e5) === 1) streak++;
6076
+ else break;
6077
+ }
6078
+ return streak;
6079
+ }
6080
+ async function printSyncHero(client) {
6081
+ let handle;
6082
+ try {
6083
+ const me = await client.getMe();
6084
+ handle = me.user.handle;
6085
+ } catch {
6086
+ return;
6087
+ }
6088
+ const todayUtc = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
6089
+ const [trending, heatmap] = await Promise.all([
6090
+ client.getTrending("30d").catch(() => null),
6091
+ client.getHeatmap(handle).catch(() => null)
6092
+ ]);
6093
+ const rank = trending?.rows.find((r) => r.handle === handle)?.rank ?? null;
6094
+ const streak = heatmap === null ? 0 : currentStreakFromDays(
6095
+ heatmap.heatmap.days.filter((d) => d.sessions > 0).map((d) => d.day),
6096
+ todayUtc
6097
+ );
6098
+ const profileUrl = `${WEB_ORIGIN}/u/${handle}`;
6099
+ console.log("");
6100
+ bold("\u{1F400} Your Token Rats standing");
6101
+ if (rank !== null) info(`Global rank: #${rank}`);
6102
+ if (streak > 0) info(`Current streak: ${streak} day${streak === 1 ? "" : "s"} \u{1F525}`);
6103
+ info(`Profile: ${profileUrl}`);
6104
+ const rankPart = rank !== null ? `ranked #${rank} globally` : "on the board";
6105
+ const streakPart = streak > 0 ? ` on a ${streak}-day streak` : "";
6106
+ const tweet = `I'm ${rankPart}${streakPart} on @tokenrats \u2014 tracking my AI coding tokens. \u{1F400}`;
6107
+ const intent = `https://x.com/intent/tweet?text=${encodeURIComponent(tweet)}&url=${encodeURIComponent(profileUrl)}`;
6108
+ console.log("");
6109
+ dim("Brag about it \u2014 Post to X:");
6110
+ console.log(` ${process.stdout.isTTY ? `${c.cyan}${intent}${c.reset}` : intent}`);
6111
+ }
5783
6112
  function chunk(arr, size) {
5784
6113
  const chunks = [];
5785
6114
  for (let i = 0; i < arr.length; i += size) {
@@ -5787,34 +6116,6 @@ function chunk(arr, size) {
5787
6116
  }
5788
6117
  return chunks;
5789
6118
  }
5790
- function mergeBySessionId(records) {
5791
- const byId = /* @__PURE__ */ new Map();
5792
- for (const r of records) {
5793
- const existing = byId.get(r.id);
5794
- if (!existing) {
5795
- byId.set(r.id, { ...r });
5796
- continue;
5797
- }
5798
- existing.inTokens += r.inTokens;
5799
- existing.outTokens += r.outTokens;
5800
- if (r.startedAt < existing.startedAt) existing.startedAt = r.startedAt;
5801
- if (r.endedAt > existing.endedAt) {
5802
- existing.endedAt = r.endedAt;
5803
- existing.model = r.model;
5804
- }
5805
- }
5806
- for (const rec of byId.values()) {
5807
- rec.costUsdCents = 0;
5808
- rec.dedupeKey = computeDedupeKey(
5809
- rec.source,
5810
- rec.model,
5811
- rec.startedAt,
5812
- rec.inTokens,
5813
- rec.outTokens
5814
- );
5815
- }
5816
- return Array.from(byId.values());
5817
- }
5818
6119
  async function syncCommand(opts) {
5819
6120
  const token = loadToken();
5820
6121
  if (!token && !opts.dryRun) {
@@ -5827,92 +6128,8 @@ async function syncCommand(opts) {
5827
6128
  deviceId: ensureDeviceId(),
5828
6129
  cliVersion: CLI_VERSION
5829
6130
  });
5830
- const claudeFiles = discoverClaudeCodeFiles();
5831
- if (opts.verbose) {
5832
- info(`Found ${claudeFiles.length} Claude Code file(s) in ~/.claude/projects/`);
5833
- for (const f of claudeFiles) dim(` ${f}`);
5834
- }
5835
- const claudeSessions = [];
5836
- for (const file of claudeFiles) {
5837
- let text;
5838
- try {
5839
- text = fs4.readFileSync(file, "utf8");
5840
- } catch {
5841
- if (opts.verbose) warn(`Could not read ${file} \u2014 skipping`);
5842
- continue;
5843
- }
5844
- try {
5845
- const records = parseClaudeCode(text);
5846
- claudeSessions.push(...records);
5847
- if (opts.verbose) dim(` ${file}: ${records.length} session(s)`);
5848
- } catch {
5849
- if (opts.verbose) warn(`Failed to parse ${file} \u2014 skipping`);
5850
- }
5851
- }
5852
- const codexFiles = discoverCodexFiles();
5853
- if (opts.verbose) {
5854
- info(`Found ${codexFiles.length} Codex rollout file(s)`);
5855
- for (const f of codexFiles) dim(` ${f}`);
5856
- }
5857
- const codexSessions = [];
5858
- for (const file of codexFiles) {
5859
- let text;
5860
- try {
5861
- text = fs4.readFileSync(file, "utf8");
5862
- } catch {
5863
- if (opts.verbose) warn(`Could not read ${file} \u2014 skipping`);
5864
- continue;
5865
- }
5866
- try {
5867
- const records = parseCodex(text);
5868
- codexSessions.push(...records);
5869
- if (opts.verbose) dim(` ${file}: ${records.length} session(s)`);
5870
- } catch {
5871
- if (opts.verbose) warn(`Failed to parse ${file} \u2014 skipping`);
5872
- }
5873
- }
5874
- const cursorSessions = [];
5875
- const { rows, skipped, dbCount } = await extractCursorGenerations();
5876
- if (skipped) {
5877
- warn(skipped);
5878
- } else if (dbCount === 0) {
5879
- if (opts.verbose) info("No Cursor workspace storage found \u2014 skipping Cursor source");
5880
- } else if (rows.length > 0) {
5881
- if (opts.verbose) info(`Scanned ${dbCount} Cursor workspace DB(s)`);
5882
- try {
5883
- const records = parseCursor(JSON.stringify(rows));
5884
- cursorSessions.push(...records);
5885
- if (opts.verbose) {
5886
- dim(
5887
- ` Cursor: ${rows.length} generation event(s) \u2192 ${records.length} session(s) (tokens estimated, see help)`
5888
- );
5889
- }
5890
- } catch {
5891
- if (opts.verbose) warn("Failed to parse Cursor rows \u2014 skipping");
5892
- }
5893
- } else if (opts.verbose) {
5894
- dim(` Scanned ${dbCount} Cursor workspace DB(s): no AI generations found`);
5895
- }
5896
- const mergedClaude = mergeBySessionId(claudeSessions);
5897
- if (opts.verbose && mergedClaude.length !== claudeSessions.length) {
5898
- dim(
5899
- ` Merged ${claudeSessions.length} Claude Code records into ${mergedClaude.length} sessions (subagent files folded into parents)`
5900
- );
5901
- }
5902
- const mergedCodex = mergeBySessionId(codexSessions);
5903
- const seen2 = /* @__PURE__ */ new Set();
5904
- const allSessions = [];
5905
- for (const s of [...mergedClaude, ...mergedCodex, ...cursorSessions]) {
5906
- if (!seen2.has(s.dedupeKey)) {
5907
- seen2.add(s.dedupeKey);
5908
- allSessions.push(s);
5909
- }
5910
- }
5911
- const sources = [];
5912
- if (claudeSessions.length > 0) sources.push("Claude Code");
5913
- if (codexSessions.length > 0) sources.push("Codex");
5914
- if (cursorSessions.length > 0) sources.push("Cursor");
5915
- const sourceStr = sources.length > 0 ? sources.join(" + ") : "no sources";
6131
+ const allSessions = await collectSessions();
6132
+ const sourceStr = [...new Set(allSessions.map((record) => record.source))].join(" + ") || "no sources";
5916
6133
  if (allSessions.length === 0) {
5917
6134
  info(`No sessions found from ${sourceStr}.`);
5918
6135
  return;
@@ -5966,137 +6183,25 @@ async function syncCommand(opts) {
5966
6183
  success(
5967
6184
  `Synced ${allSessions.length} sessions (${totalAccepted} new, ${totalDuplicates} already on server) from ${sourceStr}`
5968
6185
  );
5969
- }
5970
-
5971
- // src/commands/watch.ts
5972
- import * as fs5 from "node:fs";
5973
- import * as os4 from "node:os";
5974
- import * as path4 from "node:path";
5975
- var HEARTBEAT_MS = 6e4;
5976
- var seen = /* @__PURE__ */ new Set();
5977
- function stateFilePath() {
5978
- const xdgConfig = process.env.XDG_CONFIG_HOME ?? path4.join(os4.homedir(), ".config");
5979
- return path4.join(xdgConfig, "token-rats", "watch-state.json");
5980
- }
5981
- function loadWatchState() {
5982
- try {
5983
- const raw = fs5.readFileSync(stateFilePath(), "utf8");
5984
- const parsed = JSON.parse(raw);
5985
- return typeof parsed === "object" && parsed !== null ? parsed : {};
5986
- } catch {
5987
- return {};
5988
- }
5989
- }
5990
- function saveWatchState(state) {
5991
- try {
5992
- const file = stateFilePath();
5993
- fs5.mkdirSync(path4.dirname(file), { recursive: true });
5994
- fs5.writeFileSync(file, JSON.stringify(state), { mode: 384 });
5995
- } catch (err) {
5996
- if (err instanceof Error) {
5997
- console.warn(`watch-state write failed: ${err.message}`);
5998
- }
5999
- }
6000
- }
6001
- function statFile(p) {
6002
6186
  try {
6003
- const s = fs5.statSync(p);
6004
- return { size: s.size, mtimeMs: s.mtimeMs };
6187
+ await printSyncHero(client);
6005
6188
  } catch {
6006
- return null;
6007
- }
6008
- }
6009
- function parseAndFilter(filePath, verbose) {
6010
- let text;
6011
- try {
6012
- text = fs5.readFileSync(filePath, "utf8");
6013
- } catch {
6014
- if (verbose) warn(`Could not read ${filePath} \u2014 skipping`);
6015
- return [];
6016
- }
6017
- let records;
6018
- try {
6019
- records = parseClaudeCode(text);
6020
- } catch {
6021
- if (verbose) warn(`Failed to parse ${filePath} \u2014 skipping`);
6022
- return [];
6023
- }
6024
- const fresh = [];
6025
- for (const r of records) {
6026
- if (!seen.has(r.dedupeKey)) {
6027
- seen.add(r.dedupeKey);
6028
- fresh.push(r);
6029
- }
6030
- }
6031
- if (verbose && fresh.length > 0) {
6032
- dim(` ${filePath}: ${fresh.length} new session(s)`);
6033
- }
6034
- return fresh;
6035
- }
6036
- async function upload(client, records, verbose) {
6037
- if (records.length === 0) return;
6038
- try {
6039
- const res = await client.uploadSessions(records);
6040
- if (verbose) {
6041
- dim(` Uploaded ${res.accepted} new, ${res.duplicates} duplicate(s)`);
6042
- } else {
6043
- success(`Uploaded ${res.accepted} session(s)`);
6044
- }
6045
- } catch (err) {
6046
- if (err instanceof DeviceRevokedError) {
6047
- markDisconnected();
6048
- deleteToken();
6049
- error(
6050
- "This device was disconnected from the Token Rats web UI. Daemon will exit; re-run `token-rats login` to reconnect."
6051
- );
6052
- process.exit(0);
6053
- }
6054
- if (err instanceof ApiError2 && err.status === 401) {
6055
- error("Session expired. Run `token-rats login` to re-authenticate.");
6056
- process.exit(1);
6057
- }
6058
- warn(`Upload failed: ${err instanceof Error ? err.message : String(err)}`);
6059
6189
  }
6060
6190
  }
6061
- function makeDebounced(fn, ms) {
6062
- const timers = /* @__PURE__ */ new Map();
6063
- return (path5) => {
6064
- const existing = timers.get(path5);
6065
- if (existing) clearTimeout(existing);
6066
- timers.set(
6067
- path5,
6068
- setTimeout(() => {
6069
- timers.delete(path5);
6070
- fn(path5);
6071
- }, ms)
6072
- );
6073
- };
6074
- }
6075
- function findJsonlFiles2(dir) {
6076
- const results = [];
6077
- if (!fs5.existsSync(dir)) return results;
6078
- const entries = fs5.readdirSync(dir, { withFileTypes: true });
6079
- for (const entry of entries) {
6080
- const full = `${dir}/${entry.name}`;
6081
- if (entry.isDirectory()) {
6082
- results.push(...findJsonlFiles2(full));
6083
- } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
6084
- results.push(full);
6085
- }
6086
- }
6087
- return results;
6088
- }
6191
+
6192
+ // src/commands/watch.ts
6089
6193
  async function watchCommand(opts) {
6090
- if (isDisconnected()) {
6091
- error(
6092
- "This device was disconnected from the Token Rats web UI. Run `token-rats login` to reconnect."
6093
- );
6094
- process.exit(0);
6095
- }
6096
6194
  const token = loadToken();
6097
- if (!token) {
6098
- error("Not logged in. Run `token-rats login` first.");
6099
- process.exit(1);
6195
+ if (!token || isDisconnected()) {
6196
+ error("Run `token-rats login` before you start the tracker.");
6197
+ process.exitCode = 1;
6198
+ return;
6199
+ }
6200
+ const interval = opts.interval ?? 3e4;
6201
+ if (!Number.isFinite(interval) || interval < 1e3) {
6202
+ error("The interval must be at least 1000 milliseconds.");
6203
+ process.exitCode = 1;
6204
+ return;
6100
6205
  }
6101
6206
  const client = new ApiClient({
6102
6207
  apiUrl: opts.apiUrl,
@@ -6104,143 +6209,53 @@ async function watchCommand(opts) {
6104
6209
  deviceId: ensureDeviceId(),
6105
6210
  cliVersion: CLI_VERSION
6106
6211
  });
6107
- const debounceMs = opts.interval ?? 2e3;
6108
- const dir = claudeCodeProjectsDir();
6109
- if (!fs5.existsSync(dir)) {
6110
- warn(`Claude Code projects directory not found: ${dir}`);
6111
- warn("No files to watch. Exiting.");
6112
- process.exit(0);
6113
- }
6114
- info(`Watching ${dir} (debounce: ${debounceMs}ms)`);
6115
- info("Press Ctrl-C to stop.\n");
6116
- const persistedState = loadWatchState();
6117
- const liveState = /* @__PURE__ */ new Map();
6118
- const initialFiles = findJsonlFiles2(dir);
6119
- for (const f of initialFiles) {
6120
- const cur = statFile(f);
6121
- if (!cur) continue;
6122
- liveState.set(f, cur);
6123
- const prev = persistedState[f];
6124
- if (prev && cur.size < prev.size) {
6125
- warn(`Rotation detected on ${f}: size shrank ${prev.size} \u2192 ${cur.size}.`);
6126
- }
6127
- parseAndFilter(f, false);
6128
- }
6129
- if (opts.verbose) {
6130
- dim(`Initial snapshot: ${seen.size} session(s) in ${initialFiles.length} file(s)`);
6131
- }
6132
- saveWatchState(Object.fromEntries(liveState));
6133
- const onChanged = makeDebounced(async (filePath) => {
6134
- if (!filePath.endsWith(".jsonl")) return;
6135
- const cur = statFile(filePath);
6136
- if (cur) {
6137
- const prev = liveState.get(filePath);
6138
- if (prev && cur.size < prev.size) {
6139
- warn(`Rotation detected on ${filePath}: size shrank ${prev.size} \u2192 ${cur.size}.`);
6140
- }
6141
- liveState.set(filePath, cur);
6142
- saveWatchState(Object.fromEntries(liveState));
6143
- }
6144
- const fresh = parseAndFilter(filePath, opts.verbose ?? false);
6145
- if (fresh.length > 0) {
6146
- await upload(client, fresh, opts.verbose ?? false);
6147
- }
6148
- }, debounceMs);
6149
- const heartbeatTimer = setInterval(async () => {
6150
- try {
6151
- await client.heartbeat();
6152
- } catch (err) {
6153
- if (err instanceof DeviceRevokedError) {
6154
- markDisconnected();
6155
- deleteToken();
6156
- error(
6157
- "This device was disconnected from the Token Rats web UI. Daemon exiting; re-run `token-rats login` to reconnect."
6158
- );
6159
- cleanup?.();
6160
- clearInterval(heartbeatTimer);
6161
- process.exit(0);
6162
- }
6163
- if (opts.verbose) {
6164
- dim(`Heartbeat failed: ${err instanceof Error ? err.message : String(err)}`);
6165
- }
6166
- }
6167
- }, HEARTBEAT_MS);
6168
- client.heartbeat().catch(() => {
6169
- });
6170
- let cleanup = null;
6212
+ const queue = new SyncQueue();
6213
+ const collect = createCollector();
6214
+ let stopped = false;
6215
+ let wake;
6216
+ const stop = () => {
6217
+ stopped = true;
6218
+ wake?.();
6219
+ };
6220
+ process.once("SIGINT", stop);
6221
+ process.once("SIGTERM", stop);
6222
+ info("Tracking Claude Code, Codex, and Cursor. Press Ctrl-C to stop.");
6171
6223
  try {
6172
- const chokidar = await new Function("m", "return import(m)")("chokidar");
6173
- const watcher = chokidar.watch(`${dir}/**/*.jsonl`, {
6174
- ignoreInitial: true,
6175
- persistent: true,
6176
- awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 }
6177
- });
6178
- watcher.on("add", (p) => onChanged(p));
6179
- watcher.on("change", (p) => onChanged(p));
6180
- cleanup = () => watcher.close();
6181
- if (opts.verbose) dim("Using chokidar for file watching");
6182
- } catch {
6183
- if (opts.verbose) dim("chokidar not available; using Node built-in fs.watch");
6184
- if (process.platform === "linux") {
6185
- let lastMtimes = /* @__PURE__ */ new Map();
6186
- for (const f of initialFiles) {
6187
- try {
6188
- lastMtimes.set(f, fs5.statSync(f).mtimeMs);
6189
- } catch {
6190
- }
6191
- }
6192
- const pollInterval = setInterval(() => {
6193
- const current = findJsonlFiles2(dir);
6194
- for (const f of current) {
6195
- try {
6196
- const mtime = fs5.statSync(f).mtimeMs;
6197
- const prev = lastMtimes.get(f) ?? 0;
6198
- if (mtime > prev) {
6199
- lastMtimes.set(f, mtime);
6200
- onChanged(f);
6201
- }
6202
- } catch {
6203
- }
6204
- }
6205
- for (const f of current) {
6206
- if (!lastMtimes.has(f)) {
6207
- lastMtimes.set(f, Date.now());
6208
- onChanged(f);
6209
- }
6224
+ while (!stopped) {
6225
+ try {
6226
+ const count = await queue.flush(await collect(), (batch) => client.uploadSessions(batch));
6227
+ if (count === 0) await client.uploadSessions([]);
6228
+ await client.heartbeat();
6229
+ if (count > 0) success(`Synced ${count} changed record(s).`);
6230
+ } catch (err) {
6231
+ if (err instanceof DeviceRevokedError) {
6232
+ markDisconnected();
6233
+ deleteToken();
6234
+ error("Device disconnected. Run `token-rats login` to reconnect.");
6235
+ break;
6210
6236
  }
6211
- lastMtimes = new Map(current.map((f) => [f, lastMtimes.get(f) ?? 0]));
6212
- }, debounceMs);
6213
- cleanup = () => clearInterval(pollInterval);
6214
- } else {
6215
- const controller = new AbortController();
6216
- (async () => {
6217
- try {
6218
- const { watch } = await import("node:fs/promises");
6219
- const watcher = watch(dir, { recursive: true, signal: controller.signal });
6220
- for await (const event of watcher) {
6221
- const filename = event.filename;
6222
- if (filename?.endsWith(".jsonl")) {
6223
- const fullPath = `${dir}/${filename}`;
6224
- onChanged(fullPath);
6225
- }
6226
- }
6227
- } catch (err) {
6228
- if (err instanceof Error && err.name !== "AbortError") {
6229
- warn(`Watcher error: ${err.message}`);
6230
- }
6237
+ if (err instanceof ApiError2 && err.status === 401) {
6238
+ error("Session expired. Run `token-rats login` again.");
6239
+ process.exitCode = 1;
6240
+ break;
6231
6241
  }
6232
- })();
6233
- cleanup = () => controller.abort();
6242
+ warn(
6243
+ `Sync failed. The next scan will retry: ${err instanceof Error ? err.message : String(err)}`
6244
+ );
6245
+ }
6246
+ if (stopped) break;
6247
+ await new Promise((resolve2) => {
6248
+ const timer = setTimeout(resolve2, interval);
6249
+ wake = () => {
6250
+ clearTimeout(timer);
6251
+ resolve2();
6252
+ };
6253
+ });
6234
6254
  }
6255
+ } finally {
6256
+ process.removeListener("SIGINT", stop);
6257
+ process.removeListener("SIGTERM", stop);
6235
6258
  }
6236
- function shutdown() {
6237
- info("Shutting down\u2026");
6238
- clearInterval(heartbeatTimer);
6239
- if (cleanup) cleanup();
6240
- process.exit(0);
6241
- }
6242
- process.on("SIGINT", shutdown);
6243
- process.on("SIGTERM", shutdown);
6244
6259
  }
6245
6260
 
6246
6261
  // src/commands/whoami.ts
@@ -6284,14 +6299,14 @@ function getVersion() {
6284
6299
  }
6285
6300
  function printHelp() {
6286
6301
  console.log(`
6287
- \x1B[1mtoken-rats\x1B[0m \u2014 Strava for AI token burn \x1B[2mv${getVersion()}\x1B[0m
6302
+ \x1B[1mtoken-rats\x1B[0m \u2014 AI usage tracker and community \x1B[2mv${getVersion()}\x1B[0m
6288
6303
 
6289
6304
  \x1B[1mUsage:\x1B[0m
6290
6305
  token-rats <command> [flags]
6291
6306
 
6292
6307
  \x1B[1mCommands:\x1B[0m
6293
6308
  login Authenticate with Token Rats (opens browser); installs the background watcher by default
6294
- sync Read local Claude Code + Cursor logs and upload counts
6309
+ sync Read local Claude Code, Codex + Cursor logs and upload counts
6295
6310
  watch Watch logs in real-time; upload new sessions as they appear
6296
6311
  whoami Show the currently signed-in account + device id
6297
6312
  logout Clear your stored credentials
@@ -6310,13 +6325,13 @@ function printHelp() {
6310
6325
  --verbose Print discovered files and per-file record counts
6311
6326
 
6312
6327
  \x1B[1mFlags (watch only):\x1B[0m
6313
- --interval <ms> Debounce window in ms before uploading (default: 2000)
6328
+ --interval <ms> Scan interval in ms (default: 30000, minimum: 1000)
6314
6329
  --verbose Print file change events and upload detail
6315
6330
 
6316
6331
  \x1B[1mPrivacy:\x1B[0m
6317
- Token Rats reads usage counts only \u2014 never prompts or completions.
6318
- The parser source is in packages/parsers/. We literally can't read
6319
- what you typed.
6332
+ Token Rats reads local logs and uploads usage metadata only.
6333
+ It does not upload prompts or completions.
6334
+ The parser source is in packages/parsers/.
6320
6335
 
6321
6336
  \x1B[1mCursor notes:\x1B[0m
6322
6337
  Cursor doesn't store token counts locally, so per-request tokens
@@ -6397,7 +6412,7 @@ async function main() {
6397
6412
  await installCursorCommand();
6398
6413
  break;
6399
6414
  case "install-daemon":
6400
- await installDaemonCommand();
6415
+ await installDaemonCommand(apiUrl);
6401
6416
  break;
6402
6417
  case "uninstall-daemon":
6403
6418
  await uninstallDaemonCommand();