easysql 0.1.1

getting to the sql prompt, quick and easy - saved connections, their passwords and their tunnels in one CLI + TUI
<!--
AI-ONLY DOCUMENT. This file exists to give an AI agent the COMPLETE operating picture for this repo. Optimize for completeness and precision for the agent, not for human readability. Humans read README.md instead. Do not remove detail to make this nicer, err toward more explicit, not less. FORMAT: machine-read, not a formatted human doc. Do NOT hard-wrap lines to a column width for readability; put each rule/point on ONE line, however long.
-->
# AGENTS.md

Working brief for an AI coding agent, not documentation for people (the README covers that): the rules, invariants and gotchas needed to change this project correctly without rediscovering them.

## Hard rules
- Commit, push, and publish only when the user says to ship; a mid-work commit is never the deliverable, because the user tests interactively first.
- Commit messages are short single-line conventional ones (`feat:`, `fix:`, `chore:`, ...), never with a `Co-Authored-By` trailer and never with a verbose body.
- Release flow, in this exact order: ask whether this shipment gets tests and write them only if the user says yes -> bump `version` in `Cargo.toml` -> `cargo fmt --check` clean, `cargo clippy-all` clean and `cargo test` green, which is also what refreshes `Cargo.lock` with the new version -> `cargo +1.88 msrv` clean, the only thing that proves the `rust-version` floor in `Cargo.toml` is real -> one commit -> `git push origin main` -> `cargo publish` (dry-run first, publishing is irreversible) -> tag only after publish succeeds with `git tag vX.Y.Z && git push origin --tags`; a tag must never point at a version that failed to publish, and the bump comes first because `cargo publish` fails on a `Cargo.lock` that still holds the old version.
- Tests are proposed at ship time and never before: the first step of the release flow is to ask the user, in plain words, whether this shipment gets tests, and they are written only on a yes, so the decision is always theirs but the question is never forgotten.
- Never write a test for behaviour that has not shipped yet, because code that is not in the last release tag is still being designed, and a test pinning a shape that is about to change is how a suite starts lying.
- A test may only assert something the README or `--help` promises, or a pure-logic invariant (parsing, generation, path resolution, validation); never the shape of a private function and never the specific diff that was just made, since those rot on the next refactor and teach nothing about whether the program works.
- Removing a promise from the README removes its tests in the same commit.
- A test may only write inside a temp directory it deletes, never a real config, data, cache or content directory and never a fixed path, so a machine is left exactly as it was before the suite ran.
- Never drive the interface to test it: build it, say what changed and what to look at, and let the user run it, because they see the screen instantly while an agent driving a pty or a tmux pane is slow and wrong about what it looks like; logic that is not visual can still be checked directly from `tests/`.
- Never `cargo install` to test: run the release binary at `./target/release/esql` directly, because installing replaces the binary on PATH with a work-in-progress build; install only when the user asks.
- `main` is protected: no force-push and no history rewrite, so a mistake is fixed with a forward commit.
- No em-dashes anywhere (code, comments, README, `--help`, crate description, commit messages, prose), because they read as AI-generated text; use `-` instead.
- Fix the root cause, and if a workaround must ship say the word "workaround" out loud so a silent patch never passes as a real fix; the same goes for lints, where an `#[allow]` is never the answer and the code it points at gets fixed or deleted.
- `TODO-LIST.md` (gitignored) holds one-line ideas, and the line is deleted when the idea ships.
- The demo rig is standardised across every crate in this directory, and the three parts that make the frames match must stay identical: the staged shell wears the invented `user@host` prompt written by `write_demorc`, every tape sets `Set Theme "Catppuccin Mocha"` (VHS's own default is a near-black that is harsher to read than Catppuccin's `#1e1e2e`), and every tape sets `Set FontFamily "JetBrainsMono NF"`. It is not about hiding a username, which is no leak - the pictures are a build output, and one that comes out different on every machine that regenerates it is not reproducible.
- Everything staged runs under `env -i` with a complete allowlist, never the real environment plus overrides, because an exported variable nobody thought of is exactly how a real config dir, a real endpoint or a real key ends up in a frame. The rig also writes only inside the stage, so nothing of the renderer's is touched on disk.
- Teardown goes through `assert_safe_to_delete`, word-for-word the same function in every crate's rig: the stage path must be absolute, must not be a system directory or the renderer's home, must resolve through its symlinks to a tree carrying the marker file the build stamped, and must have nothing mounted under it, with `rm -rf --one-file-system` behind that. Test the refusals after touching them, never just the happy path - an sshfs mount inside a staged home, torn down with a plain `rm -rf`, has already deleted the dotfiles on the machine at the far end.
- `.nocommit/` (gitignored) holds reference material used only to inform work here - other projects, notes, drafts - and never ships; keep it out of anything user-facing (commit messages, code, comments, README, `--help`), since a reference to material nobody outside this machine can see means nothing to them and just clutters the record.
- **easysql is a front end and never a client.** It does not speak any wire protocol, does not link a driver, and never invents a store where the client already has one: it writes `~/.pg_service.conf`, `~/.pgpass`, `~/.my.cnf` and hands the terminal to the real `psql`/`mysql`/`sqlite3`. That is what keeps `\c`, `.psqlrc`, every script and every GUI on the machine working, and it is what makes uninstalling easysql cost nothing. A feature that requires holding a connection open is out of scope, not a missing dependency.
- **Never leave somebody with a tool that does not work.** `cargo install easysql` cannot bring the clients, because they are not Rust; that is a reason to handle it, not an excuse. Anywhere a missing client can be discovered, easysql names the *one* command this machine needs (`engines::install_argv` walks PATH for apt-get/pacman/dnf/zypper/apk) and, in the TUI, offers to run it suspended so sudo and the package manager can both prompt. Printing three distros' worth of guesses is not help, and an errno from a failed spawn is not an error message. When a manager or a package name is not known for certain, `package()` returns `None` and we name the program rather than inventing a package that does not exist.
- **A password is written, never held.** It is typed into a `Kind::Secret` field, goes straight into the file that engine's client reads, and is never echoed, never previewed, never logged, never put in an environment variable and never put in an argv - `ps` shows a command line to every user on both machines, which is why MySQL itself warns about `-p<password>`. Nothing in this crate ever reads a stored password back out; the UI only ever shows *that* one exists.
- Tests never touch the real `~/.pg_service.conf`, `~/.pgpass`, `~/.my.cnf` or `~/.config/easysql`: use `App::empty()` and the `*_in(path, ...)` / `load_from(path)` helpers, which keep everything on temp files and in-memory data.
- Do not grow the CLI. It holds connect, `ls` and `self` only, all things faster to type than to click. Anything a user would have to look up belongs in the TUI, which is the whole point of the tool.

