ray-finance 0.1.0

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 (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -0
  3. package/dist/ai/agent.d.ts +2 -0
  4. package/dist/ai/agent.js +93 -0
  5. package/dist/ai/audit.d.ts +3 -0
  6. package/dist/ai/audit.js +6 -0
  7. package/dist/ai/context.d.ts +6 -0
  8. package/dist/ai/context.js +93 -0
  9. package/dist/ai/insights.d.ts +3 -0
  10. package/dist/ai/insights.js +401 -0
  11. package/dist/ai/memory.d.ts +14 -0
  12. package/dist/ai/memory.js +12 -0
  13. package/dist/ai/redactor.d.ts +2 -0
  14. package/dist/ai/redactor.js +103 -0
  15. package/dist/ai/system-prompt.d.ts +2 -0
  16. package/dist/ai/system-prompt.js +85 -0
  17. package/dist/ai/tools.d.ts +4 -0
  18. package/dist/ai/tools.js +699 -0
  19. package/dist/alerts/index.d.ts +11 -0
  20. package/dist/alerts/index.js +95 -0
  21. package/dist/auth/anthropic.d.ts +7 -0
  22. package/dist/auth/anthropic.js +85 -0
  23. package/dist/auth/pkce.d.ts +5 -0
  24. package/dist/auth/pkce.js +10 -0
  25. package/dist/auth/store.d.ts +12 -0
  26. package/dist/auth/store.js +51 -0
  27. package/dist/cli/backup.d.ts +2 -0
  28. package/dist/cli/backup.js +94 -0
  29. package/dist/cli/chat.d.ts +1 -0
  30. package/dist/cli/chat.js +203 -0
  31. package/dist/cli/commands.d.ts +13 -0
  32. package/dist/cli/commands.js +201 -0
  33. package/dist/cli/format.d.ts +14 -0
  34. package/dist/cli/format.js +144 -0
  35. package/dist/cli/index.d.ts +2 -0
  36. package/dist/cli/index.js +186 -0
  37. package/dist/cli/scheduler.d.ts +2 -0
  38. package/dist/cli/scheduler.js +114 -0
  39. package/dist/cli/setup.d.ts +1 -0
  40. package/dist/cli/setup.js +174 -0
  41. package/dist/config.d.ts +22 -0
  42. package/dist/config.js +60 -0
  43. package/dist/daily-sync.d.ts +7 -0
  44. package/dist/daily-sync.js +109 -0
  45. package/dist/db/connection.d.ts +5 -0
  46. package/dist/db/connection.js +45 -0
  47. package/dist/db/encryption.d.ts +3 -0
  48. package/dist/db/encryption.js +35 -0
  49. package/dist/db/helpers.d.ts +16 -0
  50. package/dist/db/helpers.js +45 -0
  51. package/dist/db/schema.d.ts +2 -0
  52. package/dist/db/schema.js +199 -0
  53. package/dist/index.d.ts +1 -0
  54. package/dist/index.js +1 -0
  55. package/dist/plaid/client.d.ts +2 -0
  56. package/dist/plaid/client.js +22 -0
  57. package/dist/plaid/link.d.ts +8 -0
  58. package/dist/plaid/link.js +23 -0
  59. package/dist/plaid/sync.d.ts +18 -0
  60. package/dist/plaid/sync.js +186 -0
  61. package/dist/public/favicon.png +0 -0
  62. package/dist/public/link.html +184 -0
  63. package/dist/public/ray-logo-dark.png +0 -0
  64. package/dist/queries/index.d.ts +163 -0
  65. package/dist/queries/index.js +411 -0
  66. package/dist/scoring/index.d.ts +53 -0
  67. package/dist/scoring/index.js +375 -0
  68. package/dist/server.d.ts +7 -0
  69. package/dist/server.js +172 -0
  70. package/package.json +60 -0
@@ -0,0 +1,201 @@
1
+ import chalk from "chalk";
2
+ import { getDb } from "../db/connection.js";
3
+ import { getNetWorth, getTransactionsFiltered, getBudgetStatuses, getGoals, getCashFlowThisMonth, formatMoney as rawFormatMoney, categoryLabel, } from "../queries/index.js";
4
+ import { getLatestScore, getAchievements, getMonthlySavings } from "../scoring/index.js";
5
+ import { generateAlerts } from "../alerts/index.js";
6
+ import { runDailySync } from "../daily-sync.js";
7
+ import { startLinkServer } from "../server.js";
8
+ import { heading, progressBar, formatMoney, formatMoneyColored, dim } from "./format.js";
9
+ export async function runSync() {
10
+ const ora = (await import("ora")).default;
11
+ const spinner = ora("Syncing transactions...").start();
12
+ try {
13
+ const db = getDb();
14
+ await runDailySync(db);
15
+ spinner.succeed("Sync complete.");
16
+ }
17
+ catch (err) {
18
+ spinner.fail(`Sync failed: ${err.message}`);
19
+ }
20
+ }
21
+ export async function runLink() {
22
+ const open = (await import("open")).default;
23
+ const ora = (await import("ora")).default;
24
+ const { url, waitForComplete, stop } = startLinkServer();
25
+ console.log(`\n${heading("Link Account")}\n`);
26
+ console.log(`Opening Plaid Link in your browser...\n`);
27
+ console.log(dim(` ${url}\n`));
28
+ await open(url);
29
+ const spinner = ora("Waiting for bank connection...").start();
30
+ await waitForComplete();
31
+ stop();
32
+ spinner.succeed("Bank account linked successfully!");
33
+ }
34
+ export function showStatus() {
35
+ const db = getDb();
36
+ const nw = getNetWorth(db);
37
+ const cashFlow = getCashFlowThisMonth(db);
38
+ const score = getLatestScore(db);
39
+ const savings = getMonthlySavings(db);
40
+ const alerts = generateAlerts(db);
41
+ console.log(`\n${heading("Financial Overview")}\n`);
42
+ // Net worth
43
+ const change = nw.prev_net_worth !== null ? nw.net_worth - nw.prev_net_worth : null;
44
+ let nwLine = `Net worth: ${chalk.bold(formatMoney(nw.net_worth))}`;
45
+ if (change !== null) {
46
+ nwLine += ` ${change >= 0 ? chalk.green("+" + rawFormatMoney(change)) : chalk.red(rawFormatMoney(change))} from yesterday`;
47
+ }
48
+ console.log(nwLine);
49
+ console.log(dim(` Assets: ${rawFormatMoney(nw.assets)} Liabilities: ${rawFormatMoney(nw.liabilities)}`));
50
+ if (nw.investments > 0)
51
+ console.log(dim(` Investments: ${rawFormatMoney(nw.investments)} Cash: ${rawFormatMoney(nw.cash)}`));
52
+ // Cash flow
53
+ console.log(`\n${heading("This Month")}`);
54
+ console.log(` Income: ${formatMoneyColored(cashFlow.income)} Expenses: ${formatMoney(cashFlow.expenses)} Net: ${formatMoneyColored(cashFlow.net)}`);
55
+ if (savings.baselineMonth) {
56
+ const savingsColor = savings.saved >= 0 ? chalk.green : chalk.red;
57
+ console.log(` vs ${savings.baselineMonth}: ${savingsColor((savings.saved >= 0 ? "+" : "") + rawFormatMoney(savings.saved))}`);
58
+ }
59
+ // Score
60
+ if (score) {
61
+ console.log(`\n${heading("Daily Score")}`);
62
+ console.log(` ${chalk.bold(String(score.score))}/100 ${progressBar(score.score)}`);
63
+ console.log(dim(` Streaks: ${score.no_restaurant_streak}d no restaurants | ${score.no_shopping_streak}d no shopping | ${score.on_pace_streak}d on pace`));
64
+ }
65
+ // Budgets (brief)
66
+ const budgets = getBudgetStatuses(db);
67
+ if (budgets.length > 0) {
68
+ console.log(`\n${heading("Budgets")}`);
69
+ for (const b of budgets) {
70
+ const status = b.over_budget ? chalk.red("OVER") : `${b.pct_used}%`;
71
+ console.log(` ${b.over_budget ? chalk.red("!") : "•"} ${categoryLabel(b.category)}: ${rawFormatMoney(b.spent)} / ${rawFormatMoney(b.budget)} (${status})`);
72
+ }
73
+ }
74
+ // Alerts
75
+ if (alerts.length > 0) {
76
+ console.log(`\n${heading("Alerts")}`);
77
+ for (const a of alerts) {
78
+ const icon = a.severity === "critical" ? chalk.red("●") : a.severity === "warning" ? chalk.yellow("●") : chalk.blue("●");
79
+ console.log(` ${icon} ${a.message}`);
80
+ }
81
+ }
82
+ console.log();
83
+ }
84
+ export function showTransactions(options = {}) {
85
+ const db = getDb();
86
+ const txns = getTransactionsFiltered(db, {
87
+ limit: options.limit || 20,
88
+ category: options.category,
89
+ merchant: options.merchant,
90
+ });
91
+ if (txns.length === 0) {
92
+ console.log("\nNo transactions found.");
93
+ return;
94
+ }
95
+ console.log(`\n${heading("Recent Transactions")}\n`);
96
+ for (const t of txns) {
97
+ const amount = t.amount > 0 ? chalk.red(rawFormatMoney(t.amount)) : chalk.green(rawFormatMoney(Math.abs(t.amount)));
98
+ const merchant = t.merchant_name || t.name;
99
+ console.log(` ${dim(t.date)} ${amount.padEnd(22)} ${merchant} ${dim(categoryLabel(t.category))}`);
100
+ }
101
+ console.log();
102
+ }
103
+ export async function showSpending(period = "this_month") {
104
+ const db = getDb();
105
+ const { resolvePeriod } = await import("../db/helpers.js");
106
+ const { start, end } = resolvePeriod(period);
107
+ const rows = db.prepare(`SELECT category, SUM(amount) as total, COUNT(*) as count FROM transactions
108
+ WHERE amount > 0 AND date BETWEEN ? AND ? AND pending = 0
109
+ AND category NOT IN ('TRANSFER_OUT', 'TRANSFER_IN', 'LOAN_PAYMENTS')
110
+ GROUP BY category ORDER BY total DESC`).all(start, end);
111
+ if (rows.length === 0) {
112
+ console.log("\nNo spending found for that period.");
113
+ return;
114
+ }
115
+ const grandTotal = rows.reduce((s, r) => s + r.total, 0);
116
+ console.log(`\n${heading("Spending")} ${dim(`${start} to ${end}`)}`);
117
+ console.log(` Total: ${chalk.bold(rawFormatMoney(grandTotal))}\n`);
118
+ for (const r of rows) {
119
+ const pct = Math.round((r.total / grandTotal) * 100);
120
+ console.log(` ${categoryLabel(r.category).padEnd(20)} ${rawFormatMoney(r.total).padStart(10)} ${progressBar(pct, 15)} ${dim(`${r.count} txns`)}`);
121
+ }
122
+ console.log();
123
+ }
124
+ export function showBudgets() {
125
+ const db = getDb();
126
+ const budgets = getBudgetStatuses(db);
127
+ if (budgets.length === 0) {
128
+ console.log("\nNo budgets set up. Use the chat to create budgets (e.g., 'set a budget for food at $500').");
129
+ return;
130
+ }
131
+ const now = new Date();
132
+ const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
133
+ const monthPct = Math.round((now.getDate() / daysInMonth) * 100);
134
+ console.log(`\n${heading("Budgets")} ${dim(`${monthPct}% through the month`)}\n`);
135
+ for (const b of budgets) {
136
+ const label = categoryLabel(b.category).padEnd(20);
137
+ const spent = rawFormatMoney(b.spent).padStart(10);
138
+ const limit = rawFormatMoney(b.budget);
139
+ const bar = progressBar(b.pct_used, 15);
140
+ const over = b.over_budget ? chalk.red(` ${rawFormatMoney(Math.abs(b.remaining))} over`) : "";
141
+ console.log(` ${label} ${spent} / ${limit} ${bar}${over}`);
142
+ }
143
+ console.log();
144
+ }
145
+ export function showGoals() {
146
+ const db = getDb();
147
+ const goals = getGoals(db);
148
+ if (goals.length === 0) {
149
+ console.log("\nNo goals set up. Use the chat to create goals (e.g., 'set a goal for emergency fund at $10000').");
150
+ return;
151
+ }
152
+ console.log(`\n${heading("Goals")}\n`);
153
+ for (const g of goals) {
154
+ console.log(` ${chalk.bold(g.name)}`);
155
+ console.log(` ${rawFormatMoney(g.current)} / ${rawFormatMoney(g.target)} ${progressBar(g.progress_pct, 20)}`);
156
+ if (g.target_date)
157
+ console.log(dim(` Target: ${g.target_date}`));
158
+ if (g.monthly_needed > 0)
159
+ console.log(dim(` Need: ${rawFormatMoney(g.monthly_needed)}/mo`));
160
+ }
161
+ console.log();
162
+ }
163
+ export function showScore() {
164
+ const db = getDb();
165
+ const score = getLatestScore(db);
166
+ const achievements = getAchievements(db);
167
+ if (!score) {
168
+ console.log("\nNo daily scores yet. Run 'ray sync' first.");
169
+ return;
170
+ }
171
+ console.log(`\n${heading("Daily Score")} ${dim(score.date)}\n`);
172
+ console.log(` Score: ${chalk.bold(String(score.score))}/100 ${progressBar(score.score, 25)}`);
173
+ console.log(` Spend: ${rawFormatMoney(score.total_spend)}${score.zero_spend ? chalk.green(" Zero-spend day!") : ""}`);
174
+ console.log(` Restaurants: ${score.restaurant_count} Shopping: ${score.shopping_count}`);
175
+ console.log();
176
+ console.log(` ${heading("Streaks")}`);
177
+ console.log(` No restaurants: ${chalk.bold(String(score.no_restaurant_streak))} days`);
178
+ console.log(` No shopping: ${chalk.bold(String(score.no_shopping_streak))} days`);
179
+ console.log(` On pace: ${chalk.bold(String(score.on_pace_streak))} days`);
180
+ if (achievements.length > 0) {
181
+ console.log(`\n ${heading("Achievements")}`);
182
+ for (const a of achievements) {
183
+ console.log(` 🏆 ${chalk.bold(a.name)} — ${a.description}`);
184
+ }
185
+ }
186
+ console.log();
187
+ }
188
+ export function showAlerts() {
189
+ const db = getDb();
190
+ const alerts = generateAlerts(db);
191
+ if (alerts.length === 0) {
192
+ console.log("\nNo active alerts. Everything looks good!");
193
+ return;
194
+ }
195
+ console.log(`\n${heading("Alerts")}\n`);
196
+ for (const a of alerts) {
197
+ const icon = a.severity === "critical" ? chalk.red("●") : a.severity === "warning" ? chalk.yellow("●") : chalk.blue("●");
198
+ console.log(` ${icon} ${a.message}`);
199
+ }
200
+ console.log();
201
+ }
@@ -0,0 +1,14 @@
1
+ export declare function formatMoney(n: number): string;
2
+ export declare function formatMoneyColored(n: number, invert?: boolean): string;
3
+ export declare function progressBar(pct: number, width?: number): string;
4
+ export declare function heading(text: string): string;
5
+ export declare function dim(text: string): string;
6
+ export declare function padColumns(rows: [string, string][], gap?: number): string;
7
+ export declare function banner(): string;
8
+ export declare function helpScreen(commands: {
9
+ name: string;
10
+ desc: string;
11
+ }[]): string;
12
+ /** Colorize AI response text for the terminal */
13
+ export declare function formatResponse(text: string): string;
14
+ export declare const DISCLAIMER: string;
@@ -0,0 +1,144 @@
1
+ import chalk from "chalk";
2
+ export function formatMoney(n) {
3
+ const abs = Math.abs(n);
4
+ const formatted = "$" + abs.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
5
+ if (n < 0)
6
+ return chalk.red("-" + formatted);
7
+ return formatted;
8
+ }
9
+ export function formatMoneyColored(n, invert = false) {
10
+ const formatted = formatMoney(n);
11
+ if (invert)
12
+ return n <= 0 ? chalk.green(formatted) : chalk.red(formatted);
13
+ return n >= 0 ? chalk.green(formatted) : chalk.red(formatted);
14
+ }
15
+ export function progressBar(pct, width = 20) {
16
+ const clamped = Math.max(0, Math.min(100, pct));
17
+ const filled = Math.round((clamped / 100) * width);
18
+ const empty = width - filled;
19
+ const color = pct > 100 ? chalk.red : pct > 80 ? chalk.yellow : chalk.green;
20
+ return color("█".repeat(filled)) + chalk.gray("░".repeat(empty)) + ` ${Math.round(pct)}%`;
21
+ }
22
+ export function heading(text) {
23
+ return chalk.bold.underline(text);
24
+ }
25
+ export function dim(text) {
26
+ return chalk.dim(text);
27
+ }
28
+ export function padColumns(rows, gap = 2) {
29
+ const maxLeft = Math.max(...rows.map(r => stripAnsi(r[0]).length));
30
+ return rows.map(([left, right]) => {
31
+ const pad = maxLeft - stripAnsi(left).length + gap;
32
+ return left + " ".repeat(pad) + right;
33
+ }).join("\n");
34
+ }
35
+ // Strip ANSI escape codes for length calculation
36
+ function stripAnsi(str) {
37
+ return str.replace(/\x1b\[[0-9;]*m/g, "");
38
+ }
39
+ // ─── CLI Banner ─── //
40
+ const BLOCK_CHARS = "░▒▓█";
41
+ function noiseRow(width, seed) {
42
+ let row = "";
43
+ for (let i = 0; i < width; i++) {
44
+ const v = Math.abs(Math.sin(seed * 9301 + i * 4973 + 1997)) * BLOCK_CHARS.length;
45
+ row += BLOCK_CHARS[Math.min(Math.floor(v), BLOCK_CHARS.length - 1)];
46
+ }
47
+ return row;
48
+ }
49
+ const LOGO_LINES = [
50
+ "▌▙▀▖▝▀▖▌ ▌▐",
51
+ "▌▌ ▞▀▌▝▀▌▐",
52
+ "▌▘ ▝▀▘▗▄▘▐",
53
+ ];
54
+ function blendLogoRow(noise, logoLine, offset) {
55
+ const chars = noise.split("");
56
+ for (let i = 0; i < logoLine.length; i++) {
57
+ chars[offset + i] = logoLine[i];
58
+ }
59
+ return chars.join("");
60
+ }
61
+ function box(label, lines) {
62
+ const cols = process.stdout.columns || 100;
63
+ const inner = cols - 4;
64
+ const top = `┌─── ${label} ${"─".repeat(Math.max(0, inner - label.length - 5))}┐`;
65
+ const bot = `└${"─".repeat(inner + 2)}┘`;
66
+ const pad = `│${" ".repeat(inner + 2)}│`;
67
+ const body = lines.map((l) => {
68
+ const vis = stripAnsi(l).length;
69
+ return `│ ${l}${" ".repeat(Math.max(0, inner - vis))}│`;
70
+ });
71
+ return [top, pad, ...body, pad, bot].join("\n");
72
+ }
73
+ export function banner() {
74
+ const cols = process.stdout.columns || 100;
75
+ const width = Math.min(cols, 120);
76
+ // Generate noise rows with logo blended in
77
+ const logoWidth = LOGO_LINES[0].length;
78
+ const logoOffset = 1; // left-aligned with small indent
79
+ const headerLines = [];
80
+ // 1 noise row above logo
81
+ headerLines.push(chalk.dim(noiseRow(width, 0)));
82
+ // 3 rows with logo blended
83
+ for (let i = 0; i < LOGO_LINES.length; i++) {
84
+ const noise = noiseRow(width, i + 1);
85
+ const blended = blendLogoRow(noise, LOGO_LINES[i], logoOffset);
86
+ // Color the logo portion bold white, rest dim
87
+ const before = chalk.dim(blended.slice(0, logoOffset));
88
+ const logoLine = LOGO_LINES[i];
89
+ const logo = chalk.black(logoLine[0]) + chalk.bold.white(logoLine.slice(1, -1)) + chalk.black(logoLine[logoLine.length - 1]);
90
+ const after = chalk.dim(blended.slice(logoOffset + logoWidth));
91
+ headerLines.push(before + logo + after);
92
+ }
93
+ // 1 noise row below logo
94
+ headerLines.push(chalk.dim(noiseRow(width, LOGO_LINES.length + 1)));
95
+ return headerLines.join("\n");
96
+ }
97
+ export function helpScreen(commands) {
98
+ const sections = [];
99
+ sections.push(banner());
100
+ sections.push("");
101
+ sections.push(box("Usage", [
102
+ "ray <command> [OPTIONS]",
103
+ "ray Start chat session",
104
+ ]));
105
+ sections.push("");
106
+ const nameWidth = Math.max(...commands.map((c) => c.name.length));
107
+ const cmdLines = commands.map((c) => `${chalk.white(c.name.padEnd(nameWidth))} ${chalk.dim(c.desc)}`);
108
+ sections.push(box("Commands", cmdLines));
109
+ sections.push("");
110
+ sections.push(box("Options", [
111
+ `${chalk.white("--version".padEnd(nameWidth))} ${chalk.dim("Show the version and exit")}`,
112
+ `${chalk.white("--help".padEnd(nameWidth))} ${chalk.dim("Show this help screen")}`,
113
+ ]));
114
+ sections.push("");
115
+ sections.push(chalk.dim(DISCLAIMER));
116
+ return sections.join("\n");
117
+ }
118
+ /** Colorize AI response text for the terminal */
119
+ export function formatResponse(text) {
120
+ return text
121
+ .split("\n")
122
+ .map((line) => {
123
+ // Section headers: ## Header or ### Header
124
+ if (/^#{1,3}\s+/.test(line)) {
125
+ return chalk.bold(line.replace(/^#{1,3}\s+/, ""));
126
+ }
127
+ // Bold: **text**
128
+ line = line.replace(/\*\*(.+?)\*\*/g, (_, t) => chalk.bold(t));
129
+ // Money amounts: $1,234 or $1,234.56 or -$500
130
+ line = line.replace(/-?\$[\d,]+(?:\.\d{1,2})?/g, (m) => {
131
+ return m.startsWith("-") ? chalk.red(m) : chalk.green(m);
132
+ });
133
+ // Percentages
134
+ line = line.replace(/(\d+(?:\.\d+)?%)/g, (m) => chalk.yellow(m));
135
+ // Bullet points
136
+ if (/^\s*[-•]\s/.test(line)) {
137
+ line = line.replace(/^(\s*)([-•])(\s)/, (_, sp, b, s) => sp + chalk.dim(b) + s);
138
+ }
139
+ return line;
140
+ })
141
+ .join("\n");
142
+ }
143
+ export const DISCLAIMER = "Ray is an AI tool, not a licensed financial advisor. Output is informational, " +
144
+ "may be inaccurate, and does not constitute financial advice.";
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { createRequire } from "module";
4
+ import { config, isConfigured, useManaged, RAY_PROXY_BASE } from "../config.js";
5
+ import { helpScreen } from "./format.js";
6
+ const require = createRequire(import.meta.url);
7
+ const { version } = require("../../package.json");
8
+ const program = new Command();
9
+ program
10
+ .name("ray")
11
+ .description("Personal finance AI assistant")
12
+ .version(version)
13
+ .addHelpCommand(false)
14
+ .action(async () => {
15
+ if (!isConfigured()) {
16
+ console.log("Ray is not configured yet. Running setup...\n");
17
+ const { runSetup } = await import("./setup.js");
18
+ await runSetup();
19
+ return;
20
+ }
21
+ const { startChat } = await import("./chat.js");
22
+ await startChat();
23
+ });
24
+ program
25
+ .command("setup")
26
+ .description("Configure Ray (API keys, preferences)")
27
+ .action(async () => {
28
+ const { runSetup } = await import("./setup.js");
29
+ await runSetup();
30
+ });
31
+ program
32
+ .command("sync")
33
+ .description("Sync transactions from linked banks")
34
+ .action(async () => {
35
+ ensureConfigured();
36
+ const { runSync } = await import("./commands.js");
37
+ await runSync();
38
+ });
39
+ program
40
+ .command("link")
41
+ .description("Link a new financial account via Plaid")
42
+ .action(async () => {
43
+ ensureConfigured();
44
+ if (!useManaged() && (!config.plaidClientId || !config.plaidSecret)) {
45
+ console.error("Plaid credentials not configured. Run 'ray setup' to add them, or use a Ray API key for easy setup.");
46
+ process.exit(1);
47
+ }
48
+ const { runLink } = await import("./commands.js");
49
+ await runLink();
50
+ });
51
+ program
52
+ .command("status")
53
+ .description("Show financial overview")
54
+ .action(async () => {
55
+ ensureConfigured();
56
+ const { showStatus } = await import("./commands.js");
57
+ showStatus();
58
+ });
59
+ program
60
+ .command("transactions")
61
+ .description("Show recent transactions")
62
+ .option("-n, --limit <number>", "Number of transactions", "20")
63
+ .option("-c, --category <category>", "Filter by category")
64
+ .option("-m, --merchant <name>", "Filter by merchant")
65
+ .action(async (opts) => {
66
+ ensureConfigured();
67
+ const { showTransactions } = await import("./commands.js");
68
+ showTransactions({ limit: Number(opts.limit), category: opts.category, merchant: opts.merchant });
69
+ });
70
+ program
71
+ .command("spending")
72
+ .description("Show spending breakdown")
73
+ .argument("[period]", "Period: this_month, last_month, last_30, last_90", "this_month")
74
+ .action(async (period) => {
75
+ ensureConfigured();
76
+ const { showSpending } = await import("./commands.js");
77
+ await showSpending(period);
78
+ });
79
+ program
80
+ .command("budgets")
81
+ .description("Show budget statuses")
82
+ .action(async () => {
83
+ ensureConfigured();
84
+ const { showBudgets } = await import("./commands.js");
85
+ showBudgets();
86
+ });
87
+ program
88
+ .command("goals")
89
+ .description("Show financial goals")
90
+ .action(async () => {
91
+ ensureConfigured();
92
+ const { showGoals } = await import("./commands.js");
93
+ showGoals();
94
+ });
95
+ program
96
+ .command("score")
97
+ .description("Show daily financial score and streaks")
98
+ .action(async () => {
99
+ ensureConfigured();
100
+ const { showScore } = await import("./commands.js");
101
+ showScore();
102
+ });
103
+ program
104
+ .command("alerts")
105
+ .description("Show financial alerts")
106
+ .action(async () => {
107
+ ensureConfigured();
108
+ const { showAlerts } = await import("./commands.js");
109
+ showAlerts();
110
+ });
111
+ program
112
+ .command("export")
113
+ .description("Export user data (goals, budgets, memories, context) to a backup file")
114
+ .argument("[path]", "Output file path", undefined)
115
+ .action(async (path) => {
116
+ ensureConfigured();
117
+ const { runExport } = await import("./backup.js");
118
+ runExport(path);
119
+ });
120
+ program
121
+ .command("import")
122
+ .description("Restore user data from a backup file")
123
+ .argument("<path>", "Backup file path")
124
+ .action(async (path) => {
125
+ ensureConfigured();
126
+ const { runImport } = await import("./backup.js");
127
+ runImport(path);
128
+ });
129
+ program
130
+ .command("billing")
131
+ .description("Manage your Ray subscription")
132
+ .action(async () => {
133
+ ensureConfigured();
134
+ if (!useManaged()) {
135
+ console.log("You're using self-hosted keys. No subscription to manage.");
136
+ return;
137
+ }
138
+ const open = (await import("open")).default;
139
+ console.log("Opening billing portal...");
140
+ try {
141
+ const resp = await fetch(`${RAY_PROXY_BASE.replace("/v1", "")}/stripe/portal`, {
142
+ method: "POST",
143
+ headers: {
144
+ "content-type": "application/json",
145
+ "Authorization": `Bearer ${config.rayApiKey}`,
146
+ },
147
+ });
148
+ const { url } = await resp.json();
149
+ // Only open URLs from trusted domains
150
+ const parsed = new URL(url);
151
+ if (!parsed.hostname.endsWith("stripe.com") && !parsed.hostname.endsWith("rayfinance.app")) {
152
+ console.error("Unexpected billing URL. Visit https://rayfinance.app/billing");
153
+ }
154
+ else {
155
+ await open(url);
156
+ }
157
+ }
158
+ catch {
159
+ console.error("Could not open billing portal. Visit https://rayfinance.app/billing");
160
+ }
161
+ });
162
+ function ensureConfigured() {
163
+ if (!isConfigured()) {
164
+ console.error("Ray is not configured. Run 'ray setup' first.");
165
+ process.exit(1);
166
+ }
167
+ }
168
+ // Custom help screen
169
+ program.configureHelp({
170
+ formatHelp: () => helpScreen([
171
+ { name: "setup", desc: "Configure Ray (API keys, preferences)" },
172
+ { name: "link", desc: "Link a new financial account via Plaid" },
173
+ { name: "sync", desc: "Sync transactions from linked banks" },
174
+ { name: "status", desc: "Show financial overview" },
175
+ { name: "transactions", desc: "Show recent transactions" },
176
+ { name: "spending", desc: "Show spending breakdown" },
177
+ { name: "budgets", desc: "Show budget statuses" },
178
+ { name: "goals", desc: "Show financial goals" },
179
+ { name: "score", desc: "Show daily financial score and streaks" },
180
+ { name: "alerts", desc: "Show financial alerts" },
181
+ { name: "export", desc: "Export data to a backup file" },
182
+ { name: "import", desc: "Restore data from a backup file" },
183
+ { name: "billing", desc: "Manage your Ray subscription" },
184
+ ]),
185
+ });
186
+ program.parse();
@@ -0,0 +1,2 @@
1
+ export declare function installSyncSchedule(time: string): void;
2
+ export declare function uninstallSyncSchedule(): void;
@@ -0,0 +1,114 @@
1
+ import { platform } from "os";
2
+ import { resolve, dirname } from "path";
3
+ import { existsSync, writeFileSync, unlinkSync, mkdirSync } from "fs";
4
+ import { execSync } from "child_process";
5
+ import { homedir } from "os";
6
+ const PLIST_LABEL = "com.ray-finance.daily-sync";
7
+ function getPlistPath() {
8
+ return resolve(homedir(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
9
+ }
10
+ function getRayBin() {
11
+ // Use the installed `ray` binary if available, otherwise fall back to npx
12
+ try {
13
+ return execSync("which ray", { encoding: "utf-8" }).trim();
14
+ }
15
+ catch {
16
+ return resolve(dirname(process.execPath), "npx");
17
+ }
18
+ }
19
+ function installLaunchd(hour, minute) {
20
+ const rayBin = getRayBin();
21
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
22
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
23
+ <plist version="1.0">
24
+ <dict>
25
+ \t<key>Label</key>
26
+ \t<string>${PLIST_LABEL}</string>
27
+ \t<key>ProgramArguments</key>
28
+ \t<array>
29
+ \t\t<string>${rayBin}</string>
30
+ \t\t<string>sync</string>
31
+ \t</array>
32
+ \t<key>StartCalendarInterval</key>
33
+ \t<dict>
34
+ \t\t<key>Hour</key>
35
+ \t\t<integer>${hour}</integer>
36
+ \t\t<key>Minute</key>
37
+ \t\t<integer>${minute}</integer>
38
+ \t</dict>
39
+ \t<key>StandardOutPath</key>
40
+ \t<string>${resolve(homedir(), ".ray", "sync.log")}</string>
41
+ \t<key>StandardErrorPath</key>
42
+ \t<string>${resolve(homedir(), ".ray", "sync.log")}</string>
43
+ </dict>
44
+ </plist>`;
45
+ const plistPath = getPlistPath();
46
+ const dir = dirname(plistPath);
47
+ if (!existsSync(dir))
48
+ mkdirSync(dir, { recursive: true });
49
+ // Unload existing if present
50
+ if (existsSync(plistPath)) {
51
+ try {
52
+ execSync(`launchctl unload "${plistPath}" 2>/dev/null`);
53
+ }
54
+ catch { }
55
+ }
56
+ writeFileSync(plistPath, plist);
57
+ execSync(`launchctl load "${plistPath}"`);
58
+ }
59
+ function uninstallLaunchd() {
60
+ const plistPath = getPlistPath();
61
+ if (existsSync(plistPath)) {
62
+ try {
63
+ execSync(`launchctl unload "${plistPath}" 2>/dev/null`);
64
+ }
65
+ catch { }
66
+ unlinkSync(plistPath);
67
+ }
68
+ }
69
+ const CRON_MARKER = "# ray-finance daily sync";
70
+ function installCron(hour, minute) {
71
+ const rayBin = getRayBin();
72
+ const cronLine = `${minute} ${hour} * * * ${rayBin} sync >> ${resolve(homedir(), ".ray", "sync.log")} 2>&1 ${CRON_MARKER}`;
73
+ // Remove existing ray cron entry, add new one
74
+ try {
75
+ const existing = execSync("crontab -l 2>/dev/null", { encoding: "utf-8" });
76
+ const filtered = existing.split("\n").filter(l => !l.includes(CRON_MARKER)).join("\n");
77
+ const updated = filtered.trimEnd() + "\n" + cronLine + "\n";
78
+ execSync(`echo ${JSON.stringify(updated)} | crontab -`);
79
+ }
80
+ catch {
81
+ // No existing crontab
82
+ execSync(`echo ${JSON.stringify(cronLine + "\n")} | crontab -`);
83
+ }
84
+ }
85
+ function uninstallCron() {
86
+ try {
87
+ const existing = execSync("crontab -l 2>/dev/null", { encoding: "utf-8" });
88
+ const filtered = existing.split("\n").filter(l => !l.includes(CRON_MARKER)).join("\n").trimEnd();
89
+ if (filtered) {
90
+ execSync(`echo ${JSON.stringify(filtered + "\n")} | crontab -`);
91
+ }
92
+ else {
93
+ execSync("crontab -r 2>/dev/null");
94
+ }
95
+ }
96
+ catch { }
97
+ }
98
+ export function installSyncSchedule(time) {
99
+ const [hour, minute] = time.split(":").map(Number);
100
+ if (platform() === "darwin") {
101
+ installLaunchd(hour, minute);
102
+ }
103
+ else {
104
+ installCron(hour, minute);
105
+ }
106
+ }
107
+ export function uninstallSyncSchedule() {
108
+ if (platform() === "darwin") {
109
+ uninstallLaunchd();
110
+ }
111
+ else {
112
+ uninstallCron();
113
+ }
114
+ }
@@ -0,0 +1 @@
1
+ export declare function runSetup(): Promise<void>;