earnrate 0.2.0 → 0.2.1

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.
Files changed (47) hide show
  1. package/README.md +22 -20
  2. package/dist/brand.js +2 -3
  3. package/dist/cli/args.js +64 -51
  4. package/dist/cli/cheatsheet.js +9 -12
  5. package/dist/cli/explore.js +28 -27
  6. package/dist/cli/import.js +3 -8
  7. package/dist/cli/offers.js +7 -6
  8. package/dist/cli/run.js +17 -13
  9. package/dist/cli/wallet.js +7 -6
  10. package/dist/data/file-store.js +54 -15
  11. package/dist/data/load.js +1 -1
  12. package/dist/data/store.js +0 -16
  13. package/dist/data/wallet-edit.js +68 -2
  14. package/dist/engine/bonus.js +15 -12
  15. package/dist/engine/cheat-sheet.js +2 -2
  16. package/dist/engine/evaluate.js +34 -10
  17. package/dist/engine/explore.js +9 -22
  18. package/dist/engine/offers.js +8 -21
  19. package/dist/engine/periods.js +9 -0
  20. package/dist/engine/recommend.js +14 -53
  21. package/dist/engine/record.js +2 -7
  22. package/dist/engine/simulate.js +12 -19
  23. package/dist/engine/text.js +53 -0
  24. package/dist/engine/transient.js +5 -0
  25. package/dist/engine/valuation.js +1 -1
  26. package/dist/engine/wallet-change.js +9 -15
  27. package/dist/engine/wallet-summary.js +4 -8
  28. package/dist/format/card-text.js +79 -0
  29. package/dist/format/cheat-sheet.js +1 -1
  30. package/dist/format/explore.js +3 -13
  31. package/dist/format/offers.js +5 -14
  32. package/dist/{cli/format.js → format/recommendation.js} +2 -6
  33. package/dist/format/wallet.js +38 -0
  34. package/dist/import/analyze.js +17 -19
  35. package/dist/mcp/context.js +92 -0
  36. package/dist/mcp/inputs.js +148 -0
  37. package/dist/mcp/schemas.js +1 -1
  38. package/dist/mcp/server.js +14 -818
  39. package/dist/mcp/tools/account.js +71 -0
  40. package/dist/mcp/tools/answers.js +329 -0
  41. package/dist/mcp/tools/wallet.js +128 -0
  42. package/dist/merchants/from-env.js +1 -1
  43. package/dist/merchants/resolve.js +3 -3
  44. package/package.json +2 -2
  45. package/dist/data/wallet-setup.js +0 -60
  46. package/dist/data/wallet-write.js +0 -140
  47. /package/dist/{cli → format}/cards.js +0 -0
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 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.
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. Save your cards and it tracks your caps and chosen categories. It's valuation-aware and transparent about its assumptions. It never sees a card number.
11
11
 
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
- - US cards and US merchants, for now.
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 and no affiliate links; the hosted server counts requests by tool name, nothing about what you ask. See the [privacy page](https://earnrate.dev/privacy).
13
+ - U.S. cards and U.S. merchants, for now.
14
14
 
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.
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) (run 2026-10-24) for how it compares to an existing card-picking MCP server.
16
16
 
17
17
  ## Try it
18
18
 
@@ -27,13 +27,13 @@ earnrate whole foods 80 --cards amex-gold,chase-prime-visa # just these cards
27
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
28
 
29
29
  ```sh
30
- earnrate cards sapphire # find your cards' ids
30
+ earnrate cards sapphire # find your cards' IDs
31
31
  earnrate add chase-sapphire-preferred
32
32
  earnrate add amex-gold
33
33
  earnrate whole foods 80
34
34
  ```
35
35
 