## Invariants and gotchas
- When touching a connection file: every write goes through `ini::upsert`/`ini::remove`, which back the file up to `<name>.bak.<epoch>` first and rewrite only the one section, copying every other line, comment and blank through verbatim. A user's hand-written `connect_timeout` and their ordering are not ours to reformat.
- When rewriting a section: keys the wizard did not ask about survive, because `Section::rest(&KNOWN)` hands them back and the caller appends them. Adding a field to a wizard means adding its key to that engine's `KNOWN` array in the same edit, or the value gets written twice - once as a field and once as a leftover extra.
- `sslmode` is the exception: it is an extra that the Postgres wizard *does* own, so `submit_prompt` strips it from `extra` and re-adds it from the choice field. Index 0 of `SSLMODES` is "(unset)" and means removing the key, not writing the string.
- When touching MySQL: only `[clientNAME]` groups are ours. `[client]` itself is read by every MySQL tool on the machine, so writing a host into it would silently redirect `mysqldump` and everything else; `name_of` returns `None` for it and for `[mysqld]`. An edit preserves the group's existing `password` key, because the connection wizard never shows it and a rewrite would otherwise delete it.
- When writing `~/.my.cnf` or `~/.pgpass`: chmod 0600, via `ini::harden`. This is not hygiene - libpq flatly refuses a world-readable `.pgpass` and MySQL ignores a world-writable `.my.cnf`, so the file simply does not work without it.
- `.pgpass` is `host:port:database:user:password` with `\` escaping `:` and `\`, and `*` as a wildcard in any of the first four. `set_pg` replaces the entry whose first four fields match rather than appending, because libpq takes the first match and a second line would never be reached. `Source::Pgpass(i)` counts *entries*, not lines, so comments cannot shift which row a delete removes.
- The `.pgpass` Database field defaults to `*` when saving a password, because a Postgres password belongs to the *role* and roles are cluster-wide: the same secret unlocks every database on that server, so naming one only makes `\c elsewhere` prompt. It stays editable rather than being forced, because behind a pooler (pgbouncer, an RDS proxy) the database name really does select a different backend with different credentials.
- `e` on the Passwords tab moves an entry's match fields and keeps its secret (`creds::rekey_pg`, keyed by entry index). The password is copied line-to-line and never surfaced, which is the same handling every write already gives it: rewriting the file at all means the other entries pass through memory. The invariant is that easysql never *shows* a stored password, not that it never touches the bytes. Only `.pgpass` has entries of its own - a MySQL password lives inside its connection's `[clientNAME]` group, so `e` there points at the connection instead.
- `o` on the Passwords tab opens `~/.pgpass` or `~/.my.cnf` in `$VISUAL`/`$EDITOR`, suspended like a session, after `ini::backup`. It exists because libpq takes the *first* matching line, so which of two overlapping entries wins is decided by their order and no wizard field can express that; the backup is because an editor is the one write path easysql does not control. The editor string is split on whitespace like a client command, so `EDITOR="code -w"` works. `refresh_all` after the run re-reads the file, so nothing else is needed.
- Snippets are plain `.sql` files in `~/.config/easysql/snippets`, run as `esql <conn> :<name>` with each engine's own flag (`Engine::query_flag`: `-c`, `-e`, `-Q`, and `None` for sqlite3, which takes the query as a bare argument). The `:` sigil is psql's own and cannot collide with a client flag or a database name, so a bare word after the connection still passes through untouched. They are deliberately not engine-scoped: a Postgres-only query simply fails on MySQL, and the server's error is better than any dialect guess easysql could make.
- `snippets::sync_psqlrc` regenerates a marked block in `~/.psqlrc` (`\set name '…'`) so psql expands `:name` at its own prompt too - psql is the only client with variables, which is why the shortcut has to live at the shell for the other three. It is called from `refresh_snippets`, so every reload (startup, `r`, save, delete, returning from `o`) keeps it in step; it writes nothing when the content is unchanged, which matters because it also runs on the way into every session and would otherwise drop a `.psqlrc.bak.<epoch>` per connect. Flattening strips `--` comments, or they would swallow the rest of the one-line value.
- `run_suspended` sets SIGINT/SIGQUIT to `SIG_IGN` in the parent and back to `SIG_DFL` in the child's `pre_exec`, the way `system(3)` does. Ctrl-C at the terminal goes to the whole foreground group, so without it Ctrl-C inside psql kills easysql mid-suspend and the shell is left in raw mode. `SIG_IGN` survives exec, so the child must reset it or Ctrl-C stops working in the client too.
- The `hints` setting prints one orientation line before the terminal is handed over, from `Engine::hint()` (each client's own words for list databases, switch, list tables, describe, help, quit) wrapped by `engines::hint_line`, which appends this machine's `:names` for psql alone because psql is the only client that can expand them. It fires only when a session is being opened, never for `esql <conn> :snippet` or `esql <conn> -c '...'`, because a one-shot prints its own result and a hint above it is noise in a pipe. In the TUI it is printed *after* the alt-screen is gone, or it would be wiped along with it.
- A new tab means a `View` variant, its `title()`, a state field, a `*_rows()`, and arms in `render`, `input`, `detail` and `move_sel`. The tab strip is built from `VIEWS` via `View::title()` on purpose: it used to be a second hardcoded list beside it, and adding Snippets made every tab after it render under the previous one's name. **Both tapes count `Tab` presses**, so a new view between two existing ones silently re-aims every screenshot after it.
- When identifying a connection: use `Conn::key()` (`<slug>:<name>`), never the bare name. Two engines are allowed to both have a `prod`, which is why history, selection and `reach` are all keyed on it and why `engines::find` refuses an ambiguous bare name instead of guessing.
- `Conn::connect_argv` is the single source of what runs. The CLI's `exec`, the TUI's suspended run and the wizard's live preview must all go through it, or the preview lies about what Enter will do.
- When touching the failure path: `tui/probe.rs` re-asks the server non-interactively (`select 1`, `-w` so nothing can prompt, `PGCONNECT_TIMEOUT`/`--connect-timeout`) and classifies the client's *own literal strings*. Keep those strings verbatim; paraphrasing them turns an honest diagnosis into a guess. The probe runs only after a failed connect, so the success path costs nothing.
- Two kinds of modal, and they must not look alike: `Confirm::new` is a gate in front of something irreversible, so it is red and starts on **No**; `Confirm::offer` is the app volunteering a fix after the client already failed, so it is cyan and starts on **Yes**. An offered fix opens the wizard that fixes the thing with every field pre-filled, rather than doing anything itself.
- When probing reachability: only `Engine::networked()` connections are probed, and a target whose port will not parse is dropped rather than probed on port 0. A TCP connect answers "is anything listening", never "can I log in" or "does that database exist", and the labels must not claim more. Answers carry `reach_gen`; one from an older generation is discarded rather than repainting a list that has moved on.
- When filtering: `/` starts a query over the current list only, and it is dropped when you switch view. A selection indexes the *filtered* rows, so every `selected_*` goes through `*_rows()`; indexing the raw vec picks the wrong entry the moment a filter hides anything. `filter::matches` filters and never re-ranks, since the Connections order is already earned by `history`.
- When touching list order: Connections is sorted by `history::rank` (most recently opened first, then never-used alphabetically), so `refresh_conns` must call `sort_conns` and `App::new` must load the history *before* the first refresh. `ConnOrder` has three answers, not two: `Engine` groups by `Engine::idx()` (the order `ENGINES` declares, not the slug - `lite/my/pg` is an abbreviation nobody asked to read) and then by name inside each, while `Name` sorts on the name alone and falls back to the engine only to keep the order total. `Engine` used to be called "alphabetical", which described what it does *within* an engine and hid the grouping, so `set` still accepts that old spelling and maps it to `Engine` rather than silently reordering an existing settings file. `esql ls` stays in that fixed order: a script's output must not depend on what you did yesterday.
- When an action makes something, jump to its view with `goto_view` and select the new row (`select_conn`, `select_tunnel`). Assigning `self.view` directly skips the filter clearing and can land you on a tab that hides the very thing you just made.
- When adding a setting: put it in `settings.rs` only - a field, a default, a `set` arm and a `rows()` entry - and the Settings tab, the file and the `d`-to-default key all pick it up with no UI work. A setting whose default is the empty string needs an explicit arm in `reset`, because `set` reads an empty value as "leave it alone". A setting the running app acts on must also be re-applied in `apply_settings`, or it only takes effect at the next launch.
- `package(PackageManager, Engine)` is exhaustive with **no catch-all arm**, on purpose: adding an engine or a package manager is then a compile error naming exactly which cells are still owed, instead of a feature that silently ships half-wired. Keep both sides enums and never introduce a `_ =>`; a cell we genuinely do not know is an explicit `None`, not a fallthrough. Detection probes PATH for each manager's program rather than reading `/etc/os-release`, because what is installed is the fact that matters and a distro a machine claims to be is not always the thing managing it.
- Only the apt-get row of `package()` has been verified against a real machine. The pacman, dnf, zypper and apk names are asserted, not tested, and the check is one `pacman -Si <pkg>` / `dnf info <pkg>` per row on a box that has each. Do not add a manager whose package names you have not actually looked up.
- The install offer only fires for the *default* client. Somebody who pointed `psql_command` at `pgcli` or a docker wrapper meant it, and installing `postgresql-client` is not what they asked for; that case gets a status naming the setting instead. `ConfirmAction::InstallClient` is also the one confirm arm that returns a `PendingRun`, because the install has to own the terminal like a session does.
- When adding a TUI action: `c` creates in every view, `d` deletes or kills, `e` edits, `r` refreshes, `y` yanks the command and `Y` the URL, `p` saves a password, `t` opens a tunnel. Destructive persistent actions get a yes/no gate; trivially redone ones act at once.
- When an action needs a value the app already knows, open a picker instead of a text field. The engine of a new connection, the connection a password is for, and the ssh host a tunnel goes through are all picked; the tunnel's host field is typed OR picked, with `Ctrl-o` opening the picker on it (matched by the label containing "ssh host", so renaming that label silently breaks it).
- When spawning anything: children run without a shell, so a typed `~/db.sqlite` reaches `sqlite3` as a directory literally named `~`. Expand with `ini::expand_tilde` before spawning, and use `ini::collapse_tilde` for display only.
- When suspending the TUI: call `show_cursor()` after leaving the alt-screen, because the draw loop hides the cursor and leaving the alt-screen does not restore it, so the client would otherwise run with an invisible cursor.
- Status messages are transient (`set_status`, cleared after `STATUS_TTL`); the event loop polls while a message is showing or a probe is in flight and blocks otherwise, so an idle TUI costs nothing.
- Modal and wizard boxes size to their *wrapped* rows via `wrapped_line_count`, not their line count, and the height must count the borders (2) on top of padding and the button row, or the answer buttons get clipped and the modal looks unanswerable. `centered` takes a width *percentage*, so measure against `area.width * PROMPT_PCT / 100 - 2`.
- The detail panel renders only above `MIN_WIDTH_FOR_DETAIL` and reads only what is already loaded: never a fresh query, never a connection of its own. The one exception is a `Path::exists` on a local SQLite file, which is a stat on this machine and cannot block.
- A connection can *remember* the forward it needs, in `~/.config/easysql/vias`, keyed by `Conn::key()`. That file is ours and not the client's for a reason that was tested rather than assumed: libpq validates every keyword in `~/.pg_service.conf` and refuses the whole file over one it does not know (`psql: error: syntax error in service file`), so a `via=` key there would break the connection for psql, pgAdmin and every driver on the machine. `vias::ensure` is called on the way into a session from *both* the CLI and the TUI, and it is a no-op when `tunnels::carrying(local)` already finds a live `-L` on that port, so a second run never digs a second tunnel. Deleting a connection deletes its via, or the file fills with forwards for things that no longer exist.
- A connection whose via is recorded but whose forward is not running is `◐` and yellow, never `●` red: the port really is closed, but "down" blames the database for a tunnel that is one keypress from opening. `detail::sleeping_via` is the single test, and the list mark and the detail line both ask it.
- `vias::points_at_it` catches the other half of that disagreement: a via is recorded but the connection still asks for the far address, so `ensure` opens a forward nobody uses. The detail panel says so and never repairs it, because which half is wrong is the user's call.
- The tunnel wizard's first field falls back to `vias::default_host` when the `tunnel_host` setting is empty: this connection's own last hop, else the single host every other via uses, else nothing. It never guesses between two bastions, and it never overwrites the setting, because somebody who named one meant it.
- A rename changes `Conn::key()`, so **everything keyed on it has to move in the same save**: `vias::rename` and `history::rename` are both called from the edit path. Miss one and the symptom is silent - the connection forgets the tunnel it depends on, or drops to the bottom of the list as though it had never been opened. Anything new that is keyed on `key()` belongs in that same block.
- The tunnel wizard carries the key of the connection it was opened for (`Action::Forward { key }`), which is what lets it record the via and offer the repoint the moment the forward is up. Without that, the connection still points at the far address and only finds out by failing a second time.
- The far end of a `-L` is resolved *by the ssh host*, not by us, so `prompt::forward_target` rewrites the target to `127.0.0.1` when `sshhosts::is_same_machine` says the database is on the very machine being hopped through. This is the common case and getting it wrong is invisible: the tunnel comes up fine and carries nothing, because a server bound to loopback does not answer on its own LAN address even from itself. It is re-resolved on *leaving* the ssh-host field (`leave_field`), not only when the picker fills it, because that field is typed or picked.
- Tunnel liveness reads `/proc` and shells out to `kill`, so it is Linux-only and macOS or BSD would need a different check first. A refused forward does *not* kill ssh, so `forwarding_failed` matches OpenSSH's own phrases and cleans up, or a phantom tunnel is listed carrying nothing.
- SQL Server is the exception to almost everything, twice over. **Its list is ours**, in `~/.config/easysql/mssql.conf`, because no file both clients read exists: `go-sqlcmd` keeps YAML contexts in `~/.sqlcmd/sqlconfig` and the classic ODBC `sqlcmd` from `mssql-tools18` has no config file at all, so `mssql::flags` builds plain `-S host,port -d db -U user`, which both builds understand. Note the comma in `-S host,port`: sqlcmd does not take a colon. **And it stores no password**: `-P` is never passed (Microsoft's own docs call it insecure) and `SQLCMDPASSWORD` would mean holding a secret and handing it to a child, so sqlcmd prompts on the terminal we already hand over. `Engine::stores_password()` is the gate, and it is what the `p` key, the password picker and the detail panel all ask - never `networked()`, which SQL Server also answers yes to.
- `trust_cert` is to SQL Server what `sslmode` is to Postgres: an extra the wizard owns, stripped from `extra` and rebuilt from the choice field on every save, with index 0 meaning the key goes away. It defaults to validating, because ODBC driver 18 encrypts and validates by default and waiving that silently would be easysql weakening somebody's connection for them; `-C` is a deliberate answer for a self-signed dev server.
- SQL Server has **no distro package**, on any manager: `package()` returns `None` for all five, so no install offer can fire and `install_note` names Microsoft's repo and `https://aka.ms/go-sqlcmd` instead. Anything that promises the offer (the CLI's third line) must check `install_argv(...).is_some()` first. `mssql-tools18` installs to `/opt/mssql-tools18/bin`, which is not on PATH, so a machine that has it still looks empty until the user adds it or points `sqlcmd_command` at the full path.
- The sqlcmd strings in `tui/probe.rs` ("Login failed for user", "TCP Provider", "Login timeout expired", "Cannot open database") are asserted from the documented SQL Server errors, **not verified against a real server** the way the libpq ones were. Check them against an actual `sqlcmd` before trusting the diagnosis, and treat a wrong classification here as a bug, not a tuning question.

