token-rats 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +16 -0
- package/dist/index.js +439 -436
- package/package.json +4 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Token Rats contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -86,3 +86,19 @@ Authentication uses a device-code flow:
|
|
|
86
86
|
Token Rats is open source. The CLI source is in [`packages/cli/`](.) and the parsers are in [`packages/parsers/`](../parsers/). You can inspect exactly what is read from your disk and what is sent to the server.
|
|
87
87
|
|
|
88
88
|
**Privacy posture:** Token Rats reads usage counts only — never prompts or completions. The parser source is in `packages/parsers/`. We literally can't read what you typed.
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
## Automatic tracking
|
|
92
|
+
|
|
93
|
+
`token-rats login` installs the background tracker. It reads Claude Code,
|
|
94
|
+
Codex, and Cursor records on startup, then checks for changes every 30 seconds.
|
|
95
|
+
Failed uploads are retried. Use `login --no-daemon` to use manual sync only.
|
|
96
|
+
Use `daemon-status` and `uninstall-daemon` to manage automatic tracking.
|
|
97
|
+
After an upgrade, run `install-daemon` to copy the new runtime into place.
|
|
98
|
+
Custom `--api-url` settings are passed to the installed tracker.
|
|
99
|
+
|
|
100
|
+
Open `/app/compare` on your Token Rats server to enter a subscription amount
|
|
101
|
+
for a month. Cursor counts are estimates. API estimates are not provider bills.
|
|
102
|
+
See the [counting method](https://github.com/hsalberti/token-rats/blob/main/docs/counting.md).
|
|
103
|
+
|
|
104
|
+
The CLI and its documentation are MIT licensed. The npm package includes LICENSE.
|
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.0",
|
|
5260
|
+
description: "Track Claude Code, Codex, and Cursor usage and compare AI subscriptions.",
|
|
5261
|
+
license: "MIT",
|
|
5262
|
+
type: "module",
|
|
5263
|
+
bin: {
|
|
5264
|
+
"token-rats": "./dist/index.js"
|
|
5265
|
+
},
|
|
5266
|
+
files: ["dist", "README.md", "LICENSE"],
|
|
5267
|
+
repository: {
|
|
5268
|
+
type: "git",
|
|
5269
|
+
url: "git+https://github.com/hsalberti/token-rats.git",
|
|
5270
|
+
directory: "packages/cli"
|
|
5271
|
+
},
|
|
5272
|
+
homepage: "https://tokenrats.com",
|
|
5273
|
+
keywords: ["claude", "claude-code", "cursor", "tokens", "leaderboard", "ai-usage"],
|
|
5274
|
+
publishConfig: {
|
|
5275
|
+
access: "public"
|
|
5276
|
+
},
|
|
5277
|
+
scripts: {
|
|
5278
|
+
build: "tsc --noEmit -p tsconfig.json && node build.mjs",
|
|
5279
|
+
typecheck: "tsc --noEmit",
|
|
5280
|
+
dev: "tsx src/index.ts",
|
|
5281
|
+
test: "vitest run",
|
|
5282
|
+
prepublishOnly: "node build.mjs"
|
|
5283
|
+
},
|
|
5284
|
+
optionalDependencies: {
|
|
5285
|
+
clipboardy: "^4.0.0",
|
|
5286
|
+
open: "^10.1.0"
|
|
5287
|
+
},
|
|
5288
|
+
devDependencies: {
|
|
5289
|
+
"@token-rats/contracts": "workspace:*",
|
|
5290
|
+
"@token-rats/parsers": "workspace:*",
|
|
5291
|
+
"@types/better-sqlite3": "^7.6.12",
|
|
5292
|
+
"@types/node": "22.10.2",
|
|
5293
|
+
"@types/sql.js": "^1.4.11",
|
|
5294
|
+
esbuild: "^0.24.2",
|
|
5295
|
+
tsx: "4.19.2",
|
|
5296
|
+
typescript: "5.7.2",
|
|
5297
|
+
vitest: "2.1.8"
|
|
5298
|
+
},
|
|
5299
|
+
dependencies: {
|
|
5300
|
+
"sql.js": "^1.14.1"
|
|
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,7 +5411,8 @@ 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";
|
|
5253
5417
|
|
|
5254
5418
|
// ../parsers/src/hash.ts
|
|
@@ -5281,6 +5445,7 @@ function parseClaudeCode(input) {
|
|
|
5281
5445
|
text = new TextDecoder().decode(input instanceof ArrayBuffer ? new Uint8Array(input) : input);
|
|
5282
5446
|
}
|
|
5283
5447
|
const sessions = /* @__PURE__ */ new Map();
|
|
5448
|
+
const messages = /* @__PURE__ */ new Map();
|
|
5284
5449
|
for (const rawLine of text.split("\n")) {
|
|
5285
5450
|
const line = rawLine.trim();
|
|
5286
5451
|
if (line.length === 0) continue;
|
|
@@ -5314,7 +5479,8 @@ function parseClaudeCode(input) {
|
|
|
5314
5479
|
outTokens: 0,
|
|
5315
5480
|
cacheReadTokens: 0,
|
|
5316
5481
|
cacheWriteTokens: 0,
|
|
5317
|
-
model: ""
|
|
5482
|
+
model: "",
|
|
5483
|
+
modelAt: 0
|
|
5318
5484
|
};
|
|
5319
5485
|
sessions.set(sessionId, acc);
|
|
5320
5486
|
}
|
|
@@ -5326,16 +5492,32 @@ function parseClaudeCode(input) {
|
|
|
5326
5492
|
const message = ev.message;
|
|
5327
5493
|
if (typeof message !== "object" || message === null) continue;
|
|
5328
5494
|
const msg = message;
|
|
5329
|
-
if (typeof msg.model === "string" && msg.model.length > 0) {
|
|
5495
|
+
if (typeof msg.model === "string" && msg.model.length > 0 && timestamp >= acc.modelAt) {
|
|
5330
5496
|
acc.model = msg.model;
|
|
5497
|
+
acc.modelAt = timestamp;
|
|
5331
5498
|
}
|
|
5332
5499
|
const usage = msg.usage;
|
|
5333
5500
|
if (typeof usage === "object" && usage !== null) {
|
|
5334
5501
|
const u = usage;
|
|
5335
|
-
|
|
5336
|
-
|
|
5337
|
-
|
|
5338
|
-
|
|
5502
|
+
const counts = {
|
|
5503
|
+
input: toNonNegInt(u.input_tokens),
|
|
5504
|
+
output: toNonNegInt(u.output_tokens),
|
|
5505
|
+
read: toNonNegInt(u.cache_read_input_tokens),
|
|
5506
|
+
write: toNonNegInt(u.cache_creation_input_tokens)
|
|
5507
|
+
};
|
|
5508
|
+
const key = typeof msg.id === "string" ? `${sessionId}:${msg.id}` : null;
|
|
5509
|
+
const old = key ? messages.get(key) : void 0;
|
|
5510
|
+
const next = {
|
|
5511
|
+
input: Math.max(old?.input ?? 0, counts.input),
|
|
5512
|
+
output: Math.max(old?.output ?? 0, counts.output),
|
|
5513
|
+
read: Math.max(old?.read ?? 0, counts.read),
|
|
5514
|
+
write: Math.max(old?.write ?? 0, counts.write)
|
|
5515
|
+
};
|
|
5516
|
+
acc.inTokens += next.input - (old?.input ?? 0);
|
|
5517
|
+
acc.outTokens += next.output - (old?.output ?? 0);
|
|
5518
|
+
acc.cacheReadTokens += next.read - (old?.read ?? 0);
|
|
5519
|
+
acc.cacheWriteTokens += next.write - (old?.write ?? 0);
|
|
5520
|
+
if (key) messages.set(key, next);
|
|
5339
5521
|
}
|
|
5340
5522
|
}
|
|
5341
5523
|
const results = [];
|
|
@@ -5402,12 +5584,15 @@ function parseCodex(input) {
|
|
|
5402
5584
|
const payload = typeof ev.payload === "object" && ev.payload !== null ? ev.payload : null;
|
|
5403
5585
|
if (type === "session_meta" && payload) {
|
|
5404
5586
|
const id = typeof payload.id === "string" ? payload.id : null;
|
|
5405
|
-
if (!id)
|
|
5587
|
+
if (!id) {
|
|
5588
|
+
currentSessionId = null;
|
|
5589
|
+
continue;
|
|
5590
|
+
}
|
|
5406
5591
|
currentSessionId = id;
|
|
5407
5592
|
const metaTs = parseTimestamp(payload.timestamp) || ts;
|
|
5408
5593
|
const acc2 = upsert(sessions, id);
|
|
5409
5594
|
const metaSource = typeof payload.source === "string" ? payload.source : null;
|
|
5410
|
-
acc2.channel = metaSource === "
|
|
5595
|
+
acc2.channel = metaSource === "api" ? "api" : metaSource ? "cli" : "unknown";
|
|
5411
5596
|
acc2.client = acc2.channel === "cli" ? "codex-cli" : "codex";
|
|
5412
5597
|
if (metaTs > 0 && (acc2.startedAt === 0 || metaTs < acc2.startedAt)) {
|
|
5413
5598
|
acc2.startedAt = metaTs;
|
|
@@ -5422,7 +5607,10 @@ function parseCodex(input) {
|
|
|
5422
5607
|
if (ts > acc.endedAt) acc.endedAt = ts;
|
|
5423
5608
|
}
|
|
5424
5609
|
if (type === "turn_context" && payload && typeof payload.model === "string") {
|
|
5425
|
-
acc.
|
|
5610
|
+
if (ts >= acc.modelAt) {
|
|
5611
|
+
acc.model = payload.model;
|
|
5612
|
+
acc.modelAt = ts;
|
|
5613
|
+
}
|
|
5426
5614
|
continue;
|
|
5427
5615
|
}
|
|
5428
5616
|
if (type === "event_msg" && payload && payload.type === "token_count") {
|
|
@@ -5435,8 +5623,10 @@ function parseCodex(input) {
|
|
|
5435
5623
|
const cachedInput = toNonNegInt2(t.cached_input_tokens);
|
|
5436
5624
|
const output = toNonNegInt2(t.output_tokens);
|
|
5437
5625
|
const reasoning = toNonNegInt2(t.reasoning_output_tokens);
|
|
5626
|
+
if (ts < acc.usageAt) continue;
|
|
5627
|
+
acc.usageAt = ts;
|
|
5438
5628
|
acc.inTokens = Math.max(0, inputTotal - cachedInput);
|
|
5439
|
-
acc.outTokens = output
|
|
5629
|
+
acc.outTokens = output;
|
|
5440
5630
|
acc.cacheReadTokens = cachedInput;
|
|
5441
5631
|
acc.reasoningTokens = reasoning;
|
|
5442
5632
|
}
|
|
@@ -5480,6 +5670,8 @@ function upsert(map, sessionId) {
|
|
|
5480
5670
|
cacheReadTokens: 0,
|
|
5481
5671
|
reasoningTokens: 0,
|
|
5482
5672
|
model: "",
|
|
5673
|
+
usageAt: 0,
|
|
5674
|
+
modelAt: 0,
|
|
5483
5675
|
client: "codex-cli",
|
|
5484
5676
|
channel: "unknown"
|
|
5485
5677
|
};
|
|
@@ -5680,7 +5872,7 @@ async function extractCursorGenerations() {
|
|
|
5680
5872
|
dbCount: 0
|
|
5681
5873
|
};
|
|
5682
5874
|
}
|
|
5683
|
-
const
|
|
5875
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5684
5876
|
const rows = [];
|
|
5685
5877
|
let openFailures = 0;
|
|
5686
5878
|
for (const p of dbPaths) {
|
|
@@ -5692,8 +5884,8 @@ async function extractCursorGenerations() {
|
|
|
5692
5884
|
continue;
|
|
5693
5885
|
}
|
|
5694
5886
|
for (const r of perDb) {
|
|
5695
|
-
if (
|
|
5696
|
-
|
|
5887
|
+
if (seen.has(r.id)) continue;
|
|
5888
|
+
seen.add(r.id);
|
|
5697
5889
|
rows.push(r);
|
|
5698
5890
|
}
|
|
5699
5891
|
}
|
|
@@ -5727,6 +5919,7 @@ function findJsonlFiles(dir) {
|
|
|
5727
5919
|
}
|
|
5728
5920
|
function claudeCodeProjectsDir() {
|
|
5729
5921
|
const home = os3.homedir();
|
|
5922
|
+
if (process.env.CLAUDE_CONFIG_DIR) return path3.join(process.env.CLAUDE_CONFIG_DIR, "projects");
|
|
5730
5923
|
if (process.platform === "win32") {
|
|
5731
5924
|
const profile = process.env.USERPROFILE ?? home;
|
|
5732
5925
|
return path3.join(profile, ".claude", "projects");
|
|
@@ -5740,13 +5933,19 @@ function discoverClaudeCodeFiles() {
|
|
|
5740
5933
|
function codexSessionsDirs() {
|
|
5741
5934
|
const home = os3.homedir();
|
|
5742
5935
|
const candidates = [];
|
|
5936
|
+
if (process.env.CODEX_HOME) {
|
|
5937
|
+
candidates.push(path3.join(process.env.CODEX_HOME, "sessions"));
|
|
5938
|
+
candidates.push(path3.join(process.env.CODEX_HOME, "archived_sessions"));
|
|
5939
|
+
}
|
|
5743
5940
|
if (process.platform === "win32") {
|
|
5744
5941
|
const profile = process.env.USERPROFILE ?? home;
|
|
5745
5942
|
candidates.push(path3.join(profile, ".codex", "sessions"));
|
|
5943
|
+
candidates.push(path3.join(profile, ".codex", "archived_sessions"));
|
|
5746
5944
|
const appData = process.env.APPDATA ?? path3.join(profile, "AppData", "Roaming");
|
|
5747
5945
|
candidates.push(path3.join(appData, "Codex", "sessions"));
|
|
5748
5946
|
} else {
|
|
5749
5947
|
candidates.push(path3.join(home, ".codex", "sessions"));
|
|
5948
|
+
candidates.push(path3.join(home, ".codex", "archived_sessions"));
|
|
5750
5949
|
const snapRoot = path3.join(home, "snap", "codex");
|
|
5751
5950
|
if (fs3.existsSync(snapRoot)) {
|
|
5752
5951
|
for (const entry of fs3.readdirSync(snapRoot, { withFileTypes: true })) {
|
|
@@ -5756,13 +5955,13 @@ function codexSessionsDirs() {
|
|
|
5756
5955
|
}
|
|
5757
5956
|
}
|
|
5758
5957
|
}
|
|
5759
|
-
const
|
|
5958
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5760
5959
|
const dirs = [];
|
|
5761
5960
|
for (const c2 of candidates) {
|
|
5762
5961
|
try {
|
|
5763
5962
|
const real = fs3.existsSync(c2) ? fs3.realpathSync(c2) : null;
|
|
5764
|
-
if (real && !
|
|
5765
|
-
|
|
5963
|
+
if (real && !seen.has(real)) {
|
|
5964
|
+
seen.add(real);
|
|
5766
5965
|
dirs.push(c2);
|
|
5767
5966
|
}
|
|
5768
5967
|
} catch {
|
|
@@ -5778,8 +5977,126 @@ function discoverCodexFiles() {
|
|
|
5778
5977
|
return out;
|
|
5779
5978
|
}
|
|
5780
5979
|
|
|
5980
|
+
// src/lib/collect.ts
|
|
5981
|
+
async function collectSessions() {
|
|
5982
|
+
function read(files) {
|
|
5983
|
+
return files.map((file) => fs4.readFileSync(file, "utf8")).join("\n");
|
|
5984
|
+
}
|
|
5985
|
+
const claude = parseClaudeCode(read(discoverClaudeCodeFiles()));
|
|
5986
|
+
const codex = parseCodex(read(discoverCodexFiles()));
|
|
5987
|
+
const { rows } = await extractCursorGenerations();
|
|
5988
|
+
const cursor = parseCursor(JSON.stringify(rows));
|
|
5989
|
+
return [...claude, ...codex, ...cursor].map((record) => ({
|
|
5990
|
+
...record,
|
|
5991
|
+
accountingVersion: 2,
|
|
5992
|
+
dedupeKey: createHash("sha256").update(
|
|
5993
|
+
JSON.stringify([
|
|
5994
|
+
record.id,
|
|
5995
|
+
record.source,
|
|
5996
|
+
record.model,
|
|
5997
|
+
record.startedAt,
|
|
5998
|
+
record.inTokens,
|
|
5999
|
+
record.outTokens,
|
|
6000
|
+
record.cacheReadTokens ?? 0,
|
|
6001
|
+
record.cacheWriteTokens ?? 0,
|
|
6002
|
+
record.reasoningTokens ?? 0
|
|
6003
|
+
])
|
|
6004
|
+
).digest("hex")
|
|
6005
|
+
}));
|
|
6006
|
+
}
|
|
6007
|
+
var SyncQueue = class {
|
|
6008
|
+
acknowledged = /* @__PURE__ */ new Map();
|
|
6009
|
+
async flush(records, upload) {
|
|
6010
|
+
const fresh = records.filter((r) => this.acknowledged.get(r.id) !== JSON.stringify(r));
|
|
6011
|
+
for (let i = 0; i < fresh.length; i += 90) {
|
|
6012
|
+
const batch = fresh.slice(i, i + 90);
|
|
6013
|
+
await upload(batch);
|
|
6014
|
+
for (const record of batch) this.acknowledged.set(record.id, JSON.stringify(record));
|
|
6015
|
+
}
|
|
6016
|
+
return fresh.length;
|
|
6017
|
+
}
|
|
6018
|
+
};
|
|
6019
|
+
function createCollector() {
|
|
6020
|
+
let signature = "";
|
|
6021
|
+
let records = [];
|
|
6022
|
+
return async () => {
|
|
6023
|
+
const files = [
|
|
6024
|
+
...discoverClaudeCodeFiles(),
|
|
6025
|
+
...discoverCodexFiles(),
|
|
6026
|
+
...discoverWorkspaceDbs().flatMap((file) => [file, `${file}-wal`])
|
|
6027
|
+
].sort();
|
|
6028
|
+
const next = JSON.stringify(
|
|
6029
|
+
files.map((file) => {
|
|
6030
|
+
try {
|
|
6031
|
+
const stat = fs4.statSync(file);
|
|
6032
|
+
return [file, stat.size, stat.mtimeMs];
|
|
6033
|
+
} catch {
|
|
6034
|
+
return [file, null];
|
|
6035
|
+
}
|
|
6036
|
+
})
|
|
6037
|
+
);
|
|
6038
|
+
if (next !== signature) {
|
|
6039
|
+
records = await collectSessions();
|
|
6040
|
+
signature = next;
|
|
6041
|
+
}
|
|
6042
|
+
return records;
|
|
6043
|
+
};
|
|
6044
|
+
}
|
|
6045
|
+
|
|
5781
6046
|
// src/commands/sync.ts
|
|
5782
|
-
var BATCH_SIZE =
|
|
6047
|
+
var BATCH_SIZE = 90;
|
|
6048
|
+
var WEB_ORIGIN = "https://tokenrats.com";
|
|
6049
|
+
function currentStreakFromDays(days, todayUtc) {
|
|
6050
|
+
const active = days.slice().sort();
|
|
6051
|
+
if (active.length === 0) return 0;
|
|
6052
|
+
const last = active[active.length - 1];
|
|
6053
|
+
const yesterday = (() => {
|
|
6054
|
+
const d = /* @__PURE__ */ new Date(`${todayUtc}T00:00:00Z`);
|
|
6055
|
+
d.setUTCDate(d.getUTCDate() - 1);
|
|
6056
|
+
return d.toISOString().slice(0, 10);
|
|
6057
|
+
})();
|
|
6058
|
+
if (last !== todayUtc && last !== yesterday) return 0;
|
|
6059
|
+
let streak = 1;
|
|
6060
|
+
for (let i = active.length - 1; i >= 1; i--) {
|
|
6061
|
+
const prev = (/* @__PURE__ */ new Date(`${active[i - 1]}T00:00:00Z`)).getTime();
|
|
6062
|
+
const curr = (/* @__PURE__ */ new Date(`${active[i]}T00:00:00Z`)).getTime();
|
|
6063
|
+
if (Math.round((curr - prev) / 864e5) === 1) streak++;
|
|
6064
|
+
else break;
|
|
6065
|
+
}
|
|
6066
|
+
return streak;
|
|
6067
|
+
}
|
|
6068
|
+
async function printSyncHero(client) {
|
|
6069
|
+
let handle;
|
|
6070
|
+
try {
|
|
6071
|
+
const me = await client.getMe();
|
|
6072
|
+
handle = me.user.handle;
|
|
6073
|
+
} catch {
|
|
6074
|
+
return;
|
|
6075
|
+
}
|
|
6076
|
+
const todayUtc = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
6077
|
+
const [trending, heatmap] = await Promise.all([
|
|
6078
|
+
client.getTrending("30d").catch(() => null),
|
|
6079
|
+
client.getHeatmap(handle).catch(() => null)
|
|
6080
|
+
]);
|
|
6081
|
+
const rank = trending?.rows.find((r) => r.handle === handle)?.rank ?? null;
|
|
6082
|
+
const streak = heatmap === null ? 0 : currentStreakFromDays(
|
|
6083
|
+
heatmap.heatmap.days.filter((d) => d.sessions > 0).map((d) => d.day),
|
|
6084
|
+
todayUtc
|
|
6085
|
+
);
|
|
6086
|
+
const profileUrl = `${WEB_ORIGIN}/u/${handle}`;
|
|
6087
|
+
console.log("");
|
|
6088
|
+
bold("\u{1F400} Your Token Rats standing");
|
|
6089
|
+
if (rank !== null) info(`Global rank: #${rank}`);
|
|
6090
|
+
if (streak > 0) info(`Current streak: ${streak} day${streak === 1 ? "" : "s"} \u{1F525}`);
|
|
6091
|
+
info(`Profile: ${profileUrl}`);
|
|
6092
|
+
const rankPart = rank !== null ? `ranked #${rank} globally` : "on the board";
|
|
6093
|
+
const streakPart = streak > 0 ? ` on a ${streak}-day streak` : "";
|
|
6094
|
+
const tweet = `I'm ${rankPart}${streakPart} on @tokenrats \u2014 tracking my AI coding tokens. \u{1F400}`;
|
|
6095
|
+
const intent = `https://x.com/intent/tweet?text=${encodeURIComponent(tweet)}&url=${encodeURIComponent(profileUrl)}`;
|
|
6096
|
+
console.log("");
|
|
6097
|
+
dim("Brag about it \u2014 Post to X:");
|
|
6098
|
+
console.log(` ${process.stdout.isTTY ? `${c.cyan}${intent}${c.reset}` : intent}`);
|
|
6099
|
+
}
|
|
5783
6100
|
function chunk(arr, size) {
|
|
5784
6101
|
const chunks = [];
|
|
5785
6102
|
for (let i = 0; i < arr.length; i += size) {
|
|
@@ -5787,34 +6104,6 @@ function chunk(arr, size) {
|
|
|
5787
6104
|
}
|
|
5788
6105
|
return chunks;
|
|
5789
6106
|
}
|
|
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
6107
|
async function syncCommand(opts) {
|
|
5819
6108
|
const token = loadToken();
|
|
5820
6109
|
if (!token && !opts.dryRun) {
|
|
@@ -5827,92 +6116,8 @@ async function syncCommand(opts) {
|
|
|
5827
6116
|
deviceId: ensureDeviceId(),
|
|
5828
6117
|
cliVersion: CLI_VERSION
|
|
5829
6118
|
});
|
|
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";
|
|
6119
|
+
const allSessions = await collectSessions();
|
|
6120
|
+
const sourceStr = [...new Set(allSessions.map((record) => record.source))].join(" + ") || "no sources";
|
|
5916
6121
|
if (allSessions.length === 0) {
|
|
5917
6122
|
info(`No sessions found from ${sourceStr}.`);
|
|
5918
6123
|
return;
|
|
@@ -5966,137 +6171,25 @@ async function syncCommand(opts) {
|
|
|
5966
6171
|
success(
|
|
5967
6172
|
`Synced ${allSessions.length} sessions (${totalAccepted} new, ${totalDuplicates} already on server) from ${sourceStr}`
|
|
5968
6173
|
);
|
|
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
6174
|
try {
|
|
5983
|
-
|
|
5984
|
-
const parsed = JSON.parse(raw);
|
|
5985
|
-
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
6175
|
+
await printSyncHero(client);
|
|
5986
6176
|
} catch {
|
|
5987
|
-
return {};
|
|
5988
6177
|
}
|
|
5989
6178
|
}
|
|
5990
|
-
|
|
5991
|
-
|
|
5992
|
-
const file = stateFilePath();
|
|
5993
|
-
fs5.mkdirSync(path4.dirname(file), { recursive: true });
|
|
5994
|
-
fs5.writeFileSync(file, JSON.stringify(state), { mode: 384 });
|
|
5995
|
-
} catch (err) {
|
|
5996
|
-
if (err instanceof Error) {
|
|
5997
|
-
console.warn(`watch-state write failed: ${err.message}`);
|
|
5998
|
-
}
|
|
5999
|
-
}
|
|
6000
|
-
}
|
|
6001
|
-
function statFile(p) {
|
|
6002
|
-
try {
|
|
6003
|
-
const s = fs5.statSync(p);
|
|
6004
|
-
return { size: s.size, mtimeMs: s.mtimeMs };
|
|
6005
|
-
} catch {
|
|
6006
|
-
return null;
|
|
6007
|
-
}
|
|
6008
|
-
}
|
|
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
|
-
}
|
|
6060
|
-
}
|
|
6061
|
-
function makeDebounced(fn, ms) {
|
|
6062
|
-
const timers = /* @__PURE__ */ new Map();
|
|
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
|
-
}
|
|
6179
|
+
|
|
6180
|
+
// src/commands/watch.ts
|
|
6089
6181
|
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
6182
|
const token = loadToken();
|
|
6097
|
-
if (!token) {
|
|
6098
|
-
error("
|
|
6099
|
-
process.
|
|
6183
|
+
if (!token || isDisconnected()) {
|
|
6184
|
+
error("Run `token-rats login` before you start the tracker.");
|
|
6185
|
+
process.exitCode = 1;
|
|
6186
|
+
return;
|
|
6187
|
+
}
|
|
6188
|
+
const interval = opts.interval ?? 3e4;
|
|
6189
|
+
if (!Number.isFinite(interval) || interval < 1e3) {
|
|
6190
|
+
error("The interval must be at least 1000 milliseconds.");
|
|
6191
|
+
process.exitCode = 1;
|
|
6192
|
+
return;
|
|
6100
6193
|
}
|
|
6101
6194
|
const client = new ApiClient({
|
|
6102
6195
|
apiUrl: opts.apiUrl,
|
|
@@ -6104,143 +6197,53 @@ async function watchCommand(opts) {
|
|
|
6104
6197
|
deviceId: ensureDeviceId(),
|
|
6105
6198
|
cliVersion: CLI_VERSION
|
|
6106
6199
|
});
|
|
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;
|
|
6200
|
+
const queue = new SyncQueue();
|
|
6201
|
+
const collect = createCollector();
|
|
6202
|
+
let stopped = false;
|
|
6203
|
+
let wake;
|
|
6204
|
+
const stop = () => {
|
|
6205
|
+
stopped = true;
|
|
6206
|
+
wake?.();
|
|
6207
|
+
};
|
|
6208
|
+
process.once("SIGINT", stop);
|
|
6209
|
+
process.once("SIGTERM", stop);
|
|
6210
|
+
info("Tracking Claude Code, Codex, and Cursor. Press Ctrl-C to stop.");
|
|
6171
6211
|
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
|
-
}
|
|
6212
|
+
while (!stopped) {
|
|
6213
|
+
try {
|
|
6214
|
+
const count = await queue.flush(await collect(), (batch) => client.uploadSessions(batch));
|
|
6215
|
+
if (count === 0) await client.uploadSessions([]);
|
|
6216
|
+
await client.heartbeat();
|
|
6217
|
+
if (count > 0) success(`Synced ${count} changed record(s).`);
|
|
6218
|
+
} catch (err) {
|
|
6219
|
+
if (err instanceof DeviceRevokedError) {
|
|
6220
|
+
markDisconnected();
|
|
6221
|
+
deleteToken();
|
|
6222
|
+
error("Device disconnected. Run `token-rats login` to reconnect.");
|
|
6223
|
+
break;
|
|
6204
6224
|
}
|
|
6205
|
-
|
|
6206
|
-
|
|
6207
|
-
|
|
6208
|
-
|
|
6209
|
-
}
|
|
6225
|
+
if (err instanceof ApiError2 && err.status === 401) {
|
|
6226
|
+
error("Session expired. Run `token-rats login` again.");
|
|
6227
|
+
process.exitCode = 1;
|
|
6228
|
+
break;
|
|
6210
6229
|
}
|
|
6211
|
-
|
|
6212
|
-
|
|
6213
|
-
|
|
6214
|
-
|
|
6215
|
-
|
|
6216
|
-
|
|
6217
|
-
|
|
6218
|
-
|
|
6219
|
-
|
|
6220
|
-
|
|
6221
|
-
|
|
6222
|
-
|
|
6223
|
-
const fullPath = `${dir}/${filename}`;
|
|
6224
|
-
onChanged(fullPath);
|
|
6225
|
-
}
|
|
6226
|
-
}
|
|
6227
|
-
} catch (err) {
|
|
6228
|
-
if (err instanceof Error && err.name !== "AbortError") {
|
|
6229
|
-
warn(`Watcher error: ${err.message}`);
|
|
6230
|
-
}
|
|
6231
|
-
}
|
|
6232
|
-
})();
|
|
6233
|
-
cleanup = () => controller.abort();
|
|
6230
|
+
warn(
|
|
6231
|
+
`Sync failed. The next scan will retry: ${err instanceof Error ? err.message : String(err)}`
|
|
6232
|
+
);
|
|
6233
|
+
}
|
|
6234
|
+
if (stopped) break;
|
|
6235
|
+
await new Promise((resolve2) => {
|
|
6236
|
+
const timer = setTimeout(resolve2, interval);
|
|
6237
|
+
wake = () => {
|
|
6238
|
+
clearTimeout(timer);
|
|
6239
|
+
resolve2();
|
|
6240
|
+
};
|
|
6241
|
+
});
|
|
6234
6242
|
}
|
|
6243
|
+
} finally {
|
|
6244
|
+
process.removeListener("SIGINT", stop);
|
|
6245
|
+
process.removeListener("SIGTERM", stop);
|
|
6235
6246
|
}
|
|
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
6247
|
}
|
|
6245
6248
|
|
|
6246
6249
|
// src/commands/whoami.ts
|
|
@@ -6284,14 +6287,14 @@ function getVersion() {
|
|
|
6284
6287
|
}
|
|
6285
6288
|
function printHelp() {
|
|
6286
6289
|
console.log(`
|
|
6287
|
-
\x1B[1mtoken-rats\x1B[0m \u2014
|
|
6290
|
+
\x1B[1mtoken-rats\x1B[0m \u2014 AI usage tracker and community \x1B[2mv${getVersion()}\x1B[0m
|
|
6288
6291
|
|
|
6289
6292
|
\x1B[1mUsage:\x1B[0m
|
|
6290
6293
|
token-rats <command> [flags]
|
|
6291
6294
|
|
|
6292
6295
|
\x1B[1mCommands:\x1B[0m
|
|
6293
6296
|
login Authenticate with Token Rats (opens browser); installs the background watcher by default
|
|
6294
|
-
sync Read local Claude Code + Cursor logs and upload counts
|
|
6297
|
+
sync Read local Claude Code, Codex + Cursor logs and upload counts
|
|
6295
6298
|
watch Watch logs in real-time; upload new sessions as they appear
|
|
6296
6299
|
whoami Show the currently signed-in account + device id
|
|
6297
6300
|
logout Clear your stored credentials
|
|
@@ -6310,7 +6313,7 @@ function printHelp() {
|
|
|
6310
6313
|
--verbose Print discovered files and per-file record counts
|
|
6311
6314
|
|
|
6312
6315
|
\x1B[1mFlags (watch only):\x1B[0m
|
|
6313
|
-
--interval <ms>
|
|
6316
|
+
--interval <ms> Scan interval in ms (default: 30000, minimum: 1000)
|
|
6314
6317
|
--verbose Print file change events and upload detail
|
|
6315
6318
|
|
|
6316
6319
|
\x1B[1mPrivacy:\x1B[0m
|
|
@@ -6397,7 +6400,7 @@ async function main() {
|
|
|
6397
6400
|
await installCursorCommand();
|
|
6398
6401
|
break;
|
|
6399
6402
|
case "install-daemon":
|
|
6400
|
-
await installDaemonCommand();
|
|
6403
|
+
await installDaemonCommand(apiUrl);
|
|
6401
6404
|
break;
|
|
6402
6405
|
case "uninstall-daemon":
|
|
6403
6406
|
await uninstallDaemonCommand();
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "token-rats",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
5
|
-
"license": "
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Track Claude Code, Codex, and Cursor usage and compare AI subscriptions.",
|
|
5
|
+
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"token-rats": "./dist/index.js"
|
|
9
9
|
},
|
|
10
|
-
"files": ["dist", "README.md"],
|
|
10
|
+
"files": ["dist", "README.md", "LICENSE"],
|
|
11
11
|
"repository": {
|
|
12
12
|
"type": "git",
|
|
13
13
|
"url": "git+https://github.com/hsalberti/token-rats.git",
|
|
@@ -26,7 +26,6 @@
|
|
|
26
26
|
"prepublishOnly": "node build.mjs"
|
|
27
27
|
},
|
|
28
28
|
"optionalDependencies": {
|
|
29
|
-
"chokidar": "^3.6.0",
|
|
30
29
|
"clipboardy": "^4.0.0",
|
|
31
30
|
"open": "^10.1.0"
|
|
32
31
|
},
|