Initial actual-budget skill for Claude Code
Read-only Actual Budget API integration with shell helpers for querying accounts, transactions, budgets, categories, and spending.
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
scripts/node_modules/
|
||||||
|
scripts/package-lock.json
|
||||||
|
scripts/package.json
|
||||||
115
SKILL.md
Normal file
115
SKILL.md
Normal file
@@ -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 '<json>'` — 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 <skill-scripts-dir> && 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`)
|
||||||
101
scripts/actual-helper.sh
Executable file
101
scripts/actual-helper.sh
Executable file
@@ -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 '<json_query>'
|
||||||
|
actual_query() {
|
||||||
|
_actual_run query "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# List available budgets on the server
|
||||||
|
actual_budgets() {
|
||||||
|
_actual_run budgets
|
||||||
|
}
|
||||||
252
scripts/actual-query.mjs
Normal file
252
scripts/actual-query.mjs
Normal file
@@ -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);
|
||||||
|
});
|
||||||
110
scripts/setup.sh
Executable file
110
scripts/setup.sh
Executable file
@@ -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}" <<EOF
|
||||||
|
ACTUAL_SERVER_URL="${server_url}"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Save password securely
|
||||||
|
echo -n "${password}" > "${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"
|
||||||
Reference in New Issue
Block a user