Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

sshelf

A fast terminal UI for managing and connecting to SSH hosts. Save each node once, then fuzzy-search and connect in two keystrokes.

sshelf keeps its own host database and generates the correct ssh command for you — it never reads or edits ~/.ssh/config (except an explicit, read-only import). No account, no cloud, no telemetry: your hosts live in a human-readable TOML file on your disk, secrets live in your OS keyring, and the only network activity is the ssh it hands your terminal to.

Get started

brew install max-rh/tap/sshelf     # macOS or Linux
sshelf                             # launch the TUI
  • Install — Homebrew, shell installer, .deb, .rpm, Gentoo, or cargo.
  • Quickstart — the first five minutes: add or import hosts, connect.
  • FAQ & troubleshooting — common questions, quick answers.

What’s in the box

  • An atuin-style fuzzy launcher with frecency ordering — Searching & connecting
  • A dual-pane SFTP file browser (Ctrl-t) — Transferring files
  • Background port forwards that survive quitting (Ctrl-f / F4) — Port forwarding
  • Sites with a shared bastion + defaults, plus free-form tags (F3) — Sites & tags
  • Stored passwords/passphrases auto-supplied at connect, and 2FA code prompts — Passwords, keys & 2FA
  • SSH-config export — one Include line and plain ssh/scp, rsync, and VS Code Remote see your hosts — Exporting to SSH config
  • Import from ~/.ssh/config or your whole Tailscale tailnet (--tailscale) — Importing hosts
  • A scriptable CLI (sshelf add, list --json, print-command, …) — CLI reference

Platforms: macOS + Linux, x86_64 and arm64. Runtime: OpenSSH 8.4+ for password auto-supply.

How it’s built

Deciding whether to trust it — or just curious how the pieces fit?

Contributing

Start with CONTRIBUTING.md, then the Development section in the sidebar: architecture, module map, data model, and the decision log. Docs follow the docs-in-sync rule — every behavior change updates the relevant page here in the same change, with a dated entry in the progress log.

Install

sshelf runs on macOS and Linux, x86_64 and arm64. The prebuilt packages need no Rust toolchain; at runtime sshelf wants OpenSSH 8.4+ on your machine (password auto-supply rides on SSH_ASKPASS_REQUIRE, added in OpenSSH 8.4 — see the FAQ if unsure).

Homebrew (macOS or Linux)

brew install max-rh/tap/sshelf

Shell installer

Downloads the prebuilt binary for your platform:

curl --proto '=https' --tlsv1.2 -LsSf https://github.com/max-rh/sshelf/releases/latest/download/sshelf-installer.sh | sh

Debian / Ubuntu (.deb)

Grab the .deb for your architecture from the latest release, then:

sudo apt install ./sshelf_*_amd64.deb      # or *_arm64.deb

Fedora / RHEL / Rocky / openSUSE (.rpm)

The .rpm is a static build, so one package runs on any RPM distro regardless of glibc. Grab it from the latest release, then:

sudo dnf install ./sshelf-*.x86_64.rpm     # or .aarch64.rpm

Gentoo

Via the community-maintained Masterwolf overlay (unofficial; thanks to @masterwolf-git):

eselect repository enable masterwolf
emerge --sync
emerge --ask app-admin/sshelf

Cargo (crates.io)

Needs Rust 1.88+:

cargo install sshelf

After installing

  • Shell tab-completion (subcommands + flags) ships with every package — open a new shell (or exec $SHELL) so it loads. Completion of your saved host names takes one more line in your shell rc: see Shell completions.
  • On Linux, secrets use the Secret Service (GNOME Keyring / KWallet) through a pure-Rust backend — no libdbus or OpenSSL packages needed. On a headless box with no keyring, see the age vault.
  • Next stop: the Quickstart.

Quickstart

The first five minutes with sshelf.

1. Launch

sshelf

The first run writes a commented config.toml under ~/.config/sshelf/. You land in the (empty) host list — F1 shows every key at any time.

2. Add a host — or import the ones you already have

Press Ctrl-a: the add form opens with sensible defaults, so typing a Name and a Hostname and pressing Ctrl-s is enough for a first host. Auth, jump hosts, tags, and the rest are covered in Adding & editing hosts.

Already have hosts in ~/.ssh/config? Import them — read-only, sshelf copies them into its own database and never writes your config:

sshelf import --dry-run    # preview what would be imported
sshelf import              # do it (or press Ctrl-o in the TUI)

On a Tailscale tailnet, sshelf import --tailscale brings in every machine in one go — same rules, same read-only, add-only promise.

3. Connect

Type a few characters to fuzzy-filter, Enter to connect. sshelf records your usage and then execs into ssh — the TUI is gone and it’s a plain ssh session; when it ends you’re back at your shell. The hosts you use most float to the top of the idle list.

4. Connect even faster

sshelf prod-web            # straight to a saved host by name — no TUI
sshelf -                   # reconnect to the most recently used host

5. Know where your data lives

Your hosts are one human-readable TOML file — ~/.config/sshelf/hosts.toml — safe to hand-edit and to keep in your dotfiles. Secrets are not in it: they live in your OS keyring or an encrypted vault (Passwords, keys & 2FA).

Where to next

Adding & editing hosts

Ctrl-a opens the add form; Ctrl-e edits the selected host. It’s a single screen — every field shows a dim placeholder explaining it (required · for Name/Hostname, optional · elsewhere), and it’s auth-aware: only the fields relevant to the chosen Auth method are shown.

Quick-add: the form opens with sensible defaults, so a Name + Hostname and Ctrl-s is enough.

Fields

Always shown: Name (required), Hostname (required), User (defaults to $USER at connect time), Port (defaults 22), Auth, Jump hosts (ProxyJump chain — key/agent auth only), Tags, Site, 2FA (/ yes/no — prompt for a verification code on connect), Extra args (raw ssh flags appended verbatim — the escape hatch for anything the form doesn’t model, e.g. -X or -o ServerAliveInterval=30).

Auth-specific fields:

AuthExtra fields
agent (default)none — ssh uses your agent/keys as usual
keyKey/ cycles private keys found in ~/.ssh; Enter opens a file browser to pick a key anywhere. Key passphrase — optional, only if the key is encrypted
passwordPassword — stored in the OS keyring / vault, never in a file

Key discovery finds keypairs (a .pub sibling) and standalone private keys including .pem (detected by their PRIVATE KEY header), so AWS-style keys show up too.

The file browser (from the Key field with Enter): type to fuzzy-filter, / move, Enter opens a directory or selects a file, goes up, Backspace edits the filter (or goes up when it’s empty), Esc clears the filter (or cancels when it’s empty). It starts in ~/.ssh (or near the current key); a picked key can live anywhere.

Tab / / move between fields · / (or space) change the choosers (Auth, Key, Site, 2FA) · Enter advances and saves on the last field · Ctrl-s saves from anywhere · Esc cancels. Validation errors (missing name/hostname, non-numeric port) show inline, and focus jumps to the offending field.

Secrets in the form

The masked Password / Key passphrase value goes to the OS keyring (or the age vault) keyed by host id — never into hosts.toml. When editing, leaving the field blank keeps the existing secret. Details: Passwords, keys & 2FA.

Deleting

Ctrl-d on the selected host asks for confirmation (y), then removes the host, its frecency history, and its stored secret.

Prefer the command line?

Everything above can be done non-interactively with sshelf add — see Adding hosts from the CLI. hosts.toml itself is designed to be hand-edited too; the full schema is in Data model & files (that’s also how you give one host multiple identity files).

Searching & connecting

The list screen is a fuzzy launcher in the style of atuin: the search box is always active, so plain typing filters the list, and actions use Ctrl or function keys.

Filtering

  • Type — fuzzy match against your hosts; matched characters are highlighted.
  • tag:NAME — only hosts with that tag. Repeat and combine with text (tag:prod tag:db is an AND).
  • site:NAME — only hosts in that site.

Ordering

  • Idle (no query): hosts sort by frecency — usage count decayed by recency, so your daily drivers sit at the top. The decay rate is configurable, and default_sort = "name" opts out entirely (Configuration). The idle list also groups by site (── site (n) ── headers, (no site) last).
  • Filtering: best fuzzy match first; frecency breaks ties. The list is flat, with a dim ·site· column.

Keys

KeyAction
typefilter the list (fuzzy text, tag: / site: tokens)
/ , Ctrl-p / Ctrl-nmove the selection
Enterconnect to the selected host
Ctrl-a / Ctrl-e / Ctrl-dadd / edit / delete a host
Ctrl-yyank — copy the generated ssh command without connecting
Ctrl-ttransfer files to/from the selected host
Ctrl-fport-forward through the selected host
Ctrl-oimport from ~/.ssh/config (read-only)
F1help overlay — every key, in the TUI itself
F2settings (Configuration)
F3manage sites
F4manage port forwards
Escclear the query if non-empty, otherwise quit
Ctrl-cquit

What “connect” actually does

Enter records the host’s usage (for frecency), tears the TUI down, and execs into ssh — sshelf is replaced by the real ssh process, so there is no wrapper between you and your session, and when the session ends you’re back at your shell. The command it runs is exactly what Ctrl-y (or sshelf print-command <host>) shows: plain flags built from the host’s fields plus any inherited site defaults — no temporary config files. Full mechanics: How the ssh command is built.

Connecting without the TUI

sshelf prod-web       # connect by name (or id) — same path as Enter
sshelf -              # reconnect to the most recently used host

A miss suggests the closest matching names; a host named like a subcommand (list, import, …) is reached via the TUI instead. The rest of the CLI: CLI reference.

Transferring files

Ctrl-t on a host opens a dual-pane transfer screen: your local files on one side, the host’s on the other. Copy files or whole folders in either direction over SFTP, with fuzzy search on both sides and live progress.

sshelf authenticates once: it opens an ssh ControlMaster that reuses the host’s normal auth (keys/agent/ProxyJump — or the stored password, supplied the same way as on connect) and runs sftp over it. No per-file re-prompts, and ~/.ssh/config is never touched. Remote listing and transfers run on a background thread, so the UI stays responsive on slow links.

Keys

KeyAction
typefilter the focused pane
Tabswitch the focused pane (local ↔ remote)
/ , Ctrl-p / Ctrl-nmove the selection
/ Enteropen the selected directory (on a file: send it)
Ctrl-ssend the selected file or folder (recursive) into the other pane’s directory
go up a directory
Backspaceedit the filter, or go up when it’s empty
Esccancel a running transfer, else clear the filter, else close the screen

Behavior & limits

  • Directories are shown as name/ and symlinks as name@symlinks are skipped.
  • A same-named file or folder already present in the destination is skipped (with a message), never overwritten.
  • One transfer runs at a time. Single-file downloads show bytes + percent; folders and uploads show as in-flight (cancelable with Esc).
  • Filenames are shell-quoted (spaces are fine) and control characters are stripped from display.
  • The connection uses StrictHostKeyChecking=accept-new, like connect: a first-time host key is trusted on first use, a changed key still hard-fails. See Security.

Debugging a failing transfer

The status line shows the underlying sftp error. For the full story:

sshelf --transfer-log /tmp/sshelf-transfer.log     # or $SSHELF_TRANSFER_LOG

This appends every ssh/sftp command and its stderr to the file. No secrets are logged — passwords reach ssh via SSH_ASKPASS, never the command line.

Port forwarding

Ctrl-f on a host starts an SSH tunnel that keeps running after you quit sshelf — set it up, close the TUI (or the whole terminal), and it stays up until you stop it or it drops.

Creating a forward (Ctrl-f)

Pick a kind (cycle with /):

  • Local (-L, the default) — a local port that tunnels to something reachable from the server. E.g. reach the server’s private database as 127.0.0.1:8080 on your machine.
  • Remote (-R) — a port on the server that tunnels back to something reachable from your machine. E.g. let someone on the server’s network reach a dev server on your laptop.
  • Dynamic (-D) — a local SOCKS proxy that routes traffic through the server.

Fill in the ports/host — defaults: bind 127.0.0.1, target host localhost — and press Ctrl-s. sshelf spawns a detached ssh -N … reusing the host’s auth exactly as connect does (keys/agent/ProxyJump, stored password, site defaults), then waits briefly to confirm the tunnel actually bound. A failure is shown in the popup so you can fix a field and retry:

  • local port already in use — pick another port;
  • privileged port — ports below 1024 need root; use 1024 or higher;
  • server refused the remote bind — the server’s sshd controls remote binds (GatewayPorts);
  • authentication / DNS failures, reported as-is.

On success you’re back at the list and the tunnel runs on its own.

The forwards manager (F4)

Lists every active forward across all hosts — host, a summary like L 127.0.0.1:8080 → db:3306, pid, and age.

KeyAction
/ , Ctrl-p / Ctrl-nmove the selection
d (or k), then ystop the selected forward
Esc / Ctrl-s / Ctrl-cclose the manager

The list refreshes live and is reconciled against the actually-running processes: a forward that ends — stopped here, killed from another terminal, or dropped on its own after sleep or network loss — disappears within a moment, and on every launch sshelf shows only forwards that are still really up. The ledger lives in forwards.json (data model), but the processes are authoritative — the file is just remembered PIDs. Design details: decisions.md, D-021.

Why they survive

Each forward is its own detached process in its own process group, with no tie to sshelf or your terminal: quitting sshelf orphans it (fine), and closing the terminal doesn’t hang it up. Stop one from F4 — or kill <pid> works too; sshelf notices either way.

Sites & tags

Two ways to organize hosts:

  • Tags — free-form, many per host (prod, db, web). Pure labels: filter with tag:NAME, repeatable and ANDed.
  • A siteone per host (a data center, a project, a customer). Sites group the idle list, filter with site:NAME — and can optionally carry shared SSH defaults that member hosts inherit.

Site defaults & inheritance

A site may define a default user, port, jump host(s) (the site’s bastion), and identity file(s). At connect time each member host is resolved against them:

  • the site’s value fills in only where the host leaves that field unset — the host always wins;
  • auth is never inherited — it stays per-host;
  • a bare site (name only) is pure grouping.

Inherited defaults show up everywhere the command does: connect, Ctrl-y yank, sshelf print-command, transfers, forwards. A host that names an undefined site still groups under that name — it just inherits nothing.

In the list

Idle (empty search box): hosts group under ── site (n) ── headers, with (no site) last. While filtering: a flat list with a dim ·site· column; site:NAME narrows to one site.

Managing sites (F3)

a add · e/Enter edit · d delete · Ctrl-s save · Esc cancel. Each site’s form is a name plus the optional defaults. Renaming a site updates its member hosts; deleting one clears its members’ site — nothing dangles. Assign a host’s site in the add/edit form (/ over the defined sites + (none)).

From the CLI

sshelf sites                                        # sites, member counts, their defaults
sshelf sites --json                                 # machine-readable
sshelf sites add prod-dc -u deploy -J bastion.prod  # define a site with shared defaults
sshelf add web1 -H 10.0.0.4 --site prod-dc          # add a host into it
sshelf list site:prod-dc                            # filter by site

Storage: [[site]] entries in hosts.toml — see Data model & files.

Passwords, keys & 2FA

Prefer SSH keys / agent where you can. Password storage exists for hosts you can’t use keys with; it is the least secure option sshelf offers. The full threat model: Security.

Auth methods

Each host uses one auth method, chosen in the add/edit form:

  • agent (default) — ssh uses your keys/agent as usual; sshelf stores nothing.
  • key — one or more -i identity files. If the key is encrypted, you can store its passphrase and sshelf supplies it automatically at connect.
  • password — sshelf stores the login password and supplies it automatically.

Where secrets live

  • OS keyring (default): macOS Keychain, or the Secret Service on Linux (GNOME Keyring / KWallet). Service sshelf, keyed by host id.
  • The age vault (headless): if SSHELF_VAULT_PASSPHRASE is set, secrets go to an age-encrypted file (vault.age, mode 0600) instead — the path for servers and CI with no keyring daemon. The tradeoffs are documented in Security.

Never in hosts.toml, never on a command line, never in logs or shell history.

How auto-supply works

On connect, sshelf points SSH_ASKPASS at itself and execs ssh. When ssh needs the secret it invokes that helper, which answers only genuine password/passphrase prompts (matched by their shape) and declines everything else — so a hostile server can’t phish the secret with a look-alike prompt, and the secret never appears in ps or on disk. The full mechanics — and why this needs OpenSSH 8.4+ — are in How the ssh command is built.

Storing & changing a secret

  • In the form: the masked Password / Key passphrase field. When editing, blank keeps the existing secret.
  • From a script / headless:
echo "$PASS" | sshelf set-password prod-db        # store or replace after the fact
echo "$PASS" | sshelf add legacy -H 10.0.0.9 -u root --password-stdin

Deleting a host removes its stored secret too.

Two-factor (2FA) hosts

Some servers ask for a verification code (TOTP / keyboard-interactive) on top of your key or password. Set 2FA = yes on the host (form, or sshelf add … --2fa):

  • TUI connect: a popup collects the current code before the ssh handoff and feeds it to the server’s verification prompt through the same askpass channel. sshelf never proxies the live session.
  • CLI connect (sshelf <host>): prompts for the code on the terminal.

Codes are manual entry — sshelf does not store TOTP seeds. The flag exists because a connect that auto-supplies a stored secret runs ssh with SSH_ASKPASS_REQUIRE=force, which routes the code prompt to the helper with no terminal fallback — unflagged, such a connect fails at the code prompt. (A host with no stored secret prompts inline anyway; a host that combines an encrypted key with 2FA but no stored passphrase is better served by the agent.) Background: decisions.md, D-022.

