earnrate 0.1.3 → 0.2.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 CHANGED
@@ -7,12 +7,12 @@
7
7
 
8
8
  I wanted a ghost card: one card that always charges the best of my cards. The card networks won't allow one, so I built the next best thing.
9
9
 
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.
10
+ **earnrate is the best-card decision layer for people and AI agents.** Give it a merchant, and it tells you which card earns the most there, from your own cards or from every card it knows, 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, 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).
12
+ - Runs on your machine, in your browser at [earnrate.dev](https://earnrate.dev), or in Claude as a connector at `https://mcp.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
- > **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.
15
+ > **Status:** early, and it works: the engine, CLI, optional Claude merchant lookup, MCP server and the hosted Claude connector. See [the benchmark](docs/benchmark.md) for how it compares to an existing card-picking MCP server.
16
16
 
17
17
  ## Try it
18
18
 
@@ -20,6 +20,13 @@ Needs Node 22 or later.
20
20
 
21
21
  ```sh
22
22
  npm install -g earnrate
23
+ earnrate restaurants # the best cards earnrate knows, no setup
24
+ earnrate whole foods 80 --cards amex-gold,chase-prime-visa # just these cards
25
+ ```
26
+
27
+ You never have to enter your cards: with none, every answer covers every card earnrate knows. To have earnrate remember yours, track caps and welcome bonuses, and give a cheat sheet for your own wallet, save them:
28
+
29
+ ```sh
23
30
  earnrate cards sapphire # find your cards' ids
24
31
  earnrate add chase-sapphire-preferred
25
32
  earnrate add amex-gold
@@ -51,6 +58,8 @@ Assumes Whole Foods Market codes as supermarkets (MCC 5411).
51
58
 
52
59
  Options: `--online` or `--in-store`, `--valuation flat|points-heavy`, `--all`, `--json`, `--date`, `--country`. Run `earnrate --help` for details. To keep the wallet somewhere else, set `EARNRATE_WALLET` to its path.
53
60
 
61
+ `earnrate cheatsheet` prints the card to use for each everyday kind of purchase this quarter (groceries, dining, gas, Amazon, Costco, travel, streaming, drugstores, everything else), with your caps, picks and activations as they are now. It's also in the My wallet tab on [earnrate.dev](https://earnrate.dev).
62
+
54
63
  ### Your wallet
55
64
 
56
65
  The wallet is a YAML file (`~/.earnrate/wallet.yaml`) listing your cards by ID, which is each card's file name in [`data/cards/`](data/cards). `earnrate add`, `update`, `remove` and `memberships` write it for you and keep your comments, or you can edit it by hand. Only the cards are required:
@@ -85,14 +94,20 @@ earnrate ships with a curated list of merchants. For anything else it falls back
85
94
 
86
95
  ### Welcome bonuses
87
96
 
88
- When you're working toward a card's sign-up bonus, add it to that card in your wallet:
97
+ When you're working toward a card's sign-up bonus, tell earnrate when the account opened:
98
+
99
+ ```sh
100
+ earnrate update chase-sapphire-preferred --bonus-opened 2026-10-01 --bonus-spent 400
101
+ ```
102
+
103
+ Or tell your AI assistant ("I opened my Sapphire Preferred on October 1"), or check "I'm working on this card's welcome bonus" in My wallet on earnrate.dev. `--no-bonus` stops tracking it. In the wallet file it looks like this:
89
104
 
90
105
  ```yaml
91
106
  - id: chase-sapphire-preferred
92
107
  bonus: { opened: 2026-10-01, spend: 5000, by: 2026-12-31, points: 75000 }
93
108
  ```
94
109
 
95
- While the bonus is open, that card wins wherever it's accepted, and the answer says how much is left, the weekly pace to finish on time, what the bonus is worth at your point values, and what using it costs compared with your best earner. Spend counts up as you record purchases from an AI agent or import a statement with `--apply`; refunds count down. Once it's met or the deadline passes, the card goes back to the normal ranking. With two open bonuses, the one needing more spend per day comes first.
110
+ While the bonus is open, that card wins wherever it's accepted, and the answer says how much is left, the weekly pace to finish on time, what the bonus is worth at your point values, and what using it costs compared with your best earner. Spend counts up as you record purchases (from an AI assistant or the website) or import a statement with `--apply`; refunds count down. Once it's met or the deadline passes, the card goes back to the normal ranking. With two open bonuses, the one needing more spend per day comes first.
96
111
 
97
112
  `spend`, `by` and the reward (`points`, or `dollars` for cash back) default to the card's published offer once earnrate has one; set them when your offer was different.
98
113
 
@@ -152,12 +167,19 @@ Spotted a wrong rate, or want a card added? [Open an issue](https://github.com/c
152
167
 
153
168
  ## Use it from an AI agent (MCP)
154
169
 
155
- `earnrate mcp` runs earnrate as a local MCP server over stdio, with twelve tools:
170
+ **In Claude, nothing to install:** add a custom connector (Settings → Connectors) with the address `https://mcp.earnrate.dev`. It works in Claude on the web, desktop and phone. Questions need no sign-in. If you ask it to save your cards, it opens an earnrate page where you start an anonymous wallet: no email, just a recovery code you keep. The wallet tracks caps and welcome bonuses across your Claude apps, and you can export it for the command line or delete it from the chat. In Claude Code:
171
+
172
+ ```sh
173
+ claude mcp add --transport http earnrate https://mcp.earnrate.dev
174
+ ```
175
+
176
+ **Or run it on your computer:** `earnrate mcp` runs earnrate as a local MCP server over stdio, with thirteen tools (the connector has the same ones, plus `export_wallet`, `import_wallet` and `delete_wallet` for its saved wallets):
156
177
 
157
178
  - `best_card_for_purchase`: which card to use, with reasons
158
179
  - `best_cards_for` and `simulate_spending`: compare every card earnrate knows, for one purchase or a year of spending, with no wallet needed
159
180
  - `resolve_merchant`: how a merchant was identified
160
181
  - `list_wallet`: your cards, caps left this period, chosen categories and welcome-bonus progress
182
+ - `cheat_sheet`: the card to use for each everyday kind of purchase this quarter
161
183
  - `compare_welcome_offers`: the best public sign-up offers on cards you don't have
162
184
  - `record_purchase`: books a purchase against its cap and any open welcome bonus in your wallet file, keeping your comments
163
185
  - `search_cards`, `add_card`, `update_card`, `remove_card` and `set_memberships`: set up the wallet in conversation
@@ -166,7 +188,11 @@ There's no wallet to write first: tell the assistant which cards you have, and i
166
188
 
167
189
  For a merchant earnrate doesn't know, the calling model can pass a `category` (e.g. `grocery.supermarket`). It's used only for unknown merchants and is labeled in the answer. The command line takes the same thing as `--category`.
168
190
 
169
- To add it to Claude Code:
191
+ **Claude Code plugin:** `/plugin install earnrate --marketplace cwasley/earnrate` connects Claude Code and Cowork to the same hosted server as the connector.
192
+
193
+ **Claude Desktop, one click:** download [earnrate.mcpb](https://github.com/cwasley/earnrate/releases/latest/download/earnrate.mcpb) and open it; Claude Desktop installs it (Settings → Extensions). No terminal or Node install needed. Its one setting is where your wallet file lives, by default `~/.earnrate/wallet.yaml`, the same file the command line uses.
194
+
195
+ To run it locally in Claude Code:
170
196
 
171
197
  ```sh
172
198
  claude mcp add --scope user earnrate -- npx -y earnrate mcp
@@ -214,6 +240,18 @@ A GitHub Action runs it every Monday and opens a "Card source changes" issue whe
214
240
 
215
241
  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
242
 
243
+ ## Privacy Policy
244
+
245
+ earnrate needs no account and has no telemetry. The full policy is at [earnrate.dev/privacy](https://earnrate.dev/privacy); in short:
246
+
247
+ - **The Claude connector:** earnrate's server at mcp.earnrate.dev receives each question Claude asks it (a store or kind of purchase, and any amount, spending or cards you gave) and answers without keeping it; request logging is off. It counts requests by tool name only. A saved wallet is optional and anonymous (a recovery code, no email), holds card ids and the details you gave, never card numbers, and is encrypted with its own key that only your recovery code or your connected apps can unlock. Delete it any time; it's deleted automatically after 13 months unused.
248
+ - **What it stores locally:** your wallet, a YAML file on your computer (`~/.earnrate/wallet.yaml` unless you set `EARNRATE_WALLET`). It lists cards by product id, like `amex-gold`, plus your category picks, activations and how much you've spent toward caps. earnrate never asks for or stores card numbers, expiry dates, security codes or bank logins.
249
+ - **What the local version sends:** nothing, by default. The optional merchant lookup (off unless you turn it on with your own Anthropic API key) sends only the name of a store earnrate doesn't recognize to Anthropic's API, and saves the answer in `~/.earnrate/merchant-cache.json`.
250
+ - **AI assistants:** when you use earnrate through Claude or another assistant, that assistant's provider sees the conversation, including earnrate's answers, under its own privacy policy.
251
+ - **earnrate.dev:** everything you enter on the site stays in your browser. The site counts visits with Cloudflare Web Analytics, without cookies.
252
+ - **Retention:** locally, your data lives only in files you control, for as long as you keep them. Delete the wallet file, or clear the site's data in your browser, to remove it. A connector wallet lasts until you delete it or leave it unused for 13 months.
253
+ - **Contact:** [hello@earnrate.dev](mailto:hello@earnrate.dev).
254
+
217
255
  ## Working on earnrate
218
256
 
219
257
  ```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.3
6
+
7
+ More everyday words find categories, for questions asked without a store name ("coffee shop", "dinner", "train"). Each maps to the category those purchases already fall under. No rates changed.
8
+
9
+ | Category | Field | Old → new | Source |
10
+ | ------------------------------------------------------------------------------------------------------------------------- | ------- | ------------------- | ------ |
11
+ | dining, dining.fast_food, gas, transit, travel.hotels, travel.flights, convenience_store, home_improvement, online_retail | aliases | adds everyday words | n/a |
12
+
5
13
  ## 2026.10.30.2
6
14
 
7
15
  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.
package/data/VERSION CHANGED
@@ -1 +1 @@
1
- 2026.10.30.2
1
+ 2026.10.30.3
@@ -5,11 +5,24 @@
5
5
 
6
6
  - id: dining
7
7
  name: Restaurants
8
- aliases: [dining, restaurant, food, eating out, takeout]
8
+ aliases:
9
+ [
10
+ dining,
11
+ restaurant,
12
+ food,
13
+ eating out,
14
+ takeout,
15
+ dinner,
16
+ lunch,
17
+ breakfast,
18
+ brunch,
19
+ food delivery,
20
+ ]
9
21
  mccs: [5812, 5813, 5814]
10
22
  - id: dining.fast_food
11
23
  name: Fast food
12
- aliases: [fast food restaurants]
24
+ aliases:
25
+ [fast food restaurants, coffee shop, coffee shops, coffee, cafe, cafes]
13
26
  mccs: [5814]
14
27
 
15
28
  - id: grocery
@@ -33,11 +46,12 @@
33
46
 
34
47
  - id: convenience_store
35
48
  name: Convenience stores
49
+ aliases: [convenience store, corner store, bodega]
36
50
  mccs: [5499]
37
51
 
38
52
  - id: gas
39
53
  name: Gas stations
40
- aliases: [gas, fuel, gas station]
54
+ aliases: [gas, fuel, gas station, gas stations]
41
55
  mccs: [5541, 5542]
42
56
  - id: gas.grocery_store
43
57
  name: Gas at grocery stores
@@ -50,6 +64,7 @@
50
64
 
51
65
  - id: transit
52
66
  name: Transit
67
+ aliases: [public transit, train, trains, bus, commuting]
53
68
  mccs: [4111, 4112, 4131, 4784, 7523]
54
69
  - id: transit.rideshare
55
70
  name: Rideshare and taxis
@@ -61,11 +76,11 @@
61
76
  mccs: [4722]
62
77
  - id: travel.flights
63
78
  name: Flights
64
- aliases: [flights, airfare, airlines]
79
+ aliases: [flights, airfare, airlines, flight, plane tickets]
65
80
  mccs: [4511]
66
81
  - id: travel.hotels
67
82
  name: Hotels
68
- aliases: [hotel]
83
+ aliases: [hotel, hotels, motel, motels]
69
84
  mccs: [7011]
70
85
  - id: travel.car_rental
71
86
  name: Car rental
@@ -124,7 +139,7 @@
124
139
  mccs: [5941]
125
140
  - id: home_improvement
126
141
  name: Home improvement
127
- aliases: [hardware stores]
142
+ aliases: [hardware stores, hardware store, home improvement store]
128
143
  mccs: [5200, 5211]
129
144
  - id: furniture
130
145
  name: Furniture stores
@@ -132,7 +147,7 @@
132
147
 
133
148
  - id: online_retail
134
149
  name: Online retail
135
- aliases: [online shopping, shopping online]
150
+ aliases: [online shopping, shopping online, online]
136
151
  - id: office_supply
137
152
  name: Office supply stores
138
153
  - id: shipping
package/dist/cli/args.js CHANGED
@@ -32,6 +32,8 @@ export function parseCliArgs(argv) {
32
32
  category: { type: 'string' },
33
33
  all: { type: 'boolean' },
34
34
  llm: { type: 'boolean' },
35
+ cards: { type: 'string' },
36
+ member: { type: 'string', multiple: true },
35
37
  help: { type: 'boolean', short: 'h' },
36
38
  },
37
39
  });
