sshush

sshush — Architecture

Interactive CLI/TUI to switch SSH keys, inspect the agent, view hosts, and edit SSH config.

Status: through v0.7.0 (milestones 0–30, 39, 40, 42, 43) — full read/merge pipeline, agent switch (load/unload/unload-all), host directive + key/host CRUD with backup+confirm, wildcard hosts, read-only Match blocks, restore-from-backup, key↔host association, hot reload, app config with multiple default identities + auto-load, configurable SSH dir/config path, multi-algorithm key generation, scrollable panes, live search/filter, connect-to-host, permission audit+fix, known_hosts management, clipboard copy, smart shell-init, help overlay, opt-in motion system, 16 color themes (fg+bg, in-app switcher), launch update-check, lipgloss styling, e2e suite + ubuntu/macOS CI, packaging (completions, man page, install script, brew/AUR config), and a versioned self-update/release pipeline. Adaptive two-column layout (M41) was tried and reverted — full-height single pane reads better and avoids row truncation. Match-block editing is deferred (surfaced read-only for now); brew/AUR/install-script publishing awaits public releases + tap/AUR secrets. Next window: v0.9.0 (v1.0 stabilization) → v1.0.0. Tests cover every pkg; see README.md for usage.

Decisions (locked)

Topic Choice Reason
Config write Round-trip via github.com/kevinburke/ssh_config AST Preserve user comments, ordering, unknown options. Mutate nodes, not regenerate.
Agent read golang.org/x/crypto/ssh/agent over $SSH_AUTH_SOCK List + fingerprint loaded keys, no external dep.
Agent write Shell to ssh-add / ssh-add -d Terminal handles encrypted-key passphrase prompt natively.
Config scope ~/.ssh/config + Include‘d files Covers real setups. Writes only to user files.
Key gen Shell to ssh-keygen Don’t reimplement key generation.
App settings TOML at ~/.config/sshush/config.toml (BurntSushi/toml) sshush’s own prefs (default identity), separate from ~/.ssh.
Hot reload fsnotify watching config dirs + ~/.ssh, debounced Pick up external edits without a manual refresh.
Releases goreleaser + GitHub Actions on v* tags Cross-platform binaries + checksums, reproducible.
Self-update creativeprojects/go-selfupdate (GitHub releases) sshush update: detect, checksum-verify, replace in place.

Layout

cmd/sshush/main.go     entrypoint + subcommands (TUI, load-default, shell-init, update, version)
pkg/config/            domain model (Identity, Host, SshConfigModel)
pkg/sshconfig/         parse + round-trip write (kevinburke wrapper)
pkg/keys/              scan ~/.ssh for keypairs; generate/delete keys
pkg/agent/             agent client: List (Go proto), Add/Remove/RemoveAll (exec ssh-add)
pkg/service/           orchestrator: builds unified model, mediates mutations
pkg/appconfig/         sshush settings (default identity, SSH dir/config overrides) at ~/.config/sshush
pkg/watch/             fsnotify wrapper, debounced change signals (hot reload)
internal/tui/          BubbleTea views/update — thin, no IO of its own

TUI never touches files/agent directly. All IO behind pkg/service interfaces → testable headless, mockable, hot-reload-ready. (For interactive subprocesses — ssh-add on an encrypted key, ssh-keygen — the TUI uses tea.ExecProcess with command builders from pkg/agent/pkg/keys, the one sanctioned exception to “no direct IO”.)

Interfaces (contracts)

// pkg/sshconfig — round-tripping config read/write
type ConfigRepo interface {
    Load() (*config.SshConfigModel, error)   // parse user config + Includes
    SetHostField(h config.HostID, key, val string) error
    DeleteHostField(h config.HostID, key string) error
    AddHostIdentity(h config.HostID, path string) error          // IdentityFile +=
    RemoveHostIdentity(h config.HostID, id config.IdentityID) error
    AddHost(config.Host) error
    DeleteHost(config.HostID) error
    Save() error                              // backup <path>.bak, then write AST
}

