token-rats 0.1.0 → 0.2.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 (3) hide show
  1. package/README.md +11 -54
  2. package/dist/index.js +844 -989
  3. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -32,6 +32,379 @@ 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 * as os2 from "node:os";
39
+ import * as path2 from "node:path";
40
+ import { promisify } from "node:util";
41
+
42
+ // src/lib/auth-store.ts
43
+ import * as crypto from "node:crypto";
44
+ import * as fs from "node:fs";
45
+ import * as os from "node:os";
46
+ import * as path from "node:path";
47
+ function tokenDir() {
48
+ const xdgConfig = process.env.XDG_CONFIG_HOME;
49
+ const base = xdgConfig ?? path.join(os.homedir(), ".config");
50
+ return path.join(base, "token-rats");
51
+ }
52
+ function tokenPath() {
53
+ return path.join(tokenDir(), "token");
54
+ }
55
+ function statePath() {
56
+ return path.join(tokenDir(), "state.json");
57
+ }
58
+ function disconnectedPath() {
59
+ return path.join(tokenDir(), "disconnected");
60
+ }
61
+ function saveToken(token) {
62
+ const dir = tokenDir();
63
+ fs.mkdirSync(dir, { recursive: true });
64
+ fs.writeFileSync(tokenPath(), token, { encoding: "utf8", mode: 384 });
65
+ }
66
+ function loadToken() {
67
+ try {
68
+ const token = fs.readFileSync(tokenPath(), "utf8").trim();
69
+ return token.length > 0 ? token : null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+ function deleteToken() {
75
+ try {
76
+ fs.unlinkSync(tokenPath());
77
+ } catch {
78
+ }
79
+ }
80
+ function isLoggedIn() {
81
+ return loadToken() !== null;
82
+ }
83
+ function ensureDeviceId() {
84
+ try {
85
+ const raw = fs.readFileSync(statePath(), "utf8");
86
+ const parsed = JSON.parse(raw);
87
+ if (typeof parsed.deviceId === "string" && parsed.deviceId.length > 0) {
88
+ return parsed.deviceId;
89
+ }
90
+ } catch {
91
+ }
92
+ const dir = tokenDir();
93
+ fs.mkdirSync(dir, { recursive: true });
94
+ const deviceId = crypto.randomUUID();
95
+ const state = { deviceId, createdAt: Date.now() };
96
+ fs.writeFileSync(statePath(), JSON.stringify(state, null, 2), {
97
+ encoding: "utf8",
98
+ mode: 384
99
+ });
100
+ return deviceId;
101
+ }
102
+ function markDisconnected() {
103
+ const dir = tokenDir();
104
+ fs.mkdirSync(dir, { recursive: true });
105
+ fs.writeFileSync(disconnectedPath(), String(Date.now()), { mode: 384 });
106
+ }
107
+ function clearDisconnected() {
108
+ try {
109
+ fs.unlinkSync(disconnectedPath());
110
+ } catch {
111
+ }
112
+ }
113
+ function isDisconnected() {
114
+ return fs.existsSync(disconnectedPath());
115
+ }
116
+
117
+ // src/lib/log.ts
118
+ var ESC = "\x1B";
119
+ var c = {
120
+ reset: `${ESC}[0m`,
121
+ bold: `${ESC}[1m`,
122
+ dim: `${ESC}[2m`,
123
+ green: `${ESC}[32m`,
124
+ yellow: `${ESC}[33m`,
125
+ cyan: `${ESC}[36m`,
126
+ red: `${ESC}[31m`,
127
+ gray: `${ESC}[90m`
128
+ };
129
+ function strip(s) {
130
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
131
+ }
132
+ function isTTY() {
133
+ return process.stdout.isTTY === true;
134
+ }
135
+ function color(code, text) {
136
+ return isTTY() ? `${code}${text}${c.reset}` : strip(text);
137
+ }
138
+ function info(msg) {
139
+ console.log(color(c.cyan, ` ${msg}`));
140
+ }
141
+ function success(msg) {
142
+ console.log(color(c.green, `\u2713 ${msg}`));
143
+ }
144
+ function warn(msg) {
145
+ console.warn(color(c.yellow, `\u26A0 ${msg}`));
146
+ }
147
+ function error(msg) {
148
+ console.error(color(c.red, `\u2717 ${msg}`));
149
+ }
150
+ function dim(msg) {
151
+ console.log(color(c.dim, ` ${msg}`));
152
+ }
153
+ function spinner(label) {
154
+ if (!isTTY()) {
155
+ process.stdout.write(` ${label}...
156
+ `);
157
+ return {
158
+ stop(finalMsg) {
159
+ if (finalMsg) process.stdout.write(` ${finalMsg}
160
+ `);
161
+ }
162
+ };
163
+ }
164
+ const frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
165
+ let i = 0;
166
+ const interval = setInterval(() => {
167
+ process.stdout.write(`\r${c.cyan}${frames[i++ % frames.length]}${c.reset} ${label} `);
168
+ }, 80);
169
+ return {
170
+ stop(finalMsg) {
171
+ clearInterval(interval);
172
+ process.stdout.write("\r\x1B[K");
173
+ if (finalMsg) success(finalMsg);
174
+ }
175
+ };
176
+ }
177
+
178
+ // src/commands/install-daemon.ts
179
+ var exec = promisify(execFile);
180
+ var LABEL = "com.tokenrats.watch";
181
+ var LINUX_UNIT = "token-rats-watch.service";
182
+ var WINDOWS_TASK = "TokenRatsWatch";
183
+ function resolveCliPath() {
184
+ const script = process.argv[1] ?? "token-rats";
185
+ return { node: process.execPath, script };
186
+ }
187
+ function darwinPlistPath() {
188
+ return path2.join(os2.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
189
+ }
190
+ function darwinLogDir() {
191
+ return path2.join(os2.homedir(), "Library", "Logs", "token-rats");
192
+ }
193
+ function darwinPlist(node, script) {
194
+ const logDir = darwinLogDir();
195
+ const stdout = path2.join(logDir, "watch.out.log");
196
+ const stderr = path2.join(logDir, "watch.err.log");
197
+ return `<?xml version="1.0" encoding="UTF-8"?>
198
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
199
+ <plist version="1.0">
200
+ <dict>
201
+ <key>Label</key>
202
+ <string>${LABEL}</string>
203
+ <key>ProgramArguments</key>
204
+ <array>
205
+ <string>${node}</string>
206
+ <string>${script}</string>
207
+ <string>watch</string>
208
+ </array>
209
+ <key>RunAtLoad</key>
210
+ <true/>
211
+ <key>KeepAlive</key>
212
+ <true/>
213
+ <key>StandardOutPath</key>
214
+ <string>${stdout}</string>
215
+ <key>StandardErrorPath</key>
216
+ <string>${stderr}</string>
217
+ </dict>
218
+ </plist>
219
+ `;
220
+ }
221
+ async function darwinInstall() {
222
+ const { node, script } = resolveCliPath();
223
+ fs2.mkdirSync(darwinLogDir(), { recursive: true });
224
+ const plistPath = darwinPlistPath();
225
+ fs2.mkdirSync(path2.dirname(plistPath), { recursive: true });
226
+ fs2.writeFileSync(plistPath, darwinPlist(node, script), { mode: 420 });
227
+ try {
228
+ await exec("launchctl", ["bootstrap", `gui/${process.getuid?.() ?? 0}`, plistPath]);
229
+ } catch {
230
+ try {
231
+ await exec("launchctl", ["load", plistPath]);
232
+ } catch (err) {
233
+ throw new Error(
234
+ `Wrote ${plistPath} but failed to load it: ${err instanceof Error ? err.message : String(err)}`
235
+ );
236
+ }
237
+ }
238
+ }
239
+ async function darwinUninstall() {
240
+ const plistPath = darwinPlistPath();
241
+ try {
242
+ await exec("launchctl", ["bootout", `gui/${process.getuid?.() ?? 0}/${LABEL}`]);
243
+ } catch {
244
+ try {
245
+ await exec("launchctl", ["unload", plistPath]);
246
+ } catch {
247
+ }
248
+ }
249
+ try {
250
+ fs2.unlinkSync(plistPath);
251
+ } catch {
252
+ }
253
+ }
254
+ async function darwinStatus() {
255
+ if (!fs2.existsSync(darwinPlistPath())) return "not-installed";
256
+ try {
257
+ const { stdout } = await exec("launchctl", ["list"]);
258
+ if (stdout.split("\n").some((line) => line.endsWith(LABEL))) return "running";
259
+ return "stopped";
260
+ } catch {
261
+ return "stopped";
262
+ }
263
+ }
264
+ function linuxUnitPath() {
265
+ const xdgConfig = process.env.XDG_CONFIG_HOME ?? path2.join(os2.homedir(), ".config");
266
+ return path2.join(xdgConfig, "systemd", "user", LINUX_UNIT);
267
+ }
268
+ function linuxUnit(node, script) {
269
+ return `[Unit]
270
+ Description=Token Rats watcher \u2014 live AI usage sync
271
+ After=network-online.target
272
+ Wants=network-online.target
273
+
274
+ [Service]
275
+ Type=simple
276
+ ExecStart=${node} ${script} watch
277
+ Restart=on-failure
278
+ RestartSec=10s
279
+ Environment=NODE_ENV=production
280
+
281
+ [Install]
282
+ WantedBy=default.target
283
+ `;
284
+ }
285
+ async function linuxInstall() {
286
+ const { node, script } = resolveCliPath();
287
+ const unitPath = linuxUnitPath();
288
+ fs2.mkdirSync(path2.dirname(unitPath), { recursive: true });
289
+ fs2.writeFileSync(unitPath, linuxUnit(node, script), { mode: 420 });
290
+ try {
291
+ await exec("systemctl", ["--user", "daemon-reload"]);
292
+ await exec("systemctl", ["--user", "enable", "--now", LINUX_UNIT]);
293
+ } catch (err) {
294
+ throw new Error(
295
+ `Wrote ${unitPath} but failed to enable+start it: ${err instanceof Error ? err.message : String(err)}`
296
+ );
297
+ }
298
+ }
299
+ async function linuxUninstall() {
300
+ try {
301
+ await exec("systemctl", ["--user", "disable", "--now", LINUX_UNIT]);
302
+ } catch {
303
+ }
304
+ try {
305
+ fs2.unlinkSync(linuxUnitPath());
306
+ } catch {
307
+ }
308
+ try {
309
+ await exec("systemctl", ["--user", "daemon-reload"]);
310
+ } catch {
311
+ }
312
+ }
313
+ async function linuxStatus() {
314
+ if (!fs2.existsSync(linuxUnitPath())) return "not-installed";
315
+ try {
316
+ const { stdout } = await exec("systemctl", ["--user", "is-active", LINUX_UNIT]);
317
+ return stdout.trim() === "active" ? "running" : "stopped";
318
+ } catch {
319
+ return "stopped";
320
+ }
321
+ }
322
+ async function windowsInstall() {
323
+ const { node, script } = resolveCliPath();
324
+ await exec("schtasks", [
325
+ "/Create",
326
+ "/SC",
327
+ "ONLOGON",
328
+ "/TN",
329
+ WINDOWS_TASK,
330
+ "/TR",
331
+ `"${node}" "${script}" watch`,
332
+ "/RL",
333
+ "LIMITED",
334
+ "/F"
335
+ ]);
336
+ try {
337
+ await exec("schtasks", ["/Run", "/TN", WINDOWS_TASK]);
338
+ } catch {
339
+ }
340
+ }
341
+ async function windowsUninstall() {
342
+ try {
343
+ await exec("schtasks", ["/End", "/TN", WINDOWS_TASK]);
344
+ } catch {
345
+ }
346
+ try {
347
+ await exec("schtasks", ["/Delete", "/TN", WINDOWS_TASK, "/F"]);
348
+ } catch {
349
+ }
350
+ }
351
+ async function windowsStatus() {
352
+ try {
353
+ const { stdout } = await exec("schtasks", ["/Query", "/TN", WINDOWS_TASK, "/FO", "CSV", "/NH"]);
354
+ if (stdout.includes("Running")) return "running";
355
+ return "stopped";
356
+ } catch {
357
+ return "not-installed";
358
+ }
359
+ }
360
+ async function installDaemonCommand() {
361
+ clearDisconnected();
362
+ try {
363
+ if (process.platform === "darwin") {
364
+ await darwinInstall();
365
+ } else if (process.platform === "linux") {
366
+ await linuxInstall();
367
+ } else if (process.platform === "win32") {
368
+ await windowsInstall();
369
+ } else {
370
+ warn(`No daemon installer for platform ${process.platform}; skipping.`);
371
+ return;
372
+ }
373
+ success("Background watcher installed and running.");
374
+ dim("It will pick up new sessions from Claude Code logs in real time.");
375
+ dim("Manage it with `token-rats daemon-status` and `token-rats uninstall-daemon`.");
376
+ } catch (err) {
377
+ error(`Failed to install daemon: ${err instanceof Error ? err.message : String(err)}`);
378
+ warn("You can still run `token-rats sync` manually.");
379
+ }
380
+ }
381
+ async function uninstallDaemonCommand() {
382
+ if (process.platform === "darwin") {
383
+ await darwinUninstall();
384
+ } else if (process.platform === "linux") {
385
+ await linuxUninstall();
386
+ } else if (process.platform === "win32") {
387
+ await windowsUninstall();
388
+ } else {
389
+ warn(`No daemon installer for platform ${process.platform}; nothing to remove.`);
390
+ return;
391
+ }
392
+ info("Daemon removed. `token-rats sync` will still work manually.");
393
+ }
394
+ async function daemonStatusCommand() {
395
+ let status;
396
+ if (process.platform === "darwin") status = await darwinStatus();
397
+ else if (process.platform === "linux") status = await linuxStatus();
398
+ else if (process.platform === "win32") status = await windowsStatus();
399
+ else {
400
+ warn(`No daemon for platform ${process.platform}.`);
401
+ return;
402
+ }
403
+ if (status === "running") success("Token Rats watcher is running.");
404
+ else if (status === "stopped") warn("Token Rats watcher is installed but not running.");
405
+ else info("Token Rats watcher is not installed. Run `token-rats install-daemon` to start it.");
406
+ }
407
+
35
408
  // ../../node_modules/.pnpm/zod@3.23.8/node_modules/zod/lib/index.mjs
36
409
  var util;
37
410
  (function(util2) {
@@ -385,8 +758,8 @@ function getErrorMap() {
385
758
  return overrideErrorMap;
386
759
  }
387
760
  var makeIssue = (params) => {
388
- const { data, path: path7, errorMaps, issueData } = params;
389
- const fullPath = [...path7, ...issueData.path || []];
761
+ const { data, path: path5, errorMaps, issueData } = params;
762
+ const fullPath = [...path5, ...issueData.path || []];
390
763
  const fullIssue = {
391
764
  ...issueData,
392
765
  path: fullPath
@@ -508,11 +881,11 @@ var errorUtil;
508
881
  var _ZodEnum_cache;
509
882
  var _ZodNativeEnum_cache;
510
883
  var ParseInputLazyPath = class {
511
- constructor(parent, value, path7, key) {
884
+ constructor(parent, value, path5, key) {
512
885
  this._cachedPath = [];
513
886
  this.parent = parent;
514
887
  this.data = value;
515
- this._path = path7;
888
+ this._path = path5;
516
889
  this._key = key;
517
890
  }
518
891
  get path() {
@@ -3948,11 +4321,21 @@ var z = /* @__PURE__ */ Object.freeze({
3948
4321
 
3949
4322
  // ../contracts/src/session.ts
3950
4323
  var Source = z.enum(["claude-code", "cursor", "codex"]);
3951
- var Provider = z.enum(["anthropic", "openai", "cursor", "unknown"]);
4324
+ var Provider = z.enum([
4325
+ "anthropic",
4326
+ "openai",
4327
+ "openrouter",
4328
+ "cursor",
4329
+ "ollama",
4330
+ "unknown"
4331
+ ]);
4332
+ var SessionChannel = z.enum(["cli", "ide", "api", "proxy", "local", "unknown"]);
3952
4333
  var SessionRecord = z.object({
3953
4334
  id: z.string().min(1),
3954
4335
  source: Source,
3955
4336
  provider: Provider.optional(),
4337
+ client: z.string().min(1).max(64).optional(),
4338
+ channel: SessionChannel.optional(),
3956
4339
  model: z.string().min(1),
3957
4340
  inTokens: z.number().int().nonnegative(),
3958
4341
  outTokens: z.number().int().nonnegative(),
@@ -3969,6 +4352,12 @@ var SessionRecord = z.object({
3969
4352
  });
3970
4353
 
3971
4354
  // ../contracts/src/user.ts
4355
+ var ProfileAttributionEntry = z.object({
4356
+ source: z.string(),
4357
+ tokens: z.number().int().nonnegative(),
4358
+ costUsdCents: z.number().int().nonnegative(),
4359
+ sessions: z.number().int().nonnegative()
4360
+ });
3972
4361
  var User = z.object({
3973
4362
  id: z.string(),
3974
4363
  handle: z.string(),
@@ -4002,6 +4391,10 @@ var Profile = User.extend({
4002
4391
  }),
4003
4392
  /** Number of users this user has brought in via their referral link. */
4004
4393
  referredCount: z.number().int().nonnegative().optional(),
4394
+ /** All-time attribution grouped by tool/client. */
4395
+ sources: z.array(ProfileAttributionEntry).optional(),
4396
+ /** All-time attribution grouped by transport channel. */
4397
+ channels: z.array(ProfileAttributionEntry).optional(),
4005
4398
  /**
4006
4399
  * The profile owner's referral code. Self-only — present only when the
4007
4400
  * caller is viewing their own profile, so the page can render a
@@ -4077,8 +4470,14 @@ var LeaderboardRow = z.object({
4077
4470
  tokens: z.number().int().nonnegative(),
4078
4471
  costUsdCents: z.number().int().nonnegative(),
4079
4472
  sessions: z.number().int().nonnegative(),
4473
+ /** ISO 3166-1 alpha-2 country code. Null when the user has no stamped country. */
4474
+ country: z.string().length(2).nullable().default(null),
4080
4475
  /** Up to 2 dominant sources by token volume, descending. May be empty. */
4081
- topSources: z.array(SourceBreakdownEntry).max(2).default([])
4476
+ topSources: z.array(SourceBreakdownEntry).max(2).default([]),
4477
+ /** Up to 2 dominant clients/tools by token volume, descending. */
4478
+ topClients: z.array(SourceBreakdownEntry).max(2).default([]),
4479
+ /** Up to 2 dominant transport channels by token volume, descending. */
4480
+ topChannels: z.array(SourceBreakdownEntry).max(2).default([])
4082
4481
  });
4083
4482
  var Leaderboard = z.object({
4084
4483
  range: LeaderboardRange,
@@ -4414,6 +4813,12 @@ var ENDPOINTS = {
4414
4813
  meReferral: "/v1/me/referral",
4415
4814
  // v1.2 Track AD — friends derived from shared private rooms
4416
4815
  meFriends: "/v1/me/friends",
4816
+ // Multi-device — anonymized device list + revoke + heartbeat
4817
+ meDevices: "/v1/me/devices",
4818
+ meDeviceRevoke: (deviceId) => `/v1/me/devices/${deviceId}/revoke`,
4819
+ meDeviceHeartbeat: "/v1/me/devices/heartbeat",
4820
+ // CLI version + upgrade banner
4821
+ cliVersion: "/v1/cli/version",
4417
4822
  // Phase 3 Track O — Org plan
4418
4823
  orgs: "/v1/orgs",
4419
4824
  org: (slug) => `/v1/orgs/${slug}`,
@@ -4547,6 +4952,67 @@ var FriendsResponse = z.object({
4547
4952
  friends: z.array(FriendRow)
4548
4953
  });
4549
4954
 
4955
+ // ../contracts/src/device.ts
4956
+ var DeviceTotals = z.object({
4957
+ tokens: z.number().int().nonnegative(),
4958
+ costUsdCents: z.number().int().nonnegative(),
4959
+ sessions: z.number().int().nonnegative()
4960
+ });
4961
+ var DeviceBreakdownEntry = z.object({
4962
+ value: z.string().min(1),
4963
+ tokens: z.number().int().nonnegative(),
4964
+ costUsdCents: z.number().int().nonnegative(),
4965
+ sessions: z.number().int().nonnegative()
4966
+ });
4967
+ var Device = z.object({
4968
+ deviceId: z.string().min(1),
4969
+ createdAt: z.number().int().nonnegative(),
4970
+ lastSeenAt: z.number().int().nonnegative(),
4971
+ lastHeartbeatAt: z.number().int().nonnegative().nullable(),
4972
+ /** Derived: true iff lastHeartbeatAt is within 5 min of `now`. */
4973
+ isLive: z.boolean(),
4974
+ lastUploadCount: z.number().int().nonnegative(),
4975
+ cliVersion: z.string().nullable(),
4976
+ /** Unix-ms when the user revoked this device via the web UI, else null. */
4977
+ revokedAt: z.number().int().nonnegative().nullable(),
4978
+ /** True for the synthetic pre-device-id bucket. */
4979
+ isLegacy: z.boolean().default(false),
4980
+ /** Last observed session upload timestamp for this device, if any. */
4981
+ lastSessionAt: z.number().int().nonnegative().nullable(),
4982
+ /** 30-day totals for this device, computed at request time. */
4983
+ totals: DeviceTotals,
4984
+ /** All-time totals for this device. */
4985
+ totalsAllTime: DeviceTotals,
4986
+ /** Dominant sources in the last 30 days, ordered by tokens desc. */
4987
+ topSources: z.array(DeviceBreakdownEntry).max(3).default([]),
4988
+ /** Dominant clients/tools in the last 30 days, ordered by tokens desc. */
4989
+ topClients: z.array(DeviceBreakdownEntry).max(3).default([]),
4990
+ /** Dominant transport channels in the last 30 days, ordered by tokens desc. */
4991
+ topChannels: z.array(DeviceBreakdownEntry).max(3).default([]),
4992
+ /** Dominant providers in the last 30 days, ordered by tokens desc. */
4993
+ topProviders: z.array(DeviceBreakdownEntry).max(3).default([]),
4994
+ /** Dominant models in the last 30 days, ordered by tokens desc. */
4995
+ topModels: z.array(DeviceBreakdownEntry).max(3).default([])
4996
+ });
4997
+ var GetMeDevicesResponse = z.object({
4998
+ devices: z.array(Device)
4999
+ });
5000
+ var RevokeDeviceResponse = z.object({
5001
+ ok: z.literal(true),
5002
+ revokedAt: z.number().int().nonnegative()
5003
+ });
5004
+ var DeviceHeartbeatResponse = z.object({
5005
+ ok: z.literal(true),
5006
+ lastHeartbeatAt: z.number().int().nonnegative()
5007
+ });
5008
+
5009
+ // ../contracts/src/cli.ts
5010
+ var CliVersionResponse = z.object({
5011
+ latest: z.string().min(1),
5012
+ minSupported: z.string().min(1),
5013
+ upgradeCommand: z.string().min(1)
5014
+ });
5015
+
4550
5016
  // src/lib/api.ts
4551
5017
  var DEFAULT_API_URL = "https://api.tokenrats.com";
4552
5018
  function isTransient(status) {
@@ -4563,33 +5029,40 @@ var ApiError2 = class extends Error {
4563
5029
  this.name = "ApiError";
4564
5030
  }
4565
5031
  };
5032
+ var DeviceRevokedError = class extends ApiError2 {
5033
+ constructor(body) {
5034
+ super(401, body);
5035
+ this.name = "DeviceRevokedError";
5036
+ }
5037
+ };
4566
5038
  var ApiClient = class {
4567
5039
  apiUrl;
4568
5040
  token;
5041
+ deviceId;
5042
+ cliVersion;
4569
5043
  constructor(opts = {}) {
4570
5044
  this.apiUrl = (opts.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
4571
5045
  this.token = opts.token;
5046
+ this.deviceId = opts.deviceId;
5047
+ this.cliVersion = opts.cliVersion;
4572
5048
  }
4573
5049
  setToken(token) {
4574
5050
  this.token = token;
4575
5051
  }
4576
5052
  headers() {
4577
5053
  const h = { "Content-Type": "application/json" };
4578
- if (this.token) {
4579
- h.Authorization = `Bearer ${this.token}`;
4580
- }
5054
+ if (this.token) h.Authorization = `Bearer ${this.token}`;
5055
+ if (this.deviceId) h["X-Device-Id"] = this.deviceId;
5056
+ if (this.cliVersion) h["X-Cli-Version"] = this.cliVersion;
4581
5057
  return h;
4582
5058
  }
4583
- /** Perform a fetch with retry+backoff. maxRetries=3, delays: 1s, 2s, 4s. */
4584
5059
  async fetchWithRetry(url, init, maxRetries = 3) {
4585
5060
  let attempt = 0;
4586
5061
  let lastErr;
4587
5062
  while (attempt <= maxRetries) {
4588
5063
  try {
4589
5064
  const res = await fetch(url, init);
4590
- if (res.ok || !isTransient(res.status)) {
4591
- return res;
4592
- }
5065
+ if (res.ok || !isTransient(res.status)) return res;
4593
5066
  lastErr = new ApiError2(res.status, await res.text());
4594
5067
  } catch (err) {
4595
5068
  lastErr = err;
@@ -4601,34 +5074,37 @@ var ApiClient = class {
4601
5074
  }
4602
5075
  throw lastErr;
4603
5076
  }
4604
- async post(path7, body) {
4605
- const url = `${this.apiUrl}${path7}`;
5077
+ async failedResponseToError(res) {
5078
+ const body = await res.text();
5079
+ if (res.status === 401 && /device_revoked/.test(body)) {
5080
+ return new DeviceRevokedError(body);
5081
+ }
5082
+ return new ApiError2(res.status, body);
5083
+ }
5084
+ async post(path5, body) {
5085
+ const url = `${this.apiUrl}${path5}`;
4606
5086
  const res = await this.fetchWithRetry(url, {
4607
5087
  method: "POST",
4608
5088
  headers: this.headers(),
4609
5089
  body: JSON.stringify(body)
4610
5090
  });
4611
- if (!res.ok) {
4612
- throw new ApiError2(res.status, await res.text());
4613
- }
5091
+ if (!res.ok) throw await this.failedResponseToError(res);
4614
5092
  return res.json();
4615
5093
  }
4616
- async get(path7) {
4617
- const url = `${this.apiUrl}${path7}`;
5094
+ async get(path5) {
5095
+ const url = `${this.apiUrl}${path5}`;
4618
5096
  const res = await this.fetchWithRetry(url, {
4619
5097
  method: "GET",
4620
5098
  headers: this.headers()
4621
5099
  });
4622
- if (!res.ok) {
4623
- throw new ApiError2(res.status, await res.text());
4624
- }
5100
+ if (!res.ok) throw await this.failedResponseToError(res);
4625
5101
  return res.json();
4626
5102
  }
4627
5103
  /** Initiate device-code flow. */
4628
5104
  async cliExchange() {
4629
5105
  return this.post(ENDPOINTS.authCliExchange, {});
4630
5106
  }
4631
- /** Poll for auth token. Returns token on success, null on pending, throws on error. */
5107
+ /** Poll for auth token. Returns token on success, null on pending. */
4632
5108
  async cliPoll(pollToken) {
4633
5109
  const url = `${this.apiUrl}${ENDPOINTS.authCliPoll}`;
4634
5110
  const res = await fetch(url, {
@@ -4640,12 +5116,8 @@ var ApiClient = class {
4640
5116
  const data = await res.json();
4641
5117
  return data.token;
4642
5118
  }
4643
- if (res.status === 202) {
4644
- return null;
4645
- }
4646
- if (res.status === 410) {
4647
- throw new ApiError2(410, "Code expired");
4648
- }
5119
+ if (res.status === 202) return null;
5120
+ if (res.status === 410) throw new ApiError2(410, "Code expired");
4649
5121
  throw new ApiError2(res.status, await res.text());
4650
5122
  }
4651
5123
  /** GET /v1/me */
@@ -4656,106 +5128,18 @@ var ApiClient = class {
4656
5128
  async uploadSessions(sessions) {
4657
5129
  return this.post(ENDPOINTS.sessions, { sessions });
4658
5130
  }
5131
+ /** GET /v1/me/devices */
5132
+ async getDevices() {
5133
+ return this.get(ENDPOINTS.meDevices);
5134
+ }
5135
+ /** POST /v1/me/devices/heartbeat */
5136
+ async heartbeat() {
5137
+ return this.post(ENDPOINTS.meDeviceHeartbeat, {});
5138
+ }
4659
5139
  };
4660
5140
 
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;
4686
- }
4687
- }
4688
- function deleteToken() {
4689
- const file = tokenPath();
4690
- try {
4691
- fs.unlinkSync(file);
4692
- } catch {
4693
- }
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
- };
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
- };
4745
- }
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
- }
5141
+ // src/lib/cli-version.ts
5142
+ var CLI_VERSION = "0.2.0";
4759
5143
 
4760
5144
  // src/commands/login.ts
4761
5145
  async function openBrowser(url) {
@@ -4768,12 +5152,12 @@ async function openBrowser(url) {
4768
5152
  } catch {
4769
5153
  }
4770
5154
  try {
4771
- const { execFile } = await import("node:child_process");
4772
- const { promisify } = await import("node:util");
4773
- const exec = promisify(execFile);
5155
+ const { execFile: execFile2 } = await import("node:child_process");
5156
+ const { promisify: promisify2 } = await import("node:util");
5157
+ const exec2 = promisify2(execFile2);
4774
5158
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
4775
5159
  const args = process.platform === "win32" ? ["/c", "start", url] : [url];
4776
- await exec(cmd, args).catch(() => null);
5160
+ await exec2(cmd, args).catch(() => null);
4777
5161
  } catch {
4778
5162
  }
4779
5163
  }
@@ -4789,7 +5173,12 @@ async function copyToClipboard(text) {
4789
5173
  return false;
4790
5174
  }
4791
5175
  async function loginCommand(opts) {
4792
- const client = new ApiClient({ apiUrl: opts.apiUrl });
5176
+ const deviceId = ensureDeviceId();
5177
+ const client = new ApiClient({
5178
+ apiUrl: opts.apiUrl,
5179
+ deviceId,
5180
+ cliVersion: CLI_VERSION
5181
+ });
4793
5182
  info("Authenticating with Token Rats\u2026");
4794
5183
  let exchange;
4795
5184
  try {
@@ -4837,7 +5226,16 @@ async function loginCommand(opts) {
4837
5226
  process.exit(1);
4838
5227
  }
4839
5228
  saveToken(token);
5229
+ clearDisconnected();
4840
5230
  success("Logged in! Run `token-rats whoami` to verify.");
5231
+ dim(`Device id: ${deviceId}`);
5232
+ if (opts.noDaemon) {
5233
+ info("Skipping background watcher install (--no-daemon).");
5234
+ info("Run `token-rats sync` manually whenever you want to upload usage.");
5235
+ return;
5236
+ }
5237
+ info("Installing background watcher so usage uploads automatically\u2026");
5238
+ await installDaemonCommand();
4841
5239
  }
4842
5240
 
4843
5241
  // src/commands/logout.ts
@@ -4851,8 +5249,7 @@ function logoutCommand() {
4851
5249
  }
4852
5250
 
4853
5251
  // src/commands/sync.ts
4854
- import * as fs7 from "node:fs";
4855
- import * as readline from "node:readline/promises";
5252
+ import * as fs4 from "node:fs";
4856
5253
 
4857
5254
  // ../parsers/src/hash.ts
4858
5255
  var FNV_PRIME = 16777619;
@@ -4959,6 +5356,8 @@ function parseClaudeCode(input) {
4959
5356
  id: `claude-code:${acc.sessionId}`,
4960
5357
  source: "claude-code",
4961
5358
  provider: "anthropic",
5359
+ client: "claude-code",
5360
+ channel: "cli",
4962
5361
  model,
4963
5362
  inTokens: acc.inTokens,
4964
5363
  outTokens: acc.outTokens,
@@ -5007,6 +5406,9 @@ function parseCodex(input) {
5007
5406
  currentSessionId = id;
5008
5407
  const metaTs = parseTimestamp(payload.timestamp) || ts;
5009
5408
  const acc2 = upsert(sessions, id);
5409
+ const metaSource = typeof payload.source === "string" ? payload.source : null;
5410
+ acc2.channel = metaSource === "cli" ? "cli" : metaSource === "api" ? "api" : "unknown";
5411
+ acc2.client = acc2.channel === "cli" ? "codex-cli" : "codex";
5010
5412
  if (metaTs > 0 && (acc2.startedAt === 0 || metaTs < acc2.startedAt)) {
5011
5413
  acc2.startedAt = metaTs;
5012
5414
  }
@@ -5051,6 +5453,8 @@ function parseCodex(input) {
5051
5453
  id: `codex:${acc.sessionId}`,
5052
5454
  source: "codex",
5053
5455
  provider: "openai",
5456
+ client: acc.client || "codex-cli",
5457
+ channel: acc.channel,
5054
5458
  model,
5055
5459
  inTokens: acc.inTokens,
5056
5460
  outTokens: acc.outTokens,
@@ -5075,7 +5479,9 @@ function upsert(map, sessionId) {
5075
5479
  outTokens: 0,
5076
5480
  cacheReadTokens: 0,
5077
5481
  reasoningTokens: 0,
5078
- model: ""
5482
+ model: "",
5483
+ client: "codex-cli",
5484
+ channel: "unknown"
5079
5485
  };
5080
5486
  map.set(sessionId, acc);
5081
5487
  }
@@ -5132,6 +5538,8 @@ function parseCursor(input) {
5132
5538
  id: `cursor:${id}`,
5133
5539
  source: "cursor",
5134
5540
  provider: "cursor",
5541
+ client: "cursor",
5542
+ channel: "ide",
5135
5543
  model,
5136
5544
  inTokens,
5137
5545
  outTokens,
@@ -5145,31 +5553,31 @@ function parseCursor(input) {
5145
5553
  }
5146
5554
 
5147
5555
  // src/lib/cursor-extract.ts
5148
- import { existsSync, readdirSync, statSync } from "node:fs";
5556
+ import { existsSync as existsSync3, readdirSync } from "node:fs";
5149
5557
  import { readFile } from "node:fs/promises";
5150
- import { homedir as homedir2 } from "node:os";
5151
- import { join as join2 } from "node:path";
5558
+ import { homedir as homedir3 } from "node:os";
5559
+ import { join as join3 } from "node:path";
5152
5560
  function cursorWorkspaceStorageDir() {
5153
- const home = homedir2();
5154
- const rel = join2("Cursor", "User", "workspaceStorage");
5561
+ const home = homedir3();
5562
+ const rel = join3("Cursor", "User", "workspaceStorage");
5155
5563
  if (process.platform === "darwin") {
5156
- return join2(home, "Library", "Application Support", rel);
5564
+ return join3(home, "Library", "Application Support", rel);
5157
5565
  }
5158
5566
  if (process.platform === "win32") {
5159
- const appData = process.env.APPDATA ?? join2(home, "AppData", "Roaming");
5160
- return join2(appData, rel);
5567
+ const appData = process.env.APPDATA ?? join3(home, "AppData", "Roaming");
5568
+ return join3(appData, rel);
5161
5569
  }
5162
- const xdgConfig = process.env.XDG_CONFIG_HOME ?? join2(home, ".config");
5163
- return join2(xdgConfig, rel);
5570
+ const xdgConfig = process.env.XDG_CONFIG_HOME ?? join3(home, ".config");
5571
+ return join3(xdgConfig, rel);
5164
5572
  }
5165
5573
  function discoverWorkspaceDbs() {
5166
5574
  const root = cursorWorkspaceStorageDir();
5167
- if (!existsSync(root)) return [];
5575
+ if (!existsSync3(root)) return [];
5168
5576
  const out = [];
5169
5577
  for (const entry of readdirSync(root, { withFileTypes: true })) {
5170
5578
  if (!entry.isDirectory()) continue;
5171
- const candidate = join2(root, entry.name, "state.vscdb");
5172
- if (existsSync(candidate)) out.push(candidate);
5579
+ const candidate = join3(root, entry.name, "state.vscdb");
5580
+ if (existsSync3(candidate)) out.push(candidate);
5173
5581
  }
5174
5582
  return out;
5175
5583
  }
@@ -5298,373 +5706,17 @@ async function extractCursorGenerations() {
5298
5706
  }
5299
5707
  return { rows, dbCount: dbPaths.length, skipped: null };
5300
5708
  }
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
5709
 
5332
- // src/lib/daemon/linux.ts
5333
- import { spawnSync } from "node:child_process";
5710
+ // src/lib/discover.ts
5334
5711
  import * as fs3 from "node:fs";
5335
5712
  import * as os3 from "node:os";
5336
5713
  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
5714
  function findJsonlFiles(dir) {
5663
5715
  const results = [];
5664
- if (!fs6.existsSync(dir)) return results;
5665
- const entries = fs6.readdirSync(dir, { withFileTypes: true });
5716
+ if (!fs3.existsSync(dir)) return results;
5717
+ const entries = fs3.readdirSync(dir, { withFileTypes: true });
5666
5718
  for (const entry of entries) {
5667
- const full = path5.join(dir, entry.name);
5719
+ const full = path3.join(dir, entry.name);
5668
5720
  if (entry.isDirectory()) {
5669
5721
  results.push(...findJsonlFiles(full));
5670
5722
  } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
@@ -5674,32 +5726,32 @@ function findJsonlFiles(dir) {
5674
5726
  return results;
5675
5727
  }
5676
5728
  function claudeCodeProjectsDir() {
5677
- const home = os5.homedir();
5729
+ const home = os3.homedir();
5678
5730
  if (process.platform === "win32") {
5679
5731
  const profile = process.env.USERPROFILE ?? home;
5680
- return path5.join(profile, ".claude", "projects");
5732
+ return path3.join(profile, ".claude", "projects");
5681
5733
  }
5682
- return path5.join(home, ".claude", "projects");
5734
+ return path3.join(home, ".claude", "projects");
5683
5735
  }
5684
5736
  function discoverClaudeCodeFiles() {
5685
5737
  const dir = claudeCodeProjectsDir();
5686
5738
  return findJsonlFiles(dir);
5687
5739
  }
5688
5740
  function codexSessionsDirs() {
5689
- const home = os5.homedir();
5741
+ const home = os3.homedir();
5690
5742
  const candidates = [];
5691
5743
  if (process.platform === "win32") {
5692
5744
  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"));
5745
+ candidates.push(path3.join(profile, ".codex", "sessions"));
5746
+ const appData = process.env.APPDATA ?? path3.join(profile, "AppData", "Roaming");
5747
+ candidates.push(path3.join(appData, "Codex", "sessions"));
5696
5748
  } 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 })) {
5749
+ candidates.push(path3.join(home, ".codex", "sessions"));
5750
+ const snapRoot = path3.join(home, "snap", "codex");
5751
+ if (fs3.existsSync(snapRoot)) {
5752
+ for (const entry of fs3.readdirSync(snapRoot, { withFileTypes: true })) {
5701
5753
  if (entry.isDirectory()) {
5702
- candidates.push(path5.join(snapRoot, entry.name, "sessions"));
5754
+ candidates.push(path3.join(snapRoot, entry.name, "sessions"));
5703
5755
  }
5704
5756
  }
5705
5757
  }
@@ -5708,7 +5760,7 @@ function codexSessionsDirs() {
5708
5760
  const dirs = [];
5709
5761
  for (const c2 of candidates) {
5710
5762
  try {
5711
- const real = fs6.existsSync(c2) ? fs6.realpathSync(c2) : null;
5763
+ const real = fs3.existsSync(c2) ? fs3.realpathSync(c2) : null;
5712
5764
  if (real && !seen2.has(real)) {
5713
5765
  seen2.add(real);
5714
5766
  dirs.push(c2);
@@ -5726,9 +5778,6 @@ function discoverCodexFiles() {
5726
5778
  return out;
5727
5779
  }
5728
5780
 
5729
- // src/lib/version.ts
5730
- var CLI_VERSION = "0.1.0";
5731
-
5732
5781
  // src/commands/sync.ts
5733
5782
  var BATCH_SIZE = 500;
5734
5783
  function chunk(arr, size) {
@@ -5772,7 +5821,12 @@ async function syncCommand(opts) {
5772
5821
  error("Not logged in. Run `token-rats login` first.");
5773
5822
  process.exit(1);
5774
5823
  }
5775
- const client = opts.dryRun ? null : new ApiClient({ apiUrl: opts.apiUrl, token: token ?? void 0 });
5824
+ const client = opts.dryRun ? null : new ApiClient({
5825
+ apiUrl: opts.apiUrl,
5826
+ token: token ?? void 0,
5827
+ deviceId: ensureDeviceId(),
5828
+ cliVersion: CLI_VERSION
5829
+ });
5776
5830
  const claudeFiles = discoverClaudeCodeFiles();
5777
5831
  if (opts.verbose) {
5778
5832
  info(`Found ${claudeFiles.length} Claude Code file(s) in ~/.claude/projects/`);
@@ -5782,7 +5836,7 @@ async function syncCommand(opts) {
5782
5836
  for (const file of claudeFiles) {
5783
5837
  let text;
5784
5838
  try {
5785
- text = fs7.readFileSync(file, "utf8");
5839
+ text = fs4.readFileSync(file, "utf8");
5786
5840
  } catch {
5787
5841
  if (opts.verbose) warn(`Could not read ${file} \u2014 skipping`);
5788
5842
  continue;
@@ -5804,7 +5858,7 @@ async function syncCommand(opts) {
5804
5858
  for (const file of codexFiles) {
5805
5859
  let text;
5806
5860
  try {
5807
- text = fs7.readFileSync(file, "utf8");
5861
+ text = fs4.readFileSync(file, "utf8");
5808
5862
  } catch {
5809
5863
  if (opts.verbose) warn(`Could not read ${file} \u2014 skipping`);
5810
5864
  continue;
@@ -5890,6 +5944,14 @@ async function syncCommand(opts) {
5890
5944
  totalDuplicates += res.duplicates;
5891
5945
  } catch (err) {
5892
5946
  spin.stop();
5947
+ if (err instanceof DeviceRevokedError) {
5948
+ markDisconnected();
5949
+ deleteToken();
5950
+ error(
5951
+ "This device was disconnected from the Token Rats web UI. Run `token-rats login` to reconnect."
5952
+ );
5953
+ process.exit(1);
5954
+ }
5893
5955
  if (err instanceof ApiError2 && err.status === 401) {
5894
5956
  error("Session expired. Run `token-rats login` to re-authenticate.");
5895
5957
  process.exit(1);
@@ -5904,459 +5966,282 @@ async function syncCommand(opts) {
5904
5966
  success(
5905
5967
  `Synced ${allSessions.length} sessions (${totalAccepted} new, ${totalDuplicates} already on server) from ${sourceStr}`
5906
5968
  );
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
- 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`.");
5961
- }
5962
5969
  }
5963
5970
 
5964
5971
  // 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
- }
5972
+ import * as fs5 from "node:fs";
5973
+ import * as os4 from "node:os";
5974
+ import * as path4 from "node:path";
5975
+ var HEARTBEAT_MS = 6e4;
5995
5976
  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 });
6008
- return;
5977
+ function stateFilePath() {
5978
+ const xdgConfig = process.env.XDG_CONFIG_HOME ?? path4.join(os4.homedir(), ".config");
5979
+ return path4.join(xdgConfig, "token-rats", "watch-state.json");
5980
+ }
5981
+ function loadWatchState() {
5982
+ try {
5983
+ const raw = fs5.readFileSync(stateFilePath(), "utf8");
5984
+ const parsed = JSON.parse(raw);
5985
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
5986
+ } catch {
5987
+ return {};
6009
5988
  }
5989
+ }
5990
+ function saveWatchState(state) {
6010
5991
  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 });
5992
+ const file = stateFilePath();
5993
+ fs5.mkdirSync(path4.dirname(file), { recursive: true });
5994
+ fs5.writeFileSync(file, JSON.stringify(state), { mode: 384 });
6015
5995
  } 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);
5996
+ if (err instanceof Error) {
5997
+ console.warn(`watch-state write failed: ${err.message}`);
6027
5998
  }
6028
- log.warn(`Upload failed: ${err instanceof Error ? err.message : String(err)}`);
6029
5999
  }
6030
6000
  }
6031
- function freshClaudeRecords(filePath, log) {
6032
- let text;
6001
+ function statFile(p) {
6033
6002
  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);
6003
+ const s = fs5.statSync(p);
6004
+ return { size: s.size, mtimeMs: s.mtimeMs };
6042
6005
  } 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)`);
6006
+ return null;
6055
6007
  }
6056
- return fresh;
6057
6008
  }
6058
- function freshCodexRecords(filePath, log) {
6009
+ function parseAndFilter(filePath, verbose) {
6059
6010
  let text;
6060
6011
  try {
6061
- text = fs8.readFileSync(filePath, "utf8");
6012
+ text = fs5.readFileSync(filePath, "utf8");
6062
6013
  } catch {
6014
+ if (verbose) warn(`Could not read ${filePath} \u2014 skipping`);
6063
6015
  return [];
6064
6016
  }
6065
6017
  let records;
6066
6018
  try {
6067
- records = parseCodex(text);
6019
+ records = parseClaudeCode(text);
6068
6020
  } catch {
6021
+ if (verbose) warn(`Failed to parse ${filePath} \u2014 skipping`);
6069
6022
  return [];
6070
6023
  }
6071
6024
  const fresh = [];
6072
6025
  for (const r of records) {
6073
- if (seen.has(r.dedupeKey)) continue;
6074
- seen.add(r.dedupeKey);
6075
- fresh.push(r);
6026
+ if (!seen.has(r.dedupeKey)) {
6027
+ seen.add(r.dedupeKey);
6028
+ fresh.push(r);
6029
+ }
6076
6030
  }
6077
- if (fresh.length > 0) {
6078
- health.lastSourceAt.codex = (/* @__PURE__ */ new Date()).toISOString();
6079
- log.debug(`${filePath}: ${fresh.length} new Codex session(s)`);
6031
+ if (verbose && fresh.length > 0) {
6032
+ dim(` ${filePath}: ${fresh.length} new session(s)`);
6080
6033
  }
6081
6034
  return fresh;
6082
6035
  }
6036
+ async function upload(client, records, verbose) {
6037
+ if (records.length === 0) return;
6038
+ try {
6039
+ const res = await client.uploadSessions(records);
6040
+ if (verbose) {
6041
+ dim(` Uploaded ${res.accepted} new, ${res.duplicates} duplicate(s)`);
6042
+ } else {
6043
+ success(`Uploaded ${res.accepted} session(s)`);
6044
+ }
6045
+ } catch (err) {
6046
+ if (err instanceof DeviceRevokedError) {
6047
+ markDisconnected();
6048
+ deleteToken();
6049
+ error(
6050
+ "This device was disconnected from the Token Rats web UI. Daemon will exit; re-run `token-rats login` to reconnect."
6051
+ );
6052
+ process.exit(0);
6053
+ }
6054
+ if (err instanceof ApiError2 && err.status === 401) {
6055
+ error("Session expired. Run `token-rats login` to re-authenticate.");
6056
+ process.exit(1);
6057
+ }
6058
+ warn(`Upload failed: ${err instanceof Error ? err.message : String(err)}`);
6059
+ }
6060
+ }
6083
6061
  function makeDebounced(fn, ms) {
6084
6062
  const timers = /* @__PURE__ */ new Map();
6085
- return (p) => {
6086
- const existing = timers.get(p);
6063
+ return (path5) => {
6064
+ const existing = timers.get(path5);
6087
6065
  if (existing) clearTimeout(existing);
6088
6066
  timers.set(
6089
- p,
6067
+ path5,
6090
6068
  setTimeout(() => {
6091
- timers.delete(p);
6092
- fn(p);
6069
+ timers.delete(path5);
6070
+ fn(path5);
6093
6071
  }, ms)
6094
6072
  );
6095
6073
  };
6096
6074
  }
6097
6075
  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);
6076
+ const results = [];
6077
+ if (!fs5.existsSync(dir)) return results;
6078
+ const entries = fs5.readdirSync(dir, { withFileTypes: true });
6079
+ for (const entry of entries) {
6080
+ const full = `${dir}/${entry.name}`;
6081
+ if (entry.isDirectory()) {
6082
+ results.push(...findJsonlFiles2(full));
6083
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
6084
+ results.push(full);
6085
+ }
6104
6086
  }
6105
- return out;
6087
+ return results;
6106
6088
  }
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
- };
6089
+ async function watchCommand(opts) {
6090
+ if (isDisconnected()) {
6091
+ error(
6092
+ "This device was disconnected from the Token Rats web UI. Run `token-rats login` to reconnect."
6093
+ );
6094
+ process.exit(0);
6095
+ }
6096
+ const token = loadToken();
6097
+ if (!token) {
6098
+ error("Not logged in. Run `token-rats login` first.");
6099
+ process.exit(1);
6112
6100
  }
6113
- const debouncedChange = makeDebounced((p) => {
6114
- if (p.endsWith(".jsonl")) onChange(p);
6101
+ const client = new ApiClient({
6102
+ apiUrl: opts.apiUrl,
6103
+ token,
6104
+ deviceId: ensureDeviceId(),
6105
+ cliVersion: CLI_VERSION
6106
+ });
6107
+ const debounceMs = opts.interval ?? 2e3;
6108
+ const dir = claudeCodeProjectsDir();
6109
+ if (!fs5.existsSync(dir)) {
6110
+ warn(`Claude Code projects directory not found: ${dir}`);
6111
+ warn("No files to watch. Exiting.");
6112
+ process.exit(0);
6113
+ }
6114
+ info(`Watching ${dir} (debounce: ${debounceMs}ms)`);
6115
+ info("Press Ctrl-C to stop.\n");
6116
+ const persistedState = loadWatchState();
6117
+ const liveState = /* @__PURE__ */ new Map();
6118
+ const initialFiles = findJsonlFiles2(dir);
6119
+ for (const f of initialFiles) {
6120
+ const cur = statFile(f);
6121
+ if (!cur) continue;
6122
+ liveState.set(f, cur);
6123
+ const prev = persistedState[f];
6124
+ if (prev && cur.size < prev.size) {
6125
+ warn(`Rotation detected on ${f}: size shrank ${prev.size} \u2192 ${cur.size}.`);
6126
+ }
6127
+ parseAndFilter(f, false);
6128
+ }
6129
+ if (opts.verbose) {
6130
+ dim(`Initial snapshot: ${seen.size} session(s) in ${initialFiles.length} file(s)`);
6131
+ }
6132
+ saveWatchState(Object.fromEntries(liveState));
6133
+ const onChanged = makeDebounced(async (filePath) => {
6134
+ if (!filePath.endsWith(".jsonl")) return;
6135
+ const cur = statFile(filePath);
6136
+ if (cur) {
6137
+ const prev = liveState.get(filePath);
6138
+ if (prev && cur.size < prev.size) {
6139
+ warn(`Rotation detected on ${filePath}: size shrank ${prev.size} \u2192 ${cur.size}.`);
6140
+ }
6141
+ liveState.set(filePath, cur);
6142
+ saveWatchState(Object.fromEntries(liveState));
6143
+ }
6144
+ const fresh = parseAndFilter(filePath, opts.verbose ?? false);
6145
+ if (fresh.length > 0) {
6146
+ await upload(client, fresh, opts.verbose ?? false);
6147
+ }
6115
6148
  }, debounceMs);
6149
+ const heartbeatTimer = setInterval(async () => {
6150
+ try {
6151
+ await client.heartbeat();
6152
+ } catch (err) {
6153
+ if (err instanceof DeviceRevokedError) {
6154
+ markDisconnected();
6155
+ deleteToken();
6156
+ error(
6157
+ "This device was disconnected from the Token Rats web UI. Daemon exiting; re-run `token-rats login` to reconnect."
6158
+ );
6159
+ cleanup?.();
6160
+ clearInterval(heartbeatTimer);
6161
+ process.exit(0);
6162
+ }
6163
+ if (opts.verbose) {
6164
+ dim(`Heartbeat failed: ${err instanceof Error ? err.message : String(err)}`);
6165
+ }
6166
+ }
6167
+ }, HEARTBEAT_MS);
6168
+ client.heartbeat().catch(() => {
6169
+ });
6170
+ let cleanup = null;
6116
6171
  try {
6117
- const dynImport = new Function("m", "return import(m)");
6118
- const chokidarMod = await dynImport("chokidar");
6119
- const watcher = chokidarMod.watch(`${dir}/**/*.jsonl`, {
6172
+ const chokidar = await new Function("m", "return import(m)")("chokidar");
6173
+ const watcher = chokidar.watch(`${dir}/**/*.jsonl`, {
6120
6174
  ignoreInitial: true,
6121
6175
  persistent: true,
6122
6176
  awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 }
6123
6177
  });
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
- };
6178
+ watcher.on("add", (p) => onChanged(p));
6179
+ watcher.on("change", (p) => onChanged(p));
6180
+ cleanup = () => watcher.close();
6181
+ if (opts.verbose) dim("Using chokidar for file watching");
6133
6182
  } catch {
6134
- }
6135
- if (process.platform === "linux") {
6136
- let lastMtimes = /* @__PURE__ */ new Map();
6137
- for (const f of findJsonlFiles2(dir)) {
6138
- 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) {
6183
+ if (opts.verbose) dim("chokidar not available; using Node built-in fs.watch");
6184
+ if (process.platform === "linux") {
6185
+ let lastMtimes = /* @__PURE__ */ new Map();
6186
+ for (const f of initialFiles) {
6146
6187
  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
- }
6188
+ lastMtimes.set(f, fs5.statSync(f).mtimeMs);
6153
6189
  } catch {
6154
6190
  }
6155
6191
  }
6156
- for (const f of current) {
6157
- if (!lastMtimes.has(f)) {
6158
- lastMtimes.set(f, Date.now());
6159
- debouncedChange(f);
6192
+ const pollInterval = setInterval(() => {
6193
+ const current = findJsonlFiles2(dir);
6194
+ for (const f of current) {
6195
+ try {
6196
+ const mtime = fs5.statSync(f).mtimeMs;
6197
+ const prev = lastMtimes.get(f) ?? 0;
6198
+ if (mtime > prev) {
6199
+ lastMtimes.set(f, mtime);
6200
+ onChanged(f);
6201
+ }
6202
+ } catch {
6203
+ }
6160
6204
  }
6161
- }
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));
6205
+ for (const f of current) {
6206
+ if (!lastMtimes.has(f)) {
6207
+ lastMtimes.set(f, Date.now());
6208
+ onChanged(f);
6209
+ }
6174
6210
  }
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;
6211
+ lastMtimes = new Map(current.map((f) => [f, lastMtimes.get(f) ?? 0]));
6212
+ }, debounceMs);
6213
+ cleanup = () => clearInterval(pollInterval);
6214
+ } else {
6215
+ const controller = new AbortController();
6216
+ (async () => {
6217
+ try {
6218
+ const { watch } = await import("node:fs/promises");
6219
+ const watcher = watch(dir, { recursive: true, signal: controller.signal });
6220
+ for await (const event of watcher) {
6221
+ const filename = event.filename;
6222
+ if (filename?.endsWith(".jsonl")) {
6223
+ const fullPath = `${dir}/${filename}`;
6224
+ onChanged(fullPath);
6225
+ }
6226
+ }
6227
+ } catch (err) {
6228
+ if (err instanceof Error && err.name !== "AbortError") {
6229
+ warn(`Watcher error: ${err.message}`);
6230
+ }
6231
+ }
6232
+ })();
6233
+ cleanup = () => controller.abort();
6225
6234
  }
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
6235
  }
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();
6236
+ function shutdown() {
6237
+ info("Shutting down\u2026");
6238
+ clearInterval(heartbeatTimer);
6239
+ if (cleanup) cleanup();
6299
6240
  process.exit(0);
6300
- };
6241
+ }
6301
6242
  process.on("SIGINT", shutdown);
6302
6243
  process.on("SIGTERM", shutdown);
6303
6244
  }
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
- );
6322
- }
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)"}`);
6352
- }
6353
- }
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
6245
 
6361
6246
  // src/commands/whoami.ts
6362
6247
  async function whoamiCommand(opts) {
@@ -6365,11 +6250,25 @@ async function whoamiCommand(opts) {
6365
6250
  error("Not logged in. Run `token-rats login` first.");
6366
6251
  process.exit(1);
6367
6252
  }
6368
- const client = new ApiClient({ apiUrl: opts.apiUrl, token });
6253
+ const client = new ApiClient({
6254
+ apiUrl: opts.apiUrl,
6255
+ token,
6256
+ deviceId: ensureDeviceId(),
6257
+ cliVersion: CLI_VERSION
6258
+ });
6369
6259
  try {
6370
6260
  const res = await client.getMe();
6371
6261
  info(`Signed in as \x1B[1m@${res.user.handle}\x1B[0m`);
6262
+ info(`Device id: ${ensureDeviceId()}`);
6372
6263
  } catch (err) {
6264
+ if (err instanceof DeviceRevokedError) {
6265
+ markDisconnected();
6266
+ deleteToken();
6267
+ error(
6268
+ "This device was disconnected from the Token Rats web UI. Run `token-rats login` to reconnect."
6269
+ );
6270
+ process.exit(1);
6271
+ }
6373
6272
  if (err instanceof ApiError2 && err.status === 401) {
6374
6273
  error("Your session has expired. Run `token-rats login` to re-authenticate.");
6375
6274
  process.exit(1);
@@ -6391,19 +6290,17 @@ function printHelp() {
6391
6290
  token-rats <command> [flags]
6392
6291
 
6393
6292
  \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
6293
+ login Authenticate with Token Rats (opens browser); installs the background watcher by default
6294
+ sync Read local Claude Code + Cursor logs and upload counts
6295
+ watch Watch logs in real-time; upload new sessions as they appear
6296
+ whoami Show the currently signed-in account + device id
6297
+ logout Clear your stored credentials
6298
+ install-daemon Install the background watcher (runs at logon)
6299
+ uninstall-daemon Remove the background watcher
6300
+ daemon-status Show whether the background watcher is running
6301
+ install-cursor Install better-sqlite3 globally for faster Cursor reads
6302
+ (sql.js works out of the box \u2014 this is opt-in speed-up)
6303
+ help Show this help message
6407
6304
 
6408
6305
  \x1B[1mFlags (all commands):\x1B[0m
6409
6306
  --api-url <url> Override API URL (default: https://api.tokenrats.com)
@@ -6411,15 +6308,8 @@ function printHelp() {
6411
6308
  \x1B[1mFlags (sync only):\x1B[0m
6412
6309
  --dry-run Parse but do not upload; print what would be sent
6413
6310
  --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
6311
 
6417
6312
  \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
6313
  --interval <ms> Debounce window in ms before uploading (default: 2000)
6424
6314
  --verbose Print file change events and upload detail
6425
6315
 
@@ -6449,10 +6339,6 @@ function parseArgs(argv) {
6449
6339
  let verbose = false;
6450
6340
  let interval;
6451
6341
  let noDaemon = false;
6452
- let daemon = false;
6453
- let install = false;
6454
- let uninstall = false;
6455
- let status = false;
6456
6342
  let i = 0;
6457
6343
  while (i < argv.length) {
6458
6344
  const arg = argv[i];
@@ -6470,14 +6356,6 @@ function parseArgs(argv) {
6470
6356
  interval = Number(arg.slice("--interval=".length));
6471
6357
  } else if (arg === "--no-daemon") {
6472
6358
  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
6359
  } else if (arg === "--version" || arg === "-V") {
6482
6360
  console.log(getVersion());
6483
6361
  process.exit(0);
@@ -6490,56 +6368,24 @@ function parseArgs(argv) {
6490
6368
  i++;
6491
6369
  }
6492
6370
  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
- };
6371
+ return { command, apiUrl, dryRun, verbose, interval, noDaemon, rest };
6506
6372
  }
6507
6373
  async function main() {
6508
6374
  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;
6375
+ const { command, apiUrl, dryRun, verbose, interval, noDaemon } = args;
6521
6376
  if (!command || command === "help") {
6522
6377
  printHelp();
6523
6378
  process.exit(0);
6524
6379
  }
6525
6380
  switch (command) {
6526
6381
  case "login":
6527
- await loginCommand({ apiUrl });
6382
+ await loginCommand({ apiUrl, noDaemon });
6528
6383
  break;
6529
6384
  case "sync":
6530
- await syncCommand({ apiUrl, dryRun, verbose, noDaemon });
6385
+ await syncCommand({ apiUrl, dryRun, verbose });
6531
6386
  break;
6532
6387
  case "watch":
6533
- await watchCommand({
6534
- apiUrl,
6535
- verbose,
6536
- interval,
6537
- daemon,
6538
- install,
6539
- uninstall,
6540
- status,
6541
- version: getVersion()
6542
- });
6388
+ await watchCommand({ apiUrl, verbose, interval });
6543
6389
  break;
6544
6390
  case "whoami":
6545
6391
  await whoamiCommand({ apiUrl });
@@ -6550,6 +6396,15 @@ async function main() {
6550
6396
  case "install-cursor":
6551
6397
  await installCursorCommand();
6552
6398
  break;
6399
+ case "install-daemon":
6400
+ await installDaemonCommand();
6401
+ break;
6402
+ case "uninstall-daemon":
6403
+ await uninstallDaemonCommand();
6404
+ break;
6405
+ case "daemon-status":
6406
+ await daemonStatusCommand();
6407
+ break;
6553
6408
  default:
6554
6409
  console.error(`\x1B[31mUnknown command: ${command}\x1B[0m`);
6555
6410
  console.error("Run \x1B[1mtoken-rats help\x1B[0m for a list of commands.");