A small, safe CLI for managing SSH keys and developer profiles (key + git
identity + ~/.ssh/config host blocks). Built for people who juggle work /
personal / client identities and keep mixing up which key and which
git user.email is active.
Switching contexts usually means changing three things at once — the SSH key,
the git user.name/user.email, and the relevant Host entry in
~/.ssh/config. sshprofile treats those as a single switchable unit: a
profile.
The tool never reads, prints, copies, or logs private key contents. It deals in paths and manages config — nothing more.
- Python 3.11+ (uses the stdlib
tomllibto read TOML) ssh-add/ssh-agentandgitonPATH- macOS or Linux — the tool relies on POSIX file permissions (
600/700) and the OpenSSH /gitCLIs. Windows is not supported.
Install as an isolated CLI with pipx:
# From the repository
pipx install --python python3.11 git+https://github.com/hex-wave-ai/ssh-profile-tool
# …or from a local clone
pipx install --python python3.11 .
--pythonis worth specifying explicitly so pipx doesn't pick up an older system interpreter.
For development:
python3.11 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
pytestPowered by Typer:
sshprofile --install-completion # install for your shell
sshprofile --show-completion # print it to inspect/customize| Command | Description |
|---|---|
sshprofile list |
List all known profiles and show which is active |
sshprofile add |
Add a new profile (interactive or via flags) |
sshprofile switch <name> |
Activate a profile: load its key into the agent, set git identity, update config |
sshprofile show <name> |
Show details of one profile |
sshprofile edit <name> |
Edit fields of an existing profile |
sshprofile rename <a> <b> |
Rename a profile |
sshprofile remove <name> |
Remove a profile from the registry (does not delete the key file) |
sshprofile current |
Print the currently active profile |
sshprofile keys |
List private keys found under ~/.ssh (with a safety check) |
sshprofile doctor |
Audit ~/.ssh permissions; --fix tightens them to 600/700 |
# Add a profile interactively
sshprofile add
# Add via flags
sshprofile add work \
--key ~/.ssh/id_ed25519_work \
--git-name "Jane Dev" \
--git-email jane@company.com \
--host github.com-work \
--hostname github.com # real HostName the alias resolves to
# See everything
sshprofile list
# Preview a switch without changing anything
sshprofile switch work --dry-run
# Switch context (key + git identity + config)
sshprofile switch work
# Who am I right now?
sshprofile current
# Find loose-permission keys and fix them
sshprofile doctor --fix- Validates the profile: the key exists, is a regular file with safe permissions, the host alias is a single clean token, and the git identity has both name and email (or neither). Anything that fails aborts before a single change is made.
- Adds this profile's key first (
ssh-add; on macOS with--apple-use-keychain), and only after that succeeds removes the previously active profile's key (unless--no-remove-old) — so a failed switch never leaves you with no key. - Sets git identity if the profile defines one. By default the global
git config user.name/user.email; with--localit sets only the current repo (and refuses if you're not inside one). Applied atomically — if the second field fails to write, the first is rolled back, so you never end up with a half-changed, mixed identity. A profile without an identity leaves git config untouched (switchreportsgit identity: unchanged). - Only if the profile sets a
host: ensures thatHostblock exists in~/.ssh/config(in place — never duplicated), after backing the file up. Refuses if the alias shares aHostline with other aliases. A profile with nohostnever touches~/.ssh/configat all. - Records the active profile in the state file.
Each step is conditional on the matching field, so the only thing switch
always does is load the key into ssh-agent.
--dry-run prints exactly what each of these steps would do — including a diff
of the ~/.ssh/config change — and applies nothing.
- Config file:
~/.config/sshprofile/profiles.toml - State (active profile):
~/.config/sshprofile/state.toml - Both created
600, inside a700directory. XDG_CONFIG_HOMEis honored when set.
Example profiles.toml:
[profiles.work]
key = "~/.ssh/id_ed25519_work"
git_name = "Jane Dev"
git_email = "jane@company.com"
host = "github.com-work"
hostname = "github.com"
[profiles.personal]
key = "~/.ssh/id_ed25519"
git_name = "jane"
git_email = "jane@personal.dev"
host = "github.com"The managed host block switch writes (or updates in place) looks like:
Host github.com-work
HostName github.com
AddKeysToAgent yes
IdentityFile /home/jane/.ssh/id_ed25519_work
IdentitiesOnly yesIdentitiesOnly yes makes ssh offer only this key (not every key in the agent),
and HostName (from --hostname) is what the alias actually connects to. Omit
--hostname only when host is already a real hostname or an existing Host
block you maintain yourself.
Note: clearing
host/hostnamelater (edit … --host "") updates the registry but does not retroactively delete the block already written to~/.ssh/config— remove or adjust it by hand. (Full managed-block reconciliation is on the roadmap.)
host is optional, and most people who already maintain ~/.ssh/config by
hand don't need it. SSH selects a key per host statically — that mapping lives
in ~/.ssh/config and never has to "switch". There are two common multi-key
layouts:
-
Alias per identity (recommended, and likely what you already have): a separate
Hostblock per key —Host github.com-work IdentityFile ~/.ssh/id_work Host github.com-personal IdentityFile ~/.ssh/id_personal
ssh picks the key from whichever alias your git remote uses, so the keys never need switching. Here the only thing that genuinely changes per context is your git author identity — so leave
hostunset and letswitchmanage just the git identity (and the agent):sshprofile add work --key ~/.ssh/id_work --git-name "Jane" --git-email jane@work.dev sshprofile switch work # changes git identity only; ~/.ssh/config untouched
-
One shared
Host, swapped key: a singleHost github.comwhose activeIdentityFileyou flip between identities. This is the one case where lettingswitchrewrite the block (viahost) earns its keep.
In short: set host only if you want sshprofile to own a Host block;
otherwise omit it and switch won't touch ~/.ssh/config.
These are enforced in code and covered by tests:
- Never touch key contents. The tool works with paths only;
ssh-addoutput is parsed for fingerprints, never key bytes. - Validated input. Values destined for
~/.ssh/configare rejected if they contain newlines or control characters, and a host alias must be a single token — so a profile can never inject extra SSH directives. - Safe permissions, no surprises. Files the tool creates end up
600, directories it creates700, and backups are always600even if the source was world-readable. It never silently re-permissions a directory that already exists (e.g. your~/.ssh). It refuses to use a group/world-readable key, anddoctorfinds and fixes such keys. - Backup before any config write.
~/.ssh/configis copied to a timestamped.bak-…and replaced atomically (temp file +os.replace), so a hand-edited config is never silently destroyed. - Idempotent config edits. Re-adding an existing
Hostblock updates it in place instead of duplicating; an unchanged switch writes nothing at all. - Friendly failures. A missing
ssh-addorgitproduces a clear message, not a stack trace.
sshprofile/
├── cli.py # typer app, command definitions
├── profiles.py # load/save profiles.toml, state.toml
├── ssh_config.py # safe parse + write of ~/.ssh/config
├── validate.py # profile-data validation (control chars, alias, identity)
├── agent.py # ssh-add wrappers
├── gitident.py # git config wrappers
└── fsutil.py # perms checks, atomic write, backups
tests/
├── test_fsutil.py
├── test_profiles.py
├── test_ssh_config.py
├── test_validate.py
├── test_subprocess_wrappers.py
└── test_cli.py
- Generating new keys (
ssh-keygen). - Remote/cloud sync of profiles.
- GUI.
These and other planned features are tracked in ROADMAP.md.
MIT — see LICENSE.