earnrate 0.1.4 → 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 (53) hide show
  1. package/README.md +52 -17
  2. package/data/CHANGELOG.md +8 -0
  3. package/data/VERSION +1 -1
  4. package/data/categories.yaml +22 -7
  5. package/dist/brand.js +2 -3
  6. package/dist/cli/args.js +67 -45
  7. package/dist/cli/cheatsheet.js +36 -35
  8. package/dist/cli/explore.js +47 -163
  9. package/dist/cli/import.js +3 -8
  10. package/dist/cli/offers.js +14 -56
  11. package/dist/cli/run.js +90 -20
  12. package/dist/cli/wallet.js +47 -6
  13. package/dist/data/file-store.js +95 -0
  14. package/dist/data/load.js +10 -2
  15. package/dist/data/store.js +92 -0
  16. package/dist/data/wallet-edit.js +93 -4
  17. package/dist/engine/bonus.js +15 -12
  18. package/dist/engine/cheat-sheet.js +59 -4
  19. package/dist/engine/evaluate.js +34 -10
  20. package/dist/engine/explore.js +16 -28
  21. package/dist/engine/offers.js +8 -21
  22. package/dist/engine/periods.js +9 -0
  23. package/dist/engine/recommend.js +17 -53
  24. package/dist/engine/record.js +34 -4
  25. package/dist/engine/simulate.js +33 -19
  26. package/dist/engine/text.js +53 -0
  27. package/dist/engine/transient.js +26 -0
  28. package/dist/engine/valuation.js +1 -1
  29. package/dist/engine/wallet-change.js +36 -15
  30. package/dist/engine/wallet-summary.js +4 -8
  31. package/dist/format/card-text.js +79 -0
  32. package/dist/format/cheat-sheet.js +19 -0
  33. package/dist/format/explore.js +114 -0
  34. package/dist/format/offers.js +33 -0
  35. package/dist/{cli/format.js → format/recommendation.js} +9 -11
  36. package/dist/format/wallet-yaml.js +45 -0
  37. package/dist/format/wallet.js +38 -0
  38. package/dist/import/analyze.js +17 -19
  39. package/dist/mcp/context.js +92 -0
  40. package/dist/mcp/inputs.js +148 -0
  41. package/dist/mcp/schemas.js +8 -2
  42. package/dist/mcp/serve.js +3 -1
  43. package/dist/mcp/server.js +43 -660
  44. package/dist/mcp/tools/account.js +71 -0
  45. package/dist/mcp/tools/answers.js +329 -0
  46. package/dist/mcp/tools/wallet.js +128 -0
  47. package/dist/merchants/from-env.js +1 -1
  48. package/dist/merchants/resolve.js +3 -3
  49. package/dist/schema/catalog.js +1 -0
  50. package/package.json +8 -3
  51. package/dist/data/wallet-setup.js +0 -60
  52. package/dist/data/wallet-write.js +0 -140
  53. /package/dist/{cli → format}/cards.js +0 -0
@@ -1,12 +1,12 @@
1
1
  import { parseArgs } from 'node:util';
2
2
  import { CLI_NAME } from "../brand.js";
3
- import { walletProblems } from "../data/integrity.js";
4
3
  import { loadCatalog, walletPath } from "../data/load.js";
5
4
  import { ALL_PERSONAL_CARDS, exploreCards, findCategory, } from "../engine/explore.js";