36
- Adding a card tells you anything else it needs to earn correctly, such as the categories you picked on Cash+, Amazon Prime for Prime Visa's 5%, or which rotating categories you've activated. `earnrate update` sets those. Using an AI assistant instead? [Add the MCP server](#use-it-from-an-ai-agent-mcp) and tell it which cards you have.
36
+ Adding a card tells you anything else it needs to earn correctly, such as the categories you picked on Cash+, Amazon Prime for Prime Visa's 5%, or which rotating categories you've activated. `earnrate update` sets those. Using an AI assistant instead? [Add earnrate to it](#use-it-in-an-ai-assistant-mcp) and ask; name your cards or save them there.
37
37
 
38
38
  With the [example wallet](wallet.example.yaml), it prints:
39
39
 
@@ -81,7 +81,7 @@ cards:
81
81
  two-percent: [gas-ev-charging]
82
82
  ```
83
83
 
84
- Rule IDs for `activated`, the choice slots for `chosen` and the membership IDs that unlock bonuses (such as `bofa-rewards-premier` for a BofA Rewards tier) are in each card's file. Currencies and their default values are in [`data/currencies.yaml`](data/currencies.yaml). Spending caps fill up as you record purchases from an AI agent or import a statement with `--apply`, and reset with each period.
84
+ Rule IDs for `activated`, the choice slots for `chosen` and the membership IDs that unlock bonuses (such as `bofa-rewards-premier` for a BofA Rewards tier) are in each card's file. Currencies and their default values are in [`data/currencies.yaml`](data/currencies.yaml). Spending caps fill up as you record purchases from an AI assistant or import a statement with `--apply`, and reset with each period.
85
85
 
86
86
  ### Merchants earnrate doesn't know
87
87
 
@@ -113,7 +113,7 @@ While the bonus is open, that card wins wherever it's accepted, and the answer s
113
113
 
114
114
  ### Which new card?
115
115
 
116
- No wallet needed for these:
116
+ These compare every card earnrate knows:
117
117
 
118
118
  ```sh
119
119
  earnrate best groceries # every card, ranked for one purchase
@@ -165,7 +165,7 @@ Something the format can't express yet (Bilt's rent earning, Citi Nights) is lis
165
165
 
166
166
  Spotted a wrong rate, or want a card added? [Open an issue](https://github.com/cwasley/earnrate/issues) with a link to the issuer's own page.
167
167
 
168
- ## Use it from an AI agent (MCP)
168
+ ## Use it in an AI assistant (MCP)
169
169
 
170
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
171
 
@@ -173,7 +173,9 @@ Spotted a wrong rate, or want a card added? [Open an issue](https://github.com/c
173
173
  claude mcp add --transport http earnrate https://mcp.earnrate.dev
174
174
  ```
175
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):
176
+ Or install it as a Claude Code plugin, which connects Claude Code and Cowork to the same server: `/plugin marketplace add cwasley/earnrate`, then `/plugin install earnrate@earnrate`.
177
+
178
+ **Or run it on your computer:** `earnrate mcp` runs earnrate as a local MCP server over stdio, with 13 tools (the connector has the same ones, plus `export_wallet`, `import_wallet` and `delete_wallet` for its saved wallets):
177
179
 
178
180
  - `best_card_for_purchase`: which card to use, with reasons
179
181
  - `best_cards_for` and `simulate_spending`: compare every card earnrate knows, for one purchase or a year of spending, with no wallet needed
@@ -184,13 +186,11 @@ claude mcp add --transport http earnrate https://mcp.earnrate.dev
184
186
  - `record_purchase`: books a purchase against its cap and any open welcome bonus in your wallet file, keeping your comments
185
187
  - `search_cards`, `add_card`, `update_card`, `remove_card` and `set_memberships`: set up the wallet in conversation
186
188
 
187
- There's no wallet to write first: tell the assistant which cards you have, and it finds them, adds them, and asks about anything a card needs, like this quarter's Cash+ picks.
189
+ Saving cards is optional: name them in the conversation for one answer, or ask the assistant to save them, and it finds them, adds them, and asks about anything a card needs, like this quarter's Cash+ picks.
188
190
 
189
191
  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`.
190
192
 
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.
193
+ **Claude Desktop, running locally:** 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
194
 
195
195
  To run it locally in Claude Code:
196
196
 
@@ -222,12 +222,12 @@ merchant text
222
222
 
223
223
  - **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.
224
224
  - **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.
225
- - **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.
225
+ - **The engine is pure and deterministic.** Given a purchase, a date and a set of cards (yours, or every card), 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.
226
226
  - **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.
227
227
  - **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.
228
228
  - **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.
229
229
 
230
- The code is TypeScript run directly by Node 24 (`src/`), with the engine in `src/engine/`, merchant resolution in `src/merchants/`, and the CLI and MCP server as thin layers over them.
230
+ The code is TypeScript run directly by Node 24 (`src/`), with the engine in `src/engine/`, merchant resolution in `src/merchants/`, and the CLI and MCP server as thin layers over them. `site/` is earnrate.dev, which runs the same engine in the browser, and `remote/` is the hosted server at mcp.earnrate.dev.
231
231
 
232
232
  ## Keeping card data current
233
233
 
@@ -242,18 +242,20 @@ Rotating categories (Freedom Flex) are added each quarter once the issuer posts
242
242
 
243
243
  ## Privacy Policy
244
244
 
245
- earnrate needs no account and has no telemetry. The full policy is at [earnrate.dev/privacy](https://earnrate.dev/privacy); in short:
245
+ earnrate needs no account. The local version has no telemetry; the hosted server counts requests by tool name only. The full policy is at [earnrate.dev/privacy](https://earnrate.dev/privacy); in short:
246
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.
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, or a week if it's never used.
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
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
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
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.
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 saved wallet on mcp.earnrate.dev lasts until you delete it or leave it unused for 13 months.
253
253
  - **Contact:** [hello@earnrate.dev](mailto:hello@earnrate.dev).
254
254
 
255
255
  ## Working on earnrate
256
256
 
257
+ Development needs Node 24 or later.
258
+
257
259
  ```sh
258
260
  git clone https://github.com/cwasley/earnrate.git && cd earnrate
259
261
  yarn install
package/dist/brand.js CHANGED
@@ -1,6 +1,5 @@
1
- // The only place the product name lives. package.json `name` and `bin` can't
2
- // import this, so keep them in sync by hand.
3
- export const PRODUCT_NAME = 'earnrate';
1
+ // Names users type or set. package.json's `name` and `bin` must match
2
+ // CLI_NAME by hand, since JSON can't import this.
4
3
  export const CLI_NAME = 'earnrate';
5
4
  export const MCP_SERVER_NAME = 'earnrate';
6
5
  export const CONFIG_DIR = '.earnrate'; // under the home directory
package/dist/cli/args.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { parseArgs } from 'node:util';
2
2
  import { isValuationProfile, } from "../engine/profiles.js";
3
+ import { knownMemberships } from "../engine/setup.js";
4
+ // Shared by every subcommand, so each flag means the same thing everywhere.
5
+ // A string result is the error to show.
3
6
  const LOOKS_LIKE_AMOUNT = /^\$?[\d,]*\.?\d+$/;
4
7
  const AMOUNT = /^\$?(\d{1,3}(,\d{3})+|\d+)(\.\d{1,2})?$/;
5
8
  function parseAmount(text) {
@@ -9,11 +12,54 @@ function parseAmount(text) {
9
12
  const amount = Number(text.replace(/[$,]/g, ''));
10
13
  return amount > 0 ? amount : 'The amount must be more than $0.';
11
14
  }
12
- function isValidDate(text) {
13
- if (!/^\d{4}-\d{2}-\d{2}$/.test(text))
14
- return false;
15
+ // A trailing number is the amount only when something comes before it, so
16
+ // a merchant named "76" still works on its own.
17
+ export function splitAmount(positionals) {
18
+ const words = [...positionals];
19
+ const last = words.at(-1);
20
+ let amount = null;
21
+ if (words.length > 1 && last !== undefined && LOOKS_LIKE_AMOUNT.test(last)) {
22
+ const result = parseAmount(last);
23
+ if (typeof result === 'string')
24
+ return result;
25
+ amount = result;
26
+ words.pop();
27
+ }
28
+ return { text: words.join(' ').trim(), amount };
29
+ }
30
+ export function dateProblem(text) {
31
+ if (text === undefined)
32
+ return null;
15
33
  const date = new Date(`${text}T00:00:00Z`);
16
- return !Number.isNaN(date.getTime()) && date.toISOString().startsWith(text);
34
+ return /^\d{4}-\d{2}-\d{2}$/.test(text) &&
35
+ !Number.isNaN(date.getTime()) &&
36
+ date.toISOString().startsWith(text)
37
+ ? null
38
+ : `"${text}" isn't a date. Use YYYY-MM-DD.`;
39
+ }
40
+ export function valuationProblem(text) {
41
+ return text === undefined || isValuationProfile(text)
42
+ ? null
43
+ : `Unknown valuation "${text}". Use flat or points-heavy.`;
44
+ }
45
+ export function membershipProblem(catalog, members) {
46
+ const known = knownMemberships(catalog);
47
+ const unknown = members.filter((m) => !known.includes(m));
48
+ return unknown.length === 0
49
+ ? null
50
+ : `Unknown membership "${unknown.join('", "')}". Use one of: ${known.join(', ')}.`;
51
+ }
52
+ export const BOTH_CHANNELS = 'Pick --online or --in-store, not both.';
53
+ export function channelOf(values) {
54
+ return values.online ? 'online' : values['in-store'] ? 'in_store' : null;
55
+ }
56
+ export function cardList(text) {
57
+ return text === undefined
58
+ ? null
59
+ : text
60
+ .split(',')
61
+ .map((c) => c.trim())
62
+ .filter((c) => c !== '');
17
63
  }
18
64
  export function parseCliArgs(argv) {
19
65
  let parsed;
@@ -47,49 +93,21 @@ export function parseCliArgs(argv) {
47
93
  const { values, positionals } = parsed;
48
94
  if (values.help)
49
95
  return { kind: 'help' };
50
- // A trailing number is the amount only when something comes before it, so
51
- // a merchant named "76" still works on its own.
52
- const words = [...positionals];
53
- let amount = null;
54
- const last = words.at(-1);
55
- if (words.length > 1 && last !== undefined && LOOKS_LIKE_AMOUNT.test(last)) {
56
- const result = parseAmount(last);
57
- if (typeof result === 'string')
58
- return { kind: 'error', message: result };
59
- amount = result;
60
- words.pop();
61
- }
62
- const merchant = words.join(' ').trim();
96
+ const split = splitAmount(positionals);
97
+ if (typeof split === 'string')
98
+ return { kind: 'error', message: split };
99
+ const { text: merchant, amount } = split;
63
100
  if (merchant === '') {
64
101
  return { kind: 'error', message: 'Name a merchant, like "whole foods".' };
65
102
  }
66
- if (values.online && values['in-store']) {
67
- return {
68
- kind: 'error',
69
- message: 'Pick one of --online and --in-store.',
70
- };
71
- }
72
- const channel = values.online
73
- ? 'online'
74
- : values['in-store']
75
- ? 'in_store'
76
- : null;
77
- let valuation = null;
78
- if (values.valuation !== undefined) {
79
- if (!isValuationProfile(values.valuation)) {
80
- return {
81
- kind: 'error',
82
- message: `Unknown valuation "${values.valuation}". Use flat or points-heavy.`,
83
- };
84
- }
85
- valuation = values.valuation;
86
- }
87
- if (values.date !== undefined && !isValidDate(values.date)) {
88
- return {
89
- kind: 'error',
90
- message: `"${values.date}" isn't a date. Use YYYY-MM-DD.`,
91
- };
92
- }
103
+ if (values.online && values['in-store'])
104
+ return { kind: 'error', message: BOTH_CHANNELS };
105
+ const problem = valuationProblem(values.valuation) ?? dateProblem(values.date);
106
+ if (problem !== null)
107
+ return { kind: 'error', message: problem };
108
+ const valuation = values.valuation !== undefined && isValuationProfile(values.valuation)
109
+ ? values.valuation
110
+ : null;
93
111
  const country = values.country ?? 'US';
94
112
  if (!/^[A-Za-z]{2}$/.test(country)) {
95
113
  return {
@@ -102,7 +120,7 @@ export function parseCliArgs(argv) {
102
120
  options: {
103
121
  merchant,
104
122
  amount,
105
- channel,
123
+ channel: channelOf(values),
106
124
  valuation,
107
125
  date: values.date ?? null,
108
126
  country: country.toUpperCase(),
@@ -110,12 +128,7 @@ export function parseCliArgs(argv) {
110
128
  all: values.all ?? false,
111
129
  category: values.category ?? null,
112
130
  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 !== ''),
131
+ cards: cardList(values.cards),
119
132
  members: values.member ?? [],
120
133
  },
121
134
  };
@@ -2,11 +2,12 @@ import { parseArgs } from 'node:util';
2
2
  import { CLI_NAME } from "../brand.js";
3
3
  import { loadCatalog, walletPath } from "../data/load.js";
4
4
  import { catalogCheatSheet, cheatSheet } from "../engine/cheat-sheet.js";
5
- import { formatCheatSheet } from "../format/cheat-sheet.js";
6
5
  import { ALL_PERSONAL_CARDS } from "../engine/explore.js";
7
6
  import { isValuationProfile, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
7
+ import { formatCheatSheet } from "../format/cheat-sheet.js";
8
+ import { cardList, dateProblem, valuationProblem } from "./args.js";
8
9
  import { cardsToUse, NO_WALLET_HINT, RUNTIME_ERROR, USAGE_ERROR, } from "./run.js";
9
- export const CHEATSHEET_HELP = `Usage: ${CLI_NAME} cheatsheet [options]
10
+ const CHEATSHEET_HELP = `Usage: ${CLI_NAME} cheatsheet [options]
10
11
 
11
12
  The card to use for each everyday kind of purchase this quarter: from your
12
13
  wallet (with your caps, picks and activations as they are now), from cards you
@@ -44,21 +45,18 @@ export async function runCheatSheet(argv, io) {
44
45
  io.stdout(CHEATSHEET_HELP);
45
46
  return 0;
46
47
  }
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`);
48
+ const problem = valuationProblem(values.valuation) ?? dateProblem(values.date);
49
+ if (problem !== null) {
50
+ io.stderr(`${problem}\n`);
53
51
  return USAGE_ERROR;
54
52
  }
55
- const loaded = await cardsToUse(walletPath(io.env), io, {
56
- cards: values.cards === undefined ? null : values.cards.split(','),
53
+ const catalog = await loadCatalog();
54
+ const loaded = await cardsToUse(catalog, walletPath(io.env), io, {
55
+ cards: cardList(values.cards),
57
56
  members: values.member ?? [],
58
57
  });
59
58
  if (loaded === null)
60
59
  return RUNTIME_ERROR;
61
- const catalog = await loadCatalog();
62
60
  const date = values.date ?? io.today;
63
61
  if (loaded === 'none') {
64
62
  const sheet = catalogCheatSheet(catalog, date, {
@@ -82,4 +80,3 @@ export async function runCheatSheet(argv, io) {
82
80
  : formatCheatSheet(sheet));
83
81
  return 0;
84
82
  }
85
- export { formatCheatSheet };
@@ -4,8 +4,8 @@ import { loadCatalog, walletPath } from "../data/load.js";
4
4
  import { ALL_PERSONAL_CARDS, exploreCards, findCategory, } from "../engine/explore.js";
5
5
  import { isValuationProfile, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
6
6
  import { resolveSpendLines, simulateAdditions, simulateCatalog, spendLineFor, TYPICAL_MONTHLY_SPEND, TYPICAL_SPEND_NOTE, } from "../engine/simulate.js";
7
- import { knownMemberships } from "../engine/setup.js";
8
7
  import { formatAdditions, formatCatalogSimulation, formatExploration, } from "../format/explore.js";
8
+ import { BOTH_CHANNELS, cardList, channelOf, dateProblem, membershipProblem, splitAmount, valuationProblem, } from "./args.js";
9
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.
@@ -15,7 +15,7 @@ const FILTER_OPTIONS = ` --cash, --points Cash back cards only, or point
15
15
  --member <name> A membership you have, e.g. amazon-prime. Repeatable.
16
16
  --valuation <profile> flat (points at 1¢, the default) or points-heavy
17
17
  (MR and UR at 1.7¢).`;
18
- export const BEST_HELP = `Usage: ${CLI_NAME} best <merchant or category> [amount] [options]
18
+ const BEST_HELP = `Usage: ${CLI_NAME} best <merchant or category> [amount] [options]
19
19
 
20
20
  Ranks every card in earnrate's catalog for one purchase, not just yours. Each
21
21
  card is set up at its best: the right category pick, activated, cap unused.
@@ -33,7 +33,7 @@ ${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
+ 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
@@ -70,15 +70,13 @@ const FILTER_FLAGS = {
70
70
  function settingsFrom(values, catalog) {
71
71
  if (values.cash && values.points)
72
72
  return 'Pick --cash or --points, not both.';
73
- if (values.valuation !== undefined && !isValuationProfile(values.valuation))
74
- return `Unknown valuation "${values.valuation}". Use flat or points-heavy.`;
73
+ const problem = valuationProblem(values.valuation) ??
74
+ membershipProblem(catalog, values.member ?? []);
75
+ if (problem !== null)
76
+ return problem;
75
77
  const issuers = [...new Set(catalog.cards.map((c) => c.issuer))].sort();
76
78
  if (values.issuer !== undefined && !issuers.includes(values.issuer))
77
79
  return `Unknown issuer "${values.issuer}". Use one of: ${issuers.join(', ')}.`;
78
- const known = knownMemberships(catalog);
79
- const unknown = (values.member ?? []).filter((m) => !known.includes(m));
80
- if (unknown.length > 0)
81
- return `Unknown membership "${unknown.join('", "')}". Use one of: ${known.join(', ')}.`;
82
80
  return {
83
81
  filters: {
84
82
  ...ALL_PERSONAL_CARDS,
@@ -117,20 +115,23 @@ export async function runBest(argv, io) {
117
115
  io.stdout(BEST_HELP);
118
116
  return 0;
119
117
  }
120
- const words = [...positionals];
121
- const last = words.at(-1);
122
- const amount = words.length > 1 && last !== undefined && /^\$?\d+(\.\d+)?$/.test(last)
123
- ? Number(last.replace('$', ''))
124
- : null;
125
- if (amount !== null)
126
- words.pop();
127
- const text = words.join(' ').trim();
118
+ const split = splitAmount(positionals);
119
+ if (typeof split === 'string') {
120
+ io.stderr(`${split}\n`);
121
+ return USAGE_ERROR;
122
+ }
123
+ const { text, amount } = split;
128
124
  if (text === '') {
129
125
  io.stderr(`Say what you're buying, e.g. ${CLI_NAME} best groceries.\n`);
130
126
  return USAGE_ERROR;
131
127
  }
132
128
  if (values.online && values['in-store']) {
133
- io.stderr('Pick --online or --in-store, not both.\n');
129
+ io.stderr(`${BOTH_CHANNELS}\n`);
130
+ return USAGE_ERROR;
131
+ }
132
+ const dated = dateProblem(values.date);
133
+ if (dated !== null) {
134
+ io.stderr(`${dated}\n`);
134
135
  return USAGE_ERROR;
135
136
  }
136
137
  const catalog = await loadCatalog();
@@ -144,11 +145,7 @@ export async function runBest(argv, io) {
144
145
  merchant: category === null ? text : null,
145
146
  category,
146
147
  amount,
147
- channel: values.online
148
- ? 'online'
149
- : values['in-store']
150
- ? 'in_store'
151
- : null,
148
+ channel: channelOf(values),
152
149
  date: values.date ?? io.today,
153
150
  }, settings);
154
151
  io.stdout(values.json
@@ -156,7 +153,7 @@ export async function runBest(argv, io) {
156
153
  : formatExploration(result, values.all ?? false));
157
154
  return 0;
158
155
  }
159
- export function parseSpend(catalog, args) {
156
+ function parseSpend(catalog, args) {
160
157
  const lines = [];
161
158
  for (const arg of args) {
162
159
  const match = /^(.+)=\$?(\d+(?:\.\d+)?)$/.exec(arg);
@@ -217,10 +214,15 @@ export async function runSimulate(argv, io) {
217
214
  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`);
218
215
  return USAGE_ERROR;
219
216
  }
217
+ const started = dateProblem(values.start);
218
+ if (started !== null) {
219
+ io.stderr(`${started}\n`);
220
+ return USAGE_ERROR;
221
+ }
220
222
  const options = { ...settings, start: values.start ?? io.today };
221
223
  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
+ const wallet = await cardsToUse(catalog, walletPath(io.env), io, {
225
+ cards: cardList(values.cards),
224
226
  members: values.member ?? [],
225
227
  });
226
228
  if (wallet === null)
@@ -244,4 +246,3 @@ export async function runSimulate(argv, io) {
244
246
  : `${typical ? `${TYPICAL_SPEND_NOTE}\n\n` : ''}${formatCatalogSimulation(result, catalog, values.all ?? false)}`);
245
247
  return 0;
246
248
  }
247
- export { formatAdditions, formatCatalogSimulation, formatExploration };
@@ -6,13 +6,13 @@ import { z } from 'zod';
6
6
  import { CLI_NAME } from "../brand.js";
7
7
  import { walletProblems } from "../data/integrity.js";
8
8
  import { loadCatalog, walletPath } from "../data/load.js";
9
- import { writeBonusSpent, writeCapBooking } from "../data/wallet-write.js";
9
+ import { writeProgress } from "../data/wallet-edit.js";
10
10
  import { isValuationProfile, withValuationProfile } from "../engine/profiles.js";
11
11
  import { cardValuation } from "../engine/valuation.js";
12
12
  import { analyzeStatement, } from "../import/analyze.js";
13
13
  import { parseStatement } from "../import/statement.js";
14
14
  import { displayPath, readWallet, RUNTIME_ERROR, USAGE_ERROR, } from "./run.js";
15
- export const IMPORT_HELP = `Usage: ${CLI_NAME} import <statement.csv> --card <card-id> [--apply]
15
+ const IMPORT_HELP = `Usage: ${CLI_NAME} import <statement.csv> --card <card-id> [--apply]
16
16
  ${CLI_NAME} import <statement.csv>:<card-id> [<statement.csv>:<card-id> ...]
17
17
 
18
18
  Reads card statements (Amex and Chase activity CSVs) and reports:
@@ -241,12 +241,7 @@ export async function runImport(argv, io) {
241
241
  : `Nothing was written. Run again with --apply to record ${toRecord.join(' and ')}.\n`);
242
242
  return 0;
243
243
  }
244
- for (const { booking } of analysis.bookings) {
245
- await writeCapBooking(walletFile, booking);
246
- }
247
- for (const b of analysis.bonuses) {
248
- await writeBonusSpent(walletFile, b.cardId, b.after);
249
- }
244
+ await writeProgress(walletFile, analysis.bookings.map((e) => e.booking), analysis.bonuses);
250
245
  for (const entry of analysis.bookings)
251
246
  recorded.add(fingerprint(entry));
252
247
  for (const entry of bonusEntries)
@@ -4,8 +4,9 @@ import { loadCatalog, walletPath } from "../data/load.js";
4
4
  import { compareOffers } from "../engine/offers.js";
5
5
  import { isValuationProfile, withValuationProfile } from "../engine/profiles.js";
6
6
  import { formatOffers } from "../format/offers.js";
7
+ import { valuationProblem } from "./args.js";
7
8
  import { cardsToUse, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
8
- export const OFFERS_HELP = `Usage: ${CLI_NAME} offers [--cash | --points] [--no-fee] [--business] [options]
9
+ const OFFERS_HELP = `Usage: ${CLI_NAME} offers [--cash | --points] [--no-fee] [--business] [options]
9
10
 
10
11
  Ranks the welcome offers on cards you don't have by first-year value: the
11
12
  bonus at your point values minus the first year's annual fee.
@@ -52,17 +53,18 @@ export async function runOffers(argv, io) {
52
53
  io.stderr('Pick --cash or --points, not both.\n');
53
54
  return USAGE_ERROR;
54
55
  }
55
- if (values.valuation !== undefined && !isValuationProfile(values.valuation)) {
56
- io.stderr(`Unknown valuation "${values.valuation}". Use flat or points-heavy.\n`);
56
+ const problem = valuationProblem(values.valuation);
57
+ if (problem !== null) {
58
+ io.stderr(`${problem}\n`);
57
59
  return USAGE_ERROR;
58
60
  }
59
- const found = await cardsToUse(walletPath(io.env), io, {
61
+ const catalog = await loadCatalog();
62
+ const found = await cardsToUse(catalog, walletPath(io.env), io, {
60
63
  cards: null,
61
64
  members: [],
62
65
  });
63
66
  if (found === null)
64
67
  return RUNTIME_ERROR;
65
- const catalog = await loadCatalog();
66
68
  const loaded = found === 'none' ? { valuations: {}, memberships: [], cards: [] } : found;
67
69
  const wallet = values.valuation && isValuationProfile(values.valuation)
68
70
  ? withValuationProfile(loaded, values.valuation)
@@ -81,4 +83,3 @@ export async function runOffers(argv, io) {
81
83
  io.stdout(formatOffers(comparison, currencyName, values.all ?? false));
82
84
  return 0;
83
85
  }
84
- export { formatOffers };
package/dist/cli/run.js CHANGED
@@ -5,23 +5,24 @@ import { fileURLToPath } from 'node:url';
5
5
  import { CLI_NAME, CONFIG_DIR, LLM_ENV_VAR, LLM_MAX_CALLS_ENV_VAR, LLM_MODEL_ENV_VAR, MERCHANT_CACHE_ENV_VAR, WALLET_ENV_VAR, } from "../brand.js";
6
6
  import { walletProblems } from "../data/integrity.js";
7
7
  import { loadCatalog, loadWallet, walletPath } from "../data/load.js";
8
- import { VALUATION_PROFILES, withValuationProfile } from "../engine/profiles.js";
9
8
  import { ALL_PERSONAL_CARDS, categoryResolution, exploreCards, findCategory, } from "../engine/explore.js";
9
+ import { VALUATION_PROFILES, withValuationProfile } from "../engine/profiles.js";
10
10
  import { recommend } from "../engine/recommend.js";
11
11
  import { walletFromCards } from "../engine/transient.js";
12
+ import { formatExploration } from "../format/explore.js";
13
+ import { formatRecommendation, plain, } from "../format/recommendation.js";
12
14
  import { DEFAULT_LLM_MODEL } from "../merchants/anthropic.js";
13
15
  import { DEFAULT_MAX_CALLS } from "../merchants/fallback.js";
14
16
  import { llmEnabled, openLlmSession } from "../merchants/from-env.js";
15
17
  import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
16
18
  import { packageVersion } from "../version.js";
17
- import { parseCliArgs } from "./args.js";
19
+ import { membershipProblem, parseCliArgs } from "./args.js";
18
20
  import { runCheatSheet } from "./cheatsheet.js";
19
- import { formatExploration, runBest, runSimulate } from "./explore.js";
21
+ import { runBest, runSimulate } from "./explore.js";
20
22
  import { runImport } from "./import.js";
21
23
  import { runOffers } from "./offers.js";
22
24
  import { runWalletCommand, WALLET_HELP } from "./wallet.js";
23
- import { formatRecommendation, plain } from "./format.js";
24
- export const HELP = `Usage: ${CLI_NAME} <merchant> [amount] [options]
25
+ const HELP = `Usage: ${CLI_NAME} <merchant> [amount] [options]
25
26
 
26
27
  Picks the card that earns the most at a merchant, and says why: from your
27
28
  wallet if you've saved one, from cards you name with --cards, or else from
@@ -36,7 +37,7 @@ Compare every card, no wallet needed:
36
37
  ${CLI_NAME} best <merchant or category> Rank all cards for one purchase.
37
38
  ${CLI_NAME} simulate groceries=600 dining=300 Best cards and pairs for a year of spending.
38
39
 
39
- Set up your wallet:
40
+ Save your cards (optional):
40
41
  ${CLI_NAME} cards [search words] Find cards and their ids.
41
42
  ${CLI_NAME} add <card-id> [--pick ...] Add a card you hold.
42
43
  ${CLI_NAME} update <card-id> [--pick ...] Change picks, label or activations.
@@ -44,10 +45,10 @@ Set up your wallet:
44
45
  ${CLI_NAME} memberships [names...] Show or set memberships (amazon-prime).
45
46
 
46
47
  Other commands:
47
- ${CLI_NAME} cheatsheet Your best card for each category this quarter.
48
+ ${CLI_NAME} cheatsheet The best card for each category this quarter.
48
49
  ${CLI_NAME} import <statement.csv> --card <id> Replay a statement against your wallet.
49
50
  ${CLI_NAME} offers [--cash | --points] Rank welcome offers on cards you don't have.
50
- ${CLI_NAME} mcp Run the MCP server over stdio, for AI agents.
51
+ ${CLI_NAME} mcp Run the MCP server over stdio, for AI assistants.
51
52
  Add --help to any command for its options.
52
53
 
53
54
  Options:
@@ -85,8 +86,12 @@ export function displayPath(file) {
85
86
  }
86
87
  // The cards to answer for: those named with --cards, else the wallet file.
87
88
  // 'none' means no wallet and no --cards, which answers from every card.
88
- export async function cardsToUse(file, io, named) {
89
- const catalog = await loadCatalog();
89
+ export async function cardsToUse(catalog, file, io, named) {
90
+ const problem = membershipProblem(catalog, named.members);
91
+ if (problem !== null) {
92
+ io.stderr(`${problem}\n`);
93
+ return null;
94
+ }
90
95
  if (named.cards !== null) {
91
96
  const wallet = walletFromCards(catalog, {
92
97
  cards: named.cards.map((id) => ({ id })),
@@ -159,11 +164,10 @@ export async function run(argv, io) {
159
164
  }
160
165
  const { options } = args;
161
166
  const llmOn = options.llm ?? llmEnabled(io.env);
162
- const file = walletPath(io.env);
163
- const loaded = await cardsToUse(file, io, options);
167
+ const catalog = await loadCatalog();
168
+ const loaded = await cardsToUse(catalog, walletPath(io.env), io, options);
164
169
  if (loaded === null)
165
170
  return RUNTIME_ERROR;
166
- const catalog = await loadCatalog();
167
171
  const purchase = {
168
172
  merchant: options.merchant,
169
173
  amount: options.amount,