token-rats 0.2.0 → 0.3.0

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 +439 -436
  4. package/package.json +4 -5
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Token Rats contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -86,3 +86,19 @@ Authentication uses a device-code flow:
86
86
  Token Rats is open source. The CLI source is in [`packages/cli/`](.) and the parsers are in [`packages/parsers/`](../parsers/). You can inspect exactly what is read from your disk and what is sent to the server.
87
87
 
88
88
  **Privacy posture:** Token Rats reads usage counts only — never prompts or completions. The parser source is in `packages/parsers/`. We literally can't read what you typed.
89
+
90
+
91
+ ## Automatic tracking
92
+
93
+ `token-rats login` installs the background tracker. It reads Claude Code,
94
+ Codex, and Cursor records on startup, then checks for changes every 30 seconds.
95
+ Failed uploads are retried. Use `login --no-daemon` to use manual sync only.
96
+ Use `daemon-status` and `uninstall-daemon` to manage automatic tracking.
97
+ After an upgrade, run `install-daemon` to copy the new runtime into place.
98
+ Custom `--api-url` settings are passed to the installed tracker.
99
+
100
+ Open `/app/compare` on your Token Rats server to enter a subscription amount
101
+ for a month. Cursor counts are estimates. API estimates are not provider bills.
102
+ See the [counting method](https://github.com/hsalberti/token-rats/blob/main/docs/counting.md).
103
+
104
+ The CLI and its documentation are MIT licensed. The npm package includes LICENSE.
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.0",
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,7 +5411,8 @@ 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";
5253
5417
 
5254
5418
  // ../parsers/src/hash.ts
@@ -5281,6 +5445,7 @@ function parseClaudeCode(input) {
5281
5445
  text = new TextDecoder().decode(input instanceof ArrayBuffer ? new Uint8Array(input) : input);
5282
5446
  }
5283
5447
  const sessions = /* @__PURE__ */ new Map();
5448
+ const messages = /* @__PURE__ */ new Map();
5284
5449
  for (const rawLine of text.split("\n")) {
5285
5450
  const line = rawLine.trim();
5286
5451
  if (line.length === 0) continue;
@@ -5314,7 +5479,8 @@ function parseClaudeCode(input) {
5314
5479
  outTokens: 0,
5315
5480
  cacheReadTokens: 0,
5316
5481
  cacheWriteTokens: 0,
5317
- model: ""
5482
+ model: "",
5483
+ modelAt: 0
5318
5484
  };
5319
5485
  sessions.set(sessionId, acc);
5320
5486
  }
@@ -5326,16 +5492,32 @@ function parseClaudeCode(input) {
5326
5492
  const message = ev.message;
5327
5493
  if (typeof message !== "object" || message === null) continue;
5328
5494
  const msg = message;
5329
- if (typeof msg.model === "string" && msg.model.length > 0) {
5495
+ if (typeof msg.model === "string" && msg.model.length > 0 && timestamp >= acc.modelAt) {
5330
5496
  acc.model = msg.model;
5497
+ acc.modelAt = timestamp;
5331
5498
  }
5332
5499
  const usage = msg.usage;
5333
5500
  if (typeof usage === "object" && usage !== null) {
5334
5501
  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);
5502
+ const counts = {
5503
+ input: toNonNegInt(u.input_tokens),
5504
+ output: toNonNegInt(u.output_tokens),
5505
+ read: toNonNegInt(u.cache_read_input_tokens),
5506
+ write: toNonNegInt(u.cache_creation_input_tokens)
5507
+ };
5508
+ const key = typeof msg.id === "string" ? `${sessionId}:${msg.id}` : null;
5509
+ const old = key ? messages.get(key) : void 0;
5510
+ const next = {
5511
+ input: Math.max(old?.input ?? 0, counts.input),
5512
+ output: Math.max(old?.output ?? 0, counts.output),
5513
+ read: Math.max(old?.read ?? 0, counts.read),
5514
+ write: Math.max(old?.write ?? 0, counts.write)
5515
+ };
5516
+ acc.inTokens += next.input - (old?.input ?? 0);
5517
+ acc.outTokens += next.output - (old?.output ?? 0);
5518
+ acc.cacheReadTokens += next.read - (old?.read ?? 0);
5519
+ acc.cacheWriteTokens += next.write - (old?.write ?? 0);
5520
+ if (key) messages.set(key, next);
5339
5521
  }
5340
5522
  }
5341
5523
  const results = [];
@@ -5402,12 +5584,15 @@ function parseCodex(input) {
5402
5584
  const payload = typeof ev.payload === "object" && ev.payload !== null ? ev.payload : null;
5403
5585
  if (type === "session_meta" && payload) {
5404
5586
  const id = typeof payload.id === "string" ? payload.id : null;
5405
- if (!id) continue;
5587
+ if (!id) {
5588
+ currentSessionId = null;
5589
+ continue;
5590
+ }
5406
5591
  currentSessionId = id;
5407
5592
  const metaTs = parseTimestamp(payload.timestamp) || ts;
5408
5593
  const acc2 = upsert(sessions, id);
5409
5594
  const metaSource = typeof payload.source === "string" ? payload.source : null;
5410
- acc2.channel = metaSource === "cli" ? "cli" : metaSource === "api" ? "api" : "unknown";
5595
+ acc2.channel = metaSource === "api" ? "api" : metaSource ? "cli" : "unknown";
5411
5596
  acc2.client = acc2.channel === "cli" ? "codex-cli" : "codex";
5412
5597
  if (metaTs > 0 && (acc2.startedAt === 0 || metaTs < acc2.startedAt)) {
5413
5598
  acc2.startedAt = metaTs;
@@ -5422,7 +5607,10 @@ function parseCodex(input) {
5422
5607
  if (ts > acc.endedAt) acc.endedAt = ts;
5423
5608
  }
5424
5609
  if (type === "turn_context" && payload && typeof payload.model === "string") {
5425
- acc.model = payload.model;
5610
+ if (ts >= acc.modelAt) {
5611
+ acc.model = payload.model;
5612
+ acc.modelAt = ts;
5613
+ }
5426
5614
  continue;
5427
5615
  }
5428
5616
  if (type === "event_msg" && payload && payload.type === "token_count") {
@@ -5435,8 +5623,10 @@ function parseCodex(input) {
5435
5623
  const cachedInput = toNonNegInt2(t.cached_input_tokens);
5436
5624
  const output = toNonNegInt2(t.output_tokens);
5437
5625
  const reasoning = toNonNegInt2(t.reasoning_output_tokens);
5626
+ if (ts < acc.usageAt) continue;
5627
+ acc.usageAt = ts;
5438
5628
  acc.inTokens = Math.max(0, inputTotal - cachedInput);
5439
- acc.outTokens = output + reasoning;
5629
+ acc.outTokens = output;
5440
5630
  acc.cacheReadTokens = cachedInput;
5441
5631
  acc.reasoningTokens = reasoning;
5442
5632
  }
@@ -5480,6 +5670,8 @@ function upsert(map, sessionId) {
5480
5670
  cacheReadTokens: 0,
5481
5671
  reasoningTokens: 0,
5482
5672
  model: "",
5673
+ usageAt: 0,
5674
+ modelAt: 0,
5483
5675
  client: "codex-cli",
5484
5676
  channel: "unknown"
5485
5677
  };
@@ -5680,7 +5872,7 @@ async function extractCursorGenerations() {
5680
5872
  dbCount: 0
5681
5873
  };
5682
5874
  }
5683
- const seen2 = /* @__PURE__ */ new Set();
5875
+ const seen = /* @__PURE__ */ new Set();
5684
5876
  const rows = [];
5685
5877
  let openFailures = 0;
5686
5878
  for (const p of dbPaths) {
@@ -5692,8 +5884,8 @@ async function extractCursorGenerations() {
5692
5884
  continue;
5693
5885
  }
5694
5886
  for (const r of perDb) {
5695
- if (seen2.has(r.id)) continue;
5696
- seen2.add(r.id);
5887
+ if (seen.has(r.id)) continue;
5888
+ seen.add(r.id);
5697
5889
  rows.push(r);
5698
5890
  }
5699
5891
  }
@@ -5727,6 +5919,7 @@ function findJsonlFiles(dir) {
5727
5919
  }
5728
5920
  function claudeCodeProjectsDir() {
5729
5921
  const home = os3.homedir();
5922
+ if (process.env.CLAUDE_CONFIG_DIR) return path3.join(process.env.CLAUDE_CONFIG_DIR, "projects");
5730
5923
  if (process.platform === "win32") {
5731
5924
  const profile = process.env.USERPROFILE ?? home;
5732
5925
  return path3.join(profile, ".claude", "projects");
@@ -5740,13 +5933,19 @@ function discoverClaudeCodeFiles() {
5740
5933
  function codexSessionsDirs() {
5741
5934
  const home = os3.homedir();
5742
5935
  const candidates = [];
5936
+ if (process.env.CODEX_HOME) {
5937
+ candidates.push(path3.join(process.env.CODEX_HOME, "sessions"));
5938
+ candidates.push(path3.join(process.env.CODEX_HOME, "archived_sessions"));
5939
+ }
5743
5940
  if (process.platform === "win32") {
5744
5941
  const profile = process.env.USERPROFILE ?? home;
5745
5942
  candidates.push(path3.join(profile, ".codex", "sessions"));
5943
+ candidates.push(path3.join(profile, ".codex", "archived_sessions"));
5746
5944
  const appData = process.env.APPDATA ?? path3.join(profile, "AppData", "Roaming");
5747
5945
  candidates.push(path3.join(appData, "Codex", "sessions"));
5748
5946
  } else {
5749
5947
  candidates.push(path3.join(home, ".codex", "sessions"));
5948
+ candidates.push(path3.join(home, ".codex", "archived_sessions"));
5750
5949
  const snapRoot = path3.join(home, "snap", "codex");
5751
5950
  if (fs3.existsSync(snapRoot)) {
5752
5951
  for (const entry of fs3.readdirSync(snapRoot, { withFileTypes: true })) {
@@ -5756,13 +5955,13 @@ function codexSessionsDirs() {
5756
5955
  }
5757
5956
  }
5758
5957
  }
5759
- const seen2 = /* @__PURE__ */ new Set();
5958
+ const seen = /* @__PURE__ */ new Set();
5760
5959
  const dirs = [];
5761
5960
  for (const c2 of candidates) {
5762
5961
  try {
5763
5962
  const real = fs3.existsSync(c2) ? fs3.realpathSync(c2) : null;
5764
- if (real && !seen2.has(real)) {
5765
- seen2.add(real);
5963
+ if (real && !seen.has(real)) {
5964
+ seen.add(real);
5766
5965
  dirs.push(c2);
5767
5966
  }
5768
5967
  } catch {
@@ -5778,8 +5977,126 @@ function discoverCodexFiles() {
5778
5977
  return out;
5779
5978
  }
5780
5979
 
5980
+ // src/lib/collect.ts
5981
+ async function collectSessions() {
5982
+ function read(files) {
5983
+ return files.map((file) => fs4.readFileSync(file, "utf8")).join("\n");
5984
+ }
5985
+ const claude = parseClaudeCode(read(discoverClaudeCodeFiles()));
5986
+ const codex = parseCodex(read(discoverCodexFiles()));
5987
+ const { rows } = await extractCursorGenerations();
5988
+ const cursor = parseCursor(JSON.stringify(rows));
5989
+ return [...claude, ...codex, ...cursor].map((record) => ({
5990
+ ...record,
5991
+ accountingVersion: 2,
5992
+ dedupeKey: createHash("sha256").update(
5993
+ JSON.stringify([
5994
+ record.id,
5995
+ record.source,
5996
+ record.model,
5997
+ record.startedAt,
5998
+ record.inTokens,
5999
+ record.outTokens,
6000
+ record.cacheReadTokens ?? 0,
6001
+ record.cacheWriteTokens ?? 0,
6002
+ record.reasoningTokens ?? 0
6003
+ ])
6004
+ ).digest("hex")
6005
+ }));
6006
+ }
6007
+ var SyncQueue = class {
6008
+ acknowledged = /* @__PURE__ */ new Map();
6009
+ async flush(records, upload) {
6010
+ const fresh = records.filter((r) => this.acknowledged.get(r.id) !== JSON.stringify(r));
6011
+ for (let i = 0; i < fresh.length; i += 90) {
6012
+ const batch = fresh.slice(i, i + 90);
6013
+ await upload(batch);
6014
+ for (const record of batch) this.acknowledged.set(record.id, JSON.stringify(record));
6015
+ }
6016
+ return fresh.length;
6017
+ }
6018
+ };
6019
+ function createCollector() {
6020
+ let signature = "";
6021
+ let records = [];
6022
+ return async () => {
6023
+ const files = [
6024
+ ...discoverClaudeCodeFiles(),
6025
+ ...discoverCodexFiles(),
6026
+ ...discoverWorkspaceDbs().flatMap((file) => [file, `${file}-wal`])
6027
+ ].sort();
6028
+ const next = JSON.stringify(
6029
+ files.map((file) => {
6030
+ try {
6031
+ const stat = fs4.statSync(file);
6032
+ return [file, stat.size, stat.mtimeMs];
6033
+ } catch {
6034
+ return [file, null];
6035
+ }
6036
+ })
6037
+ );
6038
+ if (next !== signature) {
6039
+ records = await collectSessions();
6040
+ signature = next;
6041
+ }
6042
+ return records;
6043
+ };
6044
+ }
6045
+
5781
6046
  // src/commands/sync.ts
5782
- var BATCH_SIZE = 500;
6047
+ var BATCH_SIZE = 90;
6048
+ var WEB_ORIGIN = "https://tokenrats.com";
6049
+ function currentStreakFromDays(days, todayUtc) {
6050
+ const active = days.slice().sort();
6051
+ if (active.length === 0) return 0;
6052
+ const last = active[active.length - 1];
6053
+ const yesterday = (() => {
6054
+ const d = /* @__PURE__ */ new Date(`${todayUtc}T00:00:00Z`);
6055
+ d.setUTCDate(d.getUTCDate() - 1);
6056
+ return d.toISOString().slice(0, 10);
6057
+ })();
6058
+ if (last !== todayUtc && last !== yesterday) return 0;
6059
+ let streak = 1;
6060
+ for (let i = active.length - 1; i >= 1; i--) {
6061
+ const prev = (/* @__PURE__ */ new Date(`${active[i - 1]}T00:00:00Z`)).getTime();
6062
+ const curr = (/* @__PURE__ */ new Date(`${active[i]}T00:00:00Z`)).getTime();
6063
+ if (Math.round((curr - prev) / 864e5) === 1) streak++;
6064
+ else break;
6065
+ }
6066
+ return streak;
6067
+ }
6068
+ async function printSyncHero(client) {
6069
+ let handle;
6070
+ try {
6071
+ const me = await client.getMe();
6072
+ handle = me.user.handle;
6073
+ } catch {
6074
+ return;
6075
+ }
6076
+ const todayUtc = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
6077
+ const [trending, heatmap] = await Promise.all([
6078
+ client.getTrending("30d").catch(() => null),
6079
+ client.getHeatmap(handle).catch(() => null)
6080
+ ]);
6081
+ const rank = trending?.rows.find((r) => r.handle === handle)?.rank ?? null;
6082
+ const streak = heatmap === null ? 0 : currentStreakFromDays(
6083
+ heatmap.heatmap.days.filter((d) => d.sessions > 0).map((d) => d.day),
6084
+ todayUtc
6085
+ );
6086
+ const profileUrl = `${WEB_ORIGIN}/u/${handle}`;
6087
+ console.log("");
6088
+ bold("\u{1F400} Your Token Rats standing");
6089
+ if (rank !== null) info(`Global rank: #${rank}`);
6090
+ if (streak > 0) info(`Current streak: ${streak} day${streak === 1 ? "" : "s"} \u{1F525}`);
6091
+ info(`Profile: ${profileUrl}`);
6092
+ const rankPart = rank !== null ? `ranked #${rank} globally` : "on the board";
6093
+ const streakPart = streak > 0 ? ` on a ${streak}-day streak` : "";
6094
+ const tweet = `I'm ${rankPart}${streakPart} on @tokenrats \u2014 tracking my AI coding tokens. \u{1F400}`;
6095
+ const intent = `https://x.com/intent/tweet?text=${encodeURIComponent(tweet)}&url=${encodeURIComponent(profileUrl)}`;
6096
+ console.log("");
6097
+ dim("Brag about it \u2014 Post to X:");
6098
+ console.log(` ${process.stdout.isTTY ? `${c.cyan}${intent}${c.reset}` : intent}`);
6099
+ }
5783
6100
  function chunk(arr, size) {
5784
6101
  const chunks = [];
5785
6102
  for (let i = 0; i < arr.length; i += size) {
@@ -5787,34 +6104,6 @@ function chunk(arr, size) {
5787
6104
  }
5788
6105
  return chunks;
5789
6106
  }
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
6107
  async function syncCommand(opts) {
5819
6108
  const token = loadToken();
5820
6109
  if (!token && !opts.dryRun) {
@@ -5827,92 +6116,8 @@ async function syncCommand(opts) {
5827
6116
  deviceId: ensureDeviceId(),
5828
6117
  cliVersion: CLI_VERSION
5829
6118
  });
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";
6119
+ const allSessions = await collectSessions();
6120
+ const sourceStr = [...new Set(allSessions.map((record) => record.source))].join(" + ") || "no sources";
5916
6121
  if (allSessions.length === 0) {
5917
6122
  info(`No sessions found from ${sourceStr}.`);
5918
6123
  return;
@@ -5966,137 +6171,25 @@ async function syncCommand(opts) {
5966
6171
  success(
5967
6172
  `Synced ${allSessions.length} sessions (${totalAccepted} new, ${totalDuplicates} already on server) from ${sourceStr}`
5968
6173
  );
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
6174
  try {
5983
- const raw = fs5.readFileSync(stateFilePath(), "utf8");
5984
- const parsed = JSON.parse(raw);
5985
- return typeof parsed === "object" && parsed !== null ? parsed : {};
6175
+ await printSyncHero(client);
5986
6176
  } catch {
5987
- return {};
5988
6177
  }
5989
6178
  }
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
- try {
6003
- const s = fs5.statSync(p);
6004
- return { size: s.size, mtimeMs: s.mtimeMs };
6005
- } 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
- }
6060
- }
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
- }
6179
+
6180
+ // src/commands/watch.ts
6089
6181
  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
6182
  const token = loadToken();
6097
- if (!token) {
6098
- error("Not logged in. Run `token-rats login` first.");
6099
- process.exit(1);
6183
+ if (!token || isDisconnected()) {
6184
+ error("Run `token-rats login` before you start the tracker.");
6185
+ process.exitCode = 1;
6186
+ return;
6187
+ }
6188
+ const interval = opts.interval ?? 3e4;
6189
+ if (!Number.isFinite(interval) || interval < 1e3) {
6190
+ error("The interval must be at least 1000 milliseconds.");
6191
+ process.exitCode = 1;
6192
+ return;
6100
6193
  }
6101
6194
  const client = new ApiClient({
6102
6195
  apiUrl: opts.apiUrl,
@@ -6104,143 +6197,53 @@ async function watchCommand(opts) {
6104
6197
  deviceId: ensureDeviceId(),
6105
6198
  cliVersion: CLI_VERSION
6106
6199
  });
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;
6200
+ const queue = new SyncQueue();
6201
+ const collect = createCollector();
6202
+ let stopped = false;
6203
+ let wake;
6204
+ const stop = () => {
6205
+ stopped = true;
6206
+ wake?.();
6207
+ };
6208
+ process.once("SIGINT", stop);
6209
+ process.once("SIGTERM", stop);
6210
+ info("Tracking Claude Code, Codex, and Cursor. Press Ctrl-C to stop.");
6171
6211
  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
- }
6212
+ while (!stopped) {
6213
+ try {
6214
+ const count = await queue.flush(await collect(), (batch) => client.uploadSessions(batch));
6215
+ if (count === 0) await client.uploadSessions([]);
6216
+ await client.heartbeat();
6217
+ if (count > 0) success(`Synced ${count} changed record(s).`);
6218
+ } catch (err) {
6219
+ if (err instanceof DeviceRevokedError) {
6220
+ markDisconnected();
6221
+ deleteToken();
6222
+ error("Device disconnected. Run `token-rats login` to reconnect.");
6223
+ break;
6204
6224
  }
6205
- for (const f of current) {
6206
- if (!lastMtimes.has(f)) {
6207
- lastMtimes.set(f, Date.now());
6208
- onChanged(f);
6209
- }
6225
+ if (err instanceof ApiError2 && err.status === 401) {
6226
+ error("Session expired. Run `token-rats login` again.");
6227
+ process.exitCode = 1;
6228
+ break;
6210
6229
  }
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
- }
6231
- }
6232
- })();
6233
- cleanup = () => controller.abort();
6230
+ warn(
6231
+ `Sync failed. The next scan will retry: ${err instanceof Error ? err.message : String(err)}`
6232
+ );
6233
+ }
6234
+ if (stopped) break;
6235
+ await new Promise((resolve2) => {
6236
+ const timer = setTimeout(resolve2, interval);
6237
+ wake = () => {
6238
+ clearTimeout(timer);
6239
+ resolve2();
6240
+ };
6241
+ });
6234
6242
  }
6243
+ } finally {
6244
+ process.removeListener("SIGINT", stop);
6245
+ process.removeListener("SIGTERM", stop);
6235
6246
  }
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
6247
  }
6245
6248
 
6246
6249
  // src/commands/whoami.ts
@@ -6284,14 +6287,14 @@ function getVersion() {
6284
6287
  }
6285
6288
  function printHelp() {
6286
6289
  console.log(`
6287
- \x1B[1mtoken-rats\x1B[0m \u2014 Strava for AI token burn \x1B[2mv${getVersion()}\x1B[0m
6290
+ \x1B[1mtoken-rats\x1B[0m \u2014 AI usage tracker and community \x1B[2mv${getVersion()}\x1B[0m
6288
6291
 
6289
6292
  \x1B[1mUsage:\x1B[0m
6290
6293
  token-rats <command> [flags]
6291
6294
 
6292
6295
  \x1B[1mCommands:\x1B[0m
6293
6296
  login Authenticate with Token Rats (opens browser); installs the background watcher by default
6294
- sync Read local Claude Code + Cursor logs and upload counts
6297
+ sync Read local Claude Code, Codex + Cursor logs and upload counts
6295
6298
  watch Watch logs in real-time; upload new sessions as they appear
6296
6299
  whoami Show the currently signed-in account + device id
6297
6300
  logout Clear your stored credentials
@@ -6310,7 +6313,7 @@ function printHelp() {
6310
6313
  --verbose Print discovered files and per-file record counts
6311
6314
 
6312
6315
  \x1B[1mFlags (watch only):\x1B[0m
6313
- --interval <ms> Debounce window in ms before uploading (default: 2000)
6316
+ --interval <ms> Scan interval in ms (default: 30000, minimum: 1000)
6314
6317
  --verbose Print file change events and upload detail
6315
6318
 
6316
6319
  \x1B[1mPrivacy:\x1B[0m
@@ -6397,7 +6400,7 @@ async function main() {
6397
6400
  await installCursorCommand();
6398
6401
  break;
6399
6402
  case "install-daemon":
6400
- await installDaemonCommand();
6403
+ await installDaemonCommand(apiUrl);
6401
6404
  break;
6402
6405
  case "uninstall-daemon":
6403
6406
  await uninstallDaemonCommand();
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "token-rats",
3
- "version": "0.2.0",
4
- "description": "Sync your Claude Code + Cursor token usage to your Token Rats leaderboard.",
5
- "license": "UNLICENSED",
3
+ "version": "0.3.0",
4
+ "description": "Track Claude Code, Codex, and Cursor usage and compare AI subscriptions.",
5
+ "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "token-rats": "./dist/index.js"
9
9
  },
10
- "files": ["dist", "README.md"],
10
+ "files": ["dist", "README.md", "LICENSE"],
11
11
  "repository": {
12
12
  "type": "git",
13
13
  "url": "git+https://github.com/hsalberti/token-rats.git",
@@ -26,7 +26,6 @@
26
26
  "prepublishOnly": "node build.mjs"
27
27
  },
28
28
  "optionalDependencies": {
29
- "chokidar": "^3.6.0",
30
29
  "clipboardy": "^4.0.0",
31
30
  "open": "^10.1.0"
32
31
  },