ray-finance 0.3.3 → 0.3.5

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
@@ -3,7 +3,7 @@
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
- An open-source CLI that connects to your bank and already knows your finances before you ask.
6
+ An open-source AI financial advisor that learns your situation and gets smarter every conversation.
7
7
  </p>
8
8
 
9
9
  <p align="center">
@@ -15,26 +15,37 @@
15
15
  <br />
16
16
 
17
17
  <p align="center">
18
- <img src=".github/ray-demo.png" alt="Ray demo" />
18
+ <img src=".github/ray-demo.png" alt="Ray demo" width="100%" />
19
19
  </p>
20
20
 
21
- Open Ray and it shows your net worth, spending vs last month, budget pacing, and upcoming bills before you type a word. Ask a question and it answers from your real data, not guesses. Local-first. Encrypted. Open source.
21
+ Tell Ray about your family, goals, and financial strategy once. From then on, every answer is grounded in your real situation — not generic advice. It connects to your bank, tracks your net worth and spending, and gives you a financial briefing before you type a word. Open source. Local-first. Encrypted.
22
22
 
23
23
  ## Features
24
24
 
25
- - **It already knows** Every conversation starts with a real-time financial briefing. Net worth, spending velocity, budget alerts, goal pace, upcoming bills, and your daily score. No "let me look that up."
26
- - **Persistent context** — Ray maintains a financial profile that evolves with you: income, goals, strategy, key decisions, and open items. It updates this context as your situation changes, so every conversation picks up where the last one left off.
27
- - **Long-term memory** — Important details from conversations are saved as memories. Mention you're saving for a house or switching jobs and Ray remembers without you repeating yourself.
28
- - **Bank sync via Plaid** — Connect checking, savings, credit cards, investments, and loans
29
- - **Encrypted local database** — All data stays on your machine in an AES-256 encrypted SQLite database
30
- - **Daily scoring** — A 0-100 behavior score with streaks and 14 unlockable achievements. No restaurants for a week? That's Kitchen Hero. Five zero-spend days? Monk Mode.
25
+ ### It gets smarter every conversation
26
+
27
+ - **Your situation, always loaded** — Every conversation starts with your financial profile: family, income, goals, strategy, key decisions, and open items. Ray reads it all before you type a word.
28
+ - **Self-updating context** — Got a raise? Had a baby? Decided to pay off debt aggressively? Ray updates your profile automatically when your situation changes.
29
+ - **Long-term memory** — Mention you're saving for a house or that you cancelled a subscription. Ray remembers across every future conversation.
30
+
31
+ ### Stay on track without trying
32
+
31
33
  - **CFO personality** — Ray doesn't list options. It tells you what it would do and why, references your goals, and flags problems you haven't noticed yet.
32
- - **Budgets and goals** — Track spending limits by category and progress toward financial goals
34
+ - **Daily scoring** — A 0-100 behavior score with streaks and 14 unlockable achievements. No restaurants for a week? That's Kitchen Hero. Five zero-spend days? Monk Mode.
35
+ - **Budgets and goals** — Track spending limits by category and progress toward financial goals.
36
+ - **Smart alerts** — Large transactions, low balances, budget overruns.
37
+
38
+ ### Your data never leaves your machine
39
+
40
+ - **Encrypted local database** — All data stays on your machine in an AES-256 encrypted SQLite database.
33
41
  - **PII masking** — Names, account numbers, and identifying details are scrubbed before anything reaches the AI. Your data is analyzed, not exposed.
34
- - **Smart alerts** — Large transactions, low balances, budget overruns
35
- - **Auto-recategorization** Define rules to automatically re-label transactions
36
- - **Scheduled daily sync** — Automatic bank sync via launchd (macOS) or cron (Linux)
37
- - **Export/import** — Back up and restore your financial data
42
+
43
+ ### Set it and forget it
44
+
45
+ - **Bank sync via Plaid** — Connect checking, savings, credit cards, investments, and loans.
46
+ - **Scheduled daily sync** — Automatic bank sync via launchd (macOS) or cron (Linux).
47
+ - **Auto-recategorization** — Define rules to automatically re-label transactions.
48
+ - **Export/import** — Back up and restore your financial data.
38
49
 
