earnrate 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,7 +9,7 @@ I wanted a ghost card: one card that always charges the best of my cards. The ca
9
9
 
10
10
  **earnrate is the best-card decision layer for people and AI agents.** Give it a merchant, and it tells you which card in your wallet earns the most there, and why. It's stateful (it knows your caps and chosen categories), valuation-aware, and transparent about its assumptions. It never sees a card number.
11
11
 
12
- - Runs on your machine. No account, no telemetry, no affiliate links. See the [privacy page](https://earnrate.dev/privacy).
12
+ - Runs on your machine, or in your browser at [earnrate.dev](https://earnrate.dev) with nothing to install. No account, no telemetry, no affiliate links. See the [privacy page](https://earnrate.dev/privacy).
13
13
  - US cards and US merchants, for now.
14
14
 
15
15
  > **Status:** early, and it works: the engine, CLI, optional Claude merchant lookup and MCP server. See [the benchmark](docs/benchmark.md) for how it compares to an existing card-picking MCP server.
@@ -98,6 +98,16 @@ While the bonus is open, that card wins wherever it's accepted, and the answer s
98
98
 
99
99
  ### Which new card?
100
100
 
101
+ No wallet needed for these:
102
+
103
+ ```sh
104
+ earnrate best groceries # every card, ranked for one purchase
105
+ earnrate simulate groceries=600 dining=300 other=1000 # best cards and pairs for a year
106
+ earnrate simulate groceries=600 other=1000 --wallet # what a new card would add to yours
107
+ ```
108
+
109
+ `best` ranks every card earnrate knows for a merchant or a category, each set up at its best (the right category pick, activated, cap unused), with its annual fee and what earning the rate takes. Rates that need a membership like Amazon Prime are listed separately unless you pass `--member amazon-prime`. `simulate` spends your monthly amounts for a year, applying caps as they fill and reset, and ranks single cards and two-card pairs by rewards minus annual fees. Both take `--no-fee`, `--cash` or `--points`, `--issuer`, `--business` and `--valuation`. Welcome bonuses are left out of both; for those:
110
+
101
111
  ```sh
102
112
  earnrate offers --cash
103
113
  ```
@@ -142,9 +152,10 @@ Spotted a wrong rate, or want a card added? [Open an issue](https://github.com/c
142
152
 
143
153
  ## Use it from an AI agent (MCP)
144
154
 
145
- `earnrate mcp` runs earnrate as a local MCP server over stdio, with ten tools:
155
+ `earnrate mcp` runs earnrate as a local MCP server over stdio, with twelve tools:
146
156
 
147
157
  - `best_card_for_purchase`: which card to use, with reasons
158
+ - `best_cards_for` and `simulate_spending`: compare every card earnrate knows, for one purchase or a year of spending, with no wallet needed
148
159
  - `resolve_merchant`: how a merchant was identified
149
160
  - `list_wallet`: your cards, caps left this period, chosen categories and welcome-bonus progress
150
161
  - `compare_welcome_offers`: the best public sign-up offers on cards you don't have
@@ -185,8 +196,8 @@ merchant text
185
196
 
186
197
  - **Card terms are data, not code.** Each card is a YAML file of rules (category, rate, cap, channel, dates, activation, required membership), validated with Zod. Every rule carries a short quote from the issuer's own page, and a test fails if one is missing. The engine has no card-specific branches.
187
198
  - **Merchants resolve to a category plus the assumption behind it.** "Whole Foods codes as a supermarket (MCC 5411)" is stated in the answer, and venues that can code more than one way (a restaurant inside a stadium or hotel) come back at medium confidence with the alternative spelled out. Unknown merchants fall back to base rates, or to an opt-in, cached and labeled Claude guess.
188
- - **The engine is pure and deterministic.** Given a purchase, a date and a wallet, it checks every rule against network acceptance, the date window, caps already used (split when a purchase straddles one), quarterly picks and activations, then values points at your cents-per-point, including pooling (Freedom Unlimited points are worth Sapphire points when you hold both). Ties, the valuation at which the runner-up would win, and stale data all show up in the answer.
189
- - **Wallet edits keep your file intact.** Recording a purchase rewrites only the numbers that changed, so comments and formatting in your YAML survive.
199
+ - **The engine is pure and deterministic.** Given a purchase, a date and a wallet, it checks every rule against network acceptance, the date window, caps already used (split when a purchase straddles one), quarterly picks and activations, then values points at your cents-per-point, including pooling (Freedom Unlimited points are worth Sapphire points when you hold both). Ties, the valuation at which the runner-up would win, stale data, and a rotating card whose new quarter isn't in the data yet all show up in the answer.
200
+ - **Wallet edits keep your file intact.** Adding or changing a card, or recording a purchase, splices in only the lines that changed and checks the result before saving, so comments and formatting in your YAML survive.
190
201
  - **Drift detection.** A weekly GitHub Action opens each issuer page in a headless browser, checks every quote is still there, and opens an issue when terms move. It stores only hashes of issuer text.
191
202
  - **Tested end to end.** Golden cases written from issuer terms, CLI snapshot tests, an MCP round trip over real stdio, an install test that packs and installs the npm package, and a check that no committed file contains a card number. CI runs all of it on every push.
192
203
 
@@ -201,6 +212,8 @@ Every rule in `data/cards/` carries a short quote from the issuer's own page. In
201
212
 
202
213
  A GitHub Action runs it every Monday and opens a "Card source changes" issue when something moved. After updating a card file, run `yarn check-sources --update` to record the new baseline. Some issuers block headless browsers on shared servers; those pages are listed as "couldn't check", and running the command locally covers them. The first run needs `yarn playwright install chromium`.
203
214
 
215
+ Rotating categories (Freedom Flex) are added each quarter once the issuer posts them. Until then, answers say that quarter's categories are missing rather than guessing.
216
+
204
217
  ## Working on earnrate
205
218
 
206
219
  ```sh
package/data/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  Every change to card rules, merchants, categories or currencies under `data/`. Bump `data/VERSION` (`YYYY.MM.DD`, plus `.N` for a second release on the same day) and add a section here in the same commit. Each row cites the issuer page the new value came from.
4
4
 
5
+ ## 2026.10.30.2
6
+
7
+ Cards record when the issuer stopped taking applications, so "best card for" lists can leave out cards nobody can get. Categories gain `aliases`, the everyday words people search by ("groceries" finds supermarkets). No rates changed.
8
+
9
+ | Card | Field | Old → new | Source |
10
+ | ---------------- | ------------------- | ---------- | --------------------------------------------------------------------------- |
11
+ | citi-custom-cash | applications_closed | 2026-05-28 | [card page](https://www.citi.com/credit-cards/citi-custom-cash-credit-card) |
12
+
5
13
  ## 2026.10.30.1
6
14
 
7
15
  Freedom Flex's quarterly cap is marked `rotating`, so a quarter whose categories aren't in the data yet gets a warning instead of quietly ranking Flex at its everyday rates. Capital One's issuer id is now spelled the same on every card. No rates changed.
package/data/VERSION CHANGED
@@ -1 +1 @@
1
- 2026.10.30.1
1
+ 2026.10.30.2
@@ -5,9 +5,10 @@ network: mastercard
5
5
  currency: cash
6
6
  last_verified: 2026-10-26
7
7
  verified_by: collin
8
- # Citi stopped taking applications on May 28, 2026; existing cardmembers keep
9
- # the card and its rewards. The card page no longer lists the categories, so
10
- # the definitions come from the card's current ThankYou terms (Sept 2026).
8
+ applications_closed: 2026-05-28
9
+ # Existing cardmembers keep the card and its rewards. The card page no longer
10
+ # lists the categories, so the definitions come from the card's current
11
+ # ThankYou terms (Sept 2026).
11
12
  source_urls:
12
13
  - https://www.citi.com/credit-cards/citi-custom-cash-credit-card
13
14
  - https://www.thankyou.com/ccterms
@@ -5,24 +5,30 @@
5
5
 
6
6
  - id: dining
7
7
  name: Restaurants
8
+ aliases: [dining, restaurant, food, eating out, takeout]
8
9
  mccs: [5812, 5813, 5814]
9
10
  - id: dining.fast_food
10
11
  name: Fast food
12
+ aliases: [fast food restaurants]
11
13
  mccs: [5814]
12
14
 
13
15
  - id: grocery
14
16
  name: Grocery
15
17
  - id: grocery.supermarket
16
18
  name: Supermarkets
19
+ aliases: [groceries, grocery, grocery stores, supermarket]
17
20
  mccs: [5411]
18
21
  - id: grocery.online
19
22
  name: Online grocery and delivery
23
+ aliases: [grocery delivery]
20
24
 
21
25
  - id: superstore
22
26
  name: Superstores
27
+ aliases: [superstore, big box stores]
23
28
  mccs: [5310]
24
29
  - id: warehouse_club
25
30
  name: Warehouse clubs
31
+ aliases: [warehouse club, wholesale clubs]
26
32
  mccs: [5300]
27
33
 
28
34
  - id: convenience_store
@@ -31,6 +37,7 @@
31
37
 
32
38
  - id: gas
33
39
  name: Gas stations
40
+ aliases: [gas, fuel, gas station]
34
41
  mccs: [5541, 5542]
35
42
  - id: gas.grocery_store
36
43
  name: Gas at grocery stores
@@ -38,6 +45,7 @@
38
45
  name: Gas at superstores and warehouse clubs
39
46
  - id: ev_charging
40
47
  name: EV charging
48
+ aliases: [ev, charging]
41
49
  mccs: [5552]
42
50
 
43
51
  - id: transit
@@ -45,6 +53,7 @@
45
53
  mccs: [4111, 4112, 4131, 4784, 7523]
46
54
  - id: transit.rideshare
47
55
  name: Rideshare and taxis
56
+ aliases: [rideshare, taxi, taxis]
48
57
  mccs: [4121]
49
58
 
50
59
  - id: travel
@@ -52,41 +61,53 @@
52
61
  mccs: [4722]
53
62
  - id: travel.flights
54
63
  name: Flights
64
+ aliases: [flights, airfare, airlines]
55
65
  mccs: [4511]
56
66
  - id: travel.hotels
57
67
  name: Hotels
68
+ aliases: [hotel]
58
69
  mccs: [7011]
59
70
  - id: travel.car_rental
60
71
  name: Car rental
72
+ aliases: [car rentals, rental cars]
61
73
  mccs: [7512]
62
74
  - id: travel.cruise
63
75
  name: Cruises
76
+ aliases: [cruise]
64
77
  mccs: [4411]
65
78
 
66
79
  - id: streaming
67
80
  name: Streaming services
81
+ aliases: [streaming, subscriptions]
68
82
  mccs: [4899, 5815]
69
83
  - id: tv_internet
70
84
  name: TV and internet service
85
+ aliases: [internet, cable]
71
86
  mccs: [4899]
72
87
  - id: phone
73
88
  name: Cell phone service
89
+ aliases: [phone, cell phone, wireless]
74
90
  mccs: [4814]
75
91
  - id: utilities
76
92
  name: Home utilities
93
+ aliases: [utilities, electric, electricity]
77
94
  mccs: [4900]
78
95
 
79
96
  - id: drugstore
80
97
  name: Drugstores and pharmacies
98
+ aliases: [drugstores, pharmacy, pharmacies]
81
99
  mccs: [5912]
82
100
  - id: movie_theaters
83
101
  name: Movie theaters
102
+ aliases: [movies, movie theater]
84
103
  mccs: [7832]
85
104
  - id: entertainment
86
105
  name: Live entertainment and events
106
+ aliases: [concerts, events, tickets]
87
107
  mccs: [7922, 7941]
88
108
  - id: fitness
89
109
  name: Gyms and fitness
110
+ aliases: [gym, gyms, fitness]
90
111
  mccs: [7997]
91
112
 
92
113
  - id: department_store
@@ -103,6 +124,7 @@
103
124
  mccs: [5941]
104
125
  - id: home_improvement
105
126
  name: Home improvement
127
+ aliases: [hardware stores]
106
128
  mccs: [5200, 5211]
107
129
  - id: furniture
108
130
  name: Furniture stores
@@ -110,6 +132,7 @@
110
132
 
111
133
  - id: online_retail
112
134
  name: Online retail
135
+ aliases: [online shopping, shopping online]
113
136
  - id: office_supply
114
137
  name: Office supply stores
115
138
  - id: shipping
@@ -119,3 +142,4 @@
119
142
 
120
143
  - id: other
121
144
  name: Everything else
145
+ aliases: [everything, everything else, other]
@@ -0,0 +1,364 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { CLI_NAME } from "../brand.js";
3
+ import { walletProblems } from "../data/integrity.js";
4
+ import { loadCatalog, walletPath } from "../data/load.js";
5
+ import { ALL_PERSONAL_CARDS, exploreCards, findCategory, } from "../engine/explore.js";
6
+ import { isValuationProfile, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
7
+ import { resolveSpendLines, simulateAdditions, simulateCatalog, } from "../engine/simulate.js";
8
+ import { knownMemberships } from "../engine/setup.js";
9
+ import { displayPath, readWallet, RUNTIME_ERROR, USAGE_ERROR, } from "./run.js";
10
+ const FILTER_OPTIONS = ` --cash, --points Cash back cards only, or points and miles cards only.
11
+ --no-fee Cards with no annual fee.
12
+ --issuer <id> One issuer's cards, e.g. chase or amex.
13
+ --business Business cards instead of personal ones.
14
+ --closed Include cards the issuer no longer takes applications for.
15
+ --member <name> A membership you have, e.g. amazon-prime. Repeatable.
16
+ --valuation <profile> flat (points at 1¢, the default) or points-heavy
17
+ (MR and UR at 1.7¢).`;
18
+ export const BEST_HELP = `Usage: ${CLI_NAME} best <merchant or category> [amount] [options]
19
+
20
+ Ranks every card in earnrate's catalog for one purchase, not just yours. Each
21
+ card is set up at its best: the right category pick, activated, cap unused.
22
+
23
+ Examples:
24
+ ${CLI_NAME} best groceries
25
+ ${CLI_NAME} best amazon 120 --member amazon-prime
26
+ ${CLI_NAME} best dining --no-fee
27
+
28
+ Options:
29
+ ${FILTER_OPTIONS}
30
+ --online, --in-store How you're paying. Default: inferred from the merchant.
31
+ --date <YYYY-MM-DD> Purchase date. Default: today.
32
+ --all List every card, not just the top 10.
33
+ --json Print the full result as JSON.
34
+ -h, --help Show this help.
35
+ `;
36
+ export const SIMULATE_HELP = `Usage: ${CLI_NAME} simulate <what>=<dollars a month>... [options]
37
+
38
+ Spends this much every month for a year and ranks the cards, and the
39
+ two-card pairs, that earn the most after annual fees. Each <what> is a
40
+ category (groceries, dining, gas, travel, streaming, other) or a merchant
41
+ (amazon, costco).
42
+
43
+ Examples:
44
+ ${CLI_NAME} simulate groceries=600 dining=300 gas=150 other=1000
45
+ ${CLI_NAME} simulate groceries=600 amazon=150 other=800 --wallet
46
+
47
+ Options:
48
+ --wallet Start from your wallet and rank the cards that would
49
+ add the most to it, after their fee.
50
+ ${FILTER_OPTIONS}
51
+ --start <YYYY-MM-DD> First month of the year simulated. Default: today.
52
+ --all List more results.
53
+ --json Print the full result as JSON.
54
+ -h, --help Show this help.
55
+ `;
56
+ const FILTER_FLAGS = {
57
+ cash: { type: 'boolean' },
58
+ points: { type: 'boolean' },
59
+ 'no-fee': { type: 'boolean' },
60
+ issuer: { type: 'string' },
61
+ business: { type: 'boolean' },
62
+ closed: { type: 'boolean' },
63
+ member: { type: 'string', multiple: true },
64
+ valuation: { type: 'string' },
65
+ all: { type: 'boolean' },
66
+ json: { type: 'boolean' },
67
+ help: { type: 'boolean', short: 'h' },
68
+ };
69
+ function settingsFrom(values, catalog) {
70
+ if (values.cash && values.points)
71
+ return 'Pick --cash or --points, not both.';
72
+ if (values.valuation !== undefined && !isValuationProfile(values.valuation))
73
+ return `Unknown valuation "${values.valuation}". Use flat or points-heavy.`;
74
+ const issuers = [...new Set(catalog.cards.map((c) => c.issuer))].sort();
75
+ if (values.issuer !== undefined && !issuers.includes(values.issuer))
76
+ return `Unknown issuer "${values.issuer}". Use one of: ${issuers.join(', ')}.`;
77
+ const known = knownMemberships(catalog);
78
+ const unknown = (values.member ?? []).filter((m) => !known.includes(m));
79
+ if (unknown.length > 0)
80
+ return `Unknown membership "${unknown.join('", "')}". Use one of: ${known.join(', ')}.`;
81
+ return {
82
+ filters: {
83
+ ...ALL_PERSONAL_CARDS,
84
+ kind: values.cash ? 'cash' : values.points ? 'points' : 'any',
85
+ business: values.business ?? false,
86
+ noAnnualFee: values['no-fee'] ?? false,
87
+ issuer: values.issuer ?? null,
88
+ includeClosed: values.closed ?? false,
89
+ },
90
+ memberships: values.member ?? [],
91
+ valuations: values.valuation !== undefined && isValuationProfile(values.valuation)
92
+ ? VALUATION_PROFILES[values.valuation]
93
+ : {},
94
+ };
95
+ }
96
+ const usd = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
97
+ const wholeUsd = (n) => usd(Math.round(n));
98
+ const pct = (n) => `${String(Number(n.toFixed(2)))}%`;
99
+ function feeText(e) {
100
+ if (e.annualFee === null)
101
+ return 'annual fee not on file';
102
+ if (e.annualFee === 0)
103
+ return 'no annual fee';
104
+ if (e.firstYearFee !== null && e.firstYearFee !== e.annualFee)
105
+ return `${usd(e.annualFee)} annual fee (${e.firstYearFee === 0 ? 'waived' : usd(e.firstYearFee)} the first year)`;
106
+ return `${usd(e.annualFee)} annual fee`;
107
+ }
108
+ const TOP_BEST = 10;
109
+ export function formatExploration(r, all) {
110
+ const m = r.merchant;
111
+ const title = m.source === 'caller'
112
+ ? m.resolvedName.toLowerCase()
113
+ : m.source === 'none'
114
+ ? `"${m.input}" (not recognized)`
115
+ : m.resolvedName;
116
+ if (r.ranked.length === 0)
117
+ return `No cards match at ${title}.\n`;
118
+ const shown = all ? r.ranked : r.ranked.slice(0, TOP_BEST);
119
+ const width = Math.max(...shown.map((e) => e.cardName.length));
120
+ const lines = [`Best cards for ${title}:`, ''];
121
+ shown.forEach((e, i) => {
122
+ const earned = e.earned === null ? '' : ` ${usd(e.earned)}`;
123
+ lines.push(`${String(i + 1).padStart(2)}. ${e.cardName.padEnd(width)} ${pct(e.effectiveReturnPct).padStart(5)}${earned} ${feeText(e)}`);
124
+ for (const c of e.conditions)
125
+ lines.push(` ${c}`);
126
+ const upgrade = e.withMembership[0];
127
+ if (upgrade)
128
+ lines.push(` With ${upgrade.membership}: ${pct(upgrade.effectiveReturnPct)}.`);
129
+ for (const w of e.warnings)
130
+ lines.push(` ! ${w}`);
131
+ });
132
+ if (r.ranked.length > shown.length)
133
+ lines.push(` + ${String(r.ranked.length - shown.length)} more (--all lists them)`);
134
+ for (const w of r.warnings)
135
+ lines.push('', `! ${w}`);
136
+ lines.push('', ...r.assumptions);
137
+ return `${lines.join('\n')}\n`;
138
+ }
139
+ function picksText(picks) {
140
+ const parts = Object.entries(picks).map(([choice, options]) => `${choice}: ${options.join(', ')}`);
141
+ return parts.length === 0 ? '' : ` (picks ${parts.join('; ')})`;
142
+ }
143
+ function cardNamer(catalog) {
144
+ return (id) => catalog.cards.find((c) => c.id === id)?.name ?? id;
145
+ }
146
+ function splitText(sim, name) {
147
+ const byCard = new Map();
148
+ for (const line of sim.lines) {
149
+ const first = line.cards[0];
150
+ if (first === undefined)
151
+ continue;
152
+ byCard.set(first, [...(byCard.get(first) ?? []), line.label]);
153
+ }
154
+ return [...byCard].map(([id, labels]) => `${labels.join(', ')} → ${name(id)}`);
155
+ }
156
+ const TOP_SIMULATED = 5;
157
+ export function formatCatalogSimulation(r, catalog, all) {
158
+ const name = cardNamer(catalog);
159
+ const first = r.singles[0];
160
+ if (!first)
161
+ return 'No cards match.\n';
162
+ const top = all ? 20 : TOP_SIMULATED;
163
+ const lines = [
164
+ `A year of this spending: ${wholeUsd(first.spend)}.`,
165
+ '',
166
+ 'Best single cards (rewards minus annual fee, per year):',
167
+ ];
168
+ r.singles.slice(0, top).forEach((s, i) => {
169
+ const card = s.cards[0];
170
+ if (!card)
171
+ return;
172
+ lines.push(`${String(i + 1).padStart(2)}. ${name(card.cardId)} ${wholeUsd(s.net)} (${wholeUsd(s.rewards)} rewards, ${wholeUsd(s.annualFees)} fee)${picksText(card.picks)}`);
173
+ });
174
+ const pairs = r.pairs.slice(0, top);
175
+ if (pairs.length > 0) {
176
+ lines.push('', 'Best two-card pairs:');
177
+ pairs.forEach((s, i) => {
178
+ lines.push(`${String(i + 1).padStart(2)}. ${s.cards.map((c) => `${name(c.cardId)}${picksText(c.picks)}`).join(' + ')} ${wholeUsd(s.net)}`);
179
+ if (s.alternatives.length > 0)
180
+ lines.push(` Same result with ${s.alternatives.map(name).join(' or ')} as the second card.`);
181
+ for (const split of splitText(s, name))
182
+ lines.push(` ${split}`);
183
+ });
184
+ const bestPair = r.pairs[0];
185
+ if (bestPair && bestPair.net <= first.net)
186
+ lines.push('', `One card is enough here: no pair beats ${name(first.cards[0]?.cardId ?? '')}.`);
187
+ }
188
+ const notes = new Set([
189
+ ...r.singles.slice(0, top).flatMap((s) => s.notes),
190
+ ...pairs.flatMap((s) => s.notes),
191
+ ]);
192
+ if (notes.size > 0)
193
+ lines.push('', ...notes);
194
+ lines.push('', "Assumes each card's best picks, activations done each quarter, and no welcome bonuses.");
195
+ return `${lines.join('\n')}\n`;
196
+ }
197
+ export function formatAdditions(r, catalog, all) {
198
+ const name = cardNamer(catalog);
199
+ const lines = [
200
+ `Your wallet earns about ${wholeUsd(r.current.rewards)} a year on this spending${r.current.annualFees > 0 ? `, with ${wholeUsd(r.current.annualFees)} in annual fees` : ''}.`,
201
+ ];
202
+ for (const split of splitText(r.current, name))
203
+ lines.push(` ${split}`);
204
+ const gains = r.additions.filter((a) => a.gain > 0);
205
+ if (gains.length === 0) {
206
+ lines.push('', 'No card would add more than its annual fee costs.');
207
+ }
208
+ else {
209
+ lines.push('', 'Cards that would add the most (after their annual fee):');
210
+ gains.slice(0, all ? 20 : TOP_SIMULATED).forEach((a, i) => {
211
+ lines.push(`${String(i + 1).padStart(2)}. ${name(a.cardId)} +${wholeUsd(a.gain)} a year (+${wholeUsd(a.extraRewards)} rewards, ${wholeUsd(a.annualFee)} fee)${picksText(a.picks)}`);
212
+ });
213
+ }
214
+ const notes = new Set(r.current.notes);
215
+ if (notes.size > 0)
216
+ lines.push('', ...notes);
217
+ lines.push('', 'Assumes your picks carry over, rotating categories get activated, and caps start fresh.');
218
+ return `${lines.join('\n')}\n`;
219
+ }
220
+ export async function runBest(argv, io) {
221
+ let parsed;
222
+ try {
223
+ parsed = parseArgs({
224
+ args: argv,
225
+ allowPositionals: true,
226
+ options: {
227
+ ...FILTER_FLAGS,
228
+ online: { type: 'boolean' },
229
+ 'in-store': { type: 'boolean' },
230
+ date: { type: 'string' },
231
+ },
232
+ });
233
+ }
234
+ catch (e) {
235
+ io.stderr(`${e instanceof Error ? e.message : String(e)}\n${BEST_HELP}`);
236
+ return USAGE_ERROR;
237
+ }
238
+ const { values, positionals } = parsed;
239
+ if (values.help) {
240
+ io.stdout(BEST_HELP);
241
+ return 0;
242
+ }
243
+ const words = [...positionals];
244
+ const last = words.at(-1);
245
+ const amount = words.length > 1 && last !== undefined && /^\$?\d+(\.\d+)?$/.test(last)
246
+ ? Number(last.replace('$', ''))
247
+ : null;
248
+ if (amount !== null)
249
+ words.pop();
250
+ const text = words.join(' ').trim();
251
+ if (text === '') {
252
+ io.stderr(`Say what you're buying, e.g. ${CLI_NAME} best groceries.\n`);
253
+ return USAGE_ERROR;
254
+ }
255
+ if (values.online && values['in-store']) {
256
+ io.stderr('Pick --online or --in-store, not both.\n');
257
+ return USAGE_ERROR;
258
+ }
259
+ const catalog = await loadCatalog();
260
+ const settings = settingsFrom(values, catalog);
261
+ if (typeof settings === 'string') {
262
+ io.stderr(`${settings}\n`);
263
+ return USAGE_ERROR;
264
+ }
265
+ const category = findCategory(catalog, text);
266
+ const result = exploreCards(catalog, {
267
+ merchant: category === null ? text : null,
268
+ category,
269
+ amount,
270
+ channel: values.online
271
+ ? 'online'
272
+ : values['in-store']
273
+ ? 'in_store'
274
+ : null,
275
+ date: values.date ?? io.today,
276
+ }, settings);
277
+ io.stdout(values.json
278
+ ? `${JSON.stringify(result, null, 2)}\n`
279
+ : formatExploration(result, values.all ?? false));
280
+ return 0;
281
+ }
282
+ export function parseSpend(catalog, args) {
283
+ const lines = [];
284
+ for (const arg of args) {
285
+ const match = /^(.+)=\$?(\d+(?:\.\d+)?)$/.exec(arg);
286
+ if (!match?.[1] || match[2] === undefined)
287
+ return `"${arg}" isn't <what>=<dollars a month>, e.g. groceries=600.`;
288
+ const what = match[1].replace(/[-_]/g, ' ');
289
+ const category = findCategory(catalog, what);
290
+ lines.push({
291
+ merchant: category === null ? what : null,
292
+ category,
293
+ monthly: Number(match[2]),
294
+ });
295
+ }
296
+ if (lines.length === 0)
297
+ return `Give your monthly spending, e.g. ${CLI_NAME} simulate groceries=600 dining=300 other=1000.`;
298
+ return lines;
299
+ }
300
+ export async function runSimulate(argv, io) {
301
+ let parsed;
302
+ try {
303
+ parsed = parseArgs({
304
+ args: argv,
305
+ allowPositionals: true,
306
+ options: {
307
+ ...FILTER_FLAGS,
308
+ wallet: { type: 'boolean' },
309
+ start: { type: 'string' },
310
+ },
311
+ });
312
+ }
313
+ catch (e) {
314
+ io.stderr(`${e instanceof Error ? e.message : String(e)}\n${SIMULATE_HELP}`);
315
+ return USAGE_ERROR;
316
+ }
317
+ const { values, positionals } = parsed;
318
+ if (values.help) {
319
+ io.stdout(SIMULATE_HELP);
320
+ return 0;
321
+ }
322
+ const catalog = await loadCatalog();
323
+ const settings = settingsFrom(values, catalog);
324
+ if (typeof settings === 'string') {
325
+ io.stderr(`${settings}\n`);
326
+ return USAGE_ERROR;
327
+ }
328
+ const spend = parseSpend(catalog, positionals);
329
+ if (typeof spend === 'string') {
330
+ io.stderr(`${spend}\n`);
331
+ return USAGE_ERROR;
332
+ }
333
+ const lines = resolveSpendLines(catalog, spend);
334
+ const unknown = lines.filter((l) => l.merchant.source === 'none');
335
+ if (unknown.length > 0) {
336
+ io.stderr(`Not a category or a merchant earnrate knows: ${unknown.map((l) => `"${l.merchant.input}"`).join(', ')}. Categories include groceries, dining, gas, travel, streaming and other.\n`);
337
+ return USAGE_ERROR;
338
+ }
339
+ const options = { ...settings, start: values.start ?? io.today };
340
+ if (values.wallet) {
341
+ const file = walletPath(io.env);
342
+ const wallet = await readWallet(file, io);
343
+ if (!wallet)
344
+ return RUNTIME_ERROR;
345
+ const problems = walletProblems(wallet, catalog);
346
+ if (problems.length > 0) {
347
+ io.stderr(`Your wallet at ${displayPath(file)} has problems:\n${problems.map((p) => ` - ${p}\n`).join('')}`);
348
+ return RUNTIME_ERROR;
349
+ }
350
+ const valued = values.valuation !== undefined && isValuationProfile(values.valuation)
351
+ ? withValuationProfile(wallet, values.valuation)
352
+ : wallet;
353
+ const result = simulateAdditions(catalog, valued, lines, options);
354
+ io.stdout(values.json
355
+ ? `${JSON.stringify(result, null, 2)}\n`
356
+ : formatAdditions(result, catalog, values.all ?? false));
357
+ return 0;
358
+ }
359
+ const result = simulateCatalog(catalog, lines, options);
360
+ io.stdout(values.json
361
+ ? `${JSON.stringify(result, null, 2)}\n`
362
+ : formatCatalogSimulation(result, catalog, values.all ?? false));
363
+ return 0;
364
+ }
package/dist/cli/run.js CHANGED
@@ -12,6 +12,7 @@ import { llmEnabled, openLlmSession } from "../merchants/from-env.js";
12
12
  import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
13
13
  import { packageVersion } from "../version.js";
14
14
  import { parseCliArgs } from "./args.js";
15
+ import { runBest, runSimulate } from "./explore.js";
15
16
  import { runImport } from "./import.js";
16
17
  import { runOffers } from "./offers.js";
17
18
  import { runWalletCommand, WALLET_HELP } from "./wallet.js";
@@ -25,6 +26,10 @@ Examples:
25
26
  ${CLI_NAME} costco --in-store
26
27
  ${CLI_NAME} delta.com 450 --valuation points-heavy
27
28
 
29
+ Compare every card, no wallet needed:
30
+ ${CLI_NAME} best <merchant or category> Rank all cards for one purchase.
31
+ ${CLI_NAME} simulate groceries=600 dining=300 Best cards and pairs for a year of spending.
32
+
28
33
  Set up your wallet:
29
34
  ${CLI_NAME} cards [search words] Find cards and their ids.
30
35
  ${CLI_NAME} add <card-id> [--pick ...] Add a card you hold.
@@ -87,6 +92,10 @@ export async function run(argv, io) {
87
92
  return runImport(argv.slice(1), io);
88
93
  if (argv[0] === 'offers')
89
94
  return runOffers(argv.slice(1), io);
95
+ if (argv[0] === 'best')
96
+ return runBest(argv.slice(1), io);
97
+ if (argv[0] === 'simulate')
98
+ return runSimulate(argv.slice(1), io);
90
99
  const command = argv[0];
91
100
  if (command !== undefined && Object.hasOwn(WALLET_HELP, command)) {
92
101
  return runWalletCommand(command, argv.slice(1), io);