token-rats 0.2.0 → 0.3.1
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.
- package/LICENSE +21 -0
- package/README.md +16 -0
- package/dist/index.js +541 -526
- package/package.json +4 -5
package/dist/index.js
CHANGED
|
@@ -11,12 +11,12 @@ async function installCursorCommand(opts = {}) {
|
|
|
11
11
|
const args = ["install", "-g", "better-sqlite3@^9.4.3"];
|
|
12
12
|
console.log(`\x1B[2m$ ${pm} ${args.join(" ")}\x1B[0m
|
|
13
13
|
`);
|
|
14
|
-
const exitCode = await new Promise((
|
|
14
|
+
const exitCode = await new Promise((resolve2) => {
|
|
15
15
|
const child = spawn(pm, args, { stdio: "inherit" });
|
|
16
|
-
child.on("close", (code) =>
|
|
16
|
+
child.on("close", (code) => resolve2(code ?? 1));
|
|
17
17
|
child.on("error", (err) => {
|
|
18
18
|
console.error(`\x1B[31mFailed to launch ${pm}: ${err.message}\x1B[0m`);
|
|
19
|
-
|
|
19
|
+
resolve2(1);
|
|
20
20
|
});
|
|
21
21
|
});
|
|
22
22
|
if (exitCode === 0) {
|
|
@@ -35,6 +35,7 @@ async function installCursorCommand(opts = {}) {
|
|
|
35
35
|
// src/commands/install-daemon.ts
|
|
36
36
|
import { execFile } from "node:child_process";
|
|
37
37
|
import * as fs2 from "node:fs";
|
|
38
|
+
import { createRequire } from "node:module";
|
|
38
39
|
import * as os2 from "node:os";
|
|
39
40
|
import * as path2 from "node:path";
|
|
40
41
|
import { promisify } from "node:util";
|
|
@@ -150,6 +151,9 @@ function error(msg) {
|
|
|
150
151
|
function dim(msg) {
|
|
151
152
|
console.log(color(c.dim, ` ${msg}`));
|
|
152
153
|
}
|
|
154
|
+
function bold(msg) {
|
|
155
|
+
console.log(isTTY() ? `${c.bold}${msg}${c.reset}` : msg);
|
|
156
|
+
}
|
|
153
157
|
function spinner(label) {
|
|
154
158
|
if (!isTTY()) {
|
|
155
159
|
process.stdout.write(` ${label}...
|
|
@@ -181,8 +185,38 @@ var LABEL = "com.tokenrats.watch";
|
|
|
181
185
|
var LINUX_UNIT = "token-rats-watch.service";
|
|
182
186
|
var WINDOWS_TASK = "TokenRatsWatch";
|
|
183
187
|
function resolveCliPath() {
|
|
184
|
-
const
|
|
185
|
-
|
|
188
|
+
const entry = path2.resolve(process.argv[1] ?? "");
|
|
189
|
+
if (!entry.endsWith(".js")) throw new Error("Build the CLI before installing the daemon.");
|
|
190
|
+
const config = process.env.XDG_CONFIG_HOME ?? path2.join(os2.homedir(), ".config");
|
|
191
|
+
const runtime = path2.join(config, "token-rats", "runtime");
|
|
192
|
+
fs2.mkdirSync(runtime, { recursive: true });
|
|
193
|
+
const script = path2.join(runtime, "index.js");
|
|
194
|
+
if (entry !== script) fs2.copyFileSync(entry, script);
|
|
195
|
+
fs2.writeFileSync(path2.join(runtime, "package.json"), '{"type":"module"}\n');
|
|
196
|
+
const require2 = createRequire(import.meta.url);
|
|
197
|
+
const sqlPackage = path2.dirname(require2.resolve("sql.js/package.json"));
|
|
198
|
+
const destination = path2.join(runtime, "node_modules", "sql.js");
|
|
199
|
+
if (sqlPackage !== destination) fs2.cpSync(sqlPackage, destination, { recursive: true });
|
|
200
|
+
const runner = path2.join(runtime, "runner.mjs");
|
|
201
|
+
const settings = Object.fromEntries(
|
|
202
|
+
["XDG_CONFIG_HOME", "CLAUDE_CONFIG_DIR", "CODEX_HOME", "APPDATA"].flatMap(
|
|
203
|
+
(key) => process.env[key] ? [[key, process.env[key]]] : []
|
|
204
|
+
)
|
|
205
|
+
);
|
|
206
|
+
fs2.writeFileSync(
|
|
207
|
+
runner,
|
|
208
|
+
`Object.assign(process.env, ${JSON.stringify(settings)});
|
|
209
|
+
await import("./index.js");
|
|
210
|
+
`
|
|
211
|
+
);
|
|
212
|
+
return { node: process.execPath, script: runner };
|
|
213
|
+
}
|
|
214
|
+
function xml(value) {
|
|
215
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
216
|
+
}
|
|
217
|
+
function systemdArg(value) {
|
|
218
|
+
const escaped = value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%").replaceAll("$", "$$");
|
|
219
|
+
return `"${escaped}"`;
|
|
186
220
|
}
|
|
187
221
|
function darwinPlistPath() {
|
|
188
222
|
return path2.join(os2.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
@@ -190,7 +224,7 @@ function darwinPlistPath() {
|
|
|
190
224
|
function darwinLogDir() {
|
|
191
225
|
return path2.join(os2.homedir(), "Library", "Logs", "token-rats");
|
|
192
226
|
}
|
|
193
|
-
function darwinPlist(node, script) {
|
|
227
|
+
function darwinPlist(node, script, apiUrl) {
|
|
194
228
|
const logDir = darwinLogDir();
|
|
195
229
|
const stdout = path2.join(logDir, "watch.out.log");
|
|
196
230
|
const stderr = path2.join(logDir, "watch.err.log");
|
|
@@ -202,28 +236,32 @@ function darwinPlist(node, script) {
|
|
|
202
236
|
<string>${LABEL}</string>
|
|
203
237
|
<key>ProgramArguments</key>
|
|
204
238
|
<array>
|
|
205
|
-
<string>${node}</string>
|
|
206
|
-
<string>${script}</string>
|
|
239
|
+
<string>${xml(node)}</string>
|
|
240
|
+
<string>${xml(script)}</string>
|
|
207
241
|
<string>watch</string>
|
|
242
|
+
${apiUrl ? `<string>--api-url</string><string>${xml(apiUrl)}</string>` : ""}
|
|
208
243
|
</array>
|
|
209
244
|
<key>RunAtLoad</key>
|
|
210
245
|
<true/>
|
|
211
246
|
<key>KeepAlive</key>
|
|
212
247
|
<true/>
|
|
213
248
|
<key>StandardOutPath</key>
|
|
214
|
-
<string>${stdout}</string>
|
|
249
|
+
<string>${xml(stdout)}</string>
|
|
215
250
|
<key>StandardErrorPath</key>
|
|
216
|
-
<string>${stderr}</string>
|
|
251
|
+
<string>${xml(stderr)}</string>
|
|
217
252
|
</dict>
|
|
218
253
|
</plist>
|
|
219
254
|
`;
|
|
220
255
|
}
|
|
221
|
-
async function darwinInstall() {
|
|
256
|
+
async function darwinInstall(apiUrl) {
|
|
222
257
|
const { node, script } = resolveCliPath();
|
|
223
258
|
fs2.mkdirSync(darwinLogDir(), { recursive: true });
|
|
224
259
|
const plistPath = darwinPlistPath();
|
|
225
260
|
fs2.mkdirSync(path2.dirname(plistPath), { recursive: true });
|
|
226
|
-
fs2.writeFileSync(plistPath, darwinPlist(node, script), { mode: 420 });
|
|
261
|
+
fs2.writeFileSync(plistPath, darwinPlist(node, script, apiUrl), { mode: 420 });
|
|
262
|
+
await exec("launchctl", ["bootout", `gui/${process.getuid?.() ?? 0}/${LABEL}`]).catch(
|
|
263
|
+
() => void 0
|
|
264
|
+
);
|
|
227
265
|
try {
|
|
228
266
|
await exec("launchctl", ["bootstrap", `gui/${process.getuid?.() ?? 0}`, plistPath]);
|
|
229
267
|
} catch {
|
|
@@ -255,7 +293,8 @@ async function darwinStatus() {
|
|
|
255
293
|
if (!fs2.existsSync(darwinPlistPath())) return "not-installed";
|
|
256
294
|
try {
|
|
257
295
|
const { stdout } = await exec("launchctl", ["list"]);
|
|
258
|
-
if (stdout.split("\n").some((line) => line.endsWith(LABEL)))
|
|
296
|
+
if (stdout.split("\n").some((line) => line.endsWith(LABEL) && /^\d+\s/.test(line)))
|
|
297
|
+
return "running";
|
|
259
298
|
return "stopped";
|
|
260
299
|
} catch {
|
|
261
300
|
return "stopped";
|
|
@@ -265,7 +304,7 @@ function linuxUnitPath() {
|
|
|
265
304
|
const xdgConfig = process.env.XDG_CONFIG_HOME ?? path2.join(os2.homedir(), ".config");
|
|
266
305
|
return path2.join(xdgConfig, "systemd", "user", LINUX_UNIT);
|
|
267
306
|
}
|
|
268
|
-
function linuxUnit(node, script) {
|
|
307
|
+
function linuxUnit(node, script, apiUrl) {
|
|
269
308
|
return `[Unit]
|
|
270
309
|
Description=Token Rats watcher \u2014 live AI usage sync
|
|
271
310
|
After=network-online.target
|
|
@@ -273,7 +312,7 @@ Wants=network-online.target
|
|
|
273
312
|
|
|
274
313
|
[Service]
|
|
275
314
|
Type=simple
|
|
276
|
-
ExecStart=${node} ${script} watch
|
|
315
|
+
ExecStart=${systemdArg(node)} ${systemdArg(script)} watch${apiUrl ? ` --api-url ${systemdArg(apiUrl)}` : ""}
|
|
277
316
|
Restart=on-failure
|
|
278
317
|
RestartSec=10s
|
|
279
318
|
Environment=NODE_ENV=production
|
|
@@ -282,14 +321,15 @@ Environment=NODE_ENV=production
|
|
|
282
321
|
WantedBy=default.target
|
|
283
322
|
`;
|
|
284
323
|
}
|
|
285
|
-
async function linuxInstall() {
|
|
324
|
+
async function linuxInstall(apiUrl) {
|
|
286
325
|
const { node, script } = resolveCliPath();
|
|
287
326
|
const unitPath = linuxUnitPath();
|
|
288
327
|
fs2.mkdirSync(path2.dirname(unitPath), { recursive: true });
|
|
289
|
-
fs2.writeFileSync(unitPath, linuxUnit(node, script), { mode: 420 });
|
|
328
|
+
fs2.writeFileSync(unitPath, linuxUnit(node, script, apiUrl), { mode: 420 });
|
|
290
329
|
try {
|
|
291
330
|
await exec("systemctl", ["--user", "daemon-reload"]);
|
|
292
331
|
await exec("systemctl", ["--user", "enable", "--now", LINUX_UNIT]);
|
|
332
|
+
await exec("systemctl", ["--user", "restart", LINUX_UNIT]);
|
|
293
333
|
} catch (err) {
|
|
294
334
|
throw new Error(
|
|
295
335
|
`Wrote ${unitPath} but failed to enable+start it: ${err instanceof Error ? err.message : String(err)}`
|
|
@@ -319,7 +359,7 @@ async function linuxStatus() {
|
|
|
319
359
|
return "stopped";
|
|
320
360
|
}
|
|
321
361
|
}
|
|
322
|
-
async function windowsInstall() {
|
|
362
|
+
async function windowsInstall(apiUrl) {
|
|
323
363
|
const { node, script } = resolveCliPath();
|
|
324
364
|
await exec("schtasks", [
|
|
325
365
|
"/Create",
|
|
@@ -328,7 +368,7 @@ async function windowsInstall() {
|
|
|
328
368
|
"/TN",
|
|
329
369
|
WINDOWS_TASK,
|
|
330
370
|
"/TR",
|
|
331
|
-
`"${node}" "${script}" watch`,
|
|
371
|
+
`"${node}" "${script}" watch${apiUrl ? ` --api-url "${apiUrl}"` : ""}`,
|
|
332
372
|
"/RL",
|
|
333
373
|
"LIMITED",
|
|
334
374
|
"/F"
|
|
@@ -357,21 +397,21 @@ async function windowsStatus() {
|
|
|
357
397
|
return "not-installed";
|
|
358
398
|
}
|
|
359
399
|
}
|
|
360
|
-
async function installDaemonCommand() {
|
|
400
|
+
async function installDaemonCommand(apiUrl) {
|
|
361
401
|
clearDisconnected();
|
|
362
402
|
try {
|
|
363
403
|
if (process.platform === "darwin") {
|
|
364
|
-
await darwinInstall();
|
|
404
|
+
await darwinInstall(apiUrl);
|
|
365
405
|
} else if (process.platform === "linux") {
|
|
366
|
-
await linuxInstall();
|
|
406
|
+
await linuxInstall(apiUrl);
|
|
367
407
|
} else if (process.platform === "win32") {
|
|
368
|
-
await windowsInstall();
|
|
408
|
+
await windowsInstall(apiUrl);
|
|
369
409
|
} else {
|
|
370
410
|
warn(`No daemon installer for platform ${process.platform}; skipping.`);
|
|
371
411
|
return;
|
|
372
412
|
}
|
|
373
413
|
success("Background watcher installed and running.");
|
|
374
|
-
dim("It will pick up
|
|
414
|
+
dim("It will pick up sessions from Claude Code, Codex, and Cursor every 30 seconds.");
|
|
375
415
|
dim("Manage it with `token-rats daemon-status` and `token-rats uninstall-daemon`.");
|
|
376
416
|
} catch (err) {
|
|
377
417
|
error(`Failed to install daemon: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -758,8 +798,8 @@ function getErrorMap() {
|
|
|
758
798
|
return overrideErrorMap;
|
|
759
799
|
}
|
|
760
800
|
var makeIssue = (params) => {
|
|
761
|
-
const { data, path:
|
|
762
|
-
const fullPath = [...
|
|
801
|
+
const { data, path: path4, errorMaps, issueData } = params;
|
|
802
|
+
const fullPath = [...path4, ...issueData.path || []];
|
|
763
803
|
const fullIssue = {
|
|
764
804
|
...issueData,
|
|
765
805
|
path: fullPath
|
|
@@ -881,11 +921,11 @@ var errorUtil;
|
|
|
881
921
|
var _ZodEnum_cache;
|
|
882
922
|
var _ZodNativeEnum_cache;
|
|
883
923
|
var ParseInputLazyPath = class {
|
|
884
|
-
constructor(parent, value,
|
|
924
|
+
constructor(parent, value, path4, key) {
|
|
885
925
|
this._cachedPath = [];
|
|
886
926
|
this.parent = parent;
|
|
887
927
|
this.data = value;
|
|
888
|
-
this._path =
|
|
928
|
+
this._path = path4;
|
|
889
929
|
this._key = key;
|
|
890
930
|
}
|
|
891
931
|
get path() {
|
|
@@ -4320,7 +4360,7 @@ var z = /* @__PURE__ */ Object.freeze({
|
|
|
4320
4360
|
});
|
|
4321
4361
|
|
|
4322
4362
|
// ../contracts/src/session.ts
|
|
4323
|
-
var Source = z.enum(["claude-code", "cursor", "codex"]);
|
|
4363
|
+
var Source = z.enum(["claude-code", "cursor", "codex", "openrouter", "openai"]);
|
|
4324
4364
|
var Provider = z.enum([
|
|
4325
4365
|
"anthropic",
|
|
4326
4366
|
"openai",
|
|
@@ -4330,13 +4370,18 @@ var Provider = z.enum([
|
|
|
4330
4370
|
"unknown"
|
|
4331
4371
|
]);
|
|
4332
4372
|
var SessionChannel = z.enum(["cli", "ide", "api", "proxy", "local", "unknown"]);
|
|
4373
|
+
var ID_RE = /^[A-Za-z0-9._:/+ -]+$/;
|
|
4374
|
+
var MODEL_RE = /^[A-Za-z0-9._:/+ -]+$/;
|
|
4375
|
+
var DEDUPE_KEY_RE = /^[A-Za-z0-9._:-]+$/;
|
|
4376
|
+
var CLIENT_RE = /^[A-Za-z0-9._-]+$/;
|
|
4333
4377
|
var SessionRecord = z.object({
|
|
4334
|
-
|
|
4378
|
+
accountingVersion: z.literal(2).optional(),
|
|
4379
|
+
id: z.string().min(1).max(256).regex(ID_RE),
|
|
4335
4380
|
source: Source,
|
|
4336
4381
|
provider: Provider.optional(),
|
|
4337
|
-
client: z.string().min(1).max(64).optional(),
|
|
4382
|
+
client: z.string().min(1).max(64).regex(CLIENT_RE).optional(),
|
|
4338
4383
|
channel: SessionChannel.optional(),
|
|
4339
|
-
model: z.string().min(1),
|
|
4384
|
+
model: z.string().min(1).max(128).regex(MODEL_RE),
|
|
4340
4385
|
inTokens: z.number().int().nonnegative(),
|
|
4341
4386
|
outTokens: z.number().int().nonnegative(),
|
|
4342
4387
|
/** Anthropic cache reads / OpenAI `cached_input_tokens`. Billed cheap-or-free. */
|
|
@@ -4348,7 +4393,7 @@ var SessionRecord = z.object({
|
|
|
4348
4393
|
costUsdCents: z.number().int().nonnegative(),
|
|
4349
4394
|
startedAt: z.number().int().positive(),
|
|
4350
4395
|
endedAt: z.number().int().positive(),
|
|
4351
|
-
dedupeKey: z.string().min(1)
|
|
4396
|
+
dedupeKey: z.string().min(1).max(128).regex(DEDUPE_KEY_RE)
|
|
4352
4397
|
});
|
|
4353
4398
|
|
|
4354
4399
|
// ../contracts/src/user.ts
|
|
@@ -4358,6 +4403,27 @@ var ProfileAttributionEntry = z.object({
|
|
|
4358
4403
|
costUsdCents: z.number().int().nonnegative(),
|
|
4359
4404
|
sessions: z.number().int().nonnegative()
|
|
4360
4405
|
});
|
|
4406
|
+
var GithubProject = z.object({
|
|
4407
|
+
name: z.string().min(1).max(100),
|
|
4408
|
+
fullName: z.string().min(3).max(201).regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/),
|
|
4409
|
+
url: z.string().url().startsWith("https://github.com/"),
|
|
4410
|
+
description: z.string().max(500).nullable()
|
|
4411
|
+
}).superRefine((project, context) => {
|
|
4412
|
+
const repositoryName = project.fullName.split("/")[1];
|
|
4413
|
+
const canonicalUrl = `https://github.com/${project.fullName}`;
|
|
4414
|
+
if (repositoryName?.toLowerCase() !== project.name.toLowerCase()) {
|
|
4415
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: "GitHub project name is invalid" });
|
|
4416
|
+
}
|
|
4417
|
+
if (project.url.toLowerCase() !== canonicalUrl.toLowerCase()) {
|
|
4418
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: "GitHub project URL is invalid" });
|
|
4419
|
+
}
|
|
4420
|
+
});
|
|
4421
|
+
var GithubProjects = z.array(GithubProject).max(3).refine(
|
|
4422
|
+
(projects) => new Set(projects.map((project) => project.fullName.toLowerCase())).size === projects.length,
|
|
4423
|
+
{
|
|
4424
|
+
message: "GitHub projects must be unique"
|
|
4425
|
+
}
|
|
4426
|
+
);
|
|
4361
4427
|
var User = z.object({
|
|
4362
4428
|
id: z.string(),
|
|
4363
4429
|
handle: z.string(),
|
|
@@ -4377,13 +4443,20 @@ var User = z.object({
|
|
|
4377
4443
|
* (other readers never see this field). `null` when GitHub didn't return a
|
|
4378
4444
|
* verified email — the user sees a banner asking them to add one.
|
|
4379
4445
|
*/
|
|
4380
|
-
email: z.string().email().nullable().optional()
|
|
4446
|
+
email: z.string().email().nullable().optional(),
|
|
4447
|
+
/** Full text is self-only; public profiles receive agentInstructionsPreview instead. */
|
|
4448
|
+
agentInstructions: z.string().max(2e4).nullable().optional(),
|
|
4449
|
+
githubProjects: GithubProjects.optional()
|
|
4381
4450
|
});
|
|
4382
4451
|
var PublicProfileSettings = z.object({
|
|
4383
4452
|
publicProfile: z.boolean().optional(),
|
|
4384
|
-
bio: z.string().max(200).nullable().optional()
|
|
4453
|
+
bio: z.string().max(200).nullable().optional(),
|
|
4454
|
+
agentInstructions: z.string().max(2e4).nullable().optional(),
|
|
4455
|
+
githubProjects: GithubProjects.optional()
|
|
4385
4456
|
});
|
|
4386
4457
|
var Profile = User.extend({
|
|
4458
|
+
/** The first ten lines of the owner's saved agent instructions. */
|
|
4459
|
+
agentInstructionsPreview: z.string().max(4e3).nullable().optional(),
|
|
4387
4460
|
totals: z.object({
|
|
4388
4461
|
today: z.object({ tokens: z.number().int(), costUsdCents: z.number().int() }),
|
|
4389
4462
|
week: z.object({ tokens: z.number().int(), costUsdCents: z.number().int() }),
|
|
@@ -5013,13 +5086,43 @@ var CliVersionResponse = z.object({
|
|
|
5013
5086
|
upgradeCommand: z.string().min(1)
|
|
5014
5087
|
});
|
|
5015
5088
|
|
|
5089
|
+
// ../contracts/src/community.ts
|
|
5090
|
+
var PostKind = z.enum(["idea", "agents-md", "showcase", "question"]);
|
|
5091
|
+
var CreatePostRequest = z.object({
|
|
5092
|
+
kind: PostKind,
|
|
5093
|
+
title: z.string().trim().min(3).max(160),
|
|
5094
|
+
body: z.string().trim().min(1).max(3e4)
|
|
5095
|
+
});
|
|
5096
|
+
var CreateReplyRequest = z.object({ body: z.string().trim().min(1).max(1e4) });
|
|
5097
|
+
var CommunityPost = CreatePostRequest.extend({
|
|
5098
|
+
id: z.string(),
|
|
5099
|
+
handle: z.string(),
|
|
5100
|
+
createdAt: z.number(),
|
|
5101
|
+
replies: z.number()
|
|
5102
|
+
});
|
|
5103
|
+
var CommunityReply = z.object({
|
|
5104
|
+
id: z.string(),
|
|
5105
|
+
body: z.string(),
|
|
5106
|
+
handle: z.string(),
|
|
5107
|
+
createdAt: z.number()
|
|
5108
|
+
});
|
|
5109
|
+
|
|
5110
|
+
// ../contracts/src/comparison.ts
|
|
5111
|
+
var UsageMonth = z.string().regex(/^20\d{2}-(0[1-9]|1[0-2])$/);
|
|
5112
|
+
var SubscriptionSpendRequest = z.object({
|
|
5113
|
+
month: UsageMonth,
|
|
5114
|
+
source: z.enum(["claude-code", "codex", "cursor"]),
|
|
5115
|
+
label: z.string().trim().min(1).max(80),
|
|
5116
|
+
paidUsdCents: z.number().int().min(0).max(1e8)
|
|
5117
|
+
});
|
|
5118
|
+
|
|
5016
5119
|
// src/lib/api.ts
|
|
5017
5120
|
var DEFAULT_API_URL = "https://api.tokenrats.com";
|
|
5018
5121
|
function isTransient(status) {
|
|
5019
5122
|
return status >= 500 || status === 408 || status === 429;
|
|
5020
5123
|
}
|
|
5021
5124
|
function sleep(ms) {
|
|
5022
|
-
return new Promise((
|
|
5125
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
5023
5126
|
}
|
|
5024
5127
|
var ApiError2 = class extends Error {
|
|
5025
5128
|
constructor(status, body) {
|
|
@@ -5061,7 +5164,7 @@ var ApiClient = class {
|
|
|
5061
5164
|
let lastErr;
|
|
5062
5165
|
while (attempt <= maxRetries) {
|
|
5063
5166
|
try {
|
|
5064
|
-
const res = await fetch(url, init);
|
|
5167
|
+
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(3e4) });
|
|
5065
5168
|
if (res.ok || !isTransient(res.status)) return res;
|
|
5066
5169
|
lastErr = new ApiError2(res.status, await res.text());
|
|
5067
5170
|
} catch (err) {
|
|
@@ -5081,8 +5184,8 @@ var ApiClient = class {
|
|
|
5081
5184
|
}
|
|
5082
5185
|
return new ApiError2(res.status, body);
|
|
5083
5186
|
}
|
|
5084
|
-
async post(
|
|
5085
|
-
const url = `${this.apiUrl}${
|
|
5187
|
+
async post(path4, body) {
|
|
5188
|
+
const url = `${this.apiUrl}${path4}`;
|
|
5086
5189
|
const res = await this.fetchWithRetry(url, {
|
|
5087
5190
|
method: "POST",
|
|
5088
5191
|
headers: this.headers(),
|
|
@@ -5091,8 +5194,8 @@ var ApiClient = class {
|
|
|
5091
5194
|
if (!res.ok) throw await this.failedResponseToError(res);
|
|
5092
5195
|
return res.json();
|
|
5093
5196
|
}
|
|
5094
|
-
async get(
|
|
5095
|
-
const url = `${this.apiUrl}${
|
|
5197
|
+
async get(path4) {
|
|
5198
|
+
const url = `${this.apiUrl}${path4}`;
|
|
5096
5199
|
const res = await this.fetchWithRetry(url, {
|
|
5097
5200
|
method: "GET",
|
|
5098
5201
|
headers: this.headers()
|
|
@@ -5128,6 +5231,18 @@ var ApiClient = class {
|
|
|
5128
5231
|
async uploadSessions(sessions) {
|
|
5129
5232
|
return this.post(ENDPOINTS.sessions, { sessions });
|
|
5130
5233
|
}
|
|
5234
|
+
/** GET /v1/u/:handle */
|
|
5235
|
+
async getProfile(handle) {
|
|
5236
|
+
return this.get(ENDPOINTS.profile(handle));
|
|
5237
|
+
}
|
|
5238
|
+
/** GET /v1/u/:handle/heatmap?range=30d — used to derive the current streak. */
|
|
5239
|
+
async getHeatmap(handle) {
|
|
5240
|
+
return this.get(ENDPOINTS.profileHeatmap(handle));
|
|
5241
|
+
}
|
|
5242
|
+
/** GET /v1/trending?range — global public leaderboard, used to derive rank. */
|
|
5243
|
+
async getTrending(range = "30d") {
|
|
5244
|
+
return this.get(`${ENDPOINTS.trending}?range=${range}`);
|
|
5245
|
+
}
|
|
5131
5246
|
/** GET /v1/me/devices */
|
|
5132
5247
|
async getDevices() {
|
|
5133
5248
|
return this.get(ENDPOINTS.meDevices);
|
|
@@ -5138,8 +5253,56 @@ var ApiClient = class {
|
|
|
5138
5253
|
}
|
|
5139
5254
|
};
|
|
5140
5255
|
|
|
5256
|
+
// package.json
|
|
5257
|
+
var package_default = {
|
|
5258
|
+
name: "token-rats",
|
|
5259
|
+
version: "0.3.1",
|
|
5260
|
+
description: "Track Claude Code, Codex, and Cursor usage and compare AI subscriptions.",
|
|
5261
|
+
license: "MIT",
|
|
5262
|
+
type: "module",
|
|
5263
|
+
bin: {
|
|
5264
|
+
"token-rats": "./dist/index.js"
|
|
5265
|
+
},
|
|
5266
|
+
files: ["dist", "README.md", "LICENSE"],
|
|
5267
|
+
repository: {
|
|
5268
|
+
type: "git",
|
|
5269
|
+
url: "git+https://github.com/hsalberti/token-rats.git",
|
|
5270
|
+
directory: "packages/cli"
|
|
5271
|
+
},
|
|
5272
|
+
homepage: "https://tokenrats.com",
|
|
5273
|
+
keywords: ["claude", "claude-code", "cursor", "tokens", "leaderboard", "ai-usage"],
|
|
5274
|
+
publishConfig: {
|
|
5275
|
+
access: "public"
|
|
5276
|
+
},
|
|
5277
|
+
scripts: {
|
|
5278
|
+
build: "tsc --noEmit -p tsconfig.json && node build.mjs",
|
|
5279
|
+
typecheck: "tsc --noEmit",
|
|
5280
|
+
dev: "tsx src/index.ts",
|
|
5281
|
+
test: "vitest run",
|
|
5282
|
+
prepublishOnly: "node build.mjs"
|
|
5283
|
+
},
|
|
5284
|
+
optionalDependencies: {
|
|
5285
|
+
clipboardy: "^4.0.0",
|
|
5286
|
+
open: "^10.1.0"
|
|
5287
|
+
},
|
|
5288
|
+
devDependencies: {
|
|
5289
|
+
"@token-rats/contracts": "workspace:*",
|
|
5290
|
+
"@token-rats/parsers": "workspace:*",
|
|
5291
|
+
"@types/better-sqlite3": "^7.6.12",
|
|
5292
|
+
"@types/node": "22.10.2",
|
|
5293
|
+
"@types/sql.js": "^1.4.11",
|
|
5294
|
+
esbuild: "^0.24.2",
|
|
5295
|
+
tsx: "4.19.2",
|
|
5296
|
+
typescript: "5.7.2",
|
|
5297
|
+
vitest: "2.1.8"
|
|
5298
|
+
},
|
|
5299
|
+
dependencies: {
|
|
5300
|
+
"sql.js": "^1.14.1"
|
|
5301
|
+
}
|
|
5302
|
+
};
|
|
5303
|
+
|
|
5141
5304
|
// src/lib/cli-version.ts
|
|
5142
|
-
var CLI_VERSION =
|
|
5305
|
+
var CLI_VERSION = package_default.version;
|
|
5143
5306
|
|
|
5144
5307
|
// src/commands/login.ts
|
|
5145
5308
|
async function openBrowser(url) {
|
|
@@ -5235,7 +5398,7 @@ async function loginCommand(opts) {
|
|
|
5235
5398
|
return;
|
|
5236
5399
|
}
|
|
5237
5400
|
info("Installing background watcher so usage uploads automatically\u2026");
|
|
5238
|
-
await installDaemonCommand();
|
|
5401
|
+
await installDaemonCommand(opts.apiUrl);
|
|
5239
5402
|
}
|
|
5240
5403
|
|
|
5241
5404
|
// src/commands/logout.ts
|
|
@@ -5248,8 +5411,10 @@ function logoutCommand() {
|
|
|
5248
5411
|
info("Logged out. Your local token has been removed.");
|
|
5249
5412
|
}
|
|
5250
5413
|
|
|
5251
|
-
// src/
|
|
5414
|
+
// src/lib/collect.ts
|
|
5415
|
+
import { createHash } from "node:crypto";
|
|
5252
5416
|
import * as fs4 from "node:fs";
|
|
5417
|
+
import { createInterface } from "node:readline";
|
|
5253
5418
|
|
|
5254
5419
|
// ../parsers/src/hash.ts
|
|
5255
5420
|
var FNV_PRIME = 16777619;
|
|
@@ -5273,29 +5438,24 @@ function computeDedupeKey(source, model, startedAt, inTokens, outTokens) {
|
|
|
5273
5438
|
}
|
|
5274
5439
|
|
|
5275
5440
|
// ../parsers/src/claude-code.ts
|
|
5276
|
-
function
|
|
5277
|
-
let text;
|
|
5278
|
-
if (typeof input === "string") {
|
|
5279
|
-
text = input;
|
|
5280
|
-
} else {
|
|
5281
|
-
text = new TextDecoder().decode(input instanceof ArrayBuffer ? new Uint8Array(input) : input);
|
|
5282
|
-
}
|
|
5441
|
+
function createClaudeCodeParser() {
|
|
5283
5442
|
const sessions = /* @__PURE__ */ new Map();
|
|
5284
|
-
|
|
5443
|
+
const messages = /* @__PURE__ */ new Map();
|
|
5444
|
+
function push(rawLine) {
|
|
5285
5445
|
const line = rawLine.trim();
|
|
5286
|
-
if (line.length === 0)
|
|
5446
|
+
if (line.length === 0) return;
|
|
5287
5447
|
let event;
|
|
5288
5448
|
try {
|
|
5289
5449
|
event = JSON.parse(line);
|
|
5290
5450
|
} catch {
|
|
5291
|
-
|
|
5451
|
+
return;
|
|
5292
5452
|
}
|
|
5293
|
-
if (typeof event !== "object" || event === null)
|
|
5453
|
+
if (typeof event !== "object" || event === null) return;
|
|
5294
5454
|
const ev = event;
|
|
5295
5455
|
const sidCamel = ev.sessionId;
|
|
5296
5456
|
const sidSnake = ev.session_id;
|
|
5297
5457
|
const sessionId = typeof sidCamel === "string" ? sidCamel : typeof sidSnake === "string" ? sidSnake : null;
|
|
5298
|
-
if (!sessionId)
|
|
5458
|
+
if (!sessionId) return;
|
|
5299
5459
|
const rawTs = ev.timestamp;
|
|
5300
5460
|
let timestamp = 0;
|
|
5301
5461
|
if (typeof rawTs === "number" && Number.isFinite(rawTs)) {
|
|
@@ -5314,7 +5474,8 @@ function parseClaudeCode(input) {
|
|
|
5314
5474
|
outTokens: 0,
|
|
5315
5475
|
cacheReadTokens: 0,
|
|
5316
5476
|
cacheWriteTokens: 0,
|
|
5317
|
-
model: ""
|
|
5477
|
+
model: "",
|
|
5478
|
+
modelAt: 0
|
|
5318
5479
|
};
|
|
5319
5480
|
sessions.set(sessionId, acc);
|
|
5320
5481
|
}
|
|
@@ -5322,54 +5483,73 @@ function parseClaudeCode(input) {
|
|
|
5322
5483
|
if (acc.startedAt === 0 || timestamp < acc.startedAt) acc.startedAt = timestamp;
|
|
5323
5484
|
if (timestamp > acc.endedAt) acc.endedAt = timestamp;
|
|
5324
5485
|
}
|
|
5325
|
-
if (ev.type !== "assistant")
|
|
5486
|
+
if (ev.type !== "assistant") return;
|
|
5326
5487
|
const message = ev.message;
|
|
5327
|
-
if (typeof message !== "object" || message === null)
|
|
5488
|
+
if (typeof message !== "object" || message === null) return;
|
|
5328
5489
|
const msg = message;
|
|
5329
|
-
if (typeof msg.model === "string" && msg.model.length > 0) {
|
|
5490
|
+
if (typeof msg.model === "string" && msg.model.length > 0 && timestamp >= acc.modelAt) {
|
|
5330
5491
|
acc.model = msg.model;
|
|
5492
|
+
acc.modelAt = timestamp;
|
|
5331
5493
|
}
|
|
5332
5494
|
const usage = msg.usage;
|
|
5333
5495
|
if (typeof usage === "object" && usage !== null) {
|
|
5334
5496
|
const u = usage;
|
|
5335
|
-
|
|
5336
|
-
|
|
5337
|
-
|
|
5338
|
-
|
|
5497
|
+
const counts = {
|
|
5498
|
+
input: toNonNegInt(u.input_tokens),
|
|
5499
|
+
output: toNonNegInt(u.output_tokens),
|
|
5500
|
+
read: toNonNegInt(u.cache_read_input_tokens),
|
|
5501
|
+
write: toNonNegInt(u.cache_creation_input_tokens)
|
|
5502
|
+
};
|
|
5503
|
+
const key = typeof msg.id === "string" ? `${sessionId}:${msg.id}` : null;
|
|
5504
|
+
const old = key ? messages.get(key) : void 0;
|
|
5505
|
+
const next = {
|
|
5506
|
+
input: Math.max(old?.input ?? 0, counts.input),
|
|
5507
|
+
output: Math.max(old?.output ?? 0, counts.output),
|
|
5508
|
+
read: Math.max(old?.read ?? 0, counts.read),
|
|
5509
|
+
write: Math.max(old?.write ?? 0, counts.write)
|
|
5510
|
+
};
|
|
5511
|
+
acc.inTokens += next.input - (old?.input ?? 0);
|
|
5512
|
+
acc.outTokens += next.output - (old?.output ?? 0);
|
|
5513
|
+
acc.cacheReadTokens += next.read - (old?.read ?? 0);
|
|
5514
|
+
acc.cacheWriteTokens += next.write - (old?.write ?? 0);
|
|
5515
|
+
if (key) messages.set(key, next);
|
|
5516
|
+
}
|
|
5517
|
+
}
|
|
5518
|
+
function finish() {
|
|
5519
|
+
const results = [];
|
|
5520
|
+
for (const acc of sessions.values()) {
|
|
5521
|
+
const startedAt = acc.startedAt > 0 ? acc.startedAt : acc.endedAt;
|
|
5522
|
+
const endedAt = acc.endedAt > 0 ? acc.endedAt : acc.startedAt;
|
|
5523
|
+
if (startedAt <= 0 || endedAt <= 0) continue;
|
|
5524
|
+
const model = acc.model.length > 0 ? acc.model : "unknown";
|
|
5525
|
+
const costUsdCents = 0;
|
|
5526
|
+
const dedupeKey = computeDedupeKey(
|
|
5527
|
+
"claude-code",
|
|
5528
|
+
model,
|
|
5529
|
+
startedAt,
|
|
5530
|
+
acc.inTokens,
|
|
5531
|
+
acc.outTokens
|
|
5532
|
+
);
|
|
5533
|
+
results.push({
|
|
5534
|
+
id: `claude-code:${acc.sessionId}`,
|
|
5535
|
+
source: "claude-code",
|
|
5536
|
+
provider: "anthropic",
|
|
5537
|
+
client: "claude-code",
|
|
5538
|
+
channel: "cli",
|
|
5539
|
+
model,
|
|
5540
|
+
inTokens: acc.inTokens,
|
|
5541
|
+
outTokens: acc.outTokens,
|
|
5542
|
+
cacheReadTokens: acc.cacheReadTokens,
|
|
5543
|
+
cacheWriteTokens: acc.cacheWriteTokens,
|
|
5544
|
+
costUsdCents,
|
|
5545
|
+
startedAt,
|
|
5546
|
+
endedAt,
|
|
5547
|
+
dedupeKey
|
|
5548
|
+
});
|
|
5339
5549
|
}
|
|
5550
|
+
return results;
|
|
5340
5551
|
}
|
|
5341
|
-
|
|
5342
|
-
for (const acc of sessions.values()) {
|
|
5343
|
-
const startedAt = acc.startedAt > 0 ? acc.startedAt : acc.endedAt;
|
|
5344
|
-
const endedAt = acc.endedAt > 0 ? acc.endedAt : acc.startedAt;
|
|
5345
|
-
if (startedAt <= 0 || endedAt <= 0) continue;
|
|
5346
|
-
const model = acc.model.length > 0 ? acc.model : "unknown";
|
|
5347
|
-
const costUsdCents = 0;
|
|
5348
|
-
const dedupeKey = computeDedupeKey(
|
|
5349
|
-
"claude-code",
|
|
5350
|
-
model,
|
|
5351
|
-
startedAt,
|
|
5352
|
-
acc.inTokens,
|
|
5353
|
-
acc.outTokens
|
|
5354
|
-
);
|
|
5355
|
-
results.push({
|
|
5356
|
-
id: `claude-code:${acc.sessionId}`,
|
|
5357
|
-
source: "claude-code",
|
|
5358
|
-
provider: "anthropic",
|
|
5359
|
-
client: "claude-code",
|
|
5360
|
-
channel: "cli",
|
|
5361
|
-
model,
|
|
5362
|
-
inTokens: acc.inTokens,
|
|
5363
|
-
outTokens: acc.outTokens,
|
|
5364
|
-
cacheReadTokens: acc.cacheReadTokens,
|
|
5365
|
-
cacheWriteTokens: acc.cacheWriteTokens,
|
|
5366
|
-
costUsdCents,
|
|
5367
|
-
startedAt,
|
|
5368
|
-
endedAt,
|
|
5369
|
-
dedupeKey
|
|
5370
|
-
});
|
|
5371
|
-
}
|
|
5372
|
-
return results;
|
|
5552
|
+
return { push, finish };
|
|
5373
5553
|
}
|
|
5374
5554
|
function toNonNegInt(v) {
|
|
5375
5555
|
if (typeof v !== "number" || !Number.isFinite(v)) return 0;
|
|
@@ -5377,96 +5557,107 @@ function toNonNegInt(v) {
|
|
|
5377
5557
|
}
|
|
5378
5558
|
|
|
5379
5559
|
// ../parsers/src/codex.ts
|
|
5380
|
-
function
|
|
5381
|
-
let text;
|
|
5382
|
-
if (typeof input === "string") {
|
|
5383
|
-
text = input;
|
|
5384
|
-
} else {
|
|
5385
|
-
text = new TextDecoder().decode(input instanceof ArrayBuffer ? new Uint8Array(input) : input);
|
|
5386
|
-
}
|
|
5560
|
+
function createCodexParser() {
|
|
5387
5561
|
const sessions = /* @__PURE__ */ new Map();
|
|
5388
5562
|
let currentSessionId = null;
|
|
5389
|
-
|
|
5563
|
+
function push(rawLine) {
|
|
5390
5564
|
const line = rawLine.trim();
|
|
5391
|
-
if (line.length === 0)
|
|
5565
|
+
if (line.length === 0) return;
|
|
5392
5566
|
let event;
|
|
5393
5567
|
try {
|
|
5394
5568
|
event = JSON.parse(line);
|
|
5395
5569
|
} catch {
|
|
5396
|
-
|
|
5570
|
+
return;
|
|
5397
5571
|
}
|
|
5398
|
-
if (typeof event !== "object" || event === null)
|
|
5572
|
+
if (typeof event !== "object" || event === null) return;
|
|
5399
5573
|
const ev = event;
|
|
5400
5574
|
const ts = parseTimestamp(ev.timestamp);
|
|
5401
5575
|
const type = typeof ev.type === "string" ? ev.type : null;
|
|
5402
5576
|
const payload = typeof ev.payload === "object" && ev.payload !== null ? ev.payload : null;
|
|
5403
5577
|
if (type === "session_meta" && payload) {
|
|
5404
5578
|
const id = typeof payload.id === "string" ? payload.id : null;
|
|
5405
|
-
if (!id)
|
|
5579
|
+
if (!id) {
|
|
5580
|
+
currentSessionId = null;
|
|
5581
|
+
return;
|
|
5582
|
+
}
|
|
5406
5583
|
currentSessionId = id;
|
|
5407
5584
|
const metaTs = parseTimestamp(payload.timestamp) || ts;
|
|
5408
5585
|
const acc2 = upsert(sessions, id);
|
|
5409
5586
|
const metaSource = typeof payload.source === "string" ? payload.source : null;
|
|
5410
|
-
acc2.channel = metaSource === "
|
|
5587
|
+
acc2.channel = metaSource === "api" ? "api" : metaSource ? "cli" : "unknown";
|
|
5411
5588
|
acc2.client = acc2.channel === "cli" ? "codex-cli" : "codex";
|
|
5412
5589
|
if (metaTs > 0 && (acc2.startedAt === 0 || metaTs < acc2.startedAt)) {
|
|
5413
5590
|
acc2.startedAt = metaTs;
|
|
5414
5591
|
}
|
|
5415
5592
|
if (metaTs > acc2.endedAt) acc2.endedAt = metaTs;
|
|
5416
|
-
|
|
5593
|
+
return;
|
|
5417
5594
|
}
|
|
5418
|
-
if (!currentSessionId)
|
|
5595
|
+
if (!currentSessionId) return;
|
|
5419
5596
|
const acc = upsert(sessions, currentSessionId);
|
|
5420
5597
|
if (ts > 0) {
|
|
5421
5598
|
if (acc.startedAt === 0) acc.startedAt = ts;
|
|
5422
5599
|
if (ts > acc.endedAt) acc.endedAt = ts;
|
|
5423
5600
|
}
|
|
5424
5601
|
if (type === "turn_context" && payload && typeof payload.model === "string") {
|
|
5425
|
-
acc.
|
|
5426
|
-
|
|
5602
|
+
if (ts >= acc.modelAt) {
|
|
5603
|
+
acc.model = payload.model;
|
|
5604
|
+
acc.modelAt = ts;
|
|
5605
|
+
}
|
|
5606
|
+
return;
|
|
5427
5607
|
}
|
|
5428
5608
|
if (type === "event_msg" && payload && payload.type === "token_count") {
|
|
5429
5609
|
const info2 = payload.info;
|
|
5430
|
-
if (typeof info2 !== "object" || info2 === null)
|
|
5610
|
+
if (typeof info2 !== "object" || info2 === null) return;
|
|
5431
5611
|
const total = info2.total_token_usage;
|
|
5432
|
-
if (typeof total !== "object" || total === null)
|
|
5612
|
+
if (typeof total !== "object" || total === null) return;
|
|
5433
5613
|
const t = total;
|
|
5434
5614
|
const inputTotal = toNonNegInt2(t.input_tokens);
|
|
5435
5615
|
const cachedInput = toNonNegInt2(t.cached_input_tokens);
|
|
5436
5616
|
const output = toNonNegInt2(t.output_tokens);
|
|
5437
5617
|
const reasoning = toNonNegInt2(t.reasoning_output_tokens);
|
|
5618
|
+
if (ts < acc.usageAt) return;
|
|
5619
|
+
acc.usageAt = ts;
|
|
5438
5620
|
acc.inTokens = Math.max(0, inputTotal - cachedInput);
|
|
5439
|
-
acc.outTokens = output
|
|
5621
|
+
acc.outTokens = output;
|
|
5440
5622
|
acc.cacheReadTokens = cachedInput;
|
|
5441
5623
|
acc.reasoningTokens = reasoning;
|
|
5442
5624
|
}
|
|
5443
5625
|
}
|
|
5444
|
-
|
|
5445
|
-
|
|
5446
|
-
const
|
|
5447
|
-
|
|
5448
|
-
|
|
5449
|
-
|
|
5450
|
-
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
|
|
5454
|
-
|
|
5455
|
-
|
|
5456
|
-
|
|
5457
|
-
|
|
5458
|
-
|
|
5459
|
-
|
|
5460
|
-
|
|
5461
|
-
|
|
5462
|
-
|
|
5463
|
-
|
|
5464
|
-
|
|
5465
|
-
|
|
5466
|
-
|
|
5467
|
-
|
|
5626
|
+
function finish() {
|
|
5627
|
+
const results = [];
|
|
5628
|
+
for (const acc of sessions.values()) {
|
|
5629
|
+
const startedAt = acc.startedAt > 0 ? acc.startedAt : acc.endedAt;
|
|
5630
|
+
const endedAt = acc.endedAt > 0 ? acc.endedAt : acc.startedAt;
|
|
5631
|
+
if (startedAt <= 0 || endedAt <= 0) continue;
|
|
5632
|
+
const model = acc.model.length > 0 ? acc.model : "unknown";
|
|
5633
|
+
const costUsdCents = 0;
|
|
5634
|
+
const dedupeKey = computeDedupeKey("codex", model, startedAt, acc.inTokens, acc.outTokens);
|
|
5635
|
+
results.push({
|
|
5636
|
+
id: `codex:${acc.sessionId}`,
|
|
5637
|
+
source: "codex",
|
|
5638
|
+
provider: "openai",
|
|
5639
|
+
client: acc.client || "codex-cli",
|
|
5640
|
+
channel: acc.channel,
|
|
5641
|
+
model,
|
|
5642
|
+
inTokens: acc.inTokens,
|
|
5643
|
+
outTokens: acc.outTokens,
|
|
5644
|
+
cacheReadTokens: acc.cacheReadTokens,
|
|
5645
|
+
reasoningTokens: acc.reasoningTokens,
|
|
5646
|
+
costUsdCents,
|
|
5647
|
+
startedAt,
|
|
5648
|
+
endedAt,
|
|
5649
|
+
dedupeKey
|
|
5650
|
+
});
|
|
5651
|
+
}
|
|
5652
|
+
return results;
|
|
5468
5653
|
}
|
|
5469
|
-
return
|
|
5654
|
+
return {
|
|
5655
|
+
push,
|
|
5656
|
+
finish,
|
|
5657
|
+
startFile() {
|
|
5658
|
+
currentSessionId = null;
|
|
5659
|
+
}
|
|
5660
|
+
};
|
|
5470
5661
|
}
|
|
5471
5662
|
function upsert(map, sessionId) {
|
|
5472
5663
|
let acc = map.get(sessionId);
|
|
@@ -5480,6 +5671,8 @@ function upsert(map, sessionId) {
|
|
|
5480
5671
|
cacheReadTokens: 0,
|
|
5481
5672
|
reasoningTokens: 0,
|
|
5482
5673
|
model: "",
|
|
5674
|
+
usageAt: 0,
|
|
5675
|
+
modelAt: 0,
|
|
5483
5676
|
client: "codex-cli",
|
|
5484
5677
|
channel: "unknown"
|
|
5485
5678
|
};
|
|
@@ -5680,7 +5873,7 @@ async function extractCursorGenerations() {
|
|
|
5680
5873
|
dbCount: 0
|
|
5681
5874
|
};
|
|
5682
5875
|
}
|
|
5683
|
-
const
|
|
5876
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5684
5877
|
const rows = [];
|
|
5685
5878
|
let openFailures = 0;
|
|
5686
5879
|
for (const p of dbPaths) {
|
|
@@ -5692,8 +5885,8 @@ async function extractCursorGenerations() {
|
|
|
5692
5885
|
continue;
|
|
5693
5886
|
}
|
|
5694
5887
|
for (const r of perDb) {
|
|
5695
|
-
if (
|
|
5696
|
-
|
|
5888
|
+
if (seen.has(r.id)) continue;
|
|
5889
|
+
seen.add(r.id);
|
|
5697
5890
|
rows.push(r);
|
|
5698
5891
|
}
|
|
5699
5892
|
}
|
|
@@ -5727,6 +5920,7 @@ function findJsonlFiles(dir) {
|
|
|
5727
5920
|
}
|
|
5728
5921
|
function claudeCodeProjectsDir() {
|
|
5729
5922
|
const home = os3.homedir();
|
|
5923
|
+
if (process.env.CLAUDE_CONFIG_DIR) return path3.join(process.env.CLAUDE_CONFIG_DIR, "projects");
|
|
5730
5924
|
if (process.platform === "win32") {
|
|
5731
5925
|
const profile = process.env.USERPROFILE ?? home;
|
|
5732
5926
|
return path3.join(profile, ".claude", "projects");
|
|
@@ -5740,13 +5934,19 @@ function discoverClaudeCodeFiles() {
|
|
|
5740
5934
|
function codexSessionsDirs() {
|
|
5741
5935
|
const home = os3.homedir();
|
|
5742
5936
|
const candidates = [];
|
|
5937
|
+
if (process.env.CODEX_HOME) {
|
|
5938
|
+
candidates.push(path3.join(process.env.CODEX_HOME, "sessions"));
|
|
5939
|
+
candidates.push(path3.join(process.env.CODEX_HOME, "archived_sessions"));
|
|
5940
|
+
}
|
|
5743
5941
|
if (process.platform === "win32") {
|
|
5744
5942
|
const profile = process.env.USERPROFILE ?? home;
|
|
5745
5943
|
candidates.push(path3.join(profile, ".codex", "sessions"));
|
|
5944
|
+
candidates.push(path3.join(profile, ".codex", "archived_sessions"));
|
|
5746
5945
|
const appData = process.env.APPDATA ?? path3.join(profile, "AppData", "Roaming");
|
|
5747
5946
|
candidates.push(path3.join(appData, "Codex", "sessions"));
|
|
5748
5947
|
} else {
|
|
5749
5948
|
candidates.push(path3.join(home, ".codex", "sessions"));
|
|
5949
|
+
candidates.push(path3.join(home, ".codex", "archived_sessions"));
|
|
5750
5950
|
const snapRoot = path3.join(home, "snap", "codex");
|
|
5751
5951
|
if (fs3.existsSync(snapRoot)) {
|
|
5752
5952
|
for (const entry of fs3.readdirSync(snapRoot, { withFileTypes: true })) {
|
|
@@ -5756,13 +5956,13 @@ function codexSessionsDirs() {
|
|
|
5756
5956
|
}
|
|
5757
5957
|
}
|
|
5758
5958
|
}
|
|
5759
|
-
const
|
|
5959
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5760
5960
|
const dirs = [];
|
|
5761
5961
|
for (const c2 of candidates) {
|
|
5762
5962
|
try {
|
|
5763
5963
|
const real = fs3.existsSync(c2) ? fs3.realpathSync(c2) : null;
|
|
5764
|
-
if (real && !
|
|
5765
|
-
|
|
5964
|
+
if (real && !seen.has(real)) {
|
|
5965
|
+
seen.add(real);
|
|
5766
5966
|
dirs.push(c2);
|
|
5767
5967
|
}
|
|
5768
5968
|
} catch {
|
|
@@ -5778,8 +5978,137 @@ function discoverCodexFiles() {
|
|
|
5778
5978
|
return out;
|
|
5779
5979
|
}
|
|
5780
5980
|
|
|
5981
|
+
// src/lib/collect.ts
|
|
5982
|
+
async function parseSessionFiles(files, parser) {
|
|
5983
|
+
for (const file of files) {
|
|
5984
|
+
parser.startFile?.();
|
|
5985
|
+
const input = fs4.createReadStream(file, { encoding: "utf8" });
|
|
5986
|
+
const lines = createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY });
|
|
5987
|
+
try {
|
|
5988
|
+
for await (const line of lines) parser.push(line);
|
|
5989
|
+
} finally {
|
|
5990
|
+
lines.close();
|
|
5991
|
+
input.destroy();
|
|
5992
|
+
}
|
|
5993
|
+
}
|
|
5994
|
+
return parser.finish();
|
|
5995
|
+
}
|
|
5996
|
+
async function collectSessions() {
|
|
5997
|
+
const claude = await parseSessionFiles(discoverClaudeCodeFiles(), createClaudeCodeParser());
|
|
5998
|
+
const codex = await parseSessionFiles(discoverCodexFiles(), createCodexParser());
|
|
5999
|
+
const { rows } = await extractCursorGenerations();
|
|
6000
|
+
const cursor = parseCursor(JSON.stringify(rows));
|
|
6001
|
+
return [...claude, ...codex, ...cursor].map((record) => ({
|
|
6002
|
+
...record,
|
|
6003
|
+
accountingVersion: 2,
|
|
6004
|
+
dedupeKey: createHash("sha256").update(
|
|
6005
|
+
JSON.stringify([
|
|
6006
|
+
record.id,
|
|
6007
|
+
record.source,
|
|
6008
|
+
record.model,
|
|
6009
|
+
record.startedAt,
|
|
6010
|
+
record.inTokens,
|
|
6011
|
+
record.outTokens,
|
|
6012
|
+
record.cacheReadTokens ?? 0,
|
|
6013
|
+
record.cacheWriteTokens ?? 0,
|
|
6014
|
+
record.reasoningTokens ?? 0
|
|
6015
|
+
])
|
|
6016
|
+
).digest("hex")
|
|
6017
|
+
}));
|
|
6018
|
+
}
|
|
6019
|
+
var SyncQueue = class {
|
|
6020
|
+
acknowledged = /* @__PURE__ */ new Map();
|
|
6021
|
+
async flush(records, upload) {
|
|
6022
|
+
const fresh = records.filter((r) => this.acknowledged.get(r.id) !== JSON.stringify(r));
|
|
6023
|
+
for (let i = 0; i < fresh.length; i += 90) {
|
|
6024
|
+
const batch = fresh.slice(i, i + 90);
|
|
6025
|
+
await upload(batch);
|
|
6026
|
+
for (const record of batch) this.acknowledged.set(record.id, JSON.stringify(record));
|
|
6027
|
+
}
|
|
6028
|
+
return fresh.length;
|
|
6029
|
+
}
|
|
6030
|
+
};
|
|
6031
|
+
function createCollector() {
|
|
6032
|
+
let signature = "";
|
|
6033
|
+
let records = [];
|
|
6034
|
+
return async () => {
|
|
6035
|
+
const files = [
|
|
6036
|
+
...discoverClaudeCodeFiles(),
|
|
6037
|
+
...discoverCodexFiles(),
|
|
6038
|
+
...discoverWorkspaceDbs().flatMap((file) => [file, `${file}-wal`])
|
|
6039
|
+
].sort();
|
|
6040
|
+
const next = JSON.stringify(
|
|
6041
|
+
files.map((file) => {
|
|
6042
|
+
try {
|
|
6043
|
+
const stat = fs4.statSync(file);
|
|
6044
|
+
return [file, stat.size, stat.mtimeMs];
|
|
6045
|
+
} catch {
|
|
6046
|
+
return [file, null];
|
|
6047
|
+
}
|
|
6048
|
+
})
|
|
6049
|
+
);
|
|
6050
|
+
if (next !== signature) {
|
|
6051
|
+
records = await collectSessions();
|
|
6052
|
+
signature = next;
|
|
6053
|
+
}
|
|
6054
|
+
return records;
|
|
6055
|
+
};
|
|
6056
|
+
}
|
|
6057
|
+
|
|
5781
6058
|
// src/commands/sync.ts
|
|
5782
|
-
var BATCH_SIZE =
|
|
6059
|
+
var BATCH_SIZE = 90;
|
|
6060
|
+
var WEB_ORIGIN = "https://tokenrats.com";
|
|
6061
|
+
function currentStreakFromDays(days, todayUtc) {
|
|
6062
|
+
const active = days.slice().sort();
|
|
6063
|
+
if (active.length === 0) return 0;
|
|
6064
|
+
const last = active[active.length - 1];
|
|
6065
|
+
const yesterday = (() => {
|
|
6066
|
+
const d = /* @__PURE__ */ new Date(`${todayUtc}T00:00:00Z`);
|
|
6067
|
+
d.setUTCDate(d.getUTCDate() - 1);
|
|
6068
|
+
return d.toISOString().slice(0, 10);
|
|
6069
|
+
})();
|
|
6070
|
+
if (last !== todayUtc && last !== yesterday) return 0;
|
|
6071
|
+
let streak = 1;
|
|
6072
|
+
for (let i = active.length - 1; i >= 1; i--) {
|
|
6073
|
+
const prev = (/* @__PURE__ */ new Date(`${active[i - 1]}T00:00:00Z`)).getTime();
|
|
6074
|
+
const curr = (/* @__PURE__ */ new Date(`${active[i]}T00:00:00Z`)).getTime();
|
|
6075
|
+
if (Math.round((curr - prev) / 864e5) === 1) streak++;
|
|
6076
|
+
else break;
|
|
6077
|
+
}
|
|
6078
|
+
return streak;
|
|
6079
|
+
}
|
|
6080
|
+
async function printSyncHero(client) {
|
|
6081
|
+
let handle;
|
|
6082
|
+
try {
|
|
6083
|
+
const me = await client.getMe();
|
|
6084
|
+
handle = me.user.handle;
|
|
6085
|
+
} catch {
|
|
6086
|
+
return;
|
|
6087
|
+
}
|
|
6088
|
+
const todayUtc = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
6089
|
+
const [trending, heatmap] = await Promise.all([
|
|
6090
|
+
client.getTrending("30d").catch(() => null),
|
|
6091
|
+
client.getHeatmap(handle).catch(() => null)
|
|
6092
|
+
]);
|
|
6093
|
+
const rank = trending?.rows.find((r) => r.handle === handle)?.rank ?? null;
|
|
6094
|
+
const streak = heatmap === null ? 0 : currentStreakFromDays(
|
|
6095
|
+
heatmap.heatmap.days.filter((d) => d.sessions > 0).map((d) => d.day),
|
|
6096
|
+
todayUtc
|
|
6097
|
+
);
|
|
6098
|
+
const profileUrl = `${WEB_ORIGIN}/u/${handle}`;
|
|
6099
|
+
console.log("");
|
|
6100
|
+
bold("\u{1F400} Your Token Rats standing");
|
|
6101
|
+
if (rank !== null) info(`Global rank: #${rank}`);
|
|
6102
|
+
if (streak > 0) info(`Current streak: ${streak} day${streak === 1 ? "" : "s"} \u{1F525}`);
|
|
6103
|
+
info(`Profile: ${profileUrl}`);
|
|
6104
|
+
const rankPart = rank !== null ? `ranked #${rank} globally` : "on the board";
|
|
6105
|
+
const streakPart = streak > 0 ? ` on a ${streak}-day streak` : "";
|
|
6106
|
+
const tweet = `I'm ${rankPart}${streakPart} on @tokenrats \u2014 tracking my AI coding tokens. \u{1F400}`;
|
|
6107
|
+
const intent = `https://x.com/intent/tweet?text=${encodeURIComponent(tweet)}&url=${encodeURIComponent(profileUrl)}`;
|
|
6108
|
+
console.log("");
|
|
6109
|
+
dim("Brag about it \u2014 Post to X:");
|
|
6110
|
+
console.log(` ${process.stdout.isTTY ? `${c.cyan}${intent}${c.reset}` : intent}`);
|
|
6111
|
+
}
|
|
5783
6112
|
function chunk(arr, size) {
|
|
5784
6113
|
const chunks = [];
|
|
5785
6114
|
for (let i = 0; i < arr.length; i += size) {
|
|
@@ -5787,34 +6116,6 @@ function chunk(arr, size) {
|
|
|
5787
6116
|
}
|
|
5788
6117
|
return chunks;
|
|
5789
6118
|
}
|
|
5790
|
-
function mergeBySessionId(records) {
|
|
5791
|
-
const byId = /* @__PURE__ */ new Map();
|
|
5792
|
-
for (const r of records) {
|
|
5793
|
-
const existing = byId.get(r.id);
|
|
5794
|
-
if (!existing) {
|
|
5795
|
-
byId.set(r.id, { ...r });
|
|
5796
|
-
continue;
|
|
5797
|
-
}
|
|
5798
|
-
existing.inTokens += r.inTokens;
|
|
5799
|
-
existing.outTokens += r.outTokens;
|
|
5800
|
-
if (r.startedAt < existing.startedAt) existing.startedAt = r.startedAt;
|
|
5801
|
-
if (r.endedAt > existing.endedAt) {
|
|
5802
|
-
existing.endedAt = r.endedAt;
|
|
5803
|
-
existing.model = r.model;
|
|
5804
|
-
}
|
|
5805
|
-
}
|
|
5806
|
-
for (const rec of byId.values()) {
|
|
5807
|
-
rec.costUsdCents = 0;
|
|
5808
|
-
rec.dedupeKey = computeDedupeKey(
|
|
5809
|
-
rec.source,
|
|
5810
|
-
rec.model,
|
|
5811
|
-
rec.startedAt,
|
|
5812
|
-
rec.inTokens,
|
|
5813
|
-
rec.outTokens
|
|
5814
|
-
);
|
|
5815
|
-
}
|
|
5816
|
-
return Array.from(byId.values());
|
|
5817
|
-
}
|
|
5818
6119
|
async function syncCommand(opts) {
|
|
5819
6120
|
const token = loadToken();
|
|
5820
6121
|
if (!token && !opts.dryRun) {
|
|
@@ -5827,92 +6128,8 @@ async function syncCommand(opts) {
|
|
|
5827
6128
|
deviceId: ensureDeviceId(),
|
|
5828
6129
|
cliVersion: CLI_VERSION
|
|
5829
6130
|
});
|
|
5830
|
-
const
|
|
5831
|
-
|
|
5832
|
-
info(`Found ${claudeFiles.length} Claude Code file(s) in ~/.claude/projects/`);
|
|
5833
|
-
for (const f of claudeFiles) dim(` ${f}`);
|
|
5834
|
-
}
|
|
5835
|
-
const claudeSessions = [];
|
|
5836
|
-
for (const file of claudeFiles) {
|
|
5837
|
-
let text;
|
|
5838
|
-
try {
|
|
5839
|
-
text = fs4.readFileSync(file, "utf8");
|
|
5840
|
-
} catch {
|
|
5841
|
-
if (opts.verbose) warn(`Could not read ${file} \u2014 skipping`);
|
|
5842
|
-
continue;
|
|
5843
|
-
}
|
|
5844
|
-
try {
|
|
5845
|
-
const records = parseClaudeCode(text);
|
|
5846
|
-
claudeSessions.push(...records);
|
|
5847
|
-
if (opts.verbose) dim(` ${file}: ${records.length} session(s)`);
|
|
5848
|
-
} catch {
|
|
5849
|
-
if (opts.verbose) warn(`Failed to parse ${file} \u2014 skipping`);
|
|
5850
|
-
}
|
|
5851
|
-
}
|
|
5852
|
-
const codexFiles = discoverCodexFiles();
|
|
5853
|
-
if (opts.verbose) {
|
|
5854
|
-
info(`Found ${codexFiles.length} Codex rollout file(s)`);
|
|
5855
|
-
for (const f of codexFiles) dim(` ${f}`);
|
|
5856
|
-
}
|
|
5857
|
-
const codexSessions = [];
|
|
5858
|
-
for (const file of codexFiles) {
|
|
5859
|
-
let text;
|
|
5860
|
-
try {
|
|
5861
|
-
text = fs4.readFileSync(file, "utf8");
|
|
5862
|
-
} catch {
|
|
5863
|
-
if (opts.verbose) warn(`Could not read ${file} \u2014 skipping`);
|
|
5864
|
-
continue;
|
|
5865
|
-
}
|
|
5866
|
-
try {
|
|
5867
|
-
const records = parseCodex(text);
|
|
5868
|
-
codexSessions.push(...records);
|
|
5869
|
-
if (opts.verbose) dim(` ${file}: ${records.length} session(s)`);
|
|
5870
|
-
} catch {
|
|
5871
|
-
if (opts.verbose) warn(`Failed to parse ${file} \u2014 skipping`);
|
|
5872
|
-
}
|
|
5873
|
-
}
|
|
5874
|
-
const cursorSessions = [];
|
|
5875
|
-
const { rows, skipped, dbCount } = await extractCursorGenerations();
|
|
5876
|
-
if (skipped) {
|
|
5877
|
-
warn(skipped);
|
|
5878
|
-
} else if (dbCount === 0) {
|
|
5879
|
-
if (opts.verbose) info("No Cursor workspace storage found \u2014 skipping Cursor source");
|
|
5880
|
-
} else if (rows.length > 0) {
|
|
5881
|
-
if (opts.verbose) info(`Scanned ${dbCount} Cursor workspace DB(s)`);
|
|
5882
|
-
try {
|
|
5883
|
-
const records = parseCursor(JSON.stringify(rows));
|
|
5884
|
-
cursorSessions.push(...records);
|
|
5885
|
-
if (opts.verbose) {
|
|
5886
|
-
dim(
|
|
5887
|
-
` Cursor: ${rows.length} generation event(s) \u2192 ${records.length} session(s) (tokens estimated, see help)`
|
|
5888
|
-
);
|
|
5889
|
-
}
|
|
5890
|
-
} catch {
|
|
5891
|
-
if (opts.verbose) warn("Failed to parse Cursor rows \u2014 skipping");
|
|
5892
|
-
}
|
|
5893
|
-
} else if (opts.verbose) {
|
|
5894
|
-
dim(` Scanned ${dbCount} Cursor workspace DB(s): no AI generations found`);
|
|
5895
|
-
}
|
|
5896
|
-
const mergedClaude = mergeBySessionId(claudeSessions);
|
|
5897
|
-
if (opts.verbose && mergedClaude.length !== claudeSessions.length) {
|
|
5898
|
-
dim(
|
|
5899
|
-
` Merged ${claudeSessions.length} Claude Code records into ${mergedClaude.length} sessions (subagent files folded into parents)`
|
|
5900
|
-
);
|
|
5901
|
-
}
|
|
5902
|
-
const mergedCodex = mergeBySessionId(codexSessions);
|
|
5903
|
-
const seen2 = /* @__PURE__ */ new Set();
|
|
5904
|
-
const allSessions = [];
|
|
5905
|
-
for (const s of [...mergedClaude, ...mergedCodex, ...cursorSessions]) {
|
|
5906
|
-
if (!seen2.has(s.dedupeKey)) {
|
|
5907
|
-
seen2.add(s.dedupeKey);
|
|
5908
|
-
allSessions.push(s);
|
|
5909
|
-
}
|
|
5910
|
-
}
|
|
5911
|
-
const sources = [];
|
|
5912
|
-
if (claudeSessions.length > 0) sources.push("Claude Code");
|
|
5913
|
-
if (codexSessions.length > 0) sources.push("Codex");
|
|
5914
|
-
if (cursorSessions.length > 0) sources.push("Cursor");
|
|
5915
|
-
const sourceStr = sources.length > 0 ? sources.join(" + ") : "no sources";
|
|
6131
|
+
const allSessions = await collectSessions();
|
|
6132
|
+
const sourceStr = [...new Set(allSessions.map((record) => record.source))].join(" + ") || "no sources";
|
|
5916
6133
|
if (allSessions.length === 0) {
|
|
5917
6134
|
info(`No sessions found from ${sourceStr}.`);
|
|
5918
6135
|
return;
|
|
@@ -5966,137 +6183,25 @@ async function syncCommand(opts) {
|
|
|
5966
6183
|
success(
|
|
5967
6184
|
`Synced ${allSessions.length} sessions (${totalAccepted} new, ${totalDuplicates} already on server) from ${sourceStr}`
|
|
5968
6185
|
);
|
|
5969
|
-
}
|
|
5970
|
-
|
|
5971
|
-
// src/commands/watch.ts
|
|
5972
|
-
import * as fs5 from "node:fs";
|
|
5973
|
-
import * as os4 from "node:os";
|
|
5974
|
-
import * as path4 from "node:path";
|
|
5975
|
-
var HEARTBEAT_MS = 6e4;
|
|
5976
|
-
var seen = /* @__PURE__ */ new Set();
|
|
5977
|
-
function stateFilePath() {
|
|
5978
|
-
const xdgConfig = process.env.XDG_CONFIG_HOME ?? path4.join(os4.homedir(), ".config");
|
|
5979
|
-
return path4.join(xdgConfig, "token-rats", "watch-state.json");
|
|
5980
|
-
}
|
|
5981
|
-
function loadWatchState() {
|
|
5982
|
-
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
6186
|
try {
|
|
6003
|
-
|
|
6004
|
-
return { size: s.size, mtimeMs: s.mtimeMs };
|
|
6187
|
+
await printSyncHero(client);
|
|
6005
6188
|
} catch {
|
|
6006
|
-
return null;
|
|
6007
|
-
}
|
|
6008
|
-
}
|
|
6009
|
-
function parseAndFilter(filePath, verbose) {
|
|
6010
|
-
let text;
|
|
6011
|
-
try {
|
|
6012
|
-
text = fs5.readFileSync(filePath, "utf8");
|
|
6013
|
-
} catch {
|
|
6014
|
-
if (verbose) warn(`Could not read ${filePath} \u2014 skipping`);
|
|
6015
|
-
return [];
|
|
6016
|
-
}
|
|
6017
|
-
let records;
|
|
6018
|
-
try {
|
|
6019
|
-
records = parseClaudeCode(text);
|
|
6020
|
-
} catch {
|
|
6021
|
-
if (verbose) warn(`Failed to parse ${filePath} \u2014 skipping`);
|
|
6022
|
-
return [];
|
|
6023
|
-
}
|
|
6024
|
-
const fresh = [];
|
|
6025
|
-
for (const r of records) {
|
|
6026
|
-
if (!seen.has(r.dedupeKey)) {
|
|
6027
|
-
seen.add(r.dedupeKey);
|
|
6028
|
-
fresh.push(r);
|
|
6029
|
-
}
|
|
6030
|
-
}
|
|
6031
|
-
if (verbose && fresh.length > 0) {
|
|
6032
|
-
dim(` ${filePath}: ${fresh.length} new session(s)`);
|
|
6033
|
-
}
|
|
6034
|
-
return fresh;
|
|
6035
|
-
}
|
|
6036
|
-
async function upload(client, records, verbose) {
|
|
6037
|
-
if (records.length === 0) return;
|
|
6038
|
-
try {
|
|
6039
|
-
const res = await client.uploadSessions(records);
|
|
6040
|
-
if (verbose) {
|
|
6041
|
-
dim(` Uploaded ${res.accepted} new, ${res.duplicates} duplicate(s)`);
|
|
6042
|
-
} else {
|
|
6043
|
-
success(`Uploaded ${res.accepted} session(s)`);
|
|
6044
|
-
}
|
|
6045
|
-
} catch (err) {
|
|
6046
|
-
if (err instanceof DeviceRevokedError) {
|
|
6047
|
-
markDisconnected();
|
|
6048
|
-
deleteToken();
|
|
6049
|
-
error(
|
|
6050
|
-
"This device was disconnected from the Token Rats web UI. Daemon will exit; re-run `token-rats login` to reconnect."
|
|
6051
|
-
);
|
|
6052
|
-
process.exit(0);
|
|
6053
|
-
}
|
|
6054
|
-
if (err instanceof ApiError2 && err.status === 401) {
|
|
6055
|
-
error("Session expired. Run `token-rats login` to re-authenticate.");
|
|
6056
|
-
process.exit(1);
|
|
6057
|
-
}
|
|
6058
|
-
warn(`Upload failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
6059
6189
|
}
|
|
6060
6190
|
}
|
|
6061
|
-
|
|
6062
|
-
|
|
6063
|
-
return (path5) => {
|
|
6064
|
-
const existing = timers.get(path5);
|
|
6065
|
-
if (existing) clearTimeout(existing);
|
|
6066
|
-
timers.set(
|
|
6067
|
-
path5,
|
|
6068
|
-
setTimeout(() => {
|
|
6069
|
-
timers.delete(path5);
|
|
6070
|
-
fn(path5);
|
|
6071
|
-
}, ms)
|
|
6072
|
-
);
|
|
6073
|
-
};
|
|
6074
|
-
}
|
|
6075
|
-
function findJsonlFiles2(dir) {
|
|
6076
|
-
const results = [];
|
|
6077
|
-
if (!fs5.existsSync(dir)) return results;
|
|
6078
|
-
const entries = fs5.readdirSync(dir, { withFileTypes: true });
|
|
6079
|
-
for (const entry of entries) {
|
|
6080
|
-
const full = `${dir}/${entry.name}`;
|
|
6081
|
-
if (entry.isDirectory()) {
|
|
6082
|
-
results.push(...findJsonlFiles2(full));
|
|
6083
|
-
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
6084
|
-
results.push(full);
|
|
6085
|
-
}
|
|
6086
|
-
}
|
|
6087
|
-
return results;
|
|
6088
|
-
}
|
|
6191
|
+
|
|
6192
|
+
// src/commands/watch.ts
|
|
6089
6193
|
async function watchCommand(opts) {
|
|
6090
|
-
if (isDisconnected()) {
|
|
6091
|
-
error(
|
|
6092
|
-
"This device was disconnected from the Token Rats web UI. Run `token-rats login` to reconnect."
|
|
6093
|
-
);
|
|
6094
|
-
process.exit(0);
|
|
6095
|
-
}
|
|
6096
6194
|
const token = loadToken();
|
|
6097
|
-
if (!token) {
|
|
6098
|
-
error("
|
|
6099
|
-
process.
|
|
6195
|
+
if (!token || isDisconnected()) {
|
|
6196
|
+
error("Run `token-rats login` before you start the tracker.");
|
|
6197
|
+
process.exitCode = 1;
|
|
6198
|
+
return;
|
|
6199
|
+
}
|
|
6200
|
+
const interval = opts.interval ?? 3e4;
|
|
6201
|
+
if (!Number.isFinite(interval) || interval < 1e3) {
|
|
6202
|
+
error("The interval must be at least 1000 milliseconds.");
|
|
6203
|
+
process.exitCode = 1;
|
|
6204
|
+
return;
|
|
6100
6205
|
}
|
|
6101
6206
|
const client = new ApiClient({
|
|
6102
6207
|
apiUrl: opts.apiUrl,
|
|
@@ -6104,143 +6209,53 @@ async function watchCommand(opts) {
|
|
|
6104
6209
|
deviceId: ensureDeviceId(),
|
|
6105
6210
|
cliVersion: CLI_VERSION
|
|
6106
6211
|
});
|
|
6107
|
-
const
|
|
6108
|
-
const
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
|
|
6114
|
-
|
|
6115
|
-
|
|
6116
|
-
|
|
6117
|
-
|
|
6118
|
-
const initialFiles = findJsonlFiles2(dir);
|
|
6119
|
-
for (const f of initialFiles) {
|
|
6120
|
-
const cur = statFile(f);
|
|
6121
|
-
if (!cur) continue;
|
|
6122
|
-
liveState.set(f, cur);
|
|
6123
|
-
const prev = persistedState[f];
|
|
6124
|
-
if (prev && cur.size < prev.size) {
|
|
6125
|
-
warn(`Rotation detected on ${f}: size shrank ${prev.size} \u2192 ${cur.size}.`);
|
|
6126
|
-
}
|
|
6127
|
-
parseAndFilter(f, false);
|
|
6128
|
-
}
|
|
6129
|
-
if (opts.verbose) {
|
|
6130
|
-
dim(`Initial snapshot: ${seen.size} session(s) in ${initialFiles.length} file(s)`);
|
|
6131
|
-
}
|
|
6132
|
-
saveWatchState(Object.fromEntries(liveState));
|
|
6133
|
-
const onChanged = makeDebounced(async (filePath) => {
|
|
6134
|
-
if (!filePath.endsWith(".jsonl")) return;
|
|
6135
|
-
const cur = statFile(filePath);
|
|
6136
|
-
if (cur) {
|
|
6137
|
-
const prev = liveState.get(filePath);
|
|
6138
|
-
if (prev && cur.size < prev.size) {
|
|
6139
|
-
warn(`Rotation detected on ${filePath}: size shrank ${prev.size} \u2192 ${cur.size}.`);
|
|
6140
|
-
}
|
|
6141
|
-
liveState.set(filePath, cur);
|
|
6142
|
-
saveWatchState(Object.fromEntries(liveState));
|
|
6143
|
-
}
|
|
6144
|
-
const fresh = parseAndFilter(filePath, opts.verbose ?? false);
|
|
6145
|
-
if (fresh.length > 0) {
|
|
6146
|
-
await upload(client, fresh, opts.verbose ?? false);
|
|
6147
|
-
}
|
|
6148
|
-
}, debounceMs);
|
|
6149
|
-
const heartbeatTimer = setInterval(async () => {
|
|
6150
|
-
try {
|
|
6151
|
-
await client.heartbeat();
|
|
6152
|
-
} catch (err) {
|
|
6153
|
-
if (err instanceof DeviceRevokedError) {
|
|
6154
|
-
markDisconnected();
|
|
6155
|
-
deleteToken();
|
|
6156
|
-
error(
|
|
6157
|
-
"This device was disconnected from the Token Rats web UI. Daemon exiting; re-run `token-rats login` to reconnect."
|
|
6158
|
-
);
|
|
6159
|
-
cleanup?.();
|
|
6160
|
-
clearInterval(heartbeatTimer);
|
|
6161
|
-
process.exit(0);
|
|
6162
|
-
}
|
|
6163
|
-
if (opts.verbose) {
|
|
6164
|
-
dim(`Heartbeat failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
6165
|
-
}
|
|
6166
|
-
}
|
|
6167
|
-
}, HEARTBEAT_MS);
|
|
6168
|
-
client.heartbeat().catch(() => {
|
|
6169
|
-
});
|
|
6170
|
-
let cleanup = null;
|
|
6212
|
+
const queue = new SyncQueue();
|
|
6213
|
+
const collect = createCollector();
|
|
6214
|
+
let stopped = false;
|
|
6215
|
+
let wake;
|
|
6216
|
+
const stop = () => {
|
|
6217
|
+
stopped = true;
|
|
6218
|
+
wake?.();
|
|
6219
|
+
};
|
|
6220
|
+
process.once("SIGINT", stop);
|
|
6221
|
+
process.once("SIGTERM", stop);
|
|
6222
|
+
info("Tracking Claude Code, Codex, and Cursor. Press Ctrl-C to stop.");
|
|
6171
6223
|
try {
|
|
6172
|
-
|
|
6173
|
-
|
|
6174
|
-
|
|
6175
|
-
|
|
6176
|
-
|
|
6177
|
-
|
|
6178
|
-
|
|
6179
|
-
|
|
6180
|
-
|
|
6181
|
-
|
|
6182
|
-
|
|
6183
|
-
|
|
6184
|
-
if (process.platform === "linux") {
|
|
6185
|
-
let lastMtimes = /* @__PURE__ */ new Map();
|
|
6186
|
-
for (const f of initialFiles) {
|
|
6187
|
-
try {
|
|
6188
|
-
lastMtimes.set(f, fs5.statSync(f).mtimeMs);
|
|
6189
|
-
} catch {
|
|
6190
|
-
}
|
|
6191
|
-
}
|
|
6192
|
-
const pollInterval = setInterval(() => {
|
|
6193
|
-
const current = findJsonlFiles2(dir);
|
|
6194
|
-
for (const f of current) {
|
|
6195
|
-
try {
|
|
6196
|
-
const mtime = fs5.statSync(f).mtimeMs;
|
|
6197
|
-
const prev = lastMtimes.get(f) ?? 0;
|
|
6198
|
-
if (mtime > prev) {
|
|
6199
|
-
lastMtimes.set(f, mtime);
|
|
6200
|
-
onChanged(f);
|
|
6201
|
-
}
|
|
6202
|
-
} catch {
|
|
6203
|
-
}
|
|
6204
|
-
}
|
|
6205
|
-
for (const f of current) {
|
|
6206
|
-
if (!lastMtimes.has(f)) {
|
|
6207
|
-
lastMtimes.set(f, Date.now());
|
|
6208
|
-
onChanged(f);
|
|
6209
|
-
}
|
|
6224
|
+
while (!stopped) {
|
|
6225
|
+
try {
|
|
6226
|
+
const count = await queue.flush(await collect(), (batch) => client.uploadSessions(batch));
|
|
6227
|
+
if (count === 0) await client.uploadSessions([]);
|
|
6228
|
+
await client.heartbeat();
|
|
6229
|
+
if (count > 0) success(`Synced ${count} changed record(s).`);
|
|
6230
|
+
} catch (err) {
|
|
6231
|
+
if (err instanceof DeviceRevokedError) {
|
|
6232
|
+
markDisconnected();
|
|
6233
|
+
deleteToken();
|
|
6234
|
+
error("Device disconnected. Run `token-rats login` to reconnect.");
|
|
6235
|
+
break;
|
|
6210
6236
|
}
|
|
6211
|
-
|
|
6212
|
-
|
|
6213
|
-
|
|
6214
|
-
|
|
6215
|
-
const controller = new AbortController();
|
|
6216
|
-
(async () => {
|
|
6217
|
-
try {
|
|
6218
|
-
const { watch } = await import("node:fs/promises");
|
|
6219
|
-
const watcher = watch(dir, { recursive: true, signal: controller.signal });
|
|
6220
|
-
for await (const event of watcher) {
|
|
6221
|
-
const filename = event.filename;
|
|
6222
|
-
if (filename?.endsWith(".jsonl")) {
|
|
6223
|
-
const fullPath = `${dir}/${filename}`;
|
|
6224
|
-
onChanged(fullPath);
|
|
6225
|
-
}
|
|
6226
|
-
}
|
|
6227
|
-
} catch (err) {
|
|
6228
|
-
if (err instanceof Error && err.name !== "AbortError") {
|
|
6229
|
-
warn(`Watcher error: ${err.message}`);
|
|
6230
|
-
}
|
|
6237
|
+
if (err instanceof ApiError2 && err.status === 401) {
|
|
6238
|
+
error("Session expired. Run `token-rats login` again.");
|
|
6239
|
+
process.exitCode = 1;
|
|
6240
|
+
break;
|
|
6231
6241
|
}
|
|
6232
|
-
|
|
6233
|
-
|
|
6242
|
+
warn(
|
|
6243
|
+
`Sync failed. The next scan will retry: ${err instanceof Error ? err.message : String(err)}`
|
|
6244
|
+
);
|
|
6245
|
+
}
|
|
6246
|
+
if (stopped) break;
|
|
6247
|
+
await new Promise((resolve2) => {
|
|
6248
|
+
const timer = setTimeout(resolve2, interval);
|
|
6249
|
+
wake = () => {
|
|
6250
|
+
clearTimeout(timer);
|
|
6251
|
+
resolve2();
|
|
6252
|
+
};
|
|
6253
|
+
});
|
|
6234
6254
|
}
|
|
6255
|
+
} finally {
|
|
6256
|
+
process.removeListener("SIGINT", stop);
|
|
6257
|
+
process.removeListener("SIGTERM", stop);
|
|
6235
6258
|
}
|
|
6236
|
-
function shutdown() {
|
|
6237
|
-
info("Shutting down\u2026");
|
|
6238
|
-
clearInterval(heartbeatTimer);
|
|
6239
|
-
if (cleanup) cleanup();
|
|
6240
|
-
process.exit(0);
|
|
6241
|
-
}
|
|
6242
|
-
process.on("SIGINT", shutdown);
|
|
6243
|
-
process.on("SIGTERM", shutdown);
|
|
6244
6259
|
}
|
|
6245
6260
|
|
|
6246
6261
|
// src/commands/whoami.ts
|
|
@@ -6284,14 +6299,14 @@ function getVersion() {
|
|
|
6284
6299
|
}
|
|
6285
6300
|
function printHelp() {
|
|
6286
6301
|
console.log(`
|
|
6287
|
-
\x1B[1mtoken-rats\x1B[0m \u2014
|
|
6302
|
+
\x1B[1mtoken-rats\x1B[0m \u2014 AI usage tracker and community \x1B[2mv${getVersion()}\x1B[0m
|
|
6288
6303
|
|
|
6289
6304
|
\x1B[1mUsage:\x1B[0m
|
|
6290
6305
|
token-rats <command> [flags]
|
|
6291
6306
|
|
|
6292
6307
|
\x1B[1mCommands:\x1B[0m
|
|
6293
6308
|
login Authenticate with Token Rats (opens browser); installs the background watcher by default
|
|
6294
|
-
sync Read local Claude Code + Cursor logs and upload counts
|
|
6309
|
+
sync Read local Claude Code, Codex + Cursor logs and upload counts
|
|
6295
6310
|
watch Watch logs in real-time; upload new sessions as they appear
|
|
6296
6311
|
whoami Show the currently signed-in account + device id
|
|
6297
6312
|
logout Clear your stored credentials
|
|
@@ -6310,13 +6325,13 @@ function printHelp() {
|
|
|
6310
6325
|
--verbose Print discovered files and per-file record counts
|
|
6311
6326
|
|
|
6312
6327
|
\x1B[1mFlags (watch only):\x1B[0m
|
|
6313
|
-
--interval <ms>
|
|
6328
|
+
--interval <ms> Scan interval in ms (default: 30000, minimum: 1000)
|
|
6314
6329
|
--verbose Print file change events and upload detail
|
|
6315
6330
|
|
|
6316
6331
|
\x1B[1mPrivacy:\x1B[0m
|
|
6317
|
-
Token Rats reads
|
|
6318
|
-
|
|
6319
|
-
|
|
6332
|
+
Token Rats reads local logs and uploads usage metadata only.
|
|
6333
|
+
It does not upload prompts or completions.
|
|
6334
|
+
The parser source is in packages/parsers/.
|
|
6320
6335
|
|
|
6321
6336
|
\x1B[1mCursor notes:\x1B[0m
|
|
6322
6337
|
Cursor doesn't store token counts locally, so per-request tokens
|
|
@@ -6397,7 +6412,7 @@ async function main() {
|
|
|
6397
6412
|
await installCursorCommand();
|
|
6398
6413
|
break;
|
|
6399
6414
|
case "install-daemon":
|
|
6400
|
-
await installDaemonCommand();
|
|
6415
|
+
await installDaemonCommand(apiUrl);
|
|
6401
6416
|
break;
|
|
6402
6417
|
case "uninstall-daemon":
|
|
6403
6418
|
await uninstallDaemonCommand();
|