token-rats 0.0.4 → 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 (2) hide show
  1. package/dist/index.js +954 -301
  2. package/package.json +1 -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: path3, errorMaps, issueData } = params;
389
- const fullPath = [...path3, ...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, path3, key) {
884
+ constructor(parent, value, path5, key) {
512
885
  this._cachedPath = [];
513
886
  this.parent = parent;
514
887
  this.data = value;
515
- this._path = path3;
888
+ this._path = path5;
516
889
  this._key = key;
517
890
  }
518
891
  get path() {
@@ -3948,12 +4321,30 @@ var z = /* @__PURE__ */ Object.freeze({
3948
4321
 
3949
4322
  // ../contracts/src/session.ts
3950
4323
  var Source = z.enum(["claude-code", "cursor", "codex"]);
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"]);
3951
4333
  var SessionRecord = z.object({
3952
4334
  id: z.string().min(1),
3953
4335
  source: Source,
4336
+ provider: Provider.optional(),
4337
+ client: z.string().min(1).max(64).optional(),
4338
+ channel: SessionChannel.optional(),
3954
4339
  model: z.string().min(1),
3955
4340
  inTokens: z.number().int().nonnegative(),
3956
4341
  outTokens: z.number().int().nonnegative(),
4342
+ /** Anthropic cache reads / OpenAI `cached_input_tokens`. Billed cheap-or-free. */
4343
+ cacheReadTokens: z.number().int().nonnegative().optional(),
4344
+ /** Anthropic cache creation tokens. Billed at a premium for first-write only. */
4345
+ cacheWriteTokens: z.number().int().nonnegative().optional(),
4346
+ /** OpenAI reasoning output tokens (o-series / Codex). Billed at output rate. */
4347
+ reasoningTokens: z.number().int().nonnegative().optional(),
3957
4348
  costUsdCents: z.number().int().nonnegative(),
3958
4349
  startedAt: z.number().int().positive(),
3959
4350
  endedAt: z.number().int().positive(),
@@ -3961,6 +4352,12 @@ var SessionRecord = z.object({
3961
4352
  });
3962
4353
 
3963
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
+ });
3964
4361
  var User = z.object({
3965
4362
  id: z.string(),
3966
4363
  handle: z.string(),
@@ -3968,19 +4365,42 @@ var User = z.object({
3968
4365
  /** Present only when the caller is the user themselves or the user is public. */
3969
4366
  bio: z.string().max(200).nullable().optional(),
3970
4367
  twitterHandle: z.string().max(50).nullable().optional(),
3971
- publicProfile: z.boolean().optional()
4368
+ /**
4369
+ * True iff `twitter_handle` came from the OAuth flow (i.e. `twitter_user_id`
4370
+ * is set). Self-only — used by settings UI to distinguish manual legacy
4371
+ * handles from verified ones.
4372
+ */
4373
+ twitterVerified: z.boolean().optional(),
4374
+ publicProfile: z.boolean().optional(),
4375
+ /**
4376
+ * Primary verified GitHub email, captured at OAuth callback time. Self-only
4377
+ * (other readers never see this field). `null` when GitHub didn't return a
4378
+ * verified email — the user sees a banner asking them to add one.
4379
+ */
4380
+ email: z.string().email().nullable().optional()
3972
4381
  });
3973
4382
  var PublicProfileSettings = z.object({
3974
4383
  publicProfile: z.boolean().optional(),
3975
- bio: z.string().max(200).nullable().optional(),
3976
- twitterHandle: z.string().max(50).nullable().optional()
4384
+ bio: z.string().max(200).nullable().optional()
3977
4385
  });
3978
4386
  var Profile = User.extend({
3979
4387
  totals: z.object({
3980
4388
  today: z.object({ tokens: z.number().int(), costUsdCents: z.number().int() }),
3981
4389
  week: z.object({ tokens: z.number().int(), costUsdCents: z.number().int() }),
3982
4390
  allTime: z.object({ tokens: z.number().int(), costUsdCents: z.number().int() })
3983
- })
4391
+ }),
4392
+ /** Number of users this user has brought in via their referral link. */
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(),
4398
+ /**
4399
+ * The profile owner's referral code. Self-only — present only when the
4400
+ * caller is viewing their own profile, so the page can render a
4401
+ * copy-able invite link without a separate fetch to /v1/me/referral.
4402
+ */
4403
+ referralCode: z.string().optional()
3984
4404
  });
3985
4405
  var AutobiographyStats = z.object({
3986
4406
  handle: z.string(),
@@ -4016,13 +4436,24 @@ var Room = z.object({
4016
4436
  name: z.string().min(1).max(64),
4017
4437
  ownerId: z.string(),
4018
4438
  orgId: z.string().nullable(),
4019
- createdAt: z.number().int().positive()
4439
+ createdAt: z.number().int().positive(),
4440
+ /** v1.2: public country-locked groups. `country` is set iff `isPublic` is true. */
4441
+ isPublic: z.boolean(),
4442
+ /** ISO-3166-1 alpha-2 (`cf-ipcountry` of the creator). Null on private rooms. */
4443
+ country: z.string().min(2).max(2).nullable(),
4444
+ /**
4445
+ * True iff this room is the caller's pinned room. Populated only on
4446
+ * caller-scoped responses (e.g. GET /v1/me/rooms); absent elsewhere.
4447
+ */
4448
+ isPinned: z.boolean().optional()
4020
4449
  });
4021
4450
  var RoomMember = z.object({
4022
4451
  userId: z.string(),
4023
4452
  handle: z.string(),
4024
4453
  avatarUrl: z.string().url().nullable(),
4025
- joinedAt: z.number().int().positive()
4454
+ joinedAt: z.number().int().positive(),
4455
+ /** OAuth-verified X handle, when present. Manual handles are not surfaced. */
4456
+ twitterHandle: z.string().max(50).nullable().optional()
4026
4457
  });
4027
4458
 
4028
4459
  // ../contracts/src/leaderboard.ts
@@ -4039,8 +4470,14 @@ var LeaderboardRow = z.object({
4039
4470
  tokens: z.number().int().nonnegative(),
4040
4471
  costUsdCents: z.number().int().nonnegative(),
4041
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),
4042
4475
  /** Up to 2 dominant sources by token volume, descending. May be empty. */
4043
- 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([])
4044
4481
  });
4045
4482
  var Leaderboard = z.object({
4046
4483
  range: LeaderboardRange,
@@ -4088,8 +4525,25 @@ var ChallengeWithLeaderboard = Challenge.extend({
4088
4525
  winnerHandle: z.string().nullable()
4089
4526
  });
4090
4527
 
4528
+ // ../contracts/src/referral.ts
4529
+ var ReferredUser = z.object({
4530
+ handle: z.string(),
4531
+ avatarUrl: z.string().url().nullable(),
4532
+ /** Unix ms — when the referred user signed up. */
4533
+ createdAt: z.number().int()
4534
+ });
4535
+ var ReferralStats = z.object({
4536
+ /** The user's unique referral code (URL-safe, ~8 chars). */
4537
+ code: z.string(),
4538
+ /** Total users referred. */
4539
+ count: z.number().int().nonnegative(),
4540
+ /** Most recent referred users (newest first), capped server-side. */
4541
+ recent: z.array(ReferredUser)
4542
+ });
4543
+
4091
4544
  // ../contracts/src/org.ts
4092
- var OrgPlan = z.enum(["free", "pro"]);
4545
+ var OrgPlan = z.enum(["free", "student", "pro"]);
4546
+ var OrgStatus = z.enum(["pending", "approved"]);
4093
4547
  var OrgMemberRole = z.enum(["owner", "admin", "member"]);
4094
4548
  var OrgSlug = z.string().min(3).max(48).regex(/^[a-z0-9-]+$/, "Slug must be lowercase letters, numbers, and hyphens only");
4095
4549
  var Org = z.object({
@@ -4099,7 +4553,12 @@ var Org = z.object({
4099
4553
  plan: OrgPlan,
4100
4554
  seatCount: z.number().int().nonnegative(),
4101
4555
  githubOrgLogin: z.string().nullable(),
4102
- createdAt: z.number().int().positive()
4556
+ createdAt: z.number().int().positive(),
4557
+ /** v1.2: soft-create status. Approved orgs are fully usable; pending orgs are gated. */
4558
+ status: OrgStatus,
4559
+ requestedPlan: OrgPlan.nullable(),
4560
+ founderEmail: z.string().email().nullable(),
4561
+ founderName: z.string().nullable()
4103
4562
  });
4104
4563
  var OrgMember = z.object({
4105
4564
  userId: z.string(),
@@ -4144,8 +4603,17 @@ var OrgDashboard = z.object({
4144
4603
  var CreateOrgRequest = z.object({
4145
4604
  name: z.string().min(1).max(64),
4146
4605
  slug: OrgSlug,
4147
- githubOrgLogin: z.string().optional()
4606
+ githubOrgLogin: z.string().optional(),
4607
+ // v1.2 soft-create — required fields, trust-on-submit.
4608
+ founderEmail: z.string().email(),
4609
+ founderName: z.string().min(1).max(80).optional(),
4610
+ requestedPlan: OrgPlan
4611
+ });
4612
+ var PatchOrgRequest = z.object({
4613
+ founderEmail: z.string().email().optional(),
4614
+ founderName: z.string().min(1).max(80).optional()
4148
4615
  });
4616
+ var PatchOrgResponse = z.object({ org: Org });
4149
4617
  var CreateOrgResponse = z.object({ org: Org });
4150
4618
  var GetOrgResponse = z.object({
4151
4619
  org: Org,
@@ -4160,6 +4628,20 @@ var CreateOrgInviteRequest = z.object({
4160
4628
  var CreateOrgInviteResponse = z.object({ invite: OrgInvite });
4161
4629
  var AcceptOrgInviteResponse = z.object({ ok: z.boolean() });
4162
4630
  var GetOrgDashboardResponse = z.object({ dashboard: OrgDashboard });
4631
+ var AdminPendingOrg = z.object({
4632
+ id: z.string(),
4633
+ name: z.string(),
4634
+ slug: OrgSlug.nullable(),
4635
+ requestedPlan: OrgPlan.nullable(),
4636
+ founderEmail: z.string().email().nullable(),
4637
+ founderName: z.string().nullable(),
4638
+ founderHandle: z.string(),
4639
+ createdAt: z.number().int().positive()
4640
+ });
4641
+ var GetPendingOrgsResponse = z.object({
4642
+ orgs: z.array(AdminPendingOrg)
4643
+ });
4644
+ var ApproveOrgResponse = z.object({ org: Org });
4163
4645
 
4164
4646
  // ../contracts/src/api.ts
4165
4647
  var GetMeResponse = z.object({ user: User });
@@ -4171,7 +4653,9 @@ var UploadSessionsResponse = z.object({
4171
4653
  duplicates: z.number().int().nonnegative()
4172
4654
  });
4173
4655
  var CreateRoomRequest = z.object({
4174
- name: z.string().min(1).max(64)
4656
+ name: z.string().min(1).max(64),
4657
+ /** v1.2: when true the room is publicly listed in /groups for its country. */
4658
+ isPublic: z.boolean().default(false)
4175
4659
  });
4176
4660
  var CreateRoomResponse = z.object({ room: Room });
4177
4661
  var JoinRoomResponse = z.object({ room: Room });
@@ -4191,14 +4675,60 @@ var HeatmapDay = z.object({
4191
4675
  tokens: z.number().int().nonnegative(),
4192
4676
  sessions: z.number().int().nonnegative()
4193
4677
  });
4678
+ var HeatmapRange = z.enum(["30d", "52w"]);
4194
4679
  var Heatmap = z.object({
4680
+ range: HeatmapRange,
4195
4681
  from: z.string(),
4196
4682
  // YYYY-MM-DD UTC, inclusive
4197
4683
  to: z.string(),
4198
4684
  // YYYY-MM-DD UTC, inclusive
4199
4685
  days: z.array(HeatmapDay)
4200
4686
  });
4687
+ var GetHeatmapQuery = z.object({
4688
+ range: HeatmapRange.default("30d")
4689
+ });
4201
4690
  var GetHeatmapResponse = z.object({ heatmap: Heatmap });
4691
+ var RoomSummary = z.object({
4692
+ code: z.string(),
4693
+ name: z.string(),
4694
+ /** Future: true once feature #6 lands. Always false for now. */
4695
+ isPublic: z.boolean(),
4696
+ /** ISO country code (e.g. "DE") when isPublic is true; null otherwise. */
4697
+ country: z.string().nullable(),
4698
+ memberCount: z.number().int().nonnegative(),
4699
+ total30dTokens: z.number().int().nonnegative(),
4700
+ total30dCostUsdCents: z.number().int().nonnegative()
4701
+ });
4702
+ var GetRoomSummaryResponse = z.object({ summary: RoomSummary });
4703
+ var PublicGroupRow = z.object({
4704
+ code: z.string(),
4705
+ name: z.string(),
4706
+ country: z.string().min(2).max(2),
4707
+ memberCount: z.number().int().nonnegative(),
4708
+ total30dTokens: z.number().int().nonnegative(),
4709
+ total30dCostUsdCents: z.number().int().nonnegative()
4710
+ });
4711
+ var CountryBoardRow = z.object({
4712
+ rank: z.number().int().positive(),
4713
+ userId: z.string(),
4714
+ handle: z.string(),
4715
+ avatarUrl: z.string().url().nullable(),
4716
+ tokens: z.number().int().nonnegative(),
4717
+ costUsdCents: z.number().int().nonnegative(),
4718
+ sessions: z.number().int().nonnegative()
4719
+ });
4720
+ var GetGroupsResponse = z.object({
4721
+ country: z.string().min(2).max(2).nullable(),
4722
+ groups: z.array(PublicGroupRow),
4723
+ /** Public users in the viewer's country, ranked by trailing-30d tokens. */
4724
+ userBoard: z.array(CountryBoardRow)
4725
+ });
4726
+ var GroupStreak = z.object({
4727
+ currentStreak: z.number().int().nonnegative(),
4728
+ /** Yesterday in YYYY-MM-DD UTC. */
4729
+ asOf: z.string()
4730
+ });
4731
+ var GetGroupStreakResponse = z.object({ groupStreak: GroupStreak });
4202
4732
  var GetMyRoomsResponse = z.object({
4203
4733
  rooms: z.array(Room)
4204
4734
  });
@@ -4234,6 +4764,7 @@ var GetTrendingResponse = z.object({
4234
4764
  range: LeaderboardRange,
4235
4765
  generatedAt: z.number().int().positive()
4236
4766
  });
4767
+ var GetReferralResponse = z.object({ referral: ReferralStats });
4237
4768
  var ReportAbuseRequest = z.object({
4238
4769
  targetHandle: z.string().min(1).max(100),
4239
4770
  reason: z.string().min(1).max(500)
@@ -4249,14 +4780,22 @@ var ENDPOINTS = {
4249
4780
  profile: (handle) => `/v1/u/${handle}`,
4250
4781
  autobiography: (handle) => `/v1/u/${handle}/autobiography`,
4251
4782
  profileHeatmap: (handle) => `/v1/u/${handle}/heatmap`,
4783
+ // v1.2 room aggregates — auth optional, accessible to non-members.
4784
+ roomSummary: (code) => `/v1/r/${code}/summary`,
4785
+ roomHeatmap: (code) => `/v1/r/${code}/heatmap`,
4786
+ roomGroupStreak: (code) => `/v1/r/${code}/group-streak`,
4787
+ // v1.2 public country-locked groups list
4788
+ groups: "/v1/groups",
4252
4789
  authGithubStart: "/v1/auth/github/start",
4253
4790
  authGithubCallback: "/v1/auth/github/callback",
4791
+ authLogout: "/v1/auth/logout",
4254
4792
  authCliExchange: "/v1/auth/cli/exchange",
4255
4793
  authCliPoll: "/v1/auth/cli/poll",
4256
4794
  // Phase 2 Track G+H
4257
4795
  meRooms: "/v1/me/rooms",
4258
4796
  leaveRoom: (code) => `/v1/rooms/${code}/leave`,
4259
4797
  renameRoom: (code) => `/v1/rooms/${code}`,
4798
+ pinRoom: (code) => `/v1/rooms/${code}/pin`,
4260
4799
  roomActivity: (code) => `/v1/rooms/${code}/activity`,
4261
4800
  roomStreaks: (code) => `/v1/rooms/${code}/streaks`,
4262
4801
  roomChallenges: (code) => `/v1/rooms/${code}/challenges`,
@@ -4270,6 +4809,16 @@ var ENDPOINTS = {
4270
4809
  patchMe: "/v1/me",
4271
4810
  trending: "/v1/trending",
4272
4811
  reportAbuse: "/v1/abuse/report",
4812
+ // Affiliate / referral tracking
4813
+ meReferral: "/v1/me/referral",
4814
+ // v1.2 Track AD — friends derived from shared private rooms
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",
4273
4822
  // Phase 3 Track O — Org plan
4274
4823
  orgs: "/v1/orgs",
4275
4824
  org: (slug) => `/v1/orgs/${slug}`,
@@ -4283,7 +4832,10 @@ var ENDPOINTS = {
4283
4832
  // Admin analytics (project-owner only)
4284
4833
  adminSignups: "/v1/admin/signups",
4285
4834
  adminActivity: "/v1/admin/activity",
4286
- adminReferrers: "/v1/admin/referrers"
4835
+ adminReferrers: "/v1/admin/referrers",
4836
+ // Admin org approval (v1.2)
4837
+ adminOrgsPending: "/v1/admin/orgs/pending",
4838
+ adminOrgApprove: (slug) => `/v1/admin/orgs/${slug}/approve`
4287
4839
  };
4288
4840
 
4289
4841
  // ../contracts/src/errors.ts
@@ -4369,20 +4921,98 @@ var AdminActivityResponse = z.object({
4369
4921
  generatedAt: z.number().int().positive()
4370
4922
  });
4371
4923
  var AdminReferrerRow = z.object({
4372
- /** A label describing the source of the signal. */
4373
- label: z.string(),
4374
- /** Number of users (or events) attributed to this label. */
4924
+ handle: z.string(),
4925
+ avatarUrl: z.string().url().nullable(),
4926
+ /** Number of users this referrer has brought in. */
4375
4927
  count: z.number().int().nonnegative()
4376
4928
  });
4377
4929
  var AdminReferrersResponse = z.object({
4378
- /** True iff a real referral attribution column / table exists. */
4379
- tracked: z.boolean(),
4380
- /** Description of what `rows` represents (e.g. "First CLI source"). */
4381
- signal: z.string(),
4382
4930
  rows: z.array(AdminReferrerRow),
4383
4931
  generatedAt: z.number().int().positive()
4384
4932
  });
4385
4933
 
4934
+ // ../contracts/src/friends.ts
4935
+ var FriendSharedRoom = z.object({
4936
+ code: z.string(),
4937
+ name: z.string()
4938
+ });
4939
+ var FriendRow = z.object({
4940
+ userId: z.string(),
4941
+ handle: z.string(),
4942
+ avatarUrl: z.string().url().nullable(),
4943
+ twitterHandle: z.string().nullable().optional(),
4944
+ publicProfile: z.boolean(),
4945
+ sharedRooms: z.array(FriendSharedRoom),
4946
+ tokens: z.number().int().nonnegative(),
4947
+ costUsdCents: z.number().int().nonnegative(),
4948
+ sessions: z.number().int().nonnegative()
4949
+ });
4950
+ var FriendsResponse = z.object({
4951
+ range: LeaderboardRange,
4952
+ friends: z.array(FriendRow)
4953
+ });
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
+
4386
5016
  // src/lib/api.ts
4387
5017
  var DEFAULT_API_URL = "https://api.tokenrats.com";
4388
5018
  function isTransient(status) {
@@ -4399,33 +5029,40 @@ var ApiError2 = class extends Error {
4399
5029
  this.name = "ApiError";
4400
5030
  }
4401
5031
  };
5032
+ var DeviceRevokedError = class extends ApiError2 {
5033
+ constructor(body) {
5034
+ super(401, body);
5035
+ this.name = "DeviceRevokedError";
5036
+ }
5037
+ };
4402
5038
  var ApiClient = class {
4403
5039
  apiUrl;
4404
5040
  token;
5041
+ deviceId;
5042
+ cliVersion;
4405
5043
  constructor(opts = {}) {
4406
5044
  this.apiUrl = (opts.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
4407
5045
  this.token = opts.token;
5046
+ this.deviceId = opts.deviceId;
5047
+ this.cliVersion = opts.cliVersion;
4408
5048
  }
4409
5049
  setToken(token) {
4410
5050
  this.token = token;
4411
5051
  }
4412
5052
  headers() {
4413
5053
  const h = { "Content-Type": "application/json" };
4414
- if (this.token) {
4415
- h.Authorization = `Bearer ${this.token}`;
4416
- }
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;
4417
5057
  return h;
4418
5058
  }
4419
- /** Perform a fetch with retry+backoff. maxRetries=3, delays: 1s, 2s, 4s. */
4420
5059
  async fetchWithRetry(url, init, maxRetries = 3) {
4421
5060
  let attempt = 0;
4422
5061
  let lastErr;
4423
5062
  while (attempt <= maxRetries) {
4424
5063
  try {
4425
5064
  const res = await fetch(url, init);
4426
- if (res.ok || !isTransient(res.status)) {
4427
- return res;
4428
- }
5065
+ if (res.ok || !isTransient(res.status)) return res;
4429
5066
  lastErr = new ApiError2(res.status, await res.text());
4430
5067
  } catch (err) {
4431
5068
  lastErr = err;
@@ -4437,34 +5074,37 @@ var ApiClient = class {
4437
5074
  }
4438
5075
  throw lastErr;
4439
5076
  }
4440
- async post(path3, body) {
4441
- const url = `${this.apiUrl}${path3}`;
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}`;
4442
5086
  const res = await this.fetchWithRetry(url, {
4443
5087
  method: "POST",
4444
5088
  headers: this.headers(),
4445
5089
  body: JSON.stringify(body)
4446
5090
  });
4447
- if (!res.ok) {
4448
- throw new ApiError2(res.status, await res.text());
4449
- }
5091
+ if (!res.ok) throw await this.failedResponseToError(res);
4450
5092
  return res.json();
4451
5093
  }
4452
- async get(path3) {
4453
- const url = `${this.apiUrl}${path3}`;
5094
+ async get(path5) {
5095
+ const url = `${this.apiUrl}${path5}`;
4454
5096
  const res = await this.fetchWithRetry(url, {
4455
5097
  method: "GET",
4456
5098
  headers: this.headers()
4457
5099
  });
4458
- if (!res.ok) {
4459
- throw new ApiError2(res.status, await res.text());
4460
- }
5100
+ if (!res.ok) throw await this.failedResponseToError(res);
4461
5101
  return res.json();
4462
5102
  }
4463
5103
  /** Initiate device-code flow. */
4464
5104
  async cliExchange() {
4465
5105
  return this.post(ENDPOINTS.authCliExchange, {});
4466
5106
  }
4467
- /** 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. */
4468
5108
  async cliPoll(pollToken) {
4469
5109
  const url = `${this.apiUrl}${ENDPOINTS.authCliPoll}`;
4470
5110
  const res = await fetch(url, {
@@ -4476,12 +5116,8 @@ var ApiClient = class {
4476
5116
  const data = await res.json();
4477
5117
  return data.token;
4478
5118
  }
4479
- if (res.status === 202) {
4480
- return null;
4481
- }
4482
- if (res.status === 410) {
4483
- throw new ApiError2(410, "Code expired");
4484
- }
5119
+ if (res.status === 202) return null;
5120
+ if (res.status === 410) throw new ApiError2(410, "Code expired");
4485
5121
  throw new ApiError2(res.status, await res.text());
4486
5122
  }
4487
5123
  /** GET /v1/me */
@@ -4492,106 +5128,18 @@ var ApiClient = class {
4492
5128
  async uploadSessions(sessions) {
4493
5129
  return this.post(ENDPOINTS.sessions, { sessions });
4494
5130
  }
4495
- };
4496
-
4497
- // src/lib/auth-store.ts
4498
- import * as fs from "node:fs";
4499
- import * as os from "node:os";
4500
- import * as path from "node:path";
4501
- function tokenDir() {
4502
- const xdgConfig = process.env.XDG_CONFIG_HOME;
4503
- const base = xdgConfig ?? path.join(os.homedir(), ".config");
4504
- return path.join(base, "token-rats");
4505
- }
4506
- function tokenPath() {
4507
- return path.join(tokenDir(), "token");
4508
- }
4509
- function saveToken(token) {
4510
- const dir = tokenDir();
4511
- fs.mkdirSync(dir, { recursive: true });
4512
- const file = tokenPath();
4513
- fs.writeFileSync(file, token, { encoding: "utf8", mode: 384 });
4514
- }
4515
- function loadToken() {
4516
- const file = tokenPath();
4517
- try {
4518
- const token = fs.readFileSync(file, "utf8").trim();
4519
- return token.length > 0 ? token : null;
4520
- } catch {
4521
- return null;
5131
+ /** GET /v1/me/devices */
5132
+ async getDevices() {
5133
+ return this.get(ENDPOINTS.meDevices);
4522
5134
  }
4523
- }
4524
- function deleteToken() {
4525
- const file = tokenPath();
4526
- try {
4527
- fs.unlinkSync(file);
4528
- } catch {
5135
+ /** POST /v1/me/devices/heartbeat */
5136
+ async heartbeat() {
5137
+ return this.post(ENDPOINTS.meDeviceHeartbeat, {});
4529
5138
  }
4530
- }
4531
- function isLoggedIn() {
4532
- return loadToken() !== null;
4533
- }
4534
-
4535
- // src/lib/log.ts
4536
- var ESC = "\x1B";
4537
- var c = {
4538
- reset: `${ESC}[0m`,
4539
- bold: `${ESC}[1m`,
4540
- dim: `${ESC}[2m`,
4541
- green: `${ESC}[32m`,
4542
- yellow: `${ESC}[33m`,
4543
- cyan: `${ESC}[36m`,
4544
- red: `${ESC}[31m`,
4545
- gray: `${ESC}[90m`
4546
5139
  };
4547
- function strip(s) {
4548
- return s.replace(/\x1b\[[0-9;]*m/g, "");
4549
- }
4550
- function isTTY() {
4551
- return process.stdout.isTTY === true;
4552
- }
4553
- function color(code, text) {
4554
- return isTTY() ? `${code}${text}${c.reset}` : strip(text);
4555
- }
4556
- function info(msg) {
4557
- console.log(color(c.cyan, ` ${msg}`));
4558
- }
4559
- function success(msg) {
4560
- console.log(color(c.green, `\u2713 ${msg}`));
4561
- }
4562
- function warn(msg) {
4563
- console.warn(color(c.yellow, `\u26A0 ${msg}`));
4564
- }
4565
- function error(msg) {
4566
- console.error(color(c.red, `\u2717 ${msg}`));
4567
- }
4568
- function dim(msg) {
4569
- console.log(color(c.dim, ` ${msg}`));
4570
- }
4571
- function spinner(label) {
4572
- if (!isTTY()) {
4573
- process.stdout.write(` ${label}...
4574
- `);
4575
- return {
4576
- stop(finalMsg) {
4577
- if (finalMsg) process.stdout.write(` ${finalMsg}
4578
- `);
4579
- }
4580
- };
4581
- }
4582
- const frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
4583
- let i = 0;
4584
- const interval = setInterval(() => {
4585
- process.stdout.write(`\r${c.cyan}${frames[i++ % frames.length]}${c.reset} ${label} `);
4586
- }, 80);
4587
- return {
4588
- stop(finalMsg) {
4589
- clearInterval(interval);
4590
- process.stdout.write("\r\x1B[K");
4591
- if (finalMsg) success(finalMsg);
4592
- }
4593
- };
4594
- }
5140
+
5141
+ // src/lib/cli-version.ts
5142
+ var CLI_VERSION = "0.2.0";
4595
5143
 
4596
5144
  // src/commands/login.ts
4597
5145
  async function openBrowser(url) {
@@ -4604,12 +5152,12 @@ async function openBrowser(url) {
4604
5152
  } catch {
4605
5153
  }
4606
5154
  try {
4607
- const { execFile } = await import("node:child_process");
4608
- const { promisify } = await import("node:util");
4609
- 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);
4610
5158
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
4611
5159
  const args = process.platform === "win32" ? ["/c", "start", url] : [url];
4612
- await exec(cmd, args).catch(() => null);
5160
+ await exec2(cmd, args).catch(() => null);
4613
5161
  } catch {
4614
5162
  }
4615
5163
  }
@@ -4625,7 +5173,12 @@ async function copyToClipboard(text) {
4625
5173
  return false;
4626
5174
  }
4627
5175
  async function loginCommand(opts) {
4628
- 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
+ });
4629
5182
  info("Authenticating with Token Rats\u2026");
4630
5183
  let exchange;
4631
5184
  try {
@@ -4673,7 +5226,16 @@ async function loginCommand(opts) {
4673
5226
  process.exit(1);
4674
5227
  }
4675
5228
  saveToken(token);
5229
+ clearDisconnected();
4676
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();
4677
5239
  }
4678
5240
 
4679
5241
  // src/commands/logout.ts
@@ -4687,68 +5249,7 @@ function logoutCommand() {
4687
5249
  }
4688
5250
 
4689
5251
  // src/commands/sync.ts
4690
- import * as fs3 from "node:fs";
4691
-
4692
- // ../pricing/src/prices.ts
4693
- var prices = {
4694
- models: {
4695
- // ── Anthropic / Claude ────────────────────────────────────────────────
4696
- "claude-opus-4-7": { inputPerMTok: 15, outputPerMTok: 75 },
4697
- "claude-opus-4-6": { inputPerMTok: 15, outputPerMTok: 75 },
4698
- "claude-sonnet-4-6": { inputPerMTok: 3, outputPerMTok: 15 },
4699
- "claude-sonnet-4-5": { inputPerMTok: 3, outputPerMTok: 15 },
4700
- "claude-haiku-4-5": { inputPerMTok: 0.8, outputPerMTok: 4 },
4701
- "claude-3-5-sonnet-20241022": { inputPerMTok: 3, outputPerMTok: 15 },
4702
- "claude-3-5-sonnet-20240620": { inputPerMTok: 3, outputPerMTok: 15 },
4703
- "claude-3-5-haiku-20241022": { inputPerMTok: 0.8, outputPerMTok: 4 },
4704
- "claude-3-opus-20240229": { inputPerMTok: 15, outputPerMTok: 75 },
4705
- "claude-3-sonnet-20240229": { inputPerMTok: 3, outputPerMTok: 15 },
4706
- "claude-3-haiku-20240307": { inputPerMTok: 0.25, outputPerMTok: 1.25 },
4707
- // ── OpenAI / Codex ────────────────────────────────────────────────────
4708
- // GPT-5 family. gpt-5-codex is the Codex CLI's public model; OpenAI's
4709
- // pricing page lists only input ($1.25/MTok). Output uses the same $10
4710
- // rate the broader GPT-5 base is publicly quoted at — flagged here for
4711
- // re-verification when OpenAI publishes an explicit output figure.
4712
- "gpt-5-codex": { inputPerMTok: 1.25, outputPerMTok: 10 },
4713
- "gpt-5-mini": { inputPerMTok: 0.25, outputPerMTok: 2 },
4714
- "gpt-5-nano": { inputPerMTok: 0.05, outputPerMTok: 0.4 },
4715
- "gpt-5-pro": { inputPerMTok: 15, outputPerMTok: 120 },
4716
- "gpt-5.5": { inputPerMTok: 5, outputPerMTok: 30 },
4717
- "codex-mini-latest": { inputPerMTok: 1.5, outputPerMTok: 6 },
4718
- // GPT-4 family.
4719
- "gpt-4o": { inputPerMTok: 2.5, outputPerMTok: 10 },
4720
- "gpt-4o-mini": { inputPerMTok: 0.15, outputPerMTok: 0.6 },
4721
- "gpt-4-turbo": { inputPerMTok: 10, outputPerMTok: 30 },
4722
- "gpt-4.1": { inputPerMTok: 2, outputPerMTok: 8 },
4723
- "gpt-4.1-mini": { inputPerMTok: 0.4, outputPerMTok: 1.6 },
4724
- "gpt-4.1-nano": { inputPerMTok: 0.1, outputPerMTok: 0.4 },
4725
- // Reasoning models (o-series).
4726
- o1: { inputPerMTok: 15, outputPerMTok: 60 },
4727
- "o1-mini": { inputPerMTok: 3, outputPerMTok: 12 },
4728
- // o3 was repriced in 2026; previously $10/$40, now $2/$8.
4729
- o3: { inputPerMTok: 2, outputPerMTok: 8 },
4730
- "o3-mini": { inputPerMTok: 1.1, outputPerMTok: 4.4 },
4731
- "o4-mini": { inputPerMTok: 1.1, outputPerMTok: 4.4 }
4732
- }
4733
- };
4734
-
4735
- // ../pricing/src/index.ts
4736
- var TABLE = prices.models;
4737
- function priceOf(model, inTokens, outTokens) {
4738
- const p = TABLE[model] ?? matchPrefix(model);
4739
- if (!p) return { costUsdCents: 0, known: false };
4740
- const dollars = (inTokens * p.inputPerMTok + outTokens * p.outputPerMTok) / 1e6;
4741
- return { costUsdCents: Math.round(dollars * 100), known: true };
4742
- }
4743
- function matchPrefix(model) {
4744
- let best;
4745
- for (const [key, price] of Object.entries(TABLE)) {
4746
- if (model.startsWith(key) && (!best || key.length > best.key.length)) {
4747
- best = { key, price };
4748
- }
4749
- }
4750
- return best?.price;
4751
- }
5252
+ import * as fs4 from "node:fs";
4752
5253
 
4753
5254
  // ../parsers/src/hash.ts
4754
5255
  var FNV_PRIME = 16777619;
@@ -4811,6 +5312,8 @@ function parseClaudeCode(input) {
4811
5312
  endedAt: timestamp,
4812
5313
  inTokens: 0,
4813
5314
  outTokens: 0,
5315
+ cacheReadTokens: 0,
5316
+ cacheWriteTokens: 0,
4814
5317
  model: ""
4815
5318
  };
4816
5319
  sessions.set(sessionId, acc);
@@ -4829,10 +5332,10 @@ function parseClaudeCode(input) {
4829
5332
  const usage = msg.usage;
4830
5333
  if (typeof usage === "object" && usage !== null) {
4831
5334
  const u = usage;
4832
- const inputTokens = toNonNegInt(u.input_tokens);
4833
- const outputTokens = toNonNegInt(u.output_tokens);
4834
- acc.inTokens += inputTokens;
4835
- acc.outTokens += outputTokens;
5335
+ acc.inTokens += toNonNegInt(u.input_tokens);
5336
+ acc.outTokens += toNonNegInt(u.output_tokens);
5337
+ acc.cacheReadTokens += toNonNegInt(u.cache_read_input_tokens);
5338
+ acc.cacheWriteTokens += toNonNegInt(u.cache_creation_input_tokens);
4836
5339
  }
4837
5340
  }
4838
5341
  const results = [];
@@ -4841,7 +5344,7 @@ function parseClaudeCode(input) {
4841
5344
  const endedAt = acc.endedAt > 0 ? acc.endedAt : acc.startedAt;
4842
5345
  if (startedAt <= 0 || endedAt <= 0) continue;
4843
5346
  const model = acc.model.length > 0 ? acc.model : "unknown";
4844
- const { costUsdCents } = priceOf(model, acc.inTokens, acc.outTokens);
5347
+ const costUsdCents = 0;
4845
5348
  const dedupeKey = computeDedupeKey(
4846
5349
  "claude-code",
4847
5350
  model,
@@ -4852,9 +5355,14 @@ function parseClaudeCode(input) {
4852
5355
  results.push({
4853
5356
  id: `claude-code:${acc.sessionId}`,
4854
5357
  source: "claude-code",
5358
+ provider: "anthropic",
5359
+ client: "claude-code",
5360
+ channel: "cli",
4855
5361
  model,
4856
5362
  inTokens: acc.inTokens,
4857
5363
  outTokens: acc.outTokens,
5364
+ cacheReadTokens: acc.cacheReadTokens,
5365
+ cacheWriteTokens: acc.cacheWriteTokens,
4858
5366
  costUsdCents,
4859
5367
  startedAt,
4860
5368
  endedAt,
@@ -4898,6 +5406,9 @@ function parseCodex(input) {
4898
5406
  currentSessionId = id;
4899
5407
  const metaTs = parseTimestamp(payload.timestamp) || ts;
4900
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";
4901
5412
  if (metaTs > 0 && (acc2.startedAt === 0 || metaTs < acc2.startedAt)) {
4902
5413
  acc2.startedAt = metaTs;
4903
5414
  }
@@ -4926,6 +5437,8 @@ function parseCodex(input) {
4926
5437
  const reasoning = toNonNegInt2(t.reasoning_output_tokens);
4927
5438
  acc.inTokens = Math.max(0, inputTotal - cachedInput);
4928
5439
  acc.outTokens = output + reasoning;
5440
+ acc.cacheReadTokens = cachedInput;
5441
+ acc.reasoningTokens = reasoning;
4929
5442
  }
4930
5443
  }
4931
5444
  const results = [];
@@ -4934,14 +5447,19 @@ function parseCodex(input) {
4934
5447
  const endedAt = acc.endedAt > 0 ? acc.endedAt : acc.startedAt;
4935
5448
  if (startedAt <= 0 || endedAt <= 0) continue;
4936
5449
  const model = acc.model.length > 0 ? acc.model : "unknown";
4937
- const { costUsdCents } = priceOf(model, acc.inTokens, acc.outTokens);
5450
+ const costUsdCents = 0;
4938
5451
  const dedupeKey = computeDedupeKey("codex", model, startedAt, acc.inTokens, acc.outTokens);
4939
5452
  results.push({
4940
5453
  id: `codex:${acc.sessionId}`,
4941
5454
  source: "codex",
5455
+ provider: "openai",
5456
+ client: acc.client || "codex-cli",
5457
+ channel: acc.channel,
4942
5458
  model,
4943
5459
  inTokens: acc.inTokens,
4944
5460
  outTokens: acc.outTokens,
5461
+ cacheReadTokens: acc.cacheReadTokens,
5462
+ reasoningTokens: acc.reasoningTokens,
4945
5463
  costUsdCents,
4946
5464
  startedAt,
4947
5465
  endedAt,
@@ -4959,7 +5477,11 @@ function upsert(map, sessionId) {
4959
5477
  endedAt: 0,
4960
5478
  inTokens: 0,
4961
5479
  outTokens: 0,
4962
- model: ""
5480
+ cacheReadTokens: 0,
5481
+ reasoningTokens: 0,
5482
+ model: "",
5483
+ client: "codex-cli",
5484
+ channel: "unknown"
4963
5485
  };
4964
5486
  map.set(sessionId, acc);
4965
5487
  }
@@ -4983,12 +5505,6 @@ var ESTIMATES = {
4983
5505
  composer: { inTokens: 1e4, outTokens: 2e3, model: "cursor-composer" }
4984
5506
  // "tab" deliberately omitted — see file header.
4985
5507
  };
4986
- var INPUT_USD_PER_MTOK = 3;
4987
- var OUTPUT_USD_PER_MTOK = 15;
4988
- function estimatedCostCents(inTokens, outTokens) {
4989
- const dollars = inTokens / 1e6 * INPUT_USD_PER_MTOK + outTokens / 1e6 * OUTPUT_USD_PER_MTOK;
4990
- return Math.round(dollars * 100);
4991
- }
4992
5508
  function parseCursor(input) {
4993
5509
  let text;
4994
5510
  if (typeof input === "string") {
@@ -5007,20 +5523,23 @@ function parseCursor(input) {
5007
5523
  for (const raw of rows) {
5008
5524
  if (typeof raw !== "object" || raw === null) continue;
5009
5525
  const row = raw;
5010
- const id = typeof row["id"] === "string" ? row["id"] : null;
5526
+ const id = typeof row.id === "string" ? row.id : null;
5011
5527
  if (!id) continue;
5012
- const type = typeof row["type"] === "string" ? row["type"] : null;
5528
+ const type = typeof row.type === "string" ? row.type : null;
5013
5529
  if (!type) continue;
5014
- const unixMs = typeof row["unixMs"] === "number" && isFinite(row["unixMs"]) && row["unixMs"] > 0 ? row["unixMs"] : null;
5530
+ const unixMs = typeof row.unixMs === "number" && Number.isFinite(row.unixMs) && row.unixMs > 0 ? row.unixMs : null;
5015
5531
  if (unixMs === null) continue;
5016
5532
  const estimate = ESTIMATES[type];
5017
5533
  if (!estimate) continue;
5018
5534
  const { inTokens, outTokens, model } = estimate;
5019
- const costUsdCents = estimatedCostCents(inTokens, outTokens);
5535
+ const costUsdCents = 0;
5020
5536
  const dedupeKey = computeDedupeKey("cursor", model, unixMs, inTokens, outTokens);
5021
5537
  results.push({
5022
5538
  id: `cursor:${id}`,
5023
5539
  source: "cursor",
5540
+ provider: "cursor",
5541
+ client: "cursor",
5542
+ channel: "ide",
5024
5543
  model,
5025
5544
  inTokens,
5026
5545
  outTokens,
@@ -5034,31 +5553,31 @@ function parseCursor(input) {
5034
5553
  }
5035
5554
 
5036
5555
  // src/lib/cursor-extract.ts
5037
- import { existsSync, readdirSync } from "node:fs";
5556
+ import { existsSync as existsSync3, readdirSync } from "node:fs";
5038
5557
  import { readFile } from "node:fs/promises";
5039
- import { homedir as homedir2 } from "node:os";
5040
- import { join as join2 } from "node:path";
5558
+ import { homedir as homedir3 } from "node:os";
5559
+ import { join as join3 } from "node:path";
5041
5560
  function cursorWorkspaceStorageDir() {
5042
- const home = homedir2();
5043
- const rel = join2("Cursor", "User", "workspaceStorage");
5561
+ const home = homedir3();
5562
+ const rel = join3("Cursor", "User", "workspaceStorage");
5044
5563
  if (process.platform === "darwin") {
5045
- return join2(home, "Library", "Application Support", rel);
5564
+ return join3(home, "Library", "Application Support", rel);
5046
5565
  }
5047
5566
  if (process.platform === "win32") {
5048
- const appData = process.env["APPDATA"] ?? join2(home, "AppData", "Roaming");
5049
- return join2(appData, rel);
5567
+ const appData = process.env.APPDATA ?? join3(home, "AppData", "Roaming");
5568
+ return join3(appData, rel);
5050
5569
  }
5051
- const xdgConfig = process.env["XDG_CONFIG_HOME"] ?? join2(home, ".config");
5052
- return join2(xdgConfig, rel);
5570
+ const xdgConfig = process.env.XDG_CONFIG_HOME ?? join3(home, ".config");
5571
+ return join3(xdgConfig, rel);
5053
5572
  }
5054
5573
  function discoverWorkspaceDbs() {
5055
5574
  const root = cursorWorkspaceStorageDir();
5056
- if (!existsSync(root)) return [];
5575
+ if (!existsSync3(root)) return [];
5057
5576
  const out = [];
5058
5577
  for (const entry of readdirSync(root, { withFileTypes: true })) {
5059
5578
  if (!entry.isDirectory()) continue;
5060
- const candidate = join2(root, entry.name, "state.vscdb");
5061
- if (existsSync(candidate)) out.push(candidate);
5579
+ const candidate = join3(root, entry.name, "state.vscdb");
5580
+ if (existsSync3(candidate)) out.push(candidate);
5062
5581
  }
5063
5582
  return out;
5064
5583
  }
@@ -5136,9 +5655,9 @@ async function readGenerationsFromDb(dbPath) {
5136
5655
  for (const item of parsed) {
5137
5656
  if (typeof item !== "object" || item === null) continue;
5138
5657
  const r = item;
5139
- const id = typeof r["generationUUID"] === "string" ? r["generationUUID"] : null;
5140
- const type = typeof r["type"] === "string" ? r["type"] : null;
5141
- const unixMs = typeof r["unixMs"] === "number" && isFinite(r["unixMs"]) && r["unixMs"] > 0 ? r["unixMs"] : null;
5658
+ const id = typeof r.generationUUID === "string" ? r.generationUUID : null;
5659
+ const type = typeof r.type === "string" ? r.type : null;
5660
+ const unixMs = typeof r.unixMs === "number" && Number.isFinite(r.unixMs) && r.unixMs > 0 ? r.unixMs : null;
5142
5661
  if (!id || !type || unixMs === null) continue;
5143
5662
  out.push({ id, type, unixMs });
5144
5663
  }
@@ -5189,15 +5708,15 @@ async function extractCursorGenerations() {
5189
5708
  }
5190
5709
 
5191
5710
  // src/lib/discover.ts
5192
- import * as fs2 from "node:fs";
5193
- import * as os2 from "node:os";
5194
- import * as path2 from "node:path";
5711
+ import * as fs3 from "node:fs";
5712
+ import * as os3 from "node:os";
5713
+ import * as path3 from "node:path";
5195
5714
  function findJsonlFiles(dir) {
5196
5715
  const results = [];
5197
- if (!fs2.existsSync(dir)) return results;
5198
- const entries = fs2.readdirSync(dir, { withFileTypes: true });
5716
+ if (!fs3.existsSync(dir)) return results;
5717
+ const entries = fs3.readdirSync(dir, { withFileTypes: true });
5199
5718
  for (const entry of entries) {
5200
- const full = path2.join(dir, entry.name);
5719
+ const full = path3.join(dir, entry.name);
5201
5720
  if (entry.isDirectory()) {
5202
5721
  results.push(...findJsonlFiles(full));
5203
5722
  } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
@@ -5207,32 +5726,32 @@ function findJsonlFiles(dir) {
5207
5726
  return results;
5208
5727
  }
5209
5728
  function claudeCodeProjectsDir() {
5210
- const home = os2.homedir();
5729
+ const home = os3.homedir();
5211
5730
  if (process.platform === "win32") {
5212
5731
  const profile = process.env.USERPROFILE ?? home;
5213
- return path2.join(profile, ".claude", "projects");
5732
+ return path3.join(profile, ".claude", "projects");
5214
5733
  }
5215
- return path2.join(home, ".claude", "projects");
5734
+ return path3.join(home, ".claude", "projects");
5216
5735
  }
5217
5736
  function discoverClaudeCodeFiles() {
5218
5737
  const dir = claudeCodeProjectsDir();
5219
5738
  return findJsonlFiles(dir);
5220
5739
  }
5221
5740
  function codexSessionsDirs() {
5222
- const home = os2.homedir();
5741
+ const home = os3.homedir();
5223
5742
  const candidates = [];
5224
5743
  if (process.platform === "win32") {
5225
5744
  const profile = process.env.USERPROFILE ?? home;
5226
- candidates.push(path2.join(profile, ".codex", "sessions"));
5227
- const appData = process.env.APPDATA ?? path2.join(profile, "AppData", "Roaming");
5228
- candidates.push(path2.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"));
5229
5748
  } else {
5230
- candidates.push(path2.join(home, ".codex", "sessions"));
5231
- const snapRoot = path2.join(home, "snap", "codex");
5232
- if (fs2.existsSync(snapRoot)) {
5233
- for (const entry of fs2.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 })) {
5234
5753
  if (entry.isDirectory()) {
5235
- candidates.push(path2.join(snapRoot, entry.name, "sessions"));
5754
+ candidates.push(path3.join(snapRoot, entry.name, "sessions"));
5236
5755
  }
5237
5756
  }
5238
5757
  }
@@ -5241,7 +5760,7 @@ function codexSessionsDirs() {
5241
5760
  const dirs = [];
5242
5761
  for (const c2 of candidates) {
5243
5762
  try {
5244
- const real = fs2.existsSync(c2) ? fs2.realpathSync(c2) : null;
5763
+ const real = fs3.existsSync(c2) ? fs3.realpathSync(c2) : null;
5245
5764
  if (real && !seen2.has(real)) {
5246
5765
  seen2.add(real);
5247
5766
  dirs.push(c2);
@@ -5285,8 +5804,7 @@ function mergeBySessionId(records) {
5285
5804
  }
5286
5805
  }
5287
5806
  for (const rec of byId.values()) {
5288
- const { costUsdCents } = priceOf(rec.model, rec.inTokens, rec.outTokens);
5289
- rec.costUsdCents = costUsdCents;
5807
+ rec.costUsdCents = 0;
5290
5808
  rec.dedupeKey = computeDedupeKey(
5291
5809
  rec.source,
5292
5810
  rec.model,
@@ -5303,7 +5821,12 @@ async function syncCommand(opts) {
5303
5821
  error("Not logged in. Run `token-rats login` first.");
5304
5822
  process.exit(1);
5305
5823
  }
5306
- 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
+ });
5307
5830
  const claudeFiles = discoverClaudeCodeFiles();
5308
5831
  if (opts.verbose) {
5309
5832
  info(`Found ${claudeFiles.length} Claude Code file(s) in ~/.claude/projects/`);
@@ -5313,7 +5836,7 @@ async function syncCommand(opts) {
5313
5836
  for (const file of claudeFiles) {
5314
5837
  let text;
5315
5838
  try {
5316
- text = fs3.readFileSync(file, "utf8");
5839
+ text = fs4.readFileSync(file, "utf8");
5317
5840
  } catch {
5318
5841
  if (opts.verbose) warn(`Could not read ${file} \u2014 skipping`);
5319
5842
  continue;
@@ -5335,7 +5858,7 @@ async function syncCommand(opts) {
5335
5858
  for (const file of codexFiles) {
5336
5859
  let text;
5337
5860
  try {
5338
- text = fs3.readFileSync(file, "utf8");
5861
+ text = fs4.readFileSync(file, "utf8");
5339
5862
  } catch {
5340
5863
  if (opts.verbose) warn(`Could not read ${file} \u2014 skipping`);
5341
5864
  continue;
@@ -5396,7 +5919,9 @@ async function syncCommand(opts) {
5396
5919
  }
5397
5920
  info(`Discovered ${allSessions.length} session(s) from ${sourceStr}.`);
5398
5921
  if (opts.dryRun) {
5399
- info(`[dry-run] Would upload ${allSessions.length} session(s) in ${Math.ceil(allSessions.length / BATCH_SIZE)} batch(es).`);
5922
+ info(
5923
+ `[dry-run] Would upload ${allSessions.length} session(s) in ${Math.ceil(allSessions.length / BATCH_SIZE)} batch(es).`
5924
+ );
5400
5925
  if (opts.verbose) {
5401
5926
  for (const s of allSessions.slice(0, 10)) {
5402
5927
  dim(
@@ -5419,6 +5944,14 @@ async function syncCommand(opts) {
5419
5944
  totalDuplicates += res.duplicates;
5420
5945
  } catch (err) {
5421
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
+ }
5422
5955
  if (err instanceof ApiError2 && err.status === 401) {
5423
5956
  error("Session expired. Run `token-rats login` to re-authenticate.");
5424
5957
  process.exit(1);
@@ -5436,12 +5969,47 @@ async function syncCommand(opts) {
5436
5969
  }
5437
5970
 
5438
5971
  // src/commands/watch.ts
5439
- import * as fs4 from "node:fs";
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;
5440
5976
  var seen = /* @__PURE__ */ new Set();
5977
+ function stateFilePath() {
5978
+ const xdgConfig = process.env.XDG_CONFIG_HOME ?? path4.join(os4.homedir(), ".config");
5979
+ return path4.join(xdgConfig, "token-rats", "watch-state.json");
5980
+ }
5981
+ function loadWatchState() {
5982
+ try {
5983
+ const raw = fs5.readFileSync(stateFilePath(), "utf8");
5984
+ const parsed = JSON.parse(raw);
5985
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
5986
+ } catch {
5987
+ return {};
5988
+ }
5989
+ }
5990
+ function saveWatchState(state) {
5991
+ try {
5992
+ const file = stateFilePath();
5993
+ fs5.mkdirSync(path4.dirname(file), { recursive: true });
5994
+ fs5.writeFileSync(file, JSON.stringify(state), { mode: 384 });
5995
+ } catch (err) {
5996
+ if (err instanceof Error) {
5997
+ console.warn(`watch-state write failed: ${err.message}`);
5998
+ }
5999
+ }
6000
+ }
6001
+ function statFile(p) {
6002
+ try {
6003
+ const s = fs5.statSync(p);
6004
+ return { size: s.size, mtimeMs: s.mtimeMs };
6005
+ } catch {
6006
+ return null;
6007
+ }
6008
+ }
5441
6009
  function parseAndFilter(filePath, verbose) {
5442
6010
  let text;
5443
6011
  try {
5444
- text = fs4.readFileSync(filePath, "utf8");
6012
+ text = fs5.readFileSync(filePath, "utf8");
5445
6013
  } catch {
5446
6014
  if (verbose) warn(`Could not read ${filePath} \u2014 skipping`);
5447
6015
  return [];
@@ -5475,6 +6043,14 @@ async function upload(client, records, verbose) {
5475
6043
  success(`Uploaded ${res.accepted} session(s)`);
5476
6044
  }
5477
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
+ }
5478
6054
  if (err instanceof ApiError2 && err.status === 401) {
5479
6055
  error("Session expired. Run `token-rats login` to re-authenticate.");
5480
6056
  process.exit(1);
@@ -5484,22 +6060,22 @@ async function upload(client, records, verbose) {
5484
6060
  }
5485
6061
  function makeDebounced(fn, ms) {
5486
6062
  const timers = /* @__PURE__ */ new Map();
5487
- return (path3) => {
5488
- const existing = timers.get(path3);
6063
+ return (path5) => {
6064
+ const existing = timers.get(path5);
5489
6065
  if (existing) clearTimeout(existing);
5490
6066
  timers.set(
5491
- path3,
6067
+ path5,
5492
6068
  setTimeout(() => {
5493
- timers.delete(path3);
5494
- fn(path3);
6069
+ timers.delete(path5);
6070
+ fn(path5);
5495
6071
  }, ms)
5496
6072
  );
5497
6073
  };
5498
6074
  }
5499
6075
  function findJsonlFiles2(dir) {
5500
6076
  const results = [];
5501
- if (!fs4.existsSync(dir)) return results;
5502
- const entries = fs4.readdirSync(dir, { withFileTypes: true });
6077
+ if (!fs5.existsSync(dir)) return results;
6078
+ const entries = fs5.readdirSync(dir, { withFileTypes: true });
5503
6079
  for (const entry of entries) {
5504
6080
  const full = `${dir}/${entry.name}`;
5505
6081
  if (entry.isDirectory()) {
@@ -5511,40 +6087,89 @@ function findJsonlFiles2(dir) {
5511
6087
  return results;
5512
6088
  }
5513
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
+ }
5514
6096
  const token = loadToken();
5515
6097
  if (!token) {
5516
6098
  error("Not logged in. Run `token-rats login` first.");
5517
6099
  process.exit(1);
5518
6100
  }
5519
- const client = new ApiClient({ apiUrl: opts.apiUrl, token });
6101
+ const client = new ApiClient({
6102
+ apiUrl: opts.apiUrl,
6103
+ token,
6104
+ deviceId: ensureDeviceId(),
6105
+ cliVersion: CLI_VERSION
6106
+ });
5520
6107
  const debounceMs = opts.interval ?? 2e3;
5521
6108
  const dir = claudeCodeProjectsDir();
5522
- if (!fs4.existsSync(dir)) {
6109
+ if (!fs5.existsSync(dir)) {
5523
6110
  warn(`Claude Code projects directory not found: ${dir}`);
5524
6111
  warn("No files to watch. Exiting.");
5525
6112
  process.exit(0);
5526
6113
  }
5527
6114
  info(`Watching ${dir} (debounce: ${debounceMs}ms)`);
5528
6115
  info("Press Ctrl-C to stop.\n");
6116
+ const persistedState = loadWatchState();
6117
+ const liveState = /* @__PURE__ */ new Map();
5529
6118
  const initialFiles = findJsonlFiles2(dir);
5530
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
+ }
5531
6127
  parseAndFilter(f, false);
5532
6128
  }
5533
6129
  if (opts.verbose) {
5534
6130
  dim(`Initial snapshot: ${seen.size} session(s) in ${initialFiles.length} file(s)`);
5535
6131
  }
6132
+ saveWatchState(Object.fromEntries(liveState));
5536
6133
  const onChanged = makeDebounced(async (filePath) => {
5537
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
+ }
5538
6144
  const fresh = parseAndFilter(filePath, opts.verbose ?? false);
5539
6145
  if (fresh.length > 0) {
5540
6146
  await upload(client, fresh, opts.verbose ?? false);
5541
6147
  }
5542
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
+ });
5543
6170
  let cleanup = null;
5544
6171
  try {
5545
- const chokidar = await new Function("m", "return import(m)")(
5546
- "chokidar"
5547
- );
6172
+ const chokidar = await new Function("m", "return import(m)")("chokidar");
5548
6173
  const watcher = chokidar.watch(`${dir}/**/*.jsonl`, {
5549
6174
  ignoreInitial: true,
5550
6175
  persistent: true,
@@ -5552,9 +6177,7 @@ async function watchCommand(opts) {
5552
6177
  });
5553
6178
  watcher.on("add", (p) => onChanged(p));
5554
6179
  watcher.on("change", (p) => onChanged(p));
5555
- cleanup = () => {
5556
- watcher.close();
5557
- };
6180
+ cleanup = () => watcher.close();
5558
6181
  if (opts.verbose) dim("Using chokidar for file watching");
5559
6182
  } catch {
5560
6183
  if (opts.verbose) dim("chokidar not available; using Node built-in fs.watch");
@@ -5562,7 +6185,7 @@ async function watchCommand(opts) {
5562
6185
  let lastMtimes = /* @__PURE__ */ new Map();
5563
6186
  for (const f of initialFiles) {
5564
6187
  try {
5565
- lastMtimes.set(f, fs4.statSync(f).mtimeMs);
6188
+ lastMtimes.set(f, fs5.statSync(f).mtimeMs);
5566
6189
  } catch {
5567
6190
  }
5568
6191
  }
@@ -5570,7 +6193,7 @@ async function watchCommand(opts) {
5570
6193
  const current = findJsonlFiles2(dir);
5571
6194
  for (const f of current) {
5572
6195
  try {
5573
- const mtime = fs4.statSync(f).mtimeMs;
6196
+ const mtime = fs5.statSync(f).mtimeMs;
5574
6197
  const prev = lastMtimes.get(f) ?? 0;
5575
6198
  if (mtime > prev) {
5576
6199
  lastMtimes.set(f, mtime);
@@ -5612,6 +6235,7 @@ async function watchCommand(opts) {
5612
6235
  }
5613
6236
  function shutdown() {
5614
6237
  info("Shutting down\u2026");
6238
+ clearInterval(heartbeatTimer);
5615
6239
  if (cleanup) cleanup();
5616
6240
  process.exit(0);
5617
6241
  }
@@ -5626,11 +6250,25 @@ async function whoamiCommand(opts) {
5626
6250
  error("Not logged in. Run `token-rats login` first.");
5627
6251
  process.exit(1);
5628
6252
  }
5629
- 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
+ });
5630
6259
  try {
5631
6260
  const res = await client.getMe();
5632
6261
  info(`Signed in as \x1B[1m@${res.user.handle}\x1B[0m`);
6262
+ info(`Device id: ${ensureDeviceId()}`);
5633
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
+ }
5634
6272
  if (err instanceof ApiError2 && err.status === 401) {
5635
6273
  error("Your session has expired. Run `token-rats login` to re-authenticate.");
5636
6274
  process.exit(1);
@@ -5642,7 +6280,7 @@ async function whoamiCommand(opts) {
5642
6280
 
5643
6281
  // src/index.ts
5644
6282
  function getVersion() {
5645
- return "0.0.4";
6283
+ return CLI_VERSION;
5646
6284
  }
5647
6285
  function printHelp() {
5648
6286
  console.log(`
@@ -5652,14 +6290,17 @@ function printHelp() {
5652
6290
  token-rats <command> [flags]
5653
6291
 
5654
6292
  \x1B[1mCommands:\x1B[0m
5655
- login Authenticate with Token Rats (opens browser)
5656
- sync Read local Claude Code + Cursor logs and upload counts
5657
- watch Watch logs in real-time; upload new sessions as they appear
5658
- whoami Show the currently signed-in account
5659
- logout Clear your stored credentials
5660
- install-cursor Install better-sqlite3 globally for faster Cursor reads
5661
- (sql.js works out of the box \u2014 this is opt-in speed-up)
5662
- 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
5663
6304
 
5664
6305
  \x1B[1mFlags (all commands):\x1B[0m
5665
6306
  --api-url <url> Override API URL (default: https://api.tokenrats.com)
@@ -5697,6 +6338,7 @@ function parseArgs(argv) {
5697
6338
  let dryRun = false;
5698
6339
  let verbose = false;
5699
6340
  let interval;
6341
+ let noDaemon = false;
5700
6342
  let i = 0;
5701
6343
  while (i < argv.length) {
5702
6344
  const arg = argv[i];
@@ -5712,6 +6354,8 @@ function parseArgs(argv) {
5712
6354
  interval = Number(argv[++i]);
5713
6355
  } else if (arg.startsWith("--interval=")) {
5714
6356
  interval = Number(arg.slice("--interval=".length));
6357
+ } else if (arg === "--no-daemon") {
6358
+ noDaemon = true;
5715
6359
  } else if (arg === "--version" || arg === "-V") {
5716
6360
  console.log(getVersion());
5717
6361
  process.exit(0);
@@ -5724,18 +6368,18 @@ function parseArgs(argv) {
5724
6368
  i++;
5725
6369
  }
5726
6370
  const [command = null, ...rest] = positional;
5727
- return { command, apiUrl, dryRun, verbose, interval, rest };
6371
+ return { command, apiUrl, dryRun, verbose, interval, noDaemon, rest };
5728
6372
  }
5729
6373
  async function main() {
5730
6374
  const args = parseArgs(process.argv.slice(2));
5731
- const { command, apiUrl, dryRun, verbose, interval } = args;
6375
+ const { command, apiUrl, dryRun, verbose, interval, noDaemon } = args;
5732
6376
  if (!command || command === "help") {
5733
6377
  printHelp();
5734
6378
  process.exit(0);
5735
6379
  }
5736
6380
  switch (command) {
5737
6381
  case "login":
5738
- await loginCommand({ apiUrl });
6382
+ await loginCommand({ apiUrl, noDaemon });
5739
6383
  break;
5740
6384
  case "sync":
5741
6385
  await syncCommand({ apiUrl, dryRun, verbose });
@@ -5752,6 +6396,15 @@ async function main() {
5752
6396
  case "install-cursor":
5753
6397
  await installCursorCommand();
5754
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;
5755
6408
  default:
5756
6409
  console.error(`\x1B[31mUnknown command: ${command}\x1B[0m`);
5757
6410
  console.error("Run \x1B[1mtoken-rats help\x1B[0m for a list of commands.");