tokenmaxxing-cli 0.1.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/dist/store.js ADDED
@@ -0,0 +1,59 @@
1
+ import { appendFileSync, mkdirSync, statSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { bucketKey } from './bucket.js';
4
+ import { files } from './paths.js';
5
+ import { writeFileAtomic, isFile } from './fsutil.js';
6
+ import { scanLines, parseLine } from './lines.js';
7
+ import { COUNT_FIELDS, SOURCES } from './types.js';
8
+ export const COMPACT_THRESHOLD = 5 * 1024 * 1024;
9
+ /** Only these keys ever leave the machine (push) or get written. */
10
+ export function cleanRow(b) {
11
+ const out = { v: 1, ts: b.ts, source: b.source, model: b.model };
12
+ for (const f of COUNT_FIELDS)
13
+ out[f] = typeof b[f] === 'number' && Number.isFinite(b[f]) ? b[f] : 0;
14
+ return out;
15
+ }
16
+ function valid(o) {
17
+ return o && o.v === 1 && typeof o.ts === 'string' && SOURCES.includes(o.source) && typeof o.model === 'string';
18
+ }
19
+ /** Read buckets.jsonl keeping the last row per (ts, source, model). */
20
+ export async function loadBuckets(path = files.buckets()) {
21
+ const rows = new Map();
22
+ let lines = 0;
23
+ let bad = 0;
24
+ if (!isFile(path))
25
+ return { rows, lines, bad, bytes: 0 };
26
+ const size = statSync(path).size;
27
+ await scanLines(path, 0, size, (line) => {
28
+ lines++;
29
+ const o = parseLine(line);
30
+ if (!valid(o)) {
31
+ bad++;
32
+ return;
33
+ }
34
+ rows.set(bucketKey(o), cleanRow(o));
35
+ });
36
+ return { rows, lines, bad, bytes: size };
37
+ }
38
+ export function appendBuckets(rows, path = files.buckets()) {
39
+ if (!rows.length)
40
+ return;
41
+ mkdirSync(dirname(path), { recursive: true });
42
+ appendFileSync(path, rows.map((r) => JSON.stringify(cleanRow(r))).join('\n') + '\n');
43
+ }
44
+ export function sortRows(rows) {
45
+ return [...rows].sort((a, b) => a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : a.source < b.source ? -1 : a.source > b.source ? 1 : a.model < b.model ? -1 : a.model > b.model ? 1 : 0);
46
+ }
47
+ /** Rewrite the file keeping only the last row per key. */
48
+ export function compactBuckets(rows, path = files.buckets()) {
49
+ const body = sortRows(rows.values()).map((r) => JSON.stringify(cleanRow(r))).join('\n');
50
+ writeFileAtomic(path, body ? body + '\n' : '');
51
+ }
52
+ export function bucketsFileSize(path = files.buckets()) {
53
+ try {
54
+ return statSync(path).size;
55
+ }
56
+ catch {
57
+ return 0;
58
+ }
59
+ }
package/dist/sync.js ADDED
@@ -0,0 +1,115 @@
1
+ import { closeSync, mkdirSync, openSync, rmSync, statSync, writeSync } from 'node:fs';
2
+ import { addInto, emptyBucket, bucketKey, isZero, BucketAccumulator } from './bucket.js';
3
+ import { loadConfig } from './config.js';
4
+ import { loadCursors, saveCursors, sourceCursor } from './cursors.js';
5
+ import { BoundedSet } from './dedup.js';
6
+ import { files, tmxHome } from './paths.js';
7
+ import { appendBuckets, bucketsFileSize, compactBuckets, COMPACT_THRESHOLD, loadBuckets } from './store.js';
8
+ import { DEDUP_LIMIT, newStats } from './parsers/context.js';
9
+ import * as claude from './parsers/claude.js';
10
+ import * as codex from './parsers/codex.js';
11
+ import * as gemini from './parsers/gemini.js';
12
+ import * as cursor from './parsers/cursor.js';
13
+ import { COUNT_FIELDS } from './types.js';
14
+ export const PARSERS = {
15
+ claude: claude.parse,
16
+ codex: codex.parse,
17
+ gemini: gemini.parse,
18
+ cursor: cursor.parse,
19
+ };
20
+ const LOCK_STALE_MS = 10 * 60 * 1000;
21
+ function acquireLock() {
22
+ mkdirSync(tmxHome(), { recursive: true });
23
+ const path = files.lock();
24
+ for (let attempt = 0; attempt < 2; attempt++) {
25
+ try {
26
+ const fd = openSync(path, 'wx');
27
+ writeSync(fd, String(process.pid));
28
+ closeSync(fd);
29
+ return true;
30
+ }
31
+ catch {
32
+ try {
33
+ if (Date.now() - statSync(path).mtimeMs > LOCK_STALE_MS) {
34
+ rmSync(path, { force: true });
35
+ continue;
36
+ }
37
+ }
38
+ catch {
39
+ continue;
40
+ }
41
+ return false;
42
+ }
43
+ }
44
+ return false;
45
+ }
46
+ function releaseLock() {
47
+ rmSync(files.lock(), { force: true });
48
+ }
49
+ /** Incrementally parse every enabled source and append replacement rows for affected hours. */
50
+ export async function sync(cfg = loadConfig(), onProgress) {
51
+ const t0 = Date.now();
52
+ if (!acquireLock())
53
+ return { ms: 0, rowsWritten: 0, perSource: {}, compacted: false, skippedLocked: true };
54
+ try {
55
+ const cursors = loadCursors();
56
+ const deltas = new Map();
57
+ const perSource = {};
58
+ for (const name of Object.keys(PARSERS)) {
59
+ const sc = cfg.sources[name];
60
+ if (!sc?.enabled || !sc.paths.length)
61
+ continue;
62
+ if (name === 'cursor')
63
+ continue; // activity only: nothing to read in v1 (see parsers/cursor.ts)
64
+ onProgress?.(`scanning ${name}…`);
65
+ const cur = sourceCursor(cursors, name);
66
+ const dedup = new BoundedSet(DEDUP_LIMIT, cur.dedup);
67
+ const ctx = { paths: sc.paths, cursor: cur, dedup, acc: new BucketAccumulator(), stats: newStats() };
68
+ const rows = await PARSERS[name](ctx);
69
+ cur.dedup = dedup.toJSON();
70
+ cur.badLines += ctx.stats.badLines;
71
+ cur.lastSync = new Date().toISOString();
72
+ cur.lastFilesScanned = ctx.stats.filesSeen;
73
+ cur.lastFilesChanged = ctx.stats.filesChanged;
74
+ perSource[name] = ctx.stats;
75
+ for (const r of rows) {
76
+ if (isZero(r))
77
+ continue;
78
+ const k = bucketKey(r);
79
+ const d = deltas.get(k);
80
+ if (d)
81
+ addInto(d, r);
82
+ else
83
+ deltas.set(k, { ...r });
84
+ }
85
+ }
86
+ let rowsWritten = 0;
87
+ let compacted = false;
88
+ if (deltas.size) {
89
+ const existing = await loadBuckets();
90
+ const out = [];
91
+ for (const [k, d] of deltas) {
92
+ const next = { ...(existing.rows.get(k) ?? emptyBucket(d.ts, d.source, d.model)) };
93
+ addInto(next, d);
94
+ // A replaced Claude contribution is subtracted; never let a row go negative if the stores disagree.
95
+ for (const f of COUNT_FIELDS)
96
+ if (next[f] < 0)
97
+ next[f] = 0;
98
+ existing.rows.set(k, next);
99
+ out.push(next);
100
+ }
101
+ appendBuckets(out);
102
+ rowsWritten = out.length;
103
+ // Compact only when there is something to drop, so a large-but-dense file isn't rewritten every sync.
104
+ if (bucketsFileSize() > COMPACT_THRESHOLD && existing.lines + out.length > existing.rows.size * 1.2) {
105
+ compactBuckets(existing.rows);
106
+ compacted = true;
107
+ }
108
+ }
109
+ saveCursors(cursors);
110
+ return { ms: Date.now() - t0, rowsWritten, perSource, compacted };
111
+ }
112
+ finally {
113
+ releaseLock();
114
+ }
115
+ }
package/dist/types.js ADDED
@@ -0,0 +1,9 @@
1
+ export const SOURCES = ['claude', 'codex', 'gemini', 'cursor'];
2
+ export const PROVIDER_SOURCE = {
3
+ claude: 'claude',
4
+ openai: 'codex',
5
+ cursor: 'cursor',
6
+ google: 'gemini',
7
+ };
8
+ export const TOKEN_FIELDS = ['input', 'cache_read', 'cache_write_5m', 'cache_write_1h', 'output', 'reasoning'];
9
+ export const COUNT_FIELDS = [...TOKEN_FIELDS, 'requests', 'conversations'];
package/dist/verify.js ADDED
@@ -0,0 +1,118 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { fmtInt, fmtUsd, table } from './format.js';
3
+ import { inPeriod } from './period.js';
4
+ import { cost, resolve } from './pricing/index.js';
5
+ export const TOLERANCE = 0.01;
6
+ const METRICS = ['input', 'output', 'cache_write', 'cache_read'];
7
+ /** Output is informational: ccusage keeps the first line of a multi-line message, we keep the final usage. */
8
+ const STRICT = ['input', 'cache_write', 'cache_read'];
9
+ export const OUTPUT_NOTE = 'expected higher than ccusage: tokenmaxxing counts the final usage of multi-line messages';
10
+ const zero = () => ({ input: 0, output: 0, cache_write: 0, cache_read: 0, cost: 0 });
11
+ export function ccusageTotals(j, sinceDate) {
12
+ const m = new Map();
13
+ for (const d of j.daily ?? []) {
14
+ if (d.date < sinceDate)
15
+ continue;
16
+ for (const b of d.modelBreakdowns ?? []) {
17
+ if (!b.modelName || b.modelName === '<synthetic>')
18
+ continue;
19
+ const t = m.get(b.modelName) ?? zero();
20
+ t.input += b.inputTokens ?? 0;
21
+ t.output += b.outputTokens ?? 0;
22
+ t.cache_write += b.cacheCreationTokens ?? 0;
23
+ t.cache_read += b.cacheReadTokens ?? 0;
24
+ t.cost += b.cost ?? 0;
25
+ m.set(b.modelName, t);
26
+ }
27
+ }
28
+ return m;
29
+ }
30
+ export function ourTotals(buckets, period) {
31
+ const m = new Map();
32
+ for (const b of buckets) {
33
+ if (b.source !== 'claude' || !inPeriod(b.ts, period))
34
+ continue;
35
+ const t = m.get(b.model) ?? zero();
36
+ t.input += b.input;
37
+ t.output += b.output;
38
+ t.cache_write += b.cache_write_5m + b.cache_write_1h;
39
+ t.cache_read += b.cache_read;
40
+ const r = resolve(b.model);
41
+ if (r)
42
+ t.cost += cost(b, r);
43
+ m.set(b.model, t);
44
+ }
45
+ return m;
46
+ }
47
+ function delta(ours, theirs) {
48
+ if (ours === theirs)
49
+ return 0;
50
+ return (ours - theirs) / Math.max(Math.abs(theirs), 1);
51
+ }
52
+ export function compare(ours, theirs) {
53
+ const models = [...new Set([...ours.keys(), ...theirs.keys()])].sort();
54
+ const rows = [];
55
+ const tOurs = zero();
56
+ const tTheirs = zero();
57
+ let pass = true;
58
+ const failed = [];
59
+ const status = (k, d, label) => {
60
+ if (!STRICT.includes(k))
61
+ return '(info)';
62
+ if (Math.abs(d) <= TOLERANCE)
63
+ return 'ok';
64
+ pass = false;
65
+ failed.push(label);
66
+ return 'FAIL';
67
+ };
68
+ for (const model of models) {
69
+ const a = ours.get(model) ?? zero();
70
+ const b = theirs.get(model) ?? zero();
71
+ if (METRICS.every((k) => a[k] === 0 && b[k] === 0))
72
+ continue;
73
+ METRICS.forEach((k, i) => {
74
+ const d = delta(a[k], b[k]);
75
+ rows.push([i === 0 ? model : '', k, fmtInt(a[k]), fmtInt(b[k]), fmtPct(d), status(k, d, `${model} ${k}`)]);
76
+ });
77
+ rows.push(['', 'cost', fmtUsd(a.cost), fmtUsd(b.cost), fmtPct(delta(a.cost, b.cost)), '(info)']);
78
+ for (const k of [...METRICS, 'cost']) {
79
+ tOurs[k] += a[k];
80
+ tTheirs[k] += b[k];
81
+ }
82
+ }
83
+ METRICS.forEach((k, i) => {
84
+ const d = delta(tOurs[k], tTheirs[k]);
85
+ rows.push([i === 0 ? 'TOTAL' : '', k, fmtInt(tOurs[k]), fmtInt(tTheirs[k]), fmtPct(d), status(k, d, `TOTAL ${k}`)]);
86
+ });
87
+ rows.push(['', 'cost', fmtUsd(tOurs.cost), fmtUsd(tTheirs.cost), fmtPct(delta(tOurs.cost, tTheirs.cost)), '(info)']);
88
+ const lines = table(['MODEL', 'METRIC', 'TOKENMAXXING', 'CCUSAGE', 'DELTA', ''], rows, ['l', 'l', 'r', 'r', 'r', 'l']);
89
+ lines.push('');
90
+ lines.push(pass
91
+ ? `PASS — input, cache_write and cache_read within ${TOLERANCE * 100}% of ccusage.`
92
+ : `FAIL — outside ${TOLERANCE * 100}% tolerance: ${failed.join(', ')}`);
93
+ const outDelta = delta(tOurs.output, tTheirs.output);
94
+ lines.push(`Output ${fmtPct(outDelta)} vs ccusage (informational, not part of pass/fail): ${OUTPUT_NOTE}.`);
95
+ lines.push('Cost is informational and differs by design: ccusage prices every cache write at the 5-minute rate, while tokenmaxxing', 'prices 1-hour cache writes at the 1-hour rate, and uses the bundled pricing snapshot instead of live prices.');
96
+ return { pass, lines };
97
+ }
98
+ export function fmtPct(d) {
99
+ const p = d * 100;
100
+ return (p >= 0 ? '+' : '') + p.toFixed(2) + '%';
101
+ }
102
+ export function runCcusage(sinceYmd) {
103
+ return new Promise((resolveP) => {
104
+ execFile('ccusage', ['daily', '--since', sinceYmd, '--json'], { maxBuffer: 512 * 1024 * 1024, timeout: 10 * 60 * 1000 }, (err, stdout, stderr) => {
105
+ if (err) {
106
+ const missing = err.code === 'ENOENT';
107
+ resolveP({ ok: false, missing, error: missing ? 'ccusage not found on PATH' : (stderr || err.message).trim() });
108
+ return;
109
+ }
110
+ try {
111
+ resolveP({ ok: true, json: JSON.parse(stdout) });
112
+ }
113
+ catch (e) {
114
+ resolveP({ ok: false, missing: false, error: `could not parse ccusage output: ${e.message}` });
115
+ }
116
+ });
117
+ });
118
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "tokenmaxxing-cli",
3
+ "version": "0.1.0",
4
+ "description": "See how much API-equivalent value your AI coding subscriptions deliver. Reads local Claude Code / Codex / Gemini CLI logs only. Zero dependencies, no telemetry.",
5
+ "type": "module",
6
+ "bin": {
7
+ "tokenmaxxing": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
16
+ "scripts": {
17
+ "build": "node scripts/clean.mjs && tsc -p tsconfig.json && node scripts/copy-assets.mjs",
18
+ "pretest": "npm run build",
19
+ "test": "node --test test/*.test.mjs",
20
+ "update-pricing": "node scripts/update-pricing.mjs",
21
+ "prepack": "npm run build"
22
+ },
23
+ "keywords": [
24
+ "claude-code",
25
+ "codex",
26
+ "gemini-cli",
27
+ "tokens",
28
+ "usage",
29
+ "cost",
30
+ "roi"
31
+ ],
32
+ "homepage": "https://tokenmaxxing.fyi",
33
+ "license": "MIT",
34
+ "devDependencies": {
35
+ "@types/node": "^20.19.0",
36
+ "typescript": "^7.0.2"
37
+ },
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/soycarts/tokenmaxxing.git",
41
+ "directory": "packages/cli"
42
+ },
43
+ "bugs": {
44
+ "url": "https://github.com/soycarts/tokenmaxxing/issues"
45
+ }
46
+ }