// pkg/keys — disk scan + key file management
type KeyScanner interface {
    Scan() ([]config.Identity, error)         // walk ~/.ssh, pair pub/priv
    Generate(GenerateOpts) (config.Identity, error)  // ssh-keygen
    Delete(privPath string) error             // remove priv + .pub
}

// pkg/agent — talk to ssh-agent
type AgentClient interface {
    List() ([]AgentKey, error)                // loaded keys + fingerprints
    Add(path string) error                    // exec ssh-add <path> (inherits tty)
    Remove(path string) error                 // exec ssh-add -d <path>
    RemoveAll() error                         // exec ssh-add -D
}

// pkg/service — the only thing TUI sees
type Service interface {
    Refresh() (*config.SshConfigModel, error) // scan+parse+agent, merged
    AddKeyToAgent(config.IdentityID) error
    RemoveKeyFromAgent(config.IdentityID) error
    UnloadAllKeys() error
    EditHost(h config.HostID, field, val string) error      // edit + Save + Refresh
    DeleteHostField(h config.HostID, field string) error
    AttachKey(h config.HostID, id config.IdentityID) error  // write IdentityFile
    DetachKey(h config.HostID, id config.IdentityID) error
    AddHost(config.Host) error
    DeleteHost(config.HostID) error
    GenerateKey(keys.GenerateOpts) (config.Identity, error)
    DeleteKey(config.IdentityID) error        // unloads from agent first
}

The TUI also depends on two optional narrow interfaces it can run without: appSettings (default identity; backed by pkg/appconfig) and fileWatcher (hot reload; backed by pkg/watch).

Data flow

Service.Refresh():
  keys.Scan()        -> []Identity (ExistsOnDisk)
  sshconfig.Load()   -> Hosts + Identity refs
  agent.List()       -> []AgentKey
  merge by fingerprint: set Identity.LoadedInAgent / AgentFingerprint
  -> *SshConfigModel  (single source of truth, immutable snapshot to TUI)

TUI holds a snapshot. Mutations dispatched as tea.Cmd (async goroutine) → call Service → return result tea.Msg → TUI re-renders. Service re-Refreshes after any mutation so state stays consistent.

Safety invariants

Fragile spots (pinned + test-guarded)

Two places use reflection to reach unexported fields of kevinburke/ssh_config, pinned to v1.6.0 and guarded by tests that fail loudly on a library bump:

Milestones

# Deliverable Risk
0 Libs added, package scaffold, interfaces stubbed, tests compile none
1 pkg/keys scan + unit test w/ fixtures none (read)
2 pkg/sshconfig parse + Includes + round-trip test none (read)
3 pkg/agent List + fingerprint match none (read)
4 pkg/service Refresh merges all three none (read)
5 TUI: read-only Keys + Hosts panes none — first useful build
6 Agent add/remove (switch keys) + unload-all; auto-expiring status low (reversible)
7 Edit/add/delete host directives, Save w/ backup+confirm write — backup gated
8 Add (wizard: basic + custom options) / delete host, keygen, key delete write/destructive — confirm gated
9 Wildcard host (Host *) add/edit/delete + per-host key association write — surfaces wildcard blocks
10 Hot reload (fsnotify), reconcile medium
11 App config (~/.config/sshush), default identity low
12 load-default subcommand + shell startup snippet generator (print only) low
13 lipgloss styling pass: bordered panes, grouped help, key↔host links none
14 README + installation instructions none (docs)
15 Self-updating binary via GitHub releases medium
🏷 v0.1.0 — first tagged release: full TUI feature set + self-update
16 Configurable SSH dir / config path (override ~/.ssh defaults) low
17 Key generation with selectable algorithm (ed25519/rsa/ecdsa) + bits low
🏷 v0.2.0 — configurable paths + multi-algorithm keygen

Road to v1.0.0

Everything below gates v1.0.0: usability at scale, correctness on real-world configs, safety nets, and distribution. Releases are tagged as features land so users get value before 1.0; the API/config surface only freezes at the RC.

