earnrate 0.1.3 → 0.1.4

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
@@ -51,6 +51,8 @@ Assumes Whole Foods Market codes as supermarkets (MCC 5411).
51
51
 
52
52
  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
53
 
54
+ `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).
55
+
54
56
  ### Your wallet
55
57
 
56
58
  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:
@@ -152,12 +154,13 @@ Spotted a wrong rate, or want a card added? [Open an issue](https://github.com/c
152
154
 
153
155
  ## Use it from an AI agent (MCP)
154
156
 
155
- `earnrate mcp` runs earnrate as a local MCP server over stdio, with twelve tools:
157
+ `earnrate mcp` runs earnrate as a local MCP server over stdio, with thirteen tools:
156
158
 
157
159
  - `best_card_for_purchase`: which card to use, with reasons
158
160
  - `best_cards_for` and `simulate_spending`: compare every card earnrate knows, for one purchase or a year of spending, with no wallet needed
159
161
  - `resolve_merchant`: how a merchant was identified
160
162
  - `list_wallet`: your cards, caps left this period, chosen categories and welcome-bonus progress
163
+ - `cheat_sheet`: the card to use for each everyday kind of purchase this quarter
161
164
  - `compare_welcome_offers`: the best public sign-up offers on cards you don't have
162
165
  - `record_purchase`: books a purchase against its cap and any open welcome bonus in your wallet file, keeping your comments
163
166
  - `search_cards`, `add_card`, `update_card`, `remove_card` and `set_memberships`: set up the wallet in conversation
@@ -166,6 +169,8 @@ There's no wallet to write first: tell the assistant which cards you have, and i
166
169
 
167
170
  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
171
 
172
+ **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.
173
+
169
174
  To add it to Claude Code:
170
175
 
171
176
  ```sh
@@ -0,0 +1,81 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { CLI_NAME } from "../brand.js";
3
+ import { walletProblems } from "../data/integrity.js";
4
+ import { loadCatalog, walletPath } from "../data/load.js";
5
+ import { cheatSheet } from "../engine/cheat-sheet.js";
6
+ import { isValuationProfile, withValuationProfile } from "../engine/profiles.js";
7
+ import { displayPath, readWallet, RUNTIME_ERROR, USAGE_ERROR, } from "./run.js";
8
+ export const CHEATSHEET_HELP = `Usage: ${CLI_NAME} cheatsheet [options]
9
+
10
+ The card in your wallet to use for each everyday kind of purchase this
11
+ quarter, with your caps, picks and activations as they are now.
12
+
13
+ Options:
14
+ --valuation <profile> flat or points-heavy, instead of your wallet's values.
15
+ --date <YYYY-MM-DD> The day to check. Default: today.
16
+ --json Print the cheat sheet as JSON.
17
+ -h, --help Show this help.
18
+ `;
19
+ const pct = (n) => `${String(Number(n.toFixed(2)))}%`;
20
+ export function formatCheatSheet(sheet) {
21
+ const labelWidth = Math.max(...sheet.rows.map((r) => r.label.length));
22
+ const cardWidth = Math.max(...sheet.rows.map((r) => (r.cardName ?? 'none of your cards').length));
23
+ const lines = [`Your cheat sheet for ${sheet.quarter}`, ''];
24
+ for (const row of sheet.rows) {
25
+ lines.push(` ${row.label.padEnd(labelWidth)} ${(row.cardName ?? 'none of your cards').padEnd(cardWidth)} ${row.effectiveReturnPct === null ? '' : pct(row.effectiveReturnPct).padStart(5)}`.trimEnd());
26
+ if (row.note)
27
+ lines.push(` ${' '.repeat(labelWidth)} ! ${row.note}`);
28
+ }
29
+ if (sheet.notes.length > 0)
30
+ lines.push('', ...sheet.notes);
31
+ return `${lines.join('\n')}\n`;
32
+ }
33
+ export async function runCheatSheet(argv, io) {
34
+ let parsed;
35
+ try {
36
+ parsed = parseArgs({
37
+ args: argv,
38
+ options: {
39
+ valuation: { type: 'string' },
40
+ date: { type: 'string' },
41
+ json: { type: 'boolean' },
42
+ help: { type: 'boolean', short: 'h' },
43
+ },
44
+ });
45
+ }
46
+ catch (e) {
47
+ io.stderr(`${e instanceof Error ? e.message : String(e)}\n${CHEATSHEET_HELP}`);
48
+ return USAGE_ERROR;
49
+ }
50
+ const { values } = parsed;
51
+ if (values.help) {
52
+ io.stdout(CHEATSHEET_HELP);
53
+ return 0;
54
+ }
55
+ if (values.valuation !== undefined && !isValuationProfile(values.valuation)) {
56
+ io.stderr(`Unknown valuation "${values.valuation}". Use flat or points-heavy.\n`);
57
+ return USAGE_ERROR;
58
+ }
59
+ if (values.date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(values.date)) {
60
+ io.stderr(`"${values.date}" isn't a date. Use YYYY-MM-DD.\n`);
61
+ return USAGE_ERROR;
62
+ }
63
+ const file = walletPath(io.env);
64
+ const loaded = await readWallet(file, io);
65
+ if (!loaded)
66
+ return RUNTIME_ERROR;
67
+ const catalog = await loadCatalog();
68
+ const problems = walletProblems(loaded, catalog);
69
+ if (problems.length > 0) {
70
+ io.stderr(`Your wallet at ${displayPath(file)} has problems:\n${problems.map((p) => ` - ${p}\n`).join('')}`);
71
+ return RUNTIME_ERROR;
72
+ }
73
+ const wallet = values.valuation !== undefined && isValuationProfile(values.valuation)
74
+ ? withValuationProfile(loaded, values.valuation)
75
+ : loaded;
76
+ const sheet = cheatSheet(catalog, wallet, values.date ?? io.today);
77
+ io.stdout(values.json
78
+ ? `${JSON.stringify(sheet, null, 2)}\n`
79
+ : formatCheatSheet(sheet));
80
+ return 0;
81
+ }
package/dist/cli/run.js CHANGED
@@ -12,6 +12,7 @@ import { llmEnabled, openLlmSession } from "../merchants/from-env.js";
12
12
  import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
