#!/usr/bin/env node // Redirect console.log to stderr so API noise doesn't corrupt JSON output const _origLog = console.log; console.log = (...args) => console.error(...args); import * as fs from 'fs'; import * as path from 'path'; import * as api from '@actual-app/api'; // Restore console.log only for our own output function function output(data) { _origLog(JSON.stringify(data, null, 2)); } const CONFIG_DIR = process.env.ACTUAL_BUDGET_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); } 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 if (t.transfer_id) continue; // skip inter-account transfers 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); });