## Build / lint / test
- `cargo build --release`, binary at `target/release/esql`.
- `cargo clippy-all` is the lint pass, aliased in `.cargo/config.toml` to `clippy --release --all-targets -- -D warnings`; use it rather than a bare `cargo clippy`, which skips `tests/` and `examples/` and only warns where the release flow wants a failure.
- `cargo test`.
- `cargo fmt` formats the crate and `cargo fmt --check` fails when anything has drifted; the whole crate is rustfmt clean, so formatting is never a judgement call and never a review comment.
- `cargo +1.88 msrv` checks the crate against the `rust-version` floor it advertises (alias in `.cargo/config.toml`), and when the code starts needing a newer compiler both that floor and the toolchain in this line move together.
- `dev/db.sh up` starts a throwaway postgres and mariadb in podman (or docker) to point connections at; `dev/db.sh down` removes them. The *clients* still have to be installed on the machine, because easysql never talks to a database itself.
- `tests/readme.rs` is the same file in every sibling crate: it pins the README block between `<!-- desc:start -->` and `<!-- desc:end -->` to `Cargo.toml`'s `description`, and `UPDATE_README=1 cargo test` rewrites the block rather than editing it by hand. Beside it there are `#[cfg(test)]` modules in `ini`, `creds`, `engines` (`mod.rs` and `mysql.rs`), `settings` and `snippets`, all pure logic: the `[section]` rewrite, the `.pgpass` escaping and first-four-fields replace, the argv each engine builds, what `set` accepts, and the `.psqlrc` splice. They write only inside a temp directory they delete, and the first step of the release flow is still to ask whether the shipment being prepared gets more.

