A fast, read-only ~/.ssh/config picker that exec's the system ssh client. Includes: static high-contrast picker with recency/frequency/name sort and tag grouping, `dial keygen` (native ed25519/RSA + optional config entry), and an optional offline YubiKey launch gate with one-time recovery codes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8.0 KiB
dial — project report
A status report on the dial CLI, written to be self-contained so it can be
pasted into a fresh chat for brainstorming. No access to the codebase is needed
to follow it.
What dial is
dial is a fast, interactive command-line SSH host picker. It reads
~/.ssh/config (strictly read-only), shows a clean static list of hosts, and
on selection execs the real system ssh client into the chosen host — so the
terminal/tty, ProxyJump, agent forwarding, etc. all "just work" because dial
gets out of the way (it does not stay a parent process wrapping ssh).
It's written in Go, ships as a single static binary per platform, and targets macOS (Apple Silicon + Intel), Linux (amd64 + arm64), and Windows.
Design decisions already made
These are settled and built:
- Language: Go. Chosen over Python/Bash for true single-binary cross-platform distribution and fast cold start.
- UI style: static, numbered, high-contrast list — NOT fuzzy-filter-as-you-
type. This was a hard requirement: legible in low light, generous spacing,
large clear text, high contrast. A
/key opens an on-demand search/filter, but the resting state is always the clean static list. - TUI library:
charmbracelet/bubbletea+lipgloss. - Config parsing: hand-written parser (not a library) so it can follow
Includedirectives and support a custom# tag: prod, riedelcomment convention aboveHostblocks for grouping/filtering. - Connect = process handover. Unix uses
syscall.Exec(replaces the process); Windows runs ssh.exe as a child with inherited stdio and exits with its status. - App data lives in
~/.ssh/.dial/so it rides along on the user's existing Syncthing sync — no new sync config. Two files:settings.json(prefs + hashed gate data) anddata.json(recents, frequency, favourites). Writes are atomic; reads tolerate a partial/corrupt file mid-sync by falling back to defaults; concurrent cross-machine writes are last-write-wins. - YubiKey gate (optional): offline HMAC-SHA1 challenge-response by shelling
out to Yubico's
ykmanCLI (slot 2 default). This is the one feature that breaks "zero external deps" —ykmanmust be installed separately — a deliberate trade to lean on Yubico's own tool. dial checks forykmanon PATH and errors clearly if missing.- Gate timing: required on every launch (chosen over an idle-timeout).
- Backup recovery code: 6 groups of 5 base32 chars (~150 bits), shown in the terminal exactly once at enable time, stored only as a salted hash. Single-use: after it's used to get in, it's invalidated and a fresh one is issued (shown once).
- Nothing secret is ever stored or synced. In challenge-response mode the YubiKey secret never leaves the key; dial persists only a random (non- secret) challenge plus a salted hash of the expected response.
- Selection is decoupled from the connect action so a future SFTP mode can
reuse the picker without a rewrite. There's already a stubbed
SFTPcommand alongsideSSH.
Architecture (package layout)
cmd/dial— entrypoint + CLI subcommands (yubikey enable/disable/status/ recovery,version,help) and the pre-TUI gate flow.internal/sshconf— read-only config parser: Include following, tag comments, extracts Hostname/User/Port/ProxyJump.internal/store— the~/.ssh/.dial/persistence (atomic, sync-tolerant).internal/picker— the Bubbletea TUI (static list, on-demand filter, sort toggle, favourites, preview pane).internal/connect— the exec-into-ssh handover (per-OS files).internal/yubikey— ykman challenge-response + recovery code logic.internal/theme— high-contrast, low-light-friendly styling (adapts to light/dark terminals).
What the picker does today
- Static numbered list; navigate with arrows or
j/k;1–9jump to a host. enterconnects (exec ssh);q/escquits./opens an on-demand substring filter over alias/hostname/user/tags;escreturns to the static list.stoggles sort between recently used and most used (persisted).ftoggles a favourite; favourites pin to the top regardless of sort.- A preview box shows the highlighted host's Host/HostName/User/Port/ProxyJump.
- Tags from
# tag:comments show inline and are searchable.
Current state
- Complete and building. All five platform binaries cross-compile
(~3.5 MB each). Native build via
make build; all binaries viamake dist. - Tested: unit tests for the parser (includes/tags/edge cases), the store
(round-trip, corrupt-file fallback, favourites), the yubikey logic (enroll/
verify with a simulated key, recovery-code round-trip), and picker rendering/
overflow.
go vetclean. - Performance: parsing a 200-host config is ~0.3 ms; cold start <10 ms — well under the "comfortably under 100 ms" target.
- Live-verified via tmux: navigation, on-demand filtering, favourite + sort persistence to disk.
Known limitations / not yet exercised
- The YubiKey gate has never touched real hardware —
ykmanisn't installed on the dev box, so that path is only covered by unit tests with a simulated HMAC key. Needs a real touch-test on a machine with a key. The exactykmansubcommand (ykman otp calculate SLOT CHALLENGE) assumes ykman 5.x; older versions used different command names. - No signing/notarization. The macOS/Windows binaries are unsigned, so Gatekeeper/SmartScreen will warn unless the user clears quarantine or builds locally.
- Not in version control yet (no git repo initialised).
- No release automation (no CI, no GitHub releases, no Homebrew tap/scoop).
- Match blocks in ssh_config are recognised but their conditions aren't
evaluated (dial just doesn't attach stray keywords to a host). Wildcard
Host *patterns are excluded from the connectable list.
Explicitly out of scope for v1
- Editing/managing
~/.ssh/configfrom within the tool (strictly read-only). known_hostsmanagement.- Custom ssh-agent handling (relies on the OS ssh client).
Open questions & directions to brainstorm
From the brief's "future/v2" plus things that came up:
- SFTP / file transfer mode. After selecting a host, an alternate action to
send/receive files. Simplest: shell out to system
sftp/scp(same exec pattern). Fuller: an in-app file browser (remote listing, progress) built on a Go SFTP library, following the same readable low-light list UI. The picker is already decoupled to allow this. - How to pick the action (shell vs SFTP) once a host is selected — a second keypress on the highlighted host? a mode toggle? a menu?
- Distribution/trust. Worth signing/notarizing? A Homebrew tap + Scoop
manifest + an
install.sh? GitHub Releases via CI? - Gate ergonomics. Every-launch touch is secure but can nag. Is an opt-in idle-timeout worth adding later? Should the gate also cover the future SFTP action?
- Recovery-code UX. Currently terminal-only, shown once, single-use with auto-rotation. Is that the right balance vs. something like multiple codes, or a printable sheet?
- Richer metadata. Tags exist; would grouping/sectioning by tag, or a tag filter shortcut, be useful? Colour-coding by environment (prod/staging)?
- Search behaviour. Filter is currently plain substring (predictable, not fuzzy) to honour the "readable, not cramped" ethos. Is fuzzy matching wanted as an option?
- Multi-select / batch actions (e.g. run a command across several hosts) — in scope for a tool like this, or scope creep?
- Connection history/analytics beyond recents/frequency — last-connected timestamps shown in the list? per-host notes?
How to run/install (for reference)
- Build:
make build(native) ormake dist(all platforms). - Install a binary onto PATH, e.g. Linux user-level:
install -D -m 0755 dial-linux-amd64 ~/.local/bin/dial. - Use:
dial(needs a~/.ssh/config; there's anexamples/ssh_config.sample). - Gate:
dial yubikey enable(needsykman).