# Deliverable Risk
18 Scrollable / paginated panes (viewport) for long key & host lists low
19 Search / filter within Keys and Hosts panes low
20 Connect to host — Enter on a host launches ssh <alias> low
🏷 v0.3.0 — scrolling, search, connect-to-host
21 Permissions audit + fix (~/.ssh, keys, config, authorized_keys modes) low
22 known_hosts management (view/search, remove stale/changed keys) low
23 Copy to clipboard (public key / fingerprint / ready ssh command) low
🏷 v0.4.0 — perms fix, known_hosts, clipboard, multi-default, smart shell-init
24 Help overlay (?) + NO_COLOR, narrow-terminal, non-TTY handling low
25 Styling / polish pass + opt-in motion system low
26 Themes — palette config, in-app switcher, open-source presets, randomize/reset low
🏷 v0.5.0 — help overlay + polish/motion + theming (M41 adaptive layout reverted)
27 Match block + broader directive support (read/display, edit-safe) — read-only display done; editing deferred medium
28 Restore-from-backup (undo last write) command/action — done (R + sshush restore) low
🏷 v0.6.0 — Match blocks read-only + restore-from-backup (Match editing deferred)
29 Integration tests (real agent/keygen e2e) + CI matrix (linux/macOS) — done (-tags e2e, ubuntu+macOS matrix) low
30 Packaging: Homebrew tap, AUR, shell completions, man page — config + assets done; tap/AUR publishing needs secrets low
🏷 v0.7.0 — e2e/CI matrix + packaging (completions/man/brew/AUR) + install script + update-check
31 v1.0 stabilization: error-handling audit, config schema freeze, docs/screenshots, CHANGELOG — done (RC ready; demo GIF needs vhs docs/demo.tape) low
🏷 v0.9.0 — release candidate (feature-complete, schema frozen)
soak period: bug-fix-only patch releases (v0.9.x) from real-world use
🏷 v1.0.0 — stable release (tag + announce)

Beyond v1.0 — planned features

Daily-friction SSH features that ship after 1.0; each lands as a clear v1.x bump. The “why it hurts” column is the user pain sshush removes.

# Feature Why it hurts today Target
32 Install key on remote (ssh-copy-id) manual append to a remote authorized_keys v1.1.0
33 Agent add with lifetime / confirm (ssh-add -t/-c) keys live forever in the agent; no per-use confirm v1.1.0
34 Change / add key passphrase (ssh-keygen -p) obscure syntax; unclear which keys are encrypted v1.2.0
35 Key hygiene warnings (weak / old / orphan) weak (RSA<2048, DSA), aging, and orphaned keys pile up unnoticed v1.2.0
36 Fingerprint + randomart view (ssh-keygen -lv) verifying a server key by eye is fiddly v1.3.0
37 Connection / auth test (up/down/auth badge) “will it even connect?” needs a manual attempt v1.3.0
38 Backup & restore (export/import keys + config) moving to a new machine is manual and risky v1.4.0

Connectivity actions (20, 32, 37) reuse the tea.ExecProcess terminal-handover pattern already used for ssh-add/ssh-keygen.

Additional planned work

Newer asks, with the release window they slot into. Numbered 39+; they fold into the existing windows above (not a separate phase).

# Feature Why / note Target
39 Multiple default identities auto-load several keys on startup, not just one — teams juggle work+personal+deploy keys. default_identities = [...] in config.toml; s toggles membership; load-default loads all v0.4.0
40 Smart shell-init detect whether the snippet is already in ~/.bashrc/~/.zshrc: nudge to add it when a default is set (only if absent), and warn on shell-init if a stub is already present (avoid duplicates) v0.4.0
41 Adaptive layout wide → two-column (Keys + Hosts side by side); tall → fill vertical space Reverted. Two-column was built then removed: side-by-side panes truncated host tags + default status, and felt cramped. Kept the tall half only — full-height single pane that grows to fit, no pagination v0.5.0 (reverted)
42 Auto update-check on launch async, non-blocking: check latest release in a tea.Cmd, surface “update available → run sshush update” as a transient status. Off for dev builds; respects a check_updates = false setting — done v0.7.0
43 curl \| bash install script host an install.sh (detect OS/arch → download the right release asset + checksum verify → drop sshush on PATH); one-line install in the README — done; needs public releases (repo is private, so assets are auth-gated) v0.7.0