13
13
  import { packageVersion } from "../version.js";
14
14
  import { parseCliArgs } from "./args.js";
15
+ import { runCheatSheet } from "./cheatsheet.js";
15
16
  import { runBest, runSimulate } from "./explore.js";
16
17
  import { runImport } from "./import.js";
17
18
  import { runOffers } from "./offers.js";
@@ -38,6 +39,7 @@ Set up your wallet:
38
39
  ${CLI_NAME} memberships [names...] Show or set memberships (amazon-prime).
39
40
 
40
41
  Other commands:
42
+ ${CLI_NAME} cheatsheet Your best card for each category this quarter.
41
43
  ${CLI_NAME} import <statement.csv> --card <id> Replay a statement against your wallet.
42
44
  ${CLI_NAME} offers [--cash | --points] Rank welcome offers on cards you don't have.
43
45
  ${CLI_NAME} mcp Run the MCP server over stdio, for AI agents.
@@ -94,6 +96,8 @@ export async function run(argv, io) {
94
96
  return runOffers(argv.slice(1), io);
95
97
  if (argv[0] === 'best')
96
98
  return runBest(argv.slice(1), io);
99
+ if (argv[0] === 'cheatsheet')
100
+ return runCheatSheet(argv.slice(1), io);
97
101
  if (argv[0] === 'simulate')
98
102
  return runSimulate(argv.slice(1), io);
99
103
  const command = argv[0];
@@ -0,0 +1,48 @@
1
+ import { resolveMerchantById } from "../merchants/resolve.js";
2
+ import { categoryResolution } from "./explore.js";
3
+ import { periodKey } from "./periods.js";
4
+ import { recommend } from "./recommend.js";
5
+ import { setupNotes } from "./setup.js";
6
+ export const CHEAT_SHEET_ITEMS = [
7
+ { label: 'Groceries', category: 'grocery.supermarket' },
8
+ { label: 'Dining', category: 'dining' },
9
+ { label: 'Gas', category: 'gas' },
10
+ { label: 'Amazon', merchant: 'amazon' },
11
+ { label: 'Costco', merchant: 'costco' },
12
+ { label: 'Travel', category: 'travel' },
13
+ { label: 'Streaming', category: 'streaming' },
14
+ { label: 'Drugstores', category: 'drugstore' },
15
+ { label: 'Everything else', category: 'other' },
16
+ ];
17
+ // The best card in the wallet for each everyday kind of purchase on `date`,
18
+ // with caps, picks and activations as the wallet has them.
19
+ export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
20
+ const rows = items.map((item) => {
21
+ const merchant = 'merchant' in item
22
+ ? resolveMerchantById(catalog, item.merchant, null)
23
+ : categoryResolution(catalog, item.category, null);
24
+ if (!merchant)
25
+ throw new Error(`unknown merchant '${item.label}'`);
26
+ const rec = recommend(catalog, wallet, {
27
+ merchant: merchant.input,
28
+ amount: null,
29
+ channel: null,
30
+ date,
31
+ country: 'US',
32
+ }, merchant);
33
+ const top = rec.ranked.find((r) => r.cardId === rec.decision?.cardId);
34
+ return {
35
+ label: item.label,
36
+ cardId: top?.cardId ?? null,
37
+ cardName: top ? (top.checkoutLabel ?? top.cardName) : null,
38
+ effectiveReturnPct: top?.effectiveReturnPct ?? null,
39
+ matchedRule: top?.matchedRule ?? null,
40
+ note: top?.warnings[0] ?? null,
41
+ };
42
+ });
43
+ const notes = [
44
+ ...new Set(wallet.cards.flatMap((held) => setupNotes(catalog, wallet, held, date).map((n) => n.text))),
45
+ ];
46
+ const [year, q] = (periodKey('quarter', date, null) ?? '').split('-');
47
+ return { quarter: `${q ?? ''} ${year ?? ''}`, date, rows, notes };
48
+ }
@@ -288,3 +288,16 @@ export const SpendingSimulationSchema = z.object({
288
288
  gain: z.number(),
289
289
  })),
