token-rats 0.0.4 → 0.1.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 +54 -11
  2. package/dist/index.js +1064 -266
  3. package/package.json +2 -3
package/dist/index.js CHANGED
@@ -385,8 +385,8 @@ function getErrorMap() {
385
385
  return overrideErrorMap;
386
386
  }
387
387
  var makeIssue = (params) => {
388
- const { data, path: path3, errorMaps, issueData } = params;
389
- const fullPath = [...path3, ...issueData.path || []];
388
+ const { data, path: path7, errorMaps, issueData } = params;
389
+ const fullPath = [...path7, ...issueData.path || []];
390
390
  const fullIssue = {
391
391
  ...issueData,
392
392
  path: fullPath
@@ -508,11 +508,11 @@ var errorUtil;
508
508
  var _ZodEnum_cache;
509
509
  var _ZodNativeEnum_cache;
510
510
  var ParseInputLazyPath = class {
511
- constructor(parent, value, path3, key) {
511
+ constructor(parent, value, path7, key) {
512
512
  this._cachedPath = [];
513
513
  this.parent = parent;
514
514
  this.data = value;
515
- this._path = path3;
515
+ this._path = path7;
516
516
  this._key = key;
517
517
  }
518
518
  get path() {
@@ -3948,12 +3948,20 @@ var z = /* @__PURE__ */ Object.freeze({
3948
3948
 
3949
3949
  // ../contracts/src/session.ts
3950
3950
  var Source = z.enum(["claude-code", "cursor", "codex"]);
3951
+ var Provider = z.enum(["anthropic", "openai", "cursor", "unknown"]);
3951
3952
  var SessionRecord = z.object({
3952
3953
  id: z.string().min(1),
3953
3954
  source: Source,
3955
+ provider: Provider.optional(),
3954
3956
  model: z.string().min(1),
3955
3957
  inTokens: z.number().int().nonnegative(),
3956
3958
  outTokens: z.number().int().nonnegative(),
3959
+ /** Anthropic cache reads / OpenAI `cached_input_tokens`. Billed cheap-or-free. */
3960
+ cacheReadTokens: z.number().int().nonnegative().optional(),
3961
+ /** Anthropic cache creation tokens. Billed at a premium for first-write only. */
3962
+ cacheWriteTokens: z.number().int().nonnegative().optional(),
3963
+ /** OpenAI reasoning output tokens (o-series / Codex). Billed at output rate. */
3964
+ reasoningTokens: z.number().int().nonnegative().optional(),
3957
3965
  costUsdCents: z.number().int().nonnegative(),
3958
3966
  startedAt: z.number().int().positive(),
3959
3967
  endedAt: z.number().int().positive(),
@@ -3968,19 +3976,38 @@ var User = z.object({
3968
3976
  /** Present only when the caller is the user themselves or the user is public. */
3969
3977
  bio: z.string().max(200).nullable().optional(),
3970
3978
  twitterHandle: z.string().max(50).nullable().optional(),
3971
- publicProfile: z.boolean().optional()
3979
+ /**
3980
+ * True iff `twitter_handle` came from the OAuth flow (i.e. `twitter_user_id`
3981
+ * is set). Self-only — used by settings UI to distinguish manual legacy
3982
+ * handles from verified ones.
3983
+ */
3984
+ twitterVerified: z.boolean().optional(),
3985
+ publicProfile: z.boolean().optional(),
3986
+ /**
3987
+ * Primary verified GitHub email, captured at OAuth callback time. Self-only
3988
+ * (other readers never see this field). `null` when GitHub didn't return a
3989
+ * verified email — the user sees a banner asking them to add one.
3990
+ */
3991
+ email: z.string().email().nullable().optional()
3972
3992
  });
3973
3993
  var PublicProfileSettings = z.object({
3974
3994
  publicProfile: z.boolean().optional(),
3975
- bio: z.string().max(200).nullable().optional(),
3976
- twitterHandle: z.string().max(50).nullable().optional()
3995
+ bio: z.string().max(200).nullable().optional()
3977
3996
  });
3978
3997
  var Profile = User.extend({
3979
3998
  totals: z.object({
3980
3999
  today: z.object({ tokens: z.number().int(), costUsdCents: z.number().int() }),
3981
4000
  week: z.object({ tokens: z.number().int(), costUsdCents: z.number().int() }),
3982
4001
  allTime: z.object({ tokens: z.number().int(), costUsdCents: z.number().int() })
3983
- })
4002
+ }),
4003
+ /** Number of users this user has brought in via their referral link. */
4004
+ referredCount: z.number().int().nonnegative().optional(),
4005
+ /**
4006
+ * The profile owner's referral code. Self-only — present only when the
4007
+ * caller is viewing their own profile, so the page can render a
4008
+ * copy-able invite link without a separate fetch to /v1/me/referral.
4009
+ */
4010
+ referralCode: z.string().optional()
3984
4011
  });
3985
4012
  var AutobiographyStats = z.object({
3986
4013
  handle: z.string(),
@@ -4016,13 +4043,24 @@ var Room = z.object({
4016
4043
  name: z.string().min(1).max(64),
4017
4044
  ownerId: z.string(),
4018
4045
  orgId: z.string().nullable(),
4019
- createdAt: z.number().int().positive()
4046
+ createdAt: z.number().int().positive(),
4047
+ /** v1.2: public country-locked groups. `country` is set iff `isPublic` is true. */
4048
+ isPublic: z.boolean(),
4049
+ /** ISO-3166-1 alpha-2 (`cf-ipcountry` of the creator). Null on private rooms. */
4050
+ country: z.string().min(2).max(2).nullable(),
4051
+ /**
4052
+ * True iff this room is the caller's pinned room. Populated only on
4053
+ * caller-scoped responses (e.g. GET /v1/me/rooms); absent elsewhere.
4054
+ */
4055
+ isPinned: z.boolean().optional()
4020
4056
  });
4021
4057
  var RoomMember = z.object({
4022
4058
  userId: z.string(),
4023
4059
  handle: z.string(),
4024
4060
  avatarUrl: z.string().url().nullable(),
4025
- joinedAt: z.number().int().positive()
4061
+ joinedAt: z.number().int().positive(),
4062
+ /** OAuth-verified X handle, when present. Manual handles are not surfaced. */
4063
+ twitterHandle: z.string().max(50).nullable().optional()
4026
4064
  });
4027
4065
 
4028
4066
  // ../contracts/src/leaderboard.ts
@@ -4088,8 +4126,25 @@ var ChallengeWithLeaderboard = Challenge.extend({
4088
4126
  winnerHandle: z.string().nullable()
4089
4127
  });
4090
4128
 
4129
+ // ../contracts/src/referral.ts
4130
+ var ReferredUser = z.object({
4131
+ handle: z.string(),
4132
+ avatarUrl: z.string().url().nullable(),
4133
+ /** Unix ms — when the referred user signed up. */
4134
+ createdAt: z.number().int()
4135
+ });
4136
+ var ReferralStats = z.object({
4137
+ /** The user's unique referral code (URL-safe, ~8 chars). */
4138
+ code: z.string(),
4139
+ /** Total users referred. */
4140
+ count: z.number().int().nonnegative(),
4141
+ /** Most recent referred users (newest first), capped server-side. */
4142
+ recent: z.array(ReferredUser)
4143
+ });
4144
+
4091
4145
  // ../contracts/src/org.ts
4092
- var OrgPlan = z.enum(["free", "pro"]);
4146
+ var OrgPlan = z.enum(["free", "student", "pro"]);
4147
+ var OrgStatus = z.enum(["pending", "approved"]);
4093
4148
  var OrgMemberRole = z.enum(["owner", "admin", "member"]);
4094
4149
  var OrgSlug = z.string().min(3).max(48).regex(/^[a-z0-9-]+$/, "Slug must be lowercase letters, numbers, and hyphens only");
4095
4150
  var Org = z.object({
@@ -4099,7 +4154,12 @@ var Org = z.object({
4099
4154
  plan: OrgPlan,
4100
4155
  seatCount: z.number().int().nonnegative(),
4101
4156
  githubOrgLogin: z.string().nullable(),
4102
- createdAt: z.number().int().positive()
4157
+ createdAt: z.number().int().positive(),
4158
+ /** v1.2: soft-create status. Approved orgs are fully usable; pending orgs are gated. */
4159
+ status: OrgStatus,
4160
+ requestedPlan: OrgPlan.nullable(),
4161
+ founderEmail: z.string().email().nullable(),
4162
+ founderName: z.string().nullable()
4103
4163
  });
4104
4164
  var OrgMember = z.object({
4105
4165
  userId: z.string(),
@@ -4144,8 +4204,17 @@ var OrgDashboard = z.object({
4144
4204
  var CreateOrgRequest = z.object({
4145
4205
  name: z.string().min(1).max(64),
4146
4206
  slug: OrgSlug,
4147
- githubOrgLogin: z.string().optional()
4207
+ githubOrgLogin: z.string().optional(),
4208
+ // v1.2 soft-create — required fields, trust-on-submit.
4209
+ founderEmail: z.string().email(),
4210
+ founderName: z.string().min(1).max(80).optional(),
4211
+ requestedPlan: OrgPlan
4212
+ });
4213
+ var PatchOrgRequest = z.object({
4214
+ founderEmail: z.string().email().optional(),
4215
+ founderName: z.string().min(1).max(80).optional()
4148
4216
  });
4217
+ var PatchOrgResponse = z.object({ org: Org });
4149
4218
  var CreateOrgResponse = z.object({ org: Org });
4150
4219
  var GetOrgResponse = z.object({
4151
4220
  org: Org,
@@ -4160,6 +4229,20 @@ var CreateOrgInviteRequest = z.object({
4160
4229
  var CreateOrgInviteResponse = z.object({ invite: OrgInvite });
4161
4230
  var AcceptOrgInviteResponse = z.object({ ok: z.boolean() });
4162
4231
  var GetOrgDashboardResponse = z.object({ dashboard: OrgDashboard });
4232
+ var AdminPendingOrg = z.object({
4233
+ id: z.string(),
4234
+ name: z.string(),
4235
+ slug: OrgSlug.nullable(),
4236
+ requestedPlan: OrgPlan.nullable(),
4237
+ founderEmail: z.string().email().nullable(),
4238
+ founderName: z.string().nullable(),
4239
+ founderHandle: z.string(),
4240
+ createdAt: z.number().int().positive()
4241
+ });
4242
+ var GetPendingOrgsResponse = z.object({
4243
+ orgs: z.array(AdminPendingOrg)
4244
+ });
4245
+ var ApproveOrgResponse = z.object({ org: Org });
4163
4246
 
4164
4247
  // ../contracts/src/api.ts
4165
4248
  var GetMeResponse = z.object({ user: User });
@@ -4171,7 +4254,9 @@ var UploadSessionsResponse = z.object({
4171
4254
  duplicates: z.number().int().nonnegative()
4172
4255
  });
4173
4256
  var CreateRoomRequest = z.object({
4174
- name: z.string().min(1).max(64)
4257
+ name: z.string().min(1).max(64),
4258
+ /** v1.2: when true the room is publicly listed in /groups for its country. */
4259
+ isPublic: z.boolean().default(false)
4175
4260
  });
4176
4261
  var CreateRoomResponse = z.object({ room: Room });
4177
4262
  var JoinRoomResponse = z.object({ room: Room });
@@ -4191,14 +4276,60 @@ var HeatmapDay = z.object({
4191
4276
  tokens: z.number().int().nonnegative(),
4192
4277
  sessions: z.number().int().nonnegative()
4193
4278
  });
4279
+ var HeatmapRange = z.enum(["30d", "52w"]);
4194
4280
  var Heatmap = z.object({
4281
+ range: HeatmapRange,
4195
4282
  from: z.string(),
4196
4283
  // YYYY-MM-DD UTC, inclusive
4197
4284
  to: z.string(),
4198
4285
  // YYYY-MM-DD UTC, inclusive
4199
4286
  days: z.array(HeatmapDay)
4200
4287
  });
4288
+ var GetHeatmapQuery = z.object({
4289
+ range: HeatmapRange.default("30d")
4290
+ });
4201
4291
  var GetHeatmapResponse = z.object({ heatmap: Heatmap });
4292
+ var RoomSummary = z.object({
4293
+ code: z.string(),
4294
+ name: z.string(),
4295
+ /** Future: true once feature #6 lands. Always false for now. */
4296
+ isPublic: z.boolean(),
4297
+ /** ISO country code (e.g. "DE") when isPublic is true; null otherwise. */
4298
+ country: z.string().nullable(),
4299
+ memberCount: z.number().int().nonnegative(),
4300
+ total30dTokens: z.number().int().nonnegative(),
4301
+ total30dCostUsdCents: z.number().int().nonnegative()
4302
+ });
4303
+ var GetRoomSummaryResponse = z.object({ summary: RoomSummary });
4304
+ var PublicGroupRow = z.object({
4305
+ code: z.string(),
4306
+ name: z.string(),
4307
+ country: z.string().min(2).max(2),
4308
+ memberCount: z.number().int().nonnegative(),
4309
+ total30dTokens: z.number().int().nonnegative(),
4310
+ total30dCostUsdCents: z.number().int().nonnegative()
4311
+ });
4312
+ var CountryBoardRow = z.object({
4313
+ rank: z.number().int().positive(),
4314
+ userId: z.string(),
4315
+ handle: z.string(),
4316
+ avatarUrl: z.string().url().nullable(),
4317
+ tokens: z.number().int().nonnegative(),
4318
+ costUsdCents: z.number().int().nonnegative(),
4319
+ sessions: z.number().int().nonnegative()
4320
+ });
4321
+ var GetGroupsResponse = z.object({
4322
+ country: z.string().min(2).max(2).nullable(),
4323
+ groups: z.array(PublicGroupRow),
4324
+ /** Public users in the viewer's country, ranked by trailing-30d tokens. */
4325
+ userBoard: z.array(CountryBoardRow)
4326
+ });
4327
+ var GroupStreak = z.object({
4328
+ currentStreak: z.number().int().nonnegative(),
4329
+ /** Yesterday in YYYY-MM-DD UTC. */
4330
+ asOf: z.string()
4331
+ });
4332
+ var GetGroupStreakResponse = z.object({ groupStreak: GroupStreak });
4202
4333
  var GetMyRoomsResponse = z.object({
4203
4334
  rooms: z.array(Room)
4204
4335
  });
@@ -4234,6 +4365,7 @@ var GetTrendingResponse = z.object({
4234
4365
  range: LeaderboardRange,
4235
4366
  generatedAt: z.number().int().positive()
4236
4367
  });
4368
+ var GetReferralResponse = z.object({ referral: ReferralStats });
4237
4369
  var ReportAbuseRequest = z.object({
4238
4370
  targetHandle: z.string().min(1).max(100),
4239
4371
  reason: z.string().min(1).max(500)
@@ -4249,14 +4381,22 @@ var ENDPOINTS = {
4249
4381
  profile: (handle) => `/v1/u/${handle}`,
4250
4382
  autobiography: (handle) => `/v1/u/${handle}/autobiography`,
4251
4383
  profileHeatmap: (handle) => `/v1/u/${handle}/heatmap`,
4384
+ // v1.2 room aggregates — auth optional, accessible to non-members.
4385
+ roomSummary: (code) => `/v1/r/${code}/summary`,
4386
+ roomHeatmap: (code) => `/v1/r/${code}/heatmap`,
4387
+ roomGroupStreak: (code) => `/v1/r/${code}/group-streak`,
4388
+ // v1.2 public country-locked groups list
4389
+ groups: "/v1/groups",
4252
4390
  authGithubStart: "/v1/auth/github/start",
4253
4391
  authGithubCallback: "/v1/auth/github/callback",
4392
+ authLogout: "/v1/auth/logout",
4254
4393
  authCliExchange: "/v1/auth/cli/exchange",
4255
4394
  authCliPoll: "/v1/auth/cli/poll",
4256
4395
  // Phase 2 Track G+H
4257
4396
  meRooms: "/v1/me/rooms",
4258
4397
  leaveRoom: (code) => `/v1/rooms/${code}/leave`,
4259
4398
  renameRoom: (code) => `/v1/rooms/${code}`,
4399
+ pinRoom: (code) => `/v1/rooms/${code}/pin`,
4260
4400
  roomActivity: (code) => `/v1/rooms/${code}/activity`,
4261
4401
  roomStreaks: (code) => `/v1/rooms/${code}/streaks`,
4262
4402
  roomChallenges: (code) => `/v1/rooms/${code}/challenges`,
@@ -4270,6 +4410,10 @@ var ENDPOINTS = {
4270
4410
  patchMe: "/v1/me",
4271
4411
  trending: "/v1/trending",
4272
4412
  reportAbuse: "/v1/abuse/report",
4413
+ // Affiliate / referral tracking
4414
+ meReferral: "/v1/me/referral",
4415
+ // v1.2 Track AD — friends derived from shared private rooms
4416
+ meFriends: "/v1/me/friends",
4273
4417
  // Phase 3 Track O — Org plan
4274
4418
  orgs: "/v1/orgs",
4275
4419
  org: (slug) => `/v1/orgs/${slug}`,
@@ -4283,7 +4427,10 @@ var ENDPOINTS = {
4283
4427
  // Admin analytics (project-owner only)
4284
4428
  adminSignups: "/v1/admin/signups",
4285
4429
  adminActivity: "/v1/admin/activity",
4286
- adminReferrers: "/v1/admin/referrers"
4430
+ adminReferrers: "/v1/admin/referrers",
4431
+ // Admin org approval (v1.2)
4432
+ adminOrgsPending: "/v1/admin/orgs/pending",
4433
+ adminOrgApprove: (slug) => `/v1/admin/orgs/${slug}/approve`
4287
4434
  };
4288
4435
 
4289
4436
  // ../contracts/src/errors.ts
@@ -4369,20 +4516,37 @@ var AdminActivityResponse = z.object({
4369
4516
  generatedAt: z.number().int().positive()
4370
4517
  });
4371
4518
  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. */
4519
+ handle: z.string(),
4520
+ avatarUrl: z.string().url().nullable(),
4521
+ /** Number of users this referrer has brought in. */
4375
4522
  count: z.number().int().nonnegative()
4376
4523
  });
4377
4524
  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
4525
  rows: z.array(AdminReferrerRow),
4383
4526
  generatedAt: z.number().int().positive()
4384
4527
  });
4385
4528
 
4529
+ // ../contracts/src/friends.ts
4530
+ var FriendSharedRoom = z.object({
4531
+ code: z.string(),
4532
+ name: z.string()
4533
+ });
4534
+ var FriendRow = z.object({
4535
+ userId: z.string(),
4536
+ handle: z.string(),
4537
+ avatarUrl: z.string().url().nullable(),
4538
+ twitterHandle: z.string().nullable().optional(),
4539
+ publicProfile: z.boolean(),
4540
+ sharedRooms: z.array(FriendSharedRoom),
4541
+ tokens: z.number().int().nonnegative(),
4542
+ costUsdCents: z.number().int().nonnegative(),
4543
+ sessions: z.number().int().nonnegative()
4544
+ });
4545
+ var FriendsResponse = z.object({
4546
+ range: LeaderboardRange,
4547
+ friends: z.array(FriendRow)
4548
+ });
4549
+
4386
4550
  // src/lib/api.ts
4387
4551
  var DEFAULT_API_URL = "https://api.tokenrats.com";
4388
4552
  function isTransient(status) {
@@ -4437,8 +4601,8 @@ var ApiClient = class {
4437
4601
  }
4438
4602
  throw lastErr;
4439
4603
  }
4440
- async post(path3, body) {
4441
- const url = `${this.apiUrl}${path3}`;
4604
+ async post(path7, body) {
4605
+ const url = `${this.apiUrl}${path7}`;
4442
4606
  const res = await this.fetchWithRetry(url, {
4443
4607
  method: "POST",
4444
4608
  headers: this.headers(),
@@ -4449,8 +4613,8 @@ var ApiClient = class {
4449
4613
  }
4450
4614
  return res.json();
4451
4615
  }
4452
- async get(path3) {
4453
- const url = `${this.apiUrl}${path3}`;
4616
+ async get(path7) {
4617
+ const url = `${this.apiUrl}${path7}`;
4454
4618
  const res = await this.fetchWithRetry(url, {
4455
4619
  method: "GET",
4456
4620
  headers: this.headers()
@@ -4687,68 +4851,8 @@ function logoutCommand() {
4687
4851
  }
4688
4852
 
4689
4853
  // 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
- }
4854
+ import * as fs7 from "node:fs";
4855
+ import * as readline from "node:readline/promises";
4752
4856
 
4753
4857
  // ../parsers/src/hash.ts
4754
4858
  var FNV_PRIME = 16777619;
@@ -4811,6 +4915,8 @@ function parseClaudeCode(input) {
4811
4915
  endedAt: timestamp,
4812
4916
  inTokens: 0,
4813
4917
  outTokens: 0,
4918
+ cacheReadTokens: 0,
4919
+ cacheWriteTokens: 0,
4814
4920
  model: ""
4815
4921
  };
4816
4922
  sessions.set(sessionId, acc);
@@ -4829,10 +4935,10 @@ function parseClaudeCode(input) {
4829
4935
  const usage = msg.usage;
4830
4936
  if (typeof usage === "object" && usage !== null) {
4831
4937
  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;
4938
+ acc.inTokens += toNonNegInt(u.input_tokens);
4939
+ acc.outTokens += toNonNegInt(u.output_tokens);
4940
+ acc.cacheReadTokens += toNonNegInt(u.cache_read_input_tokens);
4941
+ acc.cacheWriteTokens += toNonNegInt(u.cache_creation_input_tokens);
4836
4942
  }
4837
4943
  }
4838
4944
  const results = [];
@@ -4841,7 +4947,7 @@ function parseClaudeCode(input) {
4841
4947
  const endedAt = acc.endedAt > 0 ? acc.endedAt : acc.startedAt;
4842
4948
  if (startedAt <= 0 || endedAt <= 0) continue;
4843
4949
  const model = acc.model.length > 0 ? acc.model : "unknown";
4844
- const { costUsdCents } = priceOf(model, acc.inTokens, acc.outTokens);
4950
+ const costUsdCents = 0;
4845
4951
  const dedupeKey = computeDedupeKey(
4846
4952
  "claude-code",
4847
4953
  model,
@@ -4852,9 +4958,12 @@ function parseClaudeCode(input) {
4852
4958
  results.push({
4853
4959
  id: `claude-code:${acc.sessionId}`,
4854
4960
  source: "claude-code",
4961
+ provider: "anthropic",
4855
4962
  model,
4856
4963
  inTokens: acc.inTokens,
4857
4964
  outTokens: acc.outTokens,
4965
+ cacheReadTokens: acc.cacheReadTokens,
4966
+ cacheWriteTokens: acc.cacheWriteTokens,
4858
4967
  costUsdCents,
4859
4968
  startedAt,
4860
4969
  endedAt,
@@ -4926,6 +5035,8 @@ function parseCodex(input) {
4926
5035
  const reasoning = toNonNegInt2(t.reasoning_output_tokens);
4927
5036
  acc.inTokens = Math.max(0, inputTotal - cachedInput);
4928
5037
  acc.outTokens = output + reasoning;
5038
+ acc.cacheReadTokens = cachedInput;
5039
+ acc.reasoningTokens = reasoning;
4929
5040
  }
4930
5041
  }
4931
5042
  const results = [];
@@ -4934,14 +5045,17 @@ function parseCodex(input) {
4934
5045
  const endedAt = acc.endedAt > 0 ? acc.endedAt : acc.startedAt;
4935
5046
  if (startedAt <= 0 || endedAt <= 0) continue;
4936
5047
  const model = acc.model.length > 0 ? acc.model : "unknown";
4937
- const { costUsdCents } = priceOf(model, acc.inTokens, acc.outTokens);
5048
+ const costUsdCents = 0;
4938
5049
  const dedupeKey = computeDedupeKey("codex", model, startedAt, acc.inTokens, acc.outTokens);
4939
5050
  results.push({
4940
5051
  id: `codex:${acc.sessionId}`,
4941
5052
  source: "codex",
5053
+ provider: "openai",
4942
5054
  model,
4943
5055
  inTokens: acc.inTokens,
4944
5056
  outTokens: acc.outTokens,
5057
+ cacheReadTokens: acc.cacheReadTokens,
5058
+ reasoningTokens: acc.reasoningTokens,
4945
5059
  costUsdCents,
4946
5060
  startedAt,
4947
5061
  endedAt,
@@ -4959,6 +5073,8 @@ function upsert(map, sessionId) {
4959
5073
  endedAt: 0,
4960
5074
  inTokens: 0,
4961
5075
  outTokens: 0,
5076
+ cacheReadTokens: 0,
5077
+ reasoningTokens: 0,
4962
5078
  model: ""
4963
5079
  };
4964
5080
  map.set(sessionId, acc);
@@ -4983,12 +5099,6 @@ var ESTIMATES = {
4983
5099
  composer: { inTokens: 1e4, outTokens: 2e3, model: "cursor-composer" }
4984
5100
  // "tab" deliberately omitted — see file header.
4985
5101
  };
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
5102
  function parseCursor(input) {
4993
5103
  let text;
4994
5104
  if (typeof input === "string") {
@@ -5007,20 +5117,21 @@ function parseCursor(input) {
5007
5117
  for (const raw of rows) {
5008
5118
  if (typeof raw !== "object" || raw === null) continue;
5009
5119
  const row = raw;
5010
- const id = typeof row["id"] === "string" ? row["id"] : null;
5120
+ const id = typeof row.id === "string" ? row.id : null;
5011
5121
  if (!id) continue;
5012
- const type = typeof row["type"] === "string" ? row["type"] : null;
5122
+ const type = typeof row.type === "string" ? row.type : null;
5013
5123
  if (!type) continue;
5014
- const unixMs = typeof row["unixMs"] === "number" && isFinite(row["unixMs"]) && row["unixMs"] > 0 ? row["unixMs"] : null;
5124
+ const unixMs = typeof row.unixMs === "number" && Number.isFinite(row.unixMs) && row.unixMs > 0 ? row.unixMs : null;
5015
5125
  if (unixMs === null) continue;
5016
5126
  const estimate = ESTIMATES[type];
5017
5127
  if (!estimate) continue;
5018
5128
  const { inTokens, outTokens, model } = estimate;
5019
- const costUsdCents = estimatedCostCents(inTokens, outTokens);
5129
+ const costUsdCents = 0;
5020
5130
  const dedupeKey = computeDedupeKey("cursor", model, unixMs, inTokens, outTokens);
5021
5131
  results.push({
5022
5132
  id: `cursor:${id}`,
5023
5133
  source: "cursor",
5134
+ provider: "cursor",
5024
5135
  model,
5025
5136
  inTokens,
5026
5137
  outTokens,
@@ -5034,7 +5145,7 @@ function parseCursor(input) {
5034
5145
  }
5035
5146
 
5036
5147
  // src/lib/cursor-extract.ts
5037
- import { existsSync, readdirSync } from "node:fs";
5148
+ import { existsSync, readdirSync, statSync } from "node:fs";
5038
5149
  import { readFile } from "node:fs/promises";
5039
5150
  import { homedir as homedir2 } from "node:os";
5040
5151
  import { join as join2 } from "node:path";
@@ -5045,10 +5156,10 @@ function cursorWorkspaceStorageDir() {
5045
5156
  return join2(home, "Library", "Application Support", rel);
5046
5157
  }
5047
5158
  if (process.platform === "win32") {
5048
- const appData = process.env["APPDATA"] ?? join2(home, "AppData", "Roaming");
5159
+ const appData = process.env.APPDATA ?? join2(home, "AppData", "Roaming");
5049
5160
  return join2(appData, rel);
5050
5161
  }
5051
- const xdgConfig = process.env["XDG_CONFIG_HOME"] ?? join2(home, ".config");
5162
+ const xdgConfig = process.env.XDG_CONFIG_HOME ?? join2(home, ".config");
5052
5163
  return join2(xdgConfig, rel);
5053
5164
  }
5054
5165
  function discoverWorkspaceDbs() {
@@ -5136,9 +5247,9 @@ async function readGenerationsFromDb(dbPath) {
5136
5247
  for (const item of parsed) {
5137
5248
  if (typeof item !== "object" || item === null) continue;
5138
5249
  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;
5250
+ const id = typeof r.generationUUID === "string" ? r.generationUUID : null;
5251
+ const type = typeof r.type === "string" ? r.type : null;
5252
+ const unixMs = typeof r.unixMs === "number" && Number.isFinite(r.unixMs) && r.unixMs > 0 ? r.unixMs : null;
5142
5253
  if (!id || !type || unixMs === null) continue;
5143
5254
  out.push({ id, type, unixMs });
5144
5255
  }
@@ -5187,17 +5298,373 @@ async function extractCursorGenerations() {
5187
5298
  }
5188
5299
  return { rows, dbCount: dbPaths.length, skipped: null };
5189
5300
  }
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
+ }
5190
5331
 
5191
- // src/lib/discover.ts
5332
+ // src/lib/daemon/linux.ts
5333
+ import { spawnSync } from "node:child_process";
5334
+ import * as fs3 from "node:fs";
5335
+ import * as os3 from "node:os";
5336
+ import * as path3 from "node:path";
5337
+
5338
+ // src/lib/daemon/paths.ts
5192
5339
  import * as fs2 from "node:fs";
5193
5340
  import * as os2 from "node:os";
5194
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";
5195
5662
  function findJsonlFiles(dir) {
5196
5663
  const results = [];
5197
- if (!fs2.existsSync(dir)) return results;
5198
- const entries = fs2.readdirSync(dir, { withFileTypes: true });
5664
+ if (!fs6.existsSync(dir)) return results;
5665
+ const entries = fs6.readdirSync(dir, { withFileTypes: true });
5199
5666
  for (const entry of entries) {
5200
- const full = path2.join(dir, entry.name);
5667
+ const full = path5.join(dir, entry.name);
5201
5668
  if (entry.isDirectory()) {
5202
5669
  results.push(...findJsonlFiles(full));
5203
5670
  } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
@@ -5207,32 +5674,32 @@ function findJsonlFiles(dir) {
5207
5674
  return results;
5208
5675
  }
5209
5676
  function claudeCodeProjectsDir() {
5210
- const home = os2.homedir();
5677
+ const home = os5.homedir();
5211
5678
  if (process.platform === "win32") {
5212
5679
  const profile = process.env.USERPROFILE ?? home;
5213
- return path2.join(profile, ".claude", "projects");
5680
+ return path5.join(profile, ".claude", "projects");
5214
5681
  }
5215
- return path2.join(home, ".claude", "projects");
5682
+ return path5.join(home, ".claude", "projects");
5216
5683
  }
5217
5684
  function discoverClaudeCodeFiles() {
5218
5685
  const dir = claudeCodeProjectsDir();
5219
5686
  return findJsonlFiles(dir);
5220
5687
  }
5221
5688
  function codexSessionsDirs() {
5222
- const home = os2.homedir();
5689
+ const home = os5.homedir();
5223
5690
  const candidates = [];
5224
5691
  if (process.platform === "win32") {
5225
5692
  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"));
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"));
5229
5696
  } 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 })) {
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 })) {
5234
5701
  if (entry.isDirectory()) {
5235
- candidates.push(path2.join(snapRoot, entry.name, "sessions"));
5702
+ candidates.push(path5.join(snapRoot, entry.name, "sessions"));
5236
5703
  }
5237
5704
  }
5238
5705
  }
@@ -5241,7 +5708,7 @@ function codexSessionsDirs() {
5241
5708
  const dirs = [];
5242
5709
  for (const c2 of candidates) {
5243
5710
  try {
5244
- const real = fs2.existsSync(c2) ? fs2.realpathSync(c2) : null;
5711
+ const real = fs6.existsSync(c2) ? fs6.realpathSync(c2) : null;
5245
5712
  if (real && !seen2.has(real)) {
5246
5713
  seen2.add(real);
5247
5714
  dirs.push(c2);
@@ -5259,6 +5726,9 @@ function discoverCodexFiles() {
5259
5726
  return out;
5260
5727
  }
5261
5728
 
5729
+ // src/lib/version.ts
5730
+ var CLI_VERSION = "0.1.0";
5731
+
5262
5732
  // src/commands/sync.ts
5263
5733
  var BATCH_SIZE = 500;
5264
5734
  function chunk(arr, size) {
@@ -5285,8 +5755,7 @@ function mergeBySessionId(records) {
5285
5755
  }
5286
5756
  }
5287
5757
  for (const rec of byId.values()) {
5288
- const { costUsdCents } = priceOf(rec.model, rec.inTokens, rec.outTokens);
5289
- rec.costUsdCents = costUsdCents;
5758
+ rec.costUsdCents = 0;
5290
5759
  rec.dedupeKey = computeDedupeKey(
5291
5760
  rec.source,
5292
5761
  rec.model,
@@ -5313,7 +5782,7 @@ async function syncCommand(opts) {
5313
5782
  for (const file of claudeFiles) {
5314
5783
  let text;
5315
5784
  try {
5316
- text = fs3.readFileSync(file, "utf8");
5785
+ text = fs7.readFileSync(file, "utf8");
5317
5786
  } catch {
5318
5787
  if (opts.verbose) warn(`Could not read ${file} \u2014 skipping`);
5319
5788
  continue;
@@ -5335,7 +5804,7 @@ async function syncCommand(opts) {
5335
5804
  for (const file of codexFiles) {
5336
5805
  let text;
5337
5806
  try {
5338
- text = fs3.readFileSync(file, "utf8");
5807
+ text = fs7.readFileSync(file, "utf8");
5339
5808
  } catch {
5340
5809
  if (opts.verbose) warn(`Could not read ${file} \u2014 skipping`);
5341
5810
  continue;
@@ -5396,7 +5865,9 @@ async function syncCommand(opts) {
5396
5865
  }
5397
5866
  info(`Discovered ${allSessions.length} session(s) from ${sourceStr}.`);
5398
5867
  if (opts.dryRun) {
5399
- info(`[dry-run] Would upload ${allSessions.length} session(s) in ${Math.ceil(allSessions.length / BATCH_SIZE)} batch(es).`);
5868
+ info(
5869
+ `[dry-run] Would upload ${allSessions.length} session(s) in ${Math.ceil(allSessions.length / BATCH_SIZE)} batch(es).`
5870
+ );
5400
5871
  if (opts.verbose) {
5401
5872
  for (const s of allSessions.slice(0, 10)) {
5402
5873
  dim(
@@ -5433,191 +5904,459 @@ async function syncCommand(opts) {
5433
5904
  success(
5434
5905
  `Synced ${allSessions.length} sessions (${totalAccepted} new, ${totalDuplicates} already on server) from ${sourceStr}`
5435
5906
  );
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
+ }
5436
5962
  }
5437
5963
 
5438
5964
  // src/commands/watch.ts
5439
- import * as fs4 from "node:fs";
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
+ }
5440
5995
  var seen = /* @__PURE__ */ new Set();
5441
- function parseAndFilter(filePath, verbose) {
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;
6009
+ }
6010
+ try {
6011
+ const res = await client.uploadSessions(records);
6012
+ health.uploaded += res.accepted;
6013
+ health.duplicates += res.duplicates;
6014
+ log.info("upload", { accepted: res.accepted, duplicates: res.duplicates });
6015
+ } catch (err) {
6016
+ health.errors++;
6017
+ if (err instanceof ApiError2 && err.status === 401) {
6018
+ if (daemon) {
6019
+ reauthBackoffUntil = Date.now() + REAUTH_BACKOFF_MS;
6020
+ log.error("Auth expired. Run `token-rats login` to recover.", {
6021
+ backoffUntil: new Date(reauthBackoffUntil).toISOString()
6022
+ });
6023
+ return;
6024
+ }
6025
+ error("Session expired. Run `token-rats login` to re-authenticate.");
6026
+ process.exit(1);
6027
+ }
6028
+ log.warn(`Upload failed: ${err instanceof Error ? err.message : String(err)}`);
6029
+ }
6030
+ }
6031
+ function freshClaudeRecords(filePath, log) {
5442
6032
  let text;
5443
6033
  try {
5444
- text = fs4.readFileSync(filePath, "utf8");
6034
+ text = fs8.readFileSync(filePath, "utf8");
5445
6035
  } catch {
5446
- if (verbose) warn(`Could not read ${filePath} \u2014 skipping`);
6036
+ log.debug(`Could not read ${filePath} \u2014 skipping`);
5447
6037
  return [];
5448
6038
  }
5449
6039
  let records;
5450
6040
  try {
5451
6041
  records = parseClaudeCode(text);
5452
6042
  } catch {
5453
- if (verbose) warn(`Failed to parse ${filePath} \u2014 skipping`);
6043
+ log.debug(`Failed to parse ${filePath} \u2014 skipping`);
5454
6044
  return [];
5455
6045
  }
5456
6046
  const fresh = [];
5457
6047
  for (const r of records) {
5458
- if (!seen.has(r.dedupeKey)) {
5459
- seen.add(r.dedupeKey);
5460
- fresh.push(r);
5461
- }
6048
+ if (seen.has(r.dedupeKey)) continue;
6049
+ seen.add(r.dedupeKey);
6050
+ fresh.push(r);
5462
6051
  }
5463
- if (verbose && fresh.length > 0) {
5464
- dim(` ${filePath}: ${fresh.length} new session(s)`);
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)`);
5465
6055
  }
5466
6056
  return fresh;
5467
6057
  }
5468
- async function upload(client, records, verbose) {
5469
- if (records.length === 0) return;
6058
+ function freshCodexRecords(filePath, log) {
6059
+ let text;
5470
6060
  try {
5471
- const res = await client.uploadSessions(records);
5472
- if (verbose) {
5473
- dim(` Uploaded ${res.accepted} new, ${res.duplicates} duplicate(s)`);
5474
- } else {
5475
- success(`Uploaded ${res.accepted} session(s)`);
5476
- }
5477
- } catch (err) {
5478
- if (err instanceof ApiError2 && err.status === 401) {
5479
- error("Session expired. Run `token-rats login` to re-authenticate.");
5480
- process.exit(1);
5481
- }
5482
- warn(`Upload failed: ${err instanceof Error ? err.message : String(err)}`);
6061
+ text = fs8.readFileSync(filePath, "utf8");
6062
+ } catch {
6063
+ return [];
6064
+ }
6065
+ let records;
6066
+ try {
6067
+ records = parseCodex(text);
6068
+ } catch {
6069
+ return [];
5483
6070
  }
6071
+ const fresh = [];
6072
+ for (const r of records) {
6073
+ if (seen.has(r.dedupeKey)) continue;
6074
+ seen.add(r.dedupeKey);
6075
+ fresh.push(r);
6076
+ }
6077
+ if (fresh.length > 0) {
6078
+ health.lastSourceAt.codex = (/* @__PURE__ */ new Date()).toISOString();
6079
+ log.debug(`${filePath}: ${fresh.length} new Codex session(s)`);
6080
+ }
6081
+ return fresh;
5484
6082
  }
5485
6083
  function makeDebounced(fn, ms) {
5486
6084
  const timers = /* @__PURE__ */ new Map();
5487
- return (path3) => {
5488
- const existing = timers.get(path3);
6085
+ return (p) => {
6086
+ const existing = timers.get(p);
5489
6087
  if (existing) clearTimeout(existing);
5490
6088
  timers.set(
5491
- path3,
6089
+ p,
5492
6090
  setTimeout(() => {
5493
- timers.delete(path3);
5494
- fn(path3);
6091
+ timers.delete(p);
6092
+ fn(p);
5495
6093
  }, ms)
5496
6094
  );
5497
6095
  };
5498
6096
  }
5499
6097
  function findJsonlFiles2(dir) {
5500
- const results = [];
5501
- if (!fs4.existsSync(dir)) return results;
5502
- const entries = fs4.readdirSync(dir, { withFileTypes: true });
5503
- for (const entry of entries) {
5504
- const full = `${dir}/${entry.name}`;
5505
- if (entry.isDirectory()) {
5506
- results.push(...findJsonlFiles2(full));
5507
- } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
5508
- results.push(full);
5509
- }
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);
5510
6104
  }
5511
- return results;
6105
+ return out;
5512
6106
  }
5513
- async function watchCommand(opts) {
5514
- const token = loadToken();
5515
- if (!token) {
5516
- error("Not logged in. Run `token-rats login` first.");
5517
- process.exit(1);
5518
- }
5519
- const client = new ApiClient({ apiUrl: opts.apiUrl, token });
5520
- const debounceMs = opts.interval ?? 2e3;
5521
- const dir = claudeCodeProjectsDir();
5522
- if (!fs4.existsSync(dir)) {
5523
- warn(`Claude Code projects directory not found: ${dir}`);
5524
- warn("No files to watch. Exiting.");
5525
- process.exit(0);
5526
- }
5527
- info(`Watching ${dir} (debounce: ${debounceMs}ms)`);
5528
- info("Press Ctrl-C to stop.\n");
5529
- const initialFiles = findJsonlFiles2(dir);
5530
- for (const f of initialFiles) {
5531
- parseAndFilter(f, false);
5532
- }
5533
- if (opts.verbose) {
5534
- dim(`Initial snapshot: ${seen.size} session(s) in ${initialFiles.length} file(s)`);
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
+ };
5535
6112
  }
5536
- const onChanged = makeDebounced(async (filePath) => {
5537
- if (!filePath.endsWith(".jsonl")) return;
5538
- const fresh = parseAndFilter(filePath, opts.verbose ?? false);
5539
- if (fresh.length > 0) {
5540
- await upload(client, fresh, opts.verbose ?? false);
5541
- }
6113
+ const debouncedChange = makeDebounced((p) => {
6114
+ if (p.endsWith(".jsonl")) onChange(p);
5542
6115
  }, debounceMs);
5543
- let cleanup = null;
5544
6116
  try {
5545
- const chokidar = await new Function("m", "return import(m)")(
5546
- "chokidar"
5547
- );
5548
- const watcher = chokidar.watch(`${dir}/**/*.jsonl`, {
6117
+ const dynImport = new Function("m", "return import(m)");
6118
+ const chokidarMod = await dynImport("chokidar");
6119
+ const watcher = chokidarMod.watch(`${dir}/**/*.jsonl`, {
5549
6120
  ignoreInitial: true,
5550
6121
  persistent: true,
5551
6122
  awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 }
5552
6123
  });
5553
- watcher.on("add", (p) => onChanged(p));
5554
- watcher.on("change", (p) => onChanged(p));
5555
- cleanup = () => {
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 () => {
5556
6131
  watcher.close();
5557
6132
  };
5558
- if (opts.verbose) dim("Using chokidar for file watching");
5559
6133
  } catch {
5560
- if (opts.verbose) dim("chokidar not available; using Node built-in fs.watch");
5561
- if (process.platform === "linux") {
5562
- let lastMtimes = /* @__PURE__ */ new Map();
5563
- for (const f of initialFiles) {
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) {
5564
6146
  try {
5565
- lastMtimes.set(f, fs4.statSync(f).mtimeMs);
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
+ }
5566
6153
  } catch {
5567
6154
  }
5568
6155
  }
5569
- const pollInterval = setInterval(() => {
5570
- const current = findJsonlFiles2(dir);
5571
- for (const f of current) {
5572
- try {
5573
- const mtime = fs4.statSync(f).mtimeMs;
5574
- const prev = lastMtimes.get(f) ?? 0;
5575
- if (mtime > prev) {
5576
- lastMtimes.set(f, mtime);
5577
- onChanged(f);
5578
- }
5579
- } catch {
5580
- }
6156
+ for (const f of current) {
6157
+ if (!lastMtimes.has(f)) {
6158
+ lastMtimes.set(f, Date.now());
6159
+ debouncedChange(f);
5581
6160
  }
5582
- for (const f of current) {
5583
- if (!lastMtimes.has(f)) {
5584
- lastMtimes.set(f, Date.now());
5585
- onChanged(f);
5586
- }
5587
- }
5588
- lastMtimes = new Map(current.map((f) => [f, lastMtimes.get(f) ?? 0]));
5589
- }, debounceMs);
5590
- cleanup = () => clearInterval(pollInterval);
5591
- } else {
5592
- const controller = new AbortController();
5593
- (async () => {
5594
- try {
5595
- const { watch } = await import("node:fs/promises");
5596
- const watcher = watch(dir, { recursive: true, signal: controller.signal });
5597
- for await (const event of watcher) {
5598
- const filename = event.filename;
5599
- if (filename?.endsWith(".jsonl")) {
5600
- const fullPath = `${dir}/${filename}`;
5601
- onChanged(fullPath);
5602
- }
5603
- }
5604
- } catch (err) {
5605
- if (err instanceof Error && err.name !== "AbortError") {
5606
- warn(`Watcher error: ${err.message}`);
5607
- }
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));
5608
6174
  }
5609
- })();
5610
- cleanup = () => controller.abort();
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;
5611
6225
  }
6226
+ };
6227
+ const initial = setTimeout(tick, 5e3);
6228
+ const handle = setInterval(tick, CURSOR_POLL_MS);
6229
+ return () => {
6230
+ stopped = true;
6231
+ clearTimeout(initial);
6232
+ clearInterval(handle);
6233
+ };
6234
+ }
6235
+ async function runWatcher(opts) {
6236
+ const daemon = opts.daemon === true;
6237
+ const verbose = opts.verbose === true;
6238
+ const log = makeLogger(daemon, verbose);
6239
+ const token = loadToken();
6240
+ if (!token) {
6241
+ log.error("Not logged in. Run `token-rats login` first.");
6242
+ if (!daemon) process.exit(1);
6243
+ reauthBackoffUntil = Date.now() + REAUTH_BACKOFF_MS;
5612
6244
  }
5613
- function shutdown() {
5614
- info("Shutting down\u2026");
5615
- if (cleanup) cleanup();
5616
- process.exit(0);
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
+ );
5617
6283
  }
6284
+ cleanups.push(startCursorLoop(client, log, daemon));
6285
+ process.on("SIGUSR1", () => {
6286
+ log.info("health", {
6287
+ uptimeSec: Math.round(process.uptime()),
6288
+ uploaded: health.uploaded,
6289
+ duplicates: health.duplicates,
6290
+ errors: health.errors,
6291
+ lastSourceAt: health.lastSourceAt,
6292
+ seenKeys: seen.size,
6293
+ reauthBackoffUntil: reauthBackoffUntil > Date.now() ? new Date(reauthBackoffUntil).toISOString() : null
6294
+ });
6295
+ });
6296
+ const shutdown = () => {
6297
+ log.info("watch stopping");
6298
+ for (const c2 of cleanups) c2();
6299
+ process.exit(0);
6300
+ };
5618
6301
  process.on("SIGINT", shutdown);
5619
6302
  process.on("SIGTERM", shutdown);
5620
6303
  }
6304
+ async function handleInstall(opts) {
6305
+ if (!isDaemonSupported()) {
6306
+ error(`Background daemon not supported on ${process.platform} yet.`);
6307
+ process.exit(1);
6308
+ }
6309
+ try {
6310
+ const r = await installDaemon(opts.version ?? "0.0.0");
6311
+ success(`Background watcher installed (${r.method}).`);
6312
+ info(`Logs: ${r.logDir}/watch.log`);
6313
+ if (r.ephemeralWarning) {
6314
+ warn(
6315
+ "You ran this via npx \u2014 the daemon may break when the npx cache is cleared.\nRun `npm i -g token-rats` (or `pnpm add -g token-rats`) for a durable install."
6316
+ );
6317
+ }
6318
+ if (r.method === "systemd" && r.lingerSet === false) {
6319
+ warn(
6320
+ "For the watcher to survive logout, run once:\n sudo loginctl enable-linger $USER\nWithout it, the watcher resumes at your next login."
6321
+ );
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
+ }
5621
6360
 
5622
6361
  // src/commands/whoami.ts
5623
6362
  async function whoamiCommand(opts) {
@@ -5642,7 +6381,7 @@ async function whoamiCommand(opts) {
5642
6381
 
5643
6382
  // src/index.ts
5644
6383
  function getVersion() {
5645
- return "0.0.4";
6384
+ return CLI_VERSION;
5646
6385
  }
5647
6386
  function printHelp() {
5648
6387
  console.log(`
@@ -5653,8 +6392,13 @@ function printHelp() {
5653
6392
 
5654
6393
  \x1B[1mCommands:\x1B[0m
5655
6394
  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
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.
5658
6402
  whoami Show the currently signed-in account
5659
6403
  logout Clear your stored credentials
5660
6404
  install-cursor Install better-sqlite3 globally for faster Cursor reads
@@ -5667,8 +6411,15 @@ function printHelp() {
5667
6411
  \x1B[1mFlags (sync only):\x1B[0m
5668
6412
  --dry-run Parse but do not upload; print what would be sent
5669
6413
  --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)
5670
6416
 
5671
6417
  \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.
5672
6423
  --interval <ms> Debounce window in ms before uploading (default: 2000)
5673
6424
  --verbose Print file change events and upload detail
5674
6425
 
@@ -5697,6 +6448,11 @@ function parseArgs(argv) {
5697
6448
  let dryRun = false;
5698
6449
  let verbose = false;
5699
6450
  let interval;
6451
+ let noDaemon = false;
6452
+ let daemon = false;
6453
+ let install = false;
6454
+ let uninstall = false;
6455
+ let status = false;
5700
6456
  let i = 0;
5701
6457
  while (i < argv.length) {
5702
6458
  const arg = argv[i];
@@ -5712,6 +6468,16 @@ function parseArgs(argv) {
5712
6468
  interval = Number(argv[++i]);
5713
6469
  } else if (arg.startsWith("--interval=")) {
5714
6470
  interval = Number(arg.slice("--interval=".length));
6471
+ } else if (arg === "--no-daemon") {
6472
+ 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;
5715
6481
  } else if (arg === "--version" || arg === "-V") {
5716
6482
  console.log(getVersion());
5717
6483
  process.exit(0);
@@ -5724,11 +6490,34 @@ function parseArgs(argv) {
5724
6490
  i++;
5725
6491
  }
5726
6492
  const [command = null, ...rest] = positional;
5727
- return { command, apiUrl, dryRun, verbose, interval, rest };
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
+ };
5728
6506
  }
5729
6507
  async function main() {
5730
6508
  const args = parseArgs(process.argv.slice(2));
5731
- const { command, apiUrl, dryRun, verbose, interval } = args;
6509
+ const {
6510
+ command,
6511
+ apiUrl,
6512
+ dryRun,
6513
+ verbose,
6514
+ interval,
6515
+ noDaemon,
6516
+ daemon,
6517
+ install,
6518
+ uninstall,
6519
+ status
6520
+ } = args;
5732
6521
  if (!command || command === "help") {
5733
6522
  printHelp();
5734
6523
  process.exit(0);
@@ -5738,10 +6527,19 @@ async function main() {
5738
6527
  await loginCommand({ apiUrl });
5739
6528
  break;
5740
6529
  case "sync":
5741
- await syncCommand({ apiUrl, dryRun, verbose });
6530
+ await syncCommand({ apiUrl, dryRun, verbose, noDaemon });
5742
6531
  break;
5743
6532
  case "watch":
5744
- await watchCommand({ apiUrl, verbose, interval });
6533
+ await watchCommand({
6534
+ apiUrl,
6535
+ verbose,
6536
+ interval,
6537
+ daemon,
6538
+ install,
6539
+ uninstall,
6540
+ status,
6541
+ version: getVersion()
6542
+ });
5745
6543
  break;
5746
6544
  case "whoami":
5747
6545
  await whoamiCommand({ apiUrl });