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.
- package/LICENSE +21 -0
- package/README.md +195 -0
- package/dist/ai/agent.d.ts +2 -0
- package/dist/ai/agent.js +93 -0
- package/dist/ai/audit.d.ts +3 -0
- package/dist/ai/audit.js +6 -0
- package/dist/ai/context.d.ts +6 -0
- package/dist/ai/context.js +93 -0
- package/dist/ai/insights.d.ts +3 -0
- package/dist/ai/insights.js +401 -0
- package/dist/ai/memory.d.ts +14 -0
- package/dist/ai/memory.js +12 -0
- package/dist/ai/redactor.d.ts +2 -0
- package/dist/ai/redactor.js +103 -0
- package/dist/ai/system-prompt.d.ts +2 -0
- package/dist/ai/system-prompt.js +85 -0
- package/dist/ai/tools.d.ts +4 -0
- package/dist/ai/tools.js +699 -0
- package/dist/alerts/index.d.ts +11 -0
- package/dist/alerts/index.js +95 -0
- package/dist/auth/anthropic.d.ts +7 -0
- package/dist/auth/anthropic.js +85 -0
- package/dist/auth/pkce.d.ts +5 -0
- package/dist/auth/pkce.js +10 -0
- package/dist/auth/store.d.ts +12 -0
- package/dist/auth/store.js +51 -0
- package/dist/cli/backup.d.ts +2 -0
- package/dist/cli/backup.js +94 -0
- package/dist/cli/chat.d.ts +1 -0
- package/dist/cli/chat.js +203 -0
- package/dist/cli/commands.d.ts +13 -0
- package/dist/cli/commands.js +201 -0
- package/dist/cli/format.d.ts +14 -0
- package/dist/cli/format.js +144 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +186 -0
- package/dist/cli/scheduler.d.ts +2 -0
- package/dist/cli/scheduler.js +114 -0
- package/dist/cli/setup.d.ts +1 -0
- package/dist/cli/setup.js +174 -0
- package/dist/config.d.ts +22 -0
- package/dist/config.js +60 -0
- package/dist/daily-sync.d.ts +7 -0
- package/dist/daily-sync.js +109 -0
- package/dist/db/connection.d.ts +5 -0
- package/dist/db/connection.js +45 -0
- package/dist/db/encryption.d.ts +3 -0
- package/dist/db/encryption.js +35 -0
- package/dist/db/helpers.d.ts +16 -0
- package/dist/db/helpers.js +45 -0
- package/dist/db/schema.d.ts +2 -0
- package/dist/db/schema.js +199 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/plaid/client.d.ts +2 -0
- package/dist/plaid/client.js +22 -0
- package/dist/plaid/link.d.ts +8 -0
- package/dist/plaid/link.js +23 -0
- package/dist/plaid/sync.d.ts +18 -0
- package/dist/plaid/sync.js +186 -0
- package/dist/public/favicon.png +0 -0
- package/dist/public/link.html +184 -0
- package/dist/public/ray-logo-dark.png +0 -0
- package/dist/queries/index.d.ts +163 -0
- package/dist/queries/index.js +411 -0
- package/dist/scoring/index.d.ts +53 -0
- package/dist/scoring/index.js +375 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.js +172 -0
- 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,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,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>;
|