SSH to Warpgate targets from your terminal — interactive setup, tab completion, and optional server bootstrap.
wssh dns01- Python 3.10+
- OpenSSH (
ssh,ssh-keygen) - pipx — the installer sets it up if missing
curl -fsSL https://raw.githubusercontent.com/McKinnonIT/wssh/main/install.sh | bash
wssh setupThe installer adds Python, pipx, and OpenSSH where they are missing (Homebrew, apt, or dnf), then installs wssh with pipx. Set WSSH_REPO to install from a fork.
From a clone instead:
git clone https://github.com/McKinnonIT/wssh.git
cd wssh
pipx install .
wssh setupwssh setup asks for your Warpgate host, username, and SSH key, signs you in, and installs shell tab completion. Settings are saved to ~/.wssh/config.yaml (mode 0600).
wssh dns01 # open a shell on the target
wssh dns01 -- systemctl status nginx # run a remote command
wssh dns01 -L 8080:localhost:80 # arguments pass through to sshCopy files with target names in place of hostnames:
wssh scp ./notes.txt dns01:~/ # up
wssh scp -r dns01:/etc/nginx . # down, options pass through to scp
wssh scp docker04:~/file.txt docker02:~/ # target to targetWarpgate picks the target from the SSH username, so one scp can reach exactly one
target — a target-to-target copy runs as two copies staged on your local disk. If the
pull half is incomplete (-r over a tree with unreadable files), wssh pushes what did
copy, says so, and exits non-zero: the destination is left partial, not empty.
For a tree wssh cannot read in full, stream it as root instead — one pass, permissions and ownership intact:
wssh docker02 -- sudo tar -czf - -C /apps guacamole-ap \
| wssh docker04 -- sudo tar -xzf - -C /appsTarget names tab-complete once setup has run. Anything that is not a known subcommand is treated as a target name.
Misspelled names get a suggestion instead of a failed connection:
$ wssh pangoli
No target pangoli in Warpgate.
Did you mean pangolin01? [y/n] (y):
If a connection fails, wssh checks whether the target exists in Warpgate, whether your account can reach it, and offers to fix role access or run setup-server. When stdin is not a terminal, these prompts are skipped so scripted use never blocks.
| Command | Purpose |
|---|---|
wssh <target> [args…] |
Connect to a target; remaining arguments go to ssh |
wssh setup |
First-time setup: host, username, SSH key, sign-in, completion |
wssh setup-server <name> |
Install Warpgate client keys on a server and register it as a target |
wssh scp [opts] SRC… DST |
Copy files using target:path; remaining options go to scp |
wssh auth login |
Sign in and store an API token |
wssh auth logout |
Remove the stored API token |
wssh targets list |
List SSH targets you can access |
wssh targets refresh |
Refresh the local target cache |
wssh credentials add-key |
Upload your SSH public key to Warpgate |
wssh completion bash|zsh |
Print the completion script |
wssh update |
Install the latest version from GitHub, if there is one |
wssh update --check |
Report whether an update is available, without installing |
wssh version |
Show the installed commit, and check for an update |
wssh config-path |
Print the active config file path |
Useful options:
| Option | Applies to | Purpose |
|---|---|---|
--config <path> |
any command | Use a different config file |
--dry-run, -n |
setup, setup-server |
Show what would change without doing it |
--manual-credentials |
setup |
Paste the SSH key in the Warpgate UI instead of uploading via API |
--skip-auth |
setup |
Skip the sign-in step |
--token <token> |
auth login |
Store a token you already have |
--force, -f |
targets list |
Refresh from the API instead of the cache |
--force, -f |
update |
Reinstall even when already up to date |
--check |
update |
Report update status without installing |
--cache-only |
targets list |
Never hit the API (used by completion) |
--key <path>, --label <str> |
credentials add-key |
Choose which public key to upload, and its label |
Targets are cached under ~/.wssh/cache/ for 24 hours so completion stays fast; wssh targets refresh updates it early.
wssh tells you when the repo has moved on, before the command runs — on the way in to
a session, where you can still act on it:
Update available (2db85ba → 5c019d5) — run wssh update
Then wssh update installs it, via pipx when it is on PATH and pip into the current interpreter otherwise. It compares first and does nothing when there is nothing to get:
$ wssh update
Already up to date (26665e7)
Nothing to install — use --force to reinstall anyway.--force reinstalls regardless, for a venv that needs rebuilding. If the remote cannot be reached, or the copy was not installed from git, there is nothing to compare and the install runs — you asked for it, and ls-remote's short timeout can fail on a link a clone survives.
To ask directly:
$ wssh version
e0a0d9d
Up to dateA commit is the version here. wssh is not released or tagged, and the version in pyproject.toml has been 0.1.0 since the first commit — so wssh version prints the commit pip recorded and nothing else. (pipx list still shows 0.1.0, because a wheel needs some version; it means nothing. It is also why wssh update has to force the reinstall — pip cannot tell two builds apart.)
An install that did not come from git prints unknown, since there is no commit to name.
wssh version checks live rather than reading the cache, since you are asking right now. Piped (wssh version | …) it prints the commit alone and makes no network call, so scripts stay fast.
The check compares the commit pip recorded for your install against git ls-remote on the repo. Because two commits cannot be ordered without a local clone, a copy installed from a branch that is ahead of main also reports an update — both short commits are always shown so you can tell.
| Behaviour | |
|---|---|
| Frequency | Network is touched once per 24 hours; the result is cached in ~/.wssh/cache/update.json |
| Output | stderr only, and only when stderr is a terminal — piping stays clean |
| Failure | Offline, no git, or no repo access means no notice, never an error or a delay |
| Not a git install | Silent, since there is no commit to compare |
| Opt out | WSSH_NO_UPDATE_CHECK=1 silences the notice and the wssh version check. wssh update --check ignores it — checking is that command's whole purpose |
Config file: ~/.wssh/config.yaml.
user: alice@example.com # your Warpgate account
host: bastion.example.com # bastion, SSH and HTTPS
port: 2222 # Warpgate SSH port
domain: example.com # optional: appended to usernames without @
server_domain: internal.example.com # optional: DNS suffix for short server names
api_token: "<your-api-token>"
default_ssh_user: root # defaults used by setup-server
default_ssh_port: 22Environment variables override file values:
| Variable | Purpose |
|---|---|
WSSH_CONFIG |
Path to the config file |
WSSH_HOST |
Warpgate bastion hostname |
WSSH_PORT |
Warpgate SSH port (default 2222) |
WSSH_DOMAIN |
Append to usernames without @ |
WSSH_SERVER_DOMAIN |
DNS suffix for wssh setup-server short names |
WSSH_API_TOKEN |
User API token |
WSSH_ADMIN_API_TOKEN |
Admin API token (for setup-server; falls back to WSSH_API_TOKEN) |
WSSH_WARPGATE_CLIENT_KEYS |
Newline-separated client public keys (offline bootstrap) |
WSSH_REPO |
Git repo wssh update and install.sh pull from |
WSSH_NO_UPDATE_CHECK |
Silence the update notice and the wssh version check |
WSSH_TERM |
TERM sent to hosts that lack your terminal's terminfo (Ghostty, Kitty). Default xterm-256color; set empty to send your real TERM |
See config.example.yaml for a commented template.
Teams can ship non-secret defaults (bastion host, email domain, server DNS suffix, default SSH user) without forking wssh:
- Maintain a partial config — see the preset note in
config.example.yaml. Noapi_token,admin_api_token, or per-useruser. - Install
wsshand copy the preset to~/.wssh/config.yaml. - Run
wssh setup— connection prompts are skipped whenhostis already set, so staff only complete username, SSH key, sign-in, and completion.
Distribute the preset via a private repo and bootstrap script, MDM, or internal wiki. Keep secrets and personal tokens out of shared files.
Sign-in. wssh auth login opens the Warpgate login page and waits for you to press Enter. It then tries to create an API token automatically; if it cannot, it asks you to paste one from Profile → API Tokens.
Automatic token creation needs an optional dependency:
pipx inject wssh browser-cookie3Completion not working. Make sure the block wssh setup added to your rc file is after compinit in .zshrc, then start a new shell. Re-running wssh setup replaces the block rather than stacking a second one.
wssh not found after install. pipx installs to ~/.local/bin:
export PATH="$HOME/.local/bin:$PATH" # add to your rc fileKey rejected. Warpgate matches public keys on <algorithm> <base64> with no trailing comment. A key uploaded with a comment never authenticates — wssh credentials add-key strips it, and reports when an existing entry was stored in the wrong format.
Uploading your key by hand:
wssh setup --manual-credentialspython3 -m venv .venv
.venv/bin/pip install -e '.[dev]'
.venv/bin/python -m pytest
.venv/bin/ruff check src testsLint settings live in pyproject.toml. B008 is ignored because typer.Option() in an argument default is how Typer is written.
MIT — see LICENSE.