<!--
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
- **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. **The one exception is SQL Server, and only once the user turns on `mssql_passwords`** (below): its client has no password file, so easysql keeps one and hands it over itself - still never shown, never logged, never in an argv.
- 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 it stays distinguishable from the bare word that means ad-hoc SQL. 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; sqlcmd's leads with `GO`, because nothing typed at its prompt runs without it) wrapped by `engines::hint_line`, which appends this machine's `:names` only when the *configured* client really is psql - the same gate the install offer uses, because somebody who pointed `psql_command` at pgcli gets a client that reads neither `~/.psqlrc` nor `\set`, and offering `:name` there advertises a syntax error. The decision lives in `hint_line_from`, kept apart from the `snippets::list()` that reads the disk so it can be tested without a real config dir. 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_target` 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. `connect_argv_db` is the same argv aimed at another database, and the CLI is the only caller that passes a `Some`. The form's preview reaches it through `Prompt::draft_conn`, a `Conn` built from the fields plus the choices that change the argv (`trust_cert`, SQLite's `readonly`); it was once hand-built per engine and silently showed neither `-C` nor `-readonly` while Enter ran both.
- `esql <name>/<db>` is translated per engine and never passed through. psql's `-d` *and* its bare positional both land in libpq's dbname and username slots, so either one demotes `service=NAME` to a username and the host, port and user go with it; `PGDATABASE` loses to the service file outright. The one form that works is `psql "service=NAME dbname=DB"`, because keywords given explicitly beat the service file's own (verified against a real server, in both orders), and that is what `pg::conninfo` builds - quoting the value the way libpq does. MySQL takes `--database=`, sqlcmd's `-d` is replaced, and SQLite is refused in `commands/connect.rs`, since another file is another connection. `engines::find_target` tries the whole string as a name before splitting on the last `/`, so a connection whose name contains one still resolves, and when that whole name is *ambiguous* it refuses rather than splits: splitting ran `esql team/app` against an unrelated `team` on another host. `pg::conninfo` quotes the service name as well as the database, or a name with a space breaks libpq's parse.
- **Read-only is read from the file, never remembered.** `Conn::read_only()` asks the block itself, so a line written by hand turns the name blue exactly like one the form wrote; `pg::read_only` compares the setting's name case-insensitively and reads every Postgres boolean that means on (`on`, `1`, any prefix of `true` or `yes`), because the server does. Postgres keeps it as `default_transaction_read_only=on` inside `options`, which libpq hands the server for every session through that service - psql, pgAdmin and every driver included, and it survives `esql <name>/<db>` because the service is still named. `pg::set_read_only` recognises the three spellings the server accepts (`-c name=v`, `-cname=v`, `--name=v`), keeps every other token in `options` in order and drops the key when nothing is left; the wizard only calls it when the answer *changed*, so an edit that never touched the field cannot respell somebody's own line. The service line alone is **not enough**, verified rather than assumed: pgcli reads the service file itself and drops `options`, so a pgcli session on a read-only connection showed `default_transaction_read_only = off` and took a `CREATE TABLE`. A session in any client other than psql therefore also gets `PGOPTIONS` (`Conn::connect_env`, applied by both the CLI's `exec` and the TUI's `run_suspended`), which every libpq client reads; a `PGOPTIONS` the user already exported is kept and ours is appended so it wins. psql is left without it on purpose: it applies the service's `options` itself, and those beat an exported `PGOPTIONS=...off` (verified), so adding it there only lengthened every displayed command. The detail panel's raw `options` row drops the read-only token, since the blue line already says it; repeating it three times is what pushed the command off the bottom of the panel. Every line that shows or copies the command - `y`, the detail panel, the form preview - goes through `widgets::with_env`, so a pasted line carries `PGOPTIONS` too; without it the copied `pgcli service=x` is writable. SQLite keeps `readonly=yes` in our own `sqlite.conf` and `connect_argv` adds `-readonly`, which sqlite3 enforces on the file; because that flag is sqlite3's own and litecli rejects it (`No such option '-r'`, verified), a read-only connection whose configured client does not speak sqlite3's flags runs with sqlite3 itself, decided inside `connect_argv` so the CLI, the TUI, the preview and `y` all agree, and the CLI says so on stderr - but `.open` on the same file inside the session reopens it writable, so the detail panel carries a caveat for SQLite as it does for Postgres. It is not offered for MySQL, verified rather than assumed: the client honours `init-command=SET SESSION TRANSACTION READ ONLY` in `[clientNAME]`, but `mysqldump` reads that group too and dies on `unknown variable 'init-command'`, and the fix is a `[mysqlNAME]` group easysql would then have to own through rename and delete. SQL Server has no session setting that enforces it. **Before every read-only Postgres session or query, easysql asks the server** (`pg::check_read_only`: psql, `-w`, `-X`, the probe deadline, after any tunnel is up and before the history stamp) and refuses to go in, exit 1 in the CLI and an alert in the TUI, when the answer is `off` or the setting was refused outright. The service's `options` is only a request, and pgbouncer 1.25 was verified to handle it three ways: by default it refuses the connect (`unsupported startup parameter in options`); with `options` in `ignore_startup_parameters` it drops it *silently* and the session could write; with `track_extra_parameters = default_transaction_read_only` it honours it, in session and transaction pooling alike. An answer that cannot be had (no psql, a password it would have to prompt for) is said on stderr and the session goes ahead, because refusing on no evidence would lock people out of their own database. The Postgres one is a guardrail and says so in the detail panel: a session can `SET` it back off, and only a role granted nothing but `SELECT` is a real limit. It is shown by colour, never a glyph: the name turns `READ_ONLY_COLOR` in the list and the detail line says what that colour means in the same colour, because a rarer codepoint falls back to the wrong character in some fonts. `esql ls -v` says `(read-only)` in words, because that line is what a script or an agent reads.
- A choice field with exactly two answers (`Read only`, `Certificate`) is painted as two chips with the picked one filled (`widgets::chip`), and one with more (`Encryption`) as its value between guillemets; `Field::display` returns the chips' plain text so the box is measured against what is drawn.
- The connection form's choice fields are found by *label* in `submit_prompt` (`Encryption`, `Certificate`, `Read only`), because which choices a form carries depends on the engine and a fixed index would read the wrong one. Renaming a label silently breaks its save, the same trap as the tunnel form's "ssh host".
- A one-shot runs with a client that can take one. Every one-shot form is spelled in the real client's own flags - `-c`, `-e`, `-Q`, and `dbname=` inside a libpq conninfo - so `engines::speaks_client_flags` asks whether the *configured* command actually contains that program (`psql`, `/usr/bin/psql` and `docker exec -it db psql` all do; `pgcli` does not) and, when it does not, `connect.rs` swaps that one setting to the default for this run and says so on stderr. A session still opens in whatever `psql_command` names, because that is what the setting was chosen for; only the query and the `/db` are stepped around, and only a default that is missing from PATH is refused. Without it pgcli answers `esql <conn> 'select 1'` with `No such option '-c'` and reads a whole conninfo string as a service name - both seen on a real machine.
- What follows the connection name has four shapes, all decided in `commands/connect.rs`: nothing opens a session, `:word` runs a snippet, a word that does not start with `-` is SQL handed to `push_query` (the same `Engine::query_flag()` door the snippet uses), and anything else - plus everything after `--` - is the client's, verbatim. The bare-word case is deliberately `ssh host 'cmd'`, and it took an argument that was worse than useless before: a second positional reached psql only as a *username*. Without it the reflex is `echo '…' | esql conn`, which opens a *session* with a pipe on stdin, prints the orientation hint into the data when stderr is merged, and exits 0 even when the query failed (psql only returns non-zero for a script with `ON_ERROR_STOP`) - all three verified. `--help` is written for that reader: the shapes are one block at the top, with the stdout/stderr and exit-code promise under it.
- `esql --md <name> ...` is the one run that does not `exec`: the client is a child with stdout piped (stdin and stderr stay the terminal's, the exit code is still the client's) and `markdown::render` redraws what it printed, so easysql still never parses SQL or speaks to a server. It goes *before* the name because everything after the name is the client's. `Engine::table_output` names the flags and the shape per engine, all verified against real clients: psql gets `-q -c '\pset format csv'` rather than `--csv`, because `~/.psqlrc` runs after the flags and a `\pset format` there beat `--csv`, and `-q` keeps a script's `SET` tags out of the rows; mysql `--batch` (TSV, backslash-escaped); sqlite3 `-csv -header`, which do beat `~/.sqliterc`. SQL Server is refused, since sqlcmd has no quoted output. Clients print consecutive results with nothing between them, so a change in field count starts a new table and two results of the same width merge. It counts as a one-shot for `use_default_client_for_one_shot` even behind a passthrough like `-f`, since the flags it adds are the real client's. A bare session under `--md` is refused.
- 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; the one exception is a read-only Postgres connection, which asks once before going in.
- `Confirm` is two of the seven kinds of box, told apart by its `danger` flag: `Confirm::new` is a gate in front of something irreversible, red, starting on **No**; `Confirm::offer` is the app volunteering a fix after the client already failed, cyan, starting on **Yes**. An offered fix opens the wizard that fixes the thing with every field pre-filled, rather than doing anything itself. A failure with no fix to propose is not a `Confirm` at all: it is an alert (`tui::alert`, yellow, dismiss only), which is where a client that could not be started ends up.
- 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`.
- **A dot is only true while nothing has moved, so re-probe whenever something has.** `start_probes` runs on `App::new`, on `r`, on a settings change, after adding or editing a connection, **after any tunnel goes up or down** (the wizard, `vias::ensure` on the way into a session, and the Tunnels tab's `enter`/`d`) and **on return from a suspended session**. Miss one and the list lies in the most confusing direction available: a connection shows red `down` while pressing Enter on it works perfectly, because the forward came up after the only probe that ever ran.
- 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`, `select_snippet`). The same goes for coming back from a session: the connection just used moves to the top of the list, so `select_conn` puts the cursor back on it, or the cursor stays on a row number that now names another connection. 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, and on the Tunnels tab `↵` turns a forward on or off (the same key does the same thing in easyssh, and normalising the two was deliberate). 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.
- Every overlay goes through the house box in `tui::widgets` (`box_block`, `box_buttons`, `box_hint`, `box_width`, `box_inner_width`, `box_height`, `box_area`), never a hand-rolled `Block`. Measure the body with `wrapped_line_count(text, box_inner_width(width))` and hand the *wrapped* count to `box_height`, which adds the chrome; counting lines instead is what clipped the buttons off. The percentage-width `centered` and `PROMPT_PCT` were deleted rather than kept beside it, because two ways to size a box is how the padding stopped being accounted for in one of them.
- 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 yellow, never red and **never green**: `detail::sleeping_via` is the single test (it returns the `Via`, so the line can name the port), and the list mark and the detail line both ask it. Red would blame the database for a tunnel that is one keypress from opening; green is the dangerous one, because a probe that answers on the near end of a forward that is not running is answering for whatever else holds that port - a local Postgres on 5432, say - and going in without saying so is how a migration lands on the wrong server. The detail line says which of the two it is.
- `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. It matches `/proc/<pid>/cmdline` against *this* forward's flag, spec and host rather than only asking whether `/proc/<pid>` exists: the state file outlives a reboot, and a recycled pid would otherwise read as up and be what `d` kills. 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 by default it stores no password**: `-P` is never passed (Microsoft's own docs call it insecure), so sqlcmd prompts on the terminal we already hand over - which also means an agent cannot run `esql <mssql> 'select 1'` at all. The `mssql_passwords` setting, off until the user turns it on, is the way out: `p` on a SQL Server connection with it off raises an offer that says what easysql would do and turns it on with the password form behind Yes. Kept passwords live in `~/.esqlpass`, one `[name]` per connection, written 0600 through `ini`, in the home folder beside `.pgpass` and `.my.cnf` because that is where people look, and deliberately not in `~/.config`, which people sync and commit (a dotfiles backup of `~/.config/easysql` would have carried plaintext passwords into git); `mssql::password` is the only read of a stored password anywhere in the crate, and it refuses a file others can read, as libpq does with `.pgpass`, so sqlcmd simply asks. The secret travels in `SQLCMDPASSWORD` via `Conn::secret_env`, applied by the CLI's `exec`, the TUI's `run_suspended` and the probe, and deliberately kept out of `connect_env`, because everything that shows or copies a command (`y`, the detail panel, the preview) goes through `connect_env`. It is keyed on the connection's name, so a rename moves it (`mssql::rename_password`, in the same save as `vias::rename`) and deleting the connection forgets it; the Passwords tab lists it whatever the setting says, so one kept while it was on can still be seen and forgotten. `Engine::keeps_password(settings)` is the gate the `p` key, the password picker, the `pw` flag and the detail panel ask; `stores_password()` still answers only whether the client has a file of its own - 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.
- Which wizard fields carry the red `*`, matching what `submit_prompt` refuses: the connection Name in both engine shapes and the SQLite Database file (a server connection needs no database up front, since `\c` moves between them); a snippet's Name and its SQL; the password field itself, in both the Postgres and the MySQL shape; and the tunnel's ssh host, database host and database port, but not its local port, which falls back to the database port. The `.pgpass` matching fields are never starred: a blank there is a real answer, since the line is widened with `*` rather than filled in.
## Build / lint / test
- `cargo build --release`, binary at `target/release/esql`.
- `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.
## The README pictures
- `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`. The stage guard exists here because a mistyped path once rendered the real `~/.pg_service.conf` into a published image. It puts two stand-ins first on the staged PATH: an `ssh` that really forwards `-L <local>` to the staged Postgres and keeps ssh's own words in its argv, because that is what `tunnels` checks before calling a forward alive, so Enter on `warehouse` truly reopens its tunnel (it exits once the stage is gone, so nothing is left listening); and a `vim` that runs with `-u DEFAULTS`, because the real one would pick up the renderer's system vimrc and the frame would differ by machine. `warehouse` is `analyst` on `reporting` behind that forward, and `metrics` has no password on file, so the Passwords GIF has one to save.
- One tape per picture, each run on its own fresh `stage.sh up` (a GIF changes the stage: it saves a password, flips read-only, stops a tunnel, and two tapes sharing one delete each other's fixtures mid-take): `browse`, `edit`, `passwords` and `snippets` for the four feature GIFs, `shots` for the one TUI still (Settings, a screen that shows everything at once), `cli` for the CLI stills. A GIF opens on the toolbox (the launch is hidden), hides its `q` so it never ends on a shell, and types SQL at paste speed with `Type@2ms`. There is no tour GIF on top: each section carries its own.
- Tabs move with `Tab`, never a number key; a tape that types `2` silently captures the same frame four times.
## 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`, and `vias::rows()`, the join of what is running with what is only remembered, which is what the Tunnels tab lists), `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.