Skip to main content

bea 0.2.0: one install, the whole Beancount toolchain

Published 17 min readMike ThriftMike Thrift
bea 0.2.0: one install, the whole Beancount toolchain
On this page

If you have ever handed a colleague, a new laptop or a nightly cron job a working Beancount setup, you know the accounting was never the hard part. The hard part was the toolchain: a Python that matches, bean-check and bean-query on the path, a reporting library pulled in for one balance sheet, and a formatter that rewrites your files the moment you ask it a question. bea 0.2.0, released on September 12, 2026, replaces that checklist with one install. The bea command now carries the complete native Beancount toolchain, runs it inside a managed engine it provisions itself, and keeps the machine-readable contract that scripts and AI agents already depend on.

This is the release note for 0.2.0, written the way we track a release internally: what shipped, what changed underneath, how it was verified before it reached a package index, what it deliberately does not do yet, and how to upgrade. If you want the first-run story instead, the 0.1.0 launch post and the CLI quick start are the shorter reads.

The release at a glance

Two channels publish the same command. Pick one, then confirm it answers with its version:

$ brew install bex-co/tap/bea        # macOS and Linuxbrew
$ uv tool install beancount-io       # anywhere with uv and Python 3.12 or newer
$ bea --version
bea 0.2.0
bea 0.2.0
cli-v0.2.02026-09-12
engine
beancount 3.2.3 beanquery 0.2.0
optional
beangulp 0.2.0 beanprice 2.1.0
python
3.12 3.14

The 0.2.0 release card: the tag and publication date, the Beancount and Beanquery versions the managed engine pins, the two optional engine features, and the Python versions the release was installed and tested on.

FieldValue
Version0.2.0, tag cli-v0.2.0, published to PyPI and the bex-co/homebrew-tap Homebrew tap on 2026-09-12
Previous release0.1.0, tagged 2026-09-09, three days earlier
Change set27 commits touching the CLI, 119 files changed, roughly 12,300 lines added and 2,100 removed
Engine pinsBeancount 3.2.3 and Beanquery 0.2.0 in the base engine; Beangulp 0.2.0 and Beanprice 2.1.0 as opt-in features
HeadlineEvery native Beancount tool under one prefix, served by a managed engine; the JSON envelope and exit-code contract from 0.1.0 are unchanged

What changed underneath: the managed engine

In 0.1.0, bea imported Beancount into its own process, the way any Python tool would. That worked, but it made the CLI's dependency graph Beancount's dependency graph, and it left "install Beancount first" as an unwritten step in every guide.

0.2.0 draws a line through the middle of the program. The bea frontend, the part that owns the commands, the options and the rendering, never loads Beancount, Beanquery or the vendored Fava reporting code. Local ledger work runs in a managed engine: a separate Python environment that bea provisions from a hash-pinned lock and launches as a child interpreter. The frontend sends a JSON request across that boundary and renders what comes back. You do not install Beancount, put bean-* tools on your path, or think about which Python they found.

How the engine arrives depends on the channel:

  • Homebrew creates the frontend and engine environments during installation. Local commands use the keg-local engine with no further download.
  • PyPI (uv tool install or pipx) provisions on first use. The first local command that needs the engine downloads the pinned combination, which requires network access and uv on the path once. Later commands reuse it offline from ~/.local/share/bea/engine/<version>, or under XDG_DATA_HOME if you set it.

Three properties follow from that design, and each one removes a support ticket we have already seen:

  1. Upgrades stay paired. bea upgrade hands the update to whichever package manager installed this copy, then rebuilds the matching engine, so a frontend and an engine can never drift to different versions.
  2. A broken engine heals itself. If a provision fails halfway, the managed environment is discarded and rebuilt on the next successful attempt. Stray bean-check binaries elsewhere on the path are ignored rather than picked up by accident.
  3. Heavy optional pieces stay optional. The Beangulp import framework needs the system libmagic library, and Beanprice pulls in quote-fetching dependencies. Neither is in the base engine. You enable them explicitly, into the engine only.
$ bea engine status
$ bea engine enable beangulp     # ingest helpers; needs the system libmagic library
$ bea engine enable beanprice    # bean-price quote fetching

bea engine status reports whether the engine is provisioned and which optional features are enabled, and it needs no network to say so. If a first-use provision fails, fix the network or uv and rerun any local command such as bea check. Do not pip install beancount beside it: the frontend will not use it.

Every native tool, one prefix

The engine is the mechanism. The user-facing change is parity: every executable the upstream Beancount project ships now has a bea counterpart, with the same arguments forwarded and the same output preserved.

$ bea check                                    # bean-check, plus bea's --json envelope
$ bea format main.bean -o clean.bean           # bean-format: stdout by default, -i rewrites
$ bea query "SELECT account, sum(position) GROUP BY account"
$ bea doctor context main.bean 2026-01-02      # all eleven bean-doctor operations
$ bea example --seed 1 -o example.beancount    # bean-example
$ bea treeify < balances.txt                   # treeify
$ bea ingest identify --config ingest.py inbox # Beangulp, after engine enable
$ bea price -e USD:yahoo/AAPL                  # bean-price, after engine enable
bean-check
bea check
bean-format
bea format
bean-query
bea query
bean-doctor
bea doctor
bean-example
bea example
treeify
bea treeify
beangulp
bea ingest bea engine enable beangulp
bean-price
bea price bea engine enable beanprice

