token-rats 0.1.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 +27 -54
  3. package/dist/index.js +1052 -1194
  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) {
@@ -32,6 +32,419 @@ async function installCursorCommand(opts = {}) {
32
32
  process.exit(exitCode);
33
33
  }
34
34
 
35
+ // src/commands/install-daemon.ts
36
+ import { execFile } from "node:child_process";
37
+ import * as fs2 from "node:fs";
38
+ import { createRequire } from "node:module";
39
+ import * as os2 from "node:os";
40
+ import * as path2 from "node:path";
41
+ import { promisify } from "node:util";
42
+
43
+ // src/lib/auth-store.ts
44
+ import * as crypto from "node:crypto";
45
+ import * as fs from "node:fs";
46
+ import * as os from "node:os";
47
+ import * as path from "node:path";
48
+ function tokenDir() {
49
+ const xdgConfig = process.env.XDG_CONFIG_HOME;
50
+ const base = xdgConfig ?? path.join(os.homedir(), ".config");
51
+ return path.join(base, "token-rats");
52
+ }
53
+ function tokenPath() {
54
+ return path.join(tokenDir(), "token");
55
+ }
56
+ function statePath() {
57
+ return path.join(tokenDir(), "state.json");
58
+ }
59
+ function disconnectedPath() {
60
+ return path.join(tokenDir(), "disconnected");
61
+ }
62
+ function saveToken(token) {
63
+ const dir = tokenDir();
64
+ fs.mkdirSync(dir, { recursive: true });
65
+ fs.writeFileSync(tokenPath(), token, { encoding: "utf8", mode: 384 });
66
+ }
67
+ function loadToken() {
68
+ try {
69
+ const token = fs.readFileSync(tokenPath(), "utf8").trim();
70
+ return token.length > 0 ? token : null;
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+ function deleteToken() {
76
+ try {
77
+ fs.unlinkSync(tokenPath());
78
+ } catch {
79
+ }
80
+ }
81
+ function isLoggedIn() {
82
+ return loadToken() !== null;
83
+ }
84
+ function ensureDeviceId() {
85
+ try {
86
+ const raw = fs.readFileSync(statePath(), "utf8");
87
+ const parsed = JSON.parse(raw);
88
+ if (typeof parsed.deviceId === "string" && parsed.deviceId.length > 0) {
89
+ return parsed.deviceId;
90
+ }
91
+ } catch {
92
+ }
93
+ const dir = tokenDir();
94
+ fs.mkdirSync(dir, { recursive: true });
95
+ const deviceId = crypto.randomUUID();
96
+ const state = { deviceId, createdAt: Date.now() };
97
+ fs.writeFileSync(statePath(), JSON.stringify(state, null, 2), {
98
+ encoding: "utf8",
99
+ mode: 384
100
+ });
101
+ return deviceId;
102
+ }
103
+ function markDisconnected() {
104
+ const dir = tokenDir();
105
+ fs.mkdirSync(dir, { recursive: true });
106
+ fs.writeFileSync(disconnectedPath(), String(Date.now()), { mode: 384 });
107
+ }
108
+ function clearDisconnected() {
109
+ try {
110
+ fs.unlinkSync(disconnectedPath());
111
+ } catch {
112
+ }
113
+ }
114
+ function isDisconnected() {
115
+ return fs.existsSync(disconnectedPath());
116
+ }
117
+
118
+ // src/lib/log.ts
119
+ var ESC = "\x1B";
120
+ var c = {
121
+ reset: `${ESC}[0m`,
122
+ bold: `${ESC}[1m`,
123
+ dim: `${ESC}[2m`,
124
+ green: `${ESC}[32m`,
125
+ yellow: `${ESC}[33m`,
126
+ cyan: `${ESC}[36m`,
127
+ red: `${ESC}[31m`,
128
+ gray: `${ESC}[90m`
129
+ };
130
+ function strip(s) {
131
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
132
+ }
133
+ function isTTY() {
134
+ return process.stdout.isTTY === true;
135
+ }
136
+ function color(code, text) {
137
+ return isTTY() ? `${code}${text}${c.reset}` : strip(text);
138
+ }
139
+ function info(msg) {
140
+ console.log(color(c.cyan, ` ${msg}`));
141
+ }
142
+ function success(msg) {
143
+ console.log(color(c.green, `\u2713 ${msg}`));
144
+ }
145
+ function warn(msg) {
146
+ console.warn(color(c.yellow, `\u26A0 ${msg}`));
147
+ }
148
+ function error(msg) {
149
+ console.error(color(c.red, `\u2717 ${msg}`));
150
+ }
151
+ function dim(msg) {
152
+ console.log(color(c.dim, ` ${msg}`));
153
+ }
154
+ function bold(msg) {
155
+ console.log(isTTY() ? `${c.bold}${msg}${c.reset}` : msg);
156
+ }
157
+ function spinner(label) {
158
+ if (!isTTY()) {
159
+ process.stdout.write(` ${label}...
160
+ `);
161
+ return {
162
+ stop(finalMsg) {
163
+ if (finalMsg) process.stdout.write(` ${finalMsg}
164
+ `);
165
+ }
166
+ };
167
+ }
168
+ const frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
169
+ let i = 0;
170
+ const interval = setInterval(() => {
171
+ process.stdout.write(`\r${c.cyan}${frames[i++ % frames.length]}${c.reset} ${label} `);
172
+ }, 80);
173
+ return {
174
+ stop(finalMsg) {
175
+ clearInterval(interval);
176
+ process.stdout.write("\r\x1B[K");
177
+ if (finalMsg) success(finalMsg);
178
+ }
179
+ };
180
+ }
181
+
182
+ // src/commands/install-daemon.ts
183
+ var exec = promisify(execFile);
184
+ var LABEL = "com.tokenrats.watch";
185
+ var LINUX_UNIT = "token-rats-watch.service";
186
+ var WINDOWS_TASK = "TokenRatsWatch";
187
+ function resolveCliPath() {
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}"`;
220
+ }
221
+ function darwinPlistPath() {
222
+ return path2.join(os2.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
223
+ }
224
+ function darwinLogDir() {
225
+ return path2.join(os2.homedir(), "Library", "Logs", "token-rats");
226
+ }
227
+ function darwinPlist(node, script, apiUrl) {
228
+ const logDir = darwinLogDir();
229
+ const stdout = path2.join(logDir, "watch.out.log");
230
+ const stderr = path2.join(logDir, "watch.err.log");
231
+ return `<?xml version="1.0" encoding="UTF-8"?>
232
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
233
+ <plist version="1.0">
234
+ <dict>
235
+ <key>Label</key>
236
+ <string>${LABEL}</string>
237
+ <key>ProgramArguments</key>
238
+ <array>
239
+ <string>${xml(node)}</string>
240
+ <string>${xml(script)}</string>
241
+ <string>watch</string>
242
+ ${apiUrl ? `<string>--api-url</string><string>${xml(apiUrl)}</string>` : ""}
243
+ </array>
244
+ <key>RunAtLoad</key>
245
+ <true/>
246
+ <key>KeepAlive</key>
247
+ <true/>
248
+ <key>StandardOutPath</key>
249
+ <string>${xml(stdout)}</string>
250
+ <key>StandardErrorPath</key>
251
+ <string>${xml(stderr)}</string>
252
+ </dict>
253
+ </plist>
254
+ `;
255
+ }
256
+ async function darwinInstall(apiUrl) {
257
+ const { node, script } = resolveCliPath();
258
+ fs2.mkdirSync(darwinLogDir(), { recursive: true });
259
+ const plistPath = darwinPlistPath();
260
+ fs2.mkdirSync(path2.dirname(plistPath), { recursive: true });
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
+ );
265
+ try {
266
+ await exec("launchctl", ["bootstrap", `gui/${process.getuid?.() ?? 0}`, plistPath]);
267
+ } catch {
268
+ try {
269
+ await exec("launchctl", ["load", plistPath]);
270
+ } catch (err) {
271
+ throw new Error(
272
+ `Wrote ${plistPath} but failed to load it: ${err instanceof Error ? err.message : String(err)}`
273
+ );
274
+ }
275
+ }
276
+ }
277
+ async function darwinUninstall() {
278
+ const plistPath = darwinPlistPath();
279
+ try {
280
+ await exec("launchctl", ["bootout", `gui/${process.getuid?.() ?? 0}/${LABEL}`]);
281
+ } catch {
282
+ try {
283
+ await exec("launchctl", ["unload", plistPath]);
284
+ } catch {
285
+ }
286
+ }
287
+ try {
288
+ fs2.unlinkSync(plistPath);
289
+ } catch {
290
+ }
291
+ }
292
+ async function darwinStatus() {
293
+ if (!fs2.existsSync(darwinPlistPath())) return "not-installed";
294
+ try {
295
+ const { stdout } = await exec("launchctl", ["list"]);
296
+ if (stdout.split("\n").some((line) => line.endsWith(LABEL) && /^\d+\s/.test(line)))
297
+ return "running";
298
+ return "stopped";
299
+ } catch {
300
+ return "stopped";
301
+ }
302
+ }
303
+ function linuxUnitPath() {
304
+ const xdgConfig = process.env.XDG_CONFIG_HOME ?? path2.join(os2.homedir(), ".config");
305
+ return path2.join(xdgConfig, "systemd", "user", LINUX_UNIT);
306
+ }
307
+ function linuxUnit(node, script, apiUrl) {
308
+ return `[Unit]
309
+ Description=Token Rats watcher \u2014 live AI usage sync
310
+ After=network-online.target
311
+ Wants=network-online.target
312
+
313
+ [Service]
314
+ Type=simple
315
+ ExecStart=${systemdArg(node)} ${systemdArg(script)} watch${apiUrl ? ` --api-url ${systemdArg(apiUrl)}` : ""}
316
+ Restart=on-failure
317
+ RestartSec=10s
318
+ Environment=NODE_ENV=production
319
+
320
+ [Install]
321
+ WantedBy=default.target
322
+ `;
323
+ }
324
+ async function linuxInstall(apiUrl) {
325
+ const { node, script } = resolveCliPath();
326
+ const unitPath = linuxUnitPath();
327
+ fs2.mkdirSync(path2.dirname(unitPath), { recursive: true });
328
+ fs2.writeFileSync(unitPath, linuxUnit(node, script, apiUrl), { mode: 420 });
329
+ try {
330
+ await exec("systemctl", ["--user", "daemon-reload"]);
331
+ await exec("systemctl", ["--user", "enable", "--now", LINUX_UNIT]);
332
+ await exec("systemctl", ["--user", "restart", LINUX_UNIT]);
333
+ } catch (err) {
334
+ throw new Error(
335
+ `Wrote ${unitPath} but failed to enable+start it: ${err instanceof Error ? err.message : String(err)}`
336
+ );
337
+ }
338
+ }
339
+ async function linuxUninstall() {
340
+ try {
341
+ await exec("systemctl", ["--user", "disable", "--now", LINUX_UNIT]);
342
+ } catch {
343
+ }
344
+ try {
345
+ fs2.unlinkSync(linuxUnitPath());
346
+ } catch {
347
+ }
348
+ try {
349
+ await exec("systemctl", ["--user", "daemon-reload"]);
350
+ } catch {
351
+ }
352
+ }
353
+ async function linuxStatus() {
354
+ if (!fs2.existsSync(linuxUnitPath())) return "not-installed";
355
+ try {
356
+ const { stdout } = await exec("systemctl", ["--user", "is-active", LINUX_UNIT]);
357
+ return stdout.trim() === "active" ? "running" : "stopped";
358
+ } catch {
359
+ return "stopped";
360
+ }
361
+ }
362
+ async function windowsInstall(apiUrl) {
363
+ const { node, script } = resolveCliPath();
364
+ await exec("schtasks", [
365
+ "/Create",
366
+ "/SC",
367
+ "ONLOGON",
368
+ "/TN",
369
+ WINDOWS_TASK,
370
+ "/TR",
371
+ `"${node}" "${script}" watch${apiUrl ? ` --api-url "${apiUrl}"` : ""}`,
372
+ "/RL",
373
+ "LIMITED",
374
+ "/F"
375
+ ]);
376
+ try {
377
+ await exec("schtasks", ["/Run", "/TN", WINDOWS_TASK]);
378
+ } catch {
379
+ }
380
+ }
381
+ async function windowsUninstall() {
382
+ try {
383
+ await exec("schtasks", ["/End", "/TN", WINDOWS_TASK]);
384
+ } catch {
385
+ }
386
+ try {
387
+ await exec("schtasks", ["/Delete", "/TN", WINDOWS_TASK, "/F"]);
388
+ } catch {
389
+ }
390
+ }
391
+ async function windowsStatus() {
392
+ try {
393
+ const { stdout } = await exec("schtasks", ["/Query", "/TN", WINDOWS_TASK, "/FO", "CSV", "/NH"]);
394
+ if (stdout.includes("Running")) return "running";
395
+ return "stopped";
396
+ } catch {
397
+ return "not-installed";
398
+ }
399
+ }
400
+ async function installDaemonCommand(apiUrl) {
401
+ clearDisconnected();
402
+ try {
403
+ if (process.platform === "darwin") {
404
+ await darwinInstall(apiUrl);
405
+ } else if (process.platform === "linux") {
406
+ await linuxInstall(apiUrl);
407
+ } else if (process.platform === "win32") {
408
+ await windowsInstall(apiUrl);
409
+ } else {
410
+ warn(`No daemon installer for platform ${process.platform}; skipping.`);
411
+ return;
412
+ }
413
+ success("Background watcher installed and running.");
414
+ dim("It will pick up sessions from Claude Code, Codex, and Cursor every 30 seconds.");
415
+ dim("Manage it with `token-rats daemon-status` and `token-rats uninstall-daemon`.");
416
+ } catch (err) {
417
+ error(`Failed to install daemon: ${err instanceof Error ? err.message : String(err)}`);
418
+ warn("You can still run `token-rats sync` manually.");
419
+ }
420
+ }
421
+ async function uninstallDaemonCommand() {
422
+ if (process.platform === "darwin") {
423
+ await darwinUninstall();
424
+ } else if (process.platform === "linux") {
425
+ await linuxUninstall();
426
+ } else if (process.platform === "win32") {
427
+ await windowsUninstall();
428
+ } else {
429
+ warn(`No daemon installer for platform ${process.platform}; nothing to remove.`);
430
+ return;
431
+ }
432
+ info("Daemon removed. `token-rats sync` will still work manually.");
433
+ }
434
+ async function daemonStatusCommand() {
435
+ let status;
436
+ if (process.platform === "darwin") status = await darwinStatus();
437
+ else if (process.platform === "linux") status = await linuxStatus();
438
+ else if (process.platform === "win32") status = await windowsStatus();
439
+ else {
440
+ warn(`No daemon for platform ${process.platform}.`);
441
+ return;
442
+ }
443
+ if (status === "running") success("Token Rats watcher is running.");
444
+ else if (status === "stopped") warn("Token Rats watcher is installed but not running.");
445
+ else info("Token Rats watcher is not installed. Run `token-rats install-daemon` to start it.");
446
+ }
447
+
35
448
  // ../../node_modules/.pnpm/zod@3.23.8/node_modules/zod/lib/index.mjs
36
449
  var util;
37
450
  (function(util2) {
@@ -385,8 +798,8 @@ function getErrorMap() {
385
798
  return overrideErrorMap;
386
799
  }
387
800
  var makeIssue = (params) => {
388
- const { data, path: path7, errorMaps, issueData } = params;
389
- const fullPath = [...path7, ...issueData.path || []];
801
+ const { data, path: path4, errorMaps, issueData } = params;
802
+ const fullPath = [...path4, ...issueData.path || []];
390
803
  const fullIssue = {
391
804
  ...issueData,
392
805
  path: fullPath
@@ -508,11 +921,11 @@ var errorUtil;
508
921
  var _ZodEnum_cache;
509
922
  var _ZodNativeEnum_cache;
510
923
  var ParseInputLazyPath = class {
511
- constructor(parent, value, path7, key) {
924
+ constructor(parent, value, path4, key) {
512
925
  this._cachedPath = [];
513
926
  this.parent = parent;
514
927
  this.data = value;
515
- this._path = path7;
928
+ this._path = path4;
516
929
  this._key = key;
517
930
  }
518
931
  get path() {
@@ -3947,13 +4360,28 @@ var z = /* @__PURE__ */ Object.freeze({
3947
4360
  });
3948
4361
 
3949
4362
  // ../contracts/src/session.ts
3950
- var Source = z.enum(["claude-code", "cursor", "codex"]);
3951
- var Provider = z.enum(["anthropic", "openai", "cursor", "unknown"]);
4363
+ var Source = z.enum(["claude-code", "cursor", "codex", "openrouter", "openai"]);
4364
+ var Provider = z.enum([
4365
+ "anthropic",
4366
+ "openai",
4367
+ "openrouter",
4368
+ "cursor",
4369
+ "ollama",
4370
+ "unknown"
4371
+ ]);
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._-]+$/;
3952
4377
  var SessionRecord = z.object({
3953
- id: z.string().min(1),
4378
+ accountingVersion: z.literal(2).optional(),
4379
+ id: z.string().min(1).max(256).regex(ID_RE),
3954
4380
  source: Source,
3955
4381
  provider: Provider.optional(),
3956
- model: z.string().min(1),
4382
+ client: z.string().min(1).max(64).regex(CLIENT_RE).optional(),
4383
+ channel: SessionChannel.optional(),
4384
+ model: z.string().min(1).max(128).regex(MODEL_RE),
3957
4385
  inTokens: z.number().int().nonnegative(),
3958
4386
  outTokens: z.number().int().nonnegative(),
3959
4387
  /** Anthropic cache reads / OpenAI `cached_input_tokens`. Billed cheap-or-free. */
@@ -3965,10 +4393,37 @@ var SessionRecord = z.object({
3965
4393
  costUsdCents: z.number().int().nonnegative(),
3966
4394
  startedAt: z.number().int().positive(),
3967
4395
  endedAt: z.number().int().positive(),
3968
- dedupeKey: z.string().min(1)
4396
+ dedupeKey: z.string().min(1).max(128).regex(DEDUPE_KEY_RE)
3969
4397
  });
3970
4398
 
3971
4399
  // ../contracts/src/user.ts
4400
+ var ProfileAttributionEntry = z.object({
4401
+ source: z.string(),
4402
+ tokens: z.number().int().nonnegative(),
4403
+ costUsdCents: z.number().int().nonnegative(),
4404
+ sessions: z.number().int().nonnegative()
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
+ );
3972
4427
  var User = z.object({
3973
4428
  id: z.string(),
3974
4429
  handle: z.string(),
@@ -3988,13 +4443,20 @@ var User = z.object({
3988
4443
  * (other readers never see this field). `null` when GitHub didn't return a
3989
4444
  * verified email — the user sees a banner asking them to add one.
3990
4445
  */
3991
- 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()
3992
4450
  });
3993
4451
  var PublicProfileSettings = z.object({
3994
4452
  publicProfile: z.boolean().optional(),
3995
- 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()
3996
4456
  });
3997
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(),
3998
4460
  totals: z.object({
3999
4461
  today: z.object({ tokens: z.number().int(), costUsdCents: z.number().int() }),
4000
4462
  week: z.object({ tokens: z.number().int(), costUsdCents: z.number().int() }),
@@ -4002,6 +4464,10 @@ var Profile = User.extend({
4002
4464
  }),
4003
4465
  /** Number of users this user has brought in via their referral link. */
4004
4466
  referredCount: z.number().int().nonnegative().optional(),
4467
+ /** All-time attribution grouped by tool/client. */
4468
+ sources: z.array(ProfileAttributionEntry).optional(),
4469
+ /** All-time attribution grouped by transport channel. */
4470
+ channels: z.array(ProfileAttributionEntry).optional(),
4005
4471
  /**
4006
4472
  * The profile owner's referral code. Self-only — present only when the
4007
4473
  * caller is viewing their own profile, so the page can render a
@@ -4077,8 +4543,14 @@ var LeaderboardRow = z.object({
4077
4543
  tokens: z.number().int().nonnegative(),
4078
4544
  costUsdCents: z.number().int().nonnegative(),
4079
4545
  sessions: z.number().int().nonnegative(),
4546
+ /** ISO 3166-1 alpha-2 country code. Null when the user has no stamped country. */
4547
+ country: z.string().length(2).nullable().default(null),
4080
4548
  /** Up to 2 dominant sources by token volume, descending. May be empty. */
4081
- topSources: z.array(SourceBreakdownEntry).max(2).default([])
4549
+ topSources: z.array(SourceBreakdownEntry).max(2).default([]),
4550
+ /** Up to 2 dominant clients/tools by token volume, descending. */
4551
+ topClients: z.array(SourceBreakdownEntry).max(2).default([]),
4552
+ /** Up to 2 dominant transport channels by token volume, descending. */
4553
+ topChannels: z.array(SourceBreakdownEntry).max(2).default([])
4082
4554
  });
4083
4555
  var Leaderboard = z.object({
4084
4556
  range: LeaderboardRange,
@@ -4414,6 +4886,12 @@ var ENDPOINTS = {
4414
4886
  meReferral: "/v1/me/referral",
4415
4887
  // v1.2 Track AD — friends derived from shared private rooms
4416
4888
  meFriends: "/v1/me/friends",
4889
+ // Multi-device — anonymized device list + revoke + heartbeat
4890
+ meDevices: "/v1/me/devices",
4891
+ meDeviceRevoke: (deviceId) => `/v1/me/devices/${deviceId}/revoke`,
4892
+ meDeviceHeartbeat: "/v1/me/devices/heartbeat",
4893
+ // CLI version + upgrade banner
4894
+ cliVersion: "/v1/cli/version",
4417
4895
  // Phase 3 Track O — Org plan
4418
4896
  orgs: "/v1/orgs",
4419
4897
  org: (slug) => `/v1/orgs/${slug}`,
@@ -4547,49 +5025,147 @@ var FriendsResponse = z.object({
4547
5025
  friends: z.array(FriendRow)
4548
5026
  });
4549
5027
 
4550
- // src/lib/api.ts
4551
- var DEFAULT_API_URL = "https://api.tokenrats.com";
4552
- function isTransient(status) {
4553
- return status >= 500 || status === 408 || status === 429;
4554
- }
4555
- function sleep(ms) {
4556
- return new Promise((resolve) => setTimeout(resolve, ms));
4557
- }
4558
- var ApiError2 = class extends Error {
4559
- constructor(status, body) {
4560
- super(`API error ${status}: ${body}`);
4561
- this.status = status;
4562
- this.body = body;
4563
- this.name = "ApiError";
4564
- }
4565
- };
4566
- var ApiClient = class {
4567
- apiUrl;
4568
- token;
4569
- constructor(opts = {}) {
4570
- this.apiUrl = (opts.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
4571
- this.token = opts.token;
5028
+ // ../contracts/src/device.ts
5029
+ var DeviceTotals = z.object({
5030
+ tokens: z.number().int().nonnegative(),
5031
+ costUsdCents: z.number().int().nonnegative(),
5032
+ sessions: z.number().int().nonnegative()
5033
+ });
5034
+ var DeviceBreakdownEntry = z.object({
5035
+ value: z.string().min(1),
5036
+ tokens: z.number().int().nonnegative(),
5037
+ costUsdCents: z.number().int().nonnegative(),
5038
+ sessions: z.number().int().nonnegative()
5039
+ });
5040
+ var Device = z.object({
5041
+ deviceId: z.string().min(1),
5042
+ createdAt: z.number().int().nonnegative(),
5043
+ lastSeenAt: z.number().int().nonnegative(),
5044
+ lastHeartbeatAt: z.number().int().nonnegative().nullable(),
5045
+ /** Derived: true iff lastHeartbeatAt is within 5 min of `now`. */
5046
+ isLive: z.boolean(),
5047
+ lastUploadCount: z.number().int().nonnegative(),
5048
+ cliVersion: z.string().nullable(),
5049
+ /** Unix-ms when the user revoked this device via the web UI, else null. */
5050
+ revokedAt: z.number().int().nonnegative().nullable(),
5051
+ /** True for the synthetic pre-device-id bucket. */
5052
+ isLegacy: z.boolean().default(false),
5053
+ /** Last observed session upload timestamp for this device, if any. */
5054
+ lastSessionAt: z.number().int().nonnegative().nullable(),
5055
+ /** 30-day totals for this device, computed at request time. */
5056
+ totals: DeviceTotals,
5057
+ /** All-time totals for this device. */
5058
+ totalsAllTime: DeviceTotals,
5059
+ /** Dominant sources in the last 30 days, ordered by tokens desc. */
5060
+ topSources: z.array(DeviceBreakdownEntry).max(3).default([]),
5061
+ /** Dominant clients/tools in the last 30 days, ordered by tokens desc. */
5062
+ topClients: z.array(DeviceBreakdownEntry).max(3).default([]),
5063
+ /** Dominant transport channels in the last 30 days, ordered by tokens desc. */
5064
+ topChannels: z.array(DeviceBreakdownEntry).max(3).default([]),
5065
+ /** Dominant providers in the last 30 days, ordered by tokens desc. */
5066
+ topProviders: z.array(DeviceBreakdownEntry).max(3).default([]),
5067
+ /** Dominant models in the last 30 days, ordered by tokens desc. */
5068
+ topModels: z.array(DeviceBreakdownEntry).max(3).default([])
5069
+ });
5070
+ var GetMeDevicesResponse = z.object({
5071
+ devices: z.array(Device)
5072
+ });
5073
+ var RevokeDeviceResponse = z.object({
5074
+ ok: z.literal(true),
5075
+ revokedAt: z.number().int().nonnegative()
5076
+ });
5077
+ var DeviceHeartbeatResponse = z.object({
5078
+ ok: z.literal(true),
5079
+ lastHeartbeatAt: z.number().int().nonnegative()
5080
+ });
5081
+
5082
+ // ../contracts/src/cli.ts
5083
+ var CliVersionResponse = z.object({
5084
+ latest: z.string().min(1),
5085
+ minSupported: z.string().min(1),
5086
+ upgradeCommand: z.string().min(1)
5087
+ });
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
+
5119
+ // src/lib/api.ts
5120
+ var DEFAULT_API_URL = "https://api.tokenrats.com";
5121
+ function isTransient(status) {
5122
+ return status >= 500 || status === 408 || status === 429;
5123
+ }
5124
+ function sleep(ms) {
5125
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
5126
+ }
5127
+ var ApiError2 = class extends Error {
5128
+ constructor(status, body) {
5129
+ super(`API error ${status}: ${body}`);
5130
+ this.status = status;
5131
+ this.body = body;
5132
+ this.name = "ApiError";
5133
+ }
5134
+ };
5135
+ var DeviceRevokedError = class extends ApiError2 {
5136
+ constructor(body) {
5137
+ super(401, body);
5138
+ this.name = "DeviceRevokedError";
5139
+ }
5140
+ };
5141
+ var ApiClient = class {
5142
+ apiUrl;
5143
+ token;
5144
+ deviceId;
5145
+ cliVersion;
5146
+ constructor(opts = {}) {
5147
+ this.apiUrl = (opts.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
5148
+ this.token = opts.token;
5149
+ this.deviceId = opts.deviceId;
5150
+ this.cliVersion = opts.cliVersion;
4572
5151
  }
4573
5152
  setToken(token) {
4574
5153
  this.token = token;
4575
5154
  }
4576
5155
  headers() {
4577
5156
  const h = { "Content-Type": "application/json" };
4578
- if (this.token) {
4579
- h.Authorization = `Bearer ${this.token}`;
4580
- }
5157
+ if (this.token) h.Authorization = `Bearer ${this.token}`;
5158
+ if (this.deviceId) h["X-Device-Id"] = this.deviceId;
5159
+ if (this.cliVersion) h["X-Cli-Version"] = this.cliVersion;
4581
5160
  return h;
4582
5161
  }
4583
- /** Perform a fetch with retry+backoff. maxRetries=3, delays: 1s, 2s, 4s. */
4584
5162
  async fetchWithRetry(url, init, maxRetries = 3) {
4585
5163
  let attempt = 0;
4586
5164
  let lastErr;
4587
5165
  while (attempt <= maxRetries) {
4588
5166
  try {
4589
- const res = await fetch(url, init);
4590
- if (res.ok || !isTransient(res.status)) {
4591
- return res;
4592
- }
5167
+ const res = await fetch(url, { ...init, signal: AbortSignal.timeout(3e4) });
5168
+ if (res.ok || !isTransient(res.status)) return res;
4593
5169
  lastErr = new ApiError2(res.status, await res.text());
4594
5170
  } catch (err) {
4595
5171
  lastErr = err;
@@ -4601,34 +5177,37 @@ var ApiClient = class {
4601
5177
  }
4602
5178
  throw lastErr;
4603
5179
  }
4604
- async post(path7, body) {
4605
- const url = `${this.apiUrl}${path7}`;
5180
+ async failedResponseToError(res) {
5181
+ const body = await res.text();
5182
+ if (res.status === 401 && /device_revoked/.test(body)) {
5183
+ return new DeviceRevokedError(body);
5184
+ }
5185
+ return new ApiError2(res.status, body);
5186
+ }
5187
+ async post(path4, body) {
5188
+ const url = `${this.apiUrl}${path4}`;
4606
5189
  const res = await this.fetchWithRetry(url, {
4607
5190
  method: "POST",
4608
5191
  headers: this.headers(),
4609
5192
  body: JSON.stringify(body)
4610
5193
  });
4611
- if (!res.ok) {
4612
- throw new ApiError2(res.status, await res.text());
4613
- }
5194
+ if (!res.ok) throw await this.failedResponseToError(res);
4614
5195
  return res.json();
4615
5196
  }
4616
- async get(path7) {
4617
- const url = `${this.apiUrl}${path7}`;
5197
+ async get(path4) {
5198
+ const url = `${this.apiUrl}${path4}`;
4618
5199
  const res = await this.fetchWithRetry(url, {
4619
5200
  method: "GET",
4620
5201
  headers: this.headers()
4621
5202
  });
4622
- if (!res.ok) {
4623
- throw new ApiError2(res.status, await res.text());
4624
- }
5203
+ if (!res.ok) throw await this.failedResponseToError(res);
4625
5204
  return res.json();
4626
5205
  }
4627
5206
  /** Initiate device-code flow. */
4628
5207
  async cliExchange() {
4629
5208
  return this.post(ENDPOINTS.authCliExchange, {});
4630
5209
  }
4631
- /** Poll for auth token. Returns token on success, null on pending, throws on error. */
5210
+ /** Poll for auth token. Returns token on success, null on pending. */
4632
5211
  async cliPoll(pollToken) {
4633
5212
  const url = `${this.apiUrl}${ENDPOINTS.authCliPoll}`;
4634
5213
  const res = await fetch(url, {
@@ -4640,12 +5219,8 @@ var ApiClient = class {
4640
5219
  const data = await res.json();
4641
5220
  return data.token;
4642
5221
  }
4643
- if (res.status === 202) {
4644
- return null;
4645
- }
4646
- if (res.status === 410) {
4647
- throw new ApiError2(410, "Code expired");
4648
- }
5222
+ if (res.status === 202) return null;
5223
+ if (res.status === 410) throw new ApiError2(410, "Code expired");
4649
5224
  throw new ApiError2(res.status, await res.text());
4650
5225
  }
4651
5226
  /** GET /v1/me */
@@ -4656,106 +5231,78 @@ var ApiClient = class {
4656
5231
  async uploadSessions(sessions) {
4657
5232
  return this.post(ENDPOINTS.sessions, { sessions });
4658
5233
  }
4659
- };
4660
-
4661
- // src/lib/auth-store.ts
4662
- import * as fs from "node:fs";
4663
- import * as os from "node:os";
4664
- import * as path from "node:path";
4665
- function tokenDir() {
4666
- const xdgConfig = process.env.XDG_CONFIG_HOME;
4667
- const base = xdgConfig ?? path.join(os.homedir(), ".config");
4668
- return path.join(base, "token-rats");
4669
- }
4670
- function tokenPath() {
4671
- return path.join(tokenDir(), "token");
4672
- }
4673
- function saveToken(token) {
4674
- const dir = tokenDir();
4675
- fs.mkdirSync(dir, { recursive: true });
4676
- const file = tokenPath();
4677
- fs.writeFileSync(file, token, { encoding: "utf8", mode: 384 });
4678
- }
4679
- function loadToken() {
4680
- const file = tokenPath();
4681
- try {
4682
- const token = fs.readFileSync(file, "utf8").trim();
4683
- return token.length > 0 ? token : null;
4684
- } catch {
4685
- return null;
5234
+ /** GET /v1/u/:handle */
5235
+ async getProfile(handle) {
5236
+ return this.get(ENDPOINTS.profile(handle));
4686
5237
  }
4687
- }
4688
- function deleteToken() {
4689
- const file = tokenPath();
4690
- try {
4691
- fs.unlinkSync(file);
4692
- } catch {
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
+ }
5246
+ /** GET /v1/me/devices */
5247
+ async getDevices() {
5248
+ return this.get(ENDPOINTS.meDevices);
5249
+ }
5250
+ /** POST /v1/me/devices/heartbeat */
5251
+ async heartbeat() {
5252
+ return this.post(ENDPOINTS.meDeviceHeartbeat, {});
4693
5253
  }
4694
- }
4695
- function isLoggedIn() {
4696
- return loadToken() !== null;
4697
- }
4698
-
4699
- // src/lib/log.ts
4700
- var ESC = "\x1B";
4701
- var c = {
4702
- reset: `${ESC}[0m`,
4703
- bold: `${ESC}[1m`,
4704
- dim: `${ESC}[2m`,
4705
- green: `${ESC}[32m`,
4706
- yellow: `${ESC}[33m`,
4707
- cyan: `${ESC}[36m`,
4708
- red: `${ESC}[31m`,
4709
- gray: `${ESC}[90m`
4710
5254
  };
4711
- function strip(s) {
4712
- return s.replace(/\x1b\[[0-9;]*m/g, "");
4713
- }
4714
- function isTTY() {
4715
- return process.stdout.isTTY === true;
4716
- }
4717
- function color(code, text) {
4718
- return isTTY() ? `${code}${text}${c.reset}` : strip(text);
4719
- }
4720
- function info(msg) {
4721
- console.log(color(c.cyan, ` ${msg}`));
4722
- }
4723
- function success(msg) {
4724
- console.log(color(c.green, `\u2713 ${msg}`));
4725
- }
4726
- function warn(msg) {
4727
- console.warn(color(c.yellow, `\u26A0 ${msg}`));
4728
- }
4729
- function error(msg) {
4730
- console.error(color(c.red, `\u2717 ${msg}`));
4731
- }
4732
- function dim(msg) {
4733
- console.log(color(c.dim, ` ${msg}`));
4734
- }
4735
- function spinner(label) {
4736
- if (!isTTY()) {
4737
- process.stdout.write(` ${label}...
4738
- `);
4739
- return {
4740
- stop(finalMsg) {
4741
- if (finalMsg) process.stdout.write(` ${finalMsg}
4742
- `);
4743
- }
4744
- };
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"
4745
5301
  }
4746
- const frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
4747
- let i = 0;
4748
- const interval = setInterval(() => {
4749
- process.stdout.write(`\r${c.cyan}${frames[i++ % frames.length]}${c.reset} ${label} `);
4750
- }, 80);
4751
- return {
4752
- stop(finalMsg) {
4753
- clearInterval(interval);
4754
- process.stdout.write("\r\x1B[K");
4755
- if (finalMsg) success(finalMsg);
4756
- }
4757
- };
4758
- }
5302
+ };
5303
+
5304
+ // src/lib/cli-version.ts
5305
+ var CLI_VERSION = package_default.version;
4759
5306
 
4760
5307
  // src/commands/login.ts
4761
5308
  async function openBrowser(url) {
@@ -4768,12 +5315,12 @@ async function openBrowser(url) {
4768
5315
  } catch {
4769
5316
  }
4770
5317
  try {
4771
- const { execFile } = await import("node:child_process");
4772
- const { promisify } = await import("node:util");
4773
- const exec = promisify(execFile);
5318
+ const { execFile: execFile2 } = await import("node:child_process");
5319
+ const { promisify: promisify2 } = await import("node:util");
5320
+ const exec2 = promisify2(execFile2);
4774
5321
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
4775
5322
  const args = process.platform === "win32" ? ["/c", "start", url] : [url];
4776
- await exec(cmd, args).catch(() => null);
5323
+ await exec2(cmd, args).catch(() => null);
4777
5324
  } catch {
4778
5325
  }
4779
5326
  }
@@ -4789,7 +5336,12 @@ async function copyToClipboard(text) {
4789
5336
  return false;
4790
5337
  }
4791
5338
  async function loginCommand(opts) {
4792
- const client = new ApiClient({ apiUrl: opts.apiUrl });
5339
+ const deviceId = ensureDeviceId();
5340
+ const client = new ApiClient({
5341
+ apiUrl: opts.apiUrl,
5342
+ deviceId,
5343
+ cliVersion: CLI_VERSION
5344
+ });
4793
5345
  info("Authenticating with Token Rats\u2026");
4794
5346
  let exchange;
4795
5347
  try {
@@ -4837,7 +5389,16 @@ async function loginCommand(opts) {
4837
5389
  process.exit(1);
4838
5390
  }
4839
5391
  saveToken(token);
5392
+ clearDisconnected();
4840
5393
  success("Logged in! Run `token-rats whoami` to verify.");
5394
+ dim(`Device id: ${deviceId}`);
5395
+ if (opts.noDaemon) {
5396
+ info("Skipping background watcher install (--no-daemon).");
5397
+ info("Run `token-rats sync` manually whenever you want to upload usage.");
5398
+ return;
5399
+ }
5400
+ info("Installing background watcher so usage uploads automatically\u2026");
5401
+ await installDaemonCommand(opts.apiUrl);
4841
5402
  }
4842
5403
 
4843
5404
  // src/commands/logout.ts
@@ -4850,9 +5411,9 @@ function logoutCommand() {
4850
5411
  info("Logged out. Your local token has been removed.");
4851
5412
  }
4852
5413
 
4853
- // src/commands/sync.ts
4854
- import * as fs7 from "node:fs";
4855
- import * as readline from "node:readline/promises";
5414
+ // src/lib/collect.ts
5415
+ import { createHash } from "node:crypto";
5416
+ import * as fs4 from "node:fs";
4856
5417
 
4857
5418
  // ../parsers/src/hash.ts
4858
5419
  var FNV_PRIME = 16777619;
@@ -4884,6 +5445,7 @@ function parseClaudeCode(input) {
4884
5445
  text = new TextDecoder().decode(input instanceof ArrayBuffer ? new Uint8Array(input) : input);
4885
5446
  }
4886
5447
  const sessions = /* @__PURE__ */ new Map();
5448
+ const messages = /* @__PURE__ */ new Map();
4887
5449
  for (const rawLine of text.split("\n")) {
4888
5450
  const line = rawLine.trim();
4889
5451
  if (line.length === 0) continue;
@@ -4917,7 +5479,8 @@ function parseClaudeCode(input) {
4917
5479
  outTokens: 0,
4918
5480
  cacheReadTokens: 0,
4919
5481
  cacheWriteTokens: 0,
4920
- model: ""
5482
+ model: "",
5483
+ modelAt: 0
4921
5484
  };
4922
5485
  sessions.set(sessionId, acc);
4923
5486
  }
@@ -4929,16 +5492,32 @@ function parseClaudeCode(input) {
4929
5492
  const message = ev.message;
4930
5493
  if (typeof message !== "object" || message === null) continue;
4931
5494
  const msg = message;
4932
- if (typeof msg.model === "string" && msg.model.length > 0) {
5495
+ if (typeof msg.model === "string" && msg.model.length > 0 && timestamp >= acc.modelAt) {
4933
5496
  acc.model = msg.model;
5497
+ acc.modelAt = timestamp;
4934
5498
  }
4935
5499
  const usage = msg.usage;
4936
5500
  if (typeof usage === "object" && usage !== null) {
4937
5501
  const u = usage;
4938
- acc.inTokens += toNonNegInt(u.input_tokens);
4939
- acc.outTokens += toNonNegInt(u.output_tokens);
4940
- acc.cacheReadTokens += toNonNegInt(u.cache_read_input_tokens);
4941
- 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);
4942
5521
  }
4943
5522
  }
4944
5523
  const results = [];
@@ -4959,6 +5538,8 @@ function parseClaudeCode(input) {
4959
5538
  id: `claude-code:${acc.sessionId}`,
4960
5539
  source: "claude-code",
4961
5540
  provider: "anthropic",
5541
+ client: "claude-code",
5542
+ channel: "cli",
4962
5543
  model,
4963
5544
  inTokens: acc.inTokens,
4964
5545
  outTokens: acc.outTokens,
@@ -5003,10 +5584,16 @@ function parseCodex(input) {
5003
5584
  const payload = typeof ev.payload === "object" && ev.payload !== null ? ev.payload : null;
5004
5585
  if (type === "session_meta" && payload) {
5005
5586
  const id = typeof payload.id === "string" ? payload.id : null;
5006
- if (!id) continue;
5587
+ if (!id) {
5588
+ currentSessionId = null;
5589
+ continue;
5590
+ }
5007
5591
  currentSessionId = id;
5008
5592
  const metaTs = parseTimestamp(payload.timestamp) || ts;
5009
5593
  const acc2 = upsert(sessions, id);
5594
+ const metaSource = typeof payload.source === "string" ? payload.source : null;
5595
+ acc2.channel = metaSource === "api" ? "api" : metaSource ? "cli" : "unknown";
5596
+ acc2.client = acc2.channel === "cli" ? "codex-cli" : "codex";
5010
5597
  if (metaTs > 0 && (acc2.startedAt === 0 || metaTs < acc2.startedAt)) {
5011
5598
  acc2.startedAt = metaTs;
5012
5599
  }
@@ -5020,7 +5607,10 @@ function parseCodex(input) {
5020
5607
  if (ts > acc.endedAt) acc.endedAt = ts;
5021
5608
  }
5022
5609
  if (type === "turn_context" && payload && typeof payload.model === "string") {
5023
- acc.model = payload.model;
5610
+ if (ts >= acc.modelAt) {
5611
+ acc.model = payload.model;
5612
+ acc.modelAt = ts;
5613
+ }
5024
5614
  continue;
5025
5615
  }
5026
5616
  if (type === "event_msg" && payload && payload.type === "token_count") {
@@ -5033,8 +5623,10 @@ function parseCodex(input) {
5033
5623
  const cachedInput = toNonNegInt2(t.cached_input_tokens);
5034
5624
  const output = toNonNegInt2(t.output_tokens);
5035
5625
  const reasoning = toNonNegInt2(t.reasoning_output_tokens);
5626
+ if (ts < acc.usageAt) continue;
5627
+ acc.usageAt = ts;
5036
5628
  acc.inTokens = Math.max(0, inputTotal - cachedInput);
5037
- acc.outTokens = output + reasoning;
5629
+ acc.outTokens = output;
5038
5630
  acc.cacheReadTokens = cachedInput;
5039
5631
  acc.reasoningTokens = reasoning;
5040
5632
  }
@@ -5051,6 +5643,8 @@ function parseCodex(input) {
5051
5643
  id: `codex:${acc.sessionId}`,
5052
5644
  source: "codex",
5053
5645
  provider: "openai",
5646
+ client: acc.client || "codex-cli",
5647
+ channel: acc.channel,
5054
5648
  model,
5055
5649
  inTokens: acc.inTokens,
5056
5650
  outTokens: acc.outTokens,
@@ -5075,7 +5669,11 @@ function upsert(map, sessionId) {
5075
5669
  outTokens: 0,
5076
5670
  cacheReadTokens: 0,
5077
5671
  reasoningTokens: 0,
5078
- model: ""
5672
+ model: "",
5673
+ usageAt: 0,
5674
+ modelAt: 0,
5675
+ client: "codex-cli",
5676
+ channel: "unknown"
5079
5677
  };
5080
5678
  map.set(sessionId, acc);
5081
5679
  }
@@ -5132,6 +5730,8 @@ function parseCursor(input) {
5132
5730
  id: `cursor:${id}`,
5133
5731
  source: "cursor",
5134
5732
  provider: "cursor",
5733
+ client: "cursor",
5734
+ channel: "ide",
5135
5735
  model,
5136
5736
  inTokens,
5137
5737
  outTokens,
@@ -5145,31 +5745,31 @@ function parseCursor(input) {
5145
5745
  }
5146
5746
 
5147
5747
  // src/lib/cursor-extract.ts
5148
- import { existsSync, readdirSync, statSync } from "node:fs";
5748
+ import { existsSync as existsSync3, readdirSync } from "node:fs";
5149
5749
  import { readFile } from "node:fs/promises";
5150
- import { homedir as homedir2 } from "node:os";
5151
- import { join as join2 } from "node:path";
5750
+ import { homedir as homedir3 } from "node:os";
5751
+ import { join as join3 } from "node:path";
5152
5752
  function cursorWorkspaceStorageDir() {
5153
- const home = homedir2();
5154
- const rel = join2("Cursor", "User", "workspaceStorage");
5753
+ const home = homedir3();
5754
+ const rel = join3("Cursor", "User", "workspaceStorage");
5155
5755
  if (process.platform === "darwin") {
5156
- return join2(home, "Library", "Application Support", rel);
5756
+ return join3(home, "Library", "Application Support", rel);
5157
5757
  }
5158
5758
  if (process.platform === "win32") {
5159
- const appData = process.env.APPDATA ?? join2(home, "AppData", "Roaming");
5160
- return join2(appData, rel);
5759
+ const appData = process.env.APPDATA ?? join3(home, "AppData", "Roaming");
5760
+ return join3(appData, rel);
5161
5761
  }
5162
- const xdgConfig = process.env.XDG_CONFIG_HOME ?? join2(home, ".config");
5163
- return join2(xdgConfig, rel);
5762
+ const xdgConfig = process.env.XDG_CONFIG_HOME ?? join3(home, ".config");
5763
+ return join3(xdgConfig, rel);
5164
5764
  }
5165
5765
  function discoverWorkspaceDbs() {
5166
5766
  const root = cursorWorkspaceStorageDir();
5167
- if (!existsSync(root)) return [];
5767
+ if (!existsSync3(root)) return [];
5168
5768
  const out = [];
5169
5769
  for (const entry of readdirSync(root, { withFileTypes: true })) {
5170
5770
  if (!entry.isDirectory()) continue;
5171
- const candidate = join2(root, entry.name, "state.vscdb");
5172
- if (existsSync(candidate)) out.push(candidate);
5771
+ const candidate = join3(root, entry.name, "state.vscdb");
5772
+ if (existsSync3(candidate)) out.push(candidate);
5173
5773
  }
5174
5774
  return out;
5175
5775
  }
@@ -5272,7 +5872,7 @@ async function extractCursorGenerations() {
5272
5872
  dbCount: 0
5273
5873
  };
5274
5874
  }
5275
- const seen2 = /* @__PURE__ */ new Set();
5875
+ const seen = /* @__PURE__ */ new Set();
5276
5876
  const rows = [];
5277
5877
  let openFailures = 0;
5278
5878
  for (const p of dbPaths) {
@@ -5284,8 +5884,8 @@ async function extractCursorGenerations() {
5284
5884
  continue;
5285
5885
  }
5286
5886
  for (const r of perDb) {
5287
- if (seen2.has(r.id)) continue;
5288
- seen2.add(r.id);
5887
+ if (seen.has(r.id)) continue;
5888
+ seen.add(r.id);
5289
5889
  rows.push(r);
5290
5890
  }
5291
5891
  }
@@ -5298,373 +5898,17 @@ async function extractCursorGenerations() {
5298
5898
  }
5299
5899
  return { rows, dbCount: dbPaths.length, skipped: null };
5300
5900
  }
5301
- async function extractCursorGenerationsDelta(lastMtimes, cap = 10) {
5302
- const dbPaths = discoverWorkspaceDbs();
5303
- if (dbPaths.length === 0) {
5304
- return { rows: [], newMtimes: /* @__PURE__ */ new Map(), scanned: 0, opened: 0 };
5305
- }
5306
- const stamped = [];
5307
- for (const p of dbPaths) {
5308
- try {
5309
- stamped.push({ path: p, mtimeMs: statSync(p).mtimeMs });
5310
- } catch {
5311
- }
5312
- }
5313
- stamped.sort((a, b) => b.mtimeMs - a.mtimeMs);
5314
- const head = stamped.slice(0, cap);
5315
- const newMtimes = new Map(lastMtimes);
5316
- const rows = [];
5317
- let opened = 0;
5318
- for (const { path: path7, mtimeMs } of head) {
5319
- const prev = lastMtimes.get(path7) ?? 0;
5320
- if (mtimeMs <= prev) continue;
5321
- opened++;
5322
- try {
5323
- const perDb = await readGenerationsFromDb(path7);
5324
- rows.push(...perDb);
5325
- newMtimes.set(path7, mtimeMs);
5326
- } catch {
5327
- }
5328
- }
5329
- return { rows, newMtimes, scanned: stamped.length, opened };
5330
- }
5331
5901
 
5332
- // src/lib/daemon/linux.ts
5333
- import { spawnSync } from "node:child_process";
5902
+ // src/lib/discover.ts
5334
5903
  import * as fs3 from "node:fs";
5335
5904
  import * as os3 from "node:os";
5336
5905
  import * as path3 from "node:path";
5337
-
5338
- // src/lib/daemon/paths.ts
5339
- import * as fs2 from "node:fs";
5340
- import * as os2 from "node:os";
5341
- import * as path2 from "node:path";
5342
- function configBase() {
5343
- if (process.platform === "darwin") {
5344
- return path2.join(os2.homedir(), "Library", "Application Support");
5345
- }
5346
- const xdg = process.env.XDG_CONFIG_HOME;
5347
- return xdg ?? path2.join(os2.homedir(), ".config");
5348
- }
5349
- function daemonStateDir() {
5350
- return path2.join(configBase(), "token-rats");
5351
- }
5352
- function daemonLogDir() {
5353
- return path2.join(daemonStateDir(), "logs");
5354
- }
5355
- function daemonStateFile() {
5356
- return path2.join(daemonStateDir(), "daemon-state.json");
5357
- }
5358
- function cliEntrypoint() {
5359
- const node = process.execPath;
5360
- const script = fs2.realpathSync(process.argv[1] ?? "");
5361
- return { node, script };
5362
- }
5363
- function entrypointIsEphemeral() {
5364
- const { script } = cliEntrypoint();
5365
- return script.includes("_npx") || script.includes(".npm/_cacache");
5366
- }
5367
- function ensureDaemonDirs() {
5368
- fs2.mkdirSync(daemonStateDir(), { recursive: true });
5369
- fs2.mkdirSync(daemonLogDir(), { recursive: true });
5370
- }
5371
-
5372
- // src/lib/daemon/linux.ts
5373
- var UNIT = "token-rats-watch.service";
5374
- function unitPath() {
5375
- const xdg = process.env.XDG_CONFIG_HOME ?? path3.join(os3.homedir(), ".config");
5376
- return path3.join(xdg, "systemd", "user", UNIT);
5377
- }
5378
- function renderUnit(node, script, logDir) {
5379
- return `[Unit]
5380
- Description=Token Rats background watcher
5381
- After=default.target
5382
-
5383
- [Service]
5384
- Type=simple
5385
- ExecStart=${node} ${script} watch --daemon
5386
- Restart=on-failure
5387
- RestartSec=30
5388
- StandardOutput=append:${path3.join(logDir, "watch.log")}
5389
- StandardError=append:${path3.join(logDir, "watch.err.log")}
5390
- Nice=10
5391
-
5392
- [Install]
5393
- WantedBy=default.target
5394
- `;
5395
- }
5396
- function systemctl(args) {
5397
- const r = spawnSync("systemctl", ["--user", ...args], { encoding: "utf8" });
5398
- return {
5399
- ok: r.status === 0,
5400
- stdout: r.stdout ?? "",
5401
- stderr: r.stderr ?? ""
5402
- };
5403
- }
5404
- var SystemdUnavailableError = class extends Error {
5405
- constructor() {
5406
- super("systemctl --user is unavailable on this system");
5407
- this.name = "SystemdUnavailableError";
5408
- }
5409
- };
5410
- function assertSystemd() {
5411
- const r = spawnSync("systemctl", ["--user", "--version"], { encoding: "utf8" });
5412
- if (r.status !== 0) throw new SystemdUnavailableError();
5413
- }
5414
- function lingerEnabled() {
5415
- const user = os3.userInfo().username;
5416
- const r = spawnSync("loginctl", ["show-user", user], { encoding: "utf8" });
5417
- if (r.status !== 0) return false;
5418
- return /Linger=yes/.test(r.stdout);
5419
- }
5420
- function installLinux(node, script) {
5421
- assertSystemd();
5422
- ensureDaemonDirs();
5423
- const file = unitPath();
5424
- fs3.mkdirSync(path3.dirname(file), { recursive: true });
5425
- const logDir = daemonLogDir();
5426
- fs3.writeFileSync(file, renderUnit(node, script, logDir), { encoding: "utf8", mode: 420 });
5427
- const reload = systemctl(["daemon-reload"]);
5428
- if (!reload.ok) {
5429
- throw new Error(`systemctl daemon-reload failed: ${reload.stderr.trim()}`);
5430
- }
5431
- const enable = systemctl(["enable", "--now", UNIT]);
5432
- if (!enable.ok) {
5433
- throw new Error(`systemctl enable --now failed: ${enable.stderr.trim()}`);
5434
- }
5435
- return { unit: file, logDir };
5436
- }
5437
- function uninstallLinux() {
5438
- systemctl(["disable", "--now", UNIT]);
5439
- try {
5440
- fs3.rmSync(unitPath());
5441
- } catch {
5442
- }
5443
- systemctl(["daemon-reload"]);
5444
- }
5445
- function statusLinux() {
5446
- const installed = fs3.existsSync(unitPath());
5447
- if (!installed) return { installed: false, running: false };
5448
- const r = systemctl(["show", UNIT, "--property=ActiveState,MainPID"]);
5449
- const active = /ActiveState=active/.test(r.stdout);
5450
- const pidMatch = r.stdout.match(/MainPID=(\d+)/);
5451
- const pid = pidMatch?.[1] ? Number(pidMatch[1]) : void 0;
5452
- return {
5453
- installed: true,
5454
- running: active && pid !== void 0 && pid > 0,
5455
- ...pid && pid > 0 ? { pid } : {}
5456
- };
5457
- }
5458
-
5459
- // src/lib/daemon/macos.ts
5460
- import { spawnSync as spawnSync2 } from "node:child_process";
5461
- import * as fs4 from "node:fs";
5462
- import * as os4 from "node:os";
5463
- import * as path4 from "node:path";
5464
- var LABEL = "com.tokenrats.watch";
5465
- function plistPath() {
5466
- return path4.join(os4.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
5467
- }
5468
- function xmlEscape(s) {
5469
- return s.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
5470
- }
5471
- function renderPlist(node, script, logDir) {
5472
- const stdout = xmlEscape(path4.join(logDir, "watch.log"));
5473
- const stderr = xmlEscape(path4.join(logDir, "watch.err.log"));
5474
- return `<?xml version="1.0" encoding="UTF-8"?>
5475
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
5476
- <plist version="1.0">
5477
- <dict>
5478
- <key>Label</key><string>${LABEL}</string>
5479
- <key>ProgramArguments</key>
5480
- <array>
5481
- <string>${xmlEscape(node)}</string>
5482
- <string>${xmlEscape(script)}</string>
5483
- <string>watch</string>
5484
- <string>--daemon</string>
5485
- </array>
5486
- <key>RunAtLoad</key><true/>
5487
- <key>KeepAlive</key>
5488
- <dict>
5489
- <key>SuccessfulExit</key><false/>
5490
- <key>Crashed</key><true/>
5491
- </dict>
5492
- <key>ThrottleInterval</key><integer>30</integer>
5493
- <key>StandardOutPath</key><string>${stdout}</string>
5494
- <key>StandardErrorPath</key><string>${stderr}</string>
5495
- <key>ProcessType</key><string>Background</string>
5496
- </dict>
5497
- </plist>
5498
- `;
5499
- }
5500
- function uid() {
5501
- const fn = process.getuid;
5502
- if (typeof fn !== "function") throw new Error("process.getuid unavailable");
5503
- return fn();
5504
- }
5505
- function launchctl(args) {
5506
- const r = spawnSync2("launchctl", args, { encoding: "utf8" });
5507
- return {
5508
- ok: r.status === 0,
5509
- stdout: r.stdout ?? "",
5510
- stderr: r.stderr ?? ""
5511
- };
5512
- }
5513
- function installMacos(node, script) {
5514
- ensureDaemonDirs();
5515
- const dir = path4.dirname(plistPath());
5516
- fs4.mkdirSync(dir, { recursive: true });
5517
- const logDir = daemonLogDir();
5518
- const file = plistPath();
5519
- fs4.writeFileSync(file, renderPlist(node, script, logDir), { encoding: "utf8", mode: 420 });
5520
- const target = `gui/${uid()}`;
5521
- launchctl(["bootout", target, file]);
5522
- const r = launchctl(["bootstrap", target, file]);
5523
- if (!r.ok) {
5524
- throw new Error(`launchctl bootstrap failed: ${r.stderr.trim() || r.stdout.trim()}`);
5525
- }
5526
- launchctl(["kickstart", "-k", `${target}/${LABEL}`]);
5527
- return { plist: file, logDir };
5528
- }
5529
- function uninstallMacos() {
5530
- const file = plistPath();
5531
- const target = `gui/${uid()}`;
5532
- launchctl(["bootout", target, file]);
5533
- try {
5534
- fs4.rmSync(file);
5535
- } catch {
5536
- }
5537
- }
5538
- function statusMacos() {
5539
- const file = plistPath();
5540
- const installed = fs4.existsSync(file);
5541
- if (!installed) return { installed: false, running: false };
5542
- const r = launchctl(["print", `gui/${uid()}/${LABEL}`]);
5543
- const match = r.stdout.match(/\bpid\s*=\s*(\d+)/);
5544
- if (match?.[1]) return { installed: true, running: true, pid: Number(match[1]) };
5545
- return { installed: true, running: false };
5546
- }
5547
-
5548
- // src/lib/daemon/state.ts
5549
- import * as fs5 from "node:fs";
5550
- function readDaemonState() {
5551
- try {
5552
- const raw = fs5.readFileSync(daemonStateFile(), "utf8");
5553
- const parsed = JSON.parse(raw);
5554
- return typeof parsed === "object" && parsed !== null ? parsed : {};
5555
- } catch {
5556
- return {};
5557
- }
5558
- }
5559
- function writeDaemonState(state) {
5560
- ensureDaemonDirs();
5561
- fs5.writeFileSync(daemonStateFile(), `${JSON.stringify(state, null, 2)}
5562
- `, {
5563
- encoding: "utf8",
5564
- mode: 384
5565
- });
5566
- }
5567
- function markDeclined() {
5568
- const s = readDaemonState();
5569
- s.declinedAt = (/* @__PURE__ */ new Date()).toISOString();
5570
- writeDaemonState(s);
5571
- }
5572
- function markInstalled(method, script, version) {
5573
- const s = readDaemonState();
5574
- s.installedAt = (/* @__PURE__ */ new Date()).toISOString();
5575
- s.installedScript = script;
5576
- s.method = method;
5577
- s.cliVersion = version;
5578
- s.declinedAt = void 0;
5579
- writeDaemonState(s);
5580
- }
5581
- function markUninstalled() {
5582
- const s = readDaemonState();
5583
- s.method = "none";
5584
- s.installedAt = void 0;
5585
- s.installedScript = void 0;
5586
- writeDaemonState(s);
5587
- }
5588
-
5589
- // src/lib/daemon/install.ts
5590
- function platformMethod() {
5591
- if (process.platform === "darwin") return "launchd";
5592
- if (process.platform === "linux") return "systemd";
5593
- return "unsupported";
5594
- }
5595
- function isDaemonSupported() {
5596
- return platformMethod() !== "unsupported";
5597
- }
5598
- var DaemonUnsupportedError = class extends Error {
5599
- constructor(platform) {
5600
- super(`Background daemon is not yet supported on ${platform}`);
5601
- this.name = "DaemonUnsupportedError";
5602
- }
5603
- };
5604
- async function installDaemon(version) {
5605
- const method = platformMethod();
5606
- const { node, script } = cliEntrypoint();
5607
- const ephemeralWarning = entrypointIsEphemeral();
5608
- if (method === "launchd") {
5609
- const r = installMacos(node, script);
5610
- markInstalled("launchd", script, version);
5611
- return { method: "launchd", logDir: r.logDir, ephemeralWarning };
5612
- }
5613
- if (method === "systemd") {
5614
- try {
5615
- const r = installLinux(node, script);
5616
- markInstalled("systemd", script, version);
5617
- return {
5618
- method: "systemd",
5619
- logDir: r.logDir,
5620
- ephemeralWarning,
5621
- lingerSet: lingerEnabled()
5622
- };
5623
- } catch (err) {
5624
- if (err instanceof SystemdUnavailableError) {
5625
- throw new DaemonUnsupportedError(process.platform);
5626
- }
5627
- throw err;
5628
- }
5629
- }
5630
- throw new DaemonUnsupportedError(process.platform);
5631
- }
5632
- async function uninstallDaemon() {
5633
- const method = platformMethod();
5634
- if (method === "launchd") uninstallMacos();
5635
- else if (method === "systemd") uninstallLinux();
5636
- markUninstalled();
5637
- }
5638
- async function daemonStatus() {
5639
- const method = platformMethod();
5640
- const state = readDaemonState();
5641
- const base = {
5642
- method,
5643
- installedScript: state.installedScript,
5644
- installedAt: state.installedAt,
5645
- declinedAt: state.declinedAt
5646
- };
5647
- if (method === "launchd") {
5648
- const s = statusMacos();
5649
- return { ...base, installed: s.installed, running: s.running, ...s.pid && { pid: s.pid } };
5650
- }
5651
- if (method === "systemd") {
5652
- const s = statusLinux();
5653
- return { ...base, installed: s.installed, running: s.running, ...s.pid && { pid: s.pid } };
5654
- }
5655
- return { ...base, installed: false, running: false };
5656
- }
5657
-
5658
- // src/lib/discover.ts
5659
- import * as fs6 from "node:fs";
5660
- import * as os5 from "node:os";
5661
- import * as path5 from "node:path";
5662
5906
  function findJsonlFiles(dir) {
5663
5907
  const results = [];
5664
- if (!fs6.existsSync(dir)) return results;
5665
- const entries = fs6.readdirSync(dir, { withFileTypes: true });
5908
+ if (!fs3.existsSync(dir)) return results;
5909
+ const entries = fs3.readdirSync(dir, { withFileTypes: true });
5666
5910
  for (const entry of entries) {
5667
- const full = path5.join(dir, entry.name);
5911
+ const full = path3.join(dir, entry.name);
5668
5912
  if (entry.isDirectory()) {
5669
5913
  results.push(...findJsonlFiles(full));
5670
5914
  } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
@@ -5674,43 +5918,50 @@ function findJsonlFiles(dir) {
5674
5918
  return results;
5675
5919
  }
5676
5920
  function claudeCodeProjectsDir() {
5677
- const home = os5.homedir();
5921
+ const home = os3.homedir();
5922
+ if (process.env.CLAUDE_CONFIG_DIR) return path3.join(process.env.CLAUDE_CONFIG_DIR, "projects");
5678
5923
  if (process.platform === "win32") {
5679
5924
  const profile = process.env.USERPROFILE ?? home;
5680
- return path5.join(profile, ".claude", "projects");
5925
+ return path3.join(profile, ".claude", "projects");
5681
5926
  }
5682
- return path5.join(home, ".claude", "projects");
5927
+ return path3.join(home, ".claude", "projects");
5683
5928
  }
5684
5929
  function discoverClaudeCodeFiles() {
5685
5930
  const dir = claudeCodeProjectsDir();
5686
5931
  return findJsonlFiles(dir);
5687
5932
  }
5688
5933
  function codexSessionsDirs() {
5689
- const home = os5.homedir();
5934
+ const home = os3.homedir();
5690
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
+ }
5691
5940
  if (process.platform === "win32") {
5692
5941
  const profile = process.env.USERPROFILE ?? home;
5693
- candidates.push(path5.join(profile, ".codex", "sessions"));
5694
- const appData = process.env.APPDATA ?? path5.join(profile, "AppData", "Roaming");
5695
- candidates.push(path5.join(appData, "Codex", "sessions"));
5942
+ candidates.push(path3.join(profile, ".codex", "sessions"));
5943
+ candidates.push(path3.join(profile, ".codex", "archived_sessions"));
5944
+ const appData = process.env.APPDATA ?? path3.join(profile, "AppData", "Roaming");
5945
+ candidates.push(path3.join(appData, "Codex", "sessions"));
5696
5946
  } else {
5697
- candidates.push(path5.join(home, ".codex", "sessions"));
5698
- const snapRoot = path5.join(home, "snap", "codex");
5699
- if (fs6.existsSync(snapRoot)) {
5700
- for (const entry of fs6.readdirSync(snapRoot, { withFileTypes: true })) {
5947
+ candidates.push(path3.join(home, ".codex", "sessions"));
5948
+ candidates.push(path3.join(home, ".codex", "archived_sessions"));
5949
+ const snapRoot = path3.join(home, "snap", "codex");
5950
+ if (fs3.existsSync(snapRoot)) {
5951
+ for (const entry of fs3.readdirSync(snapRoot, { withFileTypes: true })) {
5701
5952
  if (entry.isDirectory()) {
5702
- candidates.push(path5.join(snapRoot, entry.name, "sessions"));
5953
+ candidates.push(path3.join(snapRoot, entry.name, "sessions"));
5703
5954
  }
5704
5955
  }
5705
5956
  }
5706
5957
  }
5707
- const seen2 = /* @__PURE__ */ new Set();
5958
+ const seen = /* @__PURE__ */ new Set();
5708
5959
  const dirs = [];
5709
5960
  for (const c2 of candidates) {
5710
5961
  try {
5711
- const real = fs6.existsSync(c2) ? fs6.realpathSync(c2) : null;
5712
- if (real && !seen2.has(real)) {
5713
- seen2.add(real);
5962
+ const real = fs3.existsSync(c2) ? fs3.realpathSync(c2) : null;
5963
+ if (real && !seen.has(real)) {
5964
+ seen.add(real);
5714
5965
  dirs.push(c2);
5715
5966
  }
5716
5967
  } catch {
@@ -5726,11 +5977,126 @@ function discoverCodexFiles() {
5726
5977
  return out;
5727
5978
  }
5728
5979
 
5729
- // src/lib/version.ts
5730
- var CLI_VERSION = "0.1.0";
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
+ }
5731
6045
 
5732
6046
  // src/commands/sync.ts
5733
- 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
+ }
5734
6100
  function chunk(arr, size) {
5735
6101
  const chunks = [];
5736
6102
  for (let i = 0; i < arr.length; i += size) {
@@ -5738,127 +6104,20 @@ function chunk(arr, size) {
5738
6104
  }
5739
6105
  return chunks;
5740
6106
  }
5741
- function mergeBySessionId(records) {
5742
- const byId = /* @__PURE__ */ new Map();
5743
- for (const r of records) {
5744
- const existing = byId.get(r.id);
5745
- if (!existing) {
5746
- byId.set(r.id, { ...r });
5747
- continue;
5748
- }
5749
- existing.inTokens += r.inTokens;
5750
- existing.outTokens += r.outTokens;
5751
- if (r.startedAt < existing.startedAt) existing.startedAt = r.startedAt;
5752
- if (r.endedAt > existing.endedAt) {
5753
- existing.endedAt = r.endedAt;
5754
- existing.model = r.model;
5755
- }
5756
- }
5757
- for (const rec of byId.values()) {
5758
- rec.costUsdCents = 0;
5759
- rec.dedupeKey = computeDedupeKey(
5760
- rec.source,
5761
- rec.model,
5762
- rec.startedAt,
5763
- rec.inTokens,
5764
- rec.outTokens
5765
- );
5766
- }
5767
- return Array.from(byId.values());
5768
- }
5769
6107
  async function syncCommand(opts) {
5770
6108
  const token = loadToken();
5771
6109
  if (!token && !opts.dryRun) {
5772
6110
  error("Not logged in. Run `token-rats login` first.");
5773
6111
  process.exit(1);
5774
6112
  }
5775
- const client = opts.dryRun ? null : new ApiClient({ apiUrl: opts.apiUrl, token: token ?? void 0 });
5776
- const claudeFiles = discoverClaudeCodeFiles();
5777
- if (opts.verbose) {
5778
- info(`Found ${claudeFiles.length} Claude Code file(s) in ~/.claude/projects/`);
5779
- for (const f of claudeFiles) dim(` ${f}`);
5780
- }
5781
- const claudeSessions = [];
5782
- for (const file of claudeFiles) {
5783
- let text;
5784
- try {
5785
- text = fs7.readFileSync(file, "utf8");
5786
- } catch {
5787
- if (opts.verbose) warn(`Could not read ${file} \u2014 skipping`);
5788
- continue;
5789
- }
5790
- try {
5791
- const records = parseClaudeCode(text);
5792
- claudeSessions.push(...records);
5793
- if (opts.verbose) dim(` ${file}: ${records.length} session(s)`);
5794
- } catch {
5795
- if (opts.verbose) warn(`Failed to parse ${file} \u2014 skipping`);
5796
- }
5797
- }
5798
- const codexFiles = discoverCodexFiles();
5799
- if (opts.verbose) {
5800
- info(`Found ${codexFiles.length} Codex rollout file(s)`);
5801
- for (const f of codexFiles) dim(` ${f}`);
5802
- }
5803
- const codexSessions = [];
5804
- for (const file of codexFiles) {
5805
- let text;
5806
- try {
5807
- text = fs7.readFileSync(file, "utf8");
5808
- } catch {
5809
- if (opts.verbose) warn(`Could not read ${file} \u2014 skipping`);
5810
- continue;
5811
- }
5812
- try {
5813
- const records = parseCodex(text);
5814
- codexSessions.push(...records);
5815
- if (opts.verbose) dim(` ${file}: ${records.length} session(s)`);
5816
- } catch {
5817
- if (opts.verbose) warn(`Failed to parse ${file} \u2014 skipping`);
5818
- }
5819
- }
5820
- const cursorSessions = [];
5821
- const { rows, skipped, dbCount } = await extractCursorGenerations();
5822
- if (skipped) {
5823
- warn(skipped);
5824
- } else if (dbCount === 0) {
5825
- if (opts.verbose) info("No Cursor workspace storage found \u2014 skipping Cursor source");
5826
- } else if (rows.length > 0) {
5827
- if (opts.verbose) info(`Scanned ${dbCount} Cursor workspace DB(s)`);
5828
- try {
5829
- const records = parseCursor(JSON.stringify(rows));
5830
- cursorSessions.push(...records);
5831
- if (opts.verbose) {
5832
- dim(
5833
- ` Cursor: ${rows.length} generation event(s) \u2192 ${records.length} session(s) (tokens estimated, see help)`
5834
- );
5835
- }
5836
- } catch {
5837
- if (opts.verbose) warn("Failed to parse Cursor rows \u2014 skipping");
5838
- }
5839
- } else if (opts.verbose) {
5840
- dim(` Scanned ${dbCount} Cursor workspace DB(s): no AI generations found`);
5841
- }
5842
- const mergedClaude = mergeBySessionId(claudeSessions);
5843
- if (opts.verbose && mergedClaude.length !== claudeSessions.length) {
5844
- dim(
5845
- ` Merged ${claudeSessions.length} Claude Code records into ${mergedClaude.length} sessions (subagent files folded into parents)`
5846
- );
5847
- }
5848
- const mergedCodex = mergeBySessionId(codexSessions);
5849
- const seen2 = /* @__PURE__ */ new Set();
5850
- const allSessions = [];
5851
- for (const s of [...mergedClaude, ...mergedCodex, ...cursorSessions]) {
5852
- if (!seen2.has(s.dedupeKey)) {
5853
- seen2.add(s.dedupeKey);
5854
- allSessions.push(s);
5855
- }
5856
- }
5857
- const sources = [];
5858
- if (claudeSessions.length > 0) sources.push("Claude Code");
5859
- if (codexSessions.length > 0) sources.push("Codex");
5860
- if (cursorSessions.length > 0) sources.push("Cursor");
5861
- const sourceStr = sources.length > 0 ? sources.join(" + ") : "no sources";
6113
+ const client = opts.dryRun ? null : new ApiClient({
6114
+ apiUrl: opts.apiUrl,
6115
+ token: token ?? void 0,
6116
+ deviceId: ensureDeviceId(),
6117
+ cliVersion: CLI_VERSION
6118
+ });
6119
+ const allSessions = await collectSessions();
6120
+ const sourceStr = [...new Set(allSessions.map((record) => record.source))].join(" + ") || "no sources";
5862
6121
  if (allSessions.length === 0) {
5863
6122
  info(`No sessions found from ${sourceStr}.`);
5864
6123
  return;
@@ -5890,6 +6149,14 @@ async function syncCommand(opts) {
5890
6149
  totalDuplicates += res.duplicates;
5891
6150
  } catch (err) {
5892
6151
  spin.stop();
6152
+ if (err instanceof DeviceRevokedError) {
6153
+ markDisconnected();
6154
+ deleteToken();
6155
+ error(
6156
+ "This device was disconnected from the Token Rats web UI. Run `token-rats login` to reconnect."
6157
+ );
6158
+ process.exit(1);
6159
+ }
5893
6160
  if (err instanceof ApiError2 && err.status === 401) {
5894
6161
  error("Session expired. Run `token-rats login` to re-authenticate.");
5895
6162
  process.exit(1);
@@ -5904,459 +6171,80 @@ async function syncCommand(opts) {
5904
6171
  success(
5905
6172
  `Synced ${allSessions.length} sessions (${totalAccepted} new, ${totalDuplicates} already on server) from ${sourceStr}`
5906
6173
  );
5907
- await maybeWireDaemon(opts);
5908
- }
5909
- async function maybeWireDaemon(opts) {
5910
- if (opts.noDaemon) return;
5911
- if (process.env.TOKEN_RATS_NO_DAEMON === "1") return;
5912
- if (!isDaemonSupported()) return;
5913
- const status = await daemonStatus();
5914
- const state = readDaemonState();
5915
- if (status.installed) {
5916
- try {
5917
- await installDaemon(CLI_VERSION);
5918
- if (opts.verbose) dim("Refreshed daemon registration (self-heal).");
5919
- } catch (err) {
5920
- warn(`Could not refresh daemon: ${err instanceof Error ? err.message : String(err)}`);
5921
- }
5922
- return;
5923
- }
5924
- if (state.declinedAt) {
5925
- if (opts.verbose) dim(`Daemon prompt skipped \u2014 declined on ${state.declinedAt}`);
5926
- return;
5927
- }
5928
- if (!process.stdin.isTTY || !process.stdout.isTTY) return;
5929
- info("");
5930
- info("Background sync watches Claude Code + Cursor + Codex and uploads new");
5931
- info("sessions automatically \u2014 no more re-running `token-rats sync`.");
5932
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
5933
- let answer;
5934
- try {
5935
- answer = (await rl.question("Install background sync? [Y/n] ")).trim().toLowerCase();
5936
- } finally {
5937
- rl.close();
5938
- }
5939
- if (answer === "n" || answer === "no") {
5940
- markDeclined();
5941
- info("Skipped. Enable later with `token-rats watch --install`.");
5942
- return;
5943
- }
5944
6174
  try {
5945
- const r = await installDaemon(CLI_VERSION);
5946
- success(`Background sync enabled (${r.method}). Logs: ${r.logDir}/watch.log`);
5947
- info("Disable any time with `token-rats watch --uninstall`.");
5948
- if (r.ephemeralWarning) {
5949
- warn(
5950
- "You ran sync via npx \u2014 the daemon may break when the npx cache clears.\nRun `npm i -g token-rats` for a durable install."
5951
- );
5952
- }
5953
- if (r.method === "systemd" && r.lingerSet === false) {
5954
- warn(
5955
- "For the watcher to survive logout, run once:\n sudo loginctl enable-linger $USER"
5956
- );
5957
- }
5958
- } catch (err) {
5959
- error(`Install failed: ${err instanceof Error ? err.message : String(err)}`);
5960
- info("You can retry later with `token-rats watch --install`.");
6175
+ await printSyncHero(client);
6176
+ } catch {
5961
6177
  }
5962
6178
  }
5963
6179
 
5964
6180
  // src/commands/watch.ts
5965
- import * as fs8 from "node:fs";
5966
- import * as path6 from "node:path";
5967
- var CURSOR_POLL_MS = 9e4;
5968
- var CURSOR_DB_CAP = 10;
5969
- var REAUTH_BACKOFF_MS = 10 * 60 * 1e3;
5970
- function makeLogger(daemon, verbose) {
5971
- if (daemon) {
5972
- const emit = (level, msg, extra) => {
5973
- const line = JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), level, msg, ...extra });
5974
- process.stderr.write(`${line}
5975
- `);
5976
- };
5977
- return {
5978
- info: (m, e) => emit("info", m, e),
5979
- warn: (m, e) => emit("warn", m, e),
5980
- error: (m, e) => emit("error", m, e),
5981
- debug: (m, e) => {
5982
- if (verbose) emit("debug", m, e);
5983
- }
5984
- };
5985
- }
5986
- return {
5987
- info: (m) => info(m),
5988
- warn: (m) => warn(m),
5989
- error: (m) => error(m),
5990
- debug: (m) => {
5991
- if (verbose) dim(m);
5992
- }
5993
- };
5994
- }
5995
- var seen = /* @__PURE__ */ new Set();
5996
- var reauthBackoffUntil = 0;
5997
- var health = {
5998
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
5999
- uploaded: 0,
6000
- duplicates: 0,
6001
- errors: 0,
6002
- lastSourceAt: {}
6003
- };
6004
- async function upload(client, records, log, daemon) {
6005
- if (records.length === 0) return;
6006
- if (Date.now() < reauthBackoffUntil) {
6007
- log.debug("Skipping upload \u2014 in reauth backoff", { records: records.length });
6181
+ async function watchCommand(opts) {
6182
+ const token = loadToken();
6183
+ if (!token || isDisconnected()) {
6184
+ error("Run `token-rats login` before you start the tracker.");
6185
+ process.exitCode = 1;
6008
6186
  return;
6009
6187
  }
6010
- try {
6011
- const res = await client.uploadSessions(records);
6012
- health.uploaded += res.accepted;
6013
- health.duplicates += res.duplicates;
6014
- log.info("upload", { accepted: res.accepted, duplicates: res.duplicates });
6015
- } catch (err) {
6016
- health.errors++;
6017
- if (err instanceof ApiError2 && err.status === 401) {
6018
- if (daemon) {
6019
- reauthBackoffUntil = Date.now() + REAUTH_BACKOFF_MS;
6020
- log.error("Auth expired. Run `token-rats login` to recover.", {
6021
- backoffUntil: new Date(reauthBackoffUntil).toISOString()
6022
- });
6023
- return;
6024
- }
6025
- error("Session expired. Run `token-rats login` to re-authenticate.");
6026
- process.exit(1);
6027
- }
6028
- log.warn(`Upload failed: ${err instanceof Error ? err.message : String(err)}`);
6029
- }
6030
- }
6031
- function freshClaudeRecords(filePath, log) {
6032
- let text;
6033
- try {
6034
- text = fs8.readFileSync(filePath, "utf8");
6035
- } catch {
6036
- log.debug(`Could not read ${filePath} \u2014 skipping`);
6037
- return [];
6038
- }
6039
- let records;
6040
- try {
6041
- records = parseClaudeCode(text);
6042
- } catch {
6043
- log.debug(`Failed to parse ${filePath} \u2014 skipping`);
6044
- return [];
6045
- }
6046
- const fresh = [];
6047
- for (const r of records) {
6048
- if (seen.has(r.dedupeKey)) continue;
6049
- seen.add(r.dedupeKey);
6050
- fresh.push(r);
6051
- }
6052
- if (fresh.length > 0) {
6053
- health.lastSourceAt["claude-code"] = (/* @__PURE__ */ new Date()).toISOString();
6054
- log.debug(`${filePath}: ${fresh.length} new Claude Code session(s)`);
6055
- }
6056
- return fresh;
6057
- }
6058
- function freshCodexRecords(filePath, log) {
6059
- let text;
6060
- try {
6061
- text = fs8.readFileSync(filePath, "utf8");
6062
- } catch {
6063
- return [];
6064
- }
6065
- let records;
6066
- try {
6067
- records = parseCodex(text);
6068
- } catch {
6069
- return [];
6070
- }
6071
- const fresh = [];
6072
- for (const r of records) {
6073
- if (seen.has(r.dedupeKey)) continue;
6074
- seen.add(r.dedupeKey);
6075
- fresh.push(r);
6076
- }
6077
- if (fresh.length > 0) {
6078
- health.lastSourceAt.codex = (/* @__PURE__ */ new Date()).toISOString();
6079
- log.debug(`${filePath}: ${fresh.length} new Codex session(s)`);
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;
6080
6193
  }
6081
- return fresh;
6082
- }
6083
- function makeDebounced(fn, ms) {
6084
- const timers = /* @__PURE__ */ new Map();
6085
- return (p) => {
6086
- const existing = timers.get(p);
6087
- if (existing) clearTimeout(existing);
6088
- timers.set(
6089
- p,
6090
- setTimeout(() => {
6091
- timers.delete(p);
6092
- fn(p);
6093
- }, ms)
6094
- );
6194
+ const client = new ApiClient({
6195
+ apiUrl: opts.apiUrl,
6196
+ token,
6197
+ deviceId: ensureDeviceId(),
6198
+ cliVersion: CLI_VERSION
6199
+ });
6200
+ const queue = new SyncQueue();
6201
+ const collect = createCollector();
6202
+ let stopped = false;
6203
+ let wake;
6204
+ const stop = () => {
6205
+ stopped = true;
6206
+ wake?.();
6095
6207
  };
6096
- }
6097
- function findJsonlFiles2(dir) {
6098
- const out = [];
6099
- if (!fs8.existsSync(dir)) return out;
6100
- for (const entry of fs8.readdirSync(dir, { withFileTypes: true })) {
6101
- const full = path6.join(dir, entry.name);
6102
- if (entry.isDirectory()) out.push(...findJsonlFiles2(full));
6103
- else if (entry.isFile() && entry.name.endsWith(".jsonl")) out.push(full);
6104
- }
6105
- return out;
6106
- }
6107
- async function watchJsonlDir(dir, debounceMs, onChange, log) {
6108
- if (!fs8.existsSync(dir)) {
6109
- log.debug(`Directory missing \u2014 not watching: ${dir}`);
6110
- return () => {
6111
- };
6112
- }
6113
- const debouncedChange = makeDebounced((p) => {
6114
- if (p.endsWith(".jsonl")) onChange(p);
6115
- }, debounceMs);
6208
+ process.once("SIGINT", stop);
6209
+ process.once("SIGTERM", stop);
6210
+ info("Tracking Claude Code, Codex, and Cursor. Press Ctrl-C to stop.");
6116
6211
  try {
6117
- const dynImport = new Function("m", "return import(m)");
6118
- const chokidarMod = await dynImport("chokidar");
6119
- const watcher = chokidarMod.watch(`${dir}/**/*.jsonl`, {
6120
- ignoreInitial: true,
6121
- persistent: true,
6122
- awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 }
6123
- });
6124
- watcher.on("add", (p) => {
6125
- if (typeof p === "string") debouncedChange(p);
6126
- });
6127
- watcher.on("change", (p) => {
6128
- if (typeof p === "string") debouncedChange(p);
6129
- });
6130
- return () => {
6131
- watcher.close();
6132
- };
6133
- } catch {
6134
- }
6135
- if (process.platform === "linux") {
6136
- let lastMtimes = /* @__PURE__ */ new Map();
6137
- for (const f of findJsonlFiles2(dir)) {
6212
+ while (!stopped) {
6138
6213
  try {
6139
- lastMtimes.set(f, fs8.statSync(f).mtimeMs);
6140
- } catch {
6141
- }
6142
- }
6143
- const handle = setInterval(() => {
6144
- const current = findJsonlFiles2(dir);
6145
- for (const f of current) {
6146
- try {
6147
- const mtime = fs8.statSync(f).mtimeMs;
6148
- const prev = lastMtimes.get(f) ?? 0;
6149
- if (mtime > prev) {
6150
- lastMtimes.set(f, mtime);
6151
- debouncedChange(f);
6152
- }
6153
- } catch {
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;
6154
6224
  }
6155
- }
6156
- for (const f of current) {
6157
- if (!lastMtimes.has(f)) {
6158
- lastMtimes.set(f, Date.now());
6159
- debouncedChange(f);
6225
+ if (err instanceof ApiError2 && err.status === 401) {
6226
+ error("Session expired. Run `token-rats login` again.");
6227
+ process.exitCode = 1;
6228
+ break;
6160
6229
  }
6230
+ warn(
6231
+ `Sync failed. The next scan will retry: ${err instanceof Error ? err.message : String(err)}`
6232
+ );
6161
6233
  }
6162
- lastMtimes = new Map(current.map((f) => [f, lastMtimes.get(f) ?? 0]));
6163
- }, debounceMs);
6164
- return () => clearInterval(handle);
6165
- }
6166
- const controller = new AbortController();
6167
- (async () => {
6168
- try {
6169
- const { watch } = await import("node:fs/promises");
6170
- const watcher = watch(dir, { recursive: true, signal: controller.signal });
6171
- for await (const event of watcher) {
6172
- if (event.filename?.endsWith(".jsonl")) {
6173
- debouncedChange(path6.join(dir, event.filename));
6174
- }
6175
- }
6176
- } catch (err) {
6177
- if (err instanceof Error && err.name !== "AbortError") {
6178
- log.warn(`Watcher error on ${dir}: ${err.message}`);
6179
- }
6180
- }
6181
- })();
6182
- return () => controller.abort();
6183
- }
6184
- function startCursorLoop(client, log, daemon) {
6185
- let lastMtimes = /* @__PURE__ */ new Map();
6186
- let running = false;
6187
- let stopped = false;
6188
- const tick = async () => {
6189
- if (running || stopped) return;
6190
- running = true;
6191
- try {
6192
- const { rows, newMtimes, scanned, opened } = await extractCursorGenerationsDelta(
6193
- lastMtimes,
6194
- CURSOR_DB_CAP
6195
- );
6196
- lastMtimes = newMtimes;
6197
- if (rows.length === 0) {
6198
- if (opened > 0) log.debug(`Cursor poll: ${scanned} DB(s), opened ${opened}, no new rows`);
6199
- return;
6200
- }
6201
- let records;
6202
- try {
6203
- records = parseCursor(JSON.stringify(rows));
6204
- } catch {
6205
- log.warn("Failed to parse Cursor rows");
6206
- return;
6207
- }
6208
- const fresh = [];
6209
- for (const r of records) {
6210
- if (seen.has(r.dedupeKey)) continue;
6211
- seen.add(r.dedupeKey);
6212
- fresh.push(r);
6213
- }
6214
- if (fresh.length === 0) {
6215
- log.debug(`Cursor poll: ${rows.length} row(s), all duplicates`);
6216
- return;
6217
- }
6218
- health.lastSourceAt.cursor = (/* @__PURE__ */ new Date()).toISOString();
6219
- log.debug(
6220
- `Cursor poll: ${fresh.length} fresh session(s) (scanned ${scanned}, opened ${opened})`
6221
- );
6222
- await upload(client, fresh, log, daemon);
6223
- } finally {
6224
- running = false;
6225
- }
6226
- };
6227
- const initial = setTimeout(tick, 5e3);
6228
- const handle = setInterval(tick, CURSOR_POLL_MS);
6229
- return () => {
6230
- stopped = true;
6231
- clearTimeout(initial);
6232
- clearInterval(handle);
6233
- };
6234
- }
6235
- async function runWatcher(opts) {
6236
- const daemon = opts.daemon === true;
6237
- const verbose = opts.verbose === true;
6238
- const log = makeLogger(daemon, verbose);
6239
- const token = loadToken();
6240
- if (!token) {
6241
- log.error("Not logged in. Run `token-rats login` first.");
6242
- if (!daemon) process.exit(1);
6243
- reauthBackoffUntil = Date.now() + REAUTH_BACKOFF_MS;
6244
- }
6245
- const client = new ApiClient({ apiUrl: opts.apiUrl, token: token ?? void 0 });
6246
- const debounceMs = opts.interval ?? 2e3;
6247
- if (!daemon) {
6248
- info("Watching Claude Code + Cursor + Codex");
6249
- info("Press Ctrl-C to stop.\n");
6250
- } else {
6251
- log.info("watch starting", { debounceMs, cursorPollMs: CURSOR_POLL_MS });
6252
- }
6253
- const ccDir = claudeCodeProjectsDir();
6254
- for (const f of findJsonlFiles2(ccDir)) freshClaudeRecords(f, log);
6255
- for (const d of codexSessionsDirs()) {
6256
- for (const f of findJsonlFiles2(d)) freshCodexRecords(f, log);
6257
- }
6258
- log.debug(`Seeded ${seen.size} dedupe key(s) from initial snapshot`);
6259
- const cleanups = [];
6260
- cleanups.push(
6261
- await watchJsonlDir(
6262
- ccDir,
6263
- debounceMs,
6264
- async (filePath) => {
6265
- const fresh = freshClaudeRecords(filePath, log);
6266
- await upload(client, fresh, log, daemon);
6267
- },
6268
- log
6269
- )
6270
- );
6271
- for (const codexDir of codexSessionsDirs()) {
6272
- cleanups.push(
6273
- await watchJsonlDir(
6274
- codexDir,
6275
- debounceMs,
6276
- async (filePath) => {
6277
- const fresh = freshCodexRecords(filePath, log);
6278
- await upload(client, fresh, log, daemon);
6279
- },
6280
- log
6281
- )
6282
- );
6283
- }
6284
- cleanups.push(startCursorLoop(client, log, daemon));
6285
- process.on("SIGUSR1", () => {
6286
- log.info("health", {
6287
- uptimeSec: Math.round(process.uptime()),
6288
- uploaded: health.uploaded,
6289
- duplicates: health.duplicates,
6290
- errors: health.errors,
6291
- lastSourceAt: health.lastSourceAt,
6292
- seenKeys: seen.size,
6293
- reauthBackoffUntil: reauthBackoffUntil > Date.now() ? new Date(reauthBackoffUntil).toISOString() : null
6294
- });
6295
- });
6296
- const shutdown = () => {
6297
- log.info("watch stopping");
6298
- for (const c2 of cleanups) c2();
6299
- process.exit(0);
6300
- };
6301
- process.on("SIGINT", shutdown);
6302
- process.on("SIGTERM", shutdown);
6303
- }
6304
- async function handleInstall(opts) {
6305
- if (!isDaemonSupported()) {
6306
- error(`Background daemon not supported on ${process.platform} yet.`);
6307
- process.exit(1);
6308
- }
6309
- try {
6310
- const r = await installDaemon(opts.version ?? "0.0.0");
6311
- success(`Background watcher installed (${r.method}).`);
6312
- info(`Logs: ${r.logDir}/watch.log`);
6313
- if (r.ephemeralWarning) {
6314
- warn(
6315
- "You ran this via npx \u2014 the daemon may break when the npx cache is cleared.\nRun `npm i -g token-rats` (or `pnpm add -g token-rats`) for a durable install."
6316
- );
6317
- }
6318
- if (r.method === "systemd" && r.lingerSet === false) {
6319
- warn(
6320
- "For the watcher to survive logout, run once:\n sudo loginctl enable-linger $USER\nWithout it, the watcher resumes at your next login."
6321
- );
6234
+ if (stopped) break;
6235
+ await new Promise((resolve2) => {
6236
+ const timer = setTimeout(resolve2, interval);
6237
+ wake = () => {
6238
+ clearTimeout(timer);
6239
+ resolve2();
6240
+ };
6241
+ });
6322
6242
  }
6323
- } catch (err) {
6324
- error(`Install failed: ${err instanceof Error ? err.message : String(err)}`);
6325
- process.exit(1);
6326
- }
6327
- }
6328
- async function handleUninstall() {
6329
- try {
6330
- await uninstallDaemon();
6331
- success("Background watcher uninstalled.");
6332
- } catch (err) {
6333
- error(`Uninstall failed: ${err instanceof Error ? err.message : String(err)}`);
6334
- process.exit(1);
6335
- }
6336
- }
6337
- async function handleStatus() {
6338
- const s = await daemonStatus();
6339
- if (s.method === "unsupported") {
6340
- info(`Daemon not supported on ${process.platform}.`);
6341
- return;
6342
- }
6343
- info(`Method: ${s.method}`);
6344
- info(`Installed: ${s.installed ? "yes" : "no"}`);
6345
- info(`Running: ${s.running ? `yes (pid ${s.pid ?? "?"})` : "no"}`);
6346
- if (s.installedAt) info(`Installed at: ${s.installedAt}`);
6347
- if (s.installedScript) info(`Script: ${s.installedScript}`);
6348
- if (s.declinedAt) info(`Declined at: ${s.declinedAt}`);
6349
- info(`Log dir: ${daemonLogDir()}`);
6350
- if (process.platform === "linux") {
6351
- info(`Linger: ${lingerEnabled() ? "yes (survives logout)" : "no (dies on logout)"}`);
6243
+ } finally {
6244
+ process.removeListener("SIGINT", stop);
6245
+ process.removeListener("SIGTERM", stop);
6352
6246
  }
6353
6247
  }
6354
- async function watchCommand(opts) {
6355
- if (opts.install) return handleInstall(opts);
6356
- if (opts.uninstall) return handleUninstall();
6357
- if (opts.status) return handleStatus();
6358
- return runWatcher(opts);
6359
- }
6360
6248
 
6361
6249
  // src/commands/whoami.ts
6362
6250
  async function whoamiCommand(opts) {
@@ -6365,11 +6253,25 @@ async function whoamiCommand(opts) {
6365
6253
  error("Not logged in. Run `token-rats login` first.");
6366
6254
  process.exit(1);
6367
6255
  }
6368
- const client = new ApiClient({ apiUrl: opts.apiUrl, token });
6256
+ const client = new ApiClient({
6257
+ apiUrl: opts.apiUrl,
6258
+ token,
6259
+ deviceId: ensureDeviceId(),
6260
+ cliVersion: CLI_VERSION
6261
+ });
6369
6262
  try {
6370
6263
  const res = await client.getMe();
6371
6264
  info(`Signed in as \x1B[1m@${res.user.handle}\x1B[0m`);
6265
+ info(`Device id: ${ensureDeviceId()}`);
6372
6266
  } catch (err) {
6267
+ if (err instanceof DeviceRevokedError) {
6268
+ markDisconnected();
6269
+ deleteToken();
6270
+ error(
6271
+ "This device was disconnected from the Token Rats web UI. Run `token-rats login` to reconnect."
6272
+ );
6273
+ process.exit(1);
6274
+ }
6373
6275
  if (err instanceof ApiError2 && err.status === 401) {
6374
6276
  error("Your session has expired. Run `token-rats login` to re-authenticate.");
6375
6277
  process.exit(1);
@@ -6385,25 +6287,23 @@ function getVersion() {
6385
6287
  }
6386
6288
  function printHelp() {
6387
6289
  console.log(`
6388
- \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
6389
6291
 
6390
6292
  \x1B[1mUsage:\x1B[0m
6391
6293
  token-rats <command> [flags]
6392
6294
 
6393
6295
  \x1B[1mCommands:\x1B[0m
6394
- login Authenticate with Token Rats (opens browser)
6395
- sync Upload local Claude Code + Cursor + Codex usage. On first
6396
- successful sync, asks to install a background watcher
6397
- (launchd on macOS, systemd --user on Linux) that keeps
6398
- your leaderboard live \u2014 no more re-running sync.
6399
- watch Run the foreground watcher (Ctrl-C to stop), OR manage
6400
- the background watcher with --install / --uninstall /
6401
- --status.
6402
- whoami Show the currently signed-in account
6403
- logout Clear your stored credentials
6404
- install-cursor Install better-sqlite3 globally for faster Cursor reads
6405
- (sql.js works out of the box \u2014 this is opt-in speed-up)
6406
- help Show this help message
6296
+ login Authenticate with Token Rats (opens browser); installs the background watcher by default
6297
+ sync Read local Claude Code, Codex + Cursor logs and upload counts
6298
+ watch Watch logs in real-time; upload new sessions as they appear
6299
+ whoami Show the currently signed-in account + device id
6300
+ logout Clear your stored credentials
6301
+ install-daemon Install the background watcher (runs at logon)
6302
+ uninstall-daemon Remove the background watcher
6303
+ daemon-status Show whether the background watcher is running
6304
+ install-cursor Install better-sqlite3 globally for faster Cursor reads
6305
+ (sql.js works out of the box \u2014 this is opt-in speed-up)
6306
+ help Show this help message
6407
6307
 
6408
6308
  \x1B[1mFlags (all commands):\x1B[0m
6409
6309
  --api-url <url> Override API URL (default: https://api.tokenrats.com)
@@ -6411,16 +6311,9 @@ function printHelp() {
6411
6311
  \x1B[1mFlags (sync only):\x1B[0m
6412
6312
  --dry-run Parse but do not upload; print what would be sent
6413
6313
  --verbose Print discovered files and per-file record counts
6414
- --no-daemon Skip the post-sync prompt to install background watcher
6415
- (TOKEN_RATS_NO_DAEMON=1 does the same globally)
6416
6314
 
6417
6315
  \x1B[1mFlags (watch only):\x1B[0m
6418
- --install Install the background watcher (launchd / systemd --user)
6419
- --uninstall Remove the background watcher
6420
- --status Show whether the watcher is installed and running
6421
- --daemon Internal: structured logs for launchd/systemd. Not for
6422
- interactive use.
6423
- --interval <ms> Debounce window in ms before uploading (default: 2000)
6316
+ --interval <ms> Scan interval in ms (default: 30000, minimum: 1000)
6424
6317
  --verbose Print file change events and upload detail
6425
6318
 
6426
6319
  \x1B[1mPrivacy:\x1B[0m
@@ -6449,10 +6342,6 @@ function parseArgs(argv) {
6449
6342
  let verbose = false;
6450
6343
  let interval;
6451
6344
  let noDaemon = false;
6452
- let daemon = false;
6453
- let install = false;
6454
- let uninstall = false;
6455
- let status = false;
6456
6345
  let i = 0;
6457
6346
  while (i < argv.length) {
6458
6347
  const arg = argv[i];
@@ -6470,14 +6359,6 @@ function parseArgs(argv) {
6470
6359
  interval = Number(arg.slice("--interval=".length));
6471
6360
  } else if (arg === "--no-daemon") {
6472
6361
  noDaemon = true;
6473
- } else if (arg === "--daemon") {
6474
- daemon = true;
6475
- } else if (arg === "--install") {
6476
- install = true;
6477
- } else if (arg === "--uninstall") {
6478
- uninstall = true;
6479
- } else if (arg === "--status") {
6480
- status = true;
6481
6362
  } else if (arg === "--version" || arg === "-V") {
6482
6363
  console.log(getVersion());
6483
6364
  process.exit(0);
@@ -6490,56 +6371,24 @@ function parseArgs(argv) {
6490
6371
  i++;
6491
6372
  }
6492
6373
  const [command = null, ...rest] = positional;
6493
- return {
6494
- command,
6495
- apiUrl,
6496
- dryRun,
6497
- verbose,
6498
- interval,
6499
- noDaemon,
6500
- daemon,
6501
- install,
6502
- uninstall,
6503
- status,
6504
- rest
6505
- };
6374
+ return { command, apiUrl, dryRun, verbose, interval, noDaemon, rest };
6506
6375
  }
6507
6376
  async function main() {
6508
6377
  const args = parseArgs(process.argv.slice(2));
6509
- const {
6510
- command,
6511
- apiUrl,
6512
- dryRun,
6513
- verbose,
6514
- interval,
6515
- noDaemon,
6516
- daemon,
6517
- install,
6518
- uninstall,
6519
- status
6520
- } = args;
6378
+ const { command, apiUrl, dryRun, verbose, interval, noDaemon } = args;
6521
6379
  if (!command || command === "help") {
6522
6380
  printHelp();
6523
6381
  process.exit(0);
6524
6382
  }
6525
6383
  switch (command) {
6526
6384
  case "login":
6527
- await loginCommand({ apiUrl });
6385
+ await loginCommand({ apiUrl, noDaemon });
6528
6386
  break;
6529
6387
  case "sync":
6530
- await syncCommand({ apiUrl, dryRun, verbose, noDaemon });
6388
+ await syncCommand({ apiUrl, dryRun, verbose });
6531
6389
  break;
6532
6390
  case "watch":
6533
- await watchCommand({
6534
- apiUrl,
6535
- verbose,
6536
- interval,
6537
- daemon,
6538
- install,
6539
- uninstall,
6540
- status,
6541
- version: getVersion()
6542
- });
6391
+ await watchCommand({ apiUrl, verbose, interval });
6543
6392
  break;
6544
6393
  case "whoami":
6545
6394
  await whoamiCommand({ apiUrl });
@@ -6550,6 +6399,15 @@ async function main() {
6550
6399
  case "install-cursor":
6551
6400
  await installCursorCommand();
6552
6401
  break;
6402
+ case "install-daemon":
6403
+ await installDaemonCommand(apiUrl);
6404
+ break;
6405
+ case "uninstall-daemon":
6406
+ await uninstallDaemonCommand();
6407
+ break;
6408
+ case "daemon-status":
6409
+ await daemonStatusCommand();
6410
+ break;
6553
6411
  default:
6554
6412
  console.error(`\x1B[31mUnknown command: ${command}\x1B[0m`);
6555
6413
  console.error("Run \x1B[1mtoken-rats help\x1B[0m for a list of commands.");