Files
actual/packages/cli
Matiss Janis Aboltins 9acbd6388b [AI] Cache the CLI's local budget between invocations (#7539)
* [AI] Cache the CLI's local budget between invocations

Every `actual <cmd>` call currently delta-syncs the budget with the
sync server via `api.downloadBudget`, which hits the server's
500-req/min rate limit on scripted workflows. Actual is local-first:
once the budget is on disk, most read commands do not need fresh
server data.

Introduce a CLI-only cache layer inside `withConnection` that
decides per invocation whether to skip, sync, or re-download:

- Cache state lives at `{dataDir}/.actual-cli/{syncId}/state.json`,
  keyed by `syncId` to avoid the chicken-and-egg of not knowing the
  on-disk `budgetId` before the first download. The on-disk id is
  resolved via `api.getBudgets()` and persisted after first download.
- Read commands (list, balance, query run, …) skip the `/sync`
  call while `now - lastSyncedAt < cacheTtl`. Write commands
  (create, update, delete, set-*, etc.) sync before and after the
  operation to keep server state consistent.
- Encrypted budgets force a sync per call since `api/load-budget`
  does not re-verify the password.
- New `proper-lockfile`-backed shared/exclusive lock serializes
  writes while allowing parallel reads. Reader markers live in
  `{meta}/readers/`; writers sweep stale markers by PID.

New `actual sync` command with three modes: default (sync now),
`--status` (print cache age, TTL, stale flag), `--clear` (delete
cache, holding the exclusive lock to avoid racing writers).

New config surface, following the existing flag → env → config file
→ default precedence chain:

- `--cache-ttl <s>` / `ACTUAL_CACHE_TTL` / `cacheTtl` (default 60)
- `--refresh` / `--no-cache`
- `--lock-timeout <s>` / `ACTUAL_LOCK_TIMEOUT` / `lockTimeout` (10)
- `--no-lock` / `ACTUAL_NO_LOCK` / `noLock`

Every `withConnection` call site now passes an explicit
`{ mutates: boolean, skipBudget?: boolean }` so read/write intent is
visible at the edge.

The old `budgets sync` subcommand is removed — it silently diverged
from the new top-level `actual sync`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [AI] Simplify CLI cache/lock internals

Use a discriminated SyncDecision union so connection.ts no longer needs
non-null assertions on the cached state. Thread the resolved CliConfig
through withConnection's callback to drop duplicate resolveConfig calls
in the sync and budgets commands. Extract an errorCode helper and
replace the existsSync+readdirSync TOCTOU pattern in the reader-wait
polling loop with a single readdir that tolerates ENOENT.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [AI] Address PR #7539 review comments

- cache.ts: use a unique per-writer tmp filename so concurrent shared-
  lock writers (encrypted budgets, --refresh, stale TTL) don't clobber
  each other's publish and silently drop state updates.

- index.ts/config.ts: fix --no-cache and --no-lock flags. Commander
  stores --no-foo under the positive key (cache/lock) and an explicit
  false default makes the flag a no-op; the previous code also read
  the wrong keys (noCache/noLock) so the flags had no effect at all.
  Derive refresh/noLock from the correct keys.

- budgets.ts: invert encryption password precedence so the subcommand
  flag (--encryption-password) wins over env/config-file values.

- sync.ts: report stale=true in --status when lastSyncedAt is in the
  future, matching decideSyncAction's clock-skew handling.

- connection.ts: drop unnecessary `as` cast on api.getBudgets() now
  that the return type is Promise<APIFileEntity[]>.

- utils.ts: parseBoolEnv throws on unrecognized values instead of
  silently returning undefined so typos like ACTUAL_NO_LOCK=yes fail
  loudly.

- Shorten 7539 release note to a single user-facing sentence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:58:18 +00:00
..

@actual-app/cli

WARNING: This CLI is experimental.

Command-line interface for Actual Budget. Query and modify your budget data from the terminal — accounts, transactions, categories, payees, rules, schedules, and more.

Note: This CLI connects to a running Actual sync server. It does not operate on local budget files directly.

Installation

npm install -g @actual-app/cli

Requires Node.js >= 22.

Quick Start

# Set connection details
export ACTUAL_SERVER_URL=http://localhost:5006
export ACTUAL_PASSWORD=your-password
export ACTUAL_SYNC_ID=your-sync-id   # Found in Settings → Advanced → Sync ID

# List your accounts
actual accounts list

# Check a balance
actual accounts balance <account-id>

# View this month's budget
actual budgets month 2026-03

Configuration

Configuration is resolved in this order (highest priority first):

  1. CLI flags (--server-url, --password, etc.)
  2. Environment variables
  3. Config file (via cosmiconfig)
  4. Defaults (dataDir defaults to ~/.actual-cli/data)

Environment Variables

Variable Description
ACTUAL_SERVER_URL URL of the Actual sync server (required)
ACTUAL_PASSWORD Server password (required unless using token)
ACTUAL_SESSION_TOKEN Session token (alternative to password)
ACTUAL_SYNC_ID Budget Sync ID (required for most commands)
ACTUAL_DATA_DIR Local directory for cached budget data
ACTUAL_CACHE_TTL Cache TTL in seconds (default: 60)
ACTUAL_LOCK_TIMEOUT Budget-dir lock wait timeout in seconds (default: 10)
ACTUAL_NO_LOCK Set to 1 to disable budget-dir locking