Limitations worth knowing

  • Jump hosts must use key/agent auth. The askpass helper only holds the target’s secret and can’t tell which hop is prompting.
  • Building from source on macOS: an unsigned binary may trigger a Keychain approval prompt on connect (Keychain ACLs are keyed to the code signature) — see the FAQ.

Importing hosts

sshelf can populate its database from two sources you already have: your SSH config and your Tailscale tailnet. Both are read-only towards their source and add-only towards sshelf — a host whose name already exists is left alone, so re-running is always safe.

From ~/.ssh/config

sshelf import (or Ctrl-o in the TUI) copies hosts from ~/.ssh/config into sshelf’s own database. It is strictly read-only: sshelf parses the file and never writes back — your SSH config is not touched, ever.

sshelf import --dry-run    # preview what would be imported
sshelf import              # import

What it does:

  • adds every host whose name isn’t already present in sshelf — re-running is safe, existing names are left alone;
  • carries over the fields sshelf models (hostname, user, port, identity files);
  • warns about directives it doesn’t import — Match, Include, ProxyJump — instead of silently mis-importing them.

From Tailscale

sshelf import --tailscale imports your tailnet — every machine in it becomes a searchable sshelf host, in one command.

sshelf import --tailscale --dry-run   # preview
sshelf import --tailscale             # import

It runs your own tailscale CLI (tailscale status --json) and reads the output. sshelf still makes no network calls of its own: nothing happens unless you run this command — never at startup, never on a save, never in the background, and there’s no Tailscale entry point in the TUI. Your API keys and tailnet credentials stay with the Tailscale client; sshelf never sees them, and nothing tailscale-specific is written to hosts.toml.

Which machines are imported. Every peer whose MagicDNS name is under your tailnet’s own domain — one rule that leaves out Mullvad exit nodes and machines shared in from other tailnets. Peers with an expired node key are skipped. Offline peers are imported: being asleep is temporary, and the host is still real. This machine (Self) is not imported.

What each machine becomes:

sshelf fieldFrom the peer
nameFirst label of the MagicDNS name, lowercased (e.g. nas.tail4f9a2.ts.net.nas).
hostnameThe MagicDNS FQDN (nas.tail4f9a2.ts.net) — stable across IP churn, and what Tailscale SSH expects. If MagicDNS is off for your tailnet, its Tailscale IP (IPv4 first).
siteYour tailnet’s name — matched to an existing site case-insensitively, or created as a bare one (name only, no defaults).
tagsThe machine’s ACL tags, minus the tag: prefix (tag:serverserver). Untagged machines get no tags.
authagent — your key/agent auth, unchanged. Add a user, port or password afterwards if a box needs one.

Two machines whose names collide (possible only across sub-domains) keep the first; the second is reported. Everything skipped is counted in a warning line, so the numbers always add up.

If sshelf can’t find the CLI. It looks at $SSHELF_TAILSCALE_BIN, then tailscale on your PATH, then /Applications/Tailscale.app/Contents/MacOS/Tailscale — the macOS app doesn’t put its CLI on PATH. For any other location:

SSHELF_TAILSCALE_BIN=/path/to/tailscale sshelf import --tailscale

The import needs the Tailscale backend to be running (tailscale up); if it isn’t, sshelf says so and changes nothing.

After importing

Import brings everything in at once (there’s no per-host picker); curate afterwards with Ctrl-e / Ctrl-d, and organize with tags or sites. Your ~/.ssh/config keeps working exactly as before — sshelf’s database is independent of it by design (the FAQ explains why).

The reverse direction exists too: export projects your sshelf hosts back out as an Include fragment, so plain ssh/scp can use them by name — including everything a tailnet import just added.

Exporting to SSH config

sshelf export makes your sshelf hosts available to everything else: it writes an ssh_config fragment to sshelf’s own file and you add a single Include line to your ~/.ssh/config — sshelf never edits that file (or anything under ~/.ssh).

sshelf export
# Exported 14 host(s) to ~/.config/sshelf/ssh_config
# To use it, add this line to your ~/.ssh/config (sshelf never edits that file):
#   Include ~/.config/sshelf/ssh_config

Once included, sshelf’s database stops being a walled garden — your hosts resolve by name in any tool that reads SSH config:

ssh prod-web                          # plain ssh — no sshelf in the loop
scp report.pdf prod-web:/tmp/         # scp / sftp
rsync -av ./site/ prod-web:/var/www/  # rsync (runs over ssh)
git clone prod-web:/srv/repo.git      # git's ssh transport

…and in anything with an SSH-config picker: VS Code Remote-SSH lists your sshelf hosts in its host dropdown, JetBrains Gateway and similar tools likewise. The jump host, port, user, and identity file all come along — ssh prod-web through the site’s bastion just works.

Staying fresh

Creating the file once (running sshelf export) is the opt-in: from then on sshelf rewrites it automatically every time your hosts change — add/edit/delete in the TUI, sshelf add, an import, a site change. Edit a host’s bastion in sshelf and VS Code picks it up on its next connect. (Delete the file to opt back out; sshelf export --stdout prints the fragment without writing anything.)

The output is deterministic — hosts sorted by name, no timestamps — so the file only changes when your database does. Diff-friendly if you keep it alongside dotfiles.

What gets exported

Per host (with its site defaults resolved, exactly like connect): HostName, User, Port (when not 22), IdentityFile (key-auth hosts; ~ left for ssh to expand), and ProxyJump. From Extra args, -o Key=Value options translate to real config directives; other raw flags (-X, …) can’t be expressed in config and are kept visible as a comment in the host’s block instead of being guessed at.