The parity map: the six native Beancount executables above the dashed line work out of the box; the two below it are forwarded to Beangulp and Beanprice once you enable that feature in the engine.

A few of these deserve more than a row in a table.

bea check is bean-check with bea's JSON envelope layered on top: the same validation, the same error messages, and under --json the same valid and errors fields that scripts already parse.

bea format changed behavior, and it is the one change in this release that can surprise a script. In 0.1.0, bea format PATH rewrote the file. It now prints the formatted text to stdout and leaves the file alone. --in-place (-i) is what rewrites, --output FILE (-o) writes somewhere else, --check is the CI gate that exits 1 when files need formatting, and --dry-run lists what would change. This follows bean-format, whose default is the safe one: a command that reads a path and silently rewrites it cannot be tried out first. Formatting is a text transformation, not a parse, so it no longer refuses a file with a syntax error; it aligns what it recognises and leaves the rest. Run bea check for validity.

bea query grew the whole native surface. It takes BQL as an argument, from stdin, or in the interactive shell, which is now the upstream Beanquery shell launched as a child process with its .format, .output, .run and .set commands intact. --format selects text, csv or beancount rendering, --numberify splits amounts into one column per currency, -o writes to a file, and --source URI hands a native Beanquery source straight through.

bea doctor exposes all eleven bean-doctor operations: lex, parse, roundtrip, directories, list-options, print-options, context, linked, region, missing-open and display-context. If you have ever debugged a booking problem with bean-doctor context, it is the same tool at the same address.

bea example and bea treeify are the native generator and the native tree renderer, forwarded as-is.

bea ingest and bea price forward to Beangulp's identify, extract and archive and to bean-price, respectively, after bea engine enable. The no-Python CSV path, bea import --csv, needs neither and is unchanged.

One rule ties the forwarded commands together: doctor, example, treeify, price and ingest hand their arguments to upstream unchanged and keep upstream's output and exit status. That also means they take the ledger as their own positional argument, as in bea doctor lex main.bean, rather than through global --file. The envelope and the exit-code categories below describe bea's own commands.

The contract scripts can keep trusting

Nothing about the machine-readable surface moved. Global --json still puts one envelope on stdout with bea, target, data and truncated, plus limit on bounded lists and page on paged hosted lists. Amounts are decimal strings, never floats, and dates are ISO YYYY-MM-DD. --json implies --no-input; so does a non-terminal stdin or a truthy CI variable, so an unattended job never waits for a human. --strict refuses partial answers even in a terminal, and each read command's --allow-errors opts back in.

A failure writes nothing to stdout and exactly one object to stderr:

{
  "error": {
    "category": "validation",
    "message": "Ledger has 3 error(s). Pass --allow-errors to report anyway.",
    "exit_code": 1,
    "details": ["main.bean:1: Transaction does not balance: (2.50 USD)"]
  }
}
0
ok
1
validation
2
usage
3
auth
4
conflict

The five exit codes and the category string each one carries in the JSON error object. A script branches on the number; a human reads the category.

CodeCategoryMeaning
0noneSuccess, including previews and intentional duplicate skips
1validationLedger or validation error, and the catch-all for any other runtime failure
2usageBad arguments, a missing target or extra, or input needed under --no-input
3authAuthentication or permission failure, including a read-only destination
4conflictA concurrent change, an import that needs duplicate review, or a write whose outcome is unknown

Two details matter for anyone retrying on failure. A nonzero exit does not universally mean nothing changed: add transactions --partial can write the accepted rows, format -i over several files can rewrite some before failing on one, and cloud ledger create --clone can create the ledger before the clone fails. Read error.result before retrying a mutation. And hosted commands map the server's HTTP status onto the same table, keeping the server's own message: 401 and 403 exit 3, 400 exits 2, 409 exits 4, and everything else, including rate limiting, exits 1. A write whose outcome the CLI cannot know, such as a timeout mid-delete, exits 4 and says so rather than guessing.

The automation guide walks a jq pipeline through this envelope end to end.

Fixes that rode along