@@ -108,6 +110,13 @@ export function parseCliArgs(argv) {
108
110
  all: values.all ?? false,
109
111
  category: values.category ?? null,
110
112
  llm: values.llm ?? null,
113
+ cards: values.cards === undefined
114
+ ? null
115
+ : values.cards
116
+ .split(',')
117
+ .map((c) => c.trim())
118
+ .filter((c) => c !== ''),
119
+ members: values.member ?? [],
111
120
  },
112
121
  };
113
122
  }
@@ -0,0 +1,85 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { CLI_NAME } from "../brand.js";
3
+ import { loadCatalog, walletPath } from "../data/load.js";
4
+ import { catalogCheatSheet, cheatSheet } from "../engine/cheat-sheet.js";
5
+ import { formatCheatSheet } from "../format/cheat-sheet.js";
6
+ import { ALL_PERSONAL_CARDS } from "../engine/explore.js";
7
+ import { isValuationProfile, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
8
+ import { cardsToUse, NO_WALLET_HINT, RUNTIME_ERROR, USAGE_ERROR, } from "./run.js";
9
+ export const CHEATSHEET_HELP = `Usage: ${CLI_NAME} cheatsheet [options]
10
+
11
+ The card to use for each everyday kind of purchase this quarter: from your
12
+ wallet (with your caps, picks and activations as they are now), from cards you
13
+ name with --cards, or else from every card earnrate knows.
14
+
15
+ Options:
16
+ --cards <ids> Use these cards instead of your wallet, comma separated.
17
+ --member <name> A membership you have, like amazon-prime. Repeatable.
18
+ --valuation <profile> flat or points-heavy, instead of your wallet's values.
19
+ --date <YYYY-MM-DD> The day to check. Default: today.
20
+ --json Print the cheat sheet as JSON.
21
+ -h, --help Show this help.
22
+ `;
23
+ export async function runCheatSheet(argv, io) {
24
+ let parsed;
25
+ try {
26
+ parsed = parseArgs({
27
+ args: argv,
28
+ options: {
29
+ valuation: { type: 'string' },
30
+ date: { type: 'string' },
31
+ cards: { type: 'string' },
32
+ member: { type: 'string', multiple: true },
33
+ json: { type: 'boolean' },
34
+ help: { type: 'boolean', short: 'h' },
35
+ },
36
+ });
37
+ }
38
+ catch (e) {
39
+ io.stderr(`${e instanceof Error ? e.message : String(e)}\n${CHEATSHEET_HELP}`);
40
+ return USAGE_ERROR;
41
+ }
42
+ const { values } = parsed;
43
+ if (values.help) {
44
+ io.stdout(CHEATSHEET_HELP);
45
+ return 0;
46
+ }
47
+ if (values.valuation !== undefined && !isValuationProfile(values.valuation)) {
48
+ io.stderr(`Unknown valuation "${values.valuation}". Use flat or points-heavy.\n`);
49
+ return USAGE_ERROR;
50
+ }
51
+ if (values.date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(values.date)) {
52
+ io.stderr(`"${values.date}" isn't a date. Use YYYY-MM-DD.\n`);
53
+ return USAGE_ERROR;
54
+ }
55
+ const loaded = await cardsToUse(walletPath(io.env), io, {
56
+ cards: values.cards === undefined ? null : values.cards.split(','),
57
+ members: values.member ?? [],
58
+ });
59
+ if (loaded === null)
60
+ return RUNTIME_ERROR;
61
+ const catalog = await loadCatalog();
62
+ const date = values.date ?? io.today;
63
+ if (loaded === 'none') {
64
+ const sheet = catalogCheatSheet(catalog, date, {
65
+ filters: ALL_PERSONAL_CARDS,
66
+ memberships: values.member ?? [],
67
+ valuations: values.valuation !== undefined && isValuationProfile(values.valuation)
68
+ ? VALUATION_PROFILES[values.valuation]
69
+ : {},
70
+ });
71
+ io.stdout(values.json
72
+ ? `${JSON.stringify(sheet, null, 2)}\n`
73
+ : `${formatCheatSheet(sheet)}\n${NO_WALLET_HINT}\n`);
74
+ return 0;
75
+ }
76
+ const wallet = values.valuation !== undefined && isValuationProfile(values.valuation)
77
+ ? withValuationProfile(loaded, values.valuation)
78
+ : loaded;
79
+ const sheet = cheatSheet(catalog, wallet, date);
80
+ io.stdout(values.json
81
+ ? `${JSON.stringify(sheet, null, 2)}\n`
82
+ : formatCheatSheet(sheet));
83
+ return 0;
84
+ }
85
+ export { formatCheatSheet };
@@ -1,12 +1,12 @@
1
1
  import { parseArgs } from 'node:util';
2
2
  import { CLI_NAME } from "../brand.js";
3
- import { walletProblems } from "../data/integrity.js";
4
3
  import { loadCatalog, walletPath } from "../data/load.js";
5
4
  import { ALL_PERSONAL_CARDS, exploreCards, findCategory, } from "../engine/explore.js";
6
5
  import { isValuationProfile, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
7
- import { resolveSpendLines, simulateAdditions, simulateCatalog, } from "../engine/simulate.js";
6
+ import { resolveSpendLines, simulateAdditions, simulateCatalog, spendLineFor, TYPICAL_MONTHLY_SPEND, TYPICAL_SPEND_NOTE, } from "../engine/simulate.js";
8
7
  import { knownMemberships } from "../engine/setup.js";
9
- import { displayPath, readWallet, RUNTIME_ERROR, USAGE_ERROR, } from "./run.js";
8
+ import { formatAdditions, formatCatalogSimulation, formatExploration, } from "../format/explore.js";
9
+ import { cardsToUse, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
10
10
  const FILTER_OPTIONS = ` --cash, --points Cash back cards only, or points and miles cards only.
11
11
  --no-fee Cards with no annual fee.
12
12
  --issuer <id> One issuer's cards, e.g. chase or amex.
@@ -33,12 +33,12 @@ ${FILTER_OPTIONS}
33
33
  --json Print the full result as JSON.
34
34
  -h, --help Show this help.
35
35
  `;
36
- export const SIMULATE_HELP = `Usage: ${CLI_NAME} simulate <what>=<dollars a month>... [options]
36
+ export const SIMULATE_HELP = `Usage: ${CLI_NAME} simulate [<what>=<dollars a month>...] [options]
37
37
 
38
38
  Spends this much every month for a year and ranks the cards, and the
39
39
  two-card pairs, that earn the most after annual fees. Each <what> is a
40
40
  category (groceries, dining, gas, travel, streaming, other) or a merchant
41
- (amazon, costco).
41
+ (amazon, costco). With no amounts, it uses typical ones for a U.S. household.
42
42
 
43
43
  Examples:
44
44
  ${CLI_NAME} simulate groceries=600 dining=300 gas=150 other=1000
@@ -47,6 +47,7 @@ Examples:
47
47
  Options:
48
48
  --wallet Start from your wallet and rank the cards that would
49
49
  add the most to it, after their fee.
50
+ --cards <ids> The same, for cards you name instead of your wallet.
50
51
  ${FILTER_OPTIONS}
51
52
  --start <YYYY-MM-DD> First month of the year simulated. Default: today.
52
53
  --all List more results.
@@ -93,130 +94,6 @@ function settingsFrom(values, catalog) {
93
94
  : {},
94
95
  };
95
96
  }
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
97
  export async function runBest(argv, io) {
221
98
  let parsed;
222
99
  try {
@@ -306,6 +183,7 @@ export async function runSimulate(argv, io) {
306
183
  options: {
307
184
  ...FILTER_FLAGS,
308
185
  wallet: { type: 'boolean' },
186
+ cards: { type: 'string' },
309
187
  start: { type: 'string' },
310
188
  },
311
189
  });
@@ -325,7 +203,10 @@ export async function runSimulate(argv, io) {
325
203
  io.stderr(`${settings}\n`);
326
204
  return USAGE_ERROR;
327
205
  }
328
- const spend = parseSpend(catalog, positionals);
206
+ const typical = positionals.length === 0;
207
+ const spend = typical
208
+ ? TYPICAL_MONTHLY_SPEND.map((t) => spendLineFor(catalog, t.what, t.monthly))
209
+ : parseSpend(catalog, positionals);
329
210
  if (typeof spend === 'string') {
330
211
  io.stderr(`${spend}\n`);
331
212
  return USAGE_ERROR;
@@ -337,15 +218,16 @@ export async function runSimulate(argv, io) {
337
218
  return USAGE_ERROR;
338
219
  }
339
220
  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('')}`);
221
+ if (values.wallet || values.cards !== undefined) {
222
+ const wallet = await cardsToUse(walletPath(io.env), io, {
223
+ cards: values.cards === undefined ? null : values.cards.split(','),
224
+ members: values.member ?? [],
225
+ });
226
+ if (wallet === null)
348
227
  return RUNTIME_ERROR;
228
+ if (wallet === 'none') {
229
+ io.stderr(`No saved wallet yet. Name your cards instead: --cards amex-gold,chase-prime-visa\n`);
230
+ return USAGE_ERROR;
349
231
  }
350
232
  const valued = values.valuation !== undefined && isValuationProfile(values.valuation)
351
233
  ? withValuationProfile(wallet, values.valuation)
@@ -353,12 +235,13 @@ export async function runSimulate(argv, io) {
353
235
  const result = simulateAdditions(catalog, valued, lines, options);
354
236
  io.stdout(values.json
355
237
  ? `${JSON.stringify(result, null, 2)}\n`
356
- : formatAdditions(result, catalog, values.all ?? false));
238
+ : `${typical ? `${TYPICAL_SPEND_NOTE}\n\n` : ''}${formatAdditions(result, catalog, values.all ?? false)}`);
357
239
  return 0;
358
240
  }
359
241
  const result = simulateCatalog(catalog, lines, options);
360
242
  io.stdout(values.json
361
243
  ? `${JSON.stringify(result, null, 2)}\n`
362
- : formatCatalogSimulation(result, catalog, values.all ?? false));
244
+ : `${typical ? `${TYPICAL_SPEND_NOTE}\n\n` : ''}${formatCatalogSimulation(result, catalog, values.all ?? false)}`);
363
245
  return 0;
364
246
  }
247
+ export { formatAdditions, formatCatalogSimulation, formatExploration };
@@ -14,11 +14,13 @@ function header(rec, purchase) {
14
14
  ? `"${m.resolvedName}" (not recognized)`
15
15
  : m.source === 'caller'
16
16
  ? `${m.resolvedName} (category given)`
17
- : m.source === 'llm'
18
- ? m.category === 'other'
19
- ? `"${m.resolvedName}" (Claude couldn't place it)`
20
- : `${m.resolvedName} (classified by Claude)`
21
- : m.resolvedName,
17
+ : m.source === 'category'
18
+ ? m.resolvedName
19
+ : m.source === 'llm'
20
+ ? m.category === 'other'
21
+ ? `"${m.resolvedName}" (Claude couldn't place it)`
22
+ : `${m.resolvedName} (classified by Claude)`
23
+ : m.resolvedName,
22
24
  ];
23
25
  if (purchase.amount !== null)
24
26
  parts.push(amountText(purchase.amount));