39
50
  ## Install
40
51
 
@@ -70,7 +81,7 @@ ray setup
70
81
 
71
82
  The setup wizard offers two modes:
72
83
 
73
- ### Quick setup (managed)
84
+ ### Pro (quick setup)
74
85
 
75
86
  We handle the API keys. Your data stays local. $10/mo.
76
87
 
@@ -79,7 +90,7 @@ We handle the API keys. Your data stays local. $10/mo.
79
90
  3. Link your accounts — checking, savings, credit cards, investments, loans, mortgage
80
91
  4. Done — daily sync auto-scheduled at 6am
81
92
 
82
- ### Self-hosted
93
+ ### Bring your own keys
83
94
 
84
95
  Bring your own Anthropic and Plaid credentials. Free forever.
85
96
 
@@ -99,17 +110,24 @@ Run `ray --help` to see all available commands.
99
110
  | `ray --demo <cmd>` | Run any command against demo data |
100
111
  | `ray setup` | Configure API keys and preferences |
101
112
  | `ray link` | Connect a new bank account |
113
+ | `ray add` | Add a manual account (home, car, crypto, etc.) |
114
+ | `ray remove` | Remove a manual account |
102
115
  | `ray sync` | Pull latest transactions and balances |
103
116
  | `ray status` | Quick financial dashboard |
117
+ | `ray accounts` | Linked accounts with balances |
104
118
  | `ray transactions` | Recent transactions (filterable by category, merchant) |
105
119
  | `ray spending [period]` | Spending breakdown by category |
106
120
  | `ray budgets` | Budget status and overruns |
107
121
  | `ray goals` | Financial goal progress |
122
+ | `ray bills` | Upcoming bills |
123
+ | `ray recap [period]` | Monthly spending recap |
108
124
  | `ray score` | Daily score, streaks, and achievements |
109
125
  | `ray alerts` | Active financial alerts |
110
126
  | `ray export [path]` | Export data to a backup file |
111
127
  | `ray import <path>` | Restore from a backup file |
112
128
  | `ray billing` | Manage your Ray subscription (managed mode only) |
129
+ | `ray update` | Update Ray to the latest version |
130
+ | `ray doctor` | Check system health |
113
131
 
114
132
  ## How It Works
115
133
 
