earnrate 0.2.2 → 0.2.3

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
@@ -167,7 +167,7 @@ Spotted a wrong rate, or want a card added? [Open an issue](https://github.com/c
167
167
 
168
168
  ## Use it in an AI assistant (MCP)
169
169
 
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:
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. When you add it, an earnrate page opens once, where you start an anonymous wallet: no email, just a recovery code you keep. Then ask anything: with no cards saved it answers from every card. Name your cards for answers about yours, and save them to track caps and welcome bonuses across your Claude apps. From the chat you can export the wallet for the command line, switch to another one, or delete it. In Claude Code:
171
171
 
172
172
  ```sh
173
173
  claude mcp add --transport http earnrate https://mcp.earnrate.dev
@@ -113,7 +113,10 @@
113
113
  category: warehouse_club
114
114
  in_store:
115
115
  accepts: [visa]
116
- note: Warehouse registers take Visa only for credit. Costco.com takes other networks.
116
+ online:
117
+ accepts: [visa, mastercard]
118
+ # customerservice.costco.com/app/answers/answer_view/a_id/719 (2026-10-31)
119
+ note: Warehouse registers take Visa only for credit; Costco.com takes Visa and Mastercard.
117
120
  - id: costco-gas
118
121
  name: Costco Gas
119
122
  aliases: [costco gas station]
@@ -69,7 +69,9 @@ export class FileWalletStore {
69
69
  return this.change(this.existing((w) => updateInWallet(this.catalog, w, input, today)), (text, change) => updateCardText(text ?? '', input.id, change.fields));
70
70
  }
71
71
  removeCard(cardId) {
72
- return this.change(this.existing((w) => removeFromWallet(w, cardId)), (text) => removeCardText(text ?? '', cardId));
72
+ return this.change(this.existing((w) => w.cards.length === 1 && w.cards[0]?.id === cardId
73
+ ? failed(`${cardId} is the last card in ${this.where}. To start over, delete that file; to swap cards, add the new one first.`)
74
+ : removeFromWallet(w, cardId)), (text) => removeCardText(text ?? '', cardId));
73
75
  }
74
76
  setMemberships(memberships) {
75
77
  return this.change(this.existing((w) => withMemberships(this.catalog, w, memberships)), (text, change) => setMembershipsText(text ?? '', change.wallet.memberships));
@@ -30,7 +30,11 @@ export class JsonWalletStore {
30
30
  const change = apply(before);
31
31
  if (!change.ok)
32
32
  return failed(change.error);
33
- await this.backend.write(JSON.stringify(change.wallet));
33
+ // An empty wallet is stored as none: the schema needs a card.
34
+ if (change.wallet.cards.length === 0)
35
+ await this.backend.remove();
36
+ else
37
+ await this.backend.write(JSON.stringify(change.wallet));
34
38
  return {
35
39
  ok: true,
36
40
  wallet: change.wallet,
@@ -62,6 +66,10 @@ export class JsonWalletStore {
62
66
  erase() {
63
67
  return this.serialized(() => this.backend.remove());
64
68
  }
69
+ // Nothing to sign out of: the backend is the caller's own storage.
70
+ disconnect() {
71
+ return Promise.resolve();
72
+ }
65
73
  record(purchase, merchant) {
66
74
  return this.serialized(async () => {
67
75
  const wallet = await this.load();
@@ -23,6 +23,11 @@ const PERIOD_WORDS = {
23
23
  const displayName = (e) => e.held.checkout_label ?? e.card.name;
24
24
  // "Worth X or more" must hold at X itself, so round up; "less than X" rounds down.
25
25
  const roundUp = (n) => String(Math.ceil(n * 100 - EPSILON) / 100);
26
+ // At exactly the break-even value the two cards tie, so only an exact
27
+ // threshold reads "more than".
28
+ const worthAbove = (n) => Math.abs(n * 100 - Math.round(n * 100)) < EPSILON
29
+ ? `more than ${num(n)}¢`
30
+ : `${roundUp(n)}¢ or more`;
26
31
  const roundDown = (n) => String(Math.floor(n * 100 + EPSILON) / 100);
27
32
  function compareEntries(a, b) {
28
33
  const byReturn = b.effectiveReturnPct - a.effectiveReturnPct;
@@ -218,7 +223,7 @@ function sensitivity(catalog, entries) {
218
223
  if (o.range && oRate > 0) {
219
224
  const threshold = winner.effectiveReturnPct / oRate;
220
225
  if (threshold > o.centsPerUnit && threshold <= o.range[1]) {
221
- consider((threshold - o.centsPerUnit) / o.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${currencyLabel(catalog, o.currency)} points are worth ${roundUp(threshold)}¢ or more (you value them at ${num(o.centsPerUnit)}¢).`);
226
+ consider((threshold - o.centsPerUnit) / o.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${currencyLabel(catalog, o.currency)} points are worth ${worthAbove(threshold)} (you value them at ${num(o.centsPerUnit)}¢).`);
222
227
  }
223
228
  }
224
229
  }
@@ -283,6 +288,14 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
283
288
  }
284
289
  if (!winner) {
285
290
  warnings.push(`None of your cards work at ${merchant.resolvedName}.`);
291
+ const online = merchant.channel === 'in_store' && merchant.merchantId !== null
292
+ ? resolveMerchantById(catalog, merchant.merchantId, 'online')
293
+ : null;
294
+ const alt = online
295
+ ? rankEntries(catalog, wallet, { ...purchase, channel: 'online' }, online, online.category).entries[0]
296
+ : undefined;
297
+ if (alt)
298
+ warnings.push(`Ordering online instead? ${displayName(alt)} works there (${num(alt.effectiveReturnPct)}%).`);
286
299
  }
287
300
  else if (merchant.accepts === null &&
288
301
  (winner.card.network === 'amex' || winner.card.network === 'discover')) {
@@ -49,7 +49,7 @@ function needsAnniversary(card) {
49
49
  return (card.caps.some((c) => c.period === 'cardmember_year') ||
50
50
  card.rules.some((r) => typeof r.cap === 'object' && r.cap.period === 'cardmember_year'));
51
51
  }
52
- function activationRules(card, today) {
52
+ export function activationRules(card, today) {
53
53
  return card.rules
54
54
  .filter((r) => r.requires?.activation === true &&
55
55
  (r.valid_to === undefined || r.valid_to >= today) &&
@@ -208,9 +208,8 @@ export function removeFromWallet(wallet, id) {
208
208
  if (!wallet.cards.some((c) => c.id === id)) {
209
209
  return failed(`${id} isn't in the wallet. Cards: ${list(wallet.cards.map((c) => c.id))}.`);
210
210
  }
