Every team that ships a desktop app eventually learns that the GUI is the worst possible place to test the product, because a Qt window needs a display server, a compositor, a font stack, and a small miracle to run in CI, and the test that clicks a button headlessly is the test that flakes at 2 AM the night before a release. The way out is not a better GUI-testing framework but a rule about where features live: the feature lands in a shared core library, the GUI is one consumer of that library, and the CLI is the other, and once the rule actually holds, the “how do we test the app” problem collapses into “test a library and a command,” both of which a CI runner does in its sleep.

We hold that rule across the whole product line, and this post is about what holding it buys, because “we have a CLI too” is a feature bullet while “the CLI does everything the GUI does, by construction” is an engineering constraint, and the constraint is the part worth writing down.

The rule, stated plainly

No feature exists only inside the window; every button, every dialog, every menu item resolves to a call into the same core the CLI drives, which means there is no hidden code path reachable only from a QML slot and no business logic stranded in an event handler where no test will ever find it. The CLI is not a companion utility or an afterthought bolted on for the release notes, it is the second face of the same product, and in most sprints it lands first, because writing the subcommand is how we find out whether the core API is honest before we spend a week drawing the window around it.

Grexa makes the shape clearest, since it is a search-and-replace workbench whose GUI does per-file previews and its CLI does this:

# Literal search, default output is path:line:column:content
grexa-cli ~/code TODO

# Script with JSON
grexa-cli ~/code TODO --format json | jq -r '.[].full_path'

# Preview a replacement, then apply it
grexa-cli replace ~/code 'old_(\w+)' 'new_$1' --regex --dry-run
grexa-cli replace ~/code 'old_(\w+)' 'new_$1' --regex

The exit codes are deliberately grep-like, 0 when matches or modifications happened, 1 when nothing matched, 2 on error, and that is not an arbitrary choice, it is a promise to every shell script written since 1973 that grexa-cli slots into the exact control flow patterns grep trained us all on, so if grexa-cli ~/code TODO; then does what your fingers already think it does.

What parity buys in practice

The first payoff is CI that tests the real product instead of a test harness shaped like the product, because when the migration planner, the integrity checker, and the schema differ are all subcommands, the pipeline reads like the feature list, and mongreldb-kit-cli is the clearest example of that:

# Which migrations would apply, without applying them
mongreldb-kit migrate plan ./store.kitdb migrations.json

# Drift between the code schema and the stored catalog
mongreldb-kit diff schema.json ./store.kitdb

# Integrity check before a backup window
mongreldb-kit doctor ./store.kitdb

Those three commands are the entire “is the database healthy and is the schema where we think it is” question, answered with exit statuses a shell set -e can gate a deploy on, and the same binary carries the CRUD verbs, sql, fixture create/load, generate types for TypeScript, Rust, and Python, plus the user/role/auth subcommands, so a provisioning script and a developer’s shell history speak the same language as the embedded kit.

LinSync takes the parity rule into a domain where it is usually absent, since diff tools are notorious for being GUI-only toys, and its README states the rule outright: every comparison the GUI does is also a linsync-cli subcommand with stable exit codes, JSON output, and a documented plugin protocol, so linsync-cli compare a.txt b.txt in a pre-push hook is running the same engine the Merge workspace renders, and linsync-cli archive treats a .zip or .tar.zst as a folder in exactly the way the GUI’s archive compare does. It even ships shell completions for bash, zsh, and fish as a first-class subcommand, which is the kind of thing you only bother with when you expect people to live in the tool.

LinSight shows the rule on the daemon side, where linsight-cli list prints the sensor catalogue, linsight-cli read cpu.util --count 5 samples a live sensor, linsight-cli db prune --older-than 7d --vacuum does the history maintenance the settings page offers, and linsight-cli plugin new my-sensor scaffolds a whole third-party plugin crate, so the monitoring dashboard’s full surface, from ad-hoc sensor reads to housekeeping, is scriptable against the same postcard-framed socket the Kirigami window talks to.

The honest inversion

Parity runs both ways, and the part nobody puts in the marketing copy is that the CLI sometimes does more than the GUI, which is fine, because the rule is “everything the GUI does is in the CLI,” not “the two surfaces are identical.” Grexa’s CLI exposes size limits and Unicode normalization controls the window does not surface, and LinSync’s webpage subcommand exists today with its rendered-DOM and screenshot-diff modes gated behind a Cargo feature returning NotImplemented until they are ready, which is the honest way to ship a partial surface: the command exists, the help text exists, the unsupported parts say so with an error instead of silently pretending.

We are comfortable with that asymmetry because the CLI is where power users and scripts live, and a script can absorb a flag the GUI would need a whole settings page to explain, while the reverse, a GUI feature with no scriptable path, is the one that breaks the testing story and therefore never gets to exist.

What it costs

The tax is real, and pretending otherwise would be dishonest: every GUI feature owes a command shape, a help text, a decision about exit-code semantics, and a JSON output schema that cannot change casually once someone’s crontab depends on it, and “what is the stable machine-readable shape of this dialog” is a question that has to be answered during design, not during the bug triage after someone parsed our human-readable output with awk. What makes the tax payable is that the core does all the work and both consumers stay thin, so the CLI is mostly argument parsing over functions the GUI already calls, and the JSON the CLI prints is the same data the window renders, serialized instead of painted.

The old-timer version of this story is that we used to build it this way by accident, because the tool came first and the GUI, when it existed at all, was a wrapper around the tool, and then the industry spent twenty years inverting that, GUI-first apps with a command line bolted on for enterprise cred, and the bolt-on always rotted because nobody’s CI depended on it. Ours does not rot, because ours is load-bearing: the GUI smoke tests run under xvfb, but the product smoke tests are just run-cli invocations, and a release candidate that breaks the CLI fails the gate the same as a release candidate that breaks the window, which is the whole point of the rule, because the day the CLI becomes optional is the day the tests stop meaning anything.