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.
- package/README.md +22 -20
- package/dist/brand.js +2 -3
- package/dist/cli/args.js +64 -51
- package/dist/cli/cheatsheet.js +9 -12
- package/dist/cli/explore.js +28 -27
- package/dist/cli/import.js +3 -8
- package/dist/cli/offers.js +7 -6
- package/dist/cli/run.js +17 -13
- package/dist/cli/wallet.js +7 -6
- package/dist/data/file-store.js +54 -15
- package/dist/data/load.js +1 -1
- package/dist/data/store.js +0 -16
- package/dist/data/wallet-edit.js +68 -2
- package/dist/engine/bonus.js +15 -12
- package/dist/engine/cheat-sheet.js +2 -2
- package/dist/engine/evaluate.js +34 -10
- package/dist/engine/explore.js +9 -22
- package/dist/engine/offers.js +8 -21
- package/dist/engine/periods.js +9 -0
- package/dist/engine/recommend.js +14 -53
- package/dist/engine/record.js +2 -7
- package/dist/engine/simulate.js +12 -19
- package/dist/engine/text.js +53 -0
- package/dist/engine/transient.js +5 -0
- package/dist/engine/valuation.js +1 -1
- package/dist/engine/wallet-change.js +9 -15
- package/dist/engine/wallet-summary.js +4 -8
- package/dist/format/card-text.js +79 -0
- package/dist/format/cheat-sheet.js +1 -1
- package/dist/format/explore.js +3 -13
- package/dist/format/offers.js +5 -14
- package/dist/{cli/format.js → format/recommendation.js} +2 -6
- package/dist/format/wallet.js +38 -0
- package/dist/import/analyze.js +17 -19
- package/dist/mcp/context.js +92 -0
- package/dist/mcp/inputs.js +148 -0
- package/dist/mcp/schemas.js +1 -1
- package/dist/mcp/server.js +14 -818
- package/dist/mcp/tools/account.js +71 -0
- package/dist/mcp/tools/answers.js +329 -0
- package/dist/mcp/tools/wallet.js +128 -0
- package/dist/merchants/from-env.js +1 -1
- package/dist/merchants/resolve.js +3 -3
- package/package.json +2 -2
- package/dist/data/wallet-setup.js +0 -60
- package/dist/data/wallet-write.js +0 -140
- /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.
|
|
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
|
|
13
|
-
-
|
|
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'
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
2
|
-
//
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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
|
};
|
package/dist/cli/cheatsheet.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
56
|
-
|
|
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 };
|
package/dist/cli/explore.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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 };
|
package/dist/cli/import.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
package/dist/cli/offers.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
56
|
+
const problem = valuationProblem(values.valuation);
|
|
57
|
+
if (problem !== null) {
|
|
58
|
+
io.stderr(`${problem}\n`);
|
|
57
59
|
return USAGE_ERROR;
|
|
58
60
|
}
|
|
59
|
-
const
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
163
|
-
const loaded = await cardsToUse(
|
|
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,
|