290
290
  });
291
+ export const CheatSheetSchema = z.object({
292
+ quarter: z.string(),
293
+ date: z.string(),
294
+ rows: z.array(z.object({
295
+ label: z.string(),
296
+ cardId: z.string().nullable(),
297
+ cardName: z.string().nullable(),
298
+ effectiveReturnPct: z.number().nullable(),
299
+ matchedRule: z.string().nullable(),
300
+ note: z.string().nullable(),
301
+ })),
302
+ notes: z.array(z.string()),
303
+ });
@@ -3,6 +3,7 @@ import { z } from 'zod';
3
3
  import { MCP_SERVER_NAME } from "../brand.js";
4
4
  import { formatCardInfo, formatNotes } from "../cli/cards.js";
5
5
  import { formatRecommendation } from "../cli/format.js";
6
+ import { formatCheatSheet } from "../cli/cheatsheet.js";
6
7
  import { formatAdditions, formatCatalogSimulation, formatExploration, } from "../cli/explore.js";
7
8
  import { formatOffers } from "../cli/offers.js";
8
9
  import { walletProblems } from "../data/integrity.js";
@@ -10,6 +11,7 @@ import { loadWallet } from "../data/load.js";
10
11
  import { withWalletLock, writeBonusSpent, writeCapBooking, } from "../data/wallet-write.js";
11
12
  import { addCard, removeCard, setMemberships, updateCard, } from "../data/wallet-setup.js";
12
13
  import { bonusStatus, countsTowardBonus } from "../engine/bonus.js";
14
+ import { cheatSheet } from "../engine/cheat-sheet.js";
13
15
  import { ALL_PERSONAL_CARDS, exploreCards } from "../engine/explore.js";
14
16
  import { compareOffers } from "../engine/offers.js";
15
17
  import { VALUATION_PROFILES } from "../engine/profiles.js";
@@ -20,7 +22,7 @@ import { knownMemberships, searchCards } from "../engine/setup.js";
20
22
  import { walletSummary } from "../engine/wallet-summary.js";
21
23
  import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
22
24
  import { IsoDate } from "../schema/common.js";
23
- import { CardSearchSchema, ExplorationSchema, MerchantResolutionSchema, SpendingSimulationSchema, RecommendationSchema, OfferComparisonSchema, RecordResultSchema, WalletChangeSchema, WalletSummarySchema, } from "./schemas.js";
25
+ import { CardSearchSchema, CheatSheetSchema, ExplorationSchema, MerchantResolutionSchema, SpendingSimulationSchema, RecommendationSchema, OfferComparisonSchema, RecordResultSchema, WalletChangeSchema, WalletSummarySchema, } from "./schemas.js";
24
26
  const Merchant = z
25
27
  .string()
26
28
  .min(1)
@@ -41,6 +43,7 @@ const dollars = (n) => `$${n.toLocaleString('en-US', { maximumFractionDigits: 2
41
43
  export function createEarnrateServer(deps) {
42
44
  const server = new McpServer({ name: MCP_SERVER_NAME, version: deps.version }, { instructions: INSTRUCTIONS });
43
45
  const { catalog } = deps;
46
+ const noWallet = `There's no wallet yet (${deps.walletFile}), which is normal before the user adds a card. Ask the user which credit cards they have, find each one with search_cards, and add it with add_card. best_cards_for and simulate_spending work without a wallet.`;
44
47
  // Read on every call, so edits to the wallet file apply without a restart.
