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 +94 -0
- package/dist/bucket.js +56 -0
- package/dist/cli.js +283 -0
- package/dist/config.js +49 -0
- package/dist/cursors.js +24 -0
- package/dist/dedup.js +46 -0
- package/dist/detect.js +30 -0
- package/dist/doctor.js +48 -0
- package/dist/format.js +49 -0
- package/dist/fsutil.js +61 -0
- package/dist/hook.js +194 -0
- package/dist/lines.js +61 -0
- package/dist/parsers/claude.js +203 -0
- package/dist/parsers/codex.js +201 -0
- package/dist/parsers/context.js +9 -0
- package/dist/parsers/cursor.js +11 -0
- package/dist/parsers/gemini.js +110 -0
- package/dist/paths.js +32 -0
- package/dist/period.js +37 -0
- package/dist/plans.js +169 -0
- package/dist/plans.json +6 -0
- package/dist/pricing/index.js +190 -0
- package/dist/pricing/litellm.snapshot.json +1 -0
- package/dist/pricing/modelsdev.snapshot.json +1 -0
- package/dist/pricing/overrides.json +4 -0
- package/dist/prompt.js +61 -0
- package/dist/report.js +186 -0
- package/dist/schedule.js +160 -0
- package/dist/site.js +196 -0
- package/dist/store.js +59 -0
- package/dist/sync.js +115 -0
- package/dist/types.js +9 -0
- package/dist/verify.js +118 -0
- package/package.json +46 -0
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
|
+
}
|
package/dist/cursors.js
ADDED
|
@@ -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
|
+
}
|