A parity release is also a chance to close the defects a first release surfaces. These landed between the two tags, each with a regression test:

  • Numbers are written as fixed-point text, never scientific notation, including the opening balances bea init renders. A ledger that says 1E+3 is technically valid and practically unreadable.
  • Cost lots survive JSON serialization with their dates and labels intact, and lot labels are escaped correctly when a transaction is written.
  • Explicit zero postings are real amounts during import, rather than being read as "omitted, please balance me."
  • CSV imports go through one strict reader. Header discovery used to strip column names while extraction kept the raw keys, so a padded header the docs promised to accept failed as a missing column. Now names are stripped once, a mapped column must appear exactly once, and an unclosed quote fails with its line number before anything is written.
  • BQL loads the exact ledger path rather than a URL-parsed connection string, so unusual paths resolve the way the rest of the CLI resolves them.
  • bea balance <term> totals only what it shows. A retained parent no longer reports the totals of excluded siblings, an unrelated unpriced holding no longer fails a USD selection, and the envelope reports the filter that was applied. A malformed --account pattern on reports exits 2 as the usage error it is.
  • JSON-mode stderr is always one object, even when tolerated warnings precede the failure.
  • Hosted credentials fail early and consistently: a BEA_TOKEN containing whitespace is rejected before any request, a revoked credential is reported the same way by cloud status and by ledger commands, and owner/name is validated before a confirmation prompt or an authenticated call. cloud logout leaves BEA_TOKEN alone, and cloud ledger list --json echoes the page it actually served.
  • The Homebrew formula pins the exact PyPI artifact URL, so a tap install and a PyPI install are provably the same bytes.

How it was verified before you saw it

A release is a claim, and the pipeline is the evidence. A cli-v0.2.0 tag has to name a commit on main whose pyproject.toml version matches exactly; the workflow refuses anything else, including prerelease suffixes. From there:

  1. The full check suite runs first. make check-all covers lint, formatting, strict mypy, dead-code detection, the generated-reference drift check and the test suite. The release pull request records 635 tests passing.
  2. The engine lock is exported and hash-pinned, and the source distribution and wheel are built once. Every later step tests those exact artifacts, not a rebuild.
  3. Clean installs on three operating systems and two Pythons. The wheel is installed through uv tool and the sdist through pip on Linux, macOS and Windows, on Python 3.12 and 3.14, including the optional AI extra. A Homebrew job installs the sdist through a temporary tap on macOS and Linux.
  4. Publication is sequential and tokenless. PyPI receives the artifacts through trusted publishing, so no long-lived API token exists to leak; the GitHub Release is created with publish attestations attached; and Formula/bea.rb is pushed to the public tap with the sdist URL and hash PyPI actually served.
  5. Post-publication smoke tests install from the real indexes. Separate jobs install the pinned version from PyPI and from the public tap and run the same customer smoke tests against the installed executable. A failure there does not roll anything back, but it does mean the release needs attention before anyone is told about it.

This post is being written on the far side of step five.

Upgrading from 0.1.0

Run the upgrade through the manager that installed your copy, or let bea do it:

$ bea upgrade --check      # report installed and latest versions and the command that would run
$ bea upgrade              # brew upgrade bea, uv tool upgrade beancount-io, or pipx upgrade beancount-io

After the manager finishes, bea upgrade refreshes the managed engine so the two stay paired. Then check three things:

  • Any script that ran bea format PATH to rewrite a file now needs bea format -i PATH. The old default could not be previewed, and the new one can.
  • Any script that relied on format to catch a syntax error should call bea check for that, because formatting no longer parses.
  • PyPI installs need network and uv once for the first local command after upgrading, so the engine can be provisioned. Homebrew installs need nothing.

Everything your scripts already parse, the envelope keys, the decimal strings and the exit codes, is unchanged. The bea field in the envelope now reads 0.2.0.

What this release does not do

  • Hosted targeting is not implemented. There is no --ledger flag; local commands read local files and never upload one implicitly. Hosted ledgers are managed under bea cloud and worked on as git clones.
  • bea ask still needs the ask extra and Beancount.io credentials, and it does not support --json. The default install carries no AI dependencies.
  • Beangulp and Beanprice are opt-in, and Beangulp needs the system libmagic library. bea import --csv covers bank exports without either.
  • Forwarded native commands do not emit the envelope. If you need structured output from a doctor operation, that is a request we would like to hear.

Since the tag, main has already picked up the first round of QA on 0.2.0, and it will ride the next release: bea format reads stdin as a filter and its -o FILE mode answers with an envelope naming what it wrote; --json check refuses bean-check-only flags, and --json is refused outright on doctor, example and treeify so a script cannot mistake native text for an envelope; --json query -o FILE writes the envelope to the file atomically, with --numberify applied to JSON as well; bea engine status names which engine tier is serving; a BQL query that opens with a comment runs; native pass-through --help works before the engine is provisioned; and the query shell's .output restores the original stream after a failed redirection.

Where to go next

Keep your books as code

A toolchain you can install in one line is a toolchain you can hand to anyone: a cofounder, a bookkeeper, a CI runner, an AI agent. Beancount.io provides plain-text accounting that stays transparent, version-controlled and reproducible, with bea as the command that keeps a local ledger honest and the hosted service as the place your team, your phone and your assistant meet the same books. Install bea and run your first check, and if the release does something you did not expect, the GitHub repository is where we want to hear it.

Share this article

Source: https://beancount.io/blog/2026/09/16/bea-0-2-0-one-install-whole-beancount-toolchain

Published: September 16, 2026