Beyond v1.0 — fun & flair

# Feature Note Target
44 Loud / “make some noise” mode arcade-style SFX (Pokémon/Mario energy) on load/switch/error/connect. Independent on/off toggle, but only active when motion ≠ off; intensity rides alongside motion. Audio via a small embedded-asset player (e.g. beep/oto); fully optional, silent by default v1.5.0
45 Stripped build + sshush update --stripped a build tag that excludes the sound assets (smaller binary, Loud mode disabled); update --stripped fetches that variant. goreleaser produces both v1.5.0
46 Slim the binary: replace the self-update dependency measured: go-selfupdate adds ~3.7 MB (9.0 → 5.3 MB, ~41%) by pulling in three forge SDKs (go-github/v74 + GitLab client + Gitea SDK). sshush only ships to GitHub — swap to minio/selfupdate + a single stdlib GET api.github.com/.../releases/latest. Keeps sshush update; zips drop ~3.4 → ~2 MB. (-s -w already applied; UPX cut another ~60% but breaks macOS Gatekeeper, so linux/windows only) TBD

Bigger bets (separate track)

Not gating any version; standalone projects that amplify the tool.

More problems sshush could solve (backlog, unscheduled)

Candidate ideas beyond the numbered roadmap — pulled in as they prove valuable:

Milestone 9 detail

Two related gaps in the host model:

Milestone 14 detail

README.md covering: what sshush is, a screenshot/asciinema, feature list, install options (go install, prebuilt binary, build from source), the shell-init snippet for shell-startup loading, keybindings, and the ~/.config/sshush/config.toml settings. Keep ARCHITECTURE.md as the design doc; README is the user-facing entry.

Milestone 15 detail

Ship versioned releases and let the binary update itself:

Milestone 16 detail

Let users point sshush at a non-default SSH directory / config file. The constructors already accept overrides (keys.New(dir), sshconfig.New(path), agent.New(sock)); today cmd/sshush always passes "" (defaults), so nothing is user-settable except the agent socket via $SSH_AUTH_SOCK.

Milestone 17 detail

The new-key flow currently hardcodes ed25519. The backend already supports more: keys.GenerateOpts carries Algorithm and Bits, and keygenArgs emits -t <algo> (+ -b <bits> for non-ed25519). Only the TUI needs to expose it.

Milestone 18 detail

Lists currently render every row; long key/host sets overflow the pane box. Add a viewport per pane (bubbles/viewport or manual windowing): track a scroll offset, keep the cursor visible, page with pgup/pgdn, show a scroll indicator. m.height already plumbs through.

Milestone 19 detail

A /-activated filter that narrows the active pane to matching rows (substring/fuzzy on name, host alias, hostname, comment). Filtering is a view concern over the existing sorted slices; esc clears. Pairs with M18 scrolling.

Milestone 20 detail

sshush manages SSH but can’t yet do SSH. Make Enter on a host launch ssh <alias> via tea.ExecProcess — the TUI yields the terminal, the session runs, and sshush resumes when it exits. Same terminal-handover pattern as ssh-add/ssh-keygen. Use the host’s alias so the user’s own config (incl. ProxyJump, etc.) applies. Optional: a key to copy the equivalent command.

Milestone 21 detail

SSH silently refuses keys and config files with loose permissions, producing cryptic auth failures. Audit modes on ~/.ssh (700), private keys (600), config and authorized_keys; surface offenders with a warning badge and offer a one-key chmod fix (confirm-gated). Read-only detection is safe; the fix is the only write and is reversible in spirit (tightening perms).

Milestone 22 detail

The REMOTE HOST IDENTIFICATION HAS CHANGED wall blocks logins and editing known_hosts by hand is error-prone. Parse ~/.ssh/known_hosts (+ known_hosts2, hashed entries), list/search entries, show the associated host, and remove a stale/changed entry via ssh-keygen -R <host> (confirm-gated). Read-first, then the targeted removal.

Milestone 23 detail

