aboutsummaryrefslogtreecommitdiffstats
path: root/cli/src
diff options
context:
space:
mode:
authorivar <i@oiee.no>2026-03-15 22:53:10 +0100
committerivar <i@oiee.no>2026-03-15 22:53:10 +0100
commit1aa60ed7f0b0af6c55df4a32bf2620934df63e6d (patch)
treedeacb84cd3770ce139ecbc693bf2b987954c2347 /cli/src
parent69448e29a85cad3a94b3be3ad33efbc52764528f (diff)
downloadsparebank1-actualbudget-1aa60ed7f0b0af6c55df4a32bf2620934df63e6d.tar.xz
sparebank1-actualbudget-1aa60ed7f0b0af6c55df4a32bf2620934df63e6d.zip
Add ai commandsHEADmaster
Diffstat (limited to 'cli/src')
-rw-r--r--cli/src/commands/ai.ts447
-rw-r--r--cli/src/commands/init.ts13
-rw-r--r--cli/src/config.ts1
-rw-r--r--cli/src/index.ts7
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)
}