45
48
  async function wallet() {
46
49
  let loaded;
@@ -49,7 +52,7 @@ export function createEarnrateServer(deps) {
49
52
  }
50
53
  catch (e) {
51
54
  if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
52
- return `There's no wallet yet (${deps.walletFile}). Ask the user which credit cards they have, find each one with search_cards, and add it with add_card.`;
55
+ return noWallet;
53
56
  }
54
57
  return `Couldn't read the wallet at ${deps.walletFile}: ${e instanceof Error ? e.message : String(e)}`;
55
58
  }
@@ -58,6 +61,16 @@ export function createEarnrateServer(deps) {
58
61
  ? `The wallet at ${deps.walletFile} has problems:\n${problems.map((p) => `- ${p}`).join('\n')}`
59
62
  : loaded;
60
63
  }
64
+ // No wallet yet is the first-run state, not a failure: read-only tools
65
+ // answer from an empty wallet and say how to start one.
66
+ async function readableWallet() {
67
+ const w = await wallet();
68
+ if (typeof w !== 'string')
69
+ return { wallet: w, start: null };
70
+ return w === noWallet
71
+ ? { wallet: { valuations: {}, memberships: [], cards: [] }, start: w }
72
+ : w;
73
+ }
61
74
  // The caller's category is free and comes before the paid Claude fallback;
62
75
  // neither ever overrides a merchant earnrate already knows.