Pasting the right public key into GitHub/servers means hunting for the .pub. Add clipboard actions (via an OSC 52 escape or a clipboard lib): copy a key’s public key, its SHA256 fingerprint, or a ready-to-run ssh <user>@<host> -p <port> -i <path> command for the selected host. Degrade gracefully where no clipboard is available (print to status / stdout).

Milestone 24 detail

Milestone 25 detail

A second styling pass once the surface is feature-complete (scrolling, search, connect, help all in): tighten spacing/padding and column alignment, refine color relationships and contrast, polish overlay/card framing, and unify glyph/iconography. Pure presentation — no behavior change. Lands before theming (M26) so the default palette it tunes becomes the baseline themes override.

Plus an opt-in motion system — playful, arcade-style juice:

Risk: presentation only, but guard performance (cap active effects, stop the ticker when idle) and respect the off switch unconditionally.

Status — core done; flavor deferred. Shipped: [motion] config + in-app m toggle + intensity (subtle/normal/arcade), 16ms frame ticker scheduled only while an effect plays (no idle CPU), palette-matched full-width flash bar on success/error, and a punchy screen-shake (ease-out² decay + ~38Hz osc; amp 2/4/7) on errors / destructive deletes / all arcade actions / the toggle demo. Also the textStyle pass so panes don’t inherit the terminal foreground.

Deferred (continuous effects — conflict with the no-idle-CPU rule, so revisit when ready, possibly arcade-only): the breathing shimmer on the hovered row and loaded keys, and animated pane/status transitions. Routing overlay body text through explicit theme colors (tracked in M26) shipped in v0.9.2, backed by a render test that fails on any unstyled overlay text.

Milestone 26 detail

Let users theme the UI. The styling already centralizes colors in named palette constants (colPrimary, colAccent, …) — lift them into a Theme struct built from those defaults, and let config.toml override any entry (hex or 256-color index) under a [theme] table. Honors M24’s NO_COLOR (theme ignored when color is off; unknown/partial themes fall back per-field to defaults).

Status — core done. Shipped: Theme struct (fg + full-screen Bg), 16 built-in presets (default, mono, high-contrast, dracula, nord, gruvbox-dark/light, solarized-dark/light, catppuccin-mocha/macchiato/frappe/latte, tokyonight/-storm/-day), in-app picker (t) with live preview + windowing for short terminals, randomize (r) / reset, and persistence to config.toml (theme = "..."). Full-screen background fill via applyBackground — re-asserts the bg escape after every inner reset and fills to terminal width+height, so the whole screen takes the theme color (not just where text sits).

Deferred: per-field hex overrides under a [theme] table (presets only for now). Routing overlay body text through textStyle (the terminal-fg bleed above — shared with M25) shipped in v0.9.2, along with a WCAG contrast gate over every preset palette.

Milestone 27 detail

Real configs use Match blocks and directives sshush doesn’t model. Today Match blocks round-trip on write (untouched) but aren’t surfaced. Surface them read-only first (display, flag non-editable), then allow edits where the AST round-trips. Audit common directives (ProxyJump, IdentitiesOnly, AddKeysToAgent) — they flow through Options, but verify display.

Status — read-only done. Match blocks now surface in the Hosts pane keyed and labeled by their criteria (Match Host … / Match all) with a read-only tag, parsed via the library’s unexported isMatch/matchKeyword (pinned to ssh_config v1.6.0, reflection-guarded like isImplicitHost). Their directives (incl. ProxyJump etc., which flow through Options) are parsed for display. Every mutator — SetHostField, DeleteHostField, AddHostIdentity, RemoveHostIdentity, DeleteHost — refuses a Match block with ErrMatchReadOnly, and the TUI blocks e/d/i/enter on one. Directive display audited: ProxyJump/IdentitiesOnly/AddKeysToAgent round-trip and show in the edit overlay.

Deferred: editing Match blocks (the “edit where the AST round-trips” half); surfacing key directives (e.g. ProxyJump) in the host row, not just the editor. Note: the parser only accepts Match all / Match Host …; other criteria (Match user, Match exec, …) fail to parse upstream — a library limit, not handled here.

