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/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # tokenmaxxing-cli
2
+
3
+ Paste this into your coding agent:
4
+
5
+ ```
6
+ Set up tokenmaxxing for me: run `npx tokenmaxxing-cli@latest init`, show me the report it prints,
7
+ and if I like it run `npx tokenmaxxing-cli link` and open the URL it prints.
8
+ Don't edit any of my tool config files unless I say so.
9
+ ```
10
+
11
+ `tokenmaxxing` reads the usage logs your AI coding tools already keep on disk (Claude Code, Codex,
12
+ Gemini CLI), prices every token at API rates, and tells you what your subscription is worth:
13
+
14
+ ```
15
+ tokenmaxxing · last 30 days · 2026-08-23 → 2026-09-22 · pricing snapshot 2026-09-22
16
+
17
+ SOURCE MODEL INPUT CACHE R CACHE W OUTPUT API-EQUIV
18
+ claude claude-opus-5-5 2.9k 317.5M 8.9M 217.9k $112.38
19
+ codex gpt-6-astra 24.4M 1.1B 0 2.5M $1,500.63
20
+ ──────────────────────────────────────────────────────────────────────────
21
+ TOTAL 24.4M 1.4B 8.9M 2.7M $1,613.01
22
+
23
+ Plans: claude 1× max-20x = $200/mo → API-equivalent $112.38 → ROI 0.6×
24
+ Unpriced models (0 tokens counted toward $): none
25
+ ```
26
+
27
+ The package name is `tokenmaxxing-cli`; the command it installs is `tokenmaxxing`. (The bare
28
+ `tokenmaxxing` package on npm is an unrelated project.)
29
+
30
+ ## Privacy
31
+
32
+ - **Local logs only.** It reads `~/.claude/projects/**/*.jsonl` (or `CLAUDE_CONFIG_DIR`),
33
+ `~/.codex/sessions` + `archived_sessions` (or `CODEX_HOME`) and `~/.gemini/tmp/*/chats/*.json`.
34
+ From those lines it keeps token counts, model names and timestamps; prompts, code, paths and
35
+ project names are never stored.
36
+ - **Never touches sign-in data.** No keychain access, no auth or token files of any tool, no provider
37
+ API calls. A test fails the build if the source ever mentions them.
38
+ - **No telemetry.** Nothing leaves your machine unless you run `tokenmaxxing push`, which prints exactly
39
+ what it will send (hourly bucket rows: timestamp, source, model, token counts) before sending it.
40
+ - **Never edits other tools' configs** unless you run `tokenmaxxing hook install` and confirm the diff.
41
+ - **Zero runtime dependencies.** Pricing is a bundled snapshot of LiteLLM + models.dev; unknown models
42
+ are listed as *unpriced*, never silently counted as $0.
43
+
44
+ Everything it writes lives in `~/.tokenmaxxing/` (override with `TOKENMAXXING_HOME`):
45
+ `config.json`, `buckets.jsonl` (one row per hour × source × model) and `cursors.json` (per-file read
46
+ offsets so each `sync` only reads what's new).
47
+
48
+ ## Commands
49
+
50
+ ```
51
+ tokenmaxxing init detect tools, write config, first sync, print report
52
+ tokenmaxxing sync [--quiet] incremental parse of all detected sources
53
+ tokenmaxxing report [--since 7d|30d|YYYY-MM-DD] [--json] [--by model|source|day]
54
+ tokenmaxxing plan set <provider> <plan> [xN] replace a provider's plans (qty N, default 1)
55
+ tokenmaxxing plan add <provider> <plan> [xN] add a line; several plans per provider add up
56
+ claude: pro | max-5x | max-20x | team-standard | team-premium
57
+ openai: go | plus | pro-100 | pro | business (ChatGPT; Codex is in Plus/Pro/Business)
58
+ cursor: pro | pro-plus | ultra | teams
59
+ google: ai-plus | ai-pro | ai-ultra-100 | ai-ultra
60
+ any: custom <monthly> "<label>", e.g. plan add openai custom 100 "Codex $100 promo"
61
+ tokenmaxxing plan remove <provider> [<plan>] remove one line, or all of a provider's plans
62
+ tokenmaxxing plan list your lines, monthly totals and every plan id with its price
63
+ tokenmaxxing verify [--since 30d] compare Claude totals with `ccusage` (if installed), 1% tolerance
64
+ tokenmaxxing link [--site URL] prints a sign-in URL, waits for the site, stores the token
65
+ tokenmaxxing push [--site URL] [--dry-run] [--all] [--granularity hour|day|week]
66
+ upload bucket rows (opt-in); prints what is sent
67
+ tokenmaxxing granularity [set hour|day|week] hour (default) uploads hourly rows; day or week uploads
68
+ totals only, so the site never sees which hours you work
69
+ tokenmaxxing hook install|uninstall|status [--yes] Claude Code Stop hook + Codex notify hook running `sync`
70
+ tokenmaxxing schedule install|uninstall|status [--yes] launchd (macOS) / cron (Linux), every 30 min
71
+ tokenmaxxing doctor paths found, files scanned, cursor state, pricing snapshot date
72
+ tokenmaxxing --version
73
+ ```
74
+
75
+ ROI = API-equivalent cost for the period ÷ (plan price × days ÷ 30.4375).
76
+
77
+ Notes:
78
+
79
+ - Cursor is detected but excluded from totals: its local database has no token counts.
80
+ - `verify` groups by local calendar day because that is how `ccusage` groups. It checks input and cache tokens
81
+ at 1%; output is informational and expected higher, because ccusage counts only the first line of a message
82
+ that Claude Code writes as several lines, while tokenmaxxing counts its final usage. Costs differ from ccusage
83
+ by design: ccusage prices every cache write at the 5-minute rate; tokenmaxxing prices 1-hour cache
84
+ writes at the 1-hour rate.
85
+ - `--site` (or `TOKENMAXXING_SITE`) points `link`/`push` at another server, e.g. `http://localhost:3000`.
86
+ - Changing granularity (or `push --all`) tells the site to replace every row it holds for this device, so
87
+ switching between hourly and daily totals never double counts. The current day or week is resent on each
88
+ push until it is complete.
89
+ - Custom prices: `src/pricing/overrides.json` in a checkout (`{ "<model>": { "input": 3, "output": 15,
90
+ "cache_read": 0.3, "cache_write_5m": 3.75, "cache_write_1h": 6 } }`, USD per million tokens) wins over
91
+ the snapshots; `{ "<model>": { "unpriced": "<note>" } }` keeps a model unpriced and shows the note (used for
92
+ Codex's bundled `codex-auto-review` and `gpt-reserve`, which have no list price).
93
+
94
+ MIT licensed.
package/dist/bucket.js ADDED
@@ -0,0 +1,56 @@
1
+ import { COUNT_FIELDS } from './types.js';
2
+ export function emptyBucket(ts, source, model) {
3
+ return {
4
+ v: 1, ts, source, model,
5
+ input: 0, cache_read: 0, cache_write_5m: 0, cache_write_1h: 0, output: 0, reasoning: 0,
6
+ requests: 0, conversations: 0,
7
+ };
8
+ }
9
+ export function bucketKey(b) {
10
+ return `${b.ts}|${b.source}|${b.model}`;
11
+ }
12
+ /** Floor an ISO timestamp (or epoch ms) to the UTC hour: `2026-09-22T13:00:00Z`. Returns null if invalid. */
13
+ export function hourOf(t) {
14
+ if (t === undefined || t === null || t === '')
15
+ return null;
16
+ const ms = typeof t === 'number' ? t : Date.parse(t);
17
+ if (!Number.isFinite(ms))
18
+ return null;
19
+ return new Date(ms).toISOString().slice(0, 13) + ':00:00Z';
20
+ }
21
+ /** Accumulates per-(hour, source, model) deltas produced by a parser run. */
22
+ export class BucketAccumulator {
23
+ map = new Map();
24
+ get(ts, source, model) {
25
+ const key = `${ts}|${source}|${model}`;
26
+ let b = this.map.get(key);
27
+ if (!b) {
28
+ b = emptyBucket(ts, source, model);
29
+ this.map.set(key, b);
30
+ }
31
+ return b;
32
+ }
33
+ addUsage(ts, source, model, u, requests = 1) {
34
+ const b = this.get(ts, source, model);
35
+ b.input += u.input;
36
+ b.cache_read += u.cache_read;
37
+ b.cache_write_5m += u.cache_write_5m;
38
+ b.cache_write_1h += u.cache_write_1h;
39
+ b.output += u.output;
40
+ b.reasoning += u.reasoning;
41
+ b.requests += requests;
42
+ }
43
+ addConversation(ts, source, model, n = 1) {
44
+ this.get(ts, source, model).conversations += n;
45
+ }
46
+ rows() {
47
+ return [...this.map.values()];
48
+ }
49
+ }
50
+ export function addInto(target, delta) {
51
+ for (const f of COUNT_FIELDS)
52
+ target[f] += delta[f];
53
+ }
54
+ export function isZero(b) {
55
+ return COUNT_FIELDS.every((f) => b[f] === 0);
56
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,283 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { configExists, loadConfig, saveConfig } from './config.js';
4
+ import { isGranularity } from './config.js';
5
+ import { detect, detectionToSources } from './detect.js';
6
+ import { doctor } from './doctor.js';
7
+ import { fmtInt, fmtPrice, table } from './format.js';
8
+ import { hookCommand } from './hook.js';
9
+ import { parsePeriod } from './period.js';
10
+ import { addLine, CUSTOM, describeLines, isProvider, lineName, parsePlanSpec, planTable, plansMonthly, PROVIDERS, providerMonthly, unitPrice, } from './plans.js';
11
+ import { buildReport, renderReport, reportJson } from './report.js';
12
+ import { scheduleCommand } from './schedule.js';
13
+ import { link, push, SiteError } from './site.js';
14
+ import { loadBuckets } from './store.js';
15
+ import { sync } from './sync.js';
16
+ import { compare, ccusageTotals, ourTotals, runCcusage } from './verify.js';
17
+ import { SOURCES } from './types.js';
18
+ const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
19
+ const HELP = `tokenmaxxing ${VERSION} — API-equivalent value of your AI coding subscriptions, from local logs.
20
+
21
+ Usage: tokenmaxxing <command> [options]
22
+
23
+ init detect tools, write config, first sync, print report
24
+ sync [--quiet] incremental parse of all detected sources
25
+ report [--since 7d|30d|YYYY-MM-DD] [--json] [--by model|source|day]
26
+ plan set <provider> <plan> [xN] replace a provider's plans, e.g. plan set claude max-20x x5 · plan set openai pro
27
+ plan add <provider> <plan> [xN] add a line, e.g. plan add claude pro · plan add openai custom 100 "Codex $100"
28
+ plan remove <provider> [<plan>] remove one line, or all of a provider's plans (plan set <provider> none also clears)
29
+ plan list
30
+ verify [--since 30d] compare Claude totals with ccusage (if installed)
31
+ link [--site URL] connect this machine to tokenmaxxing.fyi
32
+ push [--site URL] [--dry-run] [--all] [--granularity hour|day|week]
33
+ upload bucket rows (opt-in); prints what is sent; --all resends everything
34
+ granularity [set hour|day|week] how coarse uploaded rows are (default hour; day/week hide your working hours)
35
+ hook install|uninstall|status [--yes] opt-in Claude Code Stop / Codex notify hook
36
+ schedule install|uninstall|status [--yes] run sync every 30 min (launchd / cron)
37
+ doctor paths, files scanned, cursor state, pricing snapshot
38
+ --version
39
+
40
+ Data lives in ~/.tokenmaxxing (override with TOKENMAXXING_HOME). No telemetry.`;
41
+ const VALUE_FLAGS = new Set(['since', 'by', 'site', 'qty']);
42
+ export function parseArgs(argv) {
43
+ const out = { pos: [], flags: {} };
44
+ for (let i = 0; i < argv.length; i++) {
45
+ const a = argv[i];
46
+ if (a === '-h')
47
+ out.flags.help = true;
48
+ else if (a === '-v')
49
+ out.flags.version = true;
50
+ else if (a === '-y')
51
+ out.flags.yes = true;
52
+ else if (a.startsWith('--')) {
53
+ const eq = a.indexOf('=');
54
+ const name = eq === -1 ? a.slice(2) : a.slice(2, eq);
55
+ if (eq !== -1)
56
+ out.flags[name] = a.slice(eq + 1);
57
+ else if (VALUE_FLAGS.has(name) && i + 1 < argv.length && !argv[i + 1].startsWith('--'))
58
+ out.flags[name] = argv[++i];
59
+ else
60
+ out.flags[name] = true;
61
+ }
62
+ else if (!out.cmd)
63
+ out.cmd = a;
64
+ else
65
+ out.pos.push(a);
66
+ }
67
+ return out;
68
+ }
69
+ const str = (v) => (typeof v === 'string' ? v : undefined);
70
+ function syncSummary(r) {
71
+ const parts = Object.entries(r.perSource).map(([s, st]) => `${s} ${fmtInt(st.filesSeen)} files (${fmtInt(st.filesChanged)} changed, ${(st.bytesRead / 1e6).toFixed(0)} MB read)`);
72
+ return `Synced ${parts.join(' · ') || 'nothing (no sources enabled)'} → ${fmtInt(r.rowsWritten)} bucket rows in ${(r.ms / 1000).toFixed(1)}s`;
73
+ }
74
+ async function printReport(cfg, a) {
75
+ const period = parsePeriod(str(a.flags.since));
76
+ const byFlag = str(a.flags.by);
77
+ if (byFlag && !['model', 'source', 'day'].includes(byFlag))
78
+ throw new UsageError('--by expects model, source or day');
79
+ const by = (byFlag ?? 'source-model');
80
+ const { rows } = await loadBuckets();
81
+ const rep = buildReport(rows.values(), period, cfg, by);
82
+ console.log(a.flags.json ? reportJson(rep) : renderReport(rep));
83
+ return 0;
84
+ }
85
+ class UsageError extends Error {
86
+ }
87
+ async function main(argv) {
88
+ const a = parseArgs(argv);
89
+ if (a.flags.version || a.cmd === 'version') {
90
+ console.log(VERSION);
91
+ return 0;
92
+ }
93
+ if (a.flags.help || !a.cmd || a.cmd === 'help') {
94
+ console.log(HELP);
95
+ return a.cmd || a.flags.help ? 0 : 1;
96
+ }
97
+ const quiet = !!a.flags.quiet;
98
+ const yes = !!a.flags.yes;
99
+ switch (a.cmd) {
100
+ case 'init': {
101
+ const det = detect();
102
+ const cfg = loadConfig();
103
+ cfg.sources = detectionToSources(det);
104
+ saveConfig(cfg);
105
+ for (const s of SOURCES) {
106
+ const d = det[s];
107
+ if (s === 'cursor')
108
+ console.log(`cursor ${d.found ? `detected — ${d.note}` : 'not found'}`);
109
+ else
110
+ console.log(`${s.padEnd(7)} ${d.found ? `found: ${d.paths.join(', ')}` : 'not found (disabled)'}`);
111
+ }
112
+ const r = await sync(cfg, (m) => process.stderr.write(`${m}\n`));
113
+ console.log(r.skippedLocked ? 'Another sync is running; showing current data.' : syncSummary(r));
114
+ console.log('');
115
+ await printReport(cfg, a);
116
+ console.log('');
117
+ console.log('Next: tokenmaxxing plan set <provider> <plan> (for ROI) · tokenmaxxing link (to publish)');
118
+ return 0;
119
+ }
120
+ case 'sync': {
121
+ // Extra positional args are ignored (Codex `notify` appends a JSON payload).
122
+ const cfg = ensureConfig(quiet);
123
+ const r = await sync(cfg, quiet ? undefined : (m) => process.stderr.write(`${m}\n`));
124
+ if (!quiet)
125
+ console.log(r.skippedLocked ? 'Another sync is already running.' : syncSummary(r));
126
+ return 0;
127
+ }
128
+ case 'report': {
129
+ const cfg = ensureConfig(false);
130
+ return printReport(cfg, a);
131
+ }
132
+ case 'granularity': {
133
+ const cfg = loadConfig();
134
+ const [sub, val] = a.pos;
135
+ if (sub === 'set') {
136
+ if (!val || !isGranularity(val))
137
+ throw new UsageError('granularity must be hour, day or week');
138
+ cfg.site.granularity = val;
139
+ saveConfig(cfg);
140
+ console.log(`Uploads will be ${val}ly totals${val === 'hour' ? '' : '; the site will not see which hours you work'}. The next push replaces this device's rows on the site.`);
141
+ return 0;
142
+ }
143
+ const cur = cfg.site.granularity ?? 'hour';
144
+ console.log(`${cur} (hour = most detail on your profile · day/week = the site never sees your working hours)\nSet with: tokenmaxxing granularity set hour|day|week`);
145
+ return 0;
146
+ }
147
+ case 'plan':
148
+ return planCommand(a);
149
+ case 'verify': {
150
+ const cfg = ensureConfig(true);
151
+ const period = parsePeriod(str(a.flags.since) ?? '30d');
152
+ const since = period.sinceDate.replace(/-/g, '');
153
+ const cc = await runCcusage(since);
154
+ if (!cc.ok && cc.missing) {
155
+ console.log('ccusage is not installed, so there is nothing to compare against.');
156
+ console.log('Install it with `npm i -g ccusage` (or run `npx ccusage@latest daily`), then re-run `tokenmaxxing verify`.');
157
+ return 0;
158
+ }
159
+ if (!cc.ok) {
160
+ console.error(`ccusage failed: ${cc.error}`);
161
+ return 1;
162
+ }
163
+ // Sync after ccusage so our side has seen at least everything ccusage saw.
164
+ await sync(cfg);
165
+ const { rows } = await loadBuckets();
166
+ console.log(`verify · Claude · ${period.sinceDate} → ${period.untilDate} (local days, as ccusage groups them) · tolerance 1% on tokens\n`);
167
+ const res = compare(ourTotals(rows.values(), period), ccusageTotals(cc.json, period.sinceDate));
168
+ console.log(res.lines.join('\n'));
169
+ return res.pass ? 0 : 1;
170
+ }
171
+ case 'link': {
172
+ const cfg = ensureConfig(true);
173
+ await link(cfg, { site: str(a.flags.site) });
174
+ return 0;
175
+ }
176
+ case 'push': {
177
+ const cfg = ensureConfig(true);
178
+ const g = str(a.flags.granularity);
179
+ if (g !== undefined && !isGranularity(g))
180
+ throw new UsageError('--granularity must be hour, day or week');
181
+ await push(cfg, { site: str(a.flags.site), dryRun: !!a.flags['dry-run'], all: !!a.flags.all, granularity: g });
182
+ return 0;
183
+ }
184
+ case 'hook':
185
+ return hookCommand(a.pos[0], { yes });
186
+ case 'schedule':
187
+ return scheduleCommand(a.pos[0], { yes });
188
+ case 'doctor':
189
+ return doctor(VERSION);
190
+ default:
191
+ console.error(`Unknown command: ${a.cmd}\n`);
192
+ console.error(HELP);
193
+ return 1;
194
+ }
195
+ }
196
+ const PLAN_USAGE = 'usage: tokenmaxxing plan list | plan set <provider> <plan> [xN] | plan add <provider> <plan> [xN] | plan remove <provider> [<plan>]';
197
+ function planSummary(provider, lines) {
198
+ return lines.length ? `${provider}: ${describeLines(lines)} = ${fmtPrice(providerMonthly(provider, lines))}/mo` : `${provider}: no plan`;
199
+ }
200
+ function planCommand(a) {
201
+ const cfg = loadConfig();
202
+ const [sub, provider, ...rest] = a.pos;
203
+ if (sub === 'list' || sub === undefined) {
204
+ const rows = [];
205
+ for (const p of PROVIDERS) {
206
+ const lines = cfg.plans[p] ?? [];
207
+ if (!lines.length)
208
+ rows.push([` ${p}`, 'none', '', '']);
209
+ lines.forEach((l, i) => rows.push([i === 0 ? `* ${p}` : '', `${l.qty}× ${lineName(l)}`, `${fmtPrice(unitPrice(p, l))} each`, `${fmtPrice(unitPrice(p, l) * l.qty)}/mo`]));
210
+ if (lines.length > 1)
211
+ rows.push(['', `${p} total`, '', `${fmtPrice(providerMonthly(p, lines))}/mo`]);
212
+ }
213
+ console.log(table(['', '', '', ''], rows, ['l', 'l', 'r', 'r'], 3).slice(1).join('\n'));
214
+ if (Object.keys(cfg.plans).length)
215
+ console.log(`\nAll plans: ${fmtPrice(plansMonthly(cfg.plans))}/mo`);
216
+ const t = planTable();
217
+ console.log('\nAvailable (USD per month per seat):');
218
+ for (const p of PROVIDERS)
219
+ console.log(` ${p.padEnd(7)} ${Object.entries(t[p]).map(([k, v]) => `${k} ${fmtPrice(v)}`).join(' · ')}`);
220
+ console.log(` any ${CUSTOM} <monthly> [label] e.g. plan add openai custom 100 "Codex $100 promo"`);
221
+ console.log('\nChange with: tokenmaxxing plan add <provider> <plan> [xN] · plan set <provider> <plan> [xN] · plan remove <provider> [<plan>] (* = has a plan)');
222
+ return 0;
223
+ }
224
+ if (sub !== 'set' && sub !== 'add' && sub !== 'remove')
225
+ throw new UsageError(PLAN_USAGE);
226
+ if (!provider || !isProvider(provider))
227
+ throw new UsageError(`provider must be one of: ${PROVIDERS.join(', ')}`);
228
+ const cur = cfg.plans[provider] ?? [];
229
+ let next;
230
+ if (sub === 'remove' || (sub === 'set' && (rest[0] === 'none' || rest[0] === 'off'))) {
231
+ const [plan, ...label] = rest;
232
+ if (sub === 'set' || plan === undefined)
233
+ next = [];
234
+ else {
235
+ const text = label.join(' ').trim();
236
+ const hits = cur.filter((l) => l.plan === plan && (plan !== CUSTOM || !text || l.label === text));
237
+ if (!hits.length)
238
+ throw new UsageError(`${provider} has no ${plan}${text ? ` "${text}"` : ''} line (${planSummary(provider, cur)})`);
239
+ if (hits.length > 1)
240
+ throw new UsageError(`${provider} has ${hits.length} custom lines; name one: ${hits.map((l) => `"${l.label}"`).join(', ')}`);
241
+ next = cur.filter((l) => l !== hits[0]);
242
+ }
243
+ }
244
+ else {
245
+ const line = parsePlanSpec(provider, rest, typeof a.flags.qty === 'string' ? a.flags.qty : a.flags.qty ? '' : undefined);
246
+ if (typeof line === 'string')
247
+ throw new UsageError(line);
248
+ if (sub === 'set')
249
+ next = [line];
250
+ else {
251
+ const merged = addLine(cur, line);
252
+ if (typeof merged === 'string')
253
+ throw new UsageError(merged);
254
+ next = merged;
255
+ }
256
+ }
257
+ if (next.length)
258
+ cfg.plans[provider] = next;
259
+ else
260
+ delete cfg.plans[provider];
261
+ saveConfig(cfg);
262
+ console.log(next.length ? planSummary(provider, next) : `Cleared ${provider} plan.`);
263
+ return 0;
264
+ }
265
+ /** Commands other than init work without a config by detecting sources on the fly (nothing is written). */
266
+ function ensureConfig(quiet) {
267
+ const cfg = loadConfig();
268
+ if (!configExists()) {
269
+ cfg.sources = detectionToSources(detect());
270
+ if (!quiet)
271
+ process.stderr.write('No config yet (run `tokenmaxxing init`); using detected sources.\n');
272
+ }
273
+ return cfg;
274
+ }
275
+ main(process.argv.slice(2)).then((code) => {
276
+ process.exitCode = code;
277
+ }, (e) => {
278
+ if (e instanceof SiteError || e instanceof UsageError)
279
+ console.error(`tokenmaxxing: ${e.message}`);
280
+ else
281
+ console.error(e?.stack ?? String(e));
282
+ process.exitCode = 1;
283
+ });
package/dist/config.js ADDED
@@ -0,0 +1,49 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { files } from './paths.js';
3
+ import { readJson, writeJsonAtomic, isFile } from './fsutil.js';
4
+ import { normalizePlans } from './plans.js';
5
+ export const DEFAULT_SITE = 'https://tokenmaxxing.fyi';
6
+ export const GRANULARITIES = ['hour', 'day', 'week'];
7
+ export function isGranularity(s) {
8
+ return GRANULARITIES.includes(s);
9
+ }
10
+ export function defaultConfig() {
11
+ return {
12
+ version: 1,
13
+ deviceId: randomUUID(),
14
+ sources: {
15
+ claude: { enabled: false, paths: [] },
16
+ codex: { enabled: false, paths: [] },
17
+ gemini: { enabled: false, paths: [] },
18
+ cursor: { enabled: false, paths: [] },
19
+ },
20
+ plans: {},
21
+ site: { url: DEFAULT_SITE },
22
+ };
23
+ }
24
+ export function configExists() {
25
+ return isFile(files.config());
26
+ }
27
+ export function loadConfig() {
28
+ const base = defaultConfig();
29
+ const raw = readJson(files.config(), null);
30
+ if (!raw)
31
+ return base;
32
+ return {
33
+ ...base,
34
+ ...raw,
35
+ version: 1,
36
+ deviceId: typeof raw.deviceId === 'string' && raw.deviceId ? raw.deviceId : base.deviceId,
37
+ sources: { ...base.sources, ...(raw.sources ?? {}) },
38
+ plans: normalizePlans(raw.plans),
39
+ site: { ...base.site, ...(raw.site ?? {}) },
40
+ };
41
+ }
42
+ export function saveConfig(cfg) {
43
+ writeJsonAtomic(files.config(), cfg);
44
+ }
45
+ /** Site URL precedence: --site flag > TOKENMAXXING_SITE env > config.site.url > default. */
46
+ export function siteUrl(cfg, flag) {
47
+ const raw = flag || process.env.TOKENMAXXING_SITE || cfg.site.url || DEFAULT_SITE;
48
+ return raw.replace(/\/+$/, '');
49
+ }
@@ -0,0 +1,24 @@
1
+ import { files } from './paths.js';
2
+ import { readJson, writeJsonAtomic } from './fsutil.js';
3
+ export function emptySourceCursor() {
4
+ return { files: {}, dedup: [], badLines: 0 };
5
+ }
6
+ export function loadCursors() {
7
+ const c = readJson(files.cursors(), null);
8
+ if (!c || c.v !== 1 || typeof c.sources !== 'object')
9
+ return { v: 1, sources: {} };
10
+ return c;
11
+ }
12
+ export function sourceCursor(c, name) {
13
+ if (!c.sources[name])
14
+ c.sources[name] = emptySourceCursor();
15
+ const s = c.sources[name];
16
+ s.files ??= {};
17
+ s.dedup ??= [];
18
+ s.badLines ??= 0;
19
+ return s;
20
+ }
21
+ export function saveCursors(c) {
22
+ // Compact JSON: dedup sets can hold hundreds of thousands of keys.
23
+ writeJsonAtomic(files.cursors(), c, false);
24
+ }
package/dist/dedup.js ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Insertion-ordered Set with a size bound. `add` refreshes an existing key to most-recent,
3
+ * so eviction drops the least recently seen key. Serialises to a plain array (oldest first).
4
+ */
5
+ export class BoundedSet {
6
+ limit;
7
+ set;
8
+ constructor(limit, init = []) {
9
+ this.limit = limit;
10
+ this.set = new Set(init);
11
+ this.trim();
12
+ }
13
+ has(k) {
14
+ return this.set.has(k);
15
+ }
16
+ /** Returns true if the key was new. */
17
+ add(k) {
18
+ if (this.set.has(k)) {
19
+ this.set.delete(k);
20
+ this.set.add(k);
21
+ return false;
22
+ }
23
+ this.set.add(k);
24
+ // Trim lazily in batches to keep add O(1) amortised.
25
+ if (this.set.size > this.limit * 1.25)
26
+ this.trim();
27
+ return true;
28
+ }
29
+ get size() {
30
+ return this.set.size;
31
+ }
32
+ trim() {
33
+ let excess = this.set.size - this.limit;
34
+ if (excess <= 0)
35
+ return;
36
+ for (const k of this.set) {
37
+ if (excess-- <= 0)
38
+ break;
39
+ this.set.delete(k);
40
+ }
41
+ }
42
+ toJSON() {
43
+ this.trim();
44
+ return [...this.set];
45
+ }
46
+ }
package/dist/detect.js ADDED
@@ -0,0 +1,30 @@
1
+ import { join } from 'node:path';
2
+ import { isDir, isFile } from './fsutil.js';
3
+ import { claudeCandidateRoots, codexHome, cursorTrackingDb, geminiHome } from './paths.js';
4
+ /** Look for each tool's local log directory. Reads directory metadata only. */
5
+ export function detect() {
6
+ const claudeRoots = claudeCandidateRoots().filter((r) => isDir(join(r, 'projects')));
7
+ const cx = codexHome();
8
+ const codexFound = isDir(join(cx, 'sessions')) || isDir(join(cx, 'archived_sessions'));
9
+ const gm = geminiHome();
10
+ const geminiFound = isDir(join(gm, 'tmp'));
11
+ const cursorDb = cursorTrackingDb();
12
+ const cursorFound = isFile(cursorDb);
13
+ return {
14
+ claude: { source: 'claude', found: claudeRoots.length > 0, paths: claudeRoots },
15
+ codex: { source: 'codex', found: codexFound, paths: codexFound ? [cx] : [] },
16
+ gemini: { source: 'gemini', found: geminiFound, paths: geminiFound ? [gm] : [] },
17
+ cursor: {
18
+ source: 'cursor',
19
+ found: cursorFound,
20
+ paths: cursorFound ? [cursorDb] : [],
21
+ note: 'token counts not available locally; Cursor is excluded from totals',
22
+ },
23
+ };
24
+ }
25
+ export function detectionToSources(d) {
26
+ const out = {};
27
+ for (const k of Object.keys(d))
28
+ out[k] = { enabled: d[k].found, paths: d[k].paths };
29
+ return out;
30
+ }
package/dist/doctor.js ADDED
@@ -0,0 +1,48 @@
1
+ import { configExists, loadConfig, siteUrl } from './config.js';
2
+ import { loadCursors } from './cursors.js';
3
+ import { detect } from './detect.js';
4
+ import { fmtInt } from './format.js';
5
+ import { isDir, isFile } from './fsutil.js';
6
+ import { claudeSettingsPath, claudeHasHook, codexConfigPath, codexHasOurNotify } from './hook.js';
7
+ import { files, tmxHome } from './paths.js';
8
+ import { SNAPSHOT_DATE } from './pricing/index.js';
9
+ import { loadBuckets } from './store.js';
10
+ import { readFileSync } from 'node:fs';
11
+ import { SOURCES } from './types.js';
12
+ export async function doctor(version, log = console.log) {
13
+ const cfg = loadConfig();
14
+ const cursors = loadCursors();
15
+ log(`tokenmaxxing-cli ${version} · node ${process.version} · ${process.platform}`);
16
+ log(`home: ${tmxHome()}${configExists() ? '' : ' (no config yet — run `tokenmaxxing init`)'}`);
17
+ log(`pricing: bundled snapshot ${SNAPSHOT_DATE} (LiteLLM + models.dev, overrides.json)`);
18
+ log('');
19
+ const det = detect();
20
+ for (const s of SOURCES) {
21
+ const sc = cfg.sources[s];
22
+ const cur = cursors.sources[s];
23
+ log(`${s}: ${sc?.enabled ? 'enabled' : 'disabled'}${det[s].found ? '' : ' · not detected on this machine'}`);
24
+ for (const p of sc?.paths ?? [])
25
+ log(` path ${p} ${isDir(p) || isFile(p) ? 'found' : 'MISSING'}`);
26
+ if (s === 'cursor' && det.cursor.found)
27
+ log(` note ${det.cursor.note}`);
28
+ if (cur) {
29
+ const tracked = Object.keys(cur.files ?? {}).length;
30
+ log(` cursor ${fmtInt(tracked)} files tracked · last sync ${cur.lastSync ?? 'never'} · last run scanned ${fmtInt(cur.lastFilesScanned ?? 0)}, changed ${fmtInt(cur.lastFilesChanged ?? 0)}`);
31
+ log(` dedup keys ${fmtInt(cur.dedup?.length ?? 0)}${cur.convDedup ? ` · prompt keys ${fmtInt(cur.convDedup.length)}` : ''} · skipped garbled lines ${fmtInt(cur.badLines ?? 0)}`);
32
+ }
33
+ }
34
+ log('');
35
+ const b = await loadBuckets();
36
+ log(`buckets: ${files.buckets()} · ${fmtInt(b.bytes)} bytes · ${fmtInt(b.lines)} lines · ${fmtInt(b.rows.size)} current rows${b.bad ? ` · ${b.bad} invalid lines` : ''}`);
37
+ let claudeHook = false;
38
+ try {
39
+ claudeHook = claudeHasHook(JSON.parse(isFile(claudeSettingsPath()) ? readFileSync(claudeSettingsPath(), 'utf8') : '{}'));
40
+ }
41
+ catch {
42
+ /* ignore */
43
+ }
44
+ const codexHook = isFile(codexConfigPath()) && codexHasOurNotify(readFileSync(codexConfigPath(), 'utf8'));
45
+ log(`hooks: claude ${claudeHook ? 'installed' : 'not installed'} · codex ${codexHook ? 'installed' : 'not installed'}`);
46
+ log(`site: ${siteUrl(cfg)} · ${cfg.site.token ? `linked${cfg.site.handle ? ` as @${cfg.site.handle}` : ''}` : 'not linked'}${cfg.site.lastPushedTs ? ` · last pushed ${cfg.site.lastPushedTs}` : ''}`);
47
+ return 0;
48
+ }