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.
@@ -0,0 +1,201 @@
1
+ import { readdir, stat } from 'node:fs/promises';
2
+ import { basename, join } from 'node:path';
3
+ import { BucketAccumulator, hourOf } from '../bucket.js';
4
+ import { walkFiles } from '../fsutil.js';
5
+ import { parseLine, scanLines } from '../lines.js';
6
+ import { num } from './context.js';
7
+ const isRollout = (n) => n.startsWith('rollout-') && n.endsWith('.jsonl');
8
+ /** Top-level `type` (and event_msg payload.type) sit in the first bytes of every Codex line. */
9
+ const HEAD = 320;
10
+ const NEEDLES = ['"token_usage_record"', '"turn_context"', '"session_meta"', '"token_count"'].map((s) => Buffer.from(s));
11
+ const RAW_FIELDS = [
12
+ 'input_tokens', 'cached_input_tokens', 'cache_write_input_tokens', 'output_tokens', 'reasoning_output_tokens',
13
+ ];
14
+ function raw(u) {
15
+ return {
16
+ input_tokens: num(u?.input_tokens),
17
+ cached_input_tokens: num(u?.cached_input_tokens),
18
+ cache_write_input_tokens: num(u?.cache_write_input_tokens),
19
+ output_tokens: num(u?.output_tokens),
20
+ reasoning_output_tokens: num(u?.reasoning_output_tokens),
21
+ };
22
+ }
23
+ /** Codex `input_tokens` includes `cached_input_tokens`; `output_tokens` already includes reasoning. */
24
+ export function codexUsage(r) {
25
+ return {
26
+ input: Math.max(0, r.input_tokens - r.cached_input_tokens),
27
+ cache_read: r.cached_input_tokens,
28
+ cache_write_5m: r.cache_write_input_tokens,
29
+ cache_write_1h: 0,
30
+ output: r.output_tokens,
31
+ reasoning: r.reasoning_output_tokens,
32
+ };
33
+ }
34
+ const isZeroRaw = (r) => RAW_FIELDS.every((f) => r[f] === 0);
35
+ /** rollout files keyed by basename (so a session moved into archived_sessions/ is not re-counted). */
36
+ export async function codexFiles(roots) {
37
+ const found = new Map();
38
+ for (const root of roots) {
39
+ const list = await walkFiles(join(root, 'sessions'), isRollout);
40
+ try {
41
+ const archived = join(root, 'archived_sessions');
42
+ for (const n of (await readdir(archived)).sort())
43
+ if (isRollout(n))
44
+ list.push(join(archived, n));
45
+ }
46
+ catch {
47
+ /* no archived_sessions */
48
+ }
49
+ for (const f of list)
50
+ if (!found.has(basename(f)))
51
+ found.set(basename(f), f);
52
+ }
53
+ return found;
54
+ }
55
+ export async function parse(ctx) {
56
+ const { cursor, dedup, acc, stats } = ctx;
57
+ const files = await codexFiles(ctx.paths);
58
+ for (const [key, file] of files) {
59
+ stats.filesSeen++;
60
+ let st;
61
+ try {
62
+ st = await stat(file);
63
+ }
64
+ catch {
65
+ continue;
66
+ }
67
+ const prev = cursor.files[key];
68
+ if (prev && prev.size === st.size && prev.mtimeMs === st.mtimeMs)
69
+ continue;
70
+ stats.filesChanged++;
71
+ const shrank = !!prev && st.size < prev.offset;
72
+ const base = shrank ? undefined : prev;
73
+ const start = base?.offset ?? 0;
74
+ let model = base?.model;
75
+ let root = base?.root;
76
+ let cum = base?.cum ? raw(fromUsageCursor(base.cum)) : undefined;
77
+ let sawRecords = !!base?.records;
78
+ const recAcc = new BucketAccumulator();
79
+ const fbAcc = new BucketAccumulator();
80
+ const modelNow = () => model ?? 'unknown-codex';
81
+ const handle = (obj) => {
82
+ if (!obj || typeof obj !== 'object')
83
+ return;
84
+ const p = obj.payload;
85
+ switch (obj.type) {
86
+ case 'session_meta':
87
+ if (!model && typeof p?.model === 'string' && p.model)
88
+ model = p.model;
89
+ return;
90
+ case 'turn_context': {
91
+ if (typeof p?.model === 'string' && p.model)
92
+ model = p.model;
93
+ const r = p?.root_turn_id;
94
+ if (typeof r === 'string' && r && r !== root) {
95
+ root = r;
96
+ const h = hourOf(obj.timestamp);
97
+ if (h)
98
+ acc.addConversation(h, 'codex', modelNow());
99
+ }
100
+ return;
101
+ }
102
+ case 'token_usage_record': {
103
+ sawRecords = true;
104
+ if (!p?.usage)
105
+ return;
106
+ const rid = p.response_id;
107
+ if (typeof rid === 'string' && rid && !dedup.add(rid))
108
+ return;
109
+ const r = raw(p.usage);
110
+ if (isZeroRaw(r))
111
+ return;
112
+ const h = hourOf(obj.timestamp);
113
+ if (!h) {
114
+ stats.badLines++;
115
+ return;
116
+ }
117
+ recAcc.addUsage(h, 'codex', modelNow(), codexUsage(r));
118
+ return;
119
+ }
120
+ case 'event_msg': {
121
+ if (p?.type !== 'token_count' || !p.info)
122
+ return;
123
+ const tot = p.info.total_token_usage;
124
+ if (!tot)
125
+ return;
126
+ const now = raw(tot);
127
+ let delta;
128
+ if (!cum)
129
+ delta = now;
130
+ else if (RAW_FIELDS.every((f) => now[f] >= cum[f])) {
131
+ delta = raw({});
132
+ for (const f of RAW_FIELDS)
133
+ delta[f] = now[f] - cum[f];
134
+ }
135
+ else {
136
+ // Cumulative counter went backwards (session reset): count this response only.
137
+ delta = p.info.last_token_usage ? raw(p.info.last_token_usage) : now;
138
+ }
139
+ cum = now;
140
+ if (isZeroRaw(delta))
141
+ return;
142
+ const h = hourOf(obj.timestamp);
143
+ if (h)
144
+ fbAcc.addUsage(h, 'codex', modelNow(), codexUsage(delta));
145
+ return;
146
+ }
147
+ }
148
+ };
149
+ const onLine = (line) => {
150
+ const head = line.subarray(0, HEAD);
151
+ if (!NEEDLES.some((n) => head.indexOf(n) !== -1))
152
+ return;
153
+ const obj = parseLine(line);
154
+ if (obj === undefined) {
155
+ stats.badLines++;
156
+ return;
157
+ }
158
+ handle(obj);
159
+ };
160
+ let end = start;
161
+ try {
162
+ const res = await scanLines(file, start, st.size, onLine);
163
+ end = res.end;
164
+ stats.bytesRead += st.size - start;
165
+ if (res.tail) {
166
+ const obj = parseLine(res.tail);
167
+ if (obj !== undefined) {
168
+ handle(obj);
169
+ end = st.size;
170
+ }
171
+ }
172
+ }
173
+ catch {
174
+ continue;
175
+ }
176
+ // Never mix paths within one file: token_usage_record wins whenever the file has any.
177
+ for (const b of (sawRecords ? recAcc : fbAcc).rows()) {
178
+ acc.addUsage(b.ts, 'codex', b.model, b, b.requests);
179
+ }
180
+ const next = { size: st.size, mtimeMs: st.mtimeMs, offset: end };
181
+ if (model)
182
+ next.model = model;
183
+ if (root)
184
+ next.root = root;
185
+ if (sawRecords)
186
+ next.records = true;
187
+ if (cum)
188
+ next.cum = toUsageCursor(cum);
189
+ cursor.files[key] = next;
190
+ }
191
+ return acc.rows();
192
+ }
193
+ // The cursor stores the raw cumulative counters under Usage-like names to keep cursors.json compact.
194
+ function toUsageCursor(r) {
195
+ return { i: r.input_tokens, c: r.cached_input_tokens, w: r.cache_write_input_tokens, o: r.output_tokens, r: r.reasoning_output_tokens };
196
+ }
197
+ function fromUsageCursor(c) {
198
+ return {
199
+ input_tokens: c.i, cached_input_tokens: c.c, cache_write_input_tokens: c.w, output_tokens: c.o, reasoning_output_tokens: c.r,
200
+ };
201
+ }
@@ -0,0 +1,9 @@
1
+ export const DEDUP_LIMIT = 200_000;
2
+ /** Recent per-key contributions kept in cursors.json for cross-sync replacement. */
3
+ export const RECENT_LIMIT = 10_000;
4
+ export function newStats() {
5
+ return { filesSeen: 0, filesChanged: 0, bytesRead: 0, badLines: 0 };
6
+ }
7
+ export function num(v) {
8
+ return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0;
9
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Cursor: activity only, no tokens.
3
+ *
4
+ * ~/.cursor/ai-tracking/ai-code-tracking.db has no token counts (verified), so v1 reads nothing from it;
5
+ * `init` reports Cursor as detected but excluded from totals. No sqlite dependency is added.
6
+ *
7
+ * TODO: if Cursor ever writes local token usage, parse it here (one file, fixture-tested like the others).
8
+ */
9
+ export async function parse(_ctx) {
10
+ return [];
11
+ }
@@ -0,0 +1,110 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { hourOf } from '../bucket.js';
4
+ import { num } from './context.js';
5
+ /** Gemini CLI chat files: ~/.gemini/tmp/<project-hash>/chats/*.json */
6
+ export async function geminiFiles(roots) {
7
+ const out = [];
8
+ for (const root of roots) {
9
+ const tmp = join(root, 'tmp');
10
+ let projects = [];
11
+ try {
12
+ projects = await readdir(tmp);
13
+ }
14
+ catch {
15
+ continue;
16
+ }
17
+ for (const p of projects.sort()) {
18
+ const chats = join(tmp, p, 'chats');
19
+ try {
20
+ for (const n of (await readdir(chats)).sort())
21
+ if (n.endsWith('.json'))
22
+ out.push(join(chats, n));
23
+ }
24
+ catch {
25
+ /* not a project dir */
26
+ }
27
+ }
28
+ }
29
+ return out;
30
+ }
31
+ /** input excludes cached; tool-use and thought tokens are billed as output (reasoning kept for info). */
32
+ export function geminiUsage(t) {
33
+ const input = num(t?.input);
34
+ const cached = num(t?.cached);
35
+ const output = num(t?.output);
36
+ const tool = num(t?.tool);
37
+ const thoughts = num(t?.thoughts);
38
+ const u = {
39
+ input: Math.max(0, input - cached),
40
+ cache_read: cached,
41
+ cache_write_5m: 0,
42
+ cache_write_1h: 0,
43
+ output: output + tool + thoughts,
44
+ reasoning: thoughts,
45
+ };
46
+ return u.input + u.cache_read + u.output === 0 ? null : u;
47
+ }
48
+ export async function parse(ctx) {
49
+ const { cursor, acc, stats } = ctx;
50
+ for (const file of await geminiFiles(ctx.paths)) {
51
+ stats.filesSeen++;
52
+ let st;
53
+ try {
54
+ st = await stat(file);
55
+ }
56
+ catch {
57
+ continue;
58
+ }
59
+ const prev = cursor.files[file];
60
+ if (prev && prev.size === st.size && prev.mtimeMs === st.mtimeMs)
61
+ continue;
62
+ stats.filesChanged++;
63
+ let doc;
64
+ try {
65
+ // Gemini writes one JSON document per chat (not JSONL), so the file is parsed whole.
66
+ doc = JSON.parse(await readFile(file, 'utf8'));
67
+ }
68
+ catch {
69
+ stats.badLines++;
70
+ continue; // possibly mid-write; cursor untouched so we retry next sync
71
+ }
72
+ stats.bytesRead += st.size;
73
+ const messages = Array.isArray(doc?.messages) ? doc.messages : [];
74
+ let from = prev?.count ?? 0;
75
+ if (messages.length < from)
76
+ from = 0; // file shrank / rewritten
77
+ const fallbackTs = doc?.lastUpdated ?? doc?.startTime ?? st.mtimeMs;
78
+ let pending = [];
79
+ let lastModel = prev?.model;
80
+ for (let i = from; i < messages.length; i++) {
81
+ const m = messages[i];
82
+ if (!m || typeof m !== 'object') {
83
+ stats.badLines++;
84
+ continue;
85
+ }
86
+ const role = m.role ?? m.type;
87
+ const hour = hourOf(m.timestamp ?? fallbackTs);
88
+ if (!hour)
89
+ continue;
90
+ if (role === 'user') {
91
+ pending.push(hour);
92
+ continue;
93
+ }
94
+ const u = m.tokens ? geminiUsage(m.tokens) : null;
95
+ if (!u)
96
+ continue;
97
+ const model = typeof m.model === 'string' && m.model ? m.model : 'gemini-unknown';
98
+ lastModel = model;
99
+ acc.addUsage(hour, 'gemini', model, u);
100
+ for (const h of pending)
101
+ acc.addConversation(h, 'gemini', model);
102
+ pending = [];
103
+ }
104
+ // Prompts with no response yet are attributed to the last model seen (or gemini-unknown).
105
+ for (const h of pending)
106
+ acc.addConversation(h, 'gemini', lastModel ?? 'gemini-unknown');
107
+ cursor.files[file] = { size: st.size, mtimeMs: st.mtimeMs, offset: st.size, count: messages.length, ...(lastModel ? { model: lastModel } : {}) };
108
+ }
109
+ return acc.rows();
110
+ }
package/dist/paths.js ADDED
@@ -0,0 +1,32 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ export function home() {
4
+ return process.env.HOME || homedir();
5
+ }
6
+ export function tmxHome() {
7
+ return process.env.TOKENMAXXING_HOME || join(home(), '.tokenmaxxing');
8
+ }
9
+ export const files = {
10
+ config: () => join(tmxHome(), 'config.json'),
11
+ buckets: () => join(tmxHome(), 'buckets.jsonl'),
12
+ cursors: () => join(tmxHome(), 'cursors.json'),
13
+ lock: () => join(tmxHome(), 'sync.lock'),
14
+ cacheDir: () => join(tmxHome(), 'cache'),
15
+ };
16
+ /** Claude Code roots: CLAUDE_CONFIG_DIR (comma separated) else ~/.config/claude and ~/.claude. */
17
+ export function claudeCandidateRoots() {
18
+ const env = process.env.CLAUDE_CONFIG_DIR;
19
+ if (env && env.trim()) {
20
+ return env.split(',').map((s) => s.trim()).filter(Boolean);
21
+ }
22
+ return [join(home(), '.config', 'claude'), join(home(), '.claude')];
23
+ }
24
+ export function codexHome() {
25
+ return process.env.CODEX_HOME || join(home(), '.codex');
26
+ }
27
+ export function geminiHome() {
28
+ return join(home(), '.gemini');
29
+ }
30
+ export function cursorTrackingDb() {
31
+ return join(home(), '.cursor', 'ai-tracking', 'ai-code-tracking.db');
32
+ }
package/dist/period.js ADDED
@@ -0,0 +1,37 @@
1
+ import { localDate } from './format.js';
2
+ const DAY = 86_400_000;
3
+ /**
4
+ * `--since 7d|30d|YYYY-MM-DD` (default 30d). Days are local calendar days, matching ccusage:
5
+ * "30d" on 2026-09-22 covers 2026-08-23 00:00 local → now.
6
+ */
7
+ export function parsePeriod(since, now = new Date()) {
8
+ const s = (since ?? '30d').trim();
9
+ const todayMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate());
10
+ let start;
11
+ let days;
12
+ let label;
13
+ const rel = /^(\d+)d$/i.exec(s);
14
+ const abs = /^(\d{4})-?(\d{2})-?(\d{2})$/.exec(s);
15
+ if (rel) {
16
+ days = Number(rel[1]);
17
+ if (!(days > 0))
18
+ throw new Error(`--since must be positive: ${s}`);
19
+ start = new Date(todayMidnight.getFullYear(), todayMidnight.getMonth(), todayMidnight.getDate() - days);
20
+ label = `last ${days} day${days === 1 ? '' : 's'}`;
21
+ }
22
+ else if (abs) {
23
+ start = new Date(Number(abs[1]), Number(abs[2]) - 1, Number(abs[3]));
24
+ if (Number.isNaN(start.getTime()) || start > now)
25
+ throw new Error(`invalid --since date: ${s}`);
26
+ days = Math.max(1, Math.round((todayMidnight.getTime() - start.getTime()) / DAY));
27
+ label = `since ${localDate(start)}`;
28
+ }
29
+ else {
30
+ throw new Error(`--since expects 7d, 30d or YYYY-MM-DD (got "${s}")`);
31
+ }
32
+ return { start, end: now, sinceDate: localDate(start), untilDate: localDate(now), days, label };
33
+ }
34
+ export function inPeriod(ts, p) {
35
+ const t = Date.parse(ts);
36
+ return t >= p.start.getTime() && t < p.end.getTime();
37
+ }
package/dist/plans.js ADDED
@@ -0,0 +1,169 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { PROVIDER_SOURCE } from './types.js';
3
+ let plans;
4
+ export function planTable() {
5
+ plans ??= JSON.parse(readFileSync(new URL('./plans.json', import.meta.url), 'utf8'));
6
+ return plans;
7
+ }
8
+ export const PROVIDERS = Object.keys(PROVIDER_SOURCE);
9
+ export function isProvider(p) {
10
+ return PROVIDERS.includes(p);
11
+ }
12
+ export function planPrice(provider, plan) {
13
+ const t = planTable()[provider];
14
+ return t && Object.prototype.hasOwnProperty.call(t, plan) ? t[plan] : undefined;
15
+ }
16
+ export function sourceFor(provider) {
17
+ return PROVIDER_SOURCE[provider];
18
+ }
19
+ export const MONTH_DAYS = 30.4375;
20
+ /** API-equivalent cost ÷ plan cost prorated to the period length. */
21
+ export function roi(apiCost, monthlyPrice, days) {
22
+ const prorated = (monthlyPrice * days) / MONTH_DAYS;
23
+ return prorated > 0 ? apiCost / prorated : 0;
24
+ }
25
+ // ------------------------------------------------------------------ plan lines
26
+ export const CUSTOM = 'custom';
27
+ export const QTY_MAX = 99;
28
+ export const LABEL_MAX = 40;
29
+ export const CUSTOM_MIN = 0.01;
30
+ export const CUSTOM_MAX = 10000;
31
+ export const isQty = (n) => typeof n === 'number' && Number.isInteger(n) && n >= 1 && n <= QTY_MAX;
32
+ export function cleanLabel(s) {
33
+ const t = typeof s === 'string' ? s.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, LABEL_MAX).trim() : '';
34
+ return t || 'Custom';
35
+ }
36
+ /** Custom monthly amount rounded to cents, or undefined when out of range. */
37
+ export function cleanMonthly(n) {
38
+ if (typeof n !== 'number' || !Number.isFinite(n))
39
+ return undefined;
40
+ const r = Math.round(n * 100) / 100;
41
+ return r >= CUSTOM_MIN && r <= CUSTOM_MAX ? r : undefined;
42
+ }
43
+ function lineOf(provider, raw) {
44
+ if (typeof raw === 'string')
45
+ return planPrice(provider, raw) === undefined ? undefined : { plan: raw, qty: 1 };
46
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
47
+ return undefined;
48
+ const o = raw;
49
+ const qty = o.qty === undefined ? 1 : o.qty;
50
+ if (!isQty(qty) || typeof o.plan !== 'string')
51
+ return undefined;
52
+ if (o.plan === CUSTOM) {
53
+ const monthly = cleanMonthly(o.monthly);
54
+ return monthly === undefined ? undefined : { plan: CUSTOM, label: cleanLabel(o.label), monthly, qty };
55
+ }
56
+ return planPrice(provider, o.plan) === undefined ? undefined : { plan: o.plan, qty };
57
+ }
58
+ const lineKey = (l) => (l.plan === CUSTOM ? `${CUSTOM}\u0000${l.label}\u0000${l.monthly}` : l.plan);
59
+ /**
60
+ * One provider's lines from either stored shape: the legacy string (`"max-20x"`) or a list of
61
+ * line objects. Unknown plans and bad quantities are dropped; duplicates merge by summing qty.
62
+ */
63
+ export function normalizeLines(provider, raw) {
64
+ const items = Array.isArray(raw) ? raw : raw === undefined || raw === null ? [] : [raw];
65
+ const out = [];
66
+ const byKey = new Map();
67
+ for (const item of items) {
68
+ const l = lineOf(provider, item);
69
+ if (!l)
70
+ continue;
71
+ const prev = byKey.get(lineKey(l));
72
+ if (prev)
73
+ prev.qty = Math.min(QTY_MAX, prev.qty + l.qty);
74
+ else {
75
+ byKey.set(lineKey(l), l);
76
+ out.push(l);
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+ /** Every provider's lines, from either shape. Unknown providers and empty providers are dropped. */
82
+ export function normalizePlans(raw) {
83
+ const out = {};
84
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
85
+ return out;
86
+ for (const [provider, v] of Object.entries(raw)) {
87
+ if (!isProvider(provider))
88
+ continue;
89
+ const lines = normalizeLines(provider, v);
90
+ if (lines.length)
91
+ out[provider] = lines;
92
+ }
93
+ return out;
94
+ }
95
+ /** Monthly price of one seat of a line. */
96
+ export function unitPrice(provider, l) {
97
+ return l.plan === CUSTOM ? (l.monthly ?? 0) : (planPrice(provider, l.plan) ?? 0);
98
+ }
99
+ const cents = (n) => Math.round(n * 100) / 100;
100
+ /** Σ unit price × qty for one provider. */
101
+ export function providerMonthly(provider, lines) {
102
+ return cents(lines.reduce((s, l) => s + unitPrice(provider, l) * l.qty, 0));
103
+ }
104
+ /** Σ over providers. */
105
+ export function plansMonthly(p) {
106
+ return cents(PROVIDERS.reduce((s, prov) => s + providerMonthly(prov, p[prov] ?? []), 0));
107
+ }
108
+ /** `max-20x` or the custom line's label. */
109
+ export const lineName = (l) => (l.plan === CUSTOM ? (l.label ?? 'Custom') : l.plan);
110
+ /** "5× max-20x + 1× pro" */
111
+ export function describeLines(lines) {
112
+ return lines.map((l) => `${l.qty}× ${lineName(l)}`).join(' + ');
113
+ }
114
+ /** `x5`, `X5` or `×5` → 5; anything else → undefined. */
115
+ export function parseQtyToken(s) {
116
+ const m = /^[x×](\d{1,3})$/i.exec(s);
117
+ return m ? Number(m[1]) : undefined;
118
+ }
119
+ /**
120
+ * The plan part of `plan set|add <provider> <plan> [xN]` and
121
+ * `plan set|add <provider> custom <monthly> [label…] [xN]`. `--qty N` may stand in for `xN`.
122
+ * Returns the line, or a message saying what is wrong.
123
+ */
124
+ export function parsePlanSpec(provider, args, qtyFlag) {
125
+ const [plan, ...rest] = args;
126
+ let qty;
127
+ const words = [];
128
+ for (const a of rest) {
129
+ const q = parseQtyToken(a);
130
+ if (q === undefined)
131
+ words.push(a);
132
+ else if (qty !== undefined)
133
+ return 'give the quantity once';
134
+ else
135
+ qty = q;
136
+ }
137
+ if (qtyFlag !== undefined) {
138
+ if (qty !== undefined)
139
+ return 'give the quantity once (xN or --qty N)';
140
+ qty = /^\d{1,3}$/.test(qtyFlag) ? Number(qtyFlag) : NaN;
141
+ }
142
+ qty ??= 1;
143
+ if (!isQty(qty))
144
+ return `quantity must be a whole number from 1 to ${QTY_MAX}`;
145
+ const t = planTable()[provider];
146
+ if (!plan || (plan !== CUSTOM && planPrice(provider, plan) === undefined)) {
147
+ return `plan for ${provider} must be one of: ${Object.keys(t).join(', ')}, or custom <monthly> [label]`;
148
+ }
149
+ if (plan === CUSTOM) {
150
+ const [amount, ...label] = words;
151
+ const monthly = amount !== undefined && /^\$?\d+(\.\d+)?$/.test(amount) ? cleanMonthly(Number(amount.replace('$', ''))) : undefined;
152
+ if (monthly === undefined)
153
+ return `custom needs a monthly amount from ${CUSTOM_MIN} to ${CUSTOM_MAX}, e.g. plan add ${provider} custom 100 "Promo"`;
154
+ const text = label.join(' ').trim();
155
+ if (text.length > LABEL_MAX)
156
+ return `custom label must be at most ${LABEL_MAX} characters`;
157
+ return { plan: CUSTOM, label: cleanLabel(text), monthly, qty };
158
+ }
159
+ if (words.length)
160
+ return `unexpected argument: ${words[0]}`;
161
+ return { plan, qty };
162
+ }
163
+ /** Adds a line to a provider's lines, merging with an existing line for the same plan. */
164
+ export function addLine(lines, line) {
165
+ const prev = lines.find((l) => lineKey(l) === lineKey(line));
166
+ if (prev && prev.qty + line.qty > QTY_MAX)
167
+ return `${lineName(line)} would reach ${prev.qty + line.qty}; the most is ${QTY_MAX}`;
168
+ return prev ? lines.map((l) => (l === prev ? { ...l, qty: l.qty + line.qty } : l)) : [...lines, line];
169
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "claude": { "pro": 20, "max-5x": 100, "max-20x": 200, "team-standard": 25, "team-premium": 125 },
3
+ "openai": { "go": 8, "plus": 20, "pro-100": 100, "pro": 200, "business": 25 },
4
+ "cursor": { "pro": 20, "pro-plus": 60, "ultra": 200, "teams": 40 },
5
+ "google": { "ai-plus": 4.99, "ai-pro": 19.99, "ai-ultra-100": 99.99, "ai-ultra": 199.99 }
6
+ }