diff options
| author | ivar <i@oiee.no> | 2026-03-15 22:53:10 +0100 |
|---|---|---|
| committer | ivar <i@oiee.no> | 2026-03-15 22:53:10 +0100 |
| commit | 1aa60ed7f0b0af6c55df4a32bf2620934df63e6d (patch) | |
| tree | deacb84cd3770ce139ecbc693bf2b987954c2347 /cli/src | |
| parent | 69448e29a85cad3a94b3be3ad33efbc52764528f (diff) | |
| download | sparebank1-actualbudget-1aa60ed7f0b0af6c55df4a32bf2620934df63e6d.tar.xz sparebank1-actualbudget-1aa60ed7f0b0af6c55df4a32bf2620934df63e6d.zip | |
Diffstat (limited to 'cli/src')
| -rw-r--r-- | cli/src/commands/ai.ts | 447 | ||||
| -rw-r--r-- | cli/src/commands/init.ts | 13 | ||||
| -rw-r--r-- | cli/src/config.ts | 1 | ||||
| -rw-r--r-- | cli/src/index.ts | 7 |
4 files changed, 466 insertions, 2 deletions
diff --git a/cli/src/commands/ai.ts b/cli/src/commands/ai.ts new file mode 100644 index 0000000..3f77d39 --- /dev/null +++ b/cli/src/commands/ai.ts @@ -0,0 +1,447 @@ +import Anthropic from "@anthropic-ai/sdk" +import * as actualApi from "@actual-app/api" +import * as p from "@clack/prompts" +import { loadConfig } from "../config" +import { initActual } from "../actual" +import type { Config } from "../config" + +const api = actualApi as any + +export async function aiCommand(args: string[]) { + const config = loadConfig() + + if (!config.anthropicApiKey) { + console.error("Error: No Anthropic API key configured. Run `sb1-actual init` to add one.") + process.exit(1) + } + + const subcommand = args[0] + if (!subcommand) { + printHelp() + process.exit(1) + } + + const client = new Anthropic({ apiKey: config.anthropicApiKey }) + + p.intro(`sb1-actual ai ${subcommand}`) + + await initActual(config.actual) + + switch (subcommand) { + case "consolidate-payees": + await consolidatePayees(client) + break + case "summarize": + await summarize(client, config, args.slice(1)) + break + case "insights": + await insights(client, config, args.slice(1)) + break + case "add-notes": + await addNotes(client, config, args.slice(1)) + break + case "query": + await query(client, config, args.slice(1)) + break + default: + console.error(`Unknown subcommand: ${subcommand}`) + printHelp() + process.exit(1) + } +} + +function printHelp() { + console.log("Usage: sb1-actual ai <subcommand> [options]") + console.log("") + console.log("Subcommands:") + console.log(" query [--since=YYYY-MM-DD] [question] Ask a free-form question about your data") + console.log(" consolidate-payees Identify and merge duplicate/similar payees") + console.log(" summarize [--since=YYYY-MM-DD] Summarize spending by category and payee") + console.log(" insights [--since=YYYY-MM-DD] Get AI insights into spending patterns") + console.log(" add-notes [--since=YYYY-MM-DD] Add descriptive notes to transactions") + console.log(" add-notes --dry-run Preview notes without applying them") +} + +function extractJson(text: string): any { + // Strip markdown code fences: ```json ... ``` or ``` ... ``` + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/) + const raw = fenced ? fenced[1] : text + // Find the first [...] or {...} block + const match = raw.match(/(\[[\s\S]*\]|\{[\s\S]*\})/) + if (!match) throw new Error("No JSON found in response") + return JSON.parse(match[1]) +} + +function defaultSince(): string { + const d = new Date() + d.setDate(d.getDate() - 90) + return d.toISOString().split("T")[0] +} + +async function loadTransactions(config: Config, since?: string) { + const startDate = since ?? defaultSince() + const allTxns: any[] = [] + for (const mapping of config.mappings) { + const txns = await api.getTransactions(mapping.actualId, startDate, undefined) + allTxns.push(...txns) + } + return allTxns +} + +async function resolveNames(transactions: any[]) { + const payees: Array<{ id: string; name: string }> = await api.getPayees() + const categories: Array<{ id: string; name: string }> = await api.getCategories() + const payeeMap = new Map(payees.map(p => [p.id, p.name])) + const categoryMap = new Map(categories.map(c => [c.id, c.name])) + + return { + payees, + payeeMap, + categoryMap, + formatted: transactions + .filter(t => !t.is_parent) + .map(t => ({ + id: t.id, + date: t.date, + amount: (t.amount / 100).toFixed(2), + payee: payeeMap.get(t.payee) ?? t.imported_payee ?? "Unknown", + category: categoryMap.get(t.category) ?? "Uncategorized", + notes: t.notes ?? "", + })), + } +} + +// ─── consolidate-payees ──────────────────────────────────────────────────── + +async function consolidatePayees(client: Anthropic) { + const spinner = p.spinner() + spinner.start("Loading payees...") + + const payees: Array<{ id: string; name: string; transfer_acct?: string }> = await api.getPayees() + const realPayees = payees.filter(p => !p.transfer_acct && p.name?.trim()) + + spinner.stop(`Loaded ${realPayees.length} payees`) + + if (realPayees.length === 0) { + p.outro("No payees found.") + return + } + + spinner.start("Analyzing payees with Claude...") + + const payeeList = realPayees.map(p => `${p.id}: ${p.name}`).join("\n") + + const response = await client.messages.create({ + model: "claude-haiku-4-5", + max_tokens: 4096, + tools: [{ + name: "submit_merge_groups", + description: "Submit groups of payees that should be merged together", + input_schema: { + type: "object" as const, + properties: { + groups: { + type: "array", + items: { + type: "object", + properties: { + keepId: { type: "string", description: "ID of the payee whose name to keep" }, + keepName: { type: "string", description: "The canonical display name" }, + mergeIds: { type: "array", items: { type: "string" }, description: "IDs to merge in" }, + }, + required: ["keepId", "keepName", "mergeIds"], + }, + description: "Merge groups. Empty array if no duplicates found.", + }, + }, + required: ["groups"], + }, + }], + tool_choice: { type: "tool", name: "submit_merge_groups" }, + system: "You are a personal finance assistant. Identify payees that represent the same merchant (e.g. 'Rema 1000', 'REMA 1000 OSLO', 'REMA1000'). Only group clear duplicates/variants.", + messages: [{ + role: "user", + content: `Payees:\n${payeeList}`, + }] + }) + + spinner.stop("Analysis complete") + + const toolUse = response.content.find(b => b.type === "tool_use") as any + const groups: Array<{ keepId: string; keepName: string; mergeIds: string[] }> = + toolUse?.input?.groups?.filter((g: any) => g.mergeIds?.length > 0) ?? [] + + if (groups.length === 0) { + p.outro("No duplicate or similar payees found.") + return + } + + console.log(`\nFound ${groups.length} merge group(s):\n`) + for (const group of groups) { + const keep = realPayees.find(p => p.id === group.keepId) + const merging = group.mergeIds.map(id => realPayees.find(p => p.id === id)?.name ?? id) + console.log(` Keep: "${keep?.name ?? group.keepName}"`) + console.log(` Merge: ${merging.map(n => `"${n}"`).join(", ")}`) + console.log() + } + + const confirm = await p.confirm({ message: "Apply these merges?" }) + if (p.isCancel(confirm) || !confirm) { + p.cancel("Cancelled.") + return + } + + const applySpinner = p.spinner() + applySpinner.start("Applying merges...") + + let mergedCount = 0 + for (const group of groups) { + await api.mergePayees(group.keepId, group.mergeIds) + mergedCount += group.mergeIds.length + } + + await actualApi.sync() + applySpinner.stop(`Merged ${mergedCount} payees into ${groups.length} groups`) + p.outro("Done!") +} + +// ─── summarize ──────────────────────────────────────────────────────────── + +async function summarize(client: Anthropic, config: Config, args: string[]) { + const since = args.find(a => a.startsWith("--since="))?.split("=")[1] + + const spinner = p.spinner() + spinner.start("Loading transactions...") + + const transactions = await loadTransactions(config, since) + const { formatted } = await resolveNames(transactions) + + spinner.stop(`Loaded ${formatted.length} transactions`) + + if (formatted.length === 0) { + p.outro("No transactions found.") + return + } + + const txnText = formatted + .map(t => `${t.date} | ${Number(t.amount) >= 0 ? "+" : ""}${t.amount} NOK | ${t.payee} | ${t.category}${t.notes ? ` | ${t.notes}` : ""}`) + .join("\n") + + console.log("") + + const stream = client.messages.stream({ + model: "claude-haiku-4-5", + max_tokens: 4096, + system: "You are a personal finance assistant. Summarize transaction data clearly. Group by category and list top payees with totals. Use NOK currency. Use markdown formatting.", + messages: [{ + role: "user", + content: `Summarize my spending${since ? ` since ${since}` : ` (last 90 days)`}.\n\nTransactions:\n${txnText}` + }] + }) + + for await (const event of stream) { + if (event.type === "content_block_delta" && event.delta.type === "text_delta") { + process.stdout.write(event.delta.text) + } + } + + console.log("\n") + p.outro("Done!") +} + +// ─── insights ───────────────────────────────────────────────────────────── + +async function insights(client: Anthropic, config: Config, args: string[]) { + const since = args.find(a => a.startsWith("--since="))?.split("=")[1] + + const spinner = p.spinner() + spinner.start("Loading transactions...") + + const transactions = await loadTransactions(config, since) + const { formatted } = await resolveNames(transactions) + + spinner.stop(`Loaded ${formatted.length} transactions`) + + if (formatted.length === 0) { + p.outro("No transactions found.") + return + } + + const txnText = formatted + .map(t => `${t.date} | ${Number(t.amount) >= 0 ? "+" : ""}${t.amount} NOK | ${t.payee} | ${t.category}${t.notes ? ` | ${t.notes}` : ""}`) + .join("\n") + + console.log("") + + const stream = client.messages.stream({ + model: "claude-haiku-4-5", + max_tokens: 8192, + system: "You are a personal finance advisor. Analyze transactions and give actionable insights: spending patterns, trends, anomalies, potential savings, and notable observations. Use NOK currency. Use markdown. Be specific and helpful.", + messages: [{ + role: "user", + content: `Analyze my spending${since ? ` since ${since}` : ` (last 90 days)`} and give me insights.\n\nTransactions:\n${txnText}` + }] + }) + + for await (const event of stream) { + if (event.type === "content_block_delta" && event.delta.type === "text_delta") { + process.stdout.write(event.delta.text) + } + } + + console.log("\n") + p.outro("Done!") +} + +// ─── add-notes ──────────────────────────────────────────────────────────── + +async function addNotes(client: Anthropic, config: Config, args: string[]) { + const since = args.find(a => a.startsWith("--since="))?.split("=")[1] + const dryRun = args.includes("--dry-run") + + const spinner = p.spinner() + spinner.start("Loading transactions...") + + const transactions = await loadTransactions(config, since) + const { payeeMap } = await resolveNames(transactions) + + const withoutNotes = transactions + .filter(t => !t.is_parent && (!t.notes || !t.notes.trim())) + .slice(0, 50) + + spinner.stop(`Found ${withoutNotes.length} transactions without notes (processing up to 50)`) + + if (withoutNotes.length === 0) { + p.outro("All transactions already have notes!") + return + } + + const toProcess = withoutNotes.map(t => ({ + id: t.id, + date: t.date, + amount: (t.amount / 100).toFixed(2), + payee: payeeMap.get(t.payee) ?? t.imported_payee ?? "Unknown", + })) + + spinner.start("Generating notes with Claude...") + + const response = await client.messages.create({ + model: "claude-haiku-4-5", + max_tokens: 4096, + system: `You are a personal finance assistant. For each transaction, suggest a brief helpful note (max 60 chars) adding context not obvious from the payee name alone. +If the transaction is already self-explanatory, return null for the note. +Return ONLY a JSON array: [{"id": "...", "note": "..." | null}]`, + messages: [{ + role: "user", + content: `Add helpful notes to these transactions:\n${JSON.stringify(toProcess, null, 2)}` + }] + }) + + spinner.stop("Notes generated") + + const text = response.content.find(b => b.type === "text") as any + const rawText = text?.text ?? "" + + let suggestions: Array<{ id: string; note: string | null }> = [] + try { + suggestions = extractJson(rawText) + } catch { + console.error("Failed to parse Claude's response:") + console.log(rawText) + return + } + + const toUpdate = suggestions.filter(s => s.note !== null && s.note?.trim()) + + if (toUpdate.length === 0) { + p.outro("No notes to add — all transactions are self-explanatory.") + return + } + + console.log(`\nWill add notes to ${toUpdate.length} transaction(s):`) + const preview = toUpdate.slice(0, 10) + for (const s of preview) { + const t = toProcess.find(f => f.id === s.id) + console.log(` ${t?.date} ${t?.payee}: "${s.note}"`) + } + if (toUpdate.length > 10) console.log(` ... and ${toUpdate.length - 10} more`) + + if (dryRun) { + console.log("\nDry run — no changes applied.") + p.outro("Done!") + return + } + + const confirm = await p.confirm({ message: `Apply ${toUpdate.length} note(s)?` }) + if (p.isCancel(confirm) || !confirm) { + p.cancel("Cancelled.") + return + } + + const applySpinner = p.spinner() + applySpinner.start("Applying notes...") + + for (const s of toUpdate) { + await api.updateTransaction(s.id, { notes: s.note }) + } + + await actualApi.sync() + applySpinner.stop(`Added notes to ${toUpdate.length} transactions`) + p.outro("Done!") +} + +// ─── query ──────────────────────────────────────────────────────────────── + +async function query(client: Anthropic, config: Config, args: string[]) { + const since = args.find(a => a.startsWith("--since="))?.split("=")[1] + const inlineQuestion = args.filter(a => !a.startsWith("--")).join(" ").trim() + + const question = inlineQuestion || (await p.text({ + message: "What do you want to know about your transactions?", + placeholder: "e.g. How much did I spend on groceries last month?", + validate: v => v?.trim() ? undefined : "Please enter a question", + }) as string) + + if (p.isCancel(question)) { + p.cancel("Cancelled.") + return + } + + const spinner = p.spinner() + spinner.start("Loading transactions...") + + const transactions = await loadTransactions(config, since) + const { formatted } = await resolveNames(transactions) + + spinner.stop(`Loaded ${formatted.length} transactions`) + + if (formatted.length === 0) { + p.outro("No transactions found.") + return + } + + const txnText = formatted + .map(t => `${t.date} | ${Number(t.amount) >= 0 ? "+" : ""}${t.amount} NOK | ${t.payee} | ${t.category}${t.notes ? ` | ${t.notes}` : ""}`) + .join("\n") + + console.log("") + + const stream = client.messages.stream({ + model: "claude-haiku-4-5", + max_tokens: 4096, + system: "You are a personal finance assistant with access to the user's transaction data. Answer questions clearly and concisely. Use NOK currency. Use markdown where helpful.", + messages: [{ + role: "user", + content: `My transactions${since ? ` since ${since}` : ` (last 90 days)`}:\n\n${txnText}\n\n${question}`, + }] + }) + + for await (const event of stream) { + if (event.type === "content_block_delta" && event.delta.type === "text_delta") { + process.stdout.write(event.delta.text) + } + } + + console.log("\n") + p.outro("Done!") +} diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index 64b85e0..4efc043 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -37,7 +37,7 @@ export async function init() { return p.text({ message: "Enter finInst value", initialValue: current, - validate: v => v.trim() ? undefined : "Required" + validate: v => v?.trim() ? undefined : "Required" }) as Promise<string> }, redirectUri: () => { @@ -63,7 +63,16 @@ export async function init() { }), }, { onCancel }) - const config: Config = { sb1, actual, mappings: existing.mappings ?? [] } + const anthropicApiKey = await p.password({ + message: "Anthropic API key (optional, for AI features — leave blank to skip)", + }) + + const config: Config = { + sb1, + actual, + mappings: existing.mappings ?? [], + anthropicApiKey: (anthropicApiKey as string)?.trim() || existing.anthropicApiKey, + } saveConfig(config) diff --git a/cli/src/config.ts b/cli/src/config.ts index a4c68a4..e82d92a 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -24,6 +24,7 @@ export type Config = { fileId: string } mappings: AccountMapping[] + anthropicApiKey?: string } export function loadConfig(): Config { diff --git a/cli/src/index.ts b/cli/src/index.ts index daedaae..fc7f080 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -4,6 +4,7 @@ import { accounts } from "./commands/accounts" import { runImport } from "./commands/import" import { init } from "./commands/init" import { backup, restore } from "./commands/backup" +import { aiCommand } from "./commands/ai" const [command, ...args] = process.argv.slice(2) @@ -14,6 +15,7 @@ const commands: Record<string, (args: string[]) => Promise<void>> = { import: (args) => runImport(args), backup: () => backup(), restore: (args) => restore(args), + ai: (args) => aiCommand(args), } const handler = commands[command] @@ -30,6 +32,11 @@ if (!handler) { console.log(" import --since=YYYY-MM-DD Only fetch transactions from this date") console.log(" backup Export budget to ~/.config/sb1-actual/backups/") console.log(" restore Restore budget from a backup") + console.log(" ai query [question] Ask a free-form question about your transactions") + console.log(" ai consolidate-payees Identify and merge duplicate/similar payees") + console.log(" ai summarize Summarize spending by category and payee") + console.log(" ai insights Get AI insights into spending patterns") + console.log(" ai add-notes Add descriptive notes to transactions") process.exit(1) } |
