@tokz/cli 0.2.3 → 0.2.4

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 CHANGED
@@ -4,9 +4,14 @@ Audit where your coding agents' context windows — and API dollars — actually
4
4
 
5
5
  Supports multiple agentic coders: **Claude Code** (`~/.claude/projects`),
6
6
  **OpenAI Codex CLI** (`~/.codex/sessions` rollout files, cumulative
7
- token-count deltas), and **OpenCode** (`~/.local/share/opencode/storage`).
8
- Gemini CLI and Cursor CLI are detected and listed; their session formats
9
- aren't parsed yet. The TUI opens with an agent picker choose which agent's
7
+ token-count deltas), **OpenCode** (`~/.local/share/opencode/storage`), and
8
+ **Antigravity CLI** (`~/.gemini/antigravity-cli`). Antigravity stores no
9
+ token counts on disk, so its numbers are *estimates*: per-model turn counts
10
+ are read from the conversation databases' generation-metadata records,
11
+ projects and dates from `history.jsonl`, and tokens are derived from
12
+ conversation content size (~4 chars/token) — the UI labels it "estimated"
13
+ everywhere. Cursor CLI is detected and listed with the reason it can't be
14
+ parsed yet. The TUI opens with an agent picker — choose which agent's
10
15
  analytics to explore; everything downstream (projects, dashboards,
11
16
  timeframes) is scoped to it.
12
17
 
@@ -14,11 +19,55 @@ timeframes) is scoped to it.
14
19
  npx @tokz/cli # interactive TUI: browse projects, drill into charts
15
20
  npx @tokz/cli audit # static report for the current project
16
21
  npx @tokz/cli audit --all # static report across every project on this machine
22
+ npx @tokz/cli blocks # Claude usage by rolling 5-hour billing window
17
23
 
18
24
  npm i -g @tokz/cli # installs the `tokz` command
19
25
  tokz # then just: tokz