Milestone 28 detail

Every write backs up to <path>.bak before the first change of a session (M7). Expose an undo: restore <path> from <path>.bak (confirm-gated), so a bad edit is one keystroke to revert. Surface in the TUI and as a sshush restore subcommand.

Status — done. FileRepo.BackupPaths()/Restore() revert every loaded file (main + Includes) that has a sibling .bak to that pre-edit snapshot; the service exposes CanRestore/BackupPaths/RestoreBackup (restore + refresh). In the TUI, R opens a confirm gate listing the files to revert (or reports “no backup” when none); sshush restore does the same non-interactively. The .bak is the snapshot from before sshush’s first edit of the session, so restore reverts the whole session’s writes — coarser than per-edit undo, matching the backup model. Granularity note: a stack of timestamped backups for true step-by-step undo is possible later but unscoped.

Milestone 29 detail

Unit tests are thorough; add e2e coverage behind a build tag: spin a real ssh-agent, generate a throwaway key, exercise load/unload/delete and a config edit against a temp ~/.ssh. CI matrix (ubuntu + macOS) runs the full suite incl. e2e, plus the existing gofmt/go vet gates.

Status — done. pkg/service/e2e_test.go (//go:build e2e) starts a private ssh-agent, generates a passphrase-less ed25519 key, and walks the real wiring (disk scanner + config repo + agent client): generate → load → assert LoadedInAgent (fingerprint match) → unload → edit a config host (asserting the write + .bak) → delete (asserting files gone). Run locally with go test -tags e2e ./pkg/service/. CI is now a {ubuntu, macOS} matrix running gofmt/vet/go test ./... then go test -tags e2e ./....

Milestone 30 detail

Distribution: a Homebrew tap (goreleaser publishes the formula), an AUR PKGBUILD, shell completions (bash/zsh/fish from the subcommand set), and a man page. Mostly goreleaser config + a tap repo.

Status — config + assets done; publishing needs infra. Shipped: static completions under cmd/sshush/completions/ (embedded, printed by sshush completion <bash|zsh|fish>, packaged in release archives), a roff man page man/sshush.1, and goreleaser brews:/aurs: blocks plus archive files: that bundle completions + man + LICENSE. The Homebrew formula installs the binary, all three completions, and the man page; the AUR sshush-bin package does the same under standard prefixes.

Remaining (user infra, can’t be done from the repo): create the tap repo s-johri/homebrew-tap and add a HOMEBREW_TAP_TOKEN secret (the default GITHUB_TOKEN can’t push to a second repo); register the AUR sshush-bin package and add the AUR_KEY secret. Both are wired into release.yml and no-op until the secrets exist. Note: goreleaser deprecates brews in favor of homebrew_casks; the formula path still works under ~> v2 and is conventional for a CLI — migrate if a future v2 drops it.

Milestone 31 detail

Pre-1.0 hardening: audit every error path for graceful degradation (no panics on malformed config, missing agent, unreadable keys, permission errors); freeze the config.toml schema (document it, forward-compatible parsing); add screenshots/asciinema to the README; start a CHANGELOG.md. The RC (v0.9.0) ships after this; v1.0.0 follows a soak period of bug-fix-only patches.

Status — done (four passes): (1) CHANGELOG.md backfilled v0.1.0→v0.7.0 (Keep a Changelog + compare links); (2) config.toml schema frozen and documented, with Warnings() surfacing unknown keys to stderr (forward-compatible — unknown keys ignored, never fatal); (3) error-path audit — scanner degrades per-key, indexing/assertions all guarded, no panic/os.Exit in pkg/internal, and the one silent swallow (a malformed config.toml in runTUI) now warns and falls back to defaults; (4) docs polish — a vhs demo tape (docs/demo.tape) plus README demo/changelog sections. Ready to tag the v0.9.0 RC. (The demo GIF itself is generated by running vhs docs/demo.tape.)

Milestones 32–38 (post-1.0)

See the “Beyond v1.0” table above. Brief notes:

Tests ride alongside each pkg milestone, not deferred. Parse/write corruption = worst-case bug; round-trip test guards it.