package/dist/cli/chat.js CHANGED
@@ -5,7 +5,35 @@ import { banner, formatResponse, formatDuration, formatError } from "./format.js
5
5
  function rawReadLine(prompt, belowLines) {
6
6
  return new Promise((resolve) => {
7
7
  let buf = "";
8
+ let cursor = 0; // cursor position within buf
8
9
  const out = process.stdout;
10
+ const promptLen = stripAnsi(prompt).length;
11
+ // Redraws the buffer from the prompt onward and repositions the cursor
12
+ const redraw = () => {
13
+ out.write("\r" + prompt + buf + "\x1b[K"); // clear to end of line
14
+ // Move cursor back to the correct position
15
+ const back = buf.length - cursor;
16
+ if (back > 0)
17
+ out.write(`\x1b[${back}D`);
18
+ };
19
+ // Find the start of the previous word boundary
20
+ const wordLeft = () => {
21
+ let p = cursor;
22
+ while (p > 0 && buf[p - 1] === " ")
23
+ p--; // skip trailing spaces
24
+ while (p > 0 && buf[p - 1] !== " ")
25
+ p--; // skip word chars
26
+ return p;
27
+ };
28
+ // Find the end of the next word boundary
29
+ const wordRight = () => {
30
+ let p = cursor;
31
+ while (p < buf.length && buf[p] !== " ")
32
+ p++; // skip word chars
33
+ while (p < buf.length && buf[p] === " ")
34
+ p++; // skip trailing spaces
35
+ return p;
36
+ };
9
37
  // Render: prompt on current line, then content below, then move cursor back
10
38
  out.write(prompt);
11
39
  if (belowLines.length > 0) {
@@ -32,9 +60,51 @@ function rawReadLine(prompt, belowLines) {
32
60
  resolve("\x03");
33
61
  return;
34
62
  }
63
+ // Ctrl+A — beginning of line
64
+ if (code === 1) {
65
+ if (cursor > 0) {
66
+ out.write(`\x1b[${cursor}D`);
67
+ cursor = 0;
68
+ }
69
+ continue;
70
+ }
71
+ // Ctrl+E — end of line
72
+ if (code === 5) {
73
+ if (cursor < buf.length) {
74
+ out.write(`\x1b[${buf.length - cursor}C`);
75
+ cursor = buf.length;
76
+ }
77
+ continue;
78
+ }
79
+ // Ctrl+K — delete from cursor to end of line
80
+ if (code === 11) {
81
+ buf = buf.slice(0, cursor);
82
+ out.write("\x1b[K");
83
+ continue;
84
+ }
85
+ // Ctrl+U — delete from cursor to beginning of line
86
+ if (code === 21) {
87
+ buf = buf.slice(cursor);
88
+ cursor = 0;
89
+ redraw();
90
+ continue;
91
+ }
92
+ // Ctrl+W — delete word backward
93
+ if (code === 23) {
94
+ if (cursor > 0) {
95
+ const target = wordLeft();
96
+ buf = buf.slice(0, target) + buf.slice(cursor);
97
+ cursor = target;
98
+ redraw();
99
+ }
100
+ continue;
101
+ }
35
102
  // Enter
36
103
  if (code === 13) {
37
104
  cleanup();
105
+ // Move cursor to end of buf first
106
+ if (cursor < buf.length)
107
+ out.write(`\x1b[${buf.length - cursor}C`);
38
108
  // Move past the below-content lines, then newline
39
109
  for (let j = 0; j < belowLines.length; j++)
40
110
  out.write("\x1b[1B");
@@ -44,31 +114,136 @@ function rawReadLine(prompt, belowLines) {
44
114
  }
45
115
  // Backspace
46
116
  if (code === 127 || code === 8) {
47
- if (buf.length > 0) {
48
- buf = buf.slice(0, -1);
49
- out.write("\b \b");
117
+ if (cursor > 0) {
118
+ buf = buf.slice(0, cursor - 1) + buf.slice(cursor);
119
+ cursor--;
120
+ redraw();
50
121
  }
51
122
  continue;
52
123
  }
53
- // Skip escape sequences (arrow keys etc.)
124
+ // Escape sequences (arrow keys, Option+key, etc.)
54
125
  if (code === 27) {
126
+ // Option+Backspace — ESC followed by DEL (0x7f)
127
+ if (i + 1 < chunk.length && chunk.charCodeAt(i + 1) === 127) {
128
+ i++; // consume the DEL
129
+ if (cursor > 0) {
130
+ const target = wordLeft();
131
+ buf = buf.slice(0, target) + buf.slice(cursor);
132
+ cursor = target;
133
+ redraw();
134
+ }
135
+ continue;
136
+ }
137
+ // Option+b / Option+f — ESC followed by 'b' or 'f'
138
+ if (i + 1 < chunk.length && chunk[i + 1] === "b") {
139
+ i++;
140
+ const target = wordLeft();
141
+ if (target < cursor) {
142
+ out.write(`\x1b[${cursor - target}D`);
143
+ cursor = target;
144
+ }
145
+ continue;
146
+ }
147
+ if (i + 1 < chunk.length && chunk[i + 1] === "f") {
148
+ i++;
149
+ const target = wordRight();
150
+ if (target > cursor) {
151
+ out.write(`\x1b[${target - cursor}C`);
152
+ cursor = target;
153
+ }
154
+ continue;
155
+ }
55
156
  if (i + 1 < chunk.length && chunk[i + 1] === "[") {
56
- i += 2;
57
- while (i < chunk.length && chunk.charCodeAt(i) < 64)
157
+ i += 2; // skip past ESC [
158
+ // Collect any intermediate bytes (modifiers like "1;3")
159
+ let seq = "";
160
+ while (i < chunk.length && chunk.charCodeAt(i) < 64) {
161
+ seq += chunk[i];
58
162
  i++;
163
+ }
164
+ if (i < chunk.length) {
165
+ const final = chunk[i];
166
+ // Modifier keys: ;3 = Option, ;5 = Ctrl, ;9 = Cmd (Kitty protocol)
167
+ const isWordMod = seq === "1;3" || seq === "1;5" || seq === "1;9";
168
+ const isCmd = seq === "1;9";
169
+ if (final === "D") {
170
+ if (isWordMod) {
171
+ // Option/Ctrl/Cmd+Left — word backward
172
+ const target = wordLeft();
173
+ if (target < cursor) {
174
+ out.write(`\x1b[${cursor - target}D`);
175
+ cursor = target;
176
+ }
177
+ }
178
+ else if (cursor > 0) {
179
+ cursor--;
180
+ out.write("\x1b[D");
181
+ }
182
+ }
183
+ else if (final === "C") {
184
+ if (isWordMod) {
185
+ // Option/Ctrl/Cmd+Right — word forward
186
+ const target = wordRight();
187
+ if (target > cursor) {
188
+ out.write(`\x1b[${target - cursor}C`);
189
+ cursor = target;
190
+ }
191
+ }
192
+ else if (cursor < buf.length) {
193
+ cursor++;
194
+ out.write("\x1b[C");
195
+ }
196
+ }
197
+ else if (final === "H") {
198
+ // Home
199
+ if (cursor > 0) {
200
+ out.write(`\x1b[${cursor}D`);
201
+ cursor = 0;
202
+ }
203
+ }
204
+ else if (final === "F") {
205
+ // End
206
+ if (cursor < buf.length) {
207
+ out.write(`\x1b[${buf.length - cursor}C`);
208
+ cursor = buf.length;
209
+ }
210
+ }
211
+ else if (final === "u") {
212
+ // Kitty keyboard protocol: ESC [ codepoint ; modifier u
213
+ const parts = seq.split(";");
214
+ const codepoint = parseInt(parts[0], 10);
215
+ const mod = parts.length > 1 ? parseInt(parts[1], 10) : 1;
216
+ const hasCmd = (mod - 1) & 8; // super/cmd bit
217
+ const hasCtrl = (mod - 1) & 4; // ctrl bit
218
+ if (codepoint === 127 && (hasCmd || hasCtrl)) {
219
+ // Cmd+Backspace / Ctrl+Backspace — delete to line start
220
+ if (cursor > 0) {
221
+ buf = buf.slice(cursor);
222
+ cursor = 0;
223
+ redraw();
224
+ }
225
+ }
226
+ }
227
+ // Ignore other sequences (up/down, etc.)
228
+ }
59
229
  }
60
230
  continue;
61
231
  }
62
232
  // Printable characters
63
233
  if (code >= 32) {
64
- buf += chunk[i];
65
- out.write(chunk[i]);
234
+ buf = buf.slice(0, cursor) + chunk[i] + buf.slice(cursor);
235
+ cursor++;
236
+ redraw();
66
237
  }
67
238
  }
68
239
  };
69
240
  process.stdin.on("data", onData);
70
241
  });
71
242
  }
243
+ /** Strip ANSI escape codes to get visible length */
244
+ function stripAnsi(str) {
245
+ return str.replace(/\x1b\[[0-9;]*m/g, "");
246
+ }
72
247
  const THINKING_PHRASES = [
73
248
  "Thinking...",
74
249
  "Crunching numbers...",
@@ -14,3 +14,5 @@ export declare function showScore(): void;
14
14
  export declare function runAdd(): Promise<void>;
15
15
  export declare function runRemove(): Promise<void>;
16
16
  export declare function showAlerts(): void;
17
+ export declare function showBills(days?: number): void;
18
+ export declare function showRecap(period?: string): void;
@@ -1,6 +1,6 @@
1
1
  import chalk from "chalk";
2
2
  import { getDb } from "../db/connection.js";
3
- import { getNetWorth, getTransactionsFiltered, getBudgetStatuses, getGoals, getCashFlowThisMonth, formatMoney as rawFormatMoney, categoryLabel, } from "../queries/index.js";
3
+ import { getNetWorth, getTransactionsFiltered, getBudgetStatuses, getGoals, getCashFlowThisMonth, compareSpending, getNetWorthTrend, formatMoney as rawFormatMoney, categoryLabel, } from "../queries/index.js";
4
4
  import { getLatestScore, getAchievements, getMonthlySavings } from "../scoring/index.js";
5
5
  import { generateAlerts } from "../alerts/index.js";
6
6
  import { runDailySync } from "../daily-sync.js";
@@ -388,3 +388,138 @@ export function showAlerts() {
388
388
  }
389
389
  console.log();
390
390
  }
391
+ export function showBills(days = 7) {
392
+ const db = getDb();
393
+ const now = new Date();
394
+ const todayDay = now.getDate();
395
+ const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
396
+ const endDay = todayDay + days;
397
+ let bills = [];
398
+ if (endDay <= daysInMonth) {
399
+ bills = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN ? AND ? ORDER BY day_of_month`).all(todayDay + 1, endDay);
400
+ }
401
+ else {
402
+ // Wraparound into next month
403
+ const thisMonth = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN ? AND ? ORDER BY day_of_month`).all(todayDay + 1, daysInMonth);
404
+ const nextMonth = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN 1 AND ? ORDER BY day_of_month`).all(endDay - daysInMonth);
405
+ bills = [...thisMonth, ...nextMonth];
406
+ }
407
+ if (bills.length === 0) {
408
+ console.log(`\nNo upcoming bills in the next ${days} days.`);
409
+ return;
410
+ }
411
+ console.log(`\n${heading("Upcoming Bills")} ${dim(`next ${days} days`)}\n`);
412
+ const maxName = Math.max(...bills.map(b => b.name.length));
413
+ let total = 0;
414
+ for (const b of bills) {
415
+ // Calculate the actual date for this bill
416
+ let billDate;
417
+ if (b.day_of_month > todayDay) {
418
+ billDate = new Date(now.getFullYear(), now.getMonth(), b.day_of_month);
419
+ }
420
+ else {
421
+ billDate = new Date(now.getFullYear(), now.getMonth() + 1, b.day_of_month);
422
+ }
423
+ const dateStr = billDate.toLocaleDateString("en-US", { month: "short", day: "numeric" });
424
+ console.log(` ${dim(dateStr.padEnd(8))}${b.name.padEnd(maxName + 2)}${rawFormatMoney(b.amount).padStart(10)}`);
425
+ total += b.amount;
426
+ }
427
+ console.log(`\n ${dim("Total due:".padEnd(maxName + 10))}${chalk.bold(rawFormatMoney(total))}`);
428
+ console.log();
429
+ }
430
+ export function showRecap(period = "last_month") {
431
+ const db = getDb();
432
+ const now = new Date();
433
+ const y = now.getFullYear();
434
+ const m = now.getMonth();
435
+ let start, end, label;
436
+ let prevStart, prevEnd;
437
+ if (period === "this_month") {
438
+ start = new Date(y, m, 1).toISOString().slice(0, 10);
439
+ end = now.toISOString().slice(0, 10);
440
+ label = now.toLocaleDateString("en-US", { month: "long", year: "numeric" }) + " (so far)";
441
+ prevStart = new Date(y, m - 1, 1).toISOString().slice(0, 10);
442
+ prevEnd = new Date(y, m, 0).toISOString().slice(0, 10);
443
+ }
444
+ else {
445
+ // last_month
446
+ start = new Date(y, m - 1, 1).toISOString().slice(0, 10);
447
+ end = new Date(y, m, 0).toISOString().slice(0, 10);
448
+ const lastMonth = new Date(y, m - 1, 1);
449
+ label = lastMonth.toLocaleDateString("en-US", { month: "long", year: "numeric" });
450
+ prevStart = new Date(y, m - 2, 1).toISOString().slice(0, 10);
451
+ prevEnd = new Date(y, m - 1, 0).toISOString().slice(0, 10);
452
+ }
453
+ // Spending this period
454
+ const spending = db.prepare(`SELECT SUM(amount) as total, COUNT(*) as count FROM transactions
455
+ WHERE amount > 0 AND date BETWEEN ? AND ? AND pending = 0
456
+ AND category NOT IN ('TRANSFER_OUT', 'TRANSFER_IN', 'LOAN_PAYMENTS')`).get(start, end);
457
+ // Income this period
458
+ const income = db.prepare(`SELECT COALESCE(SUM(ABS(amount)), 0) as total FROM transactions
459
+ WHERE amount < 0 AND date BETWEEN ? AND ? AND pending = 0
460
+ AND category NOT IN ('TRANSFER_IN')`).get(start, end);
461
+ const totalSpent = spending.total || 0;
462
+ const txnCount = spending.count || 0;
463
+ if (txnCount === 0) {
464
+ console.log(`\nNo transaction data for ${label}.`);
465
+ return;
466
+ }
467
+ console.log(`\n${heading("Recap")} ${dim(label)}\n`);
468
+ // ── Spending summary with comparison ──
469
+ const cmp = compareSpending(db, prevStart, prevEnd, start, end);
470
+ let spendLine = ` Spent ${chalk.bold(rawFormatMoney(totalSpent))} across ${txnCount} transactions`;
471
+ if (cmp.period1Total > 0) {
472
+ const pct = Math.abs(cmp.pctChange);
473
+ const dir = cmp.pctChange <= 0 ? chalk.green(`${pct}% less`) : chalk.red(`${pct}% more`);
474
+ spendLine += ` — ${dir} than prior month`;
475
+ }
476
+ console.log(spendLine);
477
+ // ── Income ──
478
+ if (income.total > 0) {
479
+ const net = income.total - totalSpent;
480
+ const savingsRate = Math.round((net / income.total) * 100);
481
+ console.log(` Earned ${chalk.bold(rawFormatMoney(income.total))} Net: ${formatMoneyColored(net)} ${dim(`(${savingsRate}% savings rate)`)}`);
482
+ }
483
+ // ── Biggest movers ──
484
+ const movers = cmp.categories.filter(c => Math.abs(c.diff) >= 10).slice(0, 3);
485
+ if (movers.length > 0) {
486
+ console.log(`\n ${heading("Biggest Movers")}`);
487
+ for (const mv of movers) {
488
+ const arrow = mv.diff > 0 ? chalk.red("↑") : chalk.green("↓");
489
+ const diffStr = mv.diff > 0 ? chalk.red("+" + rawFormatMoney(mv.diff)) : chalk.green("-" + rawFormatMoney(Math.abs(mv.diff)));
490
+ console.log(` ${arrow} ${categoryLabel(mv.category).padEnd(18)} ${rawFormatMoney(mv.period2).padStart(10)} ${diffStr}`);
491
+ }
492
+ }
493
+ // ── Top categories ──
494
+ const topCats = db.prepare(`SELECT category, SUM(amount) as total FROM transactions
495
+ WHERE amount > 0 AND date BETWEEN ? AND ? AND pending = 0
496
+ AND category NOT IN ('TRANSFER_OUT', 'TRANSFER_IN', 'LOAN_PAYMENTS')
497
+ GROUP BY category ORDER BY total DESC LIMIT 5`).all(start, end);
498
+ if (topCats.length > 0) {
499
+ console.log(`\n ${heading("Top Categories")}`);
500
+ for (const c of topCats) {
501
+ const pct = Math.round((c.total / totalSpent) * 100);
502
+ console.log(` ${categoryLabel(c.category).padEnd(18)} ${rawFormatMoney(c.total).padStart(10)} ${dim(`${pct}%`)}`);
503
+ }
504
+ }
505
+ // ── Net worth change over the period ──
506
+ const nwTrend = getNetWorthTrend(db, 60);
507
+ const nwAtStart = nwTrend.find(d => d.date >= start);
508
+ const nwAtEnd = [...nwTrend].reverse().find(d => d.date <= end);
509
+ if (nwAtStart && nwAtEnd) {
510
+ const nwChange = nwAtEnd.net_worth - nwAtStart.net_worth;
511
+ const arrow = nwChange >= 0 ? chalk.green("↑") : chalk.red("↓");
512
+ console.log(`\n ${heading("Net Worth")}`);
513
+ console.log(` ${rawFormatMoney(nwAtStart.net_worth)} → ${chalk.bold(rawFormatMoney(nwAtEnd.net_worth))} ${arrow} ${formatMoneyColored(nwChange)}`);
514
+ }
515
+ // ── Goals progress ──
516
+ const goals = getGoals(db);
517
+ const activeGoals = goals.filter(g => g.progress_pct < 100);
518
+ if (activeGoals.length > 0) {
519
+ console.log(`\n ${heading("Goals")}`);
520
+ for (const g of activeGoals) {
521
+ console.log(` ${g.name.padEnd(20)} ${progressBar(g.progress_pct, 12)} ${dim(rawFormatMoney(g.current) + " / " + rawFormatMoney(g.target))}`);
522
+ }
523
+ }
524
+ console.log();
525
+ }
@@ -45,12 +45,13 @@ const COMMANDS = [
45
45
  { name: "goals", desc: "Show financial goals" },
46
46
  { name: "score", desc: "Show daily financial score and streaks" },
47
47
  { name: "alerts", desc: "Show financial alerts" },
48
+ { name: "bills", desc: "Show upcoming bills" },
49
+ { name: "recap", desc: "Monthly spending recap" },
48
50
  { name: "export", desc: "Export data to a backup file" },
49
51
  { name: "import", desc: "Restore data from a backup file" },
50
52
  { name: "billing", desc: "Manage your Ray subscription" },
51
53
  { name: "update", desc: "Update Ray to the latest version" },
52
54
  { name: "doctor", desc: "Check system health" },
53
- { name: "completions", desc: "Install shell completions" },
54
55
  ];
55
56
  const SPENDING_PERIODS = ["this_month", "last_month", "last_30", "last_90"];
56
57
  function generateZsh() {
@@ -137,7 +137,7 @@ export async function runDoctor() {
137
137
  checks.push({ label: "Shell completions", status: "ok", detail: completionPath });
138
138
  }
139
139
  else {
140
- checks.push({ label: "Shell completions", status: "warn", detail: `Not installed. Run ${chalk.bold("ray completions")}` });
140
+ checks.push({ label: "Shell completions", status: "warn", detail: "Not installed" });
141
141
  }
142
142
  // ── Node version ──
143
143
  const nodeVersion = process.version;
package/dist/cli/index.js CHANGED
@@ -144,6 +144,24 @@ program
144
144
  const { showAlerts } = await import("./commands.js");
145
145
  showAlerts();
146
146
  });
147
+ program
148
+ .command("bills")
149
+ .description("Show upcoming bills")
150
+ .option("-d, --days <number>", "Number of days ahead", "7")
151
+ .action(async (opts) => {
152
+ ensureConfigured();
153
+ const { showBills } = await import("./commands.js");
154
+ showBills(Number(opts.days));
155
+ });
156
+ program
157
+ .command("recap")
158
+ .description("Monthly spending recap")
159
+ .argument("[period]", "Period: this_month, last_month", "last_month")
160
+ .action(async (period) => {
161
+ ensureConfigured();
162
+ const { showRecap } = await import("./commands.js");
163
+ showRecap(period);
164
+ });
147
165
  program
148
166
  .command("export")
149
167
  .description("Export user data (goals, budgets, memories, context) to a backup file")
@@ -168,7 +186,7 @@ program
168
186
  .action(async () => {
169
187
  ensureConfigured();
170
188
  if (!useManaged()) {
171
- console.log("You're using self-hosted keys. No subscription to manage.");
189
+ console.log("You're using your own keys. No subscription to manage.");
172
190
  return;
173
191
  }
174
192
  const open = (await import("open")).default;
@@ -217,13 +235,6 @@ program
217
235
  const { seedDemoDb } = await import("../demo/seed.js");
218
236
  seedDemoDb(demoPath);
219
237
  });
220
- program
221
- .command("completions")
222
- .description("Install shell completions")
223
- .action(async () => {
224
- const { installCompletions } = await import("./completions.js");
225
- installCompletions();
226
- });
227
238
  function ensureConfigured() {
228
239
  if (isDemoMode)
229
240
  return;
@@ -248,13 +259,14 @@ program.configureHelp({
248
259
  { name: "goals", desc: "Show financial goals" },
249
260
  { name: "score", desc: "Show daily financial score and streaks" },
250
261
  { name: "alerts", desc: "Show financial alerts" },
262
+ { name: "bills", desc: "Show upcoming bills" },
263
+ { name: "recap", desc: "Monthly spending recap" },
251
264
  { name: "export", desc: "Export data to a backup file" },
252
265
  { name: "import", desc: "Restore data from a backup file" },
253
266
  { name: "billing", desc: "Manage your Ray subscription" },
254
267
  { name: "update", desc: "Update Ray to the latest version" },
255
268
  { name: "doctor", desc: "Check system health" },
256
269
  { name: "demo", desc: "Seed a demo database with fake data" },
257
- { name: "completions", desc: "Install shell completions" },
258
270
  ]),
259
271
  });
260
272
  import("./updater.js").then(m => m.checkForUpdate(version)).catch(() => { });
package/dist/cli/setup.js CHANGED
@@ -31,7 +31,7 @@ export async function runSetup() {
31
31
  message: "How would you like to set up Ray?",
32
32
  choices: [
33
33
  { name: "Quick setup — we handle the API keys, your data stays local", value: "managed" },
34
- { name: "Self-hostedbring your own Anthropic and Plaid credentials", value: "selfhosted" },
34
+ { name: "Bring your own keys use your own Anthropic and Plaid credentials", value: "selfhosted" },
35
35
  ],
36
36
  }]);
37
37
  let canLink = false;
@@ -145,7 +145,7 @@ export async function runDailySync(db) {
145
145
  ? db.prepare(`UPDATE transactions SET category = ?, subcategory = ? WHERE ${rule.match_field} LIKE ? AND category != ?`).run(rule.target_category, rule.target_subcategory, rule.match_pattern, rule.target_category)
146
146
  : db.prepare(`UPDATE transactions SET category = ? WHERE ${rule.match_field} LIKE ? AND category != ?`).run(rule.target_category, rule.match_pattern, rule.target_category);
147
147
  if (result.changes > 0) {
148
- console.log(` Recategorized ${result.changes} txn(s): ${rule.label}`);
148
+ console.log(` Recategorized ${result.changes} txn(s): ${rule.label || rule.match_pattern}`);
149
149
  totalRecat += result.changes;
150
150
  }
151
151
  }
@@ -406,6 +406,15 @@ export function categoryLabel(cat) {
406
406
  GOVERNMENT_AND_NON_PROFIT: "Gov/Nonprofit",
407
407
  MEDICAL: "Medical",
408
408
  BANK_FEES: "Bank Fees",
409
+ EDUCATION: "Education",
410
+ INSURANCE: "Insurance",
411
+ BUSINESS: "Business",
412
+ INCOME: "Income",
413
+ TRANSFER_IN: "Transfer In",
414
+ TRANSFER_OUT: "Transfer Out",
415
+ HOME_IMPROVEMENT: "Home Improvement",
416
+ TRAVEL: "Travel",
417
+ OTHER: "Other",
409
418
  };
410
- return labels[cat] || cat;
419
+ return labels[cat] || cat.split("_").map(w => w.charAt(0) + w.slice(1).toLowerCase()).join(" ");
411
420
  }
package/dist/server.js CHANGED
@@ -75,7 +75,15 @@ export function startLinkServer() {
75
75
  }
76
76
  catch (error) {
77
77
  console.error("Link token error:", error.message);
78
- res.status(500).json({ error: "Failed to create link token" });
78
+ const plaidStatus = error?.response?.status;
79
+ if (plaidStatus === 400 || plaidStatus === 401 || plaidStatus === 403) {
80
+ res.status(500).json({
81
+ error: "Plaid credentials error — make sure you're using production (not sandbox) keys. Check PLAID_CLIENT_ID and PLAID_SECRET in ~/.ray/config.json.",
82
+ });
83
+ }
84
+ else {
85
+ res.status(500).json({ error: "Failed to create link token: " + (error.message || "unknown error") });
86
+ }
79
87
  }
80
88
  });
81
89
  // Exchange public token
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ray-finance",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Local-first CLI that turns your bank data into a personal AI financial advisor",
5
5
  "type": "module",
6
6
  "license": "MIT",