earnrate 0.2.0 → 0.2.2

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 (64) hide show
  1. package/README.md +23 -20
  2. package/data/cards/amex-blue-business-plus.yaml +1 -0
  3. package/data/cards/amex-blue-cash-everyday.yaml +4 -3
  4. package/data/cards/amex-blue-cash-preferred.yaml +5 -7
  5. package/data/cards/amex-gold.yaml +1 -0
  6. package/data/cards/amex-platinum.yaml +1 -0
  7. package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +6 -6
  8. package/data/cards/bank-of-america-atmos-rewards-summit.yaml +6 -6
  9. package/data/cards/chase-freedom-flex.yaml +5 -1
  10. package/data/cards/chase-southwest-plus.yaml +5 -5
  11. package/data/cards/chase-southwest-premier-business.yaml +8 -5
  12. package/data/cards/chase-southwest-premier.yaml +6 -6
  13. package/data/cards/chase-southwest-priority.yaml +6 -6
  14. package/data/cards/citi-aadvantage-business.yaml +6 -6
  15. package/data/cards/citi-aadvantage-platinum-select.yaml +10 -7
  16. package/data/cards/citi-costco-anywhere-visa-business.yaml +4 -4
  17. package/dist/brand.js +2 -3
  18. package/dist/cli/args.js +64 -51
  19. package/dist/cli/cheatsheet.js +9 -12
  20. package/dist/cli/explore.js +28 -27
  21. package/dist/cli/import.js +3 -8
  22. package/dist/cli/offers.js +7 -6
  23. package/dist/cli/run.js +17 -13
  24. package/dist/cli/wallet.js +7 -6
  25. package/dist/data/file-store.js +54 -15
  26. package/dist/data/load.js +1 -1
  27. package/dist/data/store.js +0 -16
  28. package/dist/data/wallet-edit.js +78 -3
  29. package/dist/engine/bonus.js +15 -12
  30. package/dist/engine/cheat-sheet.js +2 -2
  31. package/dist/engine/evaluate.js +56 -13
  32. package/dist/engine/explore.js +9 -22
  33. package/dist/engine/offers.js +10 -22
  34. package/dist/engine/periods.js +9 -0
  35. package/dist/engine/recommend.js +14 -53
  36. package/dist/engine/record.js +2 -7
  37. package/dist/engine/setup.js +2 -2
  38. package/dist/engine/simulate.js +12 -19
  39. package/dist/engine/text.js +53 -0
  40. package/dist/engine/transient.js +5 -0
  41. package/dist/engine/valuation.js +1 -1
  42. package/dist/engine/wallet-change.js +46 -19
  43. package/dist/engine/wallet-summary.js +4 -8
  44. package/dist/format/card-text.js +79 -0
  45. package/dist/format/cheat-sheet.js +1 -1
  46. package/dist/format/explore.js +3 -13
  47. package/dist/format/offers.js +15 -18
  48. package/dist/{cli/format.js → format/recommendation.js} +2 -6
  49. package/dist/format/wallet.js +57 -0
  50. package/dist/import/analyze.js +17 -19
  51. package/dist/mcp/context.js +92 -0
  52. package/dist/mcp/inputs.js +155 -0
  53. package/dist/mcp/schemas.js +2 -1
  54. package/dist/mcp/server.js +15 -819
  55. package/dist/mcp/tools/account.js +78 -0
  56. package/dist/mcp/tools/answers.js +338 -0
  57. package/dist/mcp/tools/wallet.js +128 -0
  58. package/dist/merchants/from-env.js +1 -1
  59. package/dist/merchants/resolve.js +3 -3
  60. package/dist/schema/card.js +4 -0
  61. package/package.json +3 -2
  62. package/dist/data/wallet-setup.js +0 -60
  63. package/dist/data/wallet-write.js +0 -140
  64. /package/dist/{cli → format}/cards.js +0 -0
package/dist/cli/args.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { parseArgs } from 'node:util';
2
2
  import { isValuationProfile, } from "../engine/profiles.js";
3
+ import { knownMemberships } from "../engine/setup.js";
4
+ // Shared by every subcommand, so each flag means the same thing everywhere.
5
+ // A string result is the error to show.
3
6
  const LOOKS_LIKE_AMOUNT = /^\$?[\d,]*\.?\d+$/;
4
7
  const AMOUNT = /^\$?(\d{1,3}(,\d{3})+|\d+)(\.\d{1,2})?$/;