20
26
  ```
21
27
 
28
+ ## Blocks (5-hour billing windows)
29
+
30
+ `tokz blocks` groups Claude usage into the rolling 5-hour windows Claude's
31
+ usage limits operate on: one row per block with models, total tokens, and
32
+ cost, plus burn rate (tok/min and $/hr), projected tokens/cost by block end,
33
+ and time remaining for the active block. `--active` shows only the current
34
+ block, `--recent` the last 3 days, `--token-limit N` (or `max` — your
35
+ biggest past block) adds ⚠️/🚨 warnings, `--session-length H` changes the
36
+ window, `--json` for raw output.
37
+
38
+ ## Statusline for Claude Code
39
+
40
+ `tokz statusline` renders one compact line for Claude Code's status bar —
41
+ model, session cost, today's total, active block cost with time left, burn
42
+ rate, and context usage with percentage. One command wires it up:
43
+
44
+ ```bash
45
+ tokz statusline enable # writes the hook into ~/.claude/settings.json
46
+ tokz statusline disable # removes it (only if it's tokz's)
47
+ ```
48
+
49
+ `enable` preserves everything else in your settings file (and refuses to
50
+ touch one it can't parse); it's equivalent to:
51
+
52
+ ```json
53
+ {
54
+ "statusLine": { "type": "command", "command": "npx -y @tokz/cli statusline" }
55
+ }
56
+ ```
57
+
58
+ It reads only transcripts touched in the last few hours and never fetches
59
+ pricing from the network on this path, so it stays fast.
60
+
61
+ ## Dates, ranges, and timezones
62
+
63
+ - `tokz audit --since 2026-07-01 --until 2026-07-15` — any inclusive date
64
+ range (also accepts `YYYYMMDD`); `--days N` still works.
65
+ - `tokz audit --weekly` / `--monthly` — appends an activity table rolled up
66
+ by ISO week (keyed by its Monday) or calendar month. In the TUI the
67
+ Activity tab cycles day · week · month with `g`.
68
+ - `tokz --timezone local audit …` — group days in your system timezone, or
69
+ any IANA zone (`--timezone Asia/Amman`); default is UTC.
70
+
22
71
  ## Interactive TUI
23
72
 
24
73
  Bare `tokz` launches a full-screen terminal UI (Ink) with a live parse
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ findMcpServers
4
+ } from "./chunk-65BQE6TI.js";
2
5
  import {
3
6
  addUsage,
4
7
  buildReport,
@@ -6,9 +9,10 @@ import {
6
9
  cacheSavings,
7
10
  compact,
8
11
  costUsd,
12
+ dayKey,
9
13
  duration,
10
14
  emptyUsage,
11
- findMcpServers,
15
+ groupDaily,
12
16
  parseTranscript,
13
17
  pct,
14
18
  pct1,
@@ -16,19 +20,19 @@ import {
16
20
  sanitizeProjectPath,
17
21
  shortModel,
18
22
  usd
19
- } from "./chunk-JAZOB7NC.js";
23
+ } from "./chunk-Z5W5QH2Z.js";
20
24
 
21
25
  // src/ui/Root.tsx
22
26
  import { useEffect as useEffect2, useState as useState6 } from "react";
23
27
  import { Box as Box10, Text as Text11, useApp as useApp2 } from "ink";
24
28
 
25
29
  // src/agents/index.ts
26
- import { access as access4 } from "fs/promises";
27
- import { homedir as homedir5 } from "os";
28
- import { join as join5 } from "path";
30
+ import { access as access5 } from "fs/promises";
31
+ import { homedir as homedir6 } from "os";
32
+ import { join as join6 } from "path";
29
33
 
30
- // src/agents/claude.ts
31
- import { access } from "fs/promises";
34
+ // src/agents/antigravity.ts
35
+ import { access, readFile as readFile2, readdir } from "fs/promises";
32
36
  import { homedir as homedir2 } from "os";
33
37
  import { join as join2 } from "path";
34
38
 
@@ -175,14 +179,187 @@ async function loadProjects(home = homedir(), onProgress) {
175
179
  return out;
176
180
  }
177
181
 
182
+ // src/agents/antigravity.ts
183
+ var ESTIMATED_CHARS_PER_TOKEN = 4;
184
+ var INPUT_SHARE = 0.85;
185
+ function antigravityRoot(home) {
186
+ return join2(home ?? homedir2(), ".gemini", "antigravity-cli");
187
+ }
188
+ function modelIdFromDisplayName(name) {
189
+ const bare = name.replace(/\s*\(.*\)\s*$/, "").trim();
190
+ const slug = bare.toLowerCase().replace(/\s+/g, "-");
191
+ return slug.startsWith("claude") ? slug.replace(/\./g, "-") : slug;
192
+ }
193
+ var MODEL_NAME_RX = /(Gemini|Claude|GPT)[- ][0-9][0-9A-Za-z. ]{0,25}(\((Low|Medium|High|Max|Thinking)\))?/;
194
+ function scanConversation(buf) {
195
+ const text = buf.toString("latin1");
196
+ const turnsByModel = {};
197
+ const anchor = /used_claude_conservative/g;
198
+ let m;
199
+ while (m = anchor.exec(text)) {
200
+ const window = text.slice(m.index + 24, m.index + 250);
201
+ const name = window.match(MODEL_NAME_RX);
202
+ const key = name ? modelIdFromDisplayName(name[0]) : "";
203
+ turnsByModel[key] = (turnsByModel[key] ?? 0) + 1;
204
+ }
205
+ let textChars = 0;
206
+ let start = -1;
207
+ for (let i = 0; i <= buf.length; i++) {
208
+ const c = i < buf.length ? buf[i] : 0;
209
+ const printable = c >= 32 && c < 127;
210
+ if (printable && start < 0) start = i;
211
+ if (!printable && start >= 0) {
212
+ if (i - start >= 20) textChars += i - start;
213
+ start = -1;
214
+ }
215
+ }
216
+ return { turnsByModel, textChars };
217
+ }
218
+ async function readHistory(root) {
219
+ try {
220
+ const raw = await readFile2(join2(root, "history.jsonl"), "utf8");
221
+ const out = [];
222
+ for (const line of raw.split("\n")) {
223
+ if (!line.trim()) continue;
224
+ try {
225
+ out.push(JSON.parse(line));
226
+ } catch {
227
+ }
228
+ }
229
+ return out;
230
+ } catch {
231
+ return [];
232
+ }
233
+ }
234
+ async function loadAntigravityProjects(home, onProgress) {
235
+ const root = antigravityRoot(home);
236
+ const convDir = join2(root, "conversations");
237
+ let dbFiles;
238
+ try {
239
+ dbFiles = (await readdir(convDir)).filter((f) => f.endsWith(".db"));
240
+ } catch {
241
+ return [];
242
+ }
243
+ if (dbFiles.length === 0) return [];
244
+ const history = await readHistory(root);
245
+ const byConversation = /* @__PURE__ */ new Map();
246
+ for (const e of history) {
247
+ if (!e.conversationId || !e.timestamp) continue;
248
+ const c = byConversation.get(e.conversationId) ?? {};
249
+ c.workspace ??= e.workspace;
250
+ if (!c.firstTs || e.timestamp < c.firstTs) c.firstTs = e.timestamp;
251
+ if (!c.lastTs || e.timestamp > c.lastTs) c.lastTs = e.timestamp;
252
+ byConversation.set(e.conversationId, c);
253
+ }
254
+ const sessions = [];
255
+ let parsed = 0;
256
+ for (const file of dbFiles) {
257
+ onProgress?.({ parsed, total: dbFiles.length, currentProject: "Antigravity conversations" });
258
+ let scan;
259
+ try {
260
+ scan = scanConversation(await readFile2(join2(convDir, file)));
261
+ } catch {
262
+ parsed += 1;
263
+ continue;
264
+ }
265
+ parsed += 1;
266
+ onProgress?.({ parsed, total: dbFiles.length, currentProject: "Antigravity conversations" });
267
+ const named = Object.entries(scan.turnsByModel).filter(([k]) => k !== "");
268
+ named.sort((a, b) => b[1] - a[1]);
269
+ const fallback = named[0]?.[0] ?? "antigravity-unknown";
270
+ const turnsByModel = {};
271
+ for (const [model, turns] of Object.entries(scan.turnsByModel)) {
272
+ const key = model === "" ? fallback : model;
273
+ turnsByModel[key] = (turnsByModel[key] ?? 0) + turns;
274
+ }
275
+ const totalTurns = Object.values(turnsByModel).reduce((s, n) => s + n, 0);
276
+ if (totalTurns === 0) continue;
277
+ const conversationId = file.replace(/\.db$/, "");
278
+ const meta = byConversation.get(conversationId);
279
+ const firstTs = meta?.firstTs ? new Date(meta.firstTs).toISOString() : void 0;
280
+ const lastTs = meta?.lastTs ? new Date(meta.lastTs).toISOString() : void 0;
281
+ const day = (lastTs ?? firstTs)?.slice(0, 10);
282
+ const estTokens = Math.round(scan.textChars / ESTIMATED_CHARS_PER_TOKEN);
283
+ const stats = {
284
+ file: conversationId,
285
+ cwd: meta?.workspace,
286
+ firstTs,
287
+ lastTs,
288
+ usageByModel: {},
289
+ toolCalls: {},
290
+ toolCostUsd: {},
291
+ dailyUsage: {}
292
+ };
293
+ for (const [model, turns] of Object.entries(turnsByModel)) {
294
+ const share = turns / totalTurns;
295
+ const u = stats.usageByModel[model] ??= emptyUsage();
296
+ u.inputTokens += Math.round(estTokens * share * INPUT_SHARE);
297
+ u.outputTokens += Math.round(estTokens * share * (1 - INPUT_SHARE));
298
+ u.turns += turns;
299
+ if (day) {
300
+ const d = (stats.dailyUsage[day] ??= {})[model] ??= emptyUsage();
301
+ d.inputTokens += u.inputTokens;
302
+ d.outputTokens += u.outputTokens;
303
+ d.turns += turns;
304
+ }
305
+ }
306
+ sessions.push(stats);
307
+ }
308
+ const byDir = /* @__PURE__ */ new Map();
309
+ for (const s of sessions) {
310
+ const key = s.cwd ?? "(unknown project)";
311
+ const list = byDir.get(key) ?? [];
312
+ list.push(s);
313
+ byDir.set(key, list);
314
+ }
315
+ const out = [];
316
+ for (const [dir, list] of byDir) {
317
+ out.push({
318
+ id: `antigravity:${dir}`,
319
+ name: dir,
320
+ label: dir === "(unknown project)" ? dir : baseName(dir),
321
+ realPath: dir,
322
+ report: buildReport(list, []),
323
+ sessions: list,
324
+ serverList: []
325
+ });
326
+ }
327
+ out.sort((a, b) => b.report.totalCostUsd - a.report.totalCostUsd);
328
+ return out;
329
+ }
330
+ var antigravityAdapter = {
331
+ id: "antigravity",
332
+ name: "Antigravity",
333
+ supported: true,
334
+ estimated: true,
335
+ estimateNote: "no token counts on disk \u2014 tokens/costs estimated from conversation size",
336
+ async detect(home) {
337
+ try {
338
+ await access(join2(antigravityRoot(home), "conversations"));
339
+ return true;
340
+ } catch {
341
+ try {
342
+ await access(join2(home ?? homedir2(), ".gemini", "antigravity", "conversations"));
343
+ return true;
344
+ } catch {
345
+ return false;
346
+ }
347
+ }
348
+ },
349
+ loadProjects: loadAntigravityProjects
350
+ };
351
+
178
352
  // src/agents/claude.ts
353
+ import { access as access2 } from "fs/promises";
354
+ import { homedir as homedir3 } from "os";
355
+ import { join as join3 } from "path";
179
356
  var claudeAdapter = {
180
357
  id: "claude",
181
358
  name: "Claude Code",
182
359
  supported: true,
183
- async detect(home = homedir2()) {
360
+ async detect(home = homedir3()) {
184
361
  try {
185
- await access(join2(home, ".claude", "projects"));
362
+ await access2(join3(home, ".claude", "projects"));
186
363
  return true;
187
364
  } catch {
188
365
  return false;
@@ -193,10 +370,10 @@ var claudeAdapter = {
193
370
 
194
371
  // src/agents/codex.ts
195
372
  import { createReadStream } from "fs";
196
- import { access as access2 } from "fs/promises";
373
+ import { access as access3 } from "fs/promises";
197
374
  import { createInterface } from "readline";
198
- import { homedir as homedir3 } from "os";
199
- import { join as join3 } from "path";
375
+ import { homedir as homedir4 } from "os";
376
+ import { join as join4 } from "path";
200
377
  import { glob as glob2 } from "tinyglobby";
201
378
  import { z } from "zod";
202
379
  var TokenTotals = z.object({
@@ -220,8 +397,8 @@ var Line = z.object({
220
397
  }).passthrough().optional()
221
398
  });
222
399
  function codexHome(home) {
223
- if (home) return join3(home, ".codex");
224
- return process.env.CODEX_HOME ?? join3(homedir3(), ".codex");
400
+ if (home) return join4(home, ".codex");
401
+ return process.env.CODEX_HOME ?? join4(homedir4(), ".codex");
225
402
  }
226
403
  var DEFAULT_MODEL = "gpt-5";
227
404
  async function parseCodexRollout(file) {
@@ -337,7 +514,7 @@ var codexAdapter = {
337
514
  supported: true,
338
515
  async detect(home) {
339
516
  try {
340
- await access2(join3(codexHome(home), "sessions"));
517
+ await access3(join4(codexHome(home), "sessions"));
341
518
  return true;
342
519
  } catch {
343
520
  return false;
@@ -347,9 +524,9 @@ var codexAdapter = {
347
524
  };
348
525
 
349
526
  // src/agents/opencode.ts
350
- import { access as access3, readFile as readFile2 } from "fs/promises";
351
- import { homedir as homedir4 } from "os";
352
- import { join as join4 } from "path";
527
+ import { access as access4, readFile as readFile3 } from "fs/promises";
528
+ import { homedir as homedir5 } from "os";
529
+ import { join as join5 } from "path";
353
530
  import { glob as glob3 } from "tinyglobby";
354
531
  import { z as z2 } from "zod";
355
532
  var Message = z2.object({
@@ -373,19 +550,19 @@ var Session = z2.object({
373
550
  });
374
551
  var Project = z2.object({ id: z2.string(), worktree: z2.string().optional() });
375
552
  function opencodeRoot(home) {
376
- if (home) return join4(home, ".local", "share", "opencode");
377
- return process.env.OPENCODE_DATA_DIR ?? join4(homedir4(), ".local", "share", "opencode");
553
+ if (home) return join5(home, ".local", "share", "opencode");
554
+ return process.env.OPENCODE_DATA_DIR ?? join5(homedir5(), ".local", "share", "opencode");
378
555
  }
379
556
  async function readJson(file) {
380
557
  try {
381
- return JSON.parse(await readFile2(file, "utf8"));
558
+ return JSON.parse(await readFile3(file, "utf8"));
382
559
  } catch {
383
560
  return void 0;
384
561
  }
385
562
  }
386
563
  async function loadOpencodeProjects(home, onProgress) {
387
564
  const root = opencodeRoot(home);
388
- const storage = join4(root, "storage");
565
+ const storage = join5(root, "storage");
389
566
  const messageFiles = await glob3(["message/*/*.json"], { cwd: storage, absolute: true }).catch(
390
567
  () => []
391
568
  );
@@ -471,7 +648,7 @@ var opencodeAdapter = {
471
648
  supported: true,
472
649
  async detect(home) {
473
650
  try {
474
- await access3(join4(opencodeRoot(home), "storage", "message"));
651
+ await access4(join5(opencodeRoot(home), "storage", "message"));
475
652
  return true;
476
653
  } catch {
477
654
  return false;
@@ -481,14 +658,15 @@ var opencodeAdapter = {
481
658
  };
482
659
 
483
660
  // src/agents/index.ts
484
- function detectOnly(id, name, ...pathParts) {
661
+ function detectOnly(id, name, reason, ...pathParts) {
485
662
  return {
486
663
  id,
487
664
  name,
488
665
  supported: false,
489
- async detect(home = homedir5()) {
666
+ unsupportedReason: reason,
667
+ async detect(home = homedir6()) {
490
668
  try {
491
- await access4(join5(home, ...pathParts));
669
+ await access5(join6(home, ...pathParts));
492
670
  return true;
493
671
  } catch {
494
672
  return false;
@@ -501,8 +679,8 @@ var ADAPTERS = [
501
679
  claudeAdapter,
502
680
  codexAdapter,
503
681
  opencodeAdapter,
504
- detectOnly("gemini", "Gemini CLI", ".gemini", "tmp"),
505
- detectOnly("cursor", "Cursor CLI", ".cursor", "chats")
682
+ antigravityAdapter,
683
+ detectOnly("cursor", "Cursor CLI", "sessions live in SQLite; parsing not supported yet", ".cursor", "chats")
506
684
  ];
507
685
  async function loadAllAgents(home, onProgress) {
508
686
  const out = [];
@@ -529,7 +707,7 @@ var TIMEFRAMES = [
529
707
  { id: "7d", label: "Last 7 days" },
530
708
  { id: "30d", label: "Last 30 days" }
531
709
  ];
532
- var isoDay = (offsetDays, now) => new Date(now - offsetDays * 864e5).toISOString().slice(0, 10);
710
+ var isoDay = (offsetDays, now) => dayKey(now - offsetDays * 864e5);
533
711
  function timeframeLabel(id) {
534
712
  return TIMEFRAMES.find((t) => t.id === id)?.label ?? id;
535
713
  }
@@ -625,9 +803,15 @@ function status(a) {
625
803
  if (a.projects.length > 0) {
626
804
  const cost = a.projects.reduce((s, p) => s + p.report.totalCostUsd, 0);
627
805
  const sessions = a.projects.reduce((s, p) => s + p.report.sessionCount, 0);
628
- return { text: `${a.projects.length} projects \xB7 ${sessions} sessions \xB7 ${usd(cost)}` };
806
+ const est = a.adapter.estimated ? `~` : "";
807
+ const tail = a.adapter.estimated ? " \xB7 estimated" : "";
808
+ return { text: `${a.projects.length} projects \xB7 ${sessions} sessions \xB7 ${est}${usd(cost)}${tail}` };
629
809
  }
630
- if (a.detected && !a.adapter.supported) return { text: "detected \xB7 parsing not supported yet", color: theme.warn };
810
+ if (a.detected && !a.adapter.supported)
811
+ return {
812
+ text: `detected \xB7 ${a.adapter.unsupportedReason ?? "parsing not supported yet"}`,
813
+ color: theme.warn
814
+ };
631
815
  if (a.detected) return { text: "detected \xB7 no usage data", dim: true };
632
816
  return { text: "not detected", dim: true };
633
817
  }
@@ -1188,24 +1372,33 @@ function Sessions({ r, cols }) {
1188
1372
  ] }) : null
1189
1373
  ] });
1190
1374
  }
1191
- function Activity({ r, chartW, cols }) {
1192
- const days = lastDays(r, 21).filter((d, i, all) => d.costUsd > 0 || i >= all.length - 14);
1375
+ function Activity({
1376
+ r,
1377
+ chartW,
1378
+ cols,
1379
+ grouping
1380
+ }) {
1381
+ if (r.daily.length === 0) return /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "no dated activity" });
1193
1382
  const wide = cols >= 66;
1194
- const rows = days.slice(-16).map((d) => ({
1195
- label: `${d.date} ${relativeDate(d.date) === "today" ? "\u25C2" : " "}`,
1383
+ const source = grouping === "day" ? lastDays(r, 21).filter((d, i, all) => d.costUsd > 0 || i >= all.length - 14) : groupDaily(r.daily, grouping);
1384
+ const rows = source.slice(-16).map((d) => ({
1385
+ label: grouping === "day" ? `${d.date} ${relativeDate(d.date) === "today" ? "\u25C2" : " "}` : grouping === "week" ? `wk ${d.date}` : d.date,
1196
1386
  value: d.costUsd,
1197
1387
  display: d.costUsd > 0 ? wide ? `${usd(d.costUsd)} \xB7 ${compact(d.turns)} turns` : usd(d.costUsd) : "\u2014"
1198
1388
  }));
1199
- if (r.daily.length === 0) return /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "no dated activity" });
1200
1389
  const avg = r.totalCostUsd / Math.max(1, r.daily.length);
1201
1390
  return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1202
1391
  /* @__PURE__ */ jsx8(BarChart, { rows, width: chartW }),
1203
1392
  /* @__PURE__ */ jsx8(Box7, { marginTop: 1, children: /* @__PURE__ */ jsxs7(Text8, { dimColor: true, children: [
1393
+ /* @__PURE__ */ jsx8(Text8, { color: theme.accent, children: "g" }),
1394
+ " group: ",
1395
+ grouping,
1396
+ " \xB7 ",
1204
1397
  r.daily.length,
1205
- " active days \xB7 avg ",
1206
- usd(avg),
1207
- "/active day \xB7 projected",
1398
+ " active days \xB7 avg",
1208
1399
  " ",
1400
+ usd(avg),
1401
+ "/active day \xB7 projected ",
1209
1402
  usd(r.monthlyProjectionUsd),
1210
1403
  "/mo"
1211
1404
  ] }) })
@@ -1217,12 +1410,15 @@ function Dashboard({
1217
1410
  timeframe
1218
1411
  }) {
1219
1412
  const [tab, setTab] = useState4(initialTab);
1413
+ const [grouping, setGrouping] = useState4("day");
1220
1414
  const { cols } = useTerminalSize();
1221
1415
  useInput3((input, key) => {
1222
1416
  const n = Number.parseInt(input, 10);
1223
1417
  if (n >= 1 && n <= TABS.length) setTab(n - 1);
1224
1418
  if (key.rightArrow) setTab((t) => (t + 1) % TABS.length);
1225
1419
  if (key.leftArrow) setTab((t) => (t + TABS.length - 1) % TABS.length);
1420
+ if (input === "g")
1421
+ setGrouping((g) => g === "day" ? "week" : g === "week" ? "month" : "day");
1226
1422
  });
1227
1423
  const r = project.report;
1228
1424
  const span = r.spanStart && r.spanEnd ? `${r.spanStart} \u2192 ${r.spanEnd}` : `${r.spanDays}d`;
@@ -1255,7 +1451,7 @@ function Dashboard({
1255
1451
  tab === 2 ? /* @__PURE__ */ jsx8(Tools, { r, chartW, cols }) : null,
1256
1452
  tab === 3 ? /* @__PURE__ */ jsx8(Servers, { r, cols }) : null,
1257
1453
  tab === 4 ? /* @__PURE__ */ jsx8(Sessions, { r, cols }) : null,
1258
- tab === 5 ? /* @__PURE__ */ jsx8(Activity, { r, chartW, cols }) : null
1454
+ tab === 5 ? /* @__PURE__ */ jsx8(Activity, { r, chartW, cols, grouping }) : null
1259
1455
  ] })
1260
1456
  ] });
1261
1457
  }
@@ -1270,6 +1466,7 @@ var KEYS = [
1270
1466
  ["/", "filter project list (esc clears)"],
1271
1467
  ["s", "cycle project sort: cost \xB7 recent \xB7 name"],
1272
1468
  ["t", "cycle timeframe: all \xB7 today \xB7 yesterday \xB7 7d \xB7 30d"],
1469
+ ["g", "Activity tab: group by day \xB7 week \xB7 month"],
1273
1470
  ["esc", "go back"],
1274
1471
  ["?", "toggle this help"],
1275
1472
  ["q", "quit"]
@@ -1339,7 +1536,8 @@ function App({
1339
1536
  [activeProjects, timeframe]
1340
1537
  );
1341
1538
  const totals = useMemo2(() => aggregate(scoped), [scoped]);
1342
- const agentName = agentList[agentIdx]?.adapter.name ?? "";
1539
+ const activeAdapter = agentList[agentIdx]?.adapter;
1540
+ const agentName = activeAdapter ? activeAdapter.name + (activeAdapter.estimated ? " (estimated)" : "") : "";
1343
1541
  useInput4((input, key) => {
1344
1542
  if (help) {
1345
1543
  setHelp(false);
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/mcp.ts
4
+ import { readFile } from "fs/promises";
5
+ import { homedir } from "os";
6
+ import { join } from "path";
7
+ async function readJson(file) {
8
+ try {
9
+ return JSON.parse(await readFile(file, "utf8"));
10
+ } catch {
11
+ return void 0;
12
+ }
13
+ }
14
+ function serverNames(obj) {
15
+ if (obj && typeof obj === "object" && !Array.isArray(obj)) return Object.keys(obj);
16
+ return [];
17
+ }
18
+ async function findMcpServers(projectPath, home = homedir()) {
19
+ const out = /* @__PURE__ */ new Map();
20
+ const add = (names, source) => {
21
+ for (const name of names) if (!out.has(name)) out.set(name, { name, source });
22
+ };
23
+ const projectFile = join(projectPath, ".mcp.json");
24
+ const projectCfg = await readJson(projectFile);
25
+ add(serverNames(projectCfg?.mcpServers), projectFile);
26
+ const globalFile = join(home, ".claude.json");
27
+ const globalCfg = await readJson(globalFile);
28
+ add(serverNames(globalCfg?.mcpServers), globalFile);
29
+ const projects = globalCfg?.projects;
30
+ if (projects && typeof projects === "object") {
31
+ const entry = projects[projectPath];
32
+ add(serverNames(entry?.mcpServers), `${globalFile} (project entry)`);
33
+ }
34
+ return [...out.values()];
35
+ }
36
+
37
+ export {
38
+ findMcpServers
39
+ };
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ addUsage,
4
+ compact,
5
+ costUsd,
6
+ emptyUsage,
7
+ shortModel,
8
+ usd
9
+ } from "./chunk-Z5W5QH2Z.js";
10
+
11
+ // src/blocks.ts
12
+ var BLOCK_LENGTH_MS = 5 * 60 * 60 * 1e3;
13
+ var HOUR_MS = 60 * 60 * 1e3;
14
+ function finalize(b) {
15
+ for (const [model, u] of Object.entries(b.usageByModel)) {
16
+ b.totalTokens += u.inputTokens + u.cacheReadTokens + u.cacheCreationTokens + u.outputTokens;
17
+ b.costUsd += costUsd(u, model).total;
18
+ }
19
+ return b;
20
+ }
21
+ function buildBlocks(events, opts) {
22
+ const len = opts?.sessionLengthMs ?? BLOCK_LENGTH_MS;
23
+ const now = opts?.now ?? Date.now();
24
+ const sorted = [...events].sort((a, b) => a.ts - b.ts);
25
+ const blocks = [];
26
+ let cur;
27
+ for (const e of sorted) {
28
+ if (!cur || e.ts >= cur.end) {
29
+ if (cur) blocks.push(finalize(cur));
30
+ const start = Math.floor(e.ts / HOUR_MS) * HOUR_MS;
31
+ cur = {
32
+ start,
33
+ end: start + len,
34
+ firstTs: e.ts,
35
+ lastTs: e.ts,
36
+ usageByModel: {},
37
+ totalTokens: 0,
38
+ costUsd: 0,
39
+ active: false
40
+ };
41
+ }
42
+ cur.lastTs = Math.max(cur.lastTs, e.ts);
43
+ addUsage(cur.usageByModel[e.model] ??= emptyUsage(), e.usage);
44
+ }
45
+ if (cur) {
46
+ cur.active = now < cur.end;
47
+ blocks.push(finalize(cur));
48
+ }
49
+ return blocks;
50
+ }
51
+ function burnRate(b, now = Date.now()) {
52
+ if (!b.active) return void 0;
53
+ const elapsed = Math.max(6e4, now - b.firstTs);
54
+ const remainingMs = Math.max(0, b.end - now);
55
+ const tokensPerMinute = b.totalTokens / (elapsed / 6e4);
56
+ const costPerHour = b.costUsd / (elapsed / HOUR_MS);
57
+ return {
58
+ tokensPerMinute,
59
+ costPerHour,
60
+ projectedTokens: Math.round(b.totalTokens + tokensPerMinute * (remainingMs / 6e4)),
61
+ projectedCostUsd: b.costUsd + costPerHour * (remainingMs / HOUR_MS),
62
+ remainingMs
63
+ };
64
+ }
65
+ function maxBlockTokens(blocks) {
66
+ return blocks.filter((b) => !b.active).reduce((m, b) => Math.max(m, b.totalTokens), 0);
67
+ }
68
+
69
+ // src/blocksReport.ts
70
+ import Table from "cli-table3";
71
+ import pc from "picocolors";
72
+ function fmtMs(ms) {
73
+ const mins = Math.round(ms / 6e4);
74
+ if (mins < 1) return "<1m";
75
+ if (mins < 60) return `${mins}m`;
76
+ const h = Math.floor(mins / 60);
77
+ const m = mins % 60;
78
+ return m > 0 ? `${h}h ${m}m` : `${h}h`;
79
+ }
80
+ function fmtStart(ms) {
81
+ const d = new Date(ms);
82
+ return `${d.toISOString().slice(0, 10)} ${d.toISOString().slice(11, 16)} UTC`;
83
+ }
84
+ function renderBlocksReport(blocks, opts) {
85
+ if (blocks.length === 0) return "No Claude Code activity found.";
86
+ const now = opts?.now ?? Date.now();
87
+ const limit = opts?.tokenLimit === "max" ? maxBlockTokens(blocks) : opts?.tokenLimit;
88
+ const parts = [];
89
+ parts.push(
90
+ pc.bold(`tokz blocks \u2014 ${blocks.length} \xD7 5-hour billing windows`) + pc.dim(" (Claude usage limits reset per rolling 5h block)")
91
+ );
92
+ const table = new Table({ head: ["Block start", "Status", "Models", "Tokens", "Cost"] });
93
+ for (const b of blocks) {
94
+ const models = [...new Set(Object.keys(b.usageByModel).map(shortModel))].join(", ");
95
+ const status = b.active ? pc.green(`ACTIVE \xB7 ${fmtMs(b.end - now)} left`) : pc.dim(`done \xB7 ${fmtMs(b.lastTs - b.firstTs)}`);
96
+ const tokens = limit && b.totalTokens > limit ? pc.red(`${compact(b.totalTokens)} \u{1F6A8}`) : limit && b.totalTokens > limit * 0.8 ? pc.yellow(`${compact(b.totalTokens)} \u26A0\uFE0F`) : compact(b.totalTokens);
97
+ table.push([fmtStart(b.start), status, models, tokens, usd(b.costUsd)]);
98
+ }
99
+ parts.push(table.toString());
100
+ const active = blocks.find((b) => b.active);
101
+ const rate = active ? burnRate(active, now) : void 0;
102
+ if (active && rate) {
103
+ const lines = [
104
+ pc.bold("Active block") + ` \u2014 started ${fmtStart(active.start)}, ${pc.green(fmtMs(rate.remainingMs))} remaining`,
105
+ ` burn rate ${compact(Math.round(rate.tokensPerMinute))} tok/min \xB7 ${usd(rate.costPerHour)}/hr`,
106
+ ` projected ${compact(rate.projectedTokens)} tokens \xB7 ${usd(rate.projectedCostUsd)} by block end`
107
+ ];
108
+ if (limit) {
109
+ const over = rate.projectedTokens > limit;
110
+ const nearing = !over && active.totalTokens > limit * 0.8;
111
+ lines.push(
112
+ ` limit ${compact(limit)} tokens \u2014 ` + (over ? pc.red(`\u{1F6A8} projected to exceed (${compact(rate.projectedTokens)})`) : nearing ? pc.yellow(`\u26A0\uFE0F ${Math.round(active.totalTokens / limit * 100)}% used`) : pc.green(`${Math.round(active.totalTokens / limit * 100)}% used`))
113
+ );
114
+ }
115
+ parts.push(lines.join("\n"));
116
+ }
117
+ return parts.join("\n\n");
118
+ }
119
+
120
+ export {
121
+ buildBlocks,
122
+ burnRate,
123
+ fmtMs,
124
+ renderBlocksReport
125
+ };
@@ -12,6 +12,11 @@ var PRICES = {
12
12
  "claude-sonnet-4-6": { inputPerMTok: 3, outputPerMTok: 15 },
13
13
  "claude-haiku-4-5": { inputPerMTok: 1, outputPerMTok: 5 },
14
14
  // OpenAI (no cache-write charge; cached input 0.1x)
15
+ "gpt-5.6-sol": { inputPerMTok: 5, outputPerMTok: 30, cacheWriteMult: 0 },
16
+ "gpt-5.6-terra": { inputPerMTok: 2.5, outputPerMTok: 15, cacheWriteMult: 0 },
17
+ "gpt-5.6-luna": { inputPerMTok: 1, outputPerMTok: 6, cacheWriteMult: 0 },
18
+ "gpt-5.5-pro": { inputPerMTok: 30, outputPerMTok: 180, cacheWriteMult: 0, cacheReadMult: 1 },
19
+ "gpt-5.5": { inputPerMTok: 5, outputPerMTok: 30, cacheWriteMult: 0 },
15
20
  "gpt-5-codex": { inputPerMTok: 1.25, outputPerMTok: 10, cacheWriteMult: 0 },
16
21
  "gpt-5-mini": { inputPerMTok: 0.25, outputPerMTok: 2, cacheWriteMult: 0 },
17
22
  "gpt-5-nano": { inputPerMTok: 0.05, outputPerMTok: 0.4, cacheWriteMult: 0 },
@@ -22,6 +27,8 @@ var PRICES = {
22
27
  "o4-mini": { inputPerMTok: 1.1, outputPerMTok: 4.4, cacheWriteMult: 0, cacheReadMult: 0.25 },
23
28
  o3: { inputPerMTok: 2, outputPerMTok: 8, cacheWriteMult: 0, cacheReadMult: 0.25 },
24
29
  // Google
30
+ "gemini-3.1-pro": { inputPerMTok: 2, outputPerMTok: 12, cacheWriteMult: 0 },
31
+ "gemini-3.5-flash": { inputPerMTok: 1.5, outputPerMTok: 9, cacheWriteMult: 0 },
25
32
  "gemini-2.5-pro": { inputPerMTok: 1.25, outputPerMTok: 10, cacheWriteMult: 0 },
26
33
  "gemini-2.5-flash": { inputPerMTok: 0.3, outputPerMTok: 2.5, cacheWriteMult: 0 }
27
34
  };
@@ -29,17 +36,29 @@ var DEFAULT_CACHE_READ_MULT = 0.1;
29
36
  var DEFAULT_CACHE_WRITE_MULT = 1.25;
30
37
  var CLAUDE_FALLBACK = PRICES["claude-opus-4-8"];
31
38
  var UNKNOWN = { inputPerMTok: 0, outputPerMTok: 0 };
32
- function resolvePrice(modelId) {
39
+ var livePrices = {};
40
+ var resolveCache = /* @__PURE__ */ new Map();
41
+ function setLivePrices(prices) {
42
+ livePrices = prices;
43
+ resolveCache.clear();
44
+ }
45
+ function longestPrefix(modelId, table) {
33
46
  let best;
34
47
  let bestLen = -1;
35
- for (const [prefix, price] of Object.entries(PRICES)) {
48
+ for (const [prefix, price] of Object.entries(table)) {
36
49
  if (modelId.startsWith(prefix) && prefix.length > bestLen) {
37
50
  best = price;
38
51
  bestLen = prefix.length;
39
52
  }
40
53
  }
41
- if (best) return best;
42
- return modelId.startsWith("claude") ? CLAUDE_FALLBACK : UNKNOWN;
54
+ return best;
55
+ }
56
+ function resolvePrice(modelId) {
57
+ const hit = resolveCache.get(modelId);
58
+ if (hit) return hit;
59
+ const price = livePrices[modelId] ?? longestPrefix(modelId, PRICES) ?? longestPrefix(modelId, livePrices) ?? (modelId.startsWith("claude") ? CLAUDE_FALLBACK : UNKNOWN);
60
+ resolveCache.set(modelId, price);
61
+ return price;
43
62
  }
44
63
  function emptyUsage() {
45
64
  return { inputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, outputTokens: 0, turns: 0 };
@@ -53,7 +72,8 @@ function costUsd(usage, modelId) {
53
72
  const cacheRead = usage.cacheReadTokens / 1e6 * p.inputPerMTok * readMult;
54
73
  const write1h = Math.min(usage.cacheCreation1hTokens ?? 0, usage.cacheCreationTokens);
55
74
  const write5m = usage.cacheCreationTokens - write1h;
56
- const cacheWrite = write5m / 1e6 * p.inputPerMTok * writeMult + write1h / 1e6 * p.inputPerMTok * (p.cacheWriteMult ?? CACHE_WRITE_1H_MULT);
75
+ const write1hMult = p.cacheWrite1hMult ?? (p.cacheWriteMult === 0 ? 0 : CACHE_WRITE_1H_MULT);
76
+ const cacheWrite = write5m / 1e6 * p.inputPerMTok * writeMult + write1h / 1e6 * p.inputPerMTok * write1hMult;
57
77
  const output = usage.outputTokens / 1e6 * p.outputPerMTok;
58
78
  return { input, cacheRead, cacheWrite, output, total: input + cacheRead + cacheWrite + output };
59
79
  }
@@ -220,6 +240,85 @@ function buildReport(sessions, servers, range) {
220
240
  };
221
241
  }
222
242
 
243
+ // src/format.ts
244
+ var usd = (n) => `$${n.toFixed(2)}`;
245
+ var tok = (n) => n.toLocaleString("en-US");
246
+ function compact(n) {
247
+ if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
248
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
249
+ if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
250
+ return String(n);
251
+ }
252
+ var pct = (fraction) => `${Math.round(fraction * 100)}%`;
253
+ var pct1 = (fraction) => `${(fraction * 100).toFixed(1)}%`;
254
+ var shortModel = (id) => id.replace(/^claude-/, "").replace(/-\d{8}$/, "");
255
+ function duration(startIso, endIso) {
256
+ if (!startIso || !endIso) return "\u2014";
257
+ const mins = Math.round((Date.parse(endIso) - Date.parse(startIso)) / 6e4);
258
+ if (mins < 1) return "<1m";
259
+ if (mins < 60) return `${mins}m`;
260
+ const h = Math.floor(mins / 60);
261
+ const m = mins % 60;
262
+ return m > 0 ? `${h}h ${m}m` : `${h}h`;
263
+ }
264
+ function relativeDate(iso, now = Date.now()) {
265
+ if (!iso) return "\u2014";
266
+ const date = iso.slice(0, 10);
267
+ const days = Math.floor((now - Date.parse(date)) / 864e5);
268
+ if (days <= 0) return "today";
269
+ if (days === 1) return "yesterday";
270
+ if (days < 30) return `${days}d ago`;
271
+ return date;
272
+ }
273
+
274
+ // src/dates.ts
275
+ var zone;
276
+ var formatter;
277
+ function setTimezone(tz) {
278
+ zone = tz === "utc" || tz === void 0 ? void 0 : tz;
279
+ formatter = zone ? new Intl.DateTimeFormat("en-CA", {
280
+ timeZone: zone === "local" ? void 0 : zone,
281
+ year: "numeric",
282
+ month: "2-digit",
283
+ day: "2-digit"
284
+ }) : void 0;
285
+ }
286
+ function dayKey(ts) {
287
+ if (!formatter) return typeof ts === "string" ? ts.slice(0, 10) : new Date(ts).toISOString().slice(0, 10);
288
+ return formatter.format(typeof ts === "string" ? new Date(ts) : ts);
289
+ }
290
+ function weekKey(day) {
291
+ const d = /* @__PURE__ */ new Date(`${day}T00:00:00Z`);
292
+ const dow = (d.getUTCDay() + 6) % 7;
293
+ d.setUTCDate(d.getUTCDate() - dow);
294
+ return d.toISOString().slice(0, 10);
295
+ }
296
+ function monthKey(day) {
297
+ return day.slice(0, 7);
298
+ }
299
+ function groupDaily(daily, unit) {
300
+ if (unit === "day") return daily;
301
+ const keyOf = unit === "week" ? weekKey : monthKey;
302
+ const out = /* @__PURE__ */ new Map();
303
+ for (const d of daily) {
304
+ const key = keyOf(d.date);
305
+ const acc = out.get(key) ?? { date: key, costUsd: 0, inputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, outputTokens: 0, turns: 0 };
306
+ acc.costUsd += d.costUsd;
307
+ acc.inputTokens += d.inputTokens;
308
+ acc.cacheReadTokens += d.cacheReadTokens;
309
+ acc.cacheCreationTokens += d.cacheCreationTokens;
310
+ acc.outputTokens += d.outputTokens;
311
+ acc.turns += d.turns;
312
+ out.set(key, acc);
313
+ }
314
+ return [...out.values()].sort((a, b) => a.date < b.date ? -1 : 1);
315
+ }
316
+ function parseDateArg(raw) {
317
+ if (!raw) return void 0;
318
+ const m = /^(\d{4})-?(\d{2})-?(\d{2})$/.exec(raw.trim());
319
+ return m ? `${m[1]}-${m[2]}-${m[3]}` : void 0;
320
+ }
321
+
223
322
  // src/discover.ts
224
323
  import { homedir } from "os";
225
324
  import { join } from "path";
@@ -235,40 +334,6 @@ async function findTranscripts(projectPath, home = homedir()) {
235
334
  return glob(["**/*.jsonl"], { cwd, absolute: true }).catch(() => []);
236
335
  }
237
336
 
238
- // src/mcp.ts
239
- import { readFile } from "fs/promises";
240
- import { homedir as homedir2 } from "os";
241
- import { join as join2 } from "path";
242
- async function readJson(file) {
243
- try {
244
- return JSON.parse(await readFile(file, "utf8"));
245
- } catch {
246
- return void 0;
247
- }
248
- }
249
- function serverNames(obj) {
250
- if (obj && typeof obj === "object" && !Array.isArray(obj)) return Object.keys(obj);
251
- return [];
252
- }
253
- async function findMcpServers(projectPath, home = homedir2()) {
254
- const out = /* @__PURE__ */ new Map();
255
- const add = (names, source) => {
256
- for (const name of names) if (!out.has(name)) out.set(name, { name, source });
257
- };
258
- const projectFile = join2(projectPath, ".mcp.json");
259
- const projectCfg = await readJson(projectFile);
260
- add(serverNames(projectCfg?.mcpServers), projectFile);
261
- const globalFile = join2(home, ".claude.json");
262
- const globalCfg = await readJson(globalFile);
263
- add(serverNames(globalCfg?.mcpServers), globalFile);
264
- const projects = globalCfg?.projects;
265
- if (projects && typeof projects === "object") {
266
- const entry = projects[projectPath];
267
- add(serverNames(entry?.mcpServers), `${globalFile} (project entry)`);
268
- }
269
- return [...out.values()];
270
- }
271
-
272
337
  // src/transcript.ts
273
338
  import { createReadStream } from "fs";
274
339
  import { createInterface } from "readline";
@@ -293,7 +358,7 @@ var AssistantLine = z.object({
293
358
  content: z.array(z.object({ type: z.string(), id: z.string().optional(), name: z.string().optional() }).passthrough()).optional()
294
359
  })
295
360
  });
296
- async function parseTranscript(file, seenMessages = /* @__PURE__ */ new Map(), seenToolIds = /* @__PURE__ */ new Set()) {
361
+ async function parseTranscript(file, seenMessages = /* @__PURE__ */ new Map(), seenToolIds = /* @__PURE__ */ new Set(), events) {
297
362
  const stats = { file, usageByModel: {}, toolCalls: {}, toolCostUsd: {}, dailyUsage: {} };
298
363
  const rl = createInterface({ input: createReadStream(file, "utf8"), crlfDelay: Infinity });
299
364
  for await (const line of rl) {
@@ -345,8 +410,9 @@ async function parseTranscript(file, seenMessages = /* @__PURE__ */ new Map(), s
345
410
  if (hasDelta) {
346
411
  const accs = [stats.usageByModel[model] ??= emptyUsage()];
347
412
  if (timestamp) {
348
- const day = stats.dailyUsage[timestamp.slice(0, 10)] ??= {};
413
+ const day = stats.dailyUsage[dayKey(timestamp)] ??= {};
349
414
  accs.push(day[model] ??= emptyUsage());
415
+ events?.push({ ts: Date.parse(timestamp), model, usage: delta });
350
416
  }
351
417
  for (const acc of accs) {
352
418
  acc.inputTokens += delta.inputTokens;
@@ -379,47 +445,14 @@ async function parseTranscript(file, seenMessages = /* @__PURE__ */ new Map(), s
379
445
  return stats;
380
446
  }
381
447
 
382
- // src/format.ts
383
- var usd = (n) => `$${n.toFixed(2)}`;
384
- var tok = (n) => n.toLocaleString("en-US");
385
- function compact(n) {
386
- if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
387
- if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
388
- if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
389
- return String(n);
390
- }
391
- var pct = (fraction) => `${Math.round(fraction * 100)}%`;
392
- var pct1 = (fraction) => `${(fraction * 100).toFixed(1)}%`;
393
- var shortModel = (id) => id.replace(/^claude-/, "").replace(/-\d{8}$/, "");
394
- function duration(startIso, endIso) {
395
- if (!startIso || !endIso) return "\u2014";
396
- const mins = Math.round((Date.parse(endIso) - Date.parse(startIso)) / 6e4);
397
- if (mins < 1) return "<1m";
398
- if (mins < 60) return `${mins}m`;
399
- const h = Math.floor(mins / 60);
400
- const m = mins % 60;
401
- return m > 0 ? `${h}h ${m}m` : `${h}h`;
402
- }
403
- function relativeDate(iso, now = Date.now()) {
404
- if (!iso) return "\u2014";
405
- const date = iso.slice(0, 10);
406
- const days = Math.floor((now - Date.parse(date)) / 864e5);
407
- if (days <= 0) return "today";
408
- if (days === 1) return "yesterday";
409
- if (days < 30) return `${days}d ago`;
410
- return date;
411
- }
412
-
413
448
  export {
449
+ setLivePrices,
414
450
  emptyUsage,
415
451
  costUsd,
416
452
  addUsage,
417
453
  cacheSavings,
418
454
  cacheHitRate,
419
455
  buildReport,
420
- sanitizeProjectPath,
421
- findTranscripts,
422
- findMcpServers,
423
456
  usd,
424
457
  tok,
425
458
  compact,
@@ -428,5 +461,11 @@ export {
428
461
  shortModel,
429
462
  duration,
430
463
  relativeDate,
464
+ setTimezone,
465
+ dayKey,
466
+ groupDaily,
467
+ parseDateArg,
468
+ sanitizeProjectPath,
469
+ findTranscripts,
431
470
  parseTranscript
432
471
  };
package/dist/cli.js CHANGED
@@ -1,21 +1,107 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ buildBlocks,
4
+ renderBlocksReport
5
+ } from "./chunk-6Y6MKFHZ.js";
6
+ import {
7
+ findMcpServers
8
+ } from "./chunk-65BQE6TI.js";
2
9
  import {
3
10
  buildReport,
4
- findMcpServers,
5
11
  findTranscripts,
12
+ groupDaily,
13
+ parseDateArg,
6
14
  parseTranscript,
7
15
  pct1,
16
+ setLivePrices,
17
+ setTimezone,
8
18
  tok,
9
19
  usd
10
- } from "./chunk-JAZOB7NC.js";
20
+ } from "./chunk-Z5W5QH2Z.js";
11
21
 
12
22
  // src/cli.ts
13
23
  import { createRequire } from "module";
14
24
  import { Command } from "commander";
15
25
 
26
+ // src/livePricing.ts
27
+ import { mkdir, readFile, writeFile } from "fs/promises";
28
+ import { homedir } from "os";
29
+ import { join } from "path";
30
+ var LITELLM_URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
31
+ var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
32
+ var FETCH_TIMEOUT_MS = 3500;
33
+ function mapLitellmPrices(raw) {
34
+ const out = {};
35
+ for (const [key, value] of Object.entries(raw)) {
36
+ if (!value || typeof value !== "object") continue;
37
+ const e = value;
38
+ const inCost = e.input_cost_per_token;
39
+ const outCost = e.output_cost_per_token;
40
+ if (typeof inCost !== "number" || typeof outCost !== "number" || inCost <= 0) continue;
41
+ const name = key.includes("/") ? key.slice(key.lastIndexOf("/") + 1) : key;
42
+ if (key.includes("/") && out[name]) continue;
43
+ const p = { inputPerMTok: inCost * 1e6, outputPerMTok: outCost * 1e6 };
44
+ if (typeof e.cache_read_input_token_cost === "number")
45
+ p.cacheReadMult = e.cache_read_input_token_cost / inCost;
46
+ if (typeof e.cache_creation_input_token_cost === "number")
47
+ p.cacheWriteMult = e.cache_creation_input_token_cost / inCost;
48
+ else if (!name.startsWith("claude"))
49
+ p.cacheWriteMult = 0;
50
+ if (typeof e.cache_creation_input_token_cost_above_1hr === "number")
51
+ p.cacheWrite1hMult = e.cache_creation_input_token_cost_above_1hr / inCost;
52
+ out[name] = p;
53
+ }
54
+ return out;
55
+ }
56
+ async function initPricing(opts) {
57
+ const dir = opts?.cacheDir ?? join(homedir(), ".tokz");
58
+ const file = join(dir, "litellm-prices.json");
59
+ let cached;
60
+ try {
61
+ cached = JSON.parse(await readFile(file, "utf8"));
62
+ } catch {
63
+ }
64
+ const fresh = cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS;
65
+ if (cached && (fresh || opts?.offline)) {
66
+ setLivePrices(cached.prices);
67
+ return "cached";
68
+ }
69
+ if (opts?.offline) return "static";
70
+ try {
71
+ const ctrl = new AbortController();
72
+ const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
73
+ const res = await fetch(LITELLM_URL, { signal: ctrl.signal });
74
+ clearTimeout(timer);
75
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
76
+ const prices = mapLitellmPrices(await res.json());
77
+ setLivePrices(prices);
78
+ try {
79
+ await mkdir(dir, { recursive: true });
80
+ await writeFile(file, JSON.stringify({ fetchedAt: Date.now(), prices }));
81
+ } catch {
82
+ }
83
+ return "live";
84
+ } catch {
85
+ if (cached) {
86
+ setLivePrices(cached.prices);
87
+ return "cached";
88
+ }
89
+ return "static";
90
+ }
91
+ }
92
+
16
93
  // src/report.ts
17
94
  import Table from "cli-table3";
18
95
  import pc from "picocolors";
96
+ function renderActivity(rows, unit) {
97
+ const head = unit === "week" ? "Week of" : "Month";
98
+ const table = new Table({ head: [head, "Cost", "Input", "Cache read", "Cache write", "Output", "Turns"] });
99
+ for (const d of rows) {
100
+ table.push([d.date, usd(d.costUsd), tok(d.inputTokens), tok(d.cacheReadTokens), tok(d.cacheCreationTokens), tok(d.outputTokens), String(d.turns)]);
101
+ }
102
+ return `${pc.bold(`Activity by ${unit}`)}
103
+ ${table.toString()}`;
104
+ }
19
105
  function renderReport(report) {
20
106
  const parts = [];
21
107
  const span = report.spanStart && report.spanEnd ? `${report.spanStart} \u2192 ${report.spanEnd}` : `${report.spanDays} days`;
@@ -62,47 +148,108 @@ function renderReport(report) {
62
148
  // src/cli.ts
63
149
  var { version } = createRequire(import.meta.url)("../package.json");
64
150
  var program = new Command();
65
- program.name("tokz").description("Audit where your coding agent's context window and API dollars go.").version(version);
66
- program.command("audit").argument("[project]", "project path (default: current directory)").option("--all", "scan all projects under ~/.claude/projects").option("--json", "output raw JSON report").option("--days <n>", "only include the last N days of activity").action(async (project, opts) => {
151
+ program.name("tokz").description("Audit where your coding agent's context window and API dollars go.").option("--offline", "don't fetch live pricing; use cached/built-in rates").option("--timezone <zone>", 'group days in this timezone: "utc" (default), "local", or an IANA name').version(version);
152
+ function applyGlobals() {
153
+ const opts = program.opts();
154
+ setTimezone(opts.timezone);
155
+ return { offline: opts.offline };
156
+ }
157
+ async function parseAll(projectPath, events) {
158
+ const transcripts = await findTranscripts(projectPath);
159
+ const seenMessageIds = /* @__PURE__ */ new Map();
160
+ const seenToolIds = /* @__PURE__ */ new Set();
161
+ const sessions = await Promise.all(
162
+ transcripts.map((f) => parseTranscript(f, seenMessageIds, seenToolIds, events))
163
+ );
164
+ return { transcripts, sessions };
165
+ }
166
+ program.command("audit").argument("[project]", "project path (default: current directory)").option("--all", "scan all projects under ~/.claude/projects").option("--json", "output raw JSON report").option("--days <n>", "only include the last N days of activity").option("--since <date>", "start date, YYYY-MM-DD or YYYYMMDD (inclusive)").option("--until <date>", "end date, YYYY-MM-DD or YYYYMMDD (inclusive)").option("--weekly", "append activity grouped by week").option("--monthly", "append activity grouped by month").action(async (project, opts) => {
167
+ const globals = applyGlobals();
67
168
  const projectPath = project ?? process.cwd();
68
- const transcripts = await findTranscripts(opts.all ? void 0 : projectPath);
169
+ const [{ transcripts, sessions }] = await Promise.all([
170
+ parseAll(opts.all ? void 0 : projectPath),
171
+ initPricing(globals)
172
+ ]);
69
173
  if (transcripts.length === 0) {
70
174
  console.error(`No Claude Code transcripts found for ${opts.all ? "any project" : projectPath}.`);
71
175
  process.exitCode = 1;
72
176
  return;
73
177
  }
74
- const seenMessageIds = /* @__PURE__ */ new Map();
75
- const seenToolIds = /* @__PURE__ */ new Set();
76
- const sessions = await Promise.all(
77
- transcripts.map((f) => parseTranscript(f, seenMessageIds, seenToolIds))
78
- );
79
178
  const servers = opts.all ? [] : await findMcpServers(projectPath);
80
179
  const days = opts.days ? Number.parseInt(opts.days, 10) : void 0;
81
180
  const isoDay = (offset) => new Date(Date.now() - offset * 864e5).toISOString().slice(0, 10);
82
- const range = days && days > 0 ? { from: isoDay(days - 1), to: isoDay(0) } : void 0;
181
+ const since = parseDateArg(opts.since);
182
+ const until = parseDateArg(opts.until);
183
+ let range;
184
+ if (since || until) range = { from: since ?? "0000-01-01", to: until ?? "9999-12-31" };
185
+ else if (days && days > 0) range = { from: isoDay(days - 1), to: isoDay(0) };
83
186
  const report = buildReport(sessions, servers, range);
84
- console.log(opts.json ? JSON.stringify(report, null, 2) : renderReport(report));
187
+ if (opts.json) {
188
+ console.log(JSON.stringify(report, null, 2));
189
+ return;
190
+ }
191
+ const parts = [renderReport(report)];
192
+ const grouping = opts.monthly ? "month" : opts.weekly ? "week" : void 0;
193
+ if (grouping) parts.push(renderActivity(groupDaily(report.daily, grouping), grouping));
194
+ console.log(parts.join("\n\n"));
195
+ });
196
+ program.command("blocks").description("Claude usage grouped into rolling 5-hour billing windows").option("--json", "output raw JSON").option("--active", "show only the currently active block").option("--recent", "only blocks from the last 3 days").option("--token-limit <n>", 'warn near this many tokens per block ("max" = highest past block)').option("--session-length <hours>", "block length in hours (default 5)").action(async (opts) => {
197
+ const globals = applyGlobals();
198
+ const events = [];
199
+ await Promise.all([parseAll(void 0, events), initPricing(globals)]);
200
+ const hours = opts.sessionLength ? Number.parseFloat(opts.sessionLength) : 5;
201
+ let blocks = buildBlocks(events, { sessionLengthMs: hours * 36e5 });
202
+ if (opts.recent) blocks = blocks.filter((b) => b.lastTs >= Date.now() - 3 * 864e5);
203
+ if (opts.active) blocks = blocks.filter((b) => b.active);
204
+ const tokenLimit = opts.tokenLimit === "max" ? "max" : opts.tokenLimit ? Number.parseInt(opts.tokenLimit, 10) : void 0;
205
+ if (opts.json) {
206
+ console.log(JSON.stringify(blocks, null, 2));
207
+ return;
208
+ }
209
+ console.log(renderBlocksReport(blocks, { tokenLimit }));
210
+ });
211
+ program.command("statusline").description("compact usage line for Claude Code's statusLine hook (reads hook JSON on stdin)").argument("[action]", '"enable" writes the hook into ~/.claude/settings.json, "disable" removes it').action(async (action) => {
212
+ const globals = applyGlobals();
213
+ const sl = await import("./statusline-A36XPBG6.js");
214
+ if (action === "enable" || action === "disable") {
215
+ try {
216
+ console.log(action === "enable" ? await sl.enableStatusline() : await sl.disableStatusline());
217
+ } catch (err) {
218
+ console.error(`Could not update settings: ${err instanceof Error ? err.message : err}`);
219
+ process.exitCode = 1;
220
+ }
221
+ return;
222
+ }
223
+ if (action !== void 0) {
224
+ console.error(`Unknown action "${action}" \u2014 use "enable" or "disable".`);
225
+ process.exitCode = 1;
226
+ return;
227
+ }
228
+ await initPricing({ ...globals, offline: true });
229
+ let input = {};
230
+ try {
231
+ input = JSON.parse(await sl.readStdin());
232
+ } catch {
233
+ }
234
+ console.log(await sl.statusline(input));
85
235
  });
86
236
  program.action(async () => {
237
+ const globals = applyGlobals();
238
+ await initPricing(globals);
87
239
  if (!process.stdout.isTTY) {
88
- const transcripts = await findTranscripts(void 0);
240
+ const { transcripts, sessions } = await parseAll(void 0);
89
241
  if (transcripts.length === 0) {
90
242
  console.error("No Claude Code transcripts found.");
91
243
  process.exitCode = 1;
92
244
  return;
93
245
  }
94
- const seenMessageIds = /* @__PURE__ */ new Map();
95
- const seenToolIds = /* @__PURE__ */ new Set();
96
- const sessions = await Promise.all(
97
- transcripts.map((f) => parseTranscript(f, seenMessageIds, seenToolIds))
98
- );
99
246
  console.log(renderReport(buildReport(sessions, [])));
100
247
  return;
101
248
  }
102
249
  const [{ render }, React, { Root }, { Fullscreen }] = await Promise.all([
103
250
  import("ink"),
104
251
  import("react"),
105
- import("./Root-6SZ5FQDN.js"),
252
+ import("./Root-WEIYEE6M.js"),
106
253
  import("./Fullscreen-GK2ZYXHV.js")
107
254
  ]);
108
255
  render(React.createElement(Fullscreen, null, React.createElement(Root)));
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ buildBlocks,
4
+ burnRate,
5
+ fmtMs
6
+ } from "./chunk-6Y6MKFHZ.js";
7
+ import {
8
+ compact,
9
+ costUsd,
10
+ dayKey,
11
+ findTranscripts,
12
+ parseTranscript,
13
+ shortModel,
14
+ usd
15
+ } from "./chunk-Z5W5QH2Z.js";
16
+
17
+ // src/statusline.ts
18
+ import { mkdir, readFile, stat, writeFile } from "fs/promises";
19
+ import { homedir } from "os";
20
+ import { join } from "path";
21
+ import pc from "picocolors";
22
+ var CONTEXT_WINDOW = 2e5;
23
+ var LOOKBACK_MS = 6 * 60 * 60 * 1e3;
24
+ async function lastContextTokens(transcriptPath) {
25
+ try {
26
+ const lines = (await readFile(transcriptPath, "utf8")).split("\n");
27
+ for (let i = lines.length - 1; i >= 0; i--) {
28
+ if (!lines[i].includes('"usage"')) continue;
29
+ try {
30
+ const j = JSON.parse(lines[i]);
31
+ const u = j?.message?.usage;
32
+ if (u && typeof u.input_tokens === "number") {
33
+ return u.input_tokens + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
34
+ }
35
+ } catch {
36
+ }
37
+ }
38
+ } catch {
39
+ }
40
+ return void 0;
41
+ }
42
+ async function statusline(input, now = Date.now(), home) {
43
+ const files = await findTranscripts(void 0, home);
44
+ const recent = [];
45
+ await Promise.all(
46
+ files.map(async (f) => {
47
+ try {
48
+ if ((await stat(f)).mtimeMs >= now - Math.max(LOOKBACK_MS, msSinceMidnight(now) + 6e4))
49
+ recent.push(f);
50
+ } catch {
51
+ }
52
+ })
53
+ );
54
+ const seen = /* @__PURE__ */ new Map();
55
+ const seenTools = /* @__PURE__ */ new Set();
56
+ const events = [];
57
+ const sessions = await Promise.all(recent.map((f) => parseTranscript(f, seen, seenTools, events)));
58
+ const today = dayKey(now);
59
+ let todayCost = 0;
60
+ let sessionCost = input.cost?.total_cost_usd;
61
+ for (const s of sessions) {
62
+ for (const [model2, u] of Object.entries(s.dailyUsage[today] ?? {})) todayCost += costUsd(u, model2).total;
63
+ if (sessionCost === void 0 && input.transcript_path && s.file === input.transcript_path) {
64
+ sessionCost = Object.entries(s.usageByModel).reduce((sum, [m, u]) => sum + costUsd(u, m).total, 0);
65
+ }
66
+ }
67
+ const blocks = buildBlocks(events, { now });
68
+ const active = blocks.find((b) => b.active);
69
+ const rate = active ? burnRate(active, now) : void 0;
70
+ const model = input.model?.display_name ?? shortModel(input.model?.id ?? "?");
71
+ const parts = [`\u{1F916} ${model}`];
72
+ const costs = [
73
+ sessionCost !== void 0 ? `${usd(sessionCost)} session` : void 0,
74
+ `${usd(todayCost)} today`,
75
+ active ? `${usd(active.costUsd)} block (${fmtMs(active.end - now)} left)` : void 0
76
+ ].filter(Boolean);
77
+ parts.push(`\u{1F4B0} ${costs.join(" / ")}`);
78
+ if (rate) parts.push(`\u{1F525} ${usd(rate.costPerHour)}/hr`);
79
+ const ctx = input.transcript_path ? await lastContextTokens(input.transcript_path) : void 0;
80
+ if (ctx !== void 0) {
81
+ const p = Math.round(ctx / CONTEXT_WINDOW * 100);
82
+ const colored = p >= 80 ? pc.red(`${p}%`) : p >= 50 ? pc.yellow(`${p}%`) : pc.green(`${p}%`);
83
+ parts.push(`\u{1F9E0} ${compact(ctx)} (${colored})`);
84
+ }
85
+ return parts.join(pc.dim(" | "));
86
+ }
87
+ function msSinceMidnight(now) {
88
+ const day = dayKey(now);
89
+ const midnight = Date.parse(`${day}T00:00:00Z`);
90
+ const diff = now - midnight;
91
+ return Math.min(Math.max(diff, 0), 36 * 60 * 60 * 1e3);
92
+ }
93
+ async function readStdin() {
94
+ let data = "";
95
+ for await (const chunk of process.stdin) data += chunk;
96
+ return data;
97
+ }
98
+ var STATUSLINE_COMMAND = "npx -y @tokz/cli statusline";
99
+ function settingsPath(home = homedir()) {
100
+ return join(home, ".claude", "settings.json");
101
+ }
102
+ async function readSettings(file) {
103
+ let raw;
104
+ try {
105
+ raw = await readFile(file, "utf8");
106
+ } catch {
107
+ return {};
108
+ }
109
+ const parsed = JSON.parse(raw);
110
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
111
+ throw new Error(`${file} is not a JSON object`);
112
+ return parsed;
113
+ }
114
+ async function enableStatusline(home) {
115
+ const file = settingsPath(home);
116
+ const settings = await readSettings(file);
117
+ const prev = settings.statusLine;
118
+ if (prev?.command === STATUSLINE_COMMAND) return `Already enabled in ${file}.`;
119
+ settings.statusLine = { type: "command", command: STATUSLINE_COMMAND };
120
+ await mkdir(join(file, ".."), { recursive: true });
121
+ await writeFile(file, `${JSON.stringify(settings, null, 2)}
122
+ `);
123
+ const note = prev?.command ? ` (replaced previous statusLine: ${prev.command})` : "";
124
+ return `Statusline enabled \u2014 Claude Code will run "${STATUSLINE_COMMAND}".${note}
125
+ Restart Claude Code (or start a new session) to see it.`;
126
+ }
127
+ async function disableStatusline(home) {
128
+ const file = settingsPath(home);
129
+ const settings = await readSettings(file);
130
+ const prev = settings.statusLine;
131
+ if (!prev) return `No statusLine configured in ${file}; nothing to do.`;
132
+ if (prev.command !== void 0 && !prev.command.includes("tokz") && !prev.command.includes("@tokz/cli"))
133
+ return `statusLine in ${file} is "${prev.command}" \u2014 not tokz, leaving it alone.`;
134
+ delete settings.statusLine;
135
+ await writeFile(file, `${JSON.stringify(settings, null, 2)}
136
+ `);
137
+ return `Statusline disabled \u2014 removed from ${file}.`;
138
+ }
139
+ export {
140
+ STATUSLINE_COMMAND,
141
+ disableStatusline,
142
+ enableStatusline,
143
+ readStdin,
144
+ statusline
145
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokz/cli",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Audit where your coding agent's context window and API dollars go.",
5
5
  "type": "module",
6
6
  "license": "MIT",