Config File

Create an .actualrc.json (or .actualrc, .actualrc.yaml, actual.config.js):

{
  "serverUrl": "http://localhost:5006",
  "password": "your-password",
  "syncId": "1cfdbb80-6274-49bf-b0c2-737235a4c81f",
  "cacheTtl": 60,
  "lockTimeout": 10,
  "noLock": false
}

Security: Do not store plaintext passwords in config files (e.g. .actualrc.json, .actualrc, .actualrc.yaml, actual.config.js). Add these files to .gitignore if they contain secrets. Prefer the ACTUAL_SESSION_TOKEN environment variable instead of the password field. See Environment Variables for using a session token.

Global Flags

Flag Description
--server-url <url> Server URL
--password <pw> Server password
--session-token <token> Session token
--sync-id <id> Budget Sync ID
--data-dir <path> Data directory
--cache-ttl <seconds> Cache TTL; 0 disables caching (default: 60)
--refresh Force a sync on this call, ignoring the cache
--no-cache Alias for --refresh
--lock-timeout <secs> Lock wait timeout (default: 10)
--no-lock Disable budget-dir locking (use with care)
--format <format> Output format: json (default), table, csv
--verbose Show informational messages

Commands

Command Description
accounts Manage accounts
budgets Manage budgets and allocations
categories Manage categories
category-groups Manage category groups
transactions Manage transactions
payees Manage payees
tags Manage tags
rules Manage transaction rules
schedules Manage scheduled transactions
query Run an ActualQL query
server Server utilities and lookups
sync Refresh or inspect local cache

Run actual <command> --help for subcommands and options.

Examples

# List all accounts (as a table; excludes closed by default)
actual accounts list [--include-closed] --format table

# Find an entity ID by name
actual server get-id --type accounts --name "Checking"

# Add a transaction (amount in integer cents: -2500 = -$25.00)
actual transactions add --account <id> \
  --data '[{"date":"2026-03-14","amount":-2500,"payee_name":"Coffee Shop"}]'

# Export transactions to CSV
actual transactions list --account <id> \
  --start 2026-01-01 --end 2026-12-31 --format csv > transactions.csv

# Set budget amount ($500 = 50000 cents)
actual budgets set-amount --month 2026-03 --category <id> --amount 50000

# Run an ActualQL query
actual query run --table transactions \
  --select "date,amount,payee" --filter '{"amount":{"$lt":0}}' --limit 10

Amount Convention

All monetary amounts are integer cents when passed as input (flags, JSON):

CLI Value Dollar Amount
5000 $50.00
-12350 -$123.50

Output formatting: Table (--format table) and CSV (--format csv) output automatically converts cent values to decimal (e.g. 1665.00 instead of 166500). JSON output always returns raw cents for programmatic use.

Tips & Common Pitfalls

  • Split transactions: When summing or counting transactions, filter "is_parent": false to avoid double-counting. A split parent holds the total amount, and its children hold the individual parts — including both would count the total twice.

  • Rapid sequential requests: The CLI caches the budget locally (see Caching), so read-heavy scripts no longer need a single-query workaround by default. For very chatty scripts, run actual sync once and then use a long --cache-ttl for reads:

    actual sync
    actual --cache-ttl 3600 query run ...
    actual --cache-ttl 3600 accounts list
    
  • Uncategorized transactions: category.name is null for transactions without a category. Account for this when filtering or grouping by category.

  • No date sub-fields in AQL: date.month, date.year, etc. are not supported as query fields. To group by month, fetch raw transactions with a date range filter and aggregate locally in a script.

Caching

The CLI keeps a local copy of your budget so repeated commands don't hit the sync server on every call. Within the TTL (default 60 seconds), read commands (list, balance, query run, …) reuse the cached budget without a network round-trip. Write commands (add, update, set-amount, …) always sync with the server before and after the write.

  • actual sync — refresh the cache now.
  • actual sync --status — show how stale the local cache is.
  • actual sync --clear — delete the local cache; the next command re-downloads.
  • --refresh (or --no-cache) — force a sync on a single call.
  • --cache-ttl <seconds> — override the TTL for a single call (use 0 to disable caching).

Concurrency

The CLI takes a shared lock for reads and an exclusive lock for writes on the per-budget cache directory. Many parallel reads are safe; writes serialize. If another CLI process is holding the lock, subsequent invocations wait up to --lock-timeout seconds (default 10) before failing with an error. Pass --no-lock to opt out in trusted single-process setups.

Running Locally (Development)

If you're working on the CLI within the monorepo:

# 1. Build the CLI
yarn build:cli

# 2. Start a local sync server (in a separate terminal)
yarn start:server-dev

# 3. Open http://localhost:5006 in your browser, create a budget,
#    then find the Sync ID in Settings → Advanced → Sync ID

# 4. Run the CLI directly from the build output
ACTUAL_SERVER_URL=http://localhost:5006 \
ACTUAL_PASSWORD=your-password \
ACTUAL_SYNC_ID=your-sync-id \
node packages/cli/dist/cli.js accounts list

# Or use a shorthand alias for convenience
alias actual-dev="node $(pwd)/packages/cli/dist/cli.js"
actual-dev budgets list