The complete, tested surface of ssh-manager. Every row is exercised by the test suite:
U = unit tests (go test ./...), E = end-to-end smoke (make e2e), F =
per-command feature check (make feature-check, which exercises every command in
a sandbox). Run all three with make test e2e feature-check.
The mental model: the manifest in ~/.config/ssh-manager/manifest.json is the single source
of truth; ~/.ssh is generated, reproducible output. Profiles model identity
(work · personal · simtabi · ...), not technology, and everything for an identity
lives isolated under ~/.ssh/profiles/<profile>/.
| Command | What it does | Tests |
|---|---|---|
init |
Create/converge the per-user home (OS-standard, e.g. ~/.config/ssh-manager): every run (re)creates the dir structure (log/, snapshots/, .state/) + re-asserts perms (dirs 700, secrets 600) and seeds any missing starter files (manifest, inventory, .env) - never clobbering existing ones. providers.json is not seeded (the shipped catalog is used unless you create your own). |
U·E·F |
init --force |
Overwrite the seed files with fresh defaults, in place (no backup). | U·F |
init --force --backup |
Same, but first copy the old files into ~/.config/ssh-manager/.state/init-backup-<ts>/. |
U·F |
migrate [--force] |
Move a legacy ~/.sshmgr to the resolved home ($SSH_MANAGER_HOME override-aware; auto-migration handles the simple case on any command; this resolves the stranded both-exist case doctor warns about - --force backs up the current home and replaces it). |
U |
doctor |
Verify the environment: deps, perms, agent, known_hosts, ≤1-old-key invariant, config drift, orphan/duplicate keys, alias collisions. Prints the resolved home, ~/.ssh, and the active provider-catalog source; warns about a stranded legacy home. |
U·E·F |
doctor --fix |
Re-assert canonical perms on every tool-managed path (~/.ssh tree + home secrets). |
U·F |
doctor --json |
Machine-readable report for scripting/monitoring. | U |
version / --version |
Print the version. | F |
Home resolution: $SSH_MANAGER_HOME (alias $SSH_MANAGER_CONFIG_DIR) if set, else the
OS-standard config dir + ssh-manager folder (~/.config/ssh-manager on Linux/macOS,
%APPDATA%\ssh-manager on Windows). A legacy ~/.sshmgr is auto-migrated. No
project-local mode. See installation.md for the full layout.
| Command | What it does | Tests |
|---|---|---|
reconcile [--no-pin] |
Make ~/.ssh match the manifest: build the profile tree, mint missing keys, render config, set perms, and auto-pin each profile's known_hosts for reachable hosts (--no-pin or SSH_MANAGER_AUTO_PIN=0 to skip). Snapshots ~/.ssh first. |
U·E·F |
reconcile --dry-run |
Preview the plan; write nothing. | E·F |
keygen <profile|host> [--no-pin] |
Targeted key generation; warns + skips keys that already exist; auto-pins the affected profiles' known_hosts for reachable hosts. |
U·E·F |
keygen ... --force [--yes] |
Overwrite existing keys (prompted; ~/.ssh snapshotted first). |
U·E·F |
keygen ... --passphrase |
Protect new keys with a passphrase (off by default, so omitting the flag is the "no" answer; prompts without echo on a TTY). | U |
config check |
Verify the on-disk config byte-for-byte matches a fresh render (exit ≠0 on drift). | U·E·F |
config render [--dry-run] |
Re-render config files from the manifest (one renderer, shared with check/reconcile). |
U·E·F |
config show [alias] |
Print resolved config, or ssh -G for one alias. |
U·F |
import [path] [--force] |
Onboard an existing ~/.ssh (adopt keys + hosts) into the manifest + inventory. Refuses to replace a non-empty manifest without --force (which backs it up first). |
U·F |
diff |
Preview manifest vs. on-disk reality (config + which keys are missing/present). | U·F |
| Command | What it does | Tests |
|---|---|---|
key list [selector] |
Every key with its fingerprint, expiry, the hosts using it, and its state — including keys nothing references. | U |
key add <profile> <name> [--type] [--rotate-after-days] [--host] [--passphrase] |
Declare a key on a profile and mint it. Without --host the key is minted unwired: it exists and nothing uses it, which doctor then reports. |
U |
key delete <profile/key> [--purge] [--revoke] [--yes] [--no-key-backup] |
Remove the declaration and the inventory record. Refuses while any host still resolves to the key — a Host block pointing at a missing IdentityFile fails as a bare "Permission denied (publickey)" with nothing naming the cause. --purge also deletes the files. |
U |
clean [--dry-run] [--adopt] |
Prune stale known_hosts pins, stale inventory records and write residue. Only removes pins tagged sshmgr; --adopt opts an untagged pin that matches a manifest host into management, since an untagged pin is presumed to be yours. |
U |
| Command | What it does | Tests |
|---|---|---|
show <profile|alias|profile/key> |
Everything about one thing at once: the manifest entry, the key files and their modes, the rendered config block, and the pins. An auth failure is usually a disagreement between those four, and this is where you see it. | U |
list [--profile/--provider/--type/--tag] |
Filterable tree across profiles. | U·E·F |
view <profile|alias> |
Resolved host config + key + deployment status; shows a VPN reminder for requires_vpn hosts. |
U·F |
validate [key|profile] |
Check each keypair: both parse, public key is derived from the private (ssh-keygen -y), perms correct; encrypted keys are noted not failed. Exit ≠0 on failure. |
U·E·F |
providers [--export [--force]] |
List the active provider catalog (your <home>/providers.json if present, else the shipped default) + whether each credential is set. --export writes an editable copy into the home. |
U·E·F |
net [selector] |
Per-host connection status + a VPN/tunnel indicator. Exit ≠0 if a requires_vpn host is unreachable. |
U·F |
expiry |
Per-key rotation-age table (ok / due_soon / overdue), from each key's stored expires_on. |
U·E·F |
audit [--notify] |
Where each key is deployed + expiry + hygiene + recent activity (optionally fire a desktop alert). | U·E·F |
| Command | What it does | Tests |
|---|---|---|
deploy <key> [target] |
Install the public key on its target via the host's provider (GitHub/GitLab CLI, cloud-VPS REST, generic-ssh ssh-copy-id, or web-panel/manual) and record it. Exits ≠0 if a target is unreachable. |
U·E·F |
rotate <key> [--yes] [--allow-unverified] [--passphrase] |
Zero-downtime staged rotation: stage → deploy → verify → archive (≤1 predecessor). Aborts cleanly (pulling the staged key back) if a target can't be verified or is unreachable. | U·E·F |
rollback <key> [--yes] |
Restore the single /old/ predecessor (re-deploy is best-effort; skips unreachable hosts). |
U·E·F |
load <profile> |
Add the profile's keys to the agent (Keychain on macOS). | U·F |
Network/VPN awareness is woven through every host-touching action. A host can be
marked requires_vpn (+ optional vpn_name, vpn_url). deploy/rotate run a
bounded SSH-level reachability probe first, so a down or VPN-gated host (including a
:443 host that accepts TCP but never speaks SSH) fails fast with
"connect the VPN at <url> and retry" instead of hanging - and every ssh /
ssh-copy-id is hard-timeout-bounded. See tools/network.md.
| Command | What it does | Tests |
|---|---|---|
bundle [-r recipient] [-o dir] |
age-encrypted, off-machine backup of keys + manifest + inventory + providers. Never includes .env. |
U·E·F |
restore <bundle> [-i identity] [--yes] |
Decrypt and lay the same keys back (true recovery on a new machine). | U·E·F |
snapshots list|restore|prune |
Local, reversible ~/.ssh backups (every mutating command snapshots first; last 10 kept). restore takes --yes. |
U·E·F |
recover [key] |
Break-glass: with a key, a tailored shell snippet to paste into a locked-out console; without, the full interactive fixkeys tool (reads /dev/tty). |
U·F |
| Command | What it does | Tests |
|---|---|---|
profile add <name> [--shared] [--key-name] |
Add a profile. | U·F |
profile edit <name> [--key-scope] [--key-name] |
Change a profile in place. | U·F |
profile delete <name> [--yes] [--revoke] [--purge] [--no-key-backup] |
Remove a profile and its hosts; --revoke pulls its keys off their targets. |
U·F |
host add <profile> <alias> [-H] [-u] [-p] [--provider] [--token-env] [--key-name] [--tag] |
Add a host to a profile (alias is positional). | U·F |
host edit <profile> <alias> [-H] [-u] [-p] [--provider] [--token-env] [--key-name] |
Change a host in place. | U·F |
host delete <profile> <alias> [--yes] [--revoke] [--purge] [--no-key-backup] |
Remove a host; --revoke pulls the key off its targets, --purge deletes the key files if nothing else names them. |
U·F |
knownhosts init [PROFILE] [--all] [--force] |
Initialize the trust store (create the file + pin reachable hosts, TOFU, fingerprints reported) for one profile or, with --all, every host in the manifest. There is one hashed store, so a host used by two profiles is pinned once. |
U·F |
knownhosts pin [HOST] [--all] |
Seed per-profile known_hosts via ssh-keyscan, showing each fingerprint and asking first. |
U·F |
notify install|test |
Scheduled desktop expiry reminders (launchd / systemd-user-or-cron / schtasks). | U·F |
tui (or a bare sshmgr on a terminal) |
Interactive arrow-key UI over the whole facade. | U |
Cross-cutting flags: --yes/-y on destructive verbs (with --revoke on deletes);
--passphrase on key generation (a plain flag: leaving it off is the "no" answer).
Config-driven (providers.json) and pluggable: GitHub & GitLab (CLI + token, cloud
and enterprise/self-hosted); Bitbucket, Gitea, Codeberg, Forgejo, Gogs, SourceHut,
Azure DevOps, AWS CodeCommit (web-panel); cloud-VPS account keys for DigitalOcean,
Vultr, Hetzner, Linode, Scaleway (REST); generic-ssh; and any REST key API with
no code via kind: rest. Details in tools/providers.md and
tools/vps.md.
Every verb that changes ~/.ssh confirms first, and --yes/-y answers in
advance. reconcile prints its dry run as the question, so what is approved is
the actual change rather than the name of a command.
The question is only asked when standard input is a terminal. Without one the command proceeds: there is nobody to ask, and refusing would make every existing script a silent no-op.
- One renderer drives
render/check/reconcile;checkcompares byte-for-byte. - Atomic + locked state writes (temp +
os.replaceunder an advisory lock). - Load-bearing perms (dirs 700, private keys + config 600, public + known_hosts 644),
set on create and re-asserted by
doctor/reconcile; secrets are owner-only the moment they appear. - Snapshot before mutate - every mutating command backs up
~/.sshfirst. - The
.envis gitignored, 0600, and excluded from the encrypted bundle.