From b2735d24faa29fd16d5c5a962b425086f1139b1a Mon Sep 17 00:00:00 2001 From: Bill Ballou Date: Sun, 22 Mar 2026 11:29:01 -0400 Subject: [PATCH] Initial actual-budget skill for Claude Code Read-only Actual Budget API integration with shell helpers for querying accounts, transactions, budgets, categories, and spending. --- .gitignore | 3 + SKILL.md | 115 ++++++++++++++++++ scripts/actual-helper.sh | 101 ++++++++++++++++ scripts/actual-query.mjs | 252 +++++++++++++++++++++++++++++++++++++++ scripts/setup.sh | 110 +++++++++++++++++ 5 files changed, 581 insertions(+) create mode 100644 .gitignore create mode 100644 SKILL.md create mode 100755 scripts/actual-helper.sh create mode 100644 scripts/actual-query.mjs create mode 100755 scripts/setup.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24ed0df --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +scripts/node_modules/ +scripts/package-lock.json +scripts/package.json diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..660e5aa --- /dev/null +++ b/SKILL.md @@ -0,0 +1,115 @@ +--- +name: actual-budget +description: Use when the user asks about finances, budget, spending, account balances, transactions, categories, or money. Triggers on questions like "how much did I spend", "what's my balance", "budget status", "spending breakdown", "net worth", or any financial query against the Actual Budget server. +--- + +# Actual Budget Finance Skill + +Query your Actual Budget instance for financial data — accounts, transactions, budgets, categories, and spending analysis. Read-only access. + +## Setup + +Run the setup wizard (one-time): + +```bash +SKILL_DIR=$(dirname "$(find ~/.claude -path '*/actual-budget/scripts/setup.sh' 2>/dev/null | head -1)") +bash "${SKILL_DIR}/setup.sh" +``` + +The wizard prompts for: +- **Server URL** — Your Actual Budget instance (default: `https://budget.prettyhefty.com`) +- **Password** — Server authentication password +- **Budget sync ID** — Selected from the list of available budgets + +Configuration is stored in: +- `~/.config/actual-budget/config` — Server URL, sync ID +- `~/.config/actual-budget/password` — Server password (chmod 600) + +## Using the Helper Functions + +Source the helper script: + +```bash +source "$(find ~/.claude -path '*/actual-budget/scripts/actual-helper.sh' 2>/dev/null | head -1)" +``` + +### Available Functions + +**Configuration:** +- `actual_config` — Show current configuration +- `actual_check_config` — Verify configuration is complete + +**Accounts:** +- `actual_accounts` — List all accounts with current balances +- `actual_balance [name]` — Get balance for specific account (fuzzy match by name) + +**Budget:** +- `actual_budget_months` — List all months with budget data +- `actual_budget_month [YYYY-MM]` — Budget summary for a month (defaults to current) + +**Transactions:** +- `actual_transactions [account] [start_date] [end_date]` — List transactions (all accounts if none specified) +- `actual_spending_by_category [start_date] [end_date]` — Spending breakdown by category + +**Reference Data:** +- `actual_categories` — List all categories +- `actual_category_groups` — List category groups +- `actual_payees` — List all payees +- `actual_budgets` — List budgets available on server + +**Advanced:** +- `actual_query ''` — Run an arbitrary ActualQL query + +### Output Format + +All functions output JSON. Amounts are in decimal format (e.g., `123.45`), not Actual's internal integer format. + +## Common Query Patterns + +### "How much did I spend this month?" +```bash +actual_spending_by_category "$(date +%Y-%m)-01" "$(date +%Y-%m-%d)" +``` + +### "What's my balance across all accounts?" +```bash +actual_accounts +``` + +### "Show me transactions for Groceries this month" +First get categories to find the ID, then query transactions and filter: +```bash +actual_categories # find the category ID +actual_transactions "" "$(date +%Y-%m)-01" "$(date +%Y-%m-%d)" # then filter by category in the JSON output +``` + +### "Am I over budget this month?" +```bash +actual_budget_month "$(date +%Y-%m)" +``` +The result includes `budgeted`, `spent`, and `balance` per category. + +### "What did I spend at [payee] recently?" +```bash +actual_transactions "" "2026-01-01" "$(date +%Y-%m-%d)" +``` +Then filter the JSON output by payee name. + +## ActualQL Quick Reference + +For complex queries, use `actual_query` with a JSON query object: + +```bash +actual_query '{"table":"transactions","filter":{"date":{"$gte":"2026-03-01"}},"select":["date","amount","payee","category"]}' +``` + +**Operators:** `$eq`, `$lt`, `$lte`, `$gt`, `$gte`, `$ne`, `$regex`, `$like`, `$oneof`, `$and`, `$or` + +**Tables:** `transactions` (primary), plus accounts, categories, payees accessible via the helper functions. + +## Troubleshooting + +- **"Config not found"** — Run `setup.sh` +- **"@actual-app/api not installed"** — Run `cd && npm install @actual-app/api` +- **Connection errors** — Check server URL and password in `~/.config/actual-budget/config` +- **Empty results** — Verify the sync ID matches an active budget (`actual_budgets`) diff --git a/scripts/actual-helper.sh b/scripts/actual-helper.sh new file mode 100755 index 0000000..536e1f3 --- /dev/null +++ b/scripts/actual-helper.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# Actual Budget helper functions for Claude Code skill +# Source this file: source "$(find ~/.claude/skills -name 'actual-helper.sh' 2>/dev/null | head -1)" + +# Auto-detect the script directory +ACTUAL_SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ACTUAL_QUERY="${ACTUAL_SKILL_DIR}/actual-query.mjs" +ACTUAL_CONFIG_DIR="${HOME}/.config/actual-budget" + +# Run the Node.js query script +_actual_run() { + node "${ACTUAL_QUERY}" "$@" +} + +# Show current configuration (without password) +actual_config() { + echo "=== Actual Budget Configuration ===" + if [[ -f "${ACTUAL_CONFIG_DIR}/config" ]]; then + cat "${ACTUAL_CONFIG_DIR}/config" + else + echo "No config found. Run setup.sh first." + fi + echo "" + echo "Password file: $([ -f "${ACTUAL_CONFIG_DIR}/password" ] && echo "present" || echo "missing")" + echo "Node modules: $([ -d "${ACTUAL_SKILL_DIR}/node_modules/@actual-app/api" ] && echo "installed" || echo "not installed")" +} + +# Verify configuration is complete +actual_check_config() { + local ok=true + [[ -f "${ACTUAL_CONFIG_DIR}/config" ]] || { echo "ERROR: Config file missing"; ok=false; } + [[ -f "${ACTUAL_CONFIG_DIR}/password" ]] || { echo "ERROR: Password file missing"; ok=false; } + [[ -d "${ACTUAL_SKILL_DIR}/node_modules/@actual-app/api" ]] || { echo "ERROR: @actual-app/api not installed"; ok=false; } + if $ok; then + echo "Configuration OK" + return 0 + else + echo "Run setup.sh to fix" + return 1 + fi +} + +# List all accounts with balances +actual_accounts() { + _actual_run accounts +} + +# Get account balance(s), optionally filtered by name +# Usage: actual_balance [account_name] +actual_balance() { + _actual_run balance "$@" +} + +# List all categories +actual_categories() { + _actual_run categories +} + +# List category groups +actual_category_groups() { + _actual_run category-groups +} + +# List available budget months +actual_budget_months() { + _actual_run budget-months +} + +# Get budget summary for a month +# Usage: actual_budget_month [YYYY-MM] (defaults to current month) +actual_budget_month() { + _actual_run budget-month "$@" +} + +# List transactions +# Usage: actual_transactions [account_name_or_id] [start_date] [end_date] +actual_transactions() { + _actual_run transactions "$@" +} + +# Get spending breakdown by category +# Usage: actual_spending_by_category [start_date] [end_date] +actual_spending_by_category() { + _actual_run spending-by-category "$@" +} + +# List all payees +actual_payees() { + _actual_run payees +} + +# Run an arbitrary ActualQL query +# Usage: actual_query '' +actual_query() { + _actual_run query "$@" +} + +# List available budgets on the server +actual_budgets() { + _actual_run budgets +} diff --git a/scripts/actual-query.mjs b/scripts/actual-query.mjs new file mode 100644 index 0000000..69a5894 --- /dev/null +++ b/scripts/actual-query.mjs @@ -0,0 +1,252 @@ +#!/usr/bin/env node + +import * as fs from 'fs'; +import * as path from 'path'; +import * as api from '@actual-app/api'; + +const CONFIG_DIR = path.join(process.env.HOME, '.config', 'actual-budget'); +const CONFIG_FILE = path.join(CONFIG_DIR, 'config'); +const PASSWORD_FILE = path.join(CONFIG_DIR, 'password'); +const DATA_DIR = path.join(CONFIG_DIR, 'data'); + +function loadConfig() { + if (!fs.existsSync(CONFIG_FILE)) { + console.error(JSON.stringify({ error: 'Config not found. Run setup.sh first.' })); + process.exit(1); + } + if (!fs.existsSync(PASSWORD_FILE)) { + console.error(JSON.stringify({ error: 'Password file not found. Run setup.sh first.' })); + process.exit(1); + } + + const config = {}; + const lines = fs.readFileSync(CONFIG_FILE, 'utf-8').split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + const key = trimmed.slice(0, eqIdx).trim(); + let val = trimmed.slice(eqIdx + 1).trim(); + // Strip surrounding quotes + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1); + } + config[key] = val; + } + + config.password = fs.readFileSync(PASSWORD_FILE, 'utf-8').trim(); + return config; +} + +async function initialize(config) { + if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); + } + + await api.init({ + dataDir: DATA_DIR, + serverURL: config.ACTUAL_SERVER_URL, + password: config.password, + }); + + await api.downloadBudget(config.ACTUAL_SYNC_ID); +} + +function output(data) { + console.log(JSON.stringify(data, null, 2)); +} + +async function main() { + const [command, ...args] = process.argv.slice(2); + + if (!command) { + output({ error: 'No command specified', commands: [ + 'accounts', 'balance', 'categories', 'category-groups', + 'budget-months', 'budget-month', 'transactions', + 'spending-by-category', 'payees', 'query', 'budgets' + ]}); + process.exit(1); + } + + const config = loadConfig(); + + // 'budgets' command doesn't need a loaded budget + if (command === 'budgets') { + if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); + } + await api.init({ + dataDir: DATA_DIR, + serverURL: config.ACTUAL_SERVER_URL, + password: config.password, + }); + const budgets = await api.getBudgets(); + output(budgets); + await api.shutdown(); + return; + } + + await initialize(config); + + try { + switch (command) { + case 'accounts': { + const accounts = await api.getAccounts(); + const result = []; + for (const acct of accounts) { + const balance = await api.getAccountBalance(acct.id); + result.push({ + ...acct, + balance: api.utils.integerToAmount(balance), + }); + } + output(result); + break; + } + + case 'balance': { + const accountName = args[0]; + const accounts = await api.getAccounts(); + const filtered = accountName + ? accounts.filter(a => a.name.toLowerCase().includes(accountName.toLowerCase())) + : accounts; + const result = []; + for (const acct of filtered) { + const balance = await api.getAccountBalance(acct.id); + result.push({ + name: acct.name, + balance: api.utils.integerToAmount(balance), + }); + } + output(result); + break; + } + + case 'categories': { + const categories = await api.getCategories(); + output(categories); + break; + } + + case 'category-groups': { + const groups = await api.getCategoryGroups(); + output(groups); + break; + } + + case 'budget-months': { + const months = await api.getBudgetMonths(); + output(months); + break; + } + + case 'budget-month': { + const month = args[0] || new Date().toISOString().slice(0, 7); + const budget = await api.getBudgetMonth(month); + output(budget); + break; + } + + case 'transactions': { + const accountId = args[0] || null; + const startDate = args[1] || null; + const endDate = args[2] || null; + + if (accountId) { + // Check if it's a name rather than an ID + let resolvedId = accountId; + const accounts = await api.getAccounts(); + const match = accounts.find(a => + a.name.toLowerCase() === accountId.toLowerCase() || + a.id === accountId + ); + if (match) resolvedId = match.id; + + const txns = await api.getTransactions(resolvedId, startDate, endDate); + output(txns.map(t => ({ + ...t, + amount: api.utils.integerToAmount(t.amount), + }))); + } else { + // Get transactions from all accounts + const accounts = await api.getAccounts(); + const allTxns = []; + for (const acct of accounts) { + const txns = await api.getTransactions(acct.id, startDate, endDate); + allTxns.push(...txns.map(t => ({ + ...t, + account_name: acct.name, + amount: api.utils.integerToAmount(t.amount), + }))); + } + allTxns.sort((a, b) => b.date.localeCompare(a.date)); + output(allTxns); + } + break; + } + + case 'spending-by-category': { + const startDate = args[0] || null; + const endDate = args[1] || null; + + const accounts = await api.getAccounts(); + const categories = await api.getCategories(); + const categoryMap = {}; + for (const cat of categories) { + categoryMap[cat.id] = cat.name; + } + + const spending = {}; + for (const acct of accounts) { + if (acct.offbudget || acct.closed) continue; + const txns = await api.getTransactions(acct.id, startDate, endDate); + for (const t of txns) { + if (t.amount >= 0) continue; // skip income + const catName = categoryMap[t.category] || 'Uncategorized'; + spending[catName] = (spending[catName] || 0) + t.amount; + } + } + + const result = Object.entries(spending) + .map(([category, amount]) => ({ + category, + amount: api.utils.integerToAmount(amount), + })) + .sort((a, b) => a.amount - b.amount); + + output(result); + break; + } + + case 'payees': { + const payees = await api.getPayees(); + output(payees); + break; + } + + case 'query': { + const queryJson = args[0]; + if (!queryJson) { + output({ error: 'Query JSON required' }); + process.exit(1); + } + const queryObj = JSON.parse(queryJson); + const result = await api.runQuery(queryObj); + output(result); + break; + } + + default: + output({ error: `Unknown command: ${command}` }); + process.exit(1); + } + } finally { + await api.shutdown(); + } +} + +main().catch(err => { + console.error(JSON.stringify({ error: err.message })); + process.exit(1); +}); diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000..f044033 --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Setup wizard for Actual Budget skill +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONFIG_DIR="${HOME}/.config/actual-budget" +CONFIG_FILE="${CONFIG_DIR}/config" +PASSWORD_FILE="${CONFIG_DIR}/password" + +echo "=== Actual Budget Skill Setup ===" +echo "" + +# Create config directory +mkdir -p "${CONFIG_DIR}" + +# Server URL +read -rp "Actual Budget server URL [https://budget.prettyhefty.com]: " server_url +server_url="${server_url:-https://budget.prettyhefty.com}" + +# Password +read -rsp "Server password: " password +echo "" + +if [[ -z "${password}" ]]; then + echo "ERROR: Password is required" + exit 1 +fi + +# Save config +cat > "${CONFIG_FILE}" < "${PASSWORD_FILE}" +chmod 600 "${PASSWORD_FILE}" + +echo "" +echo "Config saved to ${CONFIG_FILE}" +echo "Password saved to ${PASSWORD_FILE}" + +# Install @actual-app/api locally +echo "" +echo "Installing @actual-app/api..." +cd "${SCRIPT_DIR}" +if [[ ! -f "${SCRIPT_DIR}/package.json" ]]; then + npm init -y --silent > /dev/null 2>&1 +fi +npm install @actual-app/api --silent 2>&1 | tail -1 + +# List available budgets and let user pick +echo "" +echo "Connecting to server and listing budgets..." +budgets_json=$(node "${SCRIPT_DIR}/actual-query.mjs" budgets 2>&1) || { + echo "ERROR: Failed to connect to server" + echo "${budgets_json}" + exit 1 +} + +echo "" +echo "Available budgets:" +echo "${budgets_json}" | node -e " + const data = JSON.parse(require('fs').readFileSync('/dev/stdin', 'utf-8')); + if (Array.isArray(data)) { + data.forEach((b, i) => console.log(' ' + (i+1) + ') ' + (b.name || b.cloudFileId || b.id || 'unnamed') + ' [' + (b.cloudFileId || b.groupId || b.id || 'unknown') + ']')); + } else { + console.log(' (unexpected format)'); + console.log(JSON.stringify(data, null, 2)); + } +" + +echo "" +read -rp "Enter budget sync ID (or number from list above): " sync_id + +# If user entered a number, resolve it +if [[ "${sync_id}" =~ ^[0-9]+$ ]]; then + sync_id=$(echo "${budgets_json}" | node -e " + const data = JSON.parse(require('fs').readFileSync('/dev/stdin', 'utf-8')); + const idx = ${sync_id} - 1; + if (data[idx]) console.log(data[idx].cloudFileId || data[idx].groupId || data[idx].id || ''); + else console.log(''); + ") +fi + +if [[ -z "${sync_id}" ]]; then + echo "ERROR: Sync ID is required" + exit 1 +fi + +# Append sync ID to config +echo "ACTUAL_SYNC_ID=\"${sync_id}\"" >> "${CONFIG_FILE}" + +echo "" +echo "Testing connection..." +test_result=$(node "${SCRIPT_DIR}/actual-query.mjs" accounts 2>&1) || { + echo "ERROR: Failed to load budget" + echo "${test_result}" + exit 1 +} + +account_count=$(echo "${test_result}" | node -e " + const data = JSON.parse(require('fs').readFileSync('/dev/stdin', 'utf-8')); + console.log(Array.isArray(data) ? data.length : 0); +") + +echo "Success! Found ${account_count} account(s)." +echo "" +echo "Setup complete. Source the helper to get started:" +echo " source ${SCRIPT_DIR}/actual-helper.sh" +echo " actual_accounts"