Worth knowing:

  • Where sshelf’s entries win. ssh uses the first value it finds for an option, so put the Include at the top of ~/.ssh/config if sshelf’s entries should win for same-named hosts, or at the bottom if your hand-written entries should.
  • Password-auth hosts export fine, but plain ssh can’t read sshelf’s keyring — it prompts on the terminal. Auto-supply (and the 2FA popup) remain sshelf-connect features.
  • No behavior smuggling. sshelf’s own connects pass StrictHostKeyChecking=accept-new (an askpass necessity); the export deliberately does not, so your plain ssh keeps your own host-key defaults.
  • Names that can’t be Host patterns (containing *, ?, !, ,, #, or quotes) are skipped with a comment — they’d otherwise match other hostnames. Names with spaces are quoted and work.

Round-tripping with import is symmetric on purpose: import copies your SSH config in (read-only), export projects the database out (to its own file). Your ~/.ssh/config is never written by either. Design notes: decisions.md, D-023.

CLI reference

Everything sshelf does without opening the TUI.

Commands

CommandWhat it does
sshelfLaunch the interactive TUI.
sshelf <host>Connect straight to a saved host by name or id, skipping the TUI — same connect path as Enter (frecency recorded, stored secret auto-supplied, 2FA code prompted on the terminal). A miss suggests the closest names; a host named like a subcommand (list, import, …) is reached via the TUI instead.
sshelf -Reconnect to the most recently used host. Errors (without connecting) if there’s no history yet.
sshelf add [NAME …]Bare: open the TUI add form. With arguments: add a host non-interactively — see below.
sshelf list [query] [--json]List hosts (with a ·site· column). query filters with the TUI’s syntax — fuzzy text and/or tag:NAME / site:NAME (e.g. sshelf list site:prod-dc).
sshelf print-command <host>Print the generated, shell-quoted ssh … command (site defaults included) without connecting or touching frecency — the CLI twin of Ctrl-y.
sshelf sites [--json]List defined sites with member counts + their shared defaults.
sshelf sites add NAME [-u/-p/-J/-i]Define a site (settings optional; edit later with F3).
sshelf import [--dry-run]Read-only import from ~/.ssh/config.
sshelf import --tailscale [--dry-run]Import your Tailscale tailnet: runs your own tailscale status --json and adds every eligible peer (MagicDNS name → host, tailnet → site, ACL tags → tags). Add-only; re-running adds 0.
sshelf export [--stdout]Export the database as an ssh_config Include fragment, written next to sshelf’s config (--stdout prints it instead). Once the file exists, it refreshes on every hosts change.
sshelf set-password <host>Store a password / key passphrase for a host, read from stdin.
sshelf completions <shell>Print static shell completions.
sshelf manPrint the man page.

Global flags: --config FILE — use a specific config file (also $SSHELF_CONFIG); see Configuration. --transfer-log FILE — append transfer diagnostics, no secrets, to FILE (also $SSHELF_TRANSFER_LOG); see Transferring files.

Environment: $SSHELF_TAILSCALE_BIN — the tailscale binary --tailscale should run, for installs that aren’t on PATH (sshelf otherwise tries PATH, then the macOS app bundle at /Applications/Tailscale.app/Contents/MacOS/Tailscale). $SSHELF_VAULT_PASSPHRASE — use the age vault instead of the OS keyring; see Passwords, keys & 2FA.

Adding hosts from the CLI

sshelf add with arguments adds a host non-interactively (handy for scripts and dotfiles). NAME and --hostname are required:

# key auth (a -i identity implies --auth key)
sshelf add prod-web -H 10.25.25.10 -u deploy -i ~/.ssh/id_ed25519 -t prod,web

# jump host + custom port
sshelf add prod-db -H 10.25.25.25 -u mike -p 5432 -J bastion.example.net -i ~/.ssh/db

# agent auth (the default) with extra raw ssh flags
sshelf add edge -H edge.example.net --extra "-o ServerAliveInterval=30"

# password auth — pipe the secret in (kept out of argv/history; stored in the keyring or vault)
echo "$PASS" | sshelf add legacy -H 10.0.0.9 -u root --password-stdin
FlagMeaning
NAME (positional)Alias to search/connect by. Required.
-H, --hostname <HOST>IP or DNS name. Required.
-u, --user <USER>Login user (defaults to $USER at connect time).
-p, --port <PORT>SSH port (default 22).
-a, --auth <agent|key|password>Auth method. Inferred as key with --identity, password with --password-stdin, else agent.
-i, --identity <PATH>Identity file for key auth (repeatable).
-J, --jump <HOST>ProxyJump host (repeatable or comma-separated). Key/agent auth only.
-t, --tag <TAG>Tag (repeatable or comma-separated).
-s, --site <NAME>Assign the host to a site.
--2faMark the host as needing a verification code on connect.
--extra "<ARGS>"Extra raw ssh flags, appended verbatim.
--password-stdinRead a password / key passphrase from stdin and store it.

A duplicate name is refused. To store a secret after the fact, use sshelf set-password <name>.

Shell completions

Static completion (subcommands + flags) ships with every package — open a new shell after installing so it loads. It’s also printed by sshelf completions <shell>.

Dynamic completion — sshelf prod<Tab> completing your saved host names — takes one line in your shell rc:

source <(COMPLETE=bash sshelf)   # bash — add to ~/.bashrc
source <(COMPLETE=zsh sshelf)    # zsh  — add to ~/.zshrc (after compinit)
COMPLETE=fish sshelf | source    # fish — add to ~/.config/fish/config.fish

Host-name completion works for direct connect, print-command, and set-password.

JSON output

sshelf list --json [query] emits each selected host’s fields plus its generated ssh command, and is always valid JSON even when the selection is empty — the stable surface for scripts and integrations. sshelf sites --json does the same for sites.

Configuration

config.toml

Lives at ~/.config/sshelf/config.toml (honors XDG_CONFIG_HOME) and is written with comments on first run:

KeyDefaultMeaning
decay_rate0.2Frecency decay per day (higher = recency matters more).
default_sort"frecency"Idle list order: "frecency" or "name".
accent"cyan"UI accent color: black / red / green / yellow / blue / magenta / cyan / white / gray.
hosts_file(config dir)Custom host-database path; ~ is expanded. Editable from F2.

Point sshelf at an alternate config file with --config FILE or $SSHELF_CONFIG — the config-file location itself isn’t a key in the file (that would be circular). The hosts file location is a setting.

The settings screen (F2)

  • Config file — shown read-only (it’s chosen before the config is read; see above).
  • Hosts file — editable; blank means the default under the config dir. On save, an existing file at the new path is adopted (loaded, never overwritten); a new path is created from your current hosts, so they follow.

Where everything lives

XDG paths on macOS and Linux (~/.config / ~/.local/share, honoring XDG_CONFIG_HOME / XDG_DATA_HOME):

FileDefault locationWhat it is
hosts.toml~/.config/sshelf/The host database — human-readable, hand-editable, nice to keep in dotfiles.
config.toml~/.config/sshelf/The preferences above.
state.json~/.local/share/sshelf/Frecency counters. App-managed; churns.
forwards.json~/.local/share/sshelf/Ledger of active port forwards. App-managed.
vault.age~/.local/share/sshelf/Encrypted secret store — only in vault mode.

Secrets are never in hosts.toml — keyring or vault only. All writes are atomic (temp file + rename), so a crash mid-write can’t corrupt your files.

Hand-editing hosts.toml is supported — that’s also how you give one host multiple identity files. The full schema: Data model & files.

FAQ & troubleshooting

Why doesn’t sshelf just use my SSH config?

By design. ~/.ssh/config is often shared, load-bearing infrastructure — Ansible, Terraform, your editor’s remote mode all read it — and a tool that rewrites it can corrupt all of that. sshelf keeps an independent database and builds ssh commands from it with plain flags; its only contact with your SSH config is the explicit, read-only import. (ssh itself still reads your config normally when sshelf launches it — sshelf just never writes it.) It isn’t a one-way street either: sshelf export generates an Include file so plain ssh/scp — and anything that reads SSH config, like VS Code Remote — can use your sshelf hosts by name.

What does sshelf need at runtime?

OpenSSH 8.4+ on your machine — password/passphrase auto-supply rides on SSH_ASKPASS_REQUIRE, added in OpenSSH 8.4 (2020). Check with ssh -V. Key/agent hosts work with anything reasonably modern. Platforms: macOS + Linux, x86_64 and arm64.

Does sshelf phone home?

No. No telemetry, no account, no network calls of its own — the only network activity is the ssh/sftp it runs for you. See Security.

Then how does the Tailscale import work?

The same way: by running a program you already have. sshelf import --tailscale executes your tailscale CLI (tailscale status --json) and parses its output — sshelf opens no sockets, holds no API key, and talks to no Tailscale server. It only ever runs when you type that command: never at startup, on a save, on a timer, or from the TUI. Nothing tailscale-specific (node IDs, keys) is written to hosts.toml, and the import only ever adds hosts.

Password auto-supply isn’t working

  • Check ssh -V — you need OpenSSH 8.4+ (see above).
  • Built from source on macOS? An unsigned binary can hit a Keychain approval prompt on every connect (Keychain ACLs are keyed to the code signature). Approve it, or ad-hoc sign your build: codesign -s - target/release/sshelf.

I’m on a headless box with no keyring

Set SSHELF_VAULT_PASSPHRASE — secrets then live in an age-encrypted vault file instead of a keyring. Details: where secrets live; the env-inheritance tradeoff is documented in Security.

Can a jump host use password auth?

Not currently — jump hosts are key/agent only. The askpass helper holds the target’s secret and can’t tell which hop in a chain is prompting.

My 2FA host fails before I can type the code

Flag it: 2FA = yes in the edit form (or --2fa on sshelf add). A stored-secret connect routes all prompts to the askpass helper with no terminal fallback, so the verification prompt needs the 2FA flow to answer it.

Tab completion doesn’t complete my host names

Completion has two layers. The packages install static completion (subcommands + flags) — open a new shell so it loads. Completing your saved host names needs the dynamic engine sourced in your shell rc — one line per shell: Shell completions.

A forward vanished from F4

F4 only ever shows forwards whose processes are actually running — the list is reconciled against the OS on launch and refreshed live while open. If the tunnel died ( reboot, sleep, network drop, killed from another terminal), it leaves the list; start it again with Ctrl-f. Automatic re-launch of dropped forwards isn’t there yet.

How do I back up or sync my hosts?

hosts.toml is one human-readable TOML file — keep it in your dotfiles like any config (a custom path is a setting: Configuration). Secrets don’t travel with it: they’re per-machine, in each machine’s keyring or vault — re-add them with sshelf set-password. Frecency state is per-machine and app-managed.

Where did the first-connection host-key prompt go?

Connections pass StrictHostKeyChecking=accept-new: a brand-new host’s key is accepted and recorded on first use (so the prompt can’t interfere with automated password supply), while a changed key for a known host still hard-fails, as ever. The tradeoff is discussed in Security.

Windows?

Not currently — connect hands off via Unix exec(), and the askpass/process plumbing is Unix-specific. macOS + Linux for now.

Security & threat model

sshelf stores SSH passwords so it can auto-supply them. This document states exactly what that protects against and what it does not. The shipped root SECURITY.md (M8) is a user-facing summary of this.

Strongly prefer SSH keys / agent over stored passwords. Password storage exists for hosts you can’t use keys with; it is the least secure option sshelf offers.

Where secrets live

  • Primary — OS keyring: macOS Keychain (security-framework) or Linux Secret Service over D-Bus (keyring crate). Service sshelf, account = host id.
  • age vault (opt-in / headless): if SSHELF_VAULT_PASSPHRASE is set, secrets go in the XDG data dir as vault.age, encrypted with that passphrase (age passphrase mode = scrypt KDF + ChaCha20-Poly1305). This is the path for headless Linux with no Secret Service daemon, and for automation/CI. v1 reads the passphrase from the env var (deterministic, scriptable); an interactive prompt + auto-detection of a missing keyring are future enhancements. Env-inheritance tradeoff: the askpass helper runs as ssh’s child and reads the env var to unlock the vault, so for hosts with a stored secret the passphrase is necessarily in the ssh process tree’s environment (/proc/<pid>/environ, same-user readable). For hosts with no stored secret, ssh.rs::configure_askpass strips the variable before the exec.
  • Provisioning: sshelf set-password <name|id> stores a secret from stdin (so it can be piped in headless setups) without going through the TUI.
  • Never in hosts.toml, state.json, logs, shell history, or process arguments.

The host-key id is the lookup key in both stores, so renaming a host keeps its secret.

How the password reaches ssh

Via SSH_ASKPASSssh calls our helper, which prints the secret on stdout. The password is never passed as a CLI argument (no sshpass -p), so it never appears in ps/argv. See ssh-command.md. The helper matches the shape of OpenSSH’s standard prompts — a login password (…password:) or a key passphrase (Enter passphrase for key …) — and declines host-key confirmations, OTP/verification codes, and arbitrary server text, so a keyboard-interactive server can’t phish the stored secret by merely mentioning “password”.

Threat model

Protected against

  • On-disk plaintext exposure — secrets are in the OS keyring or encrypted at rest in the vault.
  • Process-listing / argv leakage — password is delivered via stdin/stdout to ssh, not argv.
  • Shell-history leakagesshelf never echoes the command containing a password.
  • Casual file snooping — the vault requires the master passphrase (memory-hard KDF).
  • hosts.toml sharing — it contains no secrets, so it’s safe to commit/share/back up.
  • Config-file corruption — atomic writes; a crash mid-write leaves the prior file intact.

NOT protected against (out of scope)

  • A root/admin attacker or malware on the machine — can read process memory, the keyring, or keystrokes. sshelf assumes you trust your own machine.
  • Keyloggers — can capture the master passphrase as you type it.
  • A compromised OS keyring daemon — we trust the platform’s secret service.
  • Physical theft without full-disk encryption — use FDE; that’s an OS-level control.
  • Unencrypted backups / cloud sync of vault.age — the vault is encrypted, but treat it as sensitive; don’t rely on it as your only protection in an untrusted backup.

Assumption: sshelf targets a developer/operator’s own (trusted) machine, not shared or hostile hosts.

Operational notes

  • No password recovery. Forgetting the vault master passphrase means losing vault secrets. Use a passphrase you can recover (e.g. from another password manager).
  • macOS unsigned builds: the re-exec’d askpass child reading Keychain can trigger an OS approval prompt on each connect (Keychain ACLs are keyed to code signature). Ad-hoc sign dev builds; release builds should be signed.
  • StrictHostKeyChecking=accept-new trusts a new host’s key on first connect but still hard-fails if a known host’s key changes (MITM protection retained).
  • Network: sshelf makes no network connections of its own and has no telemetry; it only ever launches the OpenSSH tools — ssh to connect, and ssh/sftp for the file-transfer screen. Transfers authenticate exactly as connect does (keys/agent, or the stored secret via SSH_ASKPASS) by opening one multiplexed ssh ControlMaster and running sftp over it — so no extra secret handling, and the secret still never reaches argv. Remote paths are quoted for sftp’s parser, control characters are stripped from displayed names, and StrictHostKeyChecking=accept-new applies there too. The optional transfer log (--transfer-log / $SSHELF_TRANSFER_LOG) records the ssh/sftp commands and their stderr for troubleshooting — it contains no secrets (same reason: the password goes via askpass).

Reporting

(M8) Add a SECURITY.md at the repo root with a disclosure contact before public release.

SSH command generation & the askpass mechanism

This is the heart of sshelf and its trickiest part. Read carefully before touching ssh.rs or askpass.rs.

1. Building the ssh argv

From a Host, build (in order):

ssh
  [-i <identity_file>]...            # one -i per entry in identity_files (auth = "key")
  [-p <port>]                        # only if port present and != 22
  [-J <jump1,jump2,…>]               # ProxyJump chain (jump_hosts), comma-joined
  -o StrictHostKeyChecking=accept-new   # see §3 — keeps host-key prompt away from askpass
  <extra_args…>                      # raw, split with `shlex`, appended verbatim
  <user>@<hostname>                  # user defaults to $USER if unset
  • Pure flags only — no temporary ssh -F config files (keeps the “never touch SSH config” promise literal and avoids cleanup).
  • extra_args is the escape hatch for anything the wizard doesn’t model (-X, -L …, -o …). Split with shlex::split so quoted args survive.
  • Example: stored host mike@10.25.25.25 with key ~/.ssh/infra-keyssh -i /home/mike/.ssh/infra-key -o StrictHostKeyChecking=accept-new mike@10.25.25.25 in the printed/yanked command (the exec path expands ~ internally as well).

The same builder backs the Ctrl-y yank action and sshelf print-command <host> (copy/print the exact command without connecting). For copy/paste safety, identity-file ~ is expanded before shell-quoting; quoted ~ would not expand in the user’s shell.

2. Launch handoff (exec)

On connect:

  1. Persist frecency first (exec() never returns — nothing runs after it).
  2. Set environment for the child:
    • SSH_ASKPASS = <path to our own binary> (std::env::current_exe())
    • SSH_ASKPASS_REQUIRE = force
    • SSHELF_ASKPASS = 1 ← how the re-exec’d binary knows it’s in askpass mode
    • SSHELF_HOST_ID = <id> ← which secret to fetch
    • env_remove("SSH_ASKPASS") of any inherited value first, then set ours (avoid pollution).
  3. Tear down the TUI: disable_raw_mode()LeaveAlternateScreen → show cursor → flush.
  4. std::os::unix::process::CommandExt::exec() into ssh. If it returns, it errored → restore terminal, show the error.

A RAII guard + panic hook guarantees step 3’s teardown also runs on panic/early-exit.

3. Secret auto-supply — the sharp edges

Applies whenever a stored secret exists for the host — a login password (password auth) or a key passphrase (key auth with an encrypted key). exec_connect wires the askpass env only when such a secret exists (wire_askpass); otherwise ssh prompts / uses the agent normally — and in that no-secret case configure_askpass also strips SSHELF_VAULT_PASSPHRASE from the child env (ssh has no reason to inherit the vault master passphrase). In the wired case the variable must stay: the helper runs as ssh’s child and reads it to unlock the vault (see docs/security.md).

ssh decides it needs a secret → because SSH_ASKPASS_REQUIRE=force, it executes the helper as sshelf "<prompt text>" (the prompt is argv[1]; there is no --askpass flag). The helper:

  1. Confirms it’s in askpass mode via SSHELF_ASKPASS=1.
  2. Inspects argv[1] by OpenSSH prompt shape and branches:
    • Ends with password: (classic user@host's password: / PAM Password:) or contains passphrase for (Enter passphrase for key '<path>':) → fetch the secret for SSHELF_HOST_ID from secrets (keyring or age vault), print it, exit 0.
    • Anything else (host-key yes/no, OTP/verification codes, arbitrary server text) → exit non-zero to decline, so ssh handles it. Never blindly print the secret.

A host uses one auth method, so answering both password and passphrase prompts with its one stored secret is correct.

Why inspection (by shape) is mandatory

SSH_ASKPASS_REQUIRE=force makes ssh route every read_passphrase() call to the helper — including the first-connect “Are you sure you want to continue connecting (yes/no/fingerprint)?”. If the helper answered that with the stored secret, the connection breaks. Worse, a malicious/compromised server could use keyboard-interactive auth to send a prompt that merely mentions “password” to phish the secret. Three defenses:

  • The helper matches the shape of real prompts (ends-with password: / contains passphrase for), not just the substring — so “Type your password to continue:” is declined.
  • We pass -o StrictHostKeyChecking=accept-new, so the host-key prompt normally never fires for new hosts (known hosts are still verified; changed keys still hard-fail).
  • The secret is host-scoped, limiting blast radius even if a prompt is mis-answered.

Validated by the M0 spike ✅ (2026-06-05, macOS, OpenSSH 10.2)

Ran against a real password-auth sshd (lscr.io/linuxserver/openssh-server):

  • Success pathSSH_ASKPASS=helper SSH_ASKPASS_REQUIRE=force, PreferredAuthentications=password, StrictHostKeyChecking=accept-new → logged in (exit 0). Confirms SSH_ASKPASS satisfies interactive PasswordAuthentication, not just key passphrases. The helper was called with argv[1] = "tester@127.0.0.1's password: ".
  • Host-key routing — with StrictHostKeyChecking=ask and a fresh known_hosts, ssh sent the helper the "…continue connecting (yes/no/[fingerprint])?" prompt; a naive helper that always returns the password caused an infinite loop on "Please type 'yes', 'no'…". This is the empirical proof that §3’s two rules are mandatory, not optional.

Linux verification is deferred to CI (M8); the mechanism is OpenSSH behavior and is expected to be identical.

4. Known v1 limitations

  • Password-auth jump hosts are unsupported. The helper only has the target’s secret and can’t tell which hop is prompting. Jump hosts must use key/agent auth in v1.
  • macOS unsigned builds: the re-exec’d askpass child reading Keychain may trigger an OS approval prompt every connect (Keychain ACLs are keyed to code signature). Ad-hoc sign for dev; document for users building from source.
  • Windows: out of scope for v1 (exec() replacement is Unix-only).

References

  • OpenSSH ssh(1), ssh_config(5) (ProxyJump, StrictHostKeyChecking).
  • SSH_ASKPASS_REQUIRE — added in OpenSSH 8.4 (2020). This machine runs 10.2.
  • std::os::unix::process::CommandExt::exec.

Architecture

sshelf is a single binary that runs in one of two modes:

  1. Interactive TUI (default, and subcommands like import) — the fuzzy launcher.
  2. Askpass helper (SSHELF_ASKPASS=1 in the environment) — a headless, non-interactive mode that ssh invokes to obtain a stored password. Never run directly by the user.

High-level flow

                 ┌─────────────────────────────────────────────┐
                 │                  sshelf (TUI)                 │
                 │                                               │
   hosts.toml ──▶│  store ──▶ model(Host) ──▶ search (fuzzy +    │
   state.json ──▶│            frecency) ──▶ ui (list/wizard)     │
   config.toml ─▶│                                               │
                 │     user presses Enter on a host ──┐          │
                 └────────────────────────────────────┼──────────┘
                                                       │
              1. update frecency (use_count, last_used) & save
              2. set env: SSH_ASKPASS=self, SSH_ASKPASS_REQUIRE=force,
                          SSHELF_ASKPASS=1, SSHELF_HOST_ID=<id>
              3. tear down TUI (raw mode off, leave alt screen, show cursor)
              4. exec("ssh", argv…)   ← process is REPLACED; sshelf is gone
                                                       │
                                                       ▼
                 ┌─────────────────────────────────────────────┐
                 │                     ssh                       │
                 │  needs a password? ─▶ runs SSH_ASKPASS:       │
                 │     `sshelf "<prompt>"`  (SSHELF_ASKPASS=1)   │
                 │              │                                │
                 │              ▼                                │
                 │   askpass mode: inspect argv[1];              │
                 │   if password prompt ─▶ secrets.get(host_id)  │
                 │      (keyring → age vault) ─▶ print, exit 0    │
                 │   else ─▶ exit non-zero (decline)             │
                 └─────────────────────────────────────────────┘
                                                       │
                                          interactive ssh session
                                                       │
                                              session ends → back at shell

Why this shape

  • exec() (process replacement), not spawn+wait. The user chose exit-to-shell: when the SSH session ends, they’re back at their normal prompt. exec() gives ssh the real TTY with zero indirection — the cleanest possible handoff. Consequence: no code runs after exec(), so anything that must persist (frecency) happens before it.

  • Password auto-supply via SSH_ASKPASS, not sshpass. ssh never accepts a password on the command line; sshpass would expose it in ps/argv and is an extra dependency. SSH_ASKPASS (OpenSSH 8.4+) lets ssh call a helper program for the password. We point it at our own binary. With SSH_ASKPASS_REQUIRE=force, ssh uses the helper even though a TTY is present. See ssh-command.md for the full mechanism and its sharp edges (the helper must inspect the prompt; host-key prompts must be neutralized).

  • Two-tier secrets. OS keyring (macOS Keychain / Linux Secret Service) is the primary store. Headless/minimal Linux often has no Secret Service daemon, so an age-encrypted vault (unlocked by a master passphrase, cached in-memory per session) is the fallback. See security.md.

  • Own database, never ~/.ssh/config. Hosts live in hosts.toml (human-readable, atomic writes). Importers (~/.ssh/config, a Tailscale tailnet) are read-only towards their source and add-only towards the database. See data-model.md.

  • Synchronous event loop, component pattern. No background work needs multiplexing (the one long-running thing, the SSH session, happens after the TUI is gone). A simple crossterm::event::read() loop with a component-per-screen structure (HostList, Wizard, Help, Confirm) keeps it small and tokio-free.

Component map

See structure.md for the file-by-file breakdown. At runtime:

  • App owns top-level state (current screen, query, selection, loaded hosts + state) and routes events to the active component.
  • search turns (hosts, state, query) into a ranked, highlight-annotated view.
  • ssh is the only place that builds argv and performs the teardown + exec().
  • secrets is the only place that talks to the keyring/vault; both the TUI (to store on add/edit) and the askpass mode (to retrieve) go through it.

Failure handling

  • If exec() returns, it failed (e.g. ssh not found) → restore the TUI and surface the error.
  • If the askpass helper can’t get the secret → exit non-zero so ssh falls back to prompting the user, rather than hanging or sending a wrong answer.
  • A panic mid-TUI restores the terminal via a guard + panic hook before unwinding.

Project structure

Keep this in sync with the actual tree (the docs-in-sync rule).

All modules present: main, app, askpass, config, export, forwards, import, model, paths, search, secrets, ssh, state, store, tailscale, transfer/{mod,worker,pane,screen,e2e}, testsupport (test-only), vault, ui/{mod,list,help,widgets,wizard,browse,settings,sites,transfer,forward_popup,forwards,two_factor}. (error.rs was removed — the codebase uses anyhow throughout.)

Repository

ssh-tui/                 (crate/binary name: `sshelf`)
├── Cargo.toml
├── CONTRIBUTING.md      contributor guide + docs-in-sync rule
├── README.md           (M8) user-facing intro + positioning
├── SECURITY.md         (M8) threat model for OSS users (mirrors docs/security.md)
├── LICENSE-MIT         (M1)
├── LICENSE-APACHE      (M1)
├── docs/               living documentation (this directory)
└── src/                see below

src/ modules

FileResponsibility
main.rsEntry/dispatch. If SSHELF_ASKPASS is set → askpass mode (read argv[1]). Else clap parses: default TUI, or subcommands (import, list, add).
app.rsApp state + synchronous event loop + screen routing (component orchestration).
model.rsHost + Site structs (+ AuthMethod); Host::with_site_defaults/find_site (site inheritance); serde derives.
store.rsLoad/save hosts.toml with atomic write (temp + rename); load config.toml.
state.rsFrecency state (use_count, last_used) load/save (state.json); score computation.
forwards.rsBackground port-forwards: the ForwardSpec/ForwardEntry model, the -L/-R/-D argv builder, spawn (detached ssh -N + readiness/error mapping), PID liveness/kill via ps/kill, reconcile, and forwards.json load/save.
secrets.rsSecretStore trait → keyring backend + age-vault fallback; zeroize on secrets.
ssh.rsBuild ssh argv from a Host; terminal teardown + exec() handoff; askpass env wiring.
askpass.rsHeadless askpass entry: inspect argv[1]; answer password prompts via secrets, or a queued 2FA code (SSHELF_2FA_CODE) for the verification prompt; else decline.
search.rsFuzzy filter (nucleo-matcher) + frecency ranking + per-row match indices for highlight.
import.rsssh2-config parse of ~/.ssh/configHost mapping; warn on unsupported Match/Include. Also the shape (ImportResult) and add-only plumbing (new_hosts, missing_sites) every importer shares.
tailscale.rssshelf import --tailscale: locate the user’s tailscale binary, run status --json, map eligible peers → hosts (MagicDNS name/FQDN, tailnet → site, ACL tags). Pure parser over &str; the only process spawn is isolated in one function.
export.rsRender the database as an ssh_config Include fragment (site defaults resolved, -o extras translated); write to ssh_config in the config dir; auto-refresh on hosts saves once the file exists.
paths.rsetcetera path resolution (config/data dirs); file paths; dir/file perms (0700/0600).
config.rsPreferences: decay_rate, default_sort, accent color; writes a commented default on first run.
transfer/mod.rsFile-transfer core: ssh-ControlMaster + sftp argv builders, the worker↔UI message protocol (WorkerCmd/WorkerEvent), and progress math.
transfer/worker.rsBackground worker thread: owns the ControlMaster (open/readiness/teardown), lists remote dirs (sftp ls -l), runs sftp get/put transfers with progress + cancel.
transfer/pane.rsOne side’s browsing state (fuzzy filter + selection + nav, reusing search); read_local_dir for the local side; RemoteEntryPaneEntry.
transfer/screen.rsThe dual-pane TransferScreen: two panes over one session, key handling, draining worker events.
ui/list.rsHost list rendering + match highlighting + selection.
ui/transfer.rsRenders the transfer screen (two panes + progress/status + hint bar) from a borrowed view.
ui/wizard.rsAuth-aware add/edit form: fields, validation, key picker, opens the file browser.
ui/browse.rsFile-browser modal (fuzzy-filtered) for picking a key file anywhere on disk.
ui/settings.rsSettings screen (F2): config-file display + editable hosts-file location.
ui/sites.rsSites manager (F3): list + add/edit/delete sites and their optional defaults; emits renames for the app to cascade.
ui/forward_popup.rsNew-port-forward popup (Ctrl-f): kind chooser (Local/Remote/Dynamic) + ports/host fields + validation; emits a ForwardSpec for the app to spawn.
ui/forwards.rsPort-forwards manager (F4): lists all active forwards from a live snapshot; emits a kill request for the app to act on.
ui/two_factor.rs2FA code popup shown before connecting to a requires_2fa host; emits the entered code for the app to queue + supply via askpass.
ui/help.rsHelp overlay.
ui/widgets.rsShared widgets: single-line text input (hand-rolled), keybind hint bar, confirm modal.

Data flow at a glance

paths ──▶ store ──▶ model(Host[])  ┐
paths ──▶ state ──▶ frecency       ├─▶ search ──▶ ui ──▶ (Enter) ──▶ ssh ──▶ exec
                                   ┘                                  │
secrets ◀── ui (store on add/edit)                     askpass ◀── ssh (via SSH_ASKPASS)
   └────────────────────────────────────────────────────▶ secrets (retrieve)

Conventions

  • One responsibility per module; ssh.rs is the only place that calls exec(); secrets.rs is the only place that touches the keyring/vault.
  • No unwrap()/expect() on fallible I/O in non-test code — return errors, surface them in the UI.
  • Every new/moved module updates this file.

Data model & on-disk layout

File locations

Paths resolve via the etcetera base strategy (XDG everywhere — ~/.config/sshelf on both macOS and Linux, honoring XDG_CONFIG_HOME/XDG_DATA_HOME when set). This keeps config hand-editable instead of buried in macOS ~/Library.

FileLocation (default)OwnerPurpose
hosts.toml~/.config/sshelf/hosts.tomluserThe host database. Human-editable.
config.toml~/.config/sshelf/config.tomluserPreferences (theme, decay_rate, sort, keybinds).
state.json~/.local/share/sshelf/state.jsonappFrecency counters, keyed by host id. Churns; not for hand-editing.
forwards.json~/.local/share/sshelf/forwards.jsonappLedger of active background port-forwards (PIDs). Reconciled against the OS on launch. Mode 0600.
ssh_config~/.config/sshelf/ssh_configappExported ssh_config Include fragment (sshelf export) — derived from hosts.toml, refreshed on every hosts save once present. Mode 0600.
vault.age~/.local/share/sshelf/vault.ageappFallback encrypted secret store (only when no OS keyring). Mode 0600.

Directories are created on first run (0700). Secrets are never written to hosts.toml.

Host / Site schema (hosts.toml)

format_version = 1            # top-level scalar; for future migrations

[[site]]                      # optional; sites are listed before hosts (scalars-before-AoT)
name      = "prod-dc"         # the name hosts reference (see host.site)
user      = "deploy"          # optional default login for member hosts
port      = 22                # optional default port
jump_hosts = ["bastion.prod"] # optional default ProxyJump (the site's bastion)
identity_files = ["~/.ssh/prod"]  # optional default key(s) (applied to key-auth members)

[[host]]
id        = "01J…"            # stable unique id (e.g. ULID/UUID); keys secrets & frecency
name      = "prod-db"         # display alias (what you search/see)
hostname  = "10.25.25.25"     # IP or DNS name           (required)
user      = "mike"            # optional; default = $USER at connect time
port      = 22                # optional; default 22
auth      = "key"             # "key" | "password" | "agent"
identity_files = ["~/.ssh/infra-key"]   # for auth="key"; repeatable (-i per entry)
jump_hosts = ["bastion.example.com"]    # ProxyJump chain; key/agent auth only in v1
tags      = ["prod", "db"]    # many-valued, free-form; for filtering/grouping
site      = "prod-dc"         # optional; one site (by name); groups + inherits its defaults
requires_2fa = true           # optional (default false); connect prompts for a verification code
extra_args = "-o ServerAliveInterval=30"  # raw, shlex-split, appended verbatim
# NOTE: no password field — ever. auth="password" means "look up the secret by id".

Notes:

  • Optional fields use Option<T> in Rust with #[serde(skip_serializing_if = "Option::is_none")] so the TOML stays clean; new fields use #[serde(default)] for backward compatibility.
  • identity_files / jump_hosts / tags are Vec<String> (empty = absent).
  • format_version lets us migrate the schema later without breaking older files. Adding [[site]] and host.site needed no bump — old files load with sites = [] / site = None.
  • requires_2fa marks a host whose login needs an interactive verification code; connect collects it and passes it to ssh via the transient SSHELF_2FA_CODE env var (never stored on disk). See decisions.md D-022.

Sites vs tags, and inheritance

A Site is one per host and may carry optional shared SSH defaults; tags are many-valued free-form labels. At connect time a host is resolved into an effective host (Host::with_site_defaults): for user, port, jump_hosts, identity_files, the site’s value fills in only where the host leaves that field unset — the host always wins. Auth is not inheritable. A host that names an undefined site still groups under that name but inherits nothing (graceful degradation). Renaming a site (F3 manager) cascades to member hosts; deleting one clears members’ site. See decisions.md D-020.

Frecency state (state.json)

{
  "01J…": { "use_count": 12, "last_used": "2026-06-05T09:30:00Z" }
}
  • Keyed by host id (so renaming a host in hosts.toml keeps its history).
  • Updated before exec() on connect: use_count += 1, last_used = now.
  • Kept separate from hosts.toml so the user-owned host file stays stable and diff-friendly.
  • Score: use_count * exp(-decay_rate * days_since_last_used) (decay_rate default 0.2). See ux.md for how it combines with fuzzy ranking.

Port-forward ledger (forwards.json)

[
  {
    "id": "01J…",                       // ULID; also names the forward's stderr log
    "host_id": "01J…",                  // originating host id
    "host_name": "prod-db",             // snapshot for display
    "kind": "local",                    // "local" | "remote" | "dynamic"
    "spec": { "listen_port": 8080, "target_host": "db", "target_port": 3306 },
    "display": "L  127.0.0.1:8080 → db:3306",
    "pid": 41234,                       // the detached `ssh -N` process
    "started_at": 1718900000
  }
]
  • App-owned; written atomically (0600). The running ssh processes are authoritative — this file is only a remembered list of PIDs, reconciled against the OS (ps) on startup, on opening the manager, and each tick while it’s open. A forward leaves the ledger the moment its process is gone, however it ended. See decisions.md D-021.
  • spec omits empty fields (bind defaults to 127.0.0.1, target_host to localhost); Dynamic forwards carry only listen_port.

Secrets

Stored in the OS keyring (service sshelf, account = host id) or, as a fallback on headless systems, in vault.age. Either way the key is the host id. Full model and threat analysis in security.md.

Atomic writes

All persistent writes use temp-file + rename() (atomic on Unix) so a crash mid-write never corrupts hosts.toml / config.toml / state.json. Single-process tool → no file locking needed.

UI design notes

How the interface is designed and why. What each screen does — and every keybinding — is documented in the user Guide (Searching & connecting, Adding & editing hosts, Transferring files, Port forwarding, Sites & tags); this page holds the design rationale and rendering details behind those screens.

Visual model

atuin.sh: slim chrome, an inline filter-as-you-type list, and a contextual keybind hint bar at the bottom. The search box is always active (single-mode, no insert/normal split), so plain letters filter the list; actions therefore use Ctrl or function keys, which can’t be typed into the query.

Main screen layout: Length(3) search · Min(0) list · Length(1) hint bar. Each row shows name · user@host[:port] · [tags], plus a dim ·site· column while filtering. The matched/total count lives in the search-box title so it’s never truncated by a narrow terminal.

Sorting / ranking

  • No query (idle): frecency desc — score = use_count * exp(-decay_rate * days_since_last_used), decay_rate default 0.2 (default_sort = "name" opts out). The idle view groups by site.
  • Typing: fuzzy-filter via nucleo-matcher; sort by match score with frecency breaking ties. Matched characters are highlighted (bold/accent) using the matcher’s match indices, rendered with unicode-width so wide/combining characters don’t misalign.
  • Fuzzy only for now (prefix/substring modes can come later).

The add/edit form

A single-screen, auth-aware field form rather than a paged wizard — simpler to navigate and edit, “guided” by dim placeholders (required · / optional ·) and inline validation with focus jumping to the offending field. Fields specific to an auth method only render for that method. The Key field is a picker (single key) backed by a fuzzy file-browser modal; key discovery matches keypairs (.pub sibling) and standalone private keys by their PRIVATE KEY header so .pem files show up. A host configured with multiple identity files keeps them on edit; entering several is done by editing hosts.toml.

Modality & precedence

Full-screen modes (transfer, sites, forwards manager) and popups (forward, 2FA, confirms) route keys before the list screen — first match wins — and render in the same precedence order, so input routing and drawing can’t disagree. Destructive actions (delete a host, stop a forward) confirm with y; any other key cancels. Help (F1) is an overlay listing every key; any key closes it.

Theming

atuin-inspired defaults: dim chrome and a single accent color (config key accent) for the selection + match highlights. Terminal resize is handled by ratatui’s layout pass — no manual recompute.

Decision log

ADR-style. Newest on top. Each entry: the decision, why, and what we rejected. Add an entry whenever you make a non-trivial design choice.


D-024 · Tailscale import shells out to the user’s CLI; MagicDNS names, suffix eligibility, add-only

sshelf import --tailscale fills the database from a tailnet — the inventory a homelab/small-fleet user already curates — without sshelf growing a network client. It shells out to the user’s own tailscale status --json and parses stdout. Rejected: the Tailscale HTTP API / an SDK, which would mean an API key to store (violating the no-secrets-in-hosts.toml rule and the whole secret-handling story), an HTTP + async dependency stack, and sshelf making network calls of its own; and reading tailscaled’s local socket directly (unstable, root-ish, platform-specific). Shelling out keeps the no-network posture intact: the binary runs only when the user runs the subcommand — never at startup, on save, on a timer, or from the TUI. Binary resolution is $SSHELF_TAILSCALE_BINPATH/Applications/Tailscale.app/Contents/MacOS/Tailscale, because the macOS app doesn’t put its CLI on PATH.

hostname = the MagicDNS FQDN, not a Tailscale IP: it survives IP churn, reads better in the list, and is what Tailscale SSH expects — with the IP (IPv4 first) as the fallback when MagicDNS is disabled for the tailnet. Eligibility is one rule: the peer’s DNSName must sit under the tailnet’s own MagicDNSSuffix — which excludes Mullvad exit nodes and shared-in/foreign nodes without special-casing either. Rejected: filtering by ExitNodeOption/OS/owner (a list of special cases that ages badly). Expired peers are skipped; offline peers are notOnline is transient and an asleep laptop is still a real host. The tailnet becomes a site (matched case-insensitively, created bare when new) and ACL tags become sshelf tags minus the tag: prefix, so the tailnet’s own structure carries over instead of being flattened.

Import is add-only: existing hosts and sites are never updated or deleted, so re-running converges to “0 added” and the user’s own edits (a user, a port, a password) are never clobbered. Rejected: sync semantics (two-way reconciliation, deletion of departed nodes) — that needs a per-host record of provenance, and v1 deliberately stores nothing tailscale-specific in hosts.toml: no node IDs, no keys. The JSON→hosts mapping is a pure function over &str (mirroring import::parse_str), so the whole feature is fixture-tested without the binary, a tailnet, or a network.

D-023 · Export writes our own ssh_config fragment; the user adds the Include line

sshelf export projects the database into ssh_config format so native tools (ssh/scp/sftp, rsync, git, editor remote extensions) resolve sshelf hosts by name — the standing objection to a tool with its own database is lock-in, and this removes it. The fragment is written to sshelf’s config dir (ssh_config, next to hosts.toml), never under ~/.ssh; the user adds the one Include line themselves, which keeps the “never edits ~/.ssh/config” promise literal (we still only ever read it — to skip the hint when the Include is already there). Rendering mirrors ssh::build_args (site defaults resolved, -i gated on key auth, port 22 omitted) minus sshelf-only plumbing: no StrictHostKeyChecking=accept-new (that exists to keep the host-key prompt away from the askpass helper; plain ssh should keep the user’s defaults) and no askpass wiring (exported password hosts prompt on the tty). extra_args are CLI flags, not config keywords, so only exact -o Key=Value pairs translate; the rest is preserved as an in-block comment rather than guessed at. Output is deterministic (name-sorted, no timestamps) so the file churns only when the database changes. Once the file exists, every hosts save refreshes it (best-effort — derived data never blocks a save); creating it is the opt-in, deleting it opts out. Host names that can’t be a safe Host pattern (glob/ negation/comment/quote characters) are skipped with a comment — exporting them would match other names; values are control-char-stripped so a crafted field can’t inject directives. Rejected: writing into ~/.ssh/ (even a new file — the promise is that sshelf never touches that directory); auto-appending the Include (mutates the user’s config, the one hard no); translating arbitrary flags to directives (lossy guessing); a config key for the export path (existence-as-opt-in needs one well-known location).

D-022 · Interactive 2FA: collect the code before connect, inject it via the askpass helper

A connect that auto-supplies a stored secret runs ssh with SSH_ASKPASS_REQUIRE=force, which routes every interactive prompt — including a server’s keyboard-interactive verification-code step — to our askpass helper; the helper declined it, and a spike confirmed force gives no terminal fallback, so the code prompt was answered empty and auth failed (a real user hit this). A during-session popup is impossible (connect exec()s into ssh), and a PTY screen-scraper was already rejected (D-019). So 2FA is handled the same way the password is: a per-host requires_2fa flag makes connect show a small code popup before the exec() (while the TUI is alive); the entered one-time code is passed to ssh via SSHELF_2FA_CODE (like the vault passphrase already rides env), and the helper answers the non-secret prompt with it. The helper’s routing: a password/passphrase-shaped prompt → the stored secret (unchanged, anti-phish guard intact); any other prompt → the queued code; else decline. configure_askpass therefore force-wires the helper when a secret exists or a code is queued (so key+2FA hosts work too). The CLI direct-connect path (sshelf <host> / -), which has no TUI, prompts for the code on the terminal before handoff. Rejected: storing the TOTP seed and generating the code ourselves (puts the second factor in the same vault as the first, and needs a TOTP dep the project avoids); auto-detecting 2FA with no flag (we can’t probe the server’s auth methods without a separate non-exec connection). Note: a host with no stored secret already prompts for the code inline after handoff (no askpass forced), so the flag/popup mainly fixes the stored-secret case; an encrypted key with no stored passphrase + 2FA should use an agent (else force askpass can’t answer the passphrase prompt either). v1 is manual entry only.

D-021 · Port forwards are detached ssh -N processes tracked by PID

Background port forwards (Ctrl-f popup, F4 manager) must keep running after sshelf exits. Each forward is one detached ssh -N -L|-R|-D <spec> process, reusing ssh::build_args + ssh::configure_askpass (so keys/agent/ProxyJump/stored-password and site defaults all work as connect does). It is spawned with std::os::unix::process::CommandExt::process_group(0) (std, no new dep) and null stdin/stdout, which makes it survive both sshelf exiting (orphaned → reparented to init) and the terminal closing (its own process group never receives the shell’s SIGHUP). Nothing kills a forward on Drop or app shutdown — that is what keeps it alive. Validated by an M0 spike: a process_group(0) child with null stdio outlives its spawner (PPID→1) in its own process group, and kill -TERM stops it.

There is no daemon. The running processes are the source of truth; forwards.json (mirrors state.json: #[serde(transparent)] over a Vec, atomic_write 0600) is just a remembered list of PIDs. reconcile re-validates each PID via ps -ww -o state=,command=: a forward stays only if the process exists, isn’t a zombie (state != Z — so a dead-but-unreaped child we spawned this session is correctly seen as gone), and its command line still matches our ssh … <spec> (a PID-reuse guard — a recycled pid is never counted alive or signalled). Reconcile runs on startup, on opening the manager, and on the ~100ms event-loop tick while it’s open. Readiness/errors: -o ExitOnForwardFailure=yes makes ssh exit non-zero on a bind failure; spawn polls try_wait for ~2.5s and, on an early exit, maps the stderr (captured to a temp file, not a pipe, so a long-lived ssh never gets SIGPIPE) to a friendly message (port in use, privileged port, server refused, auth failed). A third kind, Dynamic (-D SOCKS), was added alongside Local/Remote. Rejected: a worker thread per forward (the transfer model — unneeded, a forward has no ongoing protocol to service, just liveness); holding the Child for try_wait (can’t track forwards from a previous session, and splits liveness into two code paths); ssh -f (clean daemonize but hides the real PID, breaking the reuse guard and individual kill); libc::setsid/nix::kill (a new dep the project avoids — process_group(0) + shelling to ps/kill, as we already shell to ssh/sftp, is dep-free); kill-only for v1 (restart of a dropped forward is deferred — the spec is persisted, so it’s an easy fast-follow).

D-020 · Sites: one-per-host grouping with optional inherited SSH defaults

Hosts can belong to a Site (a data center / project), distinct from many-valued free-form tags. A site is one per host and may carry optional shared SSH defaults — user, port, jump_hosts (the bastion), identity_files — that members inherit at connect time only where the host leaves that field unset (the host always wins). A bare site (name only) is pure grouping. Auth is not inheritable (it stays per-host; inheriting it would change which fields apply and surprise users — a site can still carry a default identity that only takes effect for key-auth members). Inheritance is computed by resolving a host into an “effective host” (Host::with_site_defaults) at every Host→ssh-args boundary (connect, yank, transfer master, CLI print/list-json), leaving ssh::build_args untouched — chosen over threading &[Site] through build_args and its many callers/tests. Hosts reference a site by name; an undefined name degrades gracefully (pure grouping, no inheritance, no error). Stored in hosts.toml as [[site]] (sites before hosts; format_version unchanged — old files load with sites = []). The list groups by site when idle and shows a flat ·site· column

  • site:NAME filter while typing. Renames in the F3 manager cascade to member hosts; deleting a site clears members’ site (self-healing) rather than leaving a dangling name. Rejected: a single special tag (too weak — no inherited config); a separate sites file (one atomic hosts.toml is simpler and keeps the reference local).

D-019 · File transfer rides an ssh ControlMaster; sftp/scp as subprocesses

The dual-pane transfer screen moves files over the system sftp/scp binaries, not a Rust SSH library: every pure-Rust option either pulls C deps (libssh2) or forces tokio and can’t reuse sshelf’s SSH_ASKPASS/ProxyJump auth. To support password hosts without a fragile PTY, sshelf authenticates once by opening a backgrounded ssh ControlMaster (reusing build_args + the askpass env exactly as connect does); sftp/scp then ride it with only -o ControlPath, so there is no re-auth and no per-file prompt. A spike against a local sshd confirmed that (a) SSH_ASKPASS supplies the secret to open the master and (b) sftp/scp ride it for put/get and recursive copies. The ride commands deliberately omit -p/-i/-J (the master already carries them) — which also avoids the ssh -p vs sftp/scp -P port-flag clash. Rejected: ssh2/wezterm-ssh (C deps), russh/openssh-sftp-client (tokio + no askpass reuse), and a PTY password screen-scraper (brittle, locale/version-dependent).

Update (transfers use sftp, not scp): listing and copying both run through sftp (ls/get/put). scp was dropped after a filename with spaces failed in testing — OpenSSH 9+ scp speaks the SFTP protocol and takes the remote path literally, so shell-quoting it (needed by legacy scp) injects literal quotes. sftp quotes via its own command parser consistently across OpenSSH versions, so one quoting rule (shell_quote) is correct everywhere.

D-018 · Configurable hosts file in config; config file via flag/env only

A hosts_file key in config.toml relocates the host DB (editable via the F2 settings screen, default under the config dir). The config file’s own location can’t be a config key (bootstrap/circular), so it’s set with --config / $SSHELF_CONFIG only and shown read-only in settings. The --config flag is plumbed by setting $SSHELF_CONFIG once at startup so every Paths::resolve() (incl. subcommands) sees it uniformly. Vault/state stay in the XDG data dir, so askpass is unaffected by a custom config. On hosts-file change, an existing target is adopted (never overwritten) and config is committed only after the hosts step succeeds (so a bad path can’t brick startup). Designed to grow (more settings fields later).

D-017 · Pick keys via a file browser; detect keys by header

The Key field cycles ~/.ssh keys with ←/→ and opens an in-TUI file browser on Enter so users can pick a key anywhere (e.g. an AWS .pem in ~/Downloads) without typing a path. Key discovery detects private keys by a PRIVATE KEY header (not just a .pub sibling), so .pem/keyless keys are found. Chosen over a path text field (the user explicitly didn’t want to paste paths) and over scanning many fixed locations (a browser is more general).

D-016 · Auth-aware wizard with a single-key picker

The add/edit form shows only the fields relevant to the chosen auth method, and key auth uses a picker over ~/.ssh keys (files with a .pub sibling) rather than a freeform path field. Matches the user’s request and reduces clutter. Trade-off: the picker selects one key; a host with multiple identity files keeps them on edit, but adding several is done via hosts.toml (the model still supports Vec). Discovery uses OsString (no lossy UTF-8) so keys aren’t missed.

D-015 · askpass answers password + passphrase, matched by prompt shape

The helper now supplies the host’s stored secret for both login-password and key-passphrase prompts (a host uses one auth method, so one secret suffices), enabling auto-supply for encrypted keys. To prevent a keyboard-interactive server from phishing the secret, matching is by OpenSSH prompt shape (ends-with password: / contains passphrase for), not a bare substring. Connect wires SSH_ASKPASS only when a stored secret exists (wire_askpass).

D-014 · age vault uses scrypt (passphrase recipient), not Argon2id

The earlier plan said Argon2id; age’s passphrase mode actually uses scrypt + ChaCha20-Poly1305. We use age’s built-in passphrase encryptor rather than composing a KDF/AEAD by hand (avoids nonce-reuse/parameter footguns). Docs corrected to say scrypt.

D-013 · Secret backend chosen by SSHELF_VAULT_PASSPHRASE (v1)

OS keyring by default; if SSHELF_VAULT_PASSPHRASE is set, use the age vault instead. Chosen over runtime keyring-availability detection + an interactive passphrase modal because it’s deterministic, scriptable (headless/CI), and avoids a TUI passphrase prompt plus askpass-side unlock in v1. Trade-off: headless users set the env var (shell profile / systemd). Auto-detect fallback + interactive prompt are future enhancements. A set-password CLI provisions secrets without the TUI.

D-012 · Project name: sshelf

Chosen over ssh-tui (generic), sssh (one keystroke from ssh, typo-prone), hopp (low discoverability). sshelf = “a shelf for your SSH hosts”: brandable, memorable, still contains “ssh” for search discoverability. Confirmed available on crates.io.

D-011 · Docs-in-sync rule

Every code/behavior change updates docs/ + docs/progress.md in the same change; the rule lives in CONTRIBUTING.md. Rationale: keep a publishable, never-stale knowledge base for an open-source project and its contributors.

D-010 · License: dual MIT OR Apache-2.0

Rust ecosystem norm (ratatui, ripgrep, crossterm). Maximizes downstream compatibility vs. single MIT or AGPL. AGPL rejected (limits commercial adoption for a CLI tool).

D-009 · Platforms: macOS + Linux only (v1)

exec() process replacement is Unix-only and the secret backends differ on Windows. Windows would need a separate spawn+wait path + Credential Manager — deferred to a later version.

D-008 · Frecency = use_count * exp(-decay_rate * days_since_last_used)

Mozilla Places–style. Simple, explainable, self-adjusting. Idle list sorts by frecency; while typing, fuzzy score dominates and frecency breaks ties. decay_rate (default 0.2) is configurable. Rejected: pure recency (ignores frequency), pure alphabetical (ignores usage).

D-007 · Read-only import via ssh2-config

Best-maintained Rust SSH-config parser. It intentionally skips Match/Include, so import must warn and degrade, not silently drop. We never write back to ~/.ssh/config.

D-006 · Config/data paths: etcetera base strategy (XDG everywhere)

~/.config/sshelf on both macOS and Linux (honoring XDG env vars). Rejected directories crate’s native strategy, which buries macOS files in ~/Library/Application Support — worse for a hand-editable CLI tool. State/vault go in the XDG data dir.

D-005 · Host DB format: TOML (hosts.toml), not SQLite

Human-readable and hand-editable — matches the “my own transparent store” intent; host counts are small (tens–hundreds). Atomic writes (temp+rename) prevent corruption. One research stream suggested SQLite for indexed frecency queries; rejected for v1 as overkill, but it’s a clean future migration if scale demands.

D-004 · Frecency state separate from hosts.toml (state.json)

Mutable counters churn on every connect; keeping them out of the user-owned host file keeps that file stable and diff-friendly. Keyed by stable host id so renames preserve history.

D-003 · Two-tier secrets: OS keyring primary + age vault fallback

keyring (Keychain / Secret Service) for desktops; an age-encrypted vault (master passphrase, in-memory per session) for headless/minimal Linux with no Secret Service daemon — exactly the boxes this tool targets. age (used by atuin) chosen over hand-rolled Argon2+ChaCha to avoid error-prone crypto. Secrets are never stored in hosts.toml.

D-002 · Password auto-supply: SSH_ASKPASS (+ REQUIRE=force), not sshpass

Our own binary is the askpass helper (detected via SSHELF_ASKPASS=1; ssh calls it as sshelf "<prompt>"). No external dependency; secret never appears in ps/argv. Mandatory consequence: the helper must inspect argv[1] and only answer password prompts, and we set -o StrictHostKeyChecking=accept-new to keep host-key prompts away from it. Validated by the M0 spike before anything builds on it. Rejected sshpass: not installed by default, exposes the password in the process table.

D-001 · Connect = tear down TUI then exec() into ssh (exit-to-shell)

User chose exit-to-shell over return-to-list. exec() (process replacement) gives ssh the real TTY cleanly. Consequence: nothing runs after exec(), so frecency is persisted before the handoff. Rejected spawn+wait (would be needed only for return-to-list).

D-000 · Stack: Rust + ratatui + crossterm, sync event loop, component pattern

Matches atuin’s look/feel (user preference). ratatui 0.30 requires Rust 1.88+ (rustup update mandatory). Synchronous crossterm::event::read() loop — no tokio, since the only long-running task (the SSH session) happens after the TUI exits. Component-per-screen structure over the Elm pattern for this app’s modal UI.

Packaging & distribution

How sshelf ships to Homebrew (macOS + Linux), Debian/Ubuntu (.deb), RedHat/Fedora (.rpm), and crates.io, for x86_64 and arm64. Releases are driven from GitHub Actions on a vX.Y.Z tag.

Chosen stack:

  • dist (cargo-dist) builds the binaries for all targets, makes the GitHub Release (tarballs + checksums), generates the Homebrew formula and pushes it to your tap, and emits a curl-able shell installer.
  • Hand-written companion workflows attach what dist doesn’t build to the same Release, each triggered via workflow_run after dist’s “Release” completes (so they never race to create it): release-deb.yml (.deb, via cargo deb), release-rpm.yml (.rpm, via cargo generate-rpm, built as a static musl binary), and release-crates.yml (cargo publish to crates.io).
  • clap generates shell completions + a man page (via sshelf completions / sshelf man).
  • crates.io: cargo-dist has no built-in crates.io publish job, so release-crates.yml runs cargo publish (needs a CARGO_REGISTRY_TOKEN repo secret; skips cleanly if it’s unset).

GitHub user is max-rh; the repo is github.com/max-rh/sshelf; the Homebrew tap is max-rh/homebrew-tap.

Contents

  1. Prerequisites
  2. Target matrix (x86 + arm)
  3. Homebrew + tarballs via dist
  4. Debian/Ubuntu .deb
  5. Shell completions & man page (clap)
  6. macOS signing — no Apple program needed
  7. Cross-compilation reference
  8. Release checklist
  9. Appendix: manual Homebrew formula & APT repo

1. Prerequisites

Set in Cargo.toml before the first release:

[package]
# ...existing fields...
repository = "https://github.com/max-rh/sshelf"
homepage   = "https://max-rh.github.io/sshelf"   # the GitHub Pages docs site
readme     = "README.md"
# `exclude` keeps the published crate lean (drops docs/, .github/, examples/, the gif, etc.).
# `authors` is optional (cargo no longer auto-fills it) — omit it, or use a project ALIAS,
# never your personal email: this file is public on GitHub and copied into every .deb.

Email/privacy: the maintainer in [package.metadata.deb] is shipped in every .deb and the repository link already gives users a way to reach you (Issues). Use a dedicated alias (e.g. a Gmail +tag, a forwarding address, or sshelf@yourdomain), not your private inbox.

Already in place in this repo:

  • [package.metadata.deb] (for cargo deb) and the .github/workflows/release-deb.yml workflow.
  • sshelf completions <shell> and sshelf man subcommands (clap).
  • Dual MIT OR Apache-2.0 license, committed Cargo.lock, MSRV 1.88.

Conventions / facts that matter:

  • A release is a git tag vX.Y.Z whose number matches Cargo.toml’s version.
  • Ship prebuilt binaries (Debian/Ubuntu’s packaged rustc often predates our MSRV 1.88).
  • sshelf execs ssh → the .deb depends on openssh-client; macOS has ssh built in.
  • Linux secrets use a pure-Rust Secret Service client — no libdbus/OpenSSL/tokio C build deps — so cross-compiling is easy and the .deb needs no -dev packages. The Secret Service daemon is a Recommends (the age-vault fallback exists).

2. Target matrix

OS / archRust targetBuilt by
macOS Apple Siliconaarch64-apple-darwindist, on an arm64 macOS runner
macOS Intelx86_64-apple-darwindist (cross on the arm64 runner)
Linux x86_64 (Debian/Ubuntu amd64)x86_64-unknown-linux-gnudist + .deb on ubuntu-22.04
Linux arm64 (Debian/Ubuntu arm64)aarch64-unknown-linux-gnudist + .deb on ubuntu-24.04-arm
Linux x86_64/arm64 static (the .rpm)*-unknown-linux-muslrelease-rpm.yml (cargo generate-rpm)
  • GitHub’s free arm64 Linux runners (ubuntu-24.04-arm, GA for public repos since Aug 2025) build arm64 natively — no QEMU. (They aren’t available to private repos on the free tier.)
  • macOS runners: macos-14/macos-15 are arm64, macos-13 is the last Intel one. dist cross-compiles x86_64-apple-darwin on an arm64 runner (both SDKs are present).
  • *-gnu is correct for .deb; *-musl gives a fully static tarball that runs on any distro (nice for the generic download and Homebrew-on-Linux), but isn’t used for .deb.

3. Homebrew + tarballs via dist

One-time setup

cargo install cargo-dist --locked      # installs the `dist` binary
dist init                              # interactive; safe to rerun anytime

Answer dist init with:

  • CI: GitHub.
  • Installers: shell and homebrew.
  • Targets: aarch64-apple-darwin, x86_64-apple-darwin, x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu (add the two *-musl targets if you want static tarballs). Decline Windows — sshelf is Unix-only; remove x86_64-pc-windows-msvc if it’s added.
  • Updater: no (install-updater = false) — Homebrew/apt self-update, and the shell-installer audience is small.
  • Homebrew tap: max-rh/homebrew-tap. Create that repo first and initialize it with a README (so it has a default branch dist can push the formula to). Then add a HOMEBREW_TAP_TOKEN secret to the sshelf repo — a PAT with write access to the tap repo, because the default GITHUB_TOKEN can’t push to another repo. Without it the publish-homebrew-formula job fails.

dist init writes its config to dist-workspace.toml and generates .github/workflows/release.yml. Let dist init/dist generate manage it (it pins cargo-dist-version to your installed version). Our config:

[workspace]
members = ["cargo:."]

[dist]
cargo-dist-version = "0.32.0"    # managed by dist; don't hand-edit
ci = "github"
installers = ["shell", "homebrew"]
tap = "max-rh/homebrew-tap"
targets = [
  "aarch64-apple-darwin", "x86_64-apple-darwin",
  "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu",
]
publish-jobs = ["homebrew"]
install-path = "CARGO_HOME"
install-updater = false

Drop the Windows target: dist init adds x86_64-pc-windows-msvc by default. sshelf is Unix-only (the connect path uses exec()), so the Windows build can’t compile — remove that target from targets, leaving the four above.

Releasing

# bump version in Cargo.toml, commit, then:
git tag v0.1.0
git push origin v0.1.0

The tag triggers release.yml (dist): it builds every target, creates the GitHub Release (tarballs + dist-manifest.json + shell installer), and updates the formula in max-rh/homebrew-tap. When that workflow finishes, release-deb.yml runs via workflow_run and attaches the .debs to the Release (§4) — sequenced, not racing.

Users then:

brew install max-rh/tap/sshelf        # macOS or Linux, picks the right arch automatically
# or the shell installer dist prints in the release notes:
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/max-rh/sshelf/releases/latest/download/sshelf-installer.sh | sh

macOS signing: Developer ID signing + notarization need the paid Apple Developer Program and are optional — a CLI installed via Homebrew runs fine unsigned. Do add a free ad-hoc codesign step for a stable signature. See §6 for the full free-vs-paid breakdown.

Completions in Homebrew: dist’s generated formula installs the binary. Shell completions are available immediately via sshelf completions <shell> (§5); the .deb installs them system-wide. If you want Homebrew to install them too, use the manual formula in the appendix with generate_completions_from_executable instead of dist’s formula.


4. Debian/Ubuntu .deb

dist doesn’t build Debian packages, so a companion workflow does. The package metadata is already in Cargo.toml:

[package.metadata.deb]
maintainer = "max-rh <max-rh@mail.com>"    # public alias, not a personal inbox
depends = "$auto, openssh-client"          # we exec ssh
recommends = "gnome-keyring"               # Secret Service daemon (vault is the fallback)
section = "utils"
# ...assets: the binary, README/SECURITY, and the generated completions + man page...

The workflow .github/workflows/release-deb.yml builds natively on each arch (ubuntu-22.04 for amd64, ubuntu-24.04-arm for arm64), generates completions + the man page with the sshelf subcommands, runs cargo deb --no-build, and attaches target/debian/*.deb to the Release. It triggers on workflow_run — i.e. after dist’s release.yml completes — so the two never race to create the Release: dist owns creation, this only attaches. (A release: published trigger wouldn’t fire, because dist creates the Release with GITHUB_TOKEN, and GITHUB_TOKEN-created events can’t trigger downstream workflows.) Outline:

on:
  workflow_run: { workflows: ["Release"], types: [completed] }
jobs:
  deb:
    # only after a successful, tag-triggered Release run; head_branch is the tag
    if: github.event.workflow_run.conclusion == 'success' &&
        github.event.workflow_run.event == 'push' &&
        startsWith(github.event.workflow_run.head_branch, 'v')
    strategy:
      matrix:
        include:
          - { arch: amd64, os: ubuntu-22.04 }
          - { arch: arm64, os: ubuntu-24.04-arm }
    steps:
      - uses: actions/checkout@v4
        with: { ref: "${{ github.event.workflow_run.head_branch }}" }   # the release tag
      - run: cargo build --release --locked
      - run: |                       # generate the packaged extras
          bin=target/release/sshelf
          "$bin" completions bash > dist-extra/sshelf.bash   # + zsh, fish
          "$bin" man              > dist-extra/sshelf.1
      - run: cargo deb --no-build    # .deb arch == runner arch
      - uses: softprops/action-gh-release@v2
        with:
          tag_name: "${{ github.event.workflow_run.head_branch }}"
          files: target/debian/*.deb

Users install a downloaded package with:

sudo apt install ./sshelf_0.1.0-1_amd64.deb     # resolves deps (openssh-client, …)

For a true apt install sshelf (no file download), host a signed APT repo — see the appendix. That’s the most involved channel; the .deb-on-Releases above covers most users.


4b. RedHat/Fedora .rpm (static musl)

Same shape as the .deb, with two differences. The package metadata is in Cargo.toml ([package.metadata.generate-rpm], built by cargo-generate-rpm), and the binary is built static musl (*-unknown-linux-musl) so one .rpm runs on any RPM distro — Fedora, RHEL/Rocky/Alma, openSUSE — regardless of glibc version. (sshelf is distro-agnostic at runtime: it only shells out to the system ssh/sftp/ps/kill, all OpenSSH/ procps, and the Linux keyring is the pure-Rust Secret Service with the age-vault fallback — none of it is Debian- or RPM-specific.) auto-req = "no" stops rpm from adding bogus shared-lib Requires to a static binary; we declare openssh-clients explicitly.

.github/workflows/release-rpm.yml mirrors the .deb workflow: workflow_run after dist’s Release, a matrix of x86_64 (ubuntu-22.04) and aarch64 (ubuntu-24.04-arm, native), rustup target add <musl>, cargo build --target <musl>, generate completions/man, then cargo generate-rpm --target <musl> (which rewrites the target/release asset paths to the per-target dir) and attaches the .rpm. Users install with:

sudo dnf install ./sshelf-0.8.0-1.x86_64.rpm     # or .aarch64.rpm

Why musl, not glibc like the .deb: a glibc binary built on the CI runner only runs on distros with an equal-or-newer glibc, which excludes older RHEL. Static musl sidesteps that entirely. (The .deb keeps glibc since Debian/Ubuntu users build/run on a known-recent glibc.)


4c. crates.io (cargo install sshelf)

cargo-dist has no built-in crates.io publish job (publish-jobs only knows homebrew/npm/ custom ./jobs), so publishing is a separate companion workflow, release-crates.yml: workflow_run after the Release, then cargo publish --locked. It needs a CARGO_REGISTRY_TOKEN repo secret (a crates.io API token, ideally scoped to the sshelf crate); the step skips cleanly if the secret is unset, so the workflow stays green before it’s added. The crate’s Cargo.toml carries the required metadata (description, license, keywords, categories, repository, homepage) and an exclude that drops docs//.github//examples/ from the published tarball. cargo publish builds from the tag’s source, so it’s independent of the release binaries.

cargo install sshelf      # once published

5. Shell completions & man page

sshelf generates these itself (clap), so packaging needs no extra tooling:

sshelf completions bash      # also: zsh, fish, elvish, powershell
sshelf man                   # roff man page on stdout
  • .deb ships them system-wide (/usr/share/bash-completion/..., /usr/share/man/man1/...) — generated in the deb workflow (§4) and listed in [package.metadata.deb].assets.
  • Homebrew (dist formula): users can source <(sshelf completions zsh), or switch to the manual formula (appendix) which auto-installs via generate_completions_from_executable(bin/"sshelf", "completions") and man1.install.
  • Tarball users: the binary is self-sufficient — run the subcommands as needed.

Implementation: src/main.rs builds the clap::Command with Cli::command() and feeds it to clap_complete::generate(...) / clap_mangen::Man::new(...). No build.rs needed.


6. macOS signing — and why you don’t need the $99 Apple program

Developer ID signing + notarization require the paid Apple Developer Program ($99/yr). You do not need it to ship sshelf, because it’s a CLI distributed via Homebrew, not a GUI app. Here’s the free path and exactly what (if anything) you give up.

What macOS actually enforces:

  • Apple Silicon refuses to run a binary with no signature — but a free ad-hoc signature satisfies it, and the macOS toolchain applies one automatically when it links the binary. (Intel Macs don’t even require that.)
  • Gatekeeper’s “unidentified developer” block only hits files carrying the com.apple.quarantine xattr, which browsers set on download. curl, git, and Homebrew don’t set it for CLI formulae — so a brew install-ed binary runs with no Gatekeeper prompt, signed or not. (Homebrew’s recent tightening — deprecating --no-quarantine, disabling failing casks in Sept 2026 — targets GUI .app casks, not CLI formulae like sshelf.)

Free distribution that “just works” (recommended order):

  1. Homebrewbrew install max-rh/tap/sshelf. No quarantine, no Gatekeeper prompt, no Apple account. This is how most open-source Rust CLIs ship. ✅ your main path.

  2. Build from sourcecargo install --git https://github.com/max-rh/sshelf, or a formula with depends_on "rust" => :build. Compiled locally → no signing questions at all.

  3. Ad-hoc sign in CI (free, recommended) — the chosen hardening. Guarantees a stable signature on every macOS artifact, no cert/account/Apple-program. Verified on an Intel build: the default cargo build leaves the binary “not signed at all”; one command fixes it:

    codesign --sign - --force target/<triple>/release/sshelf          # ad-hoc, free
    codesign -dvv target/<triple>/release/sshelf 2>&1 | grep Signature  # -> Signature=adhoc
    

    (arm64 binaries are auto ad-hoc-signed by the linker — Apple Silicon requires it to run; this step also covers the cross-built x86_64 and settles the Keychain point below.)

    Wiring it into dist: dist builds macOS on macOS runners and generates .github/workflows/release.yml. Add this step to that file’s macOS build job, right after the build, so both arches get a stable ad-hoc identity:

    - name: Ad-hoc sign macOS binaries (free, stable identity)
      if: runner.os == 'macOS'
      run: find target -type f -name sshelf -perm +111 -exec codesign --sign - --force {} \;
    

    Re-apply it if you re-run dist init (which regenerates release.yml). The Linux/.deb side needs no signing.

The one thing you lose without paying: a user who downloads the release .tar.gz directly in a browser gets it quarantined, so Gatekeeper blocks it until they clear it once:

xattr -dr com.apple.quarantine "$(command -v sshelf)"     # or: right-click the file → Open

Document that, or just steer direct-download users to Homebrew. Notarization is the only thing that removes this for direct downloads — and that needs the paid program.

Keychain prompt (sshelf-specific): the per-connect Keychain prompt happens when the signature is unstable (re-built dev binaries) or absent. A released, ad-hoc-signed binary has a stable identity, and sshelf’s askpass child is the same binary file as the parent, so the Keychain ACL one creates is honored by the other → no prompt. If a user still hits keychain friction, the age vault (SSHELF_VAULT_PASSPHRASE) bypasses the OS keychain entirely — the guaranteed-free fallback (see docs/security.md).

If you ever do pay ($99/yr) for friction-free direct downloads: sign with a Developer ID Application cert under the hardened runtime, then notarize (dist can automate this from CI secrets):

codesign --force --options runtime --timestamp \
  --sign "Developer ID Application: NAME (TEAMID)" target/<triple>/release/sshelf
ditto -c -k --keepParent target/<triple>/release/sshelf sshelf.zip
xcrun notarytool submit sshelf.zip --key AuthKey.p8 --key-id KEYID --issuer ISSUER_UUID --wait

(You can’t stapler staple a bare binary/zip — only .app/.dmg/.pkg — but a notarized zip is fine for Homebrew; ship a stapled .pkg for offline direct downloads.)


7. Cross-compilation reference

  • Native (what dist + the deb workflow use): build each target on a runner of that arch.
  • cross (cross-rs/cross): cross build --release --target aarch64-unknown-linux-gnu (Docker-based; handles the linker/sysroot).
  • Plain cargo cross-link (Linux):
    sudo apt-get install -y gcc-aarch64-linux-gnu
    rustup target add aarch64-unknown-linux-gnu
    CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \
      cargo build --release --target aarch64-unknown-linux-gnu
    
    (Pure-Rust apart from libc, so no other -dev libs are needed.)
  • macOS universal binary: build both Darwin targets, then lipo -create -output sshelf <arm64> <x86_64>.

8. Release checklist

  1. Bump version in Cargo.toml; update a CHANGELOG.md.
  2. CI green (cargo test, clippy -D warnings, cargo fmt --check).
  3. git tag vX.Y.Z && git push origin vX.Y.Z.
  4. Watch release.yml (dist → tarballs + Homebrew tap + shell installer); when it finishes, release-deb.yml runs via workflow_run and attaches the .debs. macOS artifacts are ad-hoc signed (§6).
  5. Smoke-test one install per channel and connect to a host from inside the TUI (the real-TTY acceptance check in docs/progress.md):
    • brew install max-rh/tap/sshelf
    • sudo apt install ./sshelf_*_amd64.deb
    • sudo dnf install ./sshelf-*.x86_64.rpm
    • cargo install sshelf (after the crates.io publish lands)

9. Appendix

Manual Homebrew formula (alternative to dist’s)

Use this if you want Homebrew to install completions + the man page, or prefer not to run dist. Put it in max-rh/homebrew-tap as Formula/sshelf.rb:

class Sshelf < Formula
  desc "TUI for managing and connecting to SSH hosts"
  homepage "https://github.com/max-rh/sshelf"
  version "0.1.0"
  license any_of: ["MIT", "Apache-2.0"]

  on_macos do
    on_arm   { url "https://github.com/max-rh/sshelf/releases/download/v#{version}/sshelf-aarch64-apple-darwin.tar.gz"; sha256 "…" }
    on_intel { url "https://github.com/max-rh/sshelf/releases/download/v#{version}/sshelf-x86_64-apple-darwin.tar.gz";  sha256 "…" }
  end
  on_linux do
    on_arm   { url "https://github.com/max-rh/sshelf/releases/download/v#{version}/sshelf-aarch64-unknown-linux-gnu.tar.gz"; sha256 "…" }
    on_intel { url "https://github.com/max-rh/sshelf/releases/download/v#{version}/sshelf-x86_64-unknown-linux-gnu.tar.gz";  sha256 "…" }
  end

  def install
    bin.install "sshelf"
    generate_completions_from_executable(bin/"sshelf", "completions")
    (man1/"sshelf.1").write Utils.safe_popen_read(bin/"sshelf", "man")
  end

  test do
    assert_match "sshelf", shell_output("#{bin}/sshelf --version")
  end
end

(brew bump-formula-pr can update the version + SHAs on each release.)

Signed APT repository (apt install sshelf)

Host a GPG-signed repo (e.g. on GitHub Pages) with reprepro:

gpg --full-generate-key
gpg --armor --export YOUR_KEY_ID > sshelf-archive-keyring.asc
# apt/conf/distributions: Codename: stable / Architectures: amd64 arm64 / Components: main / SignWith: YOUR_KEY_ID
reprepro -b apt includedeb stable sshelf_0.1.0-1_amd64.deb sshelf_0.1.0-1_arm64.deb
# publish the ./apt tree (dists/, pool/, signed Release/InRelease) to Pages

Users:

curl -fsSL https://max-rh.github.io/sshelf-apt/sshelf-archive-keyring.asc \
  | sudo tee /usr/share/keyrings/sshelf.asc >/dev/null
echo "deb [signed-by=/usr/share/keyrings/sshelf.asc] https://max-rh.github.io/sshelf-apt stable main" \
  | sudo tee /etc/apt/sources.list.d/sshelf.list
sudo apt update && sudo apt install sshelf

An Ubuntu PPA (Launchpad) is the native alternative but requires vendoring crates (cargo vendor, dh-cargo) because Launchpad builders have no network — more work than the signed-repo-of-prebuilt-.debs above for the same apt install UX.


Sources

Progress log

Reverse-chronological. Newest entry on top. Every change to the project adds an entry here (the docs-in-sync rule). Keep entries short: what changed, why, and what’s next.

Current milestone: Inventory import — sshelf import --tailscale fills the database from a tailnet. Targets v0.11.0. (v0.10.0 export shipped.)


2026-07-27 — sshelf import --tailscale: the tailnet as an inventory source

  • New tailscale.rs + a --tailscale flag on import: runs the user’s own tailscale status --json and maps every eligible peer to a host — MagicDNS first label → name, the MagicDNS FQDN → hostname (Tailscale IP, IPv4 first, when MagicDNS is off for the tailnet), the tailnet → a site, ACL tags minus the tag: prefix → tags, auth agent. Eligibility is one rule — the peer’s DNSName must sit under the tailnet’s own MagicDNSSuffix — which drops Mullvad exit nodes and shared-in nodes without special cases; expired peers are skipped, offline ones are not (an asleep laptop is still a host). Binary resolution: $SSHELF_TAILSCALE_BINPATH → the macOS app bundle, with an error that names all three.
  • Add-only, in both directions: existing hosts and sites are never updated or deleted (case-insensitive match), so a re-run converges to “0 added” and a byte-identical hosts.toml. Nothing tailscale-specific (node IDs, keys) is stored. The no-network posture stands — the CLI runs only from this subcommand, never at startup, on save, on a timer, or from the TUI. Rationale + rejected alternatives: D-024.
  • Refactor: both import modes now share one tail (apply_import) — dedupe, site creation (import::missing_sites), save, export refresh — so the D-023 fragment auto-refreshes after a tailnet import exactly as it does after an ssh-config one. The summary gained a duplicates count and a line per created site.
  • Verified end-to-end against a stub tailscale fed the test fixture, with an isolated config dir: 3 hosts + the site land in hosts.toml, counts match (3 parsed, 3 excluded — 2 foreign, 1 expired), a second run adds 0 with an identical file, --dry-run writes nothing, and an existing NAS host + differently-cased site are reused, not duplicated. A pre-created export fragment refreshed to the imported hosts; without one, nothing was created. Each failure mode gives one message and exit 1: backend not running, unparseable JSON, a bogus $SSHELF_TAILSCALE_BIN. ~/.ssh was byte-identical throughout (content hashes, not a directory listing), and the ssh-config import path was re-checked through the shared tail. 18 new tests (213 pass), clippy + fmt --check clean.
  • Live tailnet run: on a real 6-peer tailnet — MagicDNS disabled, so the IPv4 fallback took the real path — the binary resolved itself from the macOS app bundle and imported the 2 peers with valid node keys (100.x addresses, site from CurrentTailnet.Name), excluding 4 with expired keys and this machine (Self); a re-run added 0, and list --json / print-command produced usable ssh commands. Earlier, before login, the same client reported NeedsLogin → the friendly “run tailscale up” error, so both live states are covered.
  • Docs synced: import.md (now “Importing hosts”, with the mapping table and the opt-in/ no-network posture), cli reference (flag + $SSHELF_TAILSCALE_BIN), FAQ, structure, index, README, CHANGELOG (also collapsed a duplicated 0.10.0 heading + link line). Ships in v0.11.0 via feat/import-tailscale. Next: --hcloud/--aws only if demand shows up.

2026-07-12 — sshelf export: an ssh_config Include fragment

  • New export.rs + sshelf export [--stdout]: renders every host (site defaults resolved, name-sorted, deterministic — no timestamps) as ssh_config Host blocks into ~/.config/sshelf/ssh_config, and prints the one Include line for the user to add to ~/.ssh/config — which sshelf still never writes (it only reads it, to drop the hint once the Include is already present). After that, plain ssh/scp/sftp, rsync, git, and SSH-config pickers (VS Code Remote-SSH) resolve sshelf hosts by name.
  • Rendering mirrors build_args minus sshelf-only plumbing: no StrictHostKeyChecking=accept-new (an askpass necessity, not the user’s choice) and no askpass wiring. Exact -o Key=Value extras translate to directives; other flags become an in-block comment. Names that can’t be a safe Host pattern (glob/negation/comment/quote chars) are skipped with a comment; all values are control-char-sanitized so nothing can inject extra directives.
  • Auto-refresh: once the file exists (creating it = opt-in), every hosts save rewrites it — TUI persist_hosts, sshelf add, import, sites add, and the settings hosts-file adopt path — best-effort, so a refresh failure never blocks a save. Deleting the file opts out.
  • Verified end-to-end: ssh -G -F through a real Include resolves hostname/user/port/ ProxyJump (incl. a site-inherited bastion) and the translated option; auto-refresh observed live on sshelf add. 14 new unit tests (195 pass, clippy clean). Docs synced: new guide page export.md, D-023, data-model, structure, cli, import, faq, index, README, CHANGELOG. Targets v0.10.0 via feat/export.

2026-07-06 — Docs: user guide split from internals; slim README

  • The mdBook site now leads with a Guide for users: install, quickstart, adding/editing hosts, searching & connecting, file transfer, port forwarding, sites & tags, passwords/keys/2FA, import, a full CLI reference, configuration, and an FAQ. Content that previously lived only in the README (CLI flag table, completions setup, config reference) or inside ux.md moved into these pages; index.md is a product landing page instead of a doc inventory, and SUMMARY.md is reorganized into Guide / Understanding sshelf / Development.
  • ux.md slimmed to UI design notes (visual model, ranking, form design, modality, theming) — stale milestone markers removed; behavior and keybindings are documented in the Guide pages, which the docs-in-sync rule in CONTRIBUTING.md now points at.
  • README cut from ~230 to ~140 lines: pitch, install (secondary channels folded into a <details>), a “first five minutes” block, and links into the site; added crates.io/CI/ license badges and an explicit no-telemetry / no-account / no-cloud line. Everything removed now lives on the site.
  • Why: the README had become the user manual because the site didn’t have one, while the site led with contributor docs — and the site is the crates.io homepage, so a real guide is what visitors should land on. Docs-only change; no code touched.

2026-06-23 — Distribution: crates.io + RPM packaging

  • crates.io: new release-crates.yml (workflow_run after dist’s Release → cargo publish; needs a CARGO_REGISTRY_TOKEN secret, skips cleanly if unset). cargo-dist has no built-in crates.io publish job — publish-jobs only knows homebrew/npm/custom — so it’s a companion workflow like the .deb.
  • .rpm: new release-rpm.yml + [package.metadata.generate-rpm] (x86_64 + aarch64), built as a static musl binary so one rpm runs on any RPM distro regardless of glibc. ssh works identically on RHEL/Fedora — sshelf only shells out to system ssh/sftp/ps/kill.
  • Cargo.toml: homepage → the Pages docs site, refreshed description + keywords, readme, and an exclude that trims the published crate 64 → 43 files (drops docs/, .github/, examples/, the gif). Docs synced (packaging.md §4b/§4c, README install, CHANGELOG). Ships in the next release.

2026-06-23 — Interactive 2FA support

  • New: hosts can be flagged requires_2fa (add/edit form toggle, or sshelf add … --2fa). Connecting to one shows a code popup before the exec() handoff; the entered one-time code is passed to ssh via SSHELF_2FA_CODE and answered by the askpass helper at the verification prompt — the same channel that supplies a stored password.
  • Why: a spike confirmed that a stored-secret connect runs with SSH_ASKPASS_REQUIRE=force, which routes the keyboard-interactive code prompt to our helper with no terminal fallback, so it failed before. The helper now answers a non-secret prompt with the queued code; configure_askpass force-wires the helper when a secret exists or a code is queued (so key+2FA works too). The CLI direct-connect path prompts for the code on the terminal.
  • New module ui/two_factor.rs; Host.requires_2fa (old files load false); exec_connect / configure_askpass take an optional code (transfer + forward spawners pass None). Manual entry only — no TOTP seeds stored (rejected: same-vault second factor + a dep). Docs synced (decisions D-022, data-model, ux, structure, README, CHANGELOG). Targets v0.8.0 via feat/two-factor.

2026-06-21 — Port forwarding: background SSH tunnels (M0–M4)

  • New feature: background port forwards that survive sshelf exiting. Ctrl-f on a host opens a popup (Local -L / Remote -R / Dynamic -D SOCKS); F4 opens a manager that lists every active forward and stops any.
  • Each forward is a detached ssh -N process in its own process group (std process_group(0), no new dep), tracked by PID in forwards.json and reconciled against the OS (ps) on launch, on opening the manager, and each tick while it’s open — with a zombie filter and a PID-reuse guard. ExitOnForwardFailure=yes + a brief readiness poll surfaces bind/auth errors in the popup. An M0 spike confirmed detached survival (PPID→1, own process group); an #[ignore] e2e drives a real -L through a localhost sshd (bind → traffic → busy-port failure → kill).
  • New modules forwards.rs, ui/forward_popup.rs, ui/forwards.rs; the sshd e2e helper was extracted into a shared testsupport module (transfer e2e now uses it too). Docs synced (decisions D-021, data-model forwards.json, ux, structure, README, CHANGELOG). 168 tests + 2 e2e; clippy -D warnings + fmt clean. Targets v0.7.0 via feat/port-forward → PR → cut-release.

2026-06-17 — Sites: docs + feature complete (M5)

  • Docs synced: decisions.md D-020 (the Sites ADR), data-model.md ([[site]] schema + inheritance/override + back-compat), ux.md (grouped/flat list, site: filter, F3 manager, CLI table), structure.md (ui/sites.rs + model note), README (feature bullet, keys, usage), CHANGELOG [Unreleased].
  • Sites is feature-complete: one-per-host grouping with optional inherited SSH defaults (bastion/user/port/identity); grouped-when-idle / flat-when-filtering list; wizard chooser; F3 manager with rename + delete cascades; and the full CLI. 145 tests + 1 e2e; clippy + fmt clean. Ships in v0.6.0 via the usual feat/sites branch → PR → cut-release.

2026-06-17 — Sites: CLI surface (M4)

  • sshelf add --site NAME (-s) assigns a site (warns, non-fatally, if it isn’t defined yet).
  • sshelf sites lists defined sites with member counts + their defaults; sshelf sites --json for scripts; sshelf sites add NAME [-u/-p/-J/-i] creates one.
  • sshelf list shows a ·site· column; --json already carries the site field and a command that reflects inheritance. Dynamic completion of site names on --site.
  • 145 tests (add --site + sites parse); clippy + fmt clean. Verified end to end.
  • Next: docs (D-020, data-model, ux, structure, README, CHANGELOG).

2026-06-17 — Sites: wizard chooser + F3 manager (M3)

  • The add/edit form gains a Site chooser (←/→ over the defined sites + “(none)”); editing a host preselects its site.
  • New F3 sites manager (ui/sites.rs): a list of sites with add / edit / delete and an inline form for each site’s optional defaults (user/port/jump/identity, with name-uniqueness + port validation). Name edits are tracked as renames; on save the app rewrites member hosts’ site and clears any host whose site was deleted (orphans self-heal). Help overlay + list hint updated (F3, site:, and the previously-missing ^t).
  • Tests: wizard chooser + preselect; manager add/edit/rename/delete/duplicate-reject; an app-level F3 rename-cascade + delete-orphan end to end. 143 tests; clippy + fmt clean.
  • Next: CLI (add --site, sites/sites add, list column, completion), then docs.

2026-06-17 — Sites: grouped/flat host list (M2)

  • The host list now groups by site when idle (── {site} ({n}) ── section headers, sites alphabetical, (no site) last) and shows a flat ranked list with a dim ·site· column while filtering. recompute builds a grouped order when the query is empty (group_order); order still holds host indices only, so selection/navigation are unchanged — the renderer maps the selected host past the non-selectable headers to the ListState index.
  • Tests: group_order sectioning (case-insensitive, (no site) last); render checks for the grouped headers + the filtered site column. 135 tests; clippy + fmt clean.
  • Next: the wizard site chooser + F3 sites manager (M3), then CLI (M4).

2026-06-17 — Sites: model + inheritance + search (M1)

  • New Site concept: a one-per-host grouping that may carry optional shared SSH defaults (user/port/jump/identity) member hosts inherit at connect time. Bare site = pure grouping; per-host fields always override; auth stays per-host. Distinct from many-valued tags.
  • model.rs: Site struct + Host.site: Option<String> (by name) + HostsFile.sites ([[site]], sites-first; no format_version bump — old files load unchanged). Inheritance via Host::with_site_defaults(&[Site]) (clone, fill only unset fields, id preserved; unknown site name degrades to plain grouping) + find_site (case-insensitive). search_haystack includes the site.
  • search.rs: parse_query now also yields an optional site:NAME token; rank filters by it.
  • Threaded resolution into every Host→ssh-args boundary: TUI connect/yank/transfer, CLI connect/-/print-command/list --json command. App.sites loaded + persisted (and it follows an F2 hosts-file move). Verified end-to-end via print-command + list --json.
  • 132 tests (model inheritance/degradation, site: filter, store round-trip + pre-sites back-compat); clippy + fmt clean. No UI yet.
  • Next: the grouped/flat list (M2), then the wizard chooser + F3 sites manager (M3), CLI (M4).

2026-06-16 — Transfer: --transfer-log diagnostics

  • Added a transfer debug log: sshelf --transfer-log <FILE> (or $SSHELF_TRANSFER_LOG) appends every ssh/sftp command the worker runs plus its full stderr to FILE, so a failed transfer can be inspected after the fact (the status line still shows the one-line cause). No secrets are logged — the password reaches ssh via SSH_ASKPASS, never argv. The e2e test asserts the log captures the master + get/put commands. Docs: README, ux.md (CLI table + transfer section), security.md.

2026-06-16 — Transfer: use sftp (not scp) for the copy itself

  • Bug found in local testing: transferring a filename with spaces failed (scp: failed to upload … to '/…). OpenSSH 9+ scp speaks the SFTP protocol and takes the remote path literally, so the shell-quoting legacy scp needed became literal quotes in the name. Plain names slipped through because they aren’t quoted.
  • Fixed by running transfers through sftp get/put over the same master used for listing — sftp quotes via its own command parser consistently across OpenSSH versions, so the version-dependent scp quoting trap is gone. Removed scp_args/remote_spec; added a transfer_batch unit test and a spaces regression to the e2e test.

2026-06-16 — Transfer screen: transport core + worker

  • Started the dual-pane SFTP/SCP transfer screen. Settled the transport (see decisions.md D-019): move files over the system sftp/scp riding a single ssh ControlMaster, so keys/agent/ProxyJump and the stored keyring/vault secret are reused unchanged and password hosts work with no PTY. A spike against a local sshd confirmed SSH_ASKPASS opens the master and that sftp/scp ride it (put/get + recursive).
  • Landed the tested core in src/transfer/mod.rs: the master/sftp/scp argv builders, the user@host target + shell-quoted remote-path spec, the worker↔UI message protocol, and progress math.
  • Added the worker thread + ControlMaster lifecycle (src/transfer/worker.rs): it opens the master (reusing ssh::configure_askpass, now pub(crate)), polls it ready, lists remote dirs by parsing sftp ls -l, runs scp transfers with throttled progress + mid-flight cancel, and tears the master + control socket down on stop via RAII. 101 tests; clippy + fmt clean. No UI yet; the live end-to-end run lands with the engine milestone.
  • Added transfer/pane.rs: one side’s state — fuzzy filter + selection + navigation reused from the key-picker browser, a synthetic .. entry, ls -F-style dir/@-symlink labels with control-char stripping, and a local-directory reader. Kept source-agnostic rather than behind a DirSource trait (a synchronous remote list() would block the very UI loop the worker keeps responsive); the screen feeds local entries via std::fs and remote ones via the worker. 109 tests; clippy + fmt clean.
  • Wired the screen end to end: transfer/screen.rs (two panes over one session — local nav is synchronous, remote nav requests via the worker, events drained each tick) and ui/transfer.rs (two panes, progress/status line, hint bar; TestBackend-snapshotted via a borrowed view, and a “terminal too small” clamp). Ctrl-t on the list opens it (Outcome::Transfer); the event loop polls + drains while it’s open and tears the worker down on close (RAII). Keys: Tab switch · /Enter open · Ctrl-s send file/folder · /Backspace up · Esc cancel/ clear/close. Docs: ux.md transfer section + keybinding. 113 tests; clippy + fmt clean.
  • Validated the transport end to end against a throwaway localhost sshd (transfer/e2e.rs, #[ignore]d — run with cargo test -- --ignored): the master opens, sftp pwd/ls parse, single-file download + upload (contents verified), and recursive directory download all pass.
  • Robustness + docs pass: a same-named destination is skipped rather than clobbered; README gains a feature bullet + the ^t key, security.md covers the transfer network path, and structure.md maps the new modules. Added master-command tests for ProxyJump + password hosts — the auth itself reuses build_args/configure_askpass (already tested), and the M0 spike proved SSH_ASKPASS opens the master, so a password target and key/agent jumps work; a fully automated password-auth transfer E2E needs a PAM/Docker sshd (the rootless test server is key-auth only) and is a CI-with-Docker follow-up.
  • The transfer screen is functionally complete: dual-pane browse + fuzzy on both sides, single-file and recursive folder transfer in both directions over one multiplexed master, live progress, cancel, and overwrite-skip. 116 unit tests + 1 e2e; clippy + fmt clean.

2026-06-13 — CLI: non-interactive add, list –json, dynamic completion, reconnect-last

  • sshelf add gained flags for a fully non-interactive add (scripts/dotfiles): NAME + -H/--hostname required; -u/-p/-a/-i/-J/-t/--extra/--password-stdin. Auth is inferred (key from --identity, password from --password-stdin, else agent). --extra allows hyphen-leading values; --password-stdin keeps the secret out of argv. Bare sshelf add still opens the TUI form. Duplicate names are refused. (AddArgs::into_host is pure/tested.)
  • sshelf list --json emits each selected host’s fields plus its generated command, always valid JSON (even empty) — the stable surface for integrations.
  • Dynamic shell completion of host names via clap_complete (unstable-dynamic): CompleteEnv in main, ArgValueCandidates on the <host> args of direct-connect / print-command / set-password; host_name_candidates reads hosts.toml side-effect-free. Enable with source <(COMPLETE=<shell> sshelf).
  • sshelf - reconnects to the most-recently-used host (last_used_id over the frecency state); the CLI connect path was factored into a shared connect().
  • 99 tests; clippy + fmt clean; verified end-to-end (add/list –json/password-stdin/completion).
  • Docs: README (usage + an “Adding hosts from the CLI” flag table + a “Shell completions” section) and the docs/ux.md CLI table.

2026-06-12 — CLI: print generated ssh command

  • Added sshelf print-command <host>: prints the same shell-quoted ssh … command as the TUI Ctrl-y yank action, without connecting or updating frecency. Useful for scripts, wrappers, and review before running a connection.
  • Fixed generated command strings to expand identity-file ~ before shell-quoting, so yanked or printed commands remain copy-paste runnable.
  • Docs synced: README usage, docs/ux.md CLI table, and docs/ssh-command.md builder note.

2026-06-07 — Pre-launch hardening

  • sshelf add now opens the TUI with the add form ready (app::run_add) — it was a placeholder message. Empty-list hint and internal comments cleaned of milestone references.
  • Vault env hygiene: configure_askpass strips SSHELF_VAULT_PASSPHRASE from the child env when no stored secret is wired; kept (and now documented) for vault-mode askpass, which reads it as ssh’s child. Two new env-wiring tests (ssh.rs).
  • SECURITY.md: concrete private-reporting channels (GitHub advisories + email) replace the placeholder note; added the vault-mode env-inheritance tradeoff (mirrored in docs/security.md + docs/ssh-command.md).
  • CHANGELOG.md added (backfilled 0.1.0 / 0.2.0); README now states the no-network posture (no telemetry / account / network calls) and documents sshelf add.
  • CI: new cargo audit (RustSec) and MSRV-1.88 check jobs.

2026-06-07 — Release v0.2.0

  • Cut v0.2.0: ships the sshelf <host> direct-connect and sshelf list <query> filter (below). Tagging v0.2.0 republishes brew / shell installer / .deb via dist.

2026-06-07 — CLI: direct connect + list filter

  • sshelf <host> connects straight to a saved host by name/id, skipping the TUI (reuses the TUI connect path: frecency recorded before exec, askpass wired only when a secret exists). A miss suggests close names. Clap routes via args_conflicts_with_subcommands, so subcommand names win.
  • sshelf list [query] filters with the same syntax as the TUI search box (search::rank): fuzzy text and/or tag:NAME. Plain sshelf list is unchanged.
  • 88 tests (added clap-routing + host-resolution); clippy + fmt clean. Docs: README usage + a brew completion-reload note; new docs/ux.md CLI section.

2026-06-07 — README demo GIF

  • Added an animated demo to the top of the README (docs/sshelf-readme.gif): fuzzy-search → yank the generated ssh command.

2026-06-06 — v0.1.0 released

  • First public release is live: dist’s Release workflow built all four targets, created the GitHub Release (tarballs + shell installer), and published the Homebrew formula; release-deb attached the amd64/arm64 .debs. All jobs green.
  • README Install section rewritten for the real channels (Homebrew, shell installer, .deb, from source). docs/packaging.md synced to the shipped setup: dist-workspace.toml config, workflow_run sequencing of the .deb job, and the HOMEBREW_TAP_TOKEN prerequisite.

2026-06-06 — Release pipeline: dist (cargo-dist) wired up

  • dist init: shell + Homebrew installers, 4 Unix targets (mac + linux × x86_64/arm64), install-updater = false. Added release.yml, dist-workspace.toml, and [profile.dist].
  • Dropped the x86_64-pc-windows-msvc target dist added by default — sshelf is Unix-only (the connect path uses exec()), so a Windows build can’t compile.
  • Reworked release-deb.yml to run via workflow_run after the dist Release workflow finishes, attaching the .debs to the release dist creates — avoids both workflows racing to create the same release.
  • Before tagging: create the max-rh/homebrew-tap repo + a HOMEBREW_TAP_TOKEN secret (PAT) so the Homebrew formula can be published.

2026-06-06 — CI: fix the push trigger

  • ci.yml listened on main, but the default branch is master, so direct pushes never ran CI. Now triggers on [master, main].

2026-06-06 — Funding notes: trim public meta-commentary

  • Removed the BTC-address caveat from the README Support section (the donate badge + address stay).
  • Trimmed the .github/FUNDING.yml comment down to the functional config.

2026-06-06 — Docs: contributor guide + naming polish

  • Adopted CONTRIBUTING.md as the contributor guide (GitHub-conventional name) and refreshed its cross-references in docs/{index,structure,decisions}.md.
  • Standardized the “docs-in-sync rule” naming across the docs.
  • No code changes.

2026-06-05 — Post-v1: browser fuzzy search, dynamic wizard width, settings screen ✅

  • File browser fuzzy search — type to filter the listing (nucleo); Backspace edits the filter (else up-dir), Esc clears it (else cancels). Shared ui::highlight between the host list and browser.
  • Dynamic wizard width — the add/edit form sizes to the terminal (clamped 56–100), fixing placeholder truncation; longest placeholders trimmed; placeholders now read optional · / required ·.
  • Settings screen (F2) + ui/settings.rs: edit the hosts-file location (default shown; ~ expanded), config-file path shown read-only. New hosts_file config key; --config flag + $SSHELF_CONFIG env (plumbed via env so subcommands + askpass-irrelevant paths stay uniform); Config::save/hosts_path; App.hosts_path threaded through list/import/set-password.
  • Fix: the hosts-file relocate could overwrite an existing target with the (possibly empty) in-memory hosts → now it adopts an existing file and only writes through to a new path, committing config only on success. Two app-level tests cover both branches.
  • Help overlay height bumped (the F2 line was clipping). 84 tests; clippy + fmt clean.
  • Deviation to confirm: “custom config file” is via --config/env (shown read-only in settings), not editable in the wizard — the bootstrap-correct interpretation.
  • Snapshots: target/{wizard,browse,settings}-snapshot.txt.

2026-06-05 — Post-v1: .pem keys + in-TUI file browser ✅

Follow-up to the wizard work (user requests):

  • .pem / keyless keys are detectedscan_keys includes any private key by sniffing a PRIVATE KEY header, not just <name>.pub pairs (AWS keys show up).
  • File browser (ui/browse.rs) — the Key field opens it with Enter (←/→ still cycles recent ~/.ssh keys); navigate dirs and pick a key anywhere without typing a path. A browsed path is stored as the host’s identity even outside ~/.ssh.
  • Placeholders now mark fields optional · / required ·. The Key field’s hint becomes “←/→ recent keys · ↵ browse files” when focused.
  • 75 tests (incl. scan_keys against a temp dir with a .pem, browser nav, Enter→browse); clippy + fmt clean. Snapshots: target/{wizard,browse}-snapshot.txt.
  • Acceptance gate: the browser + Enter→browse→pick flow is TestBackend-only; a real-TTY run (open the Key field, browse to a .pem, pick, save, connect) is still pending — folded into gate #2 below.

2026-06-05 — Post-v1: auth-aware wizard, key picker, key-passphrase auto-supply ✅

User-requested wizard improvements:

  • Every field shows a dim placeholder explaining it.
  • The form is auth-aware — only relevant fields show: key → Key picker + optional Key passphrase; password → Password; agent → neither.
  • Key picker cycles private keys discovered under ~/.ssh (files with a .pub sibling).
  • Key passphrase (optional) is stored as the host secret; askpass now answers passphrase prompts too, and connect wires askpass whenever a stored secret exists (password OR passphrase).

Hardening review — confirmed and fixed:

  • the “password NOT stored” message → “secret NOT stored” (applies to key passphrases too);
  • is_secret_prompt tightened to OpenSSH prompt shapes (ends-with password: / contains passphrase for) so a keyboard-interactive server can’t phish the stored secret;
  • discover_ssh_keys no longer uses lossy UTF-8 conversion (won’t miss/corrupt keys);
  • editing a multi-key host no longer drops the extra identity files.
  • Dismissed false alarms: env-clearing already unconditional, the keyring check is fail-closed, multi-key-passphrase is out of scope. Skipped 2 lows (wide-char mask cosmetics; the already documented macOS double-Keychain-prompt on unsigned builds).
  • 66 tests; clippy -D warnings + cargo fmt --check clean.

2026-06-05 — M8: OSS readiness ✅

  • Linux verified for real (Docker rust:latest): build + all 63 tests pass. The first Linux build caught a bugsync-secret-service pulled the C libdbus-sys (needs libdbus-1-dev). Switched to pure-Rust async-secret-service + crypto-rust + async-io → no C/OpenSSL/tokio build deps. (Closes acceptance gate #3.)
  • README.md, SECURITY.md (threat model + macOS-signing note), LICENSE-MIT + LICENSE-APACHE (dual), and .github/workflows/ci.yml (fmt + clippy + build + test on macOS & Linux, plus a headless-vault job that stores/retrieves via the age vault with DBUS_SESSION_BUS_ADDRESS unset — verified locally).
  • cargo fmt applied repo-wide so the CI format check passes.
  • 63 tests; clippy -D warnings clean on macOS and Linux.

2026-06-05 — M7: read-only import from ~/.ssh/config ✅

  • import.rs: ssh2-config 0.7.1 parse (ALLOW_UNKNOWN_FIELDS) → Host mapping (name, hostname, user, port, identity files; the parser expands ~ to an absolute path). Skips wildcard patterns; warns about Match / Include / ProxyJump (unsupported).
  • Ctrl-o in the TUI imports all new (non-duplicate-by-name) hosts; sshelf import [--dry-run] does the same from the CLI. Never writes ~/.ssh/config.
  • Verified against the real ~/.ssh/config: parsed 4 hosts read-only (mtime unchanged), correct mapping, --dry-run wrote nothing.
  • v1 deviation: no in-flight per-host selection UI — it imports all new hosts, then you curate with edit/delete (recorded in docs/ux.md).
  • 63 tests pass; clippy -D warnings clean.

2026-06-06 — Distribution: dist + .deb + clap completions/man (chosen stack)

Picked the channels (GitHub user max-rh): dist/cargo-dist for Homebrew + tarballs + shell installer, cargo-deb for Debian/Ubuntu, clap for completions/man, no crates.io.

  • Code: added sshelf completions <shell> and sshelf man subcommands (clap_complete / clap_mangen via Cli::command() — no build.rs). Verified bash/zsh/fish + roff output.
  • Packaging: [package.metadata.deb] in Cargo.toml (depends openssh-client, recommends gnome-keyring, ships completions + man); .github/workflows/release-deb.yml builds amd64 (ubuntu-22.04) + arm64 (ubuntu-24.04-arm) natively and attaches .debs to the v* Release (upserts alongside dist’s release.yml).
  • docs/packaging.md rewritten around this stack (multi-arch x86+arm, dist init choices, the deb companion, the macOS signing/Keychain note, manual Homebrew formula + APT repo in an appendix). dist’s release.yml itself is generated by dist init (documented).
  • §6 reframed: no paid Apple Developer Program needed — a CLI via Homebrew runs unsigned (Homebrew doesn’t quarantine formulae; arm64 just needs the free auto ad-hoc signature). Paid Developer ID/notarization is optional (only removes Gatekeeper friction for direct .tar.gz downloads). Vault stays the free Keychain fallback.
  • Chose option 3 (free ad-hoc signing): verified on this Intel Mac that a default build is “not signed at all” and codesign --sign - --forceSignature=adhoc; documented the exact step + where it slots into dist’s release.yml (§6). No paid Apple program.
  • Email: advised an alias (public in .deb/repo); authors made optional. License: keep dual MIT OR Apache-2.0. Funding: BTC only for now (GitHub Sponsors needs a payout setup) — README Support section + .github/FUNDING.yml (custom→README).
  • Pre-public-push scan: clean (no real keys/personal email/host IPs). Swapped a coincidental LAN IP in a test for the RFC5737 doc range; set Cargo.toml repository/homepage to max-rh/sshelf.
  • 84 tests; clippy + fmt clean. BTC address filled in. Ready for the initial public push (branch master).

⚠ Unverified paths (acceptance gates before “done”)

These are verified by unit tests but NOT yet exercised on a real path; treat as manual acceptance gates (a sandbox can’t cover them):

  1. macOS OS-keyring path — only the vault secret path (SSHELF_VAULT_PASSPHRASE) is verified end-to-end. The default macOS path (no env var → Keychain) is unrun; an unsigned dev build’s re-exec’d askpass child may hit a Keychain access prompt per connect (ACLs are keyed to code signature). Run from a real macOS GUI session; until then, the vault is the recommended setup and is what’s been proven.
  2. The full in-TUI connect chain has never run as one piece. For a password host it is: real TTY → exec_connect (which sets SSH_ASKPASS/SSHELF_* env) → exec(ssh) → ssh re-execs sshelf (askpass mode) as a child, which resolves paths + fetches the secret. The M5 E2E hand-set the env and called ssh directly — it did not go through exec_connect; and TestBackend doesn’t touch raw mode / alt-screen. Acceptance test: connect to a real password host from inside the TUI (not just ssh), and exercise the key file browser (open the Key field → Enter → browse to a .pem, type-to-filter → pick → save → connect) and the F2 settings relocate (change the hosts file, confirm it adopts/relocates correctly). If macOS Keychain prompts on every connect for the unsigned dev build, that’s expected → use the vault or a signed build.
  3. Linux build — ✅ closed (M8): built + tested in Docker rust:latest (63 tests pass) with the pure-Rust async-secret-service backend; CI now builds/tests Linux + a headless DBUS_SESSION_BUS_ADDRESS-unset vault job. (First real CI run still pending.)

2026-06-05 — M6: tags, config, theme, frecency wiring ✅

  • Tag filtering (the explicitly-chosen v1 feature): tag:NAME tokens in the query AND every tag (case-insensitive, exact); remaining words fuzzy-match. Combine freely (tag:prod web). Help overlay + hint bar updated.
  • default_sort wired into the TUI (was list-CLI only): empty query honors frecency-or-name from config.
  • config.toml made real: a commented default is written on first run (TUI or list), with decay_rate, default_sort, and a new accent color (themes the UI via a one-time color cell). Default-template parse is tested.
  • Deleted dead error.rs (committed fully to anyhow).
  • 59 tests pass; clippy -D warnings clean. Verified default config write + tag filter.

2026-06-05 — M5: secrets + password auto-supply ✅ (verified end-to-end)

  • vault.rs: age-encrypted (age 0.10.1, scrypt + ChaCha20-Poly1305) host_id → password map; store/get/delete + atomic writes. secrets.rs: routes to the OS keyring by default, or the vault when SSHELF_VAULT_PASSPHRASE is set (deterministic, headless/CI-friendly). keyring 3.6.3 with per-target backends (apple-native / sync-secret-service / windows-native).
  • askpass.rs: headless SSH_ASKPASS mode — inspects argv[1], answers only password prompts (fetches by SSHELF_HOST_ID), declines everything else with exit 1.
  • ssh.rs: configure_askpass sets SSH_ASKPASS/REQUIRE=force/SSHELF_ASKPASS/SSHELF_HOST_ID for password hosts only, clearing inherited askpass otherwise.
  • Wizard gained a masked Password field; save stores the secret; delete removes it.
  • New sshelf set-password <name|id> CLI (reads stdin) for headless/scripted provisioning.
  • End-to-end verified with the real binary against the live password sshd: set-password → vault; askpass returns the secret for a password prompt and declines a host-key prompt (exit 1); and a full ssh (SSH_ASKPASS=sshelf) logged in with no prompt (PW_AUTOSUPPLY_OK).
  • 54 tests pass (vault round-trip, prompt classification, wizard password capture); clippy clean.

2026-06-05 — M4: add / edit / delete ✅

  • ui/widgets.rs: hand-rolled single-line TextField (insert/backspace/cursor moves).
  • ui/wizard.rs: full-screen add/edit form (9 focusable fields: name, hostname, user, port, auth toggle, identity, jump hosts, tags, extra args) with inline validation; returns WizardOutcome {Continue, Cancel, Save(Host)}. Chose a single-screen form over a paged wizard (simpler/editable); ux.md updated.
  • app.rs: Ctrl-a add, Ctrl-e edit (prefilled), Ctrl-d delete (confirm popup). Save upserts by id and writes hosts.toml atomically; delete also drops the frecency entry.
  • Verified add-persists-to-disk and delete via tests (incl. reload-from-disk). Wizard render snapshot at target/wizard-snapshot.txt.
  • 46 tests pass; clippy -D warnings clean.

2026-06-05 — M3: connect via exec() + yank ✅

  • ssh.rs: build_args (-i per key with ~ expansion, -p only if non-22, -J comma chain, -o StrictHostKeyChecking=accept-new, shlex-split extra args, user@host); command_string (readable, tilde-preserved, for yank); exec_connect via CommandExt::exec (unix process replacement); copy_to_clipboard (arboard, best-effort).
  • app.rs: EnterOutcome::Connect, Ctrl-yOutcome::Yank. Connect defers to after ratatui::restore(): run records frecency + saves state, then execs ssh (clean TTY). Panic-safety is handled by ratatui’s init() panic hook (no separate RAII guard needed).
  • Added shlex 2.0.1, arboard 3.x (no-default-features, text-only).
  • Verified: recreated the spike sshd with a public key and connected with the exact build_args flag set (-i … -p 2222 -o StrictHostKeyChecking=accept-new tester@127.0.0.1) → CONNECT_OK. (Interactive TUI→exec is TTY-only; argv logic is unit-tested and the live connection is proven here.)
  • 33 tests pass; clippy -D warnings clean (collapsed nested ifs into 1.88 let-chains).

2026-06-05 — M2: core TUI (list + fuzzy search) ✅

Added ratatui 0.30.0 + nucleo-matcher 0.3.1. The atuin-style launcher renders: search box (with matched/total in the title), highlighted fuzzy list, contextual hint bar, F1 help overlay.

  • search.rs: nucleo fuzzy ranking; empty query → frecency order, else score desc with frecency tiebreak; match_indices for per-char highlight.
  • app.rs: App + pure on_key returning Outcome {Continue, Quit, Connect(idx)}, plus the sync event loop using ratatui::init()/restore(). Single-mode search → Ctrl-based actions (resolved the plain-letter-vs-typing conflict; ux.md updated).
  • ui/{mod,list,help}.rs: rendering as pure fns of &App, verified with TestBackend (no TTY). ASCII snapshot written to target/tui-snapshot.txt.
  • 25 tests pass; clippy -D warnings clean. Connect currently shows a placeholder status; the real exec() handoff is M3.

2026-06-05 — M1: scaffold + persistence ✅

Crate sshelf (edition 2024, rust-version = 1.88, license MIT OR Apache-2.0) builds clean with clippy -D warnings; 12 unit tests pass.

  • Deps resolved: serde 1.0.228, toml 1.1.2, serde_json 1.0.150, etcetera 0.11.0, clap 4.6.1, thiserror 2.0.18, anyhow 1.0.102, ulid 1.2.1.
  • Modules: model (Host/AuthMethod/HostsFile + ULID ids), paths (XDG via etcetera::Xdg~/.config/sshelf confirmed on macOS), store (TOML load/save + atomic temp+rename), state (frecency: use_count/last_used, score = count·e^(−decay·days)), config (decay_rate, default_sort), error (typed SshelfError).
  • main: clap CLI (list/add/import), askpass-via-env dispatch stub, list works and sorts by frecency. Verified end-to-end against examples/hosts.sample.toml.
  • Forward-declared API (save_hosts, atomic_write, state::save/record_use, Host::new, find, search_haystack) carries #[allow(dead_code)] + a milestone note; each allow is removed as the function is wired up.
  • Note: cargo defaulted to edition 2024; updated the project guide accordingly.

2026-06-05 — M0: askpass mechanism validated (spike) ✅

Empirically validated the password auto-supply design against a real password-auth sshd (Docker lscr.io/linuxserver/openssh-server, OpenSSH 10.2 client) on macOS. Also bumped the toolchain: Rust 1.74 → 1.96.0 via rustup update (clears the ratatui 0.30 MSRV gate).

  • Test 1 (success): SSH_ASKPASS=helper SSH_ASKPASS_REQUIRE=force + PreferredAuthentications=password
    • StrictHostKeyChecking=accept-newlogged in, exit 0. Confirms SSH_ASKPASS satisfies interactive PasswordAuthentication (not just key passphrases). The helper received argv[1] = "tester@127.0.0.1's password: ".
  • Test 2 (host-key routing): with StrictHostKeyChecking=ask + fresh known_hosts, ssh sent the helper the host-key prompt ("…Are you sure you want to continue connecting (yes/no/[fingerprint])?"), and a naive “always return the password” helper caused an infinite loop on "Please type 'yes', 'no' or the fingerprint:".
  • Conclusions (both already in the design): the helper must inspect argv[1] and answer only password prompts (exit non-zero otherwise), and we must pass -o StrictHostKeyChecking=accept-new so the host-key prompt never reaches it. See ssh-command.md §3.
  • Spike container kept running (sshelf-spike, host port 2222) for reuse in M5.

2026-06-05 — Documentation foundation

  • Created the project guide (the docs-in-sync rule + the hard project invariants).
  • Created the docs/ tree: index, progress, architecture, structure, data-model, ssh-command, ux, decisions, security — all seeded from the project plan.
  • No Rust code yet. Toolchain still on Rust 1.74 — must rustup update to 1.88+ before M1.
  • Next: M0 askpass spike (validate password auto-supply on macOS + Linux before building on it).

Milestones

Tracking against the project plan. Status: ⬜ not started · 🟡 in progress · ✅ done.

#MilestoneStatus
Docs foundation (project guide + docs/)
M0Spike SSH_ASKPASS password mechanism✅ (macOS; Linux pending in CI)
M1Scaffold crate + persistence (paths/model/store, clap, licenses)
M2Core TUI: list + fuzzy search + highlight + hint bar
M3Connect via exec() handoff (key/agent hosts) + yank
M4Add/Edit/Delete wizard (+ quick-add)
M5Secrets (keyring + age vault) + password auto-supply (askpass)
M6Polish: frecency tuning, tags, config, help, theme
M7Read-only import from ~/.ssh/config
M8OSS readiness: README, SECURITY, CI, licenses

The full milestone detail lives in the project plan.