tokmax 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +11 -6
  2. package/bin/tokmax.mjs +216 -19
  3. package/package.json +1 -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 <nick>
10
+ npx tokmax
11
11
  ```
12
12
 
13
- После публикации твой профиль виден на `https://tokenmax.ru/<nick>`.
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
- tokenmax <nick>
43
+ tokmax
40
44
  ```
41
45
 
42
46
  ### Опции
43
47
 
44
48
  ```text
45
- tokenmax <nick> [options]
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
- // tokenmax — scan local Codex + Claude Code logs, aggregate per-model
4
- // token usage, preview the API-equivalent $, and publish the aggregate to the
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
- // Happy path is one command:
8
- // npx tokmax <nick>
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 <nick> [options]
88
+ const HELP = `tokmax публичный счётчик API-equivalent расхода токенов
81
89
 
82
- Сканирует локальные логи Codex и Claude Code, считает токены по моделям,
83
- показывает API-equivalent в долларах и публикует агрегат на лидерборд
84
- tokenmax.ru. Наружу уходят только числа не логи и не ключи.
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)?$/i.test(answer.trim()));
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> (--help для справки)');
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 [claude, codex] = await Promise.all([scanClaudeCode(), scanCodex()]);
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
- ` Claude Code: ${claude.sessionCount} сессий · Codex: ${codex.sessionCount} сессий`,
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([claude, codex], { since: opts.since });
340
+ const agg = aggregate(scanned, { since: opts.since });
160
341
 
161
342
  if (!agg.models.length || agg.totalTokens === 0) {
162
343
  console.error(
163
- 'Не нашёл токенов в логах (с учётом --since). Публиковать нечего.',
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 ? ` · фильтр --since ${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.1.0",
3
+ "version": "0.2.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": {