211
- if (wallet.cards.length === 1) {
212
- return failed(`${id} is the last card in the wallet, and a wallet needs one. Add the new card first, then remove this one.`);
213
- }
211
+ // Removing the last card leaves an empty wallet, which each store keeps
212
+ // in its own way (or refuses, for a wallet file).
214
213
  return {
215
214
  ok: true,
216
215
  wallet: { ...wallet, cards: wallet.cards.filter((c) => c.id !== id) },
@@ -1,10 +1,98 @@
1
1
  import { bonusStatus } from "./bonus.js";
2
- import { capUsed, pickedCategories } from "./evaluate.js";
2
+ import { activationGroup, capUsed, isActivated, pickedCategories, } from "./evaluate.js";
3
3
  import { daysBetween } from "./periods.js";
4
4
  import { STALE_AFTER_DAYS } from "./recommend.js";
5
5
  import { rotationGap } from "./rotation.js";
6
+ import { activationRules } from "./setup.js";
7
+ import { categoryPhrase, earnText } from "./text.js";
6
8
  import { cardValuation } from "./valuation.js";
7
- function capSummaries(card, held, today) {
9
+ const NUMBERS = {
10
+ one: '1',
11
+ two: '2',
12
+ three: '3',
13
+ four: '4',
14
+ five: '5',
15
+ six: '6',
16
+ };
17
+ // "five-percent" reads "5%"; other ids read as words.
18
+ export function choiceName(id) {
19
+ return id
20
+ .replace(/\b(one|two|three|four|five|six)-percent\b/g, (_, n) => `${NUMBERS[n] ?? n}%`)
21
+ .replace(/[-_.]/g, ' ');
22
+ }
23
+ const listed = (parts) => parts.length <= 2
24
+ ? parts.join(' and ')
25
+ : `${parts.slice(0, -1).join(', ')} and ${parts.at(-1) ?? ''}`;
26
+ function merchantName(catalog, id) {
27
+ const all = [...catalog.merchants.overrides, ...catalog.merchants.seed];
28
+ return all.find((m) => m.id === id)?.name ?? id.replace(/[-_.]/g, ' ');
29
+ }
30
+ function optionName(catalog, card, choiceId, option) {
31
+ const o = card.choices.find((c) => c.id === choiceId)?.options[option];
32
+ if (!o)
33
+ return option.replace(/[-_.]/g, ' ');
34
+ const parts = [
35
+ ...o.categories.map((c) => categoryPhrase(catalog, c)),
36
+ ...o.merchants.map((m) => merchantName(catalog, m)),
37
+ ];
38
+ if (o.online)
39
+ parts.push('online shopping');
40
+ return parts.length > 0 ? listed(parts) : option.replace(/[-_.]/g, ' ');
41
+ }
42
+ function ruleWhat(catalog, rule) {
43
+ if (rule.match.choice !== undefined)
44
+ return `your ${choiceName(rule.match.choice)} picks`;
45
+ const parts = [
46
+ ...(rule.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
47
+ ...(rule.match.merchants ?? []).map((m) => merchantName(catalog, m)),
48
+ ];
49
+ return parts.length > 0 ? listed(parts) : rule.id.replace(/[-_.]/g, ' ');
50
+ }
51
+ function periodEnd(period) {
52
+ const quarter = /^(\d{4})-Q([1-4])$/.exec(period);
53
+ if (quarter) {
54
+ const end = ['03-31', '06-30', '09-30', '12-31'][Number(quarter[2]) - 1];
55
+ return `${quarter[1] ?? ''}-${end ?? ''}`;
56
+ }
57
+ const month = /^(\d{4})-(\d{2})$/.exec(period);
58
+ if (month) {
59
+ const last = new Date(Date.UTC(Number(month[1]), Number(month[2]), 0));
60
+ return last.toISOString().slice(0, 10);
61
+ }
62
+ return /^\d{4}$/.test(period) ? `${period}-12-31` : null;
63
+ }
64
+ const applies = (rule, today) => (rule.valid_from === undefined || rule.valid_from <= today) &&
65
+ (rule.valid_to === undefined || rule.valid_to >= today);
66
+ function headlineRates(catalog, card, currency, today) {
67
+ const best = card.rules
68
+ .filter((r) => applies(r, today) && r.rate > card.base_rate)
69
+ .sort((a, b) => b.rate - a.rate)
70
+ .slice(0, 4)
71
+ .map((r) => `${earnText(catalog, r.rate, currency)} on ${ruleWhat(catalog, r)}${r.requires?.activation ? ' (once activated)' : ''}`);
72
+ return [
73
+ ...best,
74
+ `${earnText(catalog, card.base_rate, currency)} on everything else`,
75
+ ];
76
+ }
77
+ function toActivate(catalog, card, held, today) {
78
+ const seen = new Set();
79
+ const out = [];
80
+ for (const id of activationRules(card, today)) {
81
+ if (seen.has(id) || isActivated(card, held, id))
82
+ continue;
83
+ const group = activationGroup(card, id);
84
+ for (const g of group)
85
+ seen.add(g);
86
+ const rules = card.rules.filter((r) => group.includes(r.id));
87
+ const first = rules[0];
88
+ const when = first?.valid_from !== undefined && first.valid_to !== undefined
89
+ ? ` (${first.valid_from} to ${first.valid_to})`
90
+ : '';
91
+ out.push(`${rules.map((r) => ruleWhat(catalog, r)).join(', ')}${when}`);
92
+ }
93
+ return out;
94
+ }
95
+ function capSummaries(catalog, card, held, today) {
8
96
  const caps = new Map();
9
97
  for (const shared of card.caps)
10
98
  caps.set(shared.id, { cap: shared, rules: [] });
@@ -20,8 +108,15 @@ function capSummaries(card, held, today) {
20
108
  }
21
109
  return [...caps].map(([key, { cap, rules }]) => {
22
110
  const { period: currentPeriod, spent } = capUsed(cap, key, held, today);
111
+ const covered = card.rules.filter((r) => rules.includes(r.id));
112
+ const shared = card.caps.find((c) => c.id === key);
113
+ const label = shared?.rotating
114
+ ? 'the rotating bonus categories'
115
+ : [...new Set(covered.map((r) => ruleWhat(catalog, r)))].join(', ') ||
116
+ key.replace(/[-_.]/g, ' ');
23
117
  return {
24
118
  key,
119
+ label,
25
120
  rules,
26
121
  amount: cap.amount,
27
122
  period: cap.period,
@@ -52,15 +147,22 @@ export function walletSummary(catalog, wallet, today) {
52
147
  verifiedBy: card.verified_by,
53
148
  dataIsStale: daysBetween(card.last_verified, today) > STALE_AFTER_DAYS,
54
149
  rotationGap: rotationGap(card, today),
55
- caps: capSummaries(card, held, today),
150
+ caps: capSummaries(catalog, card, held, today),
56
151
  chosen: held.chosen
57
152
  ? {
58
153
  period: held.chosen.period,
59
154
  current: pickedCategories(card, held, today).staleChoice === null,
155
+ until: periodEnd(held.chosen.period),
60
156
  picks: held.chosen.picks,
157
+ pickNames: Object.fromEntries(Object.entries(held.chosen.picks).map(([choice, options]) => [
158
+ choiceName(choice),
159
+ options.map((o) => optionName(catalog, card, choice, o)),
160
+ ])),
61
161
  }
62
162
  : null,
163
+ rates: headlineRates(catalog, card, valuation.currency, today),
63
164
  activated: held.activated,
165
+ toActivate: toActivate(catalog, card, held, today),
64
166
  anniversary: held.anniversary ?? null,
65
167
  });
66
168
  }
@@ -6,29 +6,39 @@ const NETWORKS = {
6
6
  discover: 'Discover',
7
7
  store: 'store card',
8
8
  };
9
- const words = (id) => id.replace(/[-_.]/g, ' ');
9
+ const day = (date) => new Date(`${date}T12:00:00Z`).toLocaleDateString('en-US', {
10
+ month: 'short',
11
+ day: 'numeric',
12
+ year: 'numeric',
13
+ timeZone: 'UTC',
14
+ });
10
15
  export function formatWalletSummary(summary) {
11
16
  const cards = summary.cards.map((c) => {
17
+ const label = c.checkoutLabel
18
+ ? `, shown at checkout as "${c.checkoutLabel}"`
19
+ : '';
12
20
  const lines = [
13
- `- ${c.checkoutLabel ?? c.cardName} (${c.cardId}): ${NETWORKS[c.network] ?? c.network}, ${c.currency} at ${String(c.centsPerUnit)}¢. Data checked ${c.lastVerified}${c.dataIsStale ? ' (old; check the issuer)' : ''}.`,
21
+ `- ${c.cardName} (${c.cardId})${label}: ${NETWORKS[c.network] ?? c.network}, ${c.currency} at ${String(c.centsPerUnit)}¢. Data checked ${c.lastVerified}${c.dataIsStale ? ' (old; check the issuer)' : ''}.`,
22
+ ` Earns: ${c.rates.join('; ')}.`,
14
23
  ];
15
24
  if (c.onlyAt)
16
25
  lines.push(` Works only at ${c.onlyAt.join(', ')}.`);
17
26
  if (c.chosen) {
18
- const picks = Object.entries(c.chosen.picks)
19
- .map(([choice, options]) => `${words(choice)}: ${options.map(words).join(', ')}`)
27
+ const picks = Object.entries(c.chosen.pickNames)
28
+ .map(([choice, names]) => `${choice}: ${names.join(', ')}`)
20
29
  .join('; ');
30
+ const until = c.chosen.until ? `, through ${day(c.chosen.until)}` : '';
21
31
  lines.push(c.chosen.current
22
- ? ` Picks for ${c.chosen.period}: ${picks}. They need choosing again when the period ends.`
32
+ ? ` Picks for ${c.chosen.period}${until}: ${picks}. Pick again after that.`
23
33
  : ` Picks are for ${c.chosen.period}, which is over: ask for this period's picks.`);
24
34
  }
25
- if (c.activated.length > 0)
26
- lines.push(` Activated: ${c.activated.join(', ')}.`);
35
+ if (c.toActivate.length > 0)
36
+ lines.push(` Not activated yet: ${c.toActivate.join('; ')}. Activating once with the issuer covers each group.`);
27
37
  if (c.rotationGap !== null)
28
38
  lines.push(` Rotating categories for ${c.rotationGap} aren't in earnrate yet.`);
29
39
  for (const cap of c.caps)
30
40
  if (cap.remaining !== null)
31
- lines.push(` Cap ${cap.key}: ${money(cap.spent)} of ${money(cap.amount)} used for ${cap.currentPeriod ?? ''}.`);
41
+ lines.push(` Cap on ${cap.label}: ${money(cap.spent)} of ${money(cap.amount)} used for ${cap.currentPeriod ?? ''} (cap key ${cap.key}).`);
32
42
  const b = c.bonus;
33
43
  if (b?.state === 'open' && b.spend !== null)
34
44
  lines.push(` Welcome bonus: ${money(b.spent)} of ${money(b.spend)} spent, due ${b.deadline ?? ''}.`);
@@ -7,7 +7,7 @@ export const fail = (text) => ({
7
7
  content: [{ type: 'text', text }],
8
8
  isError: true,
9
9
  });
10
- export const ALL_CARDS_NOTE = "The user hasn't named their cards, so this covers every card earnrate knows. That's a complete answer. If they want the best of their own cards, they can name them (pass them as `cards`); saving a wallet is optional.";
10
+ export const ALL_CARDS_NOTE = "No cards are saved or named, so this covers every card earnrate knows. That's a complete answer. If the user wants the best of their own cards, they can name them (pass them as `cards`); saving them is optional.";
11
11
  // What every tool shares: the saved wallet, merchant resolution, and which
12
12
  // cards an answer is about.
13
13
  export function makeContext(deps) {
@@ -15,7 +15,9 @@ export function makeContext(deps) {
15
15
  const theWallet = deps.savedWallets === 'file'
16
16
  ? `the wallet at ${deps.store.where}`
17
17
  : 'the saved wallet';
18
- const noWallet = `There's no saved wallet yet${deps.savedWallets === 'file' ? ` (${deps.store.where})` : ''}. Answers don't need one: they cover every card, or the cards the user names. If the user wants their cards remembered, find each with search_cards and save it with add_card.`;
18
+ const noWallet = deps.savedWallets === 'file'
19
+ ? `There's no saved wallet yet (${deps.store.where}). Answers don't need one: they cover every card, or the cards the user names. If the user wants their cards remembered, find each with search_cards and save it with add_card.`
20
+ : "The user's saved wallet has no cards yet. Answers cover every card, or the cards the user names. If the user wants their cards remembered, find each with search_cards and save it with add_card.";
19
21
  // Read on every call, so edits to the wallet apply without a restart.
20
22
  async function wallet() {
21
23
  let loaded;
@@ -79,7 +81,9 @@ export function makeContext(deps) {
79
81
  };
80
82
  const text = [
81
83
  message,
82
- `Wallet: ${output.cardIds.join(', ')}.`,
84
+ output.cardIds.length > 0
85
+ ? `Wallet: ${output.cardIds.join(', ')}.`
86
+ : 'The wallet has no cards now; answers cover every card.',
83
87
  ...(notes ? [formatNotes(result.notes)] : []),
84
88
  ].join('\n');
85
89
  return {
@@ -106,6 +106,7 @@ export const WalletSummarySchema = z.object({
106
106
  rotationGap: z.string().nullable(),
107
107
  caps: z.array(z.object({
108
108
  key: z.string(),
109
+ label: z.string(),
109
110
  rules: z.array(z.string()),
110
111
  amount: z.number(),
111
112
  period: CapPeriod,
@@ -117,10 +118,14 @@ export const WalletSummarySchema = z.object({
117
118
  .object({
118
119
  period: z.string(),
119
120
  current: z.boolean(),
121
+ until: z.string().nullable(),
120
122
  picks: z.record(z.string(), z.array(z.string())),
123
+ pickNames: z.record(z.string(), z.array(z.string())),
121
124
  })
122
125
  .nullable(),
126
+ rates: z.array(z.string()),
123
127
  activated: z.array(z.string()),
128
+ toActivate: z.array(z.string()),
124
129
  anniversary: z.string().nullable(),
125
130
  })),
126
131
  });
@@ -15,8 +15,7 @@ export const ANSWER_TOOLS = [
15
15
  'resolve_merchant',
16
16
  'search_cards',
17
17
  ];
18
- // The tools that read or change a saved wallet. The hosted server asks for
19
- // sign-in before running one.
18
+ // The tools that read or change a saved wallet.
20
19
  export const SAVED_WALLET_TOOLS = [
21
20
  'list_wallet',
22
21
  'record_purchase',
@@ -27,10 +26,11 @@ export const SAVED_WALLET_TOOLS = [
27
26
  'export_wallet',
28
27
  'import_wallet',
29
28
  'delete_wallet',
29
+ 'switch_wallet',
30
30
  ];
31
31
  const SAVING = {
32
32
  file: 'Only if they want earnrate to remember their cards, track spending caps or welcome bonuses, or record purchases, save a wallet with add_card and the other wallet tools.',
33
- account: "Only if they want earnrate to remember their cards, track spending caps or welcome bonuses, or record purchases, save a wallet with add_card and the other wallet tools. Saved wallets are anonymous: the first wallet tool asks the user to open an earnrate page, where they start a wallet (it shows a recovery code to keep; no email or name) or reconnect one with their code. Never ask for the recovery code in the conversation; it's typed only on that page. To change the code, the user opens https://mcp.earnrate.dev/change-code.",
33
+ account: "The user signed in to earnrate when they connected it, to an anonymous saved wallet (no email or name). It may have no cards yet; answers then cover every card. Save cards with add_card only when the user agrees. The wallet can change outside this conversation when the user signs in again on earnrate's page, so read it with list_wallet rather than from memory. To use a different wallet or start a new one, call switch_wallet; the user then connects on earnrate's page with that wallet's recovery code. Never ask for the recovery code in the conversation; it's typed only on earnrate's page. To change the code, the user opens https://mcp.earnrate.dev/change-code. earnrate.dev and the earnrate command line keep their own separate wallets; export_wallet and import_wallet move one between them.",
34
34
  };
35
35
  const instructions = (savedWallets) => `earnrate answers which credit card earns the most for a purchase, from each issuer's own terms. Anyone can use it without entering anything: with no cards given, best_card_for_purchase, best_cards_for, cheat_sheet, simulate_spending and compare_welcome_offers answer from every card earnrate knows, and that's a complete answer. Never require the user's cards, and don't ask what they spend unless the answer depends on it; simulate_spending assumes typical amounts when none are given and says so.
36
36
 
@@ -54,8 +54,12 @@ export function registerAccountTools(server, catalog, store, ctx) {
54
54
  });
55
55
  server.registerTool('delete_wallet', {
56
56
  title: 'Delete the wallet',
57
- description: 'Deletes the saved wallet now and for good, and signs out every app using it. Only when the user asks to delete it; confirm first, and offer export_wallet to keep a copy.',
58
- inputSchema: z.object({}),
57
+ description: "Deletes the saved wallet and its recovery code now and for good, and signs out every Claude app connected to it, this one included. earnrate.dev and the earnrate command line keep their own wallets and aren't affected. Only when the user asks to delete it: offer export_wallet first, and pass confirm only after the user confirms. It refuses when the wallet has no cards, since there's nothing to delete.",
58
+ inputSchema: z.object({
59
+ confirm: z
60
+ .literal(true)
61
+ .describe('true, only after the user confirms the deletion.'),
62
+ }),
59
63
  outputSchema: z.object({ deleted: z.boolean() }),
60
64
  annotations: {
61
65
  readOnlyHint: false,
@@ -64,15 +68,41 @@ export function registerAccountTools(server, catalog, store, ctx) {
64
68
  openWorldHint: false,
65
69
  },
66
70
  }, async () => {
71
+ const current = await store.load().catch(() => null);
72
+ if (current === null || current.cards.length === 0)
73
+ return fail("This saved wallet has no cards, so there's nothing to delete. If the user meant a different wallet, it may already be deleted.");
67
74
  await store.erase();
68
75
  return {
69
76
  content: [
70
77
  {
71
78
  type: 'text',
72
- text: 'Deleted the saved wallet. Answers now cover every card, or the cards named in the conversation.',
79
+ text: "Deleted the saved wallet and its recovery code, and signed out every Claude app connected to it, this one included. To keep using earnrate here, the user connects again on earnrate's page and starts a new wallet.",
73
80
  },
74
81
  ],
75
82
  structuredContent: { deleted: true },
76
83
  };
77
84
  });
85
+ server.registerTool('switch_wallet', {
86
+ title: 'Switch wallets',
87
+ description: "Disconnects this app from its saved wallet so the user can connect a different one with its recovery code, or start a new one, on earnrate's page. Nothing is deleted. Use it when the user wants another wallet. Never ask for the recovery code in the conversation.",
88
+ inputSchema: z.object({}),
89
+ outputSchema: z.object({ disconnected: z.boolean() }),
90
+ annotations: {
91
+ readOnlyHint: false,
92
+ destructiveHint: false,
93
+ idempotentHint: true,
94
+ openWorldHint: false,
95
+ },
96
+ }, async () => {
97
+ await store.disconnect();
98
+ return {
99
+ content: [
100
+ {
101
+ type: 'text',
102
+ text: "Disconnected from this saved wallet; nothing was deleted. earnrate's sign-in page opens on the next earnrate request (or reconnect earnrate in the app's connector settings). There the user chooses \"Already have a wallet?\" and types the other wallet's recovery code, or starts a new wallet. Other Claude accounts connected to this wallet through the same app are signed out too.",
103
+ },
104
+ ],
105
+ structuredContent: { disconnected: true },
106
+ };
107
+ });
78
108
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "earnrate",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Which credit card earns the most at a merchant, from your cards or every card, and why",
5
5
  "keywords": [
6
6
  "credit cards",