6
5
  import { isValuationProfile, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
7
- import { resolveSpendLines, simulateAdditions, simulateCatalog, } from "../engine/simulate.js";
8
- import { knownMemberships } from "../engine/setup.js";
9
- import { displayPath, readWallet, RUNTIME_ERROR, USAGE_ERROR, } from "./run.js";
6
+ import { resolveSpendLines, simulateAdditions, simulateCatalog, spendLineFor, TYPICAL_MONTHLY_SPEND, TYPICAL_SPEND_NOTE, } from "../engine/simulate.js";
7
+ import { formatAdditions, formatCatalogSimulation, formatExploration, } from "../format/explore.js";
8
+ import { BOTH_CHANNELS, cardList, channelOf, dateProblem, membershipProblem, splitAmount, valuationProblem, } from "./args.js";
9
+ import { cardsToUse, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
10
10
  const FILTER_OPTIONS = ` --cash, --points Cash back cards only, or points and miles cards only.
11
11
  --no-fee Cards with no annual fee.
12
12
  --issuer <id> One issuer's cards, e.g. chase or amex.
@@ -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,12 +33,12 @@ ${FILTER_OPTIONS}
33
33
  --json Print the full result as JSON.
34
34
  -h, --help Show this help.
35
35
  `;
36
- export const SIMULATE_HELP = `Usage: ${CLI_NAME} simulate <what>=<dollars a month>... [options]
36
+ const SIMULATE_HELP = `Usage: ${CLI_NAME} simulate [<what>=<dollars a month>...] [options]
37
37
 
38
38
  Spends this much every month for a year and ranks the cards, and the
39
39
  two-card pairs, that earn the most after annual fees. Each <what> is a
40
40
  category (groceries, dining, gas, travel, streaming, other) or a merchant
41
- (amazon, costco).
41
+ (amazon, costco). With no amounts, it uses typical ones for a U.S. household.
42
42
 
43
43
  Examples:
44
44
  ${CLI_NAME} simulate groceries=600 dining=300 gas=150 other=1000
@@ -47,6 +47,7 @@ Examples:
47
47
  Options:
48
48
  --wallet Start from your wallet and rank the cards that would
49
49
  add the most to it, after their fee.
50
+ --cards <ids> The same, for cards you name instead of your wallet.
50
51
  ${FILTER_OPTIONS}
51
52
  --start <YYYY-MM-DD> First month of the year simulated. Default: today.
52
53
  --all List more results.
@@ -69,15 +70,13 @@ const FILTER_FLAGS = {
69
70
  function settingsFrom(values, catalog) {
70
71
  if (values.cash && values.points)
71
72
  return 'Pick --cash or --points, not both.';
72
- if (values.valuation !== undefined && !isValuationProfile(values.valuation))
73
- return `Unknown valuation "${values.valuation}". Use flat or points-heavy.`;
73
+ const problem = valuationProblem(values.valuation) ??
74
+ membershipProblem(catalog, values.member ?? []);
75
+ if (problem !== null)
76
+ return problem;
74
77
  const issuers = [...new Set(catalog.cards.map((c) => c.issuer))].sort();
75
78
  if (values.issuer !== undefined && !issuers.includes(values.issuer))
76
79
  return `Unknown issuer "${values.issuer}". Use one of: ${issuers.join(', ')}.`;
77
- const known = knownMemberships(catalog);
78
- const unknown = (values.member ?? []).filter((m) => !known.includes(m));
79
- if (unknown.length > 0)
80
- return `Unknown membership "${unknown.join('", "')}". Use one of: ${known.join(', ')}.`;
81
80
  return {
82
81
  filters: {
83
82
  ...ALL_PERSONAL_CARDS,
@@ -93,130 +92,6 @@ function settingsFrom(values, catalog) {
93
92
  : {},
94
93
  };
95
94
  }
96
- const usd = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
97
- const wholeUsd = (n) => usd(Math.round(n));
98
- const pct = (n) => `${String(Number(n.toFixed(2)))}%`;
99
- function feeText(e) {
100
- if (e.annualFee === null)
101
- return 'annual fee not on file';
102
- if (e.annualFee === 0)
103
- return 'no annual fee';
104
- if (e.firstYearFee !== null && e.firstYearFee !== e.annualFee)
105
- return `${usd(e.annualFee)} annual fee (${e.firstYearFee === 0 ? 'waived' : usd(e.firstYearFee)} the first year)`;
106
- return `${usd(e.annualFee)} annual fee`;
107
- }
108
- const TOP_BEST = 10;
109
- export function formatExploration(r, all) {
110
- const m = r.merchant;
111
- const title = m.source === 'caller'
112
- ? m.resolvedName.toLowerCase()
113
- : m.source === 'none'
114
- ? `"${m.input}" (not recognized)`
115
- : m.resolvedName;
116
- if (r.ranked.length === 0)
117
- return `No cards match at ${title}.\n`;
118
- const shown = all ? r.ranked : r.ranked.slice(0, TOP_BEST);
119
- const width = Math.max(...shown.map((e) => e.cardName.length));
120
- const lines = [`Best cards for ${title}:`, ''];
121
- shown.forEach((e, i) => {
122
- const earned = e.earned === null ? '' : ` ${usd(e.earned)}`;
123
- lines.push(`${String(i + 1).padStart(2)}. ${e.cardName.padEnd(width)} ${pct(e.effectiveReturnPct).padStart(5)}${earned} ${feeText(e)}`);
124
- for (const c of e.conditions)
125
- lines.push(` ${c}`);
126
- const upgrade = e.withMembership[0];
127
- if (upgrade)
128
- lines.push(` With ${upgrade.membership}: ${pct(upgrade.effectiveReturnPct)}.`);
129
- for (const w of e.warnings)
130
- lines.push(` ! ${w}`);
131
- });
132
- if (r.ranked.length > shown.length)
133
- lines.push(` + ${String(r.ranked.length - shown.length)} more (--all lists them)`);
134
- for (const w of r.warnings)
135
- lines.push('', `! ${w}`);
136
- lines.push('', ...r.assumptions);
137
- return `${lines.join('\n')}\n`;
138
- }
139
- function picksText(picks) {
140
- const parts = Object.entries(picks).map(([choice, options]) => `${choice}: ${options.join(', ')}`);
141
- return parts.length === 0 ? '' : ` (picks ${parts.join('; ')})`;
142
- }
143
- function cardNamer(catalog) {
144
- return (id) => catalog.cards.find((c) => c.id === id)?.name ?? id;
145
- }
146
- function splitText(sim, name) {
147
- const byCard = new Map();
148
- for (const line of sim.lines) {
149
- const first = line.cards[0];
150
- if (first === undefined)
151
- continue;
152
- byCard.set(first, [...(byCard.get(first) ?? []), line.label]);
153
- }
154
- return [...byCard].map(([id, labels]) => `${labels.join(', ')} → ${name(id)}`);
155
- }
156
- const TOP_SIMULATED = 5;
157
- export function formatCatalogSimulation(r, catalog, all) {
158
- const name = cardNamer(catalog);
159
- const first = r.singles[0];
160
- if (!first)
161
- return 'No cards match.\n';
162
- const top = all ? 20 : TOP_SIMULATED;
163
- const lines = [
164
- `A year of this spending: ${wholeUsd(first.spend)}.`,
165
- '',
166
- 'Best single cards (rewards minus annual fee, per year):',
167
- ];
168
- r.singles.slice(0, top).forEach((s, i) => {
169
- const card = s.cards[0];
170
- if (!card)
171
- return;
172
- lines.push(`${String(i + 1).padStart(2)}. ${name(card.cardId)} ${wholeUsd(s.net)} (${wholeUsd(s.rewards)} rewards, ${wholeUsd(s.annualFees)} fee)${picksText(card.picks)}`);
173
- });
174
- const pairs = r.pairs.slice(0, top);
175
- if (pairs.length > 0) {
176
- lines.push('', 'Best two-card pairs:');
177
- pairs.forEach((s, i) => {
178
- lines.push(`${String(i + 1).padStart(2)}. ${s.cards.map((c) => `${name(c.cardId)}${picksText(c.picks)}`).join(' + ')} ${wholeUsd(s.net)}`);
179
- if (s.alternatives.length > 0)
180
- lines.push(` Same result with ${s.alternatives.map(name).join(' or ')} as the second card.`);
181
- for (const split of splitText(s, name))
182
- lines.push(` ${split}`);
183
- });
184
- const bestPair = r.pairs[0];
185
- if (bestPair && bestPair.net <= first.net)
186
- lines.push('', `One card is enough here: no pair beats ${name(first.cards[0]?.cardId ?? '')}.`);
187
- }
188
- const notes = new Set([
189
- ...r.singles.slice(0, top).flatMap((s) => s.notes),
190
- ...pairs.flatMap((s) => s.notes),
191
- ]);
192
- if (notes.size > 0)
193
- lines.push('', ...notes);
194
- lines.push('', "Assumes each card's best picks, activations done each quarter, and no welcome bonuses.");
195
- return `${lines.join('\n')}\n`;
196
- }
197
- export function formatAdditions(r, catalog, all) {
198
- const name = cardNamer(catalog);
199
- const lines = [
200
- `Your wallet earns about ${wholeUsd(r.current.rewards)} a year on this spending${r.current.annualFees > 0 ? `, with ${wholeUsd(r.current.annualFees)} in annual fees` : ''}.`,
201
- ];
202
- for (const split of splitText(r.current, name))
203
- lines.push(` ${split}`);
204
- const gains = r.additions.filter((a) => a.gain > 0);
205
- if (gains.length === 0) {
206
- lines.push('', 'No card would add more than its annual fee costs.');
207
- }
208
- else {
209
- lines.push('', 'Cards that would add the most (after their annual fee):');
210
- gains.slice(0, all ? 20 : TOP_SIMULATED).forEach((a, i) => {
211
- lines.push(`${String(i + 1).padStart(2)}. ${name(a.cardId)} +${wholeUsd(a.gain)} a year (+${wholeUsd(a.extraRewards)} rewards, ${wholeUsd(a.annualFee)} fee)${picksText(a.picks)}`);
212
- });
213
- }
214
- const notes = new Set(r.current.notes);
215
- if (notes.size > 0)
216
- lines.push('', ...notes);
217
- lines.push('', 'Assumes your picks carry over, rotating categories get activated, and caps start fresh.');
218
- return `${lines.join('\n')}\n`;
219
- }
220
95
  export async function runBest(argv, io) {
221
96
  let parsed;
222
97
  try {
@@ -240,20 +115,23 @@ export async function runBest(argv, io) {
240
115
  io.stdout(BEST_HELP);
241
116
  return 0;
242
117
  }
243
- const words = [...positionals];
244
- const last = words.at(-1);
245
- const amount = words.length > 1 && last !== undefined && /^\$?\d+(\.\d+)?$/.test(last)
246
- ? Number(last.replace('$', ''))
247
- : null;
248
- if (amount !== null)
249
- words.pop();
250
- const text = words.join(' ').trim();
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;
251
124
  if (text === '') {
252
125
  io.stderr(`Say what you're buying, e.g. ${CLI_NAME} best groceries.\n`);
253
126
  return USAGE_ERROR;
254
127
  }
255
128
  if (values.online && values['in-store']) {
256
- 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`);
257
135
  return USAGE_ERROR;
258
136
  }
259
137
  const catalog = await loadCatalog();
@@ -267,11 +145,7 @@ export async function runBest(argv, io) {
267
145
  merchant: category === null ? text : null,
268
146
  category,
269
147
  amount,
270
- channel: values.online
271
- ? 'online'
272
- : values['in-store']
273
- ? 'in_store'
274
- : null,
148
+ channel: channelOf(values),
275
149
  date: values.date ?? io.today,
276
150
  }, settings);
277
151
  io.stdout(values.json
@@ -279,7 +153,7 @@ export async function runBest(argv, io) {
279
153
  : formatExploration(result, values.all ?? false));
280
154
  return 0;
281
155
  }
282
- export function parseSpend(catalog, args) {
156
+ function parseSpend(catalog, args) {
283
157
  const lines = [];
284
158
  for (const arg of args) {
285
159
  const match = /^(.+)=\$?(\d+(?:\.\d+)?)$/.exec(arg);
@@ -306,6 +180,7 @@ export async function runSimulate(argv, io) {
306
180
  options: {
307
181
  ...FILTER_FLAGS,
308
182
  wallet: { type: 'boolean' },
183
+ cards: { type: 'string' },
309
184
  start: { type: 'string' },
310
185
  },
311
186
  });
@@ -325,7 +200,10 @@ export async function runSimulate(argv, io) {
325
200
  io.stderr(`${settings}\n`);
326
201
  return USAGE_ERROR;
327
202
  }
328
- const spend = parseSpend(catalog, positionals);
203
+ const typical = positionals.length === 0;
204
+ const spend = typical
205
+ ? TYPICAL_MONTHLY_SPEND.map((t) => spendLineFor(catalog, t.what, t.monthly))
206
+ : parseSpend(catalog, positionals);
329
207
  if (typeof spend === 'string') {
330
208
  io.stderr(`${spend}\n`);
331
209
  return USAGE_ERROR;
@@ -336,16 +214,22 @@ export async function runSimulate(argv, io) {
336
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`);
337
215
  return USAGE_ERROR;
338
216
  }
217
+ const started = dateProblem(values.start);
218
+ if (started !== null) {
219
+ io.stderr(`${started}\n`);
220
+ return USAGE_ERROR;
221
+ }
339
222
  const options = { ...settings, start: values.start ?? io.today };
340
- if (values.wallet) {
341
- const file = walletPath(io.env);
342
- const wallet = await readWallet(file, io);
343
- if (!wallet)
344
- return RUNTIME_ERROR;
345
- const problems = walletProblems(wallet, catalog);
346
- if (problems.length > 0) {
347
- io.stderr(`Your wallet at ${displayPath(file)} has problems:\n${problems.map((p) => ` - ${p}\n`).join('')}`);
223
+ if (values.wallet || values.cards !== undefined) {
224
+ const wallet = await cardsToUse(catalog, walletPath(io.env), io, {
225
+ cards: cardList(values.cards),
226
+ members: values.member ?? [],
227
+ });
228
+ if (wallet === null)
348
229
  return RUNTIME_ERROR;
230
+ if (wallet === 'none') {
231
+ io.stderr(`No saved wallet yet. Name your cards instead: --cards amex-gold,chase-prime-visa\n`);
232
+ return USAGE_ERROR;
349
233
  }
350
234
  const valued = values.valuation !== undefined && isValuationProfile(values.valuation)
351
235
  ? withValuationProfile(wallet, values.valuation)
@@ -353,12 +237,12 @@ export async function runSimulate(argv, io) {
353
237
  const result = simulateAdditions(catalog, valued, lines, options);
354
238
  io.stdout(values.json
355
239
  ? `${JSON.stringify(result, null, 2)}\n`
356
- : formatAdditions(result, catalog, values.all ?? false));
240
+ : `${typical ? `${TYPICAL_SPEND_NOTE}\n\n` : ''}${formatAdditions(result, catalog, values.all ?? false)}`);
357
241
  return 0;
358
242
  }
359
243
  const result = simulateCatalog(catalog, lines, options);
360
244
  io.stdout(values.json
361
245
  ? `${JSON.stringify(result, null, 2)}\n`
362
- : formatCatalogSimulation(result, catalog, values.all ?? false));
246
+ : `${typical ? `${TYPICAL_SPEND_NOTE}\n\n` : ''}${formatCatalogSimulation(result, catalog, values.all ?? false)}`);
363
247
  return 0;
364
248
  }
@@ -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)
@@ -1,11 +1,12 @@
1
1
  import { parseArgs } from 'node:util';
2
2
  import { CLI_NAME } from "../brand.js";
3
- import { walletProblems } from "../data/integrity.js";
4
3
  import { loadCatalog, walletPath } from "../data/load.js";
5
- import { compareOffers, } from "../engine/offers.js";
4
+ import { compareOffers } from "../engine/offers.js";
6
5
  import { isValuationProfile, withValuationProfile } from "../engine/profiles.js";
7
- import { displayPath, readWallet, RUNTIME_ERROR, USAGE_ERROR, } from "./run.js";
8
- export const OFFERS_HELP = `Usage: ${CLI_NAME} offers [--cash | --points] [--no-fee] [--business] [options]
6
+ import { formatOffers } from "../format/offers.js";
7
+ import { valuationProblem } from "./args.js";
8
+ import { cardsToUse, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
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.
@@ -21,48 +22,6 @@ Options:
21
22
  --json Print the full comparison as JSON.
22
23
  -h, --help Show this help.
23
24
  `;
24
- const TOP = 10;
25
- const usd = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
26
- function reward(o, currencyName) {
27
- if (o.dollars !== null)
28
- return usd(o.dollars);
29
- return `${(o.points ?? 0).toLocaleString('en-US')} ${currencyName}`;
30
- }
31
- function feeText(o) {
32
- if (o.annualFee === null)
33
- return 'annual fee not on file';
34
- if (o.annualFee === 0)
35
- return 'no annual fee';
36
- if (o.firstYearFee !== null && o.firstYearFee !== o.annualFee)
37
- return `${usd(o.annualFee)} annual fee (${o.firstYearFee === 0 ? 'waived' : usd(o.firstYearFee)} the first year)`;
38
- return `${usd(o.annualFee)} annual fee`;
39
- }
40
- export function formatOffers(c, currencyName, all) {
41
- if (c.offers.length === 0) {
42
- return `No published welcome offers match.${c.withoutOffer.length > 0 ? ` ${String(c.withoutOffer.length)} matching card(s) have none on file.` : ''}\n`;
43
- }
44
- const shown = all ? c.offers : c.offers.slice(0, TOP);
45
- const width = Math.max(...shown.map((o) => o.cardName.length));
46
- const lines = [];
47
- shown.forEach((o, i) => {
48
- const weeks = (o.months * 52) / 12;
49
- const pace = o.spend > 0 ? ` (about ${usd(Math.round(o.spend / weeks))} a week)` : '';
50
- const spend = o.spend > 0
51
- ? `after ${usd(o.spend)} in ${String(o.months)} month${o.months === 1 ? '' : 's'}${pace}`
52
- : 'with no spending required';
53
- lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${usd(Math.round(o.firstYearValue))} the first year`, ` ${reward(o, currencyName(o.currency))} ${spend}. ${feeText(o)[0]?.toUpperCase() ?? ''}${feeText(o).slice(1)}.${o.held ? ' In your wallet.' : ''}`);
54
- if (o.validTo)
55
- lines.push(` Offer ends ${o.validTo}.`);
56
- if (o.note)
57
- lines.push(` ${o.note}`);
58
- });
59
- if (c.offers.length > shown.length)
60
- lines.push(` + ${String(c.offers.length - shown.length)} more (--all lists them)`);
61
- if (c.withoutOffer.length > 0)
62
- lines.push('', `${String(c.withoutOffer.length)} matching card(s) have no published offer on file.`);
63
- lines.push('', ...c.notes);
64
- return `${lines.join('\n')}\n`;
65
- }
66
25
  export async function runOffers(argv, io) {
67
26
  let parsed;
68
27
  try {
@@ -94,20 +53,19 @@ export async function runOffers(argv, io) {
94
53
  io.stderr('Pick --cash or --points, not both.\n');
95
54
  return USAGE_ERROR;
96
55
  }
97
- if (values.valuation !== undefined && !isValuationProfile(values.valuation)) {
98
- 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`);
99
59
  return USAGE_ERROR;
100
60
  }
101
- const walletFile = walletPath(io.env);
102
- const loaded = await readWallet(walletFile, io);
103
- if (!loaded)
104
- return RUNTIME_ERROR;
105
61
  const catalog = await loadCatalog();
106
- const problems = walletProblems(loaded, catalog);
107
- if (problems.length > 0) {
108
- io.stderr(`Your wallet at ${displayPath(walletFile)} has problems:\n${problems.map((p) => ` - ${p}\n`).join('')}`);
62
+ const found = await cardsToUse(catalog, walletPath(io.env), io, {
63
+ cards: null,
64
+ members: [],
65
+ });
66
+ if (found === null)
109
67
  return RUNTIME_ERROR;
110
- }
68
+ const loaded = found === 'none' ? { valuations: {}, memberships: [], cards: [] } : found;
111
69
  const wallet = values.valuation && isValuationProfile(values.valuation)
112
70
  ? withValuationProfile(loaded, values.valuation)
113
71
  : loaded;
package/dist/cli/run.js CHANGED
@@ -1,26 +1,32 @@
1
+ import { access } from 'node:fs/promises';
1
2
  import { homedir } from 'node:os';
2
3
  import path from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
4
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";
5
6
  import { walletProblems } from "../data/integrity.js";
6
7
  import { loadCatalog, loadWallet, walletPath } from "../data/load.js";
7
- import { withValuationProfile } from "../engine/profiles.js";
8
+ import { ALL_PERSONAL_CARDS, categoryResolution, exploreCards, findCategory, } from "../engine/explore.js";
9
+ import { VALUATION_PROFILES, withValuationProfile } from "../engine/profiles.js";
8
10
  import { recommend } from "../engine/recommend.js";
11
+ import { walletFromCards } from "../engine/transient.js";
12
+ import { formatExploration } from "../format/explore.js";
13
+ import { formatRecommendation, plain, } from "../format/recommendation.js";
9
14
  import { DEFAULT_LLM_MODEL } from "../merchants/anthropic.js";
10
15
  import { DEFAULT_MAX_CALLS } from "../merchants/fallback.js";
11
16
  import { llmEnabled, openLlmSession } from "../merchants/from-env.js";
12
17
  import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
13
18
  import { packageVersion } from "../version.js";
14
- import { parseCliArgs } from "./args.js";
19
+ import { membershipProblem, parseCliArgs } from "./args.js";
15
20
  import { runCheatSheet } from "./cheatsheet.js";
16
21
  import { runBest, runSimulate } from "./explore.js";
17
22
  import { runImport } from "./import.js";
18
23
  import { runOffers } from "./offers.js";
19
24
  import { runWalletCommand, WALLET_HELP } from "./wallet.js";
20
- import { formatRecommendation, plain } from "./format.js";
21
- export const HELP = `Usage: ${CLI_NAME} <merchant> [amount] [options]
25
+ const HELP = `Usage: ${CLI_NAME} <merchant> [amount] [options]
22
26
 
23
- Picks the card in your wallet that earns the most at a merchant, and says why.
27
+ Picks the card that earns the most at a merchant, and says why: from your
28
+ wallet if you've saved one, from cards you name with --cards, or else from
29
+ every card earnrate knows.
24
30
 
25
31
  Examples:
26
32
  ${CLI_NAME} whole foods 80
@@ -31,7 +37,7 @@ Compare every card, no wallet needed:
31
37
  ${CLI_NAME} best <merchant or category> Rank all cards for one purchase.
32
38
  ${CLI_NAME} simulate groceries=600 dining=300 Best cards and pairs for a year of spending.
33
39
 
34
- Set up your wallet:
40
+ Save your cards (optional):
35
41
  ${CLI_NAME} cards [search words] Find cards and their ids.
36
42
  ${CLI_NAME} add <card-id> [--pick ...] Add a card you hold.
37
43
  ${CLI_NAME} update <card-id> [--pick ...] Change picks, label or activations.
@@ -39,10 +45,10 @@ Set up your wallet:
39
45
  ${CLI_NAME} memberships [names...] Show or set memberships (amazon-prime).
40
46
 
41
47
  Other commands:
42
- ${CLI_NAME} cheatsheet Your best card for each category this quarter.
48
+ ${CLI_NAME} cheatsheet The best card for each category this quarter.
43
49
  ${CLI_NAME} import <statement.csv> --card <id> Replay a statement against your wallet.
44
50
  ${CLI_NAME} offers [--cash | --points] Rank welcome offers on cards you don't have.
45
- ${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.
46
52
  Add --help to any command for its options.
47
53
 
48
54
  Options:
@@ -53,6 +59,9 @@ Options:
53
59
  --json Print the full recommendation as JSON.
54
60
  --date <YYYY-MM-DD> Purchase date. Default: today.
55
61
  --country <code> Where you're paying. Default: US.
62
+ --cards <ids> Answer for these cards instead of your wallet, comma
63
+ separated: --cards amex-gold,chase-prime-visa.
64
+ --member <name> A membership you have, with --cards or no wallet.
56
65
  --category <id> For a merchant earnrate doesn't know: its category,
57
66
  e.g. grocery.supermarket. Ignored for known merchants.
58
67
  --llm, --no-llm Ask Claude to classify merchants earnrate doesn't know.
@@ -75,6 +84,42 @@ export function displayPath(file) {
75
84
  const home = homedir();
76
85
  return file.startsWith(`${home}/`) ? `~${file.slice(home.length)}` : file;
77
86
  }
87
+ // The cards to answer for: those named with --cards, else the wallet file.
88
+ // 'none' means no wallet and no --cards, which answers from every card.
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
+ }
95
+ if (named.cards !== null) {
96
+ const wallet = walletFromCards(catalog, {
97
+ cards: named.cards.map((id) => ({ id })),
98
+ memberships: named.members,
99
+ }, io.today);
100
+ if (typeof wallet === 'string') {
101
+ io.stderr(`${wallet}\n`);
102
+ return null;
103
+ }
104
+ return wallet;
105
+ }
106
+ try {
107
+ await access(file);
108
+ }
109
+ catch {
110
+ return 'none';
111
+ }
112
+ const loaded = await readWallet(file, io);
113
+ if (!loaded)
114
+ return null;
115
+ const problems = walletProblems(loaded, catalog);
116
+ if (problems.length > 0) {
117
+ io.stderr(`Your wallet at ${displayPath(file)} has problems:\n${problems.map((p) => ` - ${p}\n`).join('')}`);
118
+ return null;
119
+ }
120
+ return loaded;
121
+ }
122
+ export const NO_WALLET_HINT = `These are every card earnrate knows. For the best of your own cards, name them with --cards amex-gold,chase-prime-visa, or save them: ${CLI_NAME} add <card-id> (${CLI_NAME} cards finds ids).`;
78
123
  export async function readWallet(file, io) {
79
124
  try {
80
125
  return await loadWallet(file);
@@ -119,19 +164,10 @@ export async function run(argv, io) {
119
164
  }
120
165
  const { options } = args;
121
166
  const llmOn = options.llm ?? llmEnabled(io.env);
122
- const file = walletPath(io.env);
123
- const loaded = await readWallet(file, io);
124
- if (!loaded)
125
- return RUNTIME_ERROR;
126
167
  const catalog = await loadCatalog();
127
- const problems = walletProblems(loaded, catalog);
128
- if (problems.length > 0) {
129
- io.stderr(`Your wallet at ${displayPath(file)} has problems:\n${problems.map((p) => ` - ${p}\n`).join('')}`);
168
+ const loaded = await cardsToUse(catalog, walletPath(io.env), io, options);
169
+ if (loaded === null)
130
170
  return RUNTIME_ERROR;
131
- }
132
- const wallet = options.valuation
133
- ? withValuationProfile(loaded, options.valuation)
134
- : loaded;
135
171
  const purchase = {
136
172
  merchant: options.merchant,
137
173
  amount: options.amount,
@@ -144,7 +180,14 @@ export async function run(argv, io) {
144
180
  io.stderr(`Unknown category "${options.category}". Use one of: ${catalog.categories.map((c) => c.id).join(', ')}.\n`);
145
181
  return USAGE_ERROR;
146
182
  }
147
- let merchant = withCallerCategory(resolveMerchant(catalog, purchase.merchant, purchase.channel), options.category);
183
+ // Plain category words ("restaurants", "groceries") work like stores.
184
+ const resolvedName = resolveMerchant(catalog, purchase.merchant, purchase.channel);
185
+ const named = resolvedName.source === 'none' && options.category === null
186
+ ? findCategory(catalog, purchase.merchant)
187
+ : null;
188
+ let merchant = named === null
189
+ ? withCallerCategory(resolvedName, options.category)
190
+ : categoryResolution(catalog, named, purchase.channel);
148
191
  const notices = [];
149
192
  if (llmOn && merchant.source === 'none') {
150
193
  const opened = await openLlmSession({
@@ -161,6 +204,33 @@ export async function run(argv, io) {
161
204
  merchant = resolved.merchant;
162
205
  notices.push(...resolved.notices);
163
206
  }
207
+ if (loaded === 'none') {
208
+ const explored = exploreCards(catalog, {
209
+ merchant: purchase.merchant,
210
+ category: null,
211
+ amount: purchase.amount,
212
+ channel: purchase.channel,
213
+ date: purchase.date,
214
+ }, {
215
+ filters: ALL_PERSONAL_CARDS,
216
+ memberships: options.members,
217
+ valuations: options.valuation
218
+ ? VALUATION_PROFILES[options.valuation]
219
+ : {},
220
+ }, merchant);
221
+ if (options.json) {
222
+ io.stdout(`${JSON.stringify(explored, null, 2)}\n`);
223
+ }
224
+ else {
225
+ io.stdout(`${formatExploration(explored, options.all)}\n${NO_WALLET_HINT}\n`);
226
+ }
227
+ for (const n of notices)
228
+ io.stderr(`${n}\n`);
229
+ return 0;
230
+ }
231
+ const wallet = options.valuation
232
+ ? withValuationProfile(loaded, options.valuation)
233
+ : loaded;
164
234
  const rec = recommend(catalog, wallet, purchase, merchant);
165
235
  if (options.json) {
166
236
  io.stdout(`${JSON.stringify(rec, null, 2)}\n`);