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>
6.9 KiB
Project Brief: Interactive SSH Host Picker CLI ("dial")
Overview
A fast, interactive command-line tool that reads ~/.ssh/config, lets the user
fuzzy-select a host, and execs the system ssh binary into it. Cross-platform
(macOS Apple Silicon/M4, Linux, Windows). Optional YubiKey gate before the app
will run. App-specific data lives inside the .ssh tree so it rides along on
existing Syncthing sync across machines.
Goals
- Parse
~/.ssh/config, includingIncludedirectives. - Present an interactive, fuzzy-searchable list of
Hostentries. - On selection,
execthe realsshbinary (not a wrapped subprocess) so the terminal/tty,ProxyJump, agent forwarding, etc. all just work. - Cold start fast enough to feel instant (target: comfortably under 100ms).
- Ship as a single static binary per platform — no runtime dependency.
- Optional YubiKey challenge-response gate required before the picker unlocks.
- Store app data (recents, favorites, tags, settings) under
~/.ssh/so it syncs automatically via the existing Syncthing setup — no new sync config needed.
Core Features
-
Config parsing
- Read
~/.ssh/config, followIncludelines. - Surface
Hostname,User,Port,ProxyJumpas preview info. - Optional lightweight tagging via comment convention, e.g.
# tag: prod, riedelabove aHostblock — used for filtering/grouping. - Never write to
~/.ssh/config— strictly read-only.
- Read
-
Interactive picker
- Static, numbered list — not a live fuzzy-filter-as-you-type interface.
Explicitly avoid the cramped/fuzzy-filter style seen in tools like the
rcloneinstaller, which is hard to read in low light. Prioritize legibility: larger clear text, generous spacing between entries, high contrast, no small dense text packed together. - Navigate with arrow keys or number keys, enter to connect, esc to quit.
- Optional
/to jump into a search/filter mode on demand (not the default view) if the host list gets long — but the resting state should always be a clean, static, readable list. - Sort by most-recently-used or most-frequently-used, toggleable.
- Show a small preview area (Hostname/User/Port/Jump host) for the highlighted entry, in equally readable, high-contrast text.
- Static, numbered list — not a live fuzzy-filter-as-you-type interface.
Explicitly avoid the cramped/fuzzy-filter style seen in tools like the
-
Connect
exec(or platform equivalent) intossh <alias>so control of the terminal passes cleanly to the ssh session — this app should not remain a parent process wrapping it.
-
YubiKey gate (optional, toggle in settings)
- HMAC-SHA1 challenge-response (YubiKey slot 2), fully offline — no internet or server dependency.
- Implemented by shelling out to
ykman(Yubico's official CLI) rather than a direct PC/SC library. Trade-off accepted:ykman(and its Python dependency) must be installed separately on every machine this runs on — this breaks the "single static binary, zero deps" goal for the YubiKey feature specifically, but keeps the implementation simple and leans on Yubico's own well-tested tool.dialshould check forykmanonPATHat startup and give a clear error if it's missing. - Require physical touch to confirm.
- Backup 2FA code: generate a one-time recovery code when the gate is first enabled, shown once and expected to be stored by the user somewhere separate (password manager, printed, etc.). This code lets the user in once if the YubiKey isn't available, avoiding total lockout. After use it should be invalidated/regenerated, not reusable indefinitely.
-
Sync-safe app data
- New directory:
~/.ssh/.dial/settings.json— yubikey on/off + slot, theme, sort preference, etc.data.json— recents, favorites, tag cache.
- Multiple machines may write this concurrently via Syncthing — design for that: last-write-wins is acceptable for v1, but reads should tolerate a mid-sync/partial file without crashing (e.g. read failure just falls back to defaults rather than erroring out).
- Nothing secret is ever written here — the YubiKey secret never leaves the key itself in challenge-response mode, so there's no key material to accidentally sync.
- New directory:
Non-Functional Requirements
- No telemetry, no network calls (beyond ssh itself).
- Read-only with respect to
~/.ssh/config. - Graceful degradation if no YubiKey is plugged in and the gate is enabled.
- Works identically on macOS (Apple Silicon), Linux, and Windows — including
Windows' different
ssh.exepath/behavior. - Readable in low light: default view is a static list with clear, well-spaced, high-contrast text — not a dense fuzzy-search UI. This is a hard requirement, not a nice-to-have.
Recommended Tech Stack
Go — chosen over your usual Python/Bash default specifically because this needs true single-binary cross-platform distribution and fast cold start; Python's interpreter startup and packaging story (PyInstaller etc.) are a worse fit here.
- TUI:
charmbracelet/bubbletea+lipgloss(or simpler:charmbracelet/huhfor a lighter list-picker if a full TUI is overkill). - SSH config parsing:
kevinburke/ssh_config. - YubiKey challenge-response: shell out to
ykman(simplest, but adds an external dependency) or use a Go PC/SC library directly for a fully self-contained binary — worth deciding based on how important "zero external deps" is to you. - Cross-compile via
GOOS/GOARCHfor the three targets from one machine.
Alternative: Rust would give similar performance/single-binary benefits with a steeper setup cost — mention if you'd rather Claude Code default to that instead.
Out of Scope (v1)
- Editing/managing
~/.ssh/configfrom within the tool. - Remote host key (known_hosts) management.
- Any custom ssh-agent handling — rely on the OS-native ssh client already installed.
Future / v2 Considerations
Not in scope for v1, but the architecture should not preclude these — worth keeping the "select a host" logic reusable/decoupled from the "connect via ssh" action so these can be added later without a rewrite:
- SFTP file transfer: after selecting a host, an alternate action to
send/receive files instead of opening a shell session.
- Simplest version: shell out to the system
sftp/scpbinary, same pattern as thesshexec flow. - Fuller version: an in-app file browser (source/dest picker, remote
directory listing, progress) built against a Go SFTP library
(e.g.
pkg/sftp) rather than shelling out — should follow the same readable, high-contrast, low-light-friendly list UI as the host picker, not the cramped style of a baresftpprompt.
- Simplest version: shell out to the system
Open Decisions (Claude Code should confirm before building)
- YubiKey gate: required every launch, or only after an idle timeout?
- Exact format/length of the backup recovery code, and where it should be displayed/stored on generation (e.g. printed to terminal only, once).