## The README pictures
- `demo/` is the rig that renders every image in `readme-assets/`, and it is committed because it is the *source* of those assets the way a build script is the source of a binary. Never capture a frame by hand and never crop one: change the tool, rerun the tape.
- `demo/stage.sh up` builds a fake HOME in `demo/home/` (gitignored), seeds every file easysql reads, starts two throwaway podman containers on 127.0.0.1:55432 and :53306 so a session really opens, and stamps `.easysql-demo-stage`; `down` refuses to delete anything without that marker. `./stage.sh shell` opens a shell where `esql` is this build, and it **refuses to open one when the stage is missing** - without that guard a mistyped path renders the real `~/.pg_service.conf` into a published image, which has already happened once in this repo.
- Three tapes, run one at a time (two sharing the stage delete each other's fixtures mid-take, and the symptom is identical images rather than an error): `shots.tape` for the TUI stills, `cli.tape` for the CLI ones (its own tape because the frame is sized to its content), `demo.tape` for the GIF. All three set `Catppuccin Mocha` and `JetBrainsMono NF`, which is the house style shared with the sibling crates.
- Tabs move with `Tab`, never a number key; a tape that types `2` silently captures the same frame four times.
- Rarer codepoints are a font-fallback risk in VHS: `◐` rendered as a stray `‹`, which is why the sleeping-tunnel mark is a yellow `●` instead. Prefer a colour over a new glyph.
- **A picture that looks wrong is a bug in the tool, not in the tape.** The clipped age column, the absolute `/home/...` path in a sqlite command, `Needs tunnelthrough bastion`, the settings help running off the pane and `nothing selected` on a freshly opened tab were all found this way and fixed in `src/`, not worked around in the rig.
- The README is never allowed to mention `demo/`: it is read by people installing the crate, who got a package with `demo/` excluded.

## Overview
Layout:
- `src/main.rs` - the clap `Cmd` enum and the dispatch match, nothing else.
- `src/commands/<verb>.rs` - one file per CLI command (`connect`, `ls`, `selfcmd`), each exposing `run`.
- `src/engines/` - the seam. `mod.rs` owns `Engine`, `Conn`, `NewConn` and the merged list; `pg.rs`, `mysql.rs`, `sqlite.rs` and `mssql.rs` each know exactly one thing: which file that client already reads, and which argv it wants. Nothing above this line knows which database it is about to open.
- `src/tui/` - the toolbox: `mod.rs` owns `App`, the event loop and the terminal handling, `input.rs` dispatches keys per view, `wizard.rs` is what a submitted prompt does, `prompt.rs` the field machinery, `picker.rs` and `confirm.rs` their overlays, `render.rs` the frame, `detail.rs` the panel beside the list, `filter.rs` what `/` keeps, `widgets.rs` the domain-blind furniture, `probe.rs` reading why a connection failed.
- Domain modules at the top level: `ini` (the `[section]` files two engines share), `creds` (`~/.pgpass` and MySQL group passwords), `tunnels` (`ssh -L`), `vias` (which forward a connection needs, `~/.config/easysql/vias`), `snippets` (the `.sql` files and the `~/.psqlrc` block), `history` (when you last opened what), `reach` (background port probes), `sshhosts` (read-only `~/.ssh/config` aliases), `clip` (the system clipboard), `settings` (`~/.config/easysql/settings`).

`easysql` is a Rust CLI and TUI that makes databases simple: one binary `esql` in place of `psql`, `mysql`, `sqlite3`, `sqlcmd`, a password manager and hand-editing each of their config files. It is a smart front end rather than a reimplementation, so every action shells out to the real client and every file it writes is one that client already reads. Bare `esql` opens a ratatui toolbox with Connections, Passwords, Tunnels, Snippets and Settings tabs and their wizards, while the CLI keeps connect, `ls` and `self`. Crate `easysql`, binary `esql`, repo `git@gitlab.com:safteinzz/easysql.git`, AGPL-3.0-only. It is the sibling of `easyssh`, and the TUI chassis is deliberately the same one: a change to how a tab, a wizard or a modal behaves in one is usually worth making in both.

## Self-repair
If anything here contradicts the code, the code wins; fix AGENTS.md in the same session you notice the drift.