5
8
  function parseAmount(text) {
@@ -9,11 +12,54 @@ function parseAmount(text) {
9
12
  const amount = Number(text.replace(/[$,]/g, ''));
10
13
  return amount > 0 ? amount : 'The amount must be more than $0.';
11
14
  }
12
- function isValidDate(text) {
13
- if (!/^\d{4}-\d{2}-\d{2}$/.test(text))
14
- return false;
15
+ // A trailing number is the amount only when something comes before it, so
16
+ // a merchant named "76" still works on its own.
17
+ export function splitAmount(positionals) {
18
+ const words = [...positionals];
19
+ const last = words.at(-1);
20
+ let amount = null;
21
+ if (words.length > 1 && last !== undefined && LOOKS_LIKE_AMOUNT.test(last)) {
22
+ const result = parseAmount(last);
23
+ if (typeof result === 'string')
24
+ return result;
25
+ amount = result;
26
+ words.pop();
27
+ }
28
+ return { text: words.join(' ').trim(), amount };
29
+ }
30
+ export function dateProblem(text) {
31
+ if (text === undefined)
32
+ return null;
15
33
  const date = new Date(`${text}T00:00:00Z`);
16
- return !Number.isNaN(date.getTime()) && date.toISOString().startsWith(text);
34
+ return /^\d{4}-\d{2}-\d{2}$/.test(text) &&
35
+ !Number.isNaN(date.getTime()) &&
36
+ date.toISOString().startsWith(text)
37
+ ? null
38
+ : `"${text}" isn't a date. Use YYYY-MM-DD.`;
39
+ }
40
+ export function valuationProblem(text) {
41
+ return text === undefined || isValuationProfile(text)
42
+ ? null
43
+ : `Unknown valuation "${text}". Use flat or points-heavy.`;
44
+ }
45
+ export function membershipProblem(catalog, members) {
46
+ const known = knownMemberships(catalog);
47
+ const unknown = members.filter((m) => !known.includes(m));
48
+ return unknown.length === 0
49
+ ? null
50
+ : `Unknown membership "${unknown.join('", "')}". Use one of: ${known.join(', ')}.`;
51
+ }
52
+ export const BOTH_CHANNELS = 'Pick --online or --in-store, not both.';
53
+ export function channelOf(values) {
54
+ return values.online ? 'online' : values['in-store'] ? 'in_store' : null;
55
+ }
56
+ export function cardList(text) {
57
+ return text === undefined
58
+ ? null
59
+ : text
60
+ .split(',')
61
+ .map((c) => c.trim())
62
+ .filter((c) => c !== '');
17
63
  }
18
64
  export function parseCliArgs(argv) {
19
65
  let parsed;
@@ -47,49 +93,21 @@ export function parseCliArgs(argv) {
47
93
  const { values, positionals } = parsed;
48
94
  if (values.help)
49
95
  return { kind: 'help' };
50
- // A trailing number is the amount only when something comes before it, so
51
- // a merchant named "76" still works on its own.
52
- const words = [...positionals];
53
- let amount = null;
54
- const last = words.at(-1);
55
- if (words.length > 1 && last !== undefined && LOOKS_LIKE_AMOUNT.test(last)) {
56
- const result = parseAmount(last);
57
- if (typeof result === 'string')
58
- return { kind: 'error', message: result };
59
- amount = result;
60
- words.pop();
61
- }
62
- const merchant = words.join(' ').trim();
96
+ const split = splitAmount(positionals);
97
+ if (typeof split === 'string')
98
+ return { kind: 'error', message: split };
99
+ const { text: merchant, amount } = split;
63
100
  if (merchant === '') {
64
101
  return { kind: 'error', message: 'Name a merchant, like "whole foods".' };
65
102
  }
66
- if (values.online && values['in-store']) {
67
- return {
68
- kind: 'error',
69
- message: 'Pick one of --online and --in-store.',
70
- };
71
- }
72
- const channel = values.online
73
- ? 'online'
74
- : values['in-store']
75
- ? 'in_store'
76
- : null;
77
- let valuation = null;
78
- if (values.valuation !== undefined) {
79
- if (!isValuationProfile(values.valuation)) {
80
- return {
81
- kind: 'error',
82
- message: `Unknown valuation "${values.valuation}". Use flat or points-heavy.`,
83
- };
84
- }
85
- valuation = values.valuation;
86
- }
87
- if (values.date !== undefined && !isValidDate(values.date)) {
88
- return {
89
- kind: 'error',
90
- message: `"${values.date}" isn't a date. Use YYYY-MM-DD.`,
91
- };
92
- }
103
+ if (values.online && values['in-store'])
104
+ return { kind: 'error', message: BOTH_CHANNELS };
105
+ const problem = valuationProblem(values.valuation) ?? dateProblem(values.date);
106
+ if (problem !== null)
107
+ return { kind: 'error', message: problem };
108
+ const valuation = values.valuation !== undefined && isValuationProfile(values.valuation)
109
+ ? values.valuation
110
+ : null;
93
111
  const country = values.country ?? 'US';
94
112
  if (!/^[A-Za-z]{2}$/.test(country)) {
95
113
  return {
@@ -102,7 +120,7 @@ export function parseCliArgs(argv) {
102
120
  options: {
103
121
  merchant,
104
122
  amount,
105
- channel,
123
+ channel: channelOf(values),
106
124
  valuation,
107
125
  date: values.date ?? null,
108
126
  country: country.toUpperCase(),
@@ -110,12 +128,7 @@ export function parseCliArgs(argv) {
110
128
  all: values.all ?? false,
111
129
  category: values.category ?? null,
112
130
  llm: values.llm ?? null,
113
- cards: values.cards === undefined
114
- ? null
115
- : values.cards
116
- .split(',')
117
- .map((c) => c.trim())
118
- .filter((c) => c !== ''),
131
+ cards: cardList(values.cards),
119
132
  members: values.member ?? [],
120
133
  },
121
134
  };
@@ -2,11 +2,12 @@ import { parseArgs } from 'node:util';
2
2
  import { CLI_NAME } from "../brand.js";
3
3
  import { loadCatalog, walletPath } from "../data/load.js";
4
4
  import { catalogCheatSheet, cheatSheet } from "../engine/cheat-sheet.js";
5
- import { formatCheatSheet } from "../format/cheat-sheet.js";
6
5
  import { ALL_PERSONAL_CARDS } from "../engine/explore.js";
7
6
  import { isValuationProfile, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
7
+ import { formatCheatSheet } from "../format/cheat-sheet.js";
8
+ import { cardList, dateProblem, valuationProblem } from "./args.js";
8
9
  import { cardsToUse, NO_WALLET_HINT, RUNTIME_ERROR, USAGE_ERROR, } from "./run.js";
9
- export const CHEATSHEET_HELP = `Usage: ${CLI_NAME} cheatsheet [options]
10
+ const CHEATSHEET_HELP = `Usage: ${CLI_NAME} cheatsheet [options]
10
11
 
11
12
  The card to use for each everyday kind of purchase this quarter: from your
12
13
  wallet (with your caps, picks and activations as they are now), from cards you
@@ -44,21 +45,18 @@ export async function runCheatSheet(argv, io) {
44
45
  io.stdout(CHEATSHEET_HELP);
45
46
  return 0;
46
47
  }
47
- if (values.valuation !== undefined && !isValuationProfile(values.valuation)) {
48
- io.stderr(`Unknown valuation "${values.valuation}". Use flat or points-heavy.\n`);
49
- return USAGE_ERROR;
50
- }
51
- if (values.date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(values.date)) {
52
- io.stderr(`"${values.date}" isn't a date. Use YYYY-MM-DD.\n`);
48
+ const problem = valuationProblem(values.valuation) ?? dateProblem(values.date);
49
+ if (problem !== null) {
50
+ io.stderr(`${problem}\n`);
53
51
  return USAGE_ERROR;
54
52
  }
55
- const loaded = await cardsToUse(walletPath(io.env), io, {
56
- cards: values.cards === undefined ? null : values.cards.split(','),
53
+ const catalog = await loadCatalog();
54
+ const loaded = await cardsToUse(catalog, walletPath(io.env), io, {
55
+ cards: cardList(values.cards),
57
56
  members: values.member ?? [],
58
57
  });
59
58
  if (loaded === null)
60
59
  return RUNTIME_ERROR;
61
- const catalog = await loadCatalog();
62
60
  const date = values.date ?? io.today;
63
61
  if (loaded === 'none') {
64
62
  const sheet = catalogCheatSheet(catalog, date, {
@@ -82,4 +80,3 @@ export async function runCheatSheet(argv, io) {
82
80
  : formatCheatSheet(sheet));
83
81
  return 0;
84
82
  }
85
- export { formatCheatSheet };
@@ -4,8 +4,8 @@ import { loadCatalog, walletPath } from "../data/load.js";
4
4
  import { ALL_PERSONAL_CARDS, exploreCards, findCategory, } from "../engine/explore.js";
5
5
  import { isValuationProfile, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
6
6
  import { resolveSpendLines, simulateAdditions, simulateCatalog, spendLineFor, TYPICAL_MONTHLY_SPEND, TYPICAL_SPEND_NOTE, } from "../engine/simulate.js";
7
- import { knownMemberships } from "../engine/setup.js";
8
7
  import { formatAdditions, formatCatalogSimulation, formatExploration, } from "../format/explore.js";
8
+ import { BOTH_CHANNELS, cardList, channelOf, dateProblem, membershipProblem, splitAmount, valuationProblem, } from "./args.js";
9
9
  import { cardsToUse, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
10
10
  const FILTER_OPTIONS = ` --cash, --points Cash back cards only, or points and miles cards only.
11
11
  --no-fee Cards with no annual fee.
@@ -15,7 +15,7 @@ const FILTER_OPTIONS = ` --cash, --points Cash back cards only, or point
15
15
  --member <name> A membership you have, e.g. amazon-prime. Repeatable.
16
16
  --valuation <profile> flat (points at 1¢, the default) or points-heavy
17
17
  (MR and UR at 1.7¢).`;
18
- export const BEST_HELP = `Usage: ${CLI_NAME} best <merchant or category> [amount] [options]
18
+ const BEST_HELP = `Usage: ${CLI_NAME} best <merchant or category> [amount] [options]
19
19
 
20
20
  Ranks every card in earnrate's catalog for one purchase, not just yours. Each
21
21
  card is set up at its best: the right category pick, activated, cap unused.
@@ -33,7 +33,7 @@ ${FILTER_OPTIONS}
33
33
  --json Print the full result as JSON.
34
34
  -h, --help Show this help.
35
35
  `;
36
- export const SIMULATE_HELP = `Usage: ${CLI_NAME} simulate [<what>=<dollars a month>...] [options]
36
+ const SIMULATE_HELP = `Usage: ${CLI_NAME} simulate [<what>=<dollars a month>...] [options]
37
37
 
38
38
  Spends this much every month for a year and ranks the cards, and the
39
39
  two-card pairs, that earn the most after annual fees. Each <what> is a
@@ -70,15 +70,13 @@ const FILTER_FLAGS = {
70
70
  function settingsFrom(values, catalog) {
71
71
  if (values.cash && values.points)
72
72
  return 'Pick --cash or --points, not both.';
73
- if (values.valuation !== undefined && !isValuationProfile(values.valuation))
74
- return `Unknown valuation "${values.valuation}". Use flat or points-heavy.`;
73
+ const problem = valuationProblem(values.valuation) ??
74
+ membershipProblem(catalog, values.member ?? []);
75
+ if (problem !== null)
76
+ return problem;
75
77
  const issuers = [...new Set(catalog.cards.map((c) => c.issuer))].sort();
76
78
  if (values.issuer !== undefined && !issuers.includes(values.issuer))
77
79
  return `Unknown issuer "${values.issuer}". Use one of: ${issuers.join(', ')}.`;
78
- const known = knownMemberships(catalog);
79
- const unknown = (values.member ?? []).filter((m) => !known.includes(m));
80
- if (unknown.length > 0)
81
- return `Unknown membership "${unknown.join('", "')}". Use one of: ${known.join(', ')}.`;
82
80
  return {
83
81
  filters: {
84
82
  ...ALL_PERSONAL_CARDS,
@@ -117,20 +115,23 @@ export async function runBest(argv, io) {
117
115
  io.stdout(BEST_HELP);
118
116
  return 0;
119
117
  }
120
- const words = [...positionals];
121
- const last = words.at(-1);
122
- const amount = words.length > 1 && last !== undefined && /^\$?\d+(\.\d+)?$/.test(last)
123
- ? Number(last.replace('$', ''))
124
- : null;
125
- if (amount !== null)
126
- words.pop();
127
- const text = words.join(' ').trim();
118
+ const split = splitAmount(positionals);
119
+ if (typeof split === 'string') {
120
+ io.stderr(`${split}\n`);
121
+ return USAGE_ERROR;
122
+ }
123
+ const { text, amount } = split;
128
124
  if (text === '') {
129
125
  io.stderr(`Say what you're buying, e.g. ${CLI_NAME} best groceries.\n`);
130
126
  return USAGE_ERROR;
131
127
  }
132
128
  if (values.online && values['in-store']) {
133
- io.stderr('Pick --online or --in-store, not both.\n');
129
+ io.stderr(`${BOTH_CHANNELS}\n`);
130
+ return USAGE_ERROR;
131
+ }
132
+ const dated = dateProblem(values.date);
133
+ if (dated !== null) {
134
+ io.stderr(`${dated}\n`);
134
135
  return USAGE_ERROR;
135
136
  }
136
137
  const catalog = await loadCatalog();
@@ -144,11 +145,7 @@ export async function runBest(argv, io) {
144
145
  merchant: category === null ? text : null,
145
146
  category,
146
147
  amount,
147
- channel: values.online
148
- ? 'online'
149
- : values['in-store']
150
- ? 'in_store'
151
- : null,
148
+ channel: channelOf(values),
152
149
  date: values.date ?? io.today,
153
150
  }, settings);
154
151
  io.stdout(values.json
@@ -156,7 +153,7 @@ export async function runBest(argv, io) {
156
153
  : formatExploration(result, values.all ?? false));
157
154
  return 0;
158
155
  }
159
- export function parseSpend(catalog, args) {
156
+ function parseSpend(catalog, args) {
160
157
  const lines = [];
161
158
  for (const arg of args) {
162
159
  const match = /^(.+)=\$?(\d+(?:\.\d+)?)$/.exec(arg);
@@ -217,10 +214,15 @@ export async function runSimulate(argv, io) {
217
214
  io.stderr(`Not a category or a merchant earnrate knows: ${unknown.map((l) => `"${l.merchant.input}"`).join(', ')}. Categories include groceries, dining, gas, travel, streaming and other.\n`);
218
215
  return USAGE_ERROR;
219
216
  }
217
+ const started = dateProblem(values.start);
218
+ if (started !== null) {
219
+ io.stderr(`${started}\n`);
220
+ return USAGE_ERROR;
221
+ }
220
222
  const options = { ...settings, start: values.start ?? io.today };
221
223
  if (values.wallet || values.cards !== undefined) {
222
- const wallet = await cardsToUse(walletPath(io.env), io, {
223
- cards: values.cards === undefined ? null : values.cards.split(','),
224
+ const wallet = await cardsToUse(catalog, walletPath(io.env), io, {
225
+ cards: cardList(values.cards),
224
226
  members: values.member ?? [],
225
227
  });
226
228
  if (wallet === null)
@@ -244,4 +246,3 @@ export async function runSimulate(argv, io) {
244
246
  : `${typical ? `${TYPICAL_SPEND_NOTE}\n\n` : ''}${formatCatalogSimulation(result, catalog, values.all ?? false)}`);
245
247
  return 0;
246
248
  }
247
- export { formatAdditions, formatCatalogSimulation, formatExploration };
@@ -6,13 +6,13 @@ import { z } from 'zod';
6
6
  import { CLI_NAME } from "../brand.js";
7
7
  import { walletProblems } from "../data/integrity.js";
8
8
  import { loadCatalog, walletPath } from "../data/load.js";
9
- import { writeBonusSpent, writeCapBooking } from "../data/wallet-write.js";
9
+ import { writeProgress } from "../data/wallet-edit.js";
10
10
  import { isValuationProfile, withValuationProfile } from "../engine/profiles.js";
11
11
  import { cardValuation } from "../engine/valuation.js";
12
12
  import { analyzeStatement, } from "../import/analyze.js";
13
13
  import { parseStatement } from "../import/statement.js";
14
14
  import { displayPath, readWallet, RUNTIME_ERROR, USAGE_ERROR, } from "./run.js";
15
- export const IMPORT_HELP = `Usage: ${CLI_NAME} import <statement.csv> --card <card-id> [--apply]
15
+ const IMPORT_HELP = `Usage: ${CLI_NAME} import <statement.csv> --card <card-id> [--apply]
16
16
  ${CLI_NAME} import <statement.csv>:<card-id> [<statement.csv>:<card-id> ...]
17
17
 
18
18
  Reads card statements (Amex and Chase activity CSVs) and reports:
@@ -241,12 +241,7 @@ export async function runImport(argv, io) {
241
241
  : `Nothing was written. Run again with --apply to record ${toRecord.join(' and ')}.\n`);
242
242
  return 0;
243
243
  }
244
- for (const { booking } of analysis.bookings) {
245
- await writeCapBooking(walletFile, booking);
246
- }
247
- for (const b of analysis.bonuses) {
248
- await writeBonusSpent(walletFile, b.cardId, b.after);
249
- }
244
+ await writeProgress(walletFile, analysis.bookings.map((e) => e.booking), analysis.bonuses);
250
245
  for (const entry of analysis.bookings)
251
246
  recorded.add(fingerprint(entry));
252
247
  for (const entry of bonusEntries)
@@ -4,8 +4,9 @@ import { loadCatalog, walletPath } from "../data/load.js";
4
4
  import { compareOffers } from "../engine/offers.js";
5
5
  import { isValuationProfile, withValuationProfile } from "../engine/profiles.js";
6
6
  import { formatOffers } from "../format/offers.js";
7
+ import { valuationProblem } from "./args.js";
7
8
  import { cardsToUse, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
8
- export const OFFERS_HELP = `Usage: ${CLI_NAME} offers [--cash | --points] [--no-fee] [--business] [options]
9
+ const OFFERS_HELP = `Usage: ${CLI_NAME} offers [--cash | --points] [--no-fee] [--business] [options]
9
10
 
10
11
  Ranks the welcome offers on cards you don't have by first-year value: the
11
12
  bonus at your point values minus the first year's annual fee.
@@ -52,17 +53,18 @@ export async function runOffers(argv, io) {
52
53
  io.stderr('Pick --cash or --points, not both.\n');
53
54
  return USAGE_ERROR;
54
55
  }
55
- if (values.valuation !== undefined && !isValuationProfile(values.valuation)) {
56
- io.stderr(`Unknown valuation "${values.valuation}". Use flat or points-heavy.\n`);
56
+ const problem = valuationProblem(values.valuation);
57
+ if (problem !== null) {
58
+ io.stderr(`${problem}\n`);
57
59
  return USAGE_ERROR;
58
60
  }
59
- const found = await cardsToUse(walletPath(io.env), io, {
61
+ const catalog = await loadCatalog();
62
+ const found = await cardsToUse(catalog, walletPath(io.env), io, {
60
63
  cards: null,
61
64
  members: [],
62
65
  });
63
66
  if (found === null)
64
67
  return RUNTIME_ERROR;
65
- const catalog = await loadCatalog();
66
68
  const loaded = found === 'none' ? { valuations: {}, memberships: [], cards: [] } : found;
67
69
  const wallet = values.valuation && isValuationProfile(values.valuation)
68
70
  ? withValuationProfile(loaded, values.valuation)
@@ -81,4 +83,3 @@ export async function runOffers(argv, io) {
81
83
  io.stdout(formatOffers(comparison, currencyName, values.all ?? false));
82
84
  return 0;
83
85
  }
84
- export { formatOffers };
package/dist/cli/run.js CHANGED
@@ -5,23 +5,24 @@ import { fileURLToPath } from 'node:url';
5
5
  import { CLI_NAME, CONFIG_DIR, LLM_ENV_VAR, LLM_MAX_CALLS_ENV_VAR, LLM_MODEL_ENV_VAR, MERCHANT_CACHE_ENV_VAR, WALLET_ENV_VAR, } from "../brand.js";
6
6
  import { walletProblems } from "../data/integrity.js";
7
7
  import { loadCatalog, loadWallet, walletPath } from "../data/load.js";
8
- import { VALUATION_PROFILES, withValuationProfile } from "../engine/profiles.js";
9
8
  import { ALL_PERSONAL_CARDS, categoryResolution, exploreCards, findCategory, } from "../engine/explore.js";
9
+ import { VALUATION_PROFILES, withValuationProfile } from "../engine/profiles.js";
10
10
  import { recommend } from "../engine/recommend.js";
11
11
  import { walletFromCards } from "../engine/transient.js";
12
+ import { formatExploration } from "../format/explore.js";
13
+ import { formatRecommendation, plain, } from "../format/recommendation.js";
12
14
  import { DEFAULT_LLM_MODEL } from "../merchants/anthropic.js";
13
15
  import { DEFAULT_MAX_CALLS } from "../merchants/fallback.js";
14
16
  import { llmEnabled, openLlmSession } from "../merchants/from-env.js";
15
17
  import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
16
18
  import { packageVersion } from "../version.js";
17
- import { parseCliArgs } from "./args.js";
19
+ import { membershipProblem, parseCliArgs } from "./args.js";
18
20
  import { runCheatSheet } from "./cheatsheet.js";
19
- import { formatExploration, runBest, runSimulate } from "./explore.js";
21
+ import { runBest, runSimulate } from "./explore.js";
20
22
  import { runImport } from "./import.js";
21
23
  import { runOffers } from "./offers.js";
22
24
  import { runWalletCommand, WALLET_HELP } from "./wallet.js";
23
- import { formatRecommendation, plain } from "./format.js";
24
- export const HELP = `Usage: ${CLI_NAME} <merchant> [amount] [options]
25
+ const HELP = `Usage: ${CLI_NAME} <merchant> [amount] [options]
25
26
 
26
27
  Picks the card that earns the most at a merchant, and says why: from your
27
28
  wallet if you've saved one, from cards you name with --cards, or else from
@@ -36,7 +37,7 @@ Compare every card, no wallet needed:
36
37
  ${CLI_NAME} best <merchant or category> Rank all cards for one purchase.
37
38
  ${CLI_NAME} simulate groceries=600 dining=300 Best cards and pairs for a year of spending.
38
39
 
39
- Set up your wallet:
40
+ Save your cards (optional):
40
41
  ${CLI_NAME} cards [search words] Find cards and their ids.
41
42
  ${CLI_NAME} add <card-id> [--pick ...] Add a card you hold.
42
43
  ${CLI_NAME} update <card-id> [--pick ...] Change picks, label or activations.
@@ -44,10 +45,10 @@ Set up your wallet:
44
45
  ${CLI_NAME} memberships [names...] Show or set memberships (amazon-prime).
45
46
 
46
47
  Other commands:
47
- ${CLI_NAME} cheatsheet Your best card for each category this quarter.
48
+ ${CLI_NAME} cheatsheet The best card for each category this quarter.
48
49
  ${CLI_NAME} import <statement.csv> --card <id> Replay a statement against your wallet.
49
50
  ${CLI_NAME} offers [--cash | --points] Rank welcome offers on cards you don't have.
50
- ${CLI_NAME} mcp Run the MCP server over stdio, for AI agents.
51
+ ${CLI_NAME} mcp Run the MCP server over stdio, for AI assistants.
51
52
  Add --help to any command for its options.
52
53
 
53
54
  Options:
@@ -85,8 +86,12 @@ export function displayPath(file) {
85
86
  }
86
87
  // The cards to answer for: those named with --cards, else the wallet file.
87
88
  // 'none' means no wallet and no --cards, which answers from every card.
88
- export async function cardsToUse(file, io, named) {
89
- const catalog = await loadCatalog();
89
+ export async function cardsToUse(catalog, file, io, named) {
90
+ const problem = membershipProblem(catalog, named.members);
91
+ if (problem !== null) {
92
+ io.stderr(`${problem}\n`);
93
+ return null;
94
+ }
90
95
  if (named.cards !== null) {
91
96
  const wallet = walletFromCards(catalog, {
92
97
  cards: named.cards.map((id) => ({ id })),
@@ -159,11 +164,10 @@ export async function run(argv, io) {
159
164
  }
160
165
  const { options } = args;
161
166
  const llmOn = options.llm ?? llmEnabled(io.env);
162
- const file = walletPath(io.env);
163
- const loaded = await cardsToUse(file, io, options);
167
+ const catalog = await loadCatalog();
168
+ const loaded = await cardsToUse(catalog, walletPath(io.env), io, options);
164
169
  if (loaded === null)
165
170
  return RUNTIME_ERROR;
166
- const catalog = await loadCatalog();
167
171
  const purchase = {
168
172
  merchant: options.merchant,
169
173
  amount: options.amount,
@@ -1,9 +1,9 @@
1
1
  import { parseArgs } from 'node:util';
2
2
  import { CLI_NAME } from "../brand.js";
3
+ import { FileWalletStore } from "../data/file-store.js";
3
4
  import { loadCatalog, loadWallet, walletPath } from "../data/load.js";
4
- import { addCard, removeCard, setMemberships, updateCard, } from "../data/wallet-setup.js";
5
5
  import { knownMemberships, searchCards } from "../engine/setup.js";
6
- import { formatCardInfo, formatNotes } from "./cards.js";
6
+ import { formatCardInfo, formatNotes } from "../format/cards.js";
7
7
  import { displayPath, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
8
8
  const CARD_OPTIONS = `Options:
9
9
  --label <text> How the card appears at checkout, e.g. "Amex Gold".
@@ -148,6 +148,7 @@ export async function runWalletCommand(command, argv, io) {
148
148
  const catalog = await loadCatalog();
149
149
  const file = walletPath(io.env);
150
150
  const where = displayPath(file);
151
+ const store = new FileWalletStore(catalog, file);
151
152
  if (command === 'cards') {
152
153
  const query = positionals.join(' ');
153
154
  const result = searchCards(catalog, query, await heldWallet(io), io.today);
@@ -169,7 +170,7 @@ export async function runWalletCommand(command, argv, io) {
169
170
  return usage('Give memberships or --none, not both.');
170
171
  }
171
172
  const list = values.none ? [] : positionals;
172
- return report(await setMemberships(file, catalog, list), list.length > 0
173
+ return report(await store.setMemberships(list), list.length > 0
173
174
  ? `Memberships set to ${list.join(', ')} in ${where}.`
174
175
  : `Memberships cleared in ${where}.`, null, io);
175
176
  }
@@ -179,7 +180,7 @@ export async function runWalletCommand(command, argv, io) {
179
180
  const id = positionals[0] ?? '';
180
181
  const name = catalog.cards.find((c) => c.id === id)?.name ?? id;
181
182
  if (command === 'remove') {
182
- return report(await removeCard(file, catalog, id), `Removed ${name} from ${where}.`, null, io);
183
+ return report(await store.removeCard(id), `Removed ${name} from ${where}.`, null, io);
183
184
  }
184
185
  const input = { id };
185
186
  if (values.label !== undefined)
@@ -227,8 +228,8 @@ export async function runWalletCommand(command, argv, io) {
227
228
  return usage('Give --bonus-opened with the other --bonus options.');
228
229
  }
229
230
  if (command === 'add') {
230
- const result = await addCard(file, catalog, input, io.today);
231
+ const result = await store.addCard(input, io.today);
231
232
  return report(result, `Added ${name} to ${where}${result.ok && result.created ? ' (a new wallet)' : ''}.`, id, io);
232
233
  }
233
- return report(await updateCard(file, catalog, input, io.today), `Updated ${name} in ${where}.`, id, io);
234
+ return report(await store.updateCard(input, io.today), `Updated ${name} in ${where}.`, id, io);
234
235
  }
@@ -1,9 +1,22 @@
1
1
  import { bookPurchase } from "../engine/record.js";
2
+ import { addToWallet, removeFromWallet, updateInWallet, withMemberships, } from "../engine/wallet-change.js";
2
3
  import { loadWallet } from "./load.js";
3
- import { addCard, removeCard, setMemberships, updateCard, } from "./wallet-setup.js";
4
- import { withWalletLock, writeBonusSpent, writeCapBooking, } from "./wallet-write.js";
5
- // The wallet as a YAML file (the CLI, the local MCP server, the .mcpb and the
6
- // local plugin). Edits splice lines so the user's comments survive.
4
+ import { addCardText, commitWalletText, readWalletText, removeCardText, setMembershipsText, updateCardText, writeProgress, } from "./wallet-edit.js";
5
+ const failed = (error) => ({ ok: false, error });
6
+ const errorText = (e) => (e instanceof Error ? e.message : String(e));
7
+ const queues = new Map();
8
+ // Runs `task` after every earlier task for the same file has settled, so a
9
+ // read-modify-write of the wallet never interleaves with another.
10
+ function withWalletLock(file, task) {
11
+ const previous = queues.get(file) ?? Promise.resolve();
12
+ const run = previous.then(task, task);
13
+ queues.set(file, run.catch(() => undefined));
14
+ return run;
15
+ }
16
+ // The wallet as a YAML file (the CLI, the local MCP server and the .mcpb).
17
+ // Each change is worked out on the parsed wallet, then spliced into the text,
18
+ // which must re-parse to exactly that wallet before it's written, so the
19
+ // user's comments survive.
7
20
  export class FileWalletStore {
8
21
  where;
9
22
  catalog;
@@ -21,34 +34,60 @@ export class FileWalletStore {
21
34
  throw e;
22
35
  }
23
36
  }
37
+ change(plan, edit) {
38
+ return withWalletLock(this.where, async () => {
39
+ try {
40
+ const { text, wallet } = await readWalletText(this.where);
41
+ const change = plan(wallet);
42
+ if (typeof change === 'string')
43
+ return failed(change);
44
+ if (!change.ok)
45
+ return change;
46
+ await commitWalletText(this.where, edit(text, change), change.wallet);
47
+ return {
48
+ ok: true,
49
+ wallet: change.wallet,
50
+ created: text === null,
51
+ notes: change.notes,
52
+ };
53
+ }
54
+ catch (e) {
55
+ return failed(errorText(e));
56
+ }
57
+ });
58
+ }
59
+ // Changes other than adding a card need a wallet file to change.
60
+ existing(apply) {
61
+ return (wallet) => wallet === null
62
+ ? `No wallet at ${this.where} yet. Add a card first to create it.`
63
+ : apply(wallet);
64
+ }
24
65
  addCard(input, today) {
25
- return withWalletLock(this.where, () => addCard(this.where, this.catalog, input, today));
66
+ return this.change((wallet) => addToWallet(this.catalog, wallet, input, today), (text, change) => addCardText(text, { id: input.id, ...change.fields }));
26
67
  }
27
68
  updateCard(input, today) {
28
- return withWalletLock(this.where, () => updateCard(this.where, this.catalog, input, today));
69
+ return this.change(this.existing((w) => updateInWallet(this.catalog, w, input, today)), (text, change) => updateCardText(text ?? '', input.id, change.fields));
29
70
  }
30
71
  removeCard(cardId) {
31
- return withWalletLock(this.where, () => removeCard(this.where, this.catalog, cardId));
72
+ return this.change(this.existing((w) => removeFromWallet(w, cardId)), (text) => removeCardText(text ?? '', cardId));
32
73
  }
33
74
  setMemberships(memberships) {
34
- return withWalletLock(this.where, () => setMemberships(this.where, this.catalog, memberships));
75
+ return this.change(this.existing((w) => withMemberships(this.catalog, w, memberships)), (text, change) => setMembershipsText(text ?? '', change.wallet.memberships));
35
76
  }
36
77
  record(purchase, merchant) {
37
78
  return withWalletLock(this.where, async () => {
38
79
  const wallet = await this.load();
39
80
  if (!wallet)
40
- return { ok: false, error: "There's no saved wallet to record it in." };
81
+ return failed("There's no saved wallet to record it in.");
41
82
  if (!wallet.cards.some((c) => c.id === purchase.cardId))
42
- return { ok: false, error: `${purchase.cardId} isn't in the wallet.` };
83
+ return failed(`${purchase.cardId} isn't in the wallet.`);
43
84
  const booked = bookPurchase(this.catalog, wallet, purchase, merchant);
44
85
  try {
45
- if (booked.cap)
46
- await writeCapBooking(this.where, booked.cap);
47
- if (booked.bonus)
48
- await writeBonusSpent(this.where, purchase.cardId, booked.bonus.after);
86
+ if (booked.cap || booked.bonus)
87
+ await writeProgress(this.where, booked.cap ? [booked.cap] : [], booked.bonus ? [booked.bonus] : []);
49
88
  }
50
89
  catch (e) {
51
- return { ok: false, error: e instanceof Error ? e.message : String(e) };
90
+ return failed(errorText(e));
52
91
  }
53
92
  return { ok: true, ...booked };
54
93
  });
package/dist/data/load.js CHANGED
@@ -11,7 +11,7 @@ import { CurrenciesFileSchema } from "../schema/currency.js";
11
11
  import { MerchantsFileSchema } from "../schema/merchant.js";
12
12
  import { WalletSchema } from "../schema/wallet.js";
13
13
  export const DATA_DIR = fileURLToPath(new URL('../../data/', import.meta.url));
14
- export async function readYaml(file, schema) {
14
+ async function readYaml(file, schema) {
15
15
  const raw = parse(await readFile(file, 'utf8'));
16
16
  const result = schema.safeParse(raw);
17
17
  if (!result.success) {