63
76
  async function resolve(merchant, channel, category) {
@@ -92,9 +105,10 @@ export function createEarnrateServer(deps) {
92
105
  openWorldHint: deps.llm !== null,
93
106
  },
94
107
  }, async ({ merchant, amount, channel, date, category }) => {
95
- const w = await wallet();
96
- if (typeof w === 'string')
97
- return fail(w);
108
+ const read = await readableWallet();
109
+ if (typeof read === 'string')
110
+ return fail(read);
111
+ const w = read.wallet;
98
112
  const purchase = {
99
113
  merchant,
100
114
  amount: amount ?? null,
@@ -104,6 +118,16 @@ export function createEarnrateServer(deps) {
104
118
  };
105
119
  const resolved = await resolve(merchant, purchase.channel, category ?? null);
106
120
  const rec = recommend(catalog, w, purchase, resolved.merchant);
121
+ if (read.start !== null) {
122
+ const output = {
123
+ ...rec,
124
+ warnings: [read.start, ...resolved.notices],
125
+ };
126
+ return {
127
+ content: [{ type: 'text', text: read.start }],
128
+ structuredContent: output,
129
+ };
130
+ }
107
131
  const output = {
108
132
  ...rec,
109
133
  warnings: [...rec.warnings, ...resolved.notices],
@@ -146,10 +170,15 @@ export function createEarnrateServer(deps) {
146
170
  outputSchema: WalletSummarySchema,
147
171
  annotations: { readOnlyHint: true, openWorldHint: false },
148
172
  }, async () => {
149
- const w = await wallet();
150
- if (typeof w === 'string')
151
- return fail(w);
152
- const summary = walletSummary(catalog, w, deps.today());
173
+ const read = await readableWallet();
174
+ if (typeof read === 'string')
175
+ return fail(read);
176
+ const summary = walletSummary(catalog, read.wallet, deps.today());
177
+ if (read.start !== null)
178
+ return {
179
+ content: [{ type: 'text', text: read.start }],
180
+ structuredContent: summary,
181
+ };
153
182
  const lines = summary.cards.map((c) => {
154
183
  const caps = c.caps
155
184
  .filter((cap) => cap.remaining !== null)
@@ -198,10 +227,10 @@ export function createEarnrateServer(deps) {
198
227
  outputSchema: OfferComparisonSchema,
199
228
  annotations: { readOnlyHint: true, openWorldHint: false },
200
229
  }, async (args) => {
201
- const w = await wallet();
202
- if (typeof w === 'string')
203
- return fail(w);
204
- const comparison = compareOffers(catalog, w, {
230
+ const read = await readableWallet();
231
+ if (typeof read === 'string')
232
+ return fail(read);
233
+ const comparison = compareOffers(catalog, read.wallet, {
205
234
  kind: args.kind ?? 'any',
206
235
  noAnnualFee: args.noAnnualFee ?? false,
207
236
  business: args.business ?? false,
@@ -318,10 +347,11 @@ export function createEarnrateServer(deps) {
318
347
  if (unknown.length > 0)
319
348
  return fail(`earnrate doesn't know ${unknown.map((l) => `"${l.merchant.input}"`).join(', ')}. Give a category for those lines instead.`);
320
349
  const options = { ...settings(args), start: deps.today() };
321
- if (args.compareToWallet) {
322
- const w = await wallet();
323
- if (typeof w === 'string')
324
- return fail(w);
350
+ const read = args.compareToWallet ? await readableWallet() : null;
351
+ if (typeof read === 'string')
352
+ return fail(read);
353
+ if (read && read.start === null) {
354
+ const w = read.wallet;
325
355
  const valued = args.valuation
326
356
  ? {
327
357
  ...w,
@@ -347,11 +377,12 @@ export function createEarnrateServer(deps) {
347
377
  };
348
378
  }
349
379
  const result = simulateCatalog(catalog, lines, options);
380
+ const text = formatCatalogSimulation(result, catalog, false);
350
381
  return {
351
382
  content: [
352
383
  {
353
384
  type: 'text',
354
- text: formatCatalogSimulation(result, catalog, false),
385
+ text: read?.start ? `${read.start}\n\n${text}` : text,
355
386
  },
356
387
  ],
357
388
  structuredContent: {
@@ -362,6 +393,27 @@ export function createEarnrateServer(deps) {
362
393
  },
363
394
  };
364
395
  });
396
+ server.registerTool('cheat_sheet', {
397
+ title: 'Cheat sheet',
398
+ description: "The card in the user's wallet to use for each everyday kind of purchase right now: groceries, dining, gas, Amazon, Costco, travel, streaming, drugstores and everything else, with caps, picks and activations as the wallet has them. Use it when the user asks which card to use for what, or wants a quick reference for the quarter. For one specific store, call best_card_for_purchase instead. Pass on the notes: they say what the wallet still needs, like this quarter's picks.",
399
+ inputSchema: z.object({ date: DateInput }),
400
+ outputSchema: CheatSheetSchema,
401
+ annotations: { readOnlyHint: true, openWorldHint: false },
402
+ }, async (args) => {
403
+ const read = await readableWallet();
404
+ if (typeof read === 'string')
405
+ return fail(read);
406
+ const sheet = cheatSheet(catalog, read.wallet, args.date ?? deps.today());
407
+ if (read.start !== null)
408
+ return {
409
+ content: [{ type: 'text', text: read.start }],
410
+ structuredContent: { ...sheet, notes: [read.start] },
411
+ };
412
+ return {
413
+ content: [{ type: 'text', text: formatCheatSheet(sheet) }],
414
+ structuredContent: sheet,
415
+ };
416
+ });
365
417
  server.registerTool('record_purchase', {
366
418
  title: 'Record a purchase',
367
419
  description: "Records a purchase the user has already made, so spending caps and welcome-bonus progress stay accurate for future recommendations. It updates the user's wallet file. Only call it after the purchase has happened, and only once per purchase: calling it twice counts the spend twice. Purchases with no cap and no open welcome bonus are acknowledged and nothing is written.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "earnrate",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Picks the best credit card from your wallet for a merchant, and explains why",
5
5
  "keywords": [
6
6
  "credit cards",
@@ -40,7 +40,9 @@
40
40
  "build": "rm -rf dist && tsc -p tsconfig.build.json",
41
41
  "prepack": "yarn build",
42
42
  "smoke:pack": "node scripts/smoke-pack.ts",
43
- "site:build": "vite build site",
43
+ "mcpb:pack": "node scripts/build-mcpb.ts",
44
+ "smoke:mcpb": "node scripts/smoke-mcpb.ts",
45
+ "site:build": "vite build site && node scripts/build-pages.ts",
44
46
  "site:dev": "vite site",
45
47
  "site:deploy": "yarn site:build && WRANGLER_SEND_METRICS=false wrangler deploy",
46
48
  "test": "vitest run",
@@ -59,6 +61,7 @@
59
61
  "zod": "^4.6.7"
60
62
  },
61
63
  "devDependencies": {
64
+ "@anthropic-ai/mcpb": "^2.1.2",
62
65
  "@eslint/js": "^10.0.1",
63
66
  "@modelcontextprotocol/client": "2.3.1",
64
67
  "@types/node": "^24",