tokmax 0.1.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/README.md +11 -6
- package/bin/tokmax.mjs +216 -19
- package/package.json +1 -1
- package/src/adapters/claude-code.mjs +16 -1
package/README.md
CHANGED
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
переводит это в **API-equivalent** доллары и публикует агрегат на публичный
|
|
5
5
|
лидерборд [tokenmax.ru](https://tokenmax.ru).
|
|
6
6
|
|
|
7
|
-
Одной
|
|
7
|
+
Одной командой — короткий онбординг (2 шага с прогресс-баром: ник → как считать):
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npx tokmax
|
|
10
|
+
npx tokmax
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
Или сразу с ником, без вопросов: `npx tokmax <nick>`. После публикации твой
|
|
14
|
+
профиль виден на `https://tokenmax.ru/<nick>`.
|
|
14
15
|
|
|
15
16
|
## Что именно уходит наружу (и что — нет)
|
|
16
17
|
|
|
@@ -31,19 +32,23 @@ npx tokmax <nick>
|
|
|
31
32
|
Node и глобальный `fetch`).
|
|
32
33
|
|
|
33
34
|
```bash
|
|
34
|
-
# разово, без установки
|
|
35
|
+
# разово, без установки — запустит онбординг (ник + как считать)
|
|
36
|
+
npx tokmax
|
|
37
|
+
|
|
38
|
+
# или сразу с ником
|
|
35
39
|
npx tokmax <nick>
|
|
36
40
|
|
|
37
41
|
# или глобально
|
|
38
42
|
npm i -g tokmax
|
|
39
|
-
|
|
43
|
+
tokmax
|
|
40
44
|
```
|
|
41
45
|
|
|
42
46
|
### Опции
|
|
43
47
|
|
|
44
48
|
```text
|
|
45
|
-
|
|
49
|
+
tokmax [<nick>] [options]
|
|
46
50
|
|
|
51
|
+
--onboard принудительно запустить онбординг
|
|
47
52
|
--since YYYY-MM-DD считать только с этого дня (по умолчанию — вся история)
|
|
48
53
|
--key <secret> capability-токен для обновления уже занятого ника
|
|
49
54
|
--api <baseUrl> базовый URL API
|
package/bin/tokmax.mjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// tokmax — scan local Codex + Claude Code logs, aggregate per-model token
|
|
4
|
+
// usage, preview the API-equivalent $, and publish the aggregate to the
|
|
5
5
|
// tokenmax leaderboard (tokenmax.ru).
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
// npx tokmax
|
|
7
|
+
// Two ways to run:
|
|
8
|
+
// npx tokmax → short interactive onboarding (2 steps, progress bar)
|
|
9
|
+
// npx tokmax <nick> → fast direct path (no prompts)
|
|
9
10
|
//
|
|
10
11
|
// SAFETY INVARIANT: only numeric token aggregates per model + dates leave this
|
|
11
12
|
// machine. Never prompt text, file contents, API keys, or raw log lines.
|
|
@@ -24,6 +25,7 @@ import { publish } from '../src/publish.mjs';
|
|
|
24
25
|
import { loadSecret, saveSecret } from '../src/secrets.mjs';
|
|
25
26
|
|
|
26
27
|
const DEFAULT_API = 'https://chatty-boar-479.convex.site';
|
|
28
|
+
const PAGE_BASE = 'https://tokenmax.vibecoding.ru'; // canonical served page (availability check)
|
|
27
29
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
28
30
|
|
|
29
31
|
function parseArgs(argv) {
|
|
@@ -33,8 +35,11 @@ function parseArgs(argv) {
|
|
|
33
35
|
key: null,
|
|
34
36
|
api: DEFAULT_API,
|
|
35
37
|
machine: os.hostname(),
|
|
38
|
+
sources: null, // null = both; { claude, codex }
|
|
39
|
+
subscriptionUsd: null,
|
|
36
40
|
dryRun: false,
|
|
37
41
|
yes: false,
|
|
42
|
+
onboard: false,
|
|
38
43
|
help: false,
|
|
39
44
|
};
|
|
40
45
|
const rest = [];
|
|
@@ -56,6 +61,9 @@ function parseArgs(argv) {
|
|
|
56
61
|
case '--dry-run':
|
|
57
62
|
opts.dryRun = true;
|
|
58
63
|
break;
|
|
64
|
+
case '--onboard':
|
|
65
|
+
opts.onboard = true;
|
|
66
|
+
break;
|
|
59
67
|
case '--yes':
|
|
60
68
|
case '-y':
|
|
61
69
|
opts.yes = true;
|
|
@@ -77,17 +85,18 @@ function parseArgs(argv) {
|
|
|
77
85
|
return opts;
|
|
78
86
|
}
|
|
79
87
|
|
|
80
|
-
const HELP = `tokmax
|
|
88
|
+
const HELP = `tokmax — публичный счётчик API-equivalent расхода токенов
|
|
81
89
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
90
|
+
Запуск:
|
|
91
|
+
npx tokmax короткий онбординг (2 шага, прогресс-бар)
|
|
92
|
+
npx tokmax <nick> быстрый прямой путь без вопросов
|
|
85
93
|
|
|
86
94
|
Options:
|
|
87
95
|
--since YYYY-MM-DD считать только с этого дня (по умолчанию — вся история)
|
|
88
96
|
--key <secret> capability-токен для обновления уже занятого ника
|
|
89
97
|
--api <baseUrl> базовый URL API (по умолчанию tokenmax deployment)
|
|
90
98
|
--machine <label> метка машины (по умолчанию hostname)
|
|
99
|
+
--onboard принудительно запустить онбординг
|
|
91
100
|
--dry-run показать превью и тело запроса, ничего не публиковать
|
|
92
101
|
--yes, -y не спрашивать подтверждение
|
|
93
102
|
--help, -h показать эту справку`;
|
|
@@ -114,6 +123,15 @@ async function readPackageVersion() {
|
|
|
114
123
|
}
|
|
115
124
|
}
|
|
116
125
|
|
|
126
|
+
function readAllStdin() {
|
|
127
|
+
return new Promise((resolve) => {
|
|
128
|
+
let data = '';
|
|
129
|
+
process.stdin.setEncoding('utf8');
|
|
130
|
+
process.stdin.on('data', (c) => (data += c));
|
|
131
|
+
process.stdin.on('end', () => resolve(data));
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
117
135
|
function confirm(question) {
|
|
118
136
|
return new Promise((resolve) => {
|
|
119
137
|
const rl = readline.createInterface({
|
|
@@ -122,11 +140,144 @@ function confirm(question) {
|
|
|
122
140
|
});
|
|
123
141
|
rl.question(question, (answer) => {
|
|
124
142
|
rl.close();
|
|
125
|
-
resolve(/^y(es)
|
|
143
|
+
resolve(/^(y(es)?|д(а)?)$/i.test(answer.trim()));
|
|
126
144
|
});
|
|
127
145
|
});
|
|
128
146
|
}
|
|
129
147
|
|
|
148
|
+
// ── Onboarding ──────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
function progressBar(step, total) {
|
|
151
|
+
const cells = 14;
|
|
152
|
+
const filled = Math.round((step / total) * cells);
|
|
153
|
+
return `[${'█'.repeat(filled)}${'░'.repeat(cells - filled)}] шаг ${step}/${total}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function validateNick(raw) {
|
|
157
|
+
const nick = String(raw || '').trim();
|
|
158
|
+
if (!nick) return { ok: false, msg: 'пустой ник' };
|
|
159
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{1,31}$/.test(nick)) {
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
msg: 'только латиница/цифры/дефис/подчёркивание, 2–32 символа, начинается с буквы или цифры',
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return { ok: true, nick };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function checkAvailability(nick) {
|
|
169
|
+
try {
|
|
170
|
+
const ctrl = new AbortController();
|
|
171
|
+
const timer = setTimeout(() => ctrl.abort(), 6000);
|
|
172
|
+
const res = await fetch(`${PAGE_BASE}/${encodeURIComponent(nick)}`, {
|
|
173
|
+
method: 'GET',
|
|
174
|
+
redirect: 'manual',
|
|
175
|
+
signal: ctrl.signal,
|
|
176
|
+
});
|
|
177
|
+
clearTimeout(timer);
|
|
178
|
+
if (res.status === 200) return 'taken';
|
|
179
|
+
if (res.status === 404) return 'free';
|
|
180
|
+
return 'unknown';
|
|
181
|
+
} catch {
|
|
182
|
+
return 'unknown';
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function runOnboarding(cliVersion) {
|
|
187
|
+
// Adaptive input: real terminal → readline; piped (testing/scripting) →
|
|
188
|
+
// pre-buffered lines (readline closes on a piped stream's EOF mid-flow).
|
|
189
|
+
const isTty = Boolean(process.stdin.isTTY);
|
|
190
|
+
const rl = isTty
|
|
191
|
+
? readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
192
|
+
: null;
|
|
193
|
+
const buffered = isTty ? [] : (await readAllStdin()).split('\n');
|
|
194
|
+
let bi = 0;
|
|
195
|
+
const ask = (q) => {
|
|
196
|
+
if (isTty) return new Promise((r) => rl.question(q, r));
|
|
197
|
+
process.stdout.write(q);
|
|
198
|
+
const ans = buffered[bi++] ?? '';
|
|
199
|
+
process.stdout.write(`${ans}\n`);
|
|
200
|
+
return Promise.resolve(ans);
|
|
201
|
+
};
|
|
202
|
+
const config = {
|
|
203
|
+
nick: null,
|
|
204
|
+
since: null,
|
|
205
|
+
sources: { claude: true, codex: true },
|
|
206
|
+
subscriptionUsd: null,
|
|
207
|
+
};
|
|
208
|
+
try {
|
|
209
|
+
console.log(`\n tokmax v${cliVersion} — публичный счётчик твоих токенов`);
|
|
210
|
+
console.log(` Соберём твою страницу на ${PAGE_BASE.replace('https://', '')}/<ник>.\n`);
|
|
211
|
+
|
|
212
|
+
// ── Step 1/2 — ник ──
|
|
213
|
+
console.log(progressBar(1, 2));
|
|
214
|
+
for (;;) {
|
|
215
|
+
const raw = await ask('Шаг 1/2 · придумай ник: ');
|
|
216
|
+
const v = validateNick(raw);
|
|
217
|
+
if (!v.ok) {
|
|
218
|
+
console.log(` ✗ ${v.msg}\n`);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
process.stdout.write(' проверяю занятость…');
|
|
222
|
+
const avail = await checkAvailability(v.nick.toLowerCase());
|
|
223
|
+
process.stdout.write('\r\x1b[K');
|
|
224
|
+
if (avail === 'taken') {
|
|
225
|
+
const a = await ask(
|
|
226
|
+
rl,
|
|
227
|
+
` ✗ «${v.nick}» уже занят. [д] это мой — обновить · [Enter] выбрать другой: `,
|
|
228
|
+
);
|
|
229
|
+
if (/^(д(а)?|y(es)?)$/i.test(a.trim())) {
|
|
230
|
+
config.nick = v.nick;
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
console.log('');
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
console.log(avail === 'free' ? ` ✓ «${v.nick}» свободен\n` : ` · беру «${v.nick}»\n`);
|
|
237
|
+
config.nick = v.nick;
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── Step 2/2 — режим ──
|
|
242
|
+
console.log(progressBar(2, 2));
|
|
243
|
+
console.log('Шаг 2/2 · как считать?');
|
|
244
|
+
console.log(' [1] По умолчанию — вся история, Codex + Claude Code (рекомендую)');
|
|
245
|
+
console.log(' [2] Настроить — период, источники, подписка');
|
|
246
|
+
const mode = (await ask(' выбор [1/2, Enter=1]: ')).trim();
|
|
247
|
+
|
|
248
|
+
if (mode === '2') {
|
|
249
|
+
// период
|
|
250
|
+
for (;;) {
|
|
251
|
+
const s = (await ask(' период — с какой даты? (YYYY-MM-DD, Enter = вся история): ')).trim();
|
|
252
|
+
if (!s) break;
|
|
253
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) {
|
|
254
|
+
config.since = s;
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
console.log(' ✗ формат YYYY-MM-DD');
|
|
258
|
+
}
|
|
259
|
+
// источники
|
|
260
|
+
const src = (
|
|
261
|
+
await ask(' источники — [Enter] оба · [c] только Claude Code · [x] только Codex: ')
|
|
262
|
+
)
|
|
263
|
+
.trim()
|
|
264
|
+
.toLowerCase();
|
|
265
|
+
if (src === 'c') config.sources = { claude: true, codex: false };
|
|
266
|
+
else if (src === 'x') config.sources = { claude: false, codex: true };
|
|
267
|
+
// подписка
|
|
268
|
+
const sub = (await ask(' сколько платишь за подписки в месяц, $? (Enter = пропустить): ')).trim();
|
|
269
|
+
const n = Number(sub.replace(/[^0-9.]/g, ''));
|
|
270
|
+
if (Number.isFinite(n) && n > 0) config.subscriptionUsd = n;
|
|
271
|
+
}
|
|
272
|
+
console.log('');
|
|
273
|
+
} finally {
|
|
274
|
+
if (rl) rl.close();
|
|
275
|
+
}
|
|
276
|
+
return config;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── Main ────────────────────────────────────────────────────────────────────
|
|
280
|
+
|
|
130
281
|
async function main() {
|
|
131
282
|
const opts = parseArgs(process.argv.slice(2));
|
|
132
283
|
|
|
@@ -134,8 +285,24 @@ async function main() {
|
|
|
134
285
|
console.log(HELP);
|
|
135
286
|
return 0;
|
|
136
287
|
}
|
|
288
|
+
|
|
289
|
+
const cliVersion = await readPackageVersion();
|
|
290
|
+
|
|
291
|
+
// No nick + interactive terminal (or --onboard) → run the onboarding.
|
|
292
|
+
if (opts.onboard || (!opts.nick && process.stdin.isTTY)) {
|
|
293
|
+
const cfg = await runOnboarding(cliVersion);
|
|
294
|
+
if (!cfg.nick) {
|
|
295
|
+
console.error('Онбординг отменён.');
|
|
296
|
+
return 1;
|
|
297
|
+
}
|
|
298
|
+
opts.nick = cfg.nick;
|
|
299
|
+
opts.since = cfg.since;
|
|
300
|
+
opts.sources = cfg.sources;
|
|
301
|
+
opts.subscriptionUsd = cfg.subscriptionUsd;
|
|
302
|
+
}
|
|
303
|
+
|
|
137
304
|
if (!opts.nick) {
|
|
138
|
-
console.error('Укажи ник: npx tokmax <nick> (
|
|
305
|
+
console.error('Укажи ник: npx tokmax <nick> (или запусти npx tokmax без аргументов)');
|
|
139
306
|
return 2;
|
|
140
307
|
}
|
|
141
308
|
if (opts.since && !/^\d{4}-\d{2}-\d{2}$/.test(opts.since)) {
|
|
@@ -145,22 +312,36 @@ async function main() {
|
|
|
145
312
|
|
|
146
313
|
const nick = opts.nick.trim();
|
|
147
314
|
const nickKey = nick.toLowerCase();
|
|
148
|
-
const cliVersion = await readPackageVersion();
|
|
149
315
|
|
|
150
316
|
console.log(`tokmax v${cliVersion} · ник: ${nick}`);
|
|
151
317
|
console.log('Сканирую локальные логи…');
|
|
152
318
|
|
|
153
|
-
const
|
|
154
|
-
|
|
319
|
+
const sources = opts.sources || { claude: true, codex: true };
|
|
320
|
+
const tasks = [];
|
|
321
|
+
if (sources.claude) tasks.push(scanClaudeCode().then((r) => ({ tool: 'claude', r })));
|
|
322
|
+
if (sources.codex) tasks.push(scanCodex().then((r) => ({ tool: 'codex', r })));
|
|
323
|
+
if (!tasks.length) {
|
|
324
|
+
console.error('Не выбрано ни одного источника.');
|
|
325
|
+
return 1;
|
|
326
|
+
}
|
|
327
|
+
const wrapped = await Promise.all(tasks);
|
|
328
|
+
const scanned = wrapped.map((w) => w.r);
|
|
155
329
|
console.log(
|
|
156
|
-
|
|
330
|
+
' ' +
|
|
331
|
+
wrapped
|
|
332
|
+
.map((w) =>
|
|
333
|
+
w.tool === 'claude'
|
|
334
|
+
? `Claude Code: ${w.r.sessionCount} сессий`
|
|
335
|
+
: `Codex: ${w.r.sessionCount} сессий`,
|
|
336
|
+
)
|
|
337
|
+
.join(' · '),
|
|
157
338
|
);
|
|
158
339
|
|
|
159
|
-
const agg = aggregate(
|
|
340
|
+
const agg = aggregate(scanned, { since: opts.since });
|
|
160
341
|
|
|
161
342
|
if (!agg.models.length || agg.totalTokens === 0) {
|
|
162
343
|
console.error(
|
|
163
|
-
'Не нашёл токенов в логах (с учётом
|
|
344
|
+
'Не нашёл токенов в логах (с учётом фильтров). Публиковать нечего.',
|
|
164
345
|
);
|
|
165
346
|
return 1;
|
|
166
347
|
}
|
|
@@ -176,8 +357,8 @@ async function main() {
|
|
|
176
357
|
const usd = previewCost(pricing, agg.models);
|
|
177
358
|
|
|
178
359
|
console.log(
|
|
179
|
-
`Период: ${agg.firstDay} → ${agg.lastDay}
|
|
180
|
-
(opts.since ? ` · фильтр
|
|
360
|
+
`Период: ${agg.firstDay} → ${agg.lastDay}` +
|
|
361
|
+
(opts.since ? ` · фильтр с ${opts.since}` : ' (всё, что нашлось)'),
|
|
181
362
|
);
|
|
182
363
|
console.log('Модели:');
|
|
183
364
|
for (const m of agg.models) {
|
|
@@ -186,6 +367,22 @@ async function main() {
|
|
|
186
367
|
}
|
|
187
368
|
console.log(`Всего токенов: ${fmtInt(agg.totalTokens)}`);
|
|
188
369
|
console.log(`API-equivalent: $${fmtUsd(usd)}`);
|
|
370
|
+
|
|
371
|
+
// Subscription comparison (CLI-side preview of the flex).
|
|
372
|
+
if (opts.subscriptionUsd && agg.firstDay && agg.lastDay) {
|
|
373
|
+
const days = Math.max(
|
|
374
|
+
1,
|
|
375
|
+
Math.round((Date.parse(agg.lastDay) - Date.parse(agg.firstDay)) / 86400000) + 1,
|
|
376
|
+
);
|
|
377
|
+
const months = Math.max(1, days / 30);
|
|
378
|
+
const subTotal = opts.subscriptionUsd * months;
|
|
379
|
+
const ratio = subTotal > 0 ? usd / subTotal : 0;
|
|
380
|
+
console.log(
|
|
381
|
+
`Подписка: $${fmtUsd(opts.subscriptionUsd)}/мес × ${months.toFixed(1)} мес ≈ $${fmtUsd(subTotal)} → ` +
|
|
382
|
+
`API-equivalent отбил подписку в ${ratio.toFixed(1)}×`,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
189
386
|
console.log('Наружу уйдёт только агрегат (числа), не логи и не ключи.');
|
|
190
387
|
|
|
191
388
|
const body = {
|
|
@@ -243,7 +440,7 @@ async function main() {
|
|
|
243
440
|
if (json.suspicious) {
|
|
244
441
|
console.log('⚠️ Сервер пометил сабмит как suspicious (на ручную проверку).');
|
|
245
442
|
}
|
|
246
|
-
console.log(`\n ${json.url}\n`);
|
|
443
|
+
console.log(`\n Готово! Твоя страница: ${json.url}\n`);
|
|
247
444
|
return 0;
|
|
248
445
|
}
|
|
249
446
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokmax",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Scan your local Codex + Claude Code logs, aggregate per-model token usage, and publish the API-equivalent $ to the tokenmax leaderboard (tokenmax.ru). Aggregates only — never your prompts, files, or keys.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -40,6 +40,12 @@ async function claudeProjectRoots() {
|
|
|
40
40
|
export async function scanClaudeCode() {
|
|
41
41
|
const records = [];
|
|
42
42
|
let sessionCount = 0;
|
|
43
|
+
// Dedup assistant turns by message.id: Claude Code resume/branch/continue
|
|
44
|
+
// replays prior turns (with their original usage) into new session files, so
|
|
45
|
+
// summing every file's assistant lines double-counts. message.id is unique per
|
|
46
|
+
// API response, so one id == one billed turn no matter how many files replay it.
|
|
47
|
+
const seenIds = new Set();
|
|
48
|
+
let duplicates = 0;
|
|
43
49
|
|
|
44
50
|
for (const root of await claudeProjectRoots()) {
|
|
45
51
|
for await (const file of walkJsonl(root)) {
|
|
@@ -68,6 +74,15 @@ export async function scanClaudeCode() {
|
|
|
68
74
|
const date = isoDay(d.timestamp);
|
|
69
75
|
if (!date) continue;
|
|
70
76
|
|
|
77
|
+
const id = msg.id;
|
|
78
|
+
if (id) {
|
|
79
|
+
if (seenIds.has(id)) {
|
|
80
|
+
duplicates++;
|
|
81
|
+
continue; // already counted this billed turn (replayed in another file)
|
|
82
|
+
}
|
|
83
|
+
seenIds.add(id);
|
|
84
|
+
}
|
|
85
|
+
|
|
71
86
|
records.push({
|
|
72
87
|
tool: 'claude-code',
|
|
73
88
|
model,
|
|
@@ -82,5 +97,5 @@ export async function scanClaudeCode() {
|
|
|
82
97
|
}
|
|
83
98
|
}
|
|
84
99
|
|
|
85
|
-
return { tool: 'claude-code', sessionCount, records };
|
|
100
|
+
return { tool: 'claude-code', sessionCount, records, duplicates };
|
|
86
101
|
}
|