Copy executable recipes that solve real multi-host SSH agent problems.
- Read this document in Portuguese (pt-BR)
- Product line: 0.5.6
- Every recipe follows the same five parts: Problem, Solution, Explanation, Variants and See Also
- Every output comes from a run on the 0.5.6 binary, with host names and paths replaced by placeholders
duration_ms,latency_msandbatch_run_idvary between runs andCONFIG_DIRstands for your configuration directory- Acronyms: SSH (Secure Shell), CLI (command-line interface), LLM (large language model), XDG (X Desktop Group base directories), TLS (Transport Layer Security), mTLS (mutual TLS), ACME (Automatic Certificate Management Environment), SCP (secure copy), SFTP (SSH File Transfer Protocol) and TOFU (trust on first use)
- Shape output with the built-in
--select,--filterand--count-onlyfirst, the recipes usejaqonly to compute or test a value, and any JSON reader works - Recipes read only canonical fields such as
target_resolvedandtarget_source, the raw output still carries the read aliaseshost_resolvedandhost_sourceand no recipe parses them - Text-mode messages shown here come from
--lang en, the JSON error bodies are English in every locale
- Expect sub-second local CRUD and a cold SSH connect dominated by network RTT
- Five runs of
ssh-cli --versiontook 25 ms in total on the measuring host, so process start is not the cost - Prefer one-shot commands over tunnels when a single remote action is enough
- Port default: 22
- Timeout default: 60000 ms
max_command_charsdefault: 1000max_output_charsdefault: 100000- Tracing default: error, with
-vinfo,-vvdebug and-vvvtrace, crate-scoped, and ambientRUST_LOGignored --lock-wait-msdefault: 5000 ms, ceiling 300000 ms, and a writer that waits longer exits 69- Empty password in list and show JSON:
null(key-only hosts) - Non-empty secrets in list and show mask as three asterisks (
FIXED_MASK) - A redacted export clears them instead:
passwordis""and the optional secrets arenull - Telemetry: disabled
- Secrets at rest: encrypted once a primary-key exists, a first password write creates
secrets.key - Install:
cargo install ssh-cli --locked - Supply chain: russh 0.65.1 and
cargo denywithyanked=denyandmultiple-versions=deny - SCP: regular files only, no
-rand no directories - Directory trees and remote filesystem operations use
sftp(--recursive,ls,mkdir) - SCP download partial suffix:
.ssh-cli.partial - SCP success JSON carries
event: "scp-transfer"and the fieldmtime_preserved - SCP wire: use 0.4.0 or newer and prefer product line 0.5.6, never 0.3.9, whose SCP was inoperant
- SFTP: prefer 0.5.3 or newer (G1 upload integrity) and verify the destination with
sha256sum - The
ssh-cli vps exportbody follows the resolved format: JSON on any non-TTY stdout and TOML only with--output-format text - Host wire: schema v3 with English keys, loads still accept the legacy Portuguese aliases
- Host names: ASCII letters, digits, dot, hyphen and underscore, starting with a letter or digit, 1 to 255 bytes
- Tunnel post-bind deadline exits 0 after
tunnel_listening(TUN-002) and a pre-bind timeout exits 74 - A tunnel ended by SIGINT exits 130 and by SIGTERM exits 143, with
reasonset tosignalintunnel_closed - Tunnel
--binddefault:127.0.0.1 - Secrets flags are CLI and XDG only:
--allow-plaintext-secrets,--secrets-key-fileand--use-keyring - ACME permanent validation such as
invalidContactexits 64, do not retry it as 74 - Only
ssh-cli vps add --timeoutunder 1000 ms warns on stderr, whilessh-cli vps edit,ssh-cli execandssh-cli health-checkaccept it silently - A password on argv warns on stderr, prefer the
--*-stdinflags SSH_CLI_SECRETS_KEYandSSH_CLI_SECRETS_KEY_FILEare refused with exit 64, and the otherSSH_CLI_*variables are ignored- Success events:
vps-added,vps-edited,vps-renamed,vps-removed,vps-connectedandvps-import - A first secret write may set
secrets_key_auto_created: trueon the samevps-addeddocument - The G numbers in the next three lines are the identifiers of the 0.5.3 section in
CHANGELOG.md - Gap notes: G1 SFTP upload integrity, G2 and G14 graduated crate-scoped verbosity, G3 SETSTAT with
atimeandmtimetogether, G4 fail-closedset_metadata - G5 and G17 keep
results.len() == input.len()on cancel, G8 one JSON object perssh-cli exec, G9sync_databefore the rename, G15 checksum as proof - G12 and G19 mask permission bits by direction with
SFTP_PERM_MASK(0o7777) on upload andSFTP_PERM_MASK_UNTRUSTED(0o0777) on download (A3) - G18 surfaces SFTP download
set_permissionsfailures, G6 and G11 concern test isolation only
- The table maps each of the 48 leaf commands to the recipe that runs it
- Look the command up here, then search the recipe title in this file
| Command | Recipe |
|---|---|
ssh-cli vps add |
How To Register a Password Host (stdin, no argv leak) |
ssh-cli vps list |
How To Shrink an Agent Payload Before It Is Written |
ssh-cli vps remove |
How To Rehearse a Destructive Operation and Read the Target Echo |
ssh-cli vps rename |
How To Rename a Host Without Retyping the Credential |
ssh-cli vps edit |
How To Edit a Host Field by Field and Clear It |
ssh-cli vps show |
How To Register a Key-Only Host |
ssh-cli vps path |
How To Diagnose XDG Paths and Secrets Mode |
ssh-cli vps doctor |
How To Diagnose XDG Paths and Secrets Mode |
ssh-cli vps export |
How To Export and Import Inventory Without Secrets |
ssh-cli vps import |
How To Export and Import Inventory Without Secrets |
ssh-cli connect |
How To Designate the Target Host and Prove Where It Ran |
ssh-cli exec |
How To Run a Remote Command With JSON |
ssh-cli sudo-exec |
How To Run Safe sudo With Compound Commands |
ssh-cli su-exec |
How To Elevate With su When sudo Is Unavailable |
ssh-cli scp upload |
How To Transfer a Release Artifact With SCP |
ssh-cli scp download |
How To Transfer a Release Artifact With SCP |
ssh-cli sftp upload |
How To Upload via SFTP and Verify the Checksum |
ssh-cli sftp download |
How To Download a File and a Tree via SFTP |
ssh-cli sftp ls |
How To Manage Remote Paths via SFTP |
ssh-cli sftp mkdir |
How To Manage Remote Paths via SFTP |
ssh-cli sftp rmdir |
How To Manage Remote Paths via SFTP |
ssh-cli sftp rm |
How To Manage Remote Paths via SFTP |
ssh-cli sftp stat |
How To Manage Remote Paths via SFTP |
ssh-cli sftp rename |
How To Manage Remote Paths via SFTP |
ssh-cli tunnel |
How To Open a Bounded Tunnel |
ssh-cli health-check |
How To Probe Connectivity With Health-Check |
ssh-cli secrets status |
How To Initialize Primary-Key Encryption |
ssh-cli secrets init |
How To Initialize Primary-Key Encryption |
ssh-cli secrets reencrypt |
How To Re-encrypt a Legacy Plaintext Inventory |
ssh-cli completions |
How To Generate Shell Completions |
ssh-cli commands |
How To Discover Contracts (schema, doctor, commands) |
ssh-cli schema |
How To Discover Contracts (schema, doctor, commands) |
ssh-cli doctor |
How To Discover Contracts (schema, doctor, commands) |
ssh-cli locale show |
How To Set the UI Locale |
ssh-cli locale set |
How To Set the UI Locale |
ssh-cli locale clear |
How To Set the UI Locale |
ssh-cli tls provider |
How To Inspect TLS Provider, Paths and ACME |
ssh-cli tls paths |
How To Inspect TLS Provider, Paths and ACME |
ssh-cli tls mtls list |
How To Import an mTLS Identity With a Valid Name |
ssh-cli tls mtls import |
How To Import an mTLS Identity With a Valid Name |
ssh-cli tls mtls show |
How To Import an mTLS Identity With a Valid Name |
ssh-cli tls mtls remove |
How To Import an mTLS Identity With a Valid Name |
ssh-cli tls acme account create |
How To Inspect TLS Provider, Paths and ACME |
ssh-cli tls acme account show |
How To Inspect TLS Provider, Paths and ACME |
ssh-cli tls acme issue |
How To Inspect TLS Provider, Paths and ACME |
ssh-cli tls acme complete |
How To Inspect TLS Provider, Paths and ACME |
ssh-cli tls acme status |
How To Inspect TLS Provider, Paths and ACME |
ssh-cli tls acme list |
How To Inspect TLS Provider, Paths and ACME |
- Secrets sit in plaintext on disk until a primary-key exists
ssh-cli secrets status --json
ssh-cli secrets init --json
ssh-cli secrets status --json- Expected output of the three calls, in order, on a fresh configuration directory
{"at_rest":"plaintext","encryption_active":false,"key_file":"CONFIG_DIR/secrets.key","key_source":"none","plaintext_opt_out":false}
{"ok":true,"event":"secrets-init","force":false,"key_file":"CONFIG_DIR/secrets.key","key_source":"xdg_file","reencrypted_hosts":0}
{"at_rest":"encrypted","encryption_active":true,"key_file":"CONFIG_DIR/secrets.key","key_source":"xdg_file","plaintext_opt_out":false}- The key is never printed, the JSON only names the file and the source
- A second
ssh-cli secrets initexits 64 withprofile already has a primary-key in CONFIG_DIR/secrets.key; use --force to rotate it - A first password write creates the key by itself and prints
secrets_key_auto_created: true
- Point at your own key file with
ssh-cli --secrets-key-file /path/to/key secrets status --json, which reportskey_sourceasfile - Use the OS keyring with
ssh-cli --use-keyring secrets init --jsonorssh-cli secrets init --keyring --json, both reach the same store - A keyring store that does not answer makes
ssh-cli secrets init --keyringexit 69 instead of reporting success - The key resolves in this order:
--secrets-key-file, the keyring under--use-keyring,secrets.keyin the config directory, then a key stored by an earlierssh-cli secrets init --keyring - With a key already in the file or in the store,
ssh-cli secrets init --keyringwithout--forceexits 64, and with--forceit retires the old source so the profile keeps one primary-key - Keep plaintext for tests only with
--allow-plaintext-secrets
- Re-encrypt a legacy inventory below and HOW_TO_USE.md
- An agent needs the output shapes and the accepted arguments without reading the source
ssh-cli schema | jaq '.schemas | length'
ssh-cli schema vps-list
ssh-cli commands --json | jaq -r '.tree.subcommands[].name'
ssh-cli doctor --json- The catalog holds 25 schemas and
ssh-cli commandslists the 16 top-level commands
25
vps connect exec sudo-exec su-exec scp sftp tunnel health-check secrets completions commands schema doctor locale tlsssh-cli schemaprints the catalog as the eventschema-catalogandssh-cli schema <name>prints one schema documentssh-cli commands --jsonprints the whole tree withid,long,short,value_name,required,repeatable,takes_valueandhiddenfor every argumentssh-cli doctor --jsonis the root alias ofssh-cli vps doctorand reports paths, secrets mode and the config directory
- Read one argument list with
ssh-cli commands --json | jaq '[.. | objects | select(.name? == "rename")] | .[0].args' - Print the schema of a batch with
ssh-cli schema exec-batch - Read one field of a large document with
ssh-cli commands --json --select tree.about, which prints{"agent_shape":{"selected":["tree.about"]},"tree":{"about":"..."}}instead of the whole tree - Read the exit codes and copyable examples with
ssh-cli --help, and one example per leaf withssh-cli exec --help
- The schema index in docs/schemas/README.md
- A password on argv shows up in process lists and shell history
printf '%s' 'demo-password-not-real' | ssh-cli vps add \
--name prod \
--host prod.example.com \
--user deploy \
--password-stdin --json{"ok":true,"event":"vps-added","key_file":"CONFIG_DIR/secrets.key","key_source":"xdg_file","name":"prod","secrets_key_auto_created":true}- Without stdin the call fails with exit 64 and
vps_auth: must provide exactly one of --password, --key, or --use-agent
--password-stdinkeeps the secret out of argv, a password on argv works but warns on stderr- The first password write created
secrets.key, sosecrets_key_auto_createdistrueonce ssh-cli vps show prod --jsonprints the password as three asterisks, never the plaintext
- Register through the agent with
ssh-cli vps add --name lab --host 203.0.113.10 --user ubuntu --use-agent - Read the password from a file with
ssh-cli vps add ... --password-stdin < pw-file
- Switch the method later with the recipe on authentication below, and HOW_TO_USE.md
- A key-based host should never store a password at all
ssh-cli vps add --name edge --host edge.example.com --user ubuntu --key ~/.ssh/id_ed25519 --json
ssh-cli vps show edge --json{"ok":true,"event":"vps-added","name":"edge","secrets_key_auto_created":false}
{"name":"edge","host":"edge.example.com","port":22,"user":"ubuntu","password":null,"key_path":"/home/you/.ssh/id_ed25519","key_passphrase":null,"sudo_password":null,"su_password":null,"timeout_ms":60000,"max_command_chars":1000,"max_output_chars":100000,"disable_sudo":false,"schema_version":3,"added_at":"2026-10-19T22:16:38.513594758+00:00"}- The key file must exist when you register, a missing path exits 66 with
file not found: private key not found - A key-only host prints
passwordasnull, which tells an agent there is no password to mask - No
secrets.keyis created because no secret is stored
- In text mode
ssh-cli vps list --output-format textprints(not set)in the PASSWORD column for a host without a password and three asterisks for a host that has one - The text columns size themselves to the widest cell, so a long user name never shifts the PASSWORD column
- Probe right after registering with
ssh-cli vps add ... --check - Group hosts with
--tag prod --tag edgeand reach them later with--tags
- The full flag list of
ssh-cli vps addin HOW_TO_USE.md
- You want one parseable answer from a remote command, not terminal prose
ssh-cli exec prod "echo hi" --json{"target_resolved":"prod","target_source":"argv","host_resolved":"prod","host_source":"argv","active_fallback":false,"stdout":"hi\n","stderr":"","exit_code":0,"truncated_stdout":false,"truncated_stderr":false,"duration_ms":112}- A call without
--stepprints exactly one JSON object (G8), never several events exit_codeis the remote exit code, and when the remote command fails the CLI exits 1, keeps this object on stdout and prints acommand_failedenvelope withremote_exit_codeon stderr- Remote stdout and stderr stay raw by design, the text mode escape does not touch them
- Add steps on the same session with
ssh-cli exec prod "echo one" --step "echo two" --json, which prints one object per step withstepandcommand - Annotate remote history with
--description "bootstrap"
- Fleet fan-out below and AGENTS.md
- A command that inherits its host from a marker on disk can land on the wrong server (GAP-SSH-EXEC-ARGC-001)
ssh-cli exec prod "systemctl restart nginx" --json
ssh-cli exec --use-active "uptime" --json- Without a marker,
--use-activeexits 66 withno_active_vps
{"exit_code":66,"error_code":"no_active_vps","message":"no active vps; run 'ssh-cli connect <NAME>' first","error_class":"permanent","retryable":false,"suggestion":"fix CLI arguments, registry state, or schema; do not retry unchanged"}- After
ssh-cli connect prod, the marker is reachable only through--use-activeand the object says so
{"target_resolved":"prod","target_source":"active_marker","host_resolved":"prod","host_source":"active_marker","active_fallback":true,"stdout":"hi\n","stderr":"","exit_code":0,"truncated_stdout":false,"truncated_stderr":false,"duration_ms":110}out=$(ssh-cli exec prod "systemctl restart nginx" --json); ec=$?
echo "$out" | jaq -r '"\(.target_resolved) via \(.target_source)"'
echo "$out" | jaq -e '.target_source == "argv"' >/dev/null || echo "ambient target, aborting"- A lone positional exits 64 because the host name would have become the command
ssh-cli exec prod --step "systemctl restart nginx" --json{"exit_code":64,"error_code":"invalid_argument","message":"invalid argument: expected exactly two positionals `<VPS> <COMMAND>`, got 1; designate the target explicitly: `<VPS> <COMMAND>`, or a selector with one positional (`--all`/`--hosts`/`--tags` `<COMMAND>`), or the active marker deliberately (`--use-active <COMMAND>`)","error_class":"permanent","retryable":false,"suggestion":"fix CLI arguments, registry state, or schema; do not retry unchanged"}target_resolvedandtarget_sourceare the canonical fields andhost_resolvedandhost_sourceare their 0.5.5 read aliasestarget_sourceisargv,active_markerorselector, so one string comparison proves where a mutation landed- The split is stable across the CLI: 64 means no target designated and 66 means the designated target does not exist
- Ask which host an ambient command would hit with
ssh-cli doctor --json | jaq -r '.local.active_vps // "none"' - Pick several hosts explicitly with
--all,--hosts a,bor--tags prod
- The selector rules in AGENTS.md
- A compound command with
&&or quotes must reach the remote shell intact
ssh-cli sudo-exec prod "echo one && echo 'two three'" --description "compound check" --json{"target_resolved":"prod","target_source":"argv","host_resolved":"prod","host_source":"argv","active_fallback":false,"stdout":"one\ntwo three\n","stderr":"","exit_code":0,"truncated_stdout":false,"truncated_stderr":false,"duration_ms":156}- A blocked call prints this refusal with exit 77
ssh-cli --disable-sudo sudo-exec prod id --json{"exit_code":77,"error_code":"sudo_disabled","message":"sudo/su disabled for this host (disable_sudo)","error_class":"permanent","retryable":false,"suggestion":"fix CLI arguments, registry state, or schema; do not retry unchanged","target_resolved":"prod","target_source":"argv","host_resolved":"prod","host_source":"argv","active_fallback":false}- The command is packed with a secure
sh -c, so metacharacters stay inside the remote shell - The sudo password comes from the host record, set it with
--sudo-password-stdin, or with--sudo-password VALUEand the stderr warning about argv - Prefer
ssh-cli sudo-execover a rawsudoinsidessh-cli exec, so the packing and the policy apply
- Install a package the same way with
ssh-cli sudo-exec prod "apt-get update && apt-get install -y curl" --description "bootstrap curl" --json - Run on a fleet with
ssh-cli sudo-exec --tags prod 'systemctl restart nginx' --json - Block elevation per host with
ssh-cli vps edit prod --disable-sudoand lift it with--enable-sudo - Remove the stored sudo password with
ssh-cli vps edit prod --clear-sudo-password --json,updatedis["sudo_password"]
- Disable elevation below and AGENTS.md
- The account has no sudo rule but you know the
supassword
printf '%s' 'demo-password-not-real' | ssh-cli vps edit prod --su-password-stdin --json
ssh-cli su-exec prod "whoami" --json{"ok":true,"event":"vps-edited","host_resolved":"prod","host_source":"argv","name":"prod","target_resolved":"prod","target_source":"argv","updated":["su_password"]}- On success
ssh-cli su-execprints the same object asssh-cli exec, and before the password is stored the call exits 64 with this envelope
{"exit_code":64,"error_code":"su_password_missing","message":"su_password not configured; use vps edit --su-password or --su-password-stdin","error_class":"permanent","retryable":false,"suggestion":"fix CLI arguments, registry state, or schema; do not retry unchanged","target_resolved":"prod","target_source":"argv","host_resolved":"prod","host_source":"argv","active_fallback":false}- The password is sealed at rest like every other secret
updatednamessu_password, so the envelope proves what changed- Remove it later with
--clear-su-password
- Set it inline with
--su-passwordand accept the stderr warning about argv - Run on a subset with
ssh-cli su-exec --hosts a,b 'whoami' --json
- Safe sudo above and AGENTS.md
- An agent may generate a command far longer than you want to run
ssh-cli vps edit prod --max-command-chars 20 --json
ssh-cli exec prod "echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" --json{"ok":true,"event":"vps-edited","host_resolved":"prod","host_source":"argv","name":"prod","target_resolved":"prod","target_source":"argv","updated":["max_command_chars"]}
{"exit_code":64,"error_code":"command_too_long","message":"command exceeds max_command_chars (20): 35 characters","error_class":"permanent","retryable":false,"suggestion":"fix CLI arguments, registry state, or schema; do not retry unchanged","target_resolved":"prod","target_source":"argv","host_resolved":"prod","host_source":"argv","active_fallback":false}- The limit is checked before any SSH work, so a rejected command costs no connection
- The count is the length of the command string, here 35 characters
- Exit 64 is permanent, shorten or split the command instead of retrying
- Restore the default with
ssh-cli vps edit prod --max-command-chars 1000 - Set it at registration with
ssh-cli vps add --max-command-chars 200
- Bound output below and AGENTS.md
- A noisy command such as
dmesgcan flood the context window of an agent
ssh-cli vps edit prod --max-output-chars 5 --json
ssh-cli exec prod "echo 123456789" --json{"ok":true,"event":"vps-edited","host_resolved":"prod","host_source":"argv","name":"prod","target_resolved":"prod","target_source":"argv","updated":["max_output_chars"]}
{"target_resolved":"prod","target_source":"argv","host_resolved":"prod","host_source":"argv","active_fallback":false,"stdout":"12345","stderr":"","exit_code":0,"truncated_stdout":true,"truncated_stderr":false,"duration_ms":98}- The cut happens at the host record limit and
truncated_stdouttells you it happened - A realistic value is 20000, the default is 100000
- Cut the envelope too with the global
--truncate-content 200
- Restore the default with
ssh-cli vps edit prod --max-output-chars 100000 - Cap a whole fleet answer with
ssh-cli exec --all 'dmesg' --json --max-output-bytes 65536 - The legacy alias
--max-charswrites the same field as--max-command-chars, sossh-cli vps edit prod --max-chars 200bounds the command length, not the output - Cut string fields with
--truncate-content, and silence the human line with--quietor--no-colorso only the payload is left - Raise the diagnostic level with
--verbose(-vinfo,-vvdebug,-vvvtrace) when the payload is not enough to explain a bound - Attach a TLS server name to a host with
ssh-cli vps edit web1 --tls-sni web1.internalso the handshake uses the certificate name instead of the dial address
- Shrink an agent payload below and HOW_TO_USE.md
- You need to know that a host answers before you rely on it
ssh-cli health-check prod --json
ssh-cli health-check prod --timeout 15000 --json
ssh-cli health-check down1 --json{"name":"prod","target_resolved":"prod","target_source":"argv","host_resolved":"prod","host_source":"argv","status":"ok","latency_ms":39}
{"name":"prod","target_resolved":"prod","target_source":"argv","host_resolved":"prod","host_source":"argv","status":"ok","latency_ms":41}
{"exit_code":74,"error_code":"connection_failed","message":"ssh connection failed: TCP dial failed for 127.0.0.1:22999: Connection refused (os error 111)","error_class":"transient","retryable":true,"suggestion":"retry at most twice with exponential full-jitter backoff (exit 74)"}--timeoutis in milliseconds, andhealth-checkaccepts a value under 1000 with no warning, onlyssh-cli vps add --timeoutwarns- A dead host exits 74 with
retryable: true, retry at most twice with backoff ssh-cli health-checktakes the same auth overrides asssh-cli exec
- Override auth with
printf '%s' "$PASS" | ssh-cli health-check prod --json --password-stdin - Use a key with
ssh-cli health-check prod --json --key ~/.ssh/id_ed25519and--key-passphrase-stdinwhen it is encrypted
- Fleet probes below and HOW_TO_USE.md
- Spawning one CLI process per host wastes wall-clock time on SSH round trips
ssh-cli --max-concurrency 8 health-check --all --json
ssh-cli exec --hosts web1,web2 "uptime" --json
ssh-cli exec --tags prod,edge "uptime" --json
ssh-cli scp upload --all ./app.tgz /tmp/app.tgz --json
ssh-cli vps doctor --probe-ssh --json- Expected
ssh-cli health-check --alloutput, the batch envelope carriesmax_concurrencyand oneresultsentry per host
{"event":"health-check-batch","batch_run_id":"00000000-0000-7000-8000-000000000000","max_concurrency":8,"results":[{"name":"web1","status":"ok","latency_ms":38},{"name":"web2","status":"ok","latency_ms":41}]}- Expected
ssh-cli exec --tagsoutput, only the hosts that carry a listed tag appear
{"event":"exec-batch","batch_run_id":"00000000-0000-7000-8000-000000000000","max_concurrency":64,"results":[{"name":"web2","ok":true,"exit_code":0,"stdout":"hi\n","stderr":"","duration_ms":147}]}- With one host down, the batch still reaches stdout, then the call exits 1 and prints a
partial_failureenvelope of classpartialon stderr
{"event":"health-check-batch","batch_run_id":"00000000-0000-7000-8000-000000000000","max_concurrency":64,"results":[{"name":"down1","status":"error","latency_ms":0,"error":"ssh connection failed: TCP dial failed for 127.0.0.1:22999: Connection refused (os error 111)"},{"name":"web1","status":"ok","latency_ms":37}]}
{"exit_code":1,"error_code":"partial_failure","message":"1/2 hosts failed during health-check","error_class":"partial","retryable":false}--all,--hostsand--tagsare mutually exclusive and clap rejects any pair with exit 2--tagsworks onssh-cli exec,ssh-cli sudo-execandssh-cli su-execonly, tags come fromssh-cli vps add --tag prod --tag edge- An empty registry with
--allexits 64, an unknown name in--hostsexits 64 withunknown host(s) for --hosts - One failed host never aborts its siblings and the call exits 1 with a
partial_failureenvelope on stderr ssh-cli tunnelstays single-host, several hosts mean several invocations
- Download from every host with
ssh-cli scp download --all /var/log/app.log ./app.log --json, the local path becomes a prefix and the file lands as./app.log.<vps> - Send several files with
ssh-cli scp upload --all --src f1 --src f2 --dest /tmp/dir --json,--destis always a directory that must already exist on the remote side, and results are namedhost:file - Stop admitting new hosts after the first failure with the global
--fail-fast, units already running still finish - A host that never started stays in
resultswithokfalse andnot attempted (fail-fast stopped admission)forssh-cli exec, measured with--max-concurrency 1 - The
ssh-cli scpandssh-cli sftpbatches word the same statenot attempted (fan-out admission stopped)in the source, a case this book did not reproduce
- Batch schemas in docs/schemas/README.md
- Info prose on stderr confuses parsers and verbose logs can leak secrets
ssh-cli exec lab "true" --json
ssh-cli -v exec lab "true" --json
ssh-cli -vv exec lab "true" --json
ssh-cli -vvv exec lab "true" --json- The default level is error, so the first call prints only the JSON object on stdout and nothing on stderr
{"target_resolved":"lab","target_source":"argv","host_resolved":"lab","host_source":"argv","active_fallback":false,"stdout":"","stderr":"","exit_code":0,"truncated_stdout":false,"truncated_stderr":false,"duration_ms":90}- With
-vstdout stays the same and stderr gains these lines
2026-10-20T01:14:56.025174Z INFO main ssh_cli::ssh::client_connect: starting SSH connection host=203.0.113.10 port=22 username=ubuntu timeout_ms=60000 has_key=true use_agent=false
2026-10-20T01:14:56.062337Z INFO main ssh_cli::ssh::client_connect: SSH authentication succeeded auth_method="publickey"
2026-10-20T01:14:56.062357Z INFO main ssh_cli::ssh::client_connect: SSH connection authenticated successfully
2026-10-20T01:14:56.153055Z INFO main ssh_cli::ssh::client::real: SSH session closed
- Filters are crate-scoped (
warn,ssh_cli=…), so a debug run never turns onrusshpassword dumps (G2/G14) - Ambient
RUST_LOGis ignored, only-v,-vvand-vvvchange the level - A host key rejection also prints one log line on stderr before the JSON envelope
- Silence human success output with
-q - Refuse to wait on a human with
ssh-cli vps list --json --no-input, every--*-stdinflag then exits 64 with--no-input forbids reading secrets from stdin; pass the value via flag or drop --no-input
- Verbosity in HOW_TO_USE.md
- You need to know which configuration wins and how secrets are stored
ssh-cli doctor --json | jaq -c '.local | {config_path,exists,hosts,permissions,schema_version,secrets_at_rest,secrets_key_source,telemetry,active_vps}'
ssh-cli vps path --json
ssh-cli secrets status --json{"config_path":"CONFIG_DIR/config.toml","exists":true,"hosts":1,"permissions":"600","schema_version":3,"secrets_at_rest":"plaintext","secrets_key_source":"none","telemetry":false,"active_vps":"prod"}
{"ok":true,"event":"vps-path","path":"CONFIG_DIR/config.toml"}
{"at_rest":"plaintext","encryption_active":false,"key_file":"CONFIG_DIR/secrets.key","key_source":"none","plaintext_opt_out":false}ssh-cli doctor --jsonprints one document with the eventvps-doctor, thelocalobject andssh_probeset tonullunless you pass--probe-sshpermissionsdescribesconfig.tomlonly, the config directory has its own fields- Text mode prints the same facts, with
Config dir mode:,Config dir exposed:andInvalid host names:lines
- Add the SSH probe with
ssh-cli vps doctor --probe-ssh --hosts web1 --json known_hosts.lockandlangare created 0600 with no window before the chmod- Print only the config file path with
ssh-cli vps path, the event isvps-path - Isolate a lab with
--config-dir /tmp/lab/config.toml, the parent/tmp/labbecomes the config directory for every sibling file - Read the config directory fields in the two recipes on the config directory below
- Read the runtime facts with
ssh-cli doctor --json --select local.runtime, the fields arelocal.runtime.os,arch,is_ci,is_container,is_termux,is_wslandsandbox
- Configuration in HOW_TO_USE.md
- A test run must never touch the registry, the keys or the language of your real configuration
printf '%s' 'demo-password-not-real' | ssh-cli --config-dir /tmp/lab/config.toml vps add --name a --host h.example.com --user deploy --password-stdin --json
ssh-cli --config-dir /tmp/lab/config.toml locale set pt-BR --json
ssh-cli --config-dir /tmp/lab/config.toml vps path --json
ls /tmp/lab{"ok":true,"event":"vps-added","key_file":"/tmp/lab/secrets.key","key_source":"xdg_file","name":"a","secrets_key_auto_created":true}
{"applies":"next_invocation_unless_overridden","lang":"pt-BR","ok":true,"path":"/tmp/lab/lang"}
{"ok":true,"event":"vps-path","path":"/tmp/lab/config.toml"}
config.toml
config.toml.lock
lang
secrets.key
- A path that ends in
.tomlmakes its parent the configuration directory of every file, so no directory namedconfig.tomlis created - The new levels are born 0700 and
known_hosts.lockandlangare born 0600 - The real directory is neither read nor written, because
--config-dirreplaces it - The
langfile stays inside the lab, sossh-cli locale setnever changes your own language
- Pass a plain directory such as
--config-dir /tmp/labfor the same result, the file isconfig.tomlinside it known_hosts,activeand thetls/tree also live in the lab directory once a command uses them- Delete the lab with
rm -r /tmp/labwhen you are done
- Diagnose XDG paths above and HOW_TO_USE.md
- An inventory written before the primary-key existed still holds plaintext passwords
ssh-cli secrets init --json
ssh-cli secrets reencrypt --json{"ok":true,"event":"secrets-init","force":false,"key_file":"CONFIG_DIR/secrets.key","key_source":"xdg_file","reencrypted_hosts":0}
{"ok":true,"event":"secrets-reencrypt","hosts":1}ssh-cli secrets initwithout--forceonly creates the key and re-ciphers nothing (reencrypted_hostsis 0)ssh-cli secrets reencryptrewritesconfig.tomlso passwords becomesshcli-enc:blobs- Both accept
--dry-runand print the plan without writing
- Preview with
ssh-cli secrets reencrypt --dry-run --json, the plan nameshostsandoperation - Re-encrypt under a key file you chose with
ssh-cli --secrets-key-file /path/to/key secrets reencrypt
- The initialization recipe above, the key rotation recipe below and HOW_TO_USE.md
- Replacing the primary-key without re-ciphering the stored passwords loses them at rest
ssh-cli secrets init --force --dry-run --json
ssh-cli secrets init --force --json
ssh-cli secrets status --json{"ok":true,"event":"dry-run","config_path":"CONFIG_DIR/config.toml","dry_run":true,"executed":false,"force":true,"hosts_to_reencrypt":1,"keyring":false,"operation":"secrets-init"}
{"ok":true,"event":"secrets-init","force":true,"key_file":"CONFIG_DIR/secrets.key","key_source":"xdg_file","reencrypted_hosts":1}
{"at_rest":"encrypted","encryption_active":true,"key_file":"CONFIG_DIR/secrets.key","key_source":"xdg_file","plaintext_opt_out":false}- Read
hosts_to_reencryptin the plan first, because it is the number of hosts whose secrets the rotation must re-cipher - The real run prints
reencrypted_hostswith the count it re-ciphered, compare it with the plan - Without
--forcean existing key makesssh-cli secrets initexit 64 and change nothing - The plan writes nothing, the key file and
config.tomlstay as they were
- Rotate into the OS keyring with
ssh-cli secrets init --force --keyring --json, the plan printskeyring: true - Rotate under a key file you chose with
ssh-cli --secrets-key-file /path/to/key secrets init --force --json
- The re-encryption recipe above and AGENTS.md
- You want to copy a registry to another machine without carrying credentials
ssh-cli vps export -o /tmp/hosts.redacted.json --json
ssh-cli --output-format text vps export -o /tmp/hosts.redacted.toml
ssh-cli --config-dir /tmp/ssh-cli-copy vps import --file /tmp/hosts.redacted.toml --allow-incomplete --dry-run --json
ssh-cli --config-dir /tmp/ssh-cli-copy vps import --file /tmp/hosts.redacted.toml --allow-incomplete --json{"ok":true,"event":"vps-export","format":"json","include_secrets":false,"path":"/tmp/hosts.redacted.json"}
{"ok":true,"event":"dry-run","config_path":"/tmp/ssh-cli-copy/config.toml","dry_run":true,"executed":false,"hosts":[{"incomplete":false,"name":"demo","replaces_existing":false}],"imported":1,"operation":"vps-import","source":"/tmp/hosts.redacted.toml"}
{"ok":true,"event":"vps-import","imported":1,"replaced":[]}- The body follows the resolved format: JSON on any non-TTY stdout, even for a
.tomlfile name, and TOML only with--output-format text - A redacted export clears every secret,
passwordbecomes""and the optional secrets becomenull, and a fake emptysshcli-enc:is never written - With
-othe call prints a smallvps-exportenvelope naming the file, without-oit prints the whole envelope with the hosts on stdout replacedlists the sorted names the import overwrote and is empty on a first import- A password host looks like a redacted export, so the import exits 64 unless you pass
--allow-incompleteand then complete it withssh-cli vps edit
- Import accepts TOML with English keys or legacy Portuguese aliases, or a JSON
vps-exportenvelope, and the user key isusernamein TOML (legacyusuario) anduserin JSON - A key-only or agent host round-trips complete,
use_agentandagent_sockettravel in the export - A missing
--file, with or without--dry-run, exits 66 witherror_codefile_not_found,error_classpermanentandretryablefalse, so do not retry it
- Invalid names on import below and HOW_TO_USE.md
- An import that reuses a host name overwrites that record, and the count of imported hosts does not say so
ssh-cli vps export -o hosts.json --json
ssh-cli --config-dir /tmp/ssh-cli-copy vps import --file hosts.json --json
ssh-cli --config-dir /tmp/ssh-cli-copy vps import --file hosts.json --dry-run --json
ssh-cli --config-dir /tmp/ssh-cli-copy vps import --file hosts.json --json- Expected output of the four calls, in order, with the registry of
/tmp/ssh-cli-copyempty at the start
{"ok":true,"event":"vps-export","format":"json","include_secrets":false,"path":"hosts.json"}
{"ok":true,"event":"vps-import","imported":1,"replaced":[]}
{"ok":true,"event":"dry-run","config_path":"/tmp/ssh-cli-copy/config.toml","dry_run":true,"executed":false,"hosts":[{"incomplete":false,"name":"edge","replaces_existing":true}],"imported":1,"operation":"vps-import","source":"hosts.json"}
{"ok":true,"event":"vps-import","imported":1,"replaced":["edge"]}replaces_existingis true when the name is already in the target registry, that field and not theimportedcount decides whether the import is safereplacedin the real run lists the sorted names that were overwritten, and it is empty on a first importincompleteis true for a host that looks like a redacted export, the import exits 64 for it unless you pass--allow-incomplete- The plan runs the same validation as the real run, so an invalid name fails the same way
- Import into a throwaway
--config-dirfirst when you are unsure, the registry you care about never changes - Keep a copy of the target registry before an overwrite with
ssh-cli vps export -o backup.json --jsonon the target machine
- The export and import recipe above and HOW_TO_USE.md
- A backup that keeps credentials must never reach a pipe by accident
ssh-cli vps export --include-secrets -o /tmp/hosts.secrets.json --json
ssh-cli vps export --include-secrets | cat{"ok":true,"event":"vps-export","format":"json","include_secrets":true,"path":"/tmp/hosts.secrets.json"}
{"exit_code":64,"error_code":"invalid_argument","message":"invalid argument: refusing --include-secrets to a pipe/non-TTY stdout; use `--output <file>` (mode 0o600) or pass `--i-understand-secrets-on-stdout`","error_class":"permanent","retryable":false,"suggestion":"fix CLI arguments, registry state, or schema; do not retry unchanged"}- The output file is created with mode 0600
- The pipe is refused with exit 64 unless you acknowledge it
- Missing parent directories of
-o new/deep/e.jsonare created with mode 0700 and existing ones are never changed
- Acknowledge stdout only when you truly need it with
--i-understand-secrets-on-stdout - Prefer a redacted export plus a fresh
--password-stdinon the target machine
- The redacted recipe above and AGENTS.md
- A forward without a deadline is a daemon and this CLI does not ship one
ssh-cli tunnel prod 18080 127.0.0.1 8080 --timeout-ms 3000 --json{"ok":true,"event":"tunnel_listening","vps":"prod","local_port":18080,"remote_host":"127.0.0.1","remote_port":8080,"timeout_ms":3000,"bind":"127.0.0.1","mode":"local"}
{"ok":true,"event":"tunnel_closed","vps":"prod","reason":"deadline","bind":"127.0.0.1","local_port":18080,"forwards_served":0,"capacity_waits":0,"duration_ms":3001,"mode":"local"}- Wait for the
tunnel_listeningobject before you use the local port (docs/schemas/tunnel-listening.schema.json) tunnel_closednames thereason,forwards_servedandcapacity_waits, and adeadlineending exits 0- A SIGINT exits 130 and a SIGTERM exits 143, both with
reasonset tosignal - A pre-bind timeout still exits 74
- The process stays alive until the deadline or a signal
- Bind another address with
--bind 0.0.0.0 --i-accept-network-exposure, a routable bind is refused without the acknowledgement (G-TUN-R13) and the call exits 64 with--bind 0.0.0.0 exposes the forwarded service to the network; pass --i-accept-network-exposure to proceed - Authenticate with
--password-stdin,--key,--key-passphraseor--key-passphrase-stdinas inssh-cli exec
- Bind safety in HOW_TO_USE.md
- N separate tunnels pay N SSH handshakes to reach N hosts behind one bastion
ssh-cli tunnel prod 1080 --socks5 --timeout-ms 300000 --json
curl --socks5-hostname 127.0.0.1:1080 http://10.0.0.5:8080/health{"ok":true,"event":"tunnel_listening","vps":"prod","local_port":1080,"remote_host":"*","remote_port":0,"timeout_ms":300000,"bind":"127.0.0.1","mode":"socks5"}- One SSH session serves every destination, chosen per connection (G-TUN-R02)
REMOTE_HOSTandREMOTE_PORTare omitted because SOCKS5 CONNECT carries the target- The proxy speaks RFC 1928 with no auth, so keep it on loopback
- Point any SOCKS5-aware client at
127.0.0.1:1080 - Mix it with
--max-concurrencyto cap simultaneous forwards
- Tunnel modes in HOW_TO_USE.md
- Docker, PostgreSQL and systemd listen on a Unix socket, not on TCP
ssh-cli tunnel prod 2375 --remote-socket /var/run/docker.sock --timeout-ms 60000 --json
docker -H tcp://127.0.0.1:2375 ps{"ok":true,"event":"tunnel_listening","vps":"prod","local_port":2375,"remote_host":"/var/run/docker.sock","remote_port":0,"timeout_ms":60000,"bind":"127.0.0.1","mode":"streamlocal"}- The call opens a
direct-streamlocal@openssh.comchannel (G-TUN-R03) - The socket path must be absolute, a relative path exits 64 before any SSH work
- Local existence is never checked because the socket lives on the server
- Treat the local port as privilege delegation, it inherits the socket's authority
- PostgreSQL uses the same shape with
ssh-cli tunnel db 15432 --remote-socket /var/run/postgresql/.s.PGSQL.5432 --timeout-ms 60000 --json - A Windows client works because locally it only speaks TCP, the server must support the extension
- Tunnel modes in HOW_TO_USE.md
- A callback webhook or a remote debugger must reach a port on your machine
ssh-cli tunnel prod 8080 127.0.0.1 9000 --reverse --timeout-ms 120000 --json{"ok":true,"event":"tunnel_listening","vps":"prod","local_port":9000,"remote_host":"127.0.0.1","remote_port":8080,"timeout_ms":120000,"bind":"127.0.0.1","mode":"reverse"}- Ask the server to allocate the port with
0as the last positional, the allocated port comes back inlocal_portand varies per run
ssh-cli tunnel prod 8080 127.0.0.1 0 --reverse --timeout-ms 120000 --json{"ok":true,"event":"tunnel_listening","vps":"prod","local_port":36423,"remote_host":"127.0.0.1","remote_port":8080,"timeout_ms":120000,"bind":"127.0.0.1","mode":"reverse"}- Exposing the server side needs the acknowledgement, because that is the exposed end
ssh-cli tunnel prod 8080 0.0.0.0 9000 --reverse --i-accept-network-exposure --timeout-ms 120000 --json- The server listens and delivers each connection to your local port (G-TUN-R01)
- Under
--reversethe event is read from the server's point of view:bindandlocal_portare where the server listens,remote_hostandremote_portare your local delivery target - Server-side
AllowTcpForwardingandGatewayPortsstill decide whether the bind is permitted --bindis accepted and then ignored under--reverse, set the server address through<remote_host>
- A local forward cannot take
REMOTE_PORT0, there would be nothing to connect to, whileLOCAL_PORT0 asks the OS for a free port - A server-side bind outside loopback without the acknowledgement exits 64 with a message that asks you to pass
--i-accept-network-exposure
- Tunnel modes in HOW_TO_USE.md
- Piping a large envelope through an external tool pays the token cost before it shrinks
ssh-cli health-check --all --json --select name,status --filter status!=ok
ssh-cli vps list --json --select name,host --sort name --limit 2
ssh-cli vps list --json --count-only
ssh-cli vps list --json --filter nameprod{"agent_shape":{"dropped":2,"input_count":3,"output_count":1},"batch_run_id":"00000000-0000-7000-8000-000000000000","event":"health-check-batch","max_concurrency":64,"results":[{"name":"down1","status":"error"}]}
[{"host":"127.0.0.1","name":"demo"},{"host":"127.0.0.1","name":"demo2"}]
{"count":3}
Invalid argument: invalid --filter `nameprod`: expected key=value, key!=value or key~substring
- The first call exits 1 because one host failed, the next two exit 0 and the last one exits 64
- The eight shaping flags run before serialization, so the big envelope is never built
- The batch result field is
statuswithokorerror, there is nookfield, so--filter ok=falsewould drop every record - A malformed predicate is rejected at parse time with exit 64, so a typo never looks like no matches
- Shaping reaches record collections (
ssh-cli vps list,results,entriesofssh-cli sftp ls) and also a single object such asssh-cli vps show,ssh-cli doctoror a single-hostssh-cli exec - On a single object only
--selectand--truncate-contentreduce, and--filter,--limit,--sort,--dedupe-by,--count-onlyand--max-output-bytesare named underagent_shape.ignored name~~prodparses as keynamewith substring~prod, the refusal covers missing operators and empty keys
- Dedupe with
--dedupe-by hostand shorten strings with--truncate-content 200 - Pick fields of a fleet answer with
ssh-cli exec --all 'echo a' --json --select name,stdout, since--select resultswould return empty objects --fieldsis an alias of--select, sossh-cli vps show prod --json --fields name,host --count-onlyprints{"agent_shape":{"ignored":["--count-only"],"selected":["name","host"]},"host":"prod.example.com","name":"prod"}- The bare array of
ssh-cli vps listhas no room foragent_shape, sossh-cli vps list --json --select name --limit 1writes{"dropped":1,"event":"agent-shape","input_count":2,"output_count":1}on stderr, and--quietsuppresses that line - A clap usage error (exit 2) and a malformed
--filter(exit 64) print plain text on stderr even with--json, only execution errors carry the JSON envelope, and the plain text follows the UI locale (Argumento inválido:under pt-BR) - Cap the envelope with
--max-output-bytes 65536, it drops trailing records and never slices the JSON text - An empty key such as
--filter =prodexits 64 withinvalid --filter: empty key, while--filter name~~prodis valid, exits 0 and matches nothing here - A reader that closes the pipe before a large output ends, such as
ssh-cli completions bash | head -c 5, makes the CLI exit 141 with the output incomplete, read it from${PIPESTATUS[0]}and never retry - A small JSON such as
ssh-cli vps list --json | head -c 5finishes writing before the reader closes and exits 0, so it proves nothing about 141 - Exit 141 covers a closed stdout and a closed stderr alike
- Payload shaping in AGENTS.md
- Moving a host between authentication methods used to exit 64 and force a detour through the agent
printf '%s' 'demo-password-not-real' | ssh-cli vps add --name web1 --host 203.0.113.10 --user deploy --password-stdin --json
ssh-cli vps edit web1 --key ~/.ssh/id_ed25519 --json
printf '%s' 'demo-password-not-real' | ssh-cli vps edit web1 --password-stdin --json{"ok":true,"event":"vps-added","key_file":"CONFIG_DIR/secrets.key","key_source":"xdg_file","name":"web1","secrets_key_auto_created":true}
{"ok":true,"event":"vps-edited","host_resolved":"web1","host_source":"argv","name":"web1","target_resolved":"web1","target_source":"argv","updated":["key_path","password"]}
{"ok":true,"event":"vps-edited","host_resolved":"web1","host_source":"argv","name":"web1","target_resolved":"web1","target_source":"argv","updated":["password","key_path"]}- Conflicting flags are caught by clap before any work, exit 2, and the first line of the plain-text error is shown
ssh-cli vps edit web1 --key ~/.ssh/id_ed25519 --password-stdin --jsonerror: the argument '--key <PATH>' cannot be used with '--password-stdin'
--keyclearspassword,use_agentandagent_socket,--password*clearskey_path,key_passphrase,use_agentandagent_socket--use-agentclearspassword,key_pathandkey_passphrase, and--agent-socketalone implies--use-agentupdatedlists the new method first and then only the fields that held a value- A different
--keydrops the old passphrase, the same key path changes nothing - A passphrase without any key exits 64 and leaves the record untouched
- Move to the agent with
ssh-cli vps edit web1 --use-agent --json,updatedis["use_agent","password"]on a password host - Add a passphrase to a key with
--key ~/.ssh/id_ed25519 --key-passphrase-stdin
- Authentication switch in HOW_TO_USE.md
- The export, edit, import detour redacted secrets and dropped agent authentication
ssh-cli connect old-name
ssh-cli vps rename old-name new-name --json
ssh-cli vps edit new-name --name newer-name --json{"ok":true,"event":"vps-connected","name":"old-name"}
{"ok":true,"event":"vps-renamed","active_rewritten":true,"host_resolved":"old-name","host_source":"argv","name":"new-name","previous_name":"old-name","target_resolved":"old-name","target_source":"argv","updated":[]}
{"ok":true,"event":"vps-renamed","active_rewritten":true,"host_resolved":"new-name","host_source":"argv","name":"newer-name","previous_name":"new-name","target_resolved":"new-name","target_source":"argv","updated":[]}- The credential is never asked for, the record seals secrets without associated data and the new name changes nothing about them
ssh-cli vps edit --nameandssh-cli vps renameemit the samevps-renamedevent withprevious_nameactive_rewrittenistrueonly when theactivemarker pointed at the old name, and it follows the new one- Repeating a rename exits 66 because the old name no longer exists, so check
ssh-cli vps listinstead of repeating
- A taken destination exits 64 with
vps_duplicateand an identical name exits 64 withnew name ... is identical to the current name - An invalid new name exits 64 as well
ssh-cli vps rename ghost ghostexits 66 because the host must exist before the names are compared
- Rename in HOW_TO_USE.md
- Every optional field could be overwritten but none could be removed
ssh-cli vps edit web1 --user deploy --port 2222 --tag prod --tag eu --json
ssh-cli vps edit web1 --clear-tags --tag staging --json
ssh-cli vps edit web1 --port 2222 --json
ssh-cli vps edit web1 --json{"ok":true,"event":"vps-edited","host_resolved":"web1","host_source":"argv","name":"web1","target_resolved":"web1","target_source":"argv","updated":["port","tags"]}
{"ok":true,"event":"vps-edited","host_resolved":"web1","host_source":"argv","name":"web1","target_resolved":"web1","target_source":"argv","updated":["tags"]}
{"ok":true,"event":"vps-edited","host_resolved":"web1","host_source":"argv","name":"web1","target_resolved":"web1","target_source":"argv","updated":[]}
{"exit_code":64,"error_code":"invalid_argument","message":"invalid argument: nothing to edit for 'web1': pass at least one field, --name, --tag or a clear flag","error_class":"permanent","retryable":false,"suggestion":"fix CLI arguments, registry state, or schema; do not retry unchanged"}- The host started with
useralreadydeploy, soupdatedlists onlyportandtagsin the first call
- The eight clear flags are
--clear-tags,--no-key,--no-agent-socket,--clear-sudo-password,--clear-su-password,--no-tls-sni,--no-tls-client-certand--no-tls-client-key --clear-tagsclears first and--tagthen appends, so the result is[staging]--no-keyalso clears the passphrase, and clearing the only remaining auth method exits 64 witherror_codedomain_validationand a message prefixedvps_auth:, and writes nothing- An edit that changes nothing is a no-op: exit 0,
updated: []andconfig.tomlis not rewritten - Each clear flag conflicts with the value flag of its own field,
--no-key --keyexits 2
- Drop both mTLS halves with
--no-tls-client-cert --no-tls-client-key - Toggle sudo with
--disable-sudoand--enable-sudo,updatedis["disable_sudo"] --no-agent-socketon an agent host with an explicit socket exits 64 where the platform has no default endpoint, pass--agent-socket PATHor switch method
- The flag table in HOW_TO_USE.md
- An older
config.tomlholds a name with a space, an accent or a control character
mkdir -p legacy && chmod 700 legacy
cat > legacy/config.toml <<'EOF'
schema_version = 3
[hosts."legacy host"]
name = "legacy host"
host = "legacy.example.com"
port = 22
username = "deploy"
use_agent = true
EOF
ssh-cli --config-dir legacy doctor --json | jaq -c '.local.invalid_host_names'
ssh-cli --config-dir legacy vps rename 'legacy host' legacy-host --json
ssh-cli --config-dir legacy doctor --json | jaq -c '.local.invalid_host_names'["legacy host"]
{"ok":true,"event":"vps-renamed","active_rewritten":false,"host_resolved":"legacy host","host_source":"argv","name":"legacy-host","previous_name":"legacy host","target_resolved":"legacy host","target_source":"argv","updated":[]}
[]- The CLI never blocks on its own file, the legacy name loads, lists, shows and resolves
ssh-cli doctorlists the names outside the strict charset, escaped, inlocal.invalid_host_namesssh-cli vps renameaccepts the legacy source because only the new name is validated- A name with a control character is refused as a target by
ssh-cli exec,ssh-cli health-check,--hostsandssh-cli connectwith exit 64 - A legacy host exports fine but restores only after
ssh-cli vps renamegives it a strict name
- A name with an ESC byte prints as
a\u{1b}[2Jbin text mode and ininvalid_host_names - Two tables that normalize to one name exit 65 with
config has two hosts named ...
- Repair a legacy host name in HOW_TO_USE.md
- A stored host name with an ESC byte can redraw your terminal when a tool prints it raw
mkdir -p hostile && chmod 700 hostile
cat > hostile/config.toml <<'EOF'
schema_version = 3
[hosts."a\u001b[2Jb"]
name = "a\u001b[2Jb"
host = "h.example.com"
port = 22
username = "deploy"
use_agent = true
EOF
ssh-cli --lang en --config-dir hostile --output-format text vps show "$(printf 'a\033[2Jb')" | sed -n 1p
ssh-cli --lang en --config-dir hostile --output-format text vps show "$(printf 'a\033[2Jz')"
ssh-cli --config-dir hostile exec "$(printf 'a\033[2Jb')" id --json
ssh-cli --config-dir hostile vps list --json | jaq -c '.[].name'- The first line below is stdout, and the second is the stderr of the call that exits 66
Name: a\u{1b}[2Jb
VPS 'a\u{1b}[2Jz' not found.
- The third call is refused with exit 64 and the fourth prints the JSON escape
{"exit_code":64,"error_code":"invalid_argument","message":"invalid argument: vps_name: name must not contain control or invisible formatting characters","error_class":"permanent","retryable":false,"suggestion":"fix CLI arguments, registry state, or schema; do not retry unchanged"}
"a\u001b[2Jb"- Text mode writes control, formatting and bidi characters as
\u{1b}in success, error and warning messages, inlistandshow, and in batch output - The JSON keeps the original value, because it is for machines and the
serde_jsonescape already hides the ESC byte - A lookup that would become a target refuses control and bidi characters with exit 64,
ssh-cli exec,ssh-cli health-check,--hostsandssh-cli connectincluded, andssh-cli connectwrites no marker - The error for a name that does not exist echoes the typed name escaped
- Remote names get the same care,
ssh-cli sftp lsandssh-cli sftp statalso escape\nand\tin text mode - The remote stdout and stderr of
ssh-cli execstay raw by design, filter them before you print them to a terminal
- Repair a legacy host name above and AGENTS.md
- A config directory with mode 0755 shows host names and file sizes to other users
ssh-cli doctor --json --select local.config_dir_exposed
mkdir shared && chmod 755 shared
ssh-cli --config-dir shared doctor --json --select local.config_dir_permissions,local.config_dir_exposed
chmod 700 shared
ssh-cli --config-dir shared doctor --json --select local.config_dir_permissions,local.config_dir_exposed{"agent_shape":{"selected":["local.config_dir_exposed"]},"local":{"config_dir_exposed":false}}
{"agent_shape":{"selected":["local.config_dir_permissions","local.config_dir_exposed"]},"local":{"config_dir_exposed":true,"config_dir_permissions":"0755"}}
{"agent_shape":{"selected":["local.config_dir_permissions","local.config_dir_exposed"]},"local":{"config_dir_exposed":false,"config_dir_permissions":"0700"}}- A directory the CLI creates is born 0700 at every new level
- An existing default XDG directory is tightened to 0700 when it is not a symlink and belongs to you
- Any other existing directory is never chmodded,
ssh-cli doctorreports the exposure instead and a log warning shows with-v --selectreaches thessh-cli doctorobject directly and names the kept paths inagent_shape.selected, while--filterand--count-onlycannot apply to one record and are named underagent_shape.ignored- The fix is yours: run
chmod 700on the directory and the same call printsconfig_dir_exposedasfalse
config_dir_permissionsis absent off Unix andconfig_dir_exposedreadsfalsethere- On Windows the directory relies on the profile ACL because there is no chmod
- Configuration in HOW_TO_USE.md
- A file with several bad host names should fail once, with the whole list
cat > inv.toml <<'EOF'
schema_version = 3
[hosts."bad name"]
name = "bad name"
host = "h.example.com"
port = 22
username = "u"
use_agent = true
[hosts."semi;colon"]
name = "semi;colon"
host = "h.example.com"
port = 22
username = "u"
use_agent = true
EOF
ssh-cli vps import --file inv.toml --json
ssh-cli vps import --file inv.toml --dry-run --json{"exit_code":64,"error_code":"invalid_argument","message":"invalid argument: invalid VPS names in import (2): 'bad name', 'semi;colon'; a name is 1..=255 ASCII letters, digits, '.', '_' or '-' and starts with a letter or digit; nothing was imported","error_class":"permanent","retryable":false,"suggestion":"fix CLI arguments, registry state, or schema; do not retry unchanged"}
{"exit_code":64,"error_code":"invalid_argument","message":"invalid argument: invalid VPS names in import (2): 'bad name', 'semi;colon'; a name is 1..=255 ASCII letters, digits, '.', '_' or '-' and starts with a letter or digit; nothing was imported","error_class":"permanent","retryable":false,"suggestion":"fix CLI arguments, registry state, or schema; do not retry unchanged"}- The import validates every item first and writes nothing when one name fails
--dry-rungives the same error, so the rehearsal is faithful- Fix the names in the file and run again, retrying unchanged cannot help
- Use ASCII letters, digits, dot, hyphen and underscore, starting with a letter or digit, up to 255 bytes
ssh-cli vps add --name 'bad name'and the target ofssh-cli vps renamefollow the same strict rule and exit 64
- Export and import in HOW_TO_USE.md
- An identity name becomes a directory name, so a hostile name must not create one
openssl req -x509 -newkey ed25519 -nodes -keyout client-key.pem -out client.pem -subj /CN=demo -days 2
ssh-cli tls mtls import --name edge --cert ./client.pem --key ./client-key.pem --json
ssh-cli tls mtls import --name 'serviço-1' --cert ./client.pem --key ./client-key.pem --json
ssh-cli tls mtls list --json
ssh-cli tls mtls show edge --json{"ok":true,"event":"tls-mtls-import","cert_path":"CONFIG_DIR/tls/mtls/edge/cert.pem","key_path":"CONFIG_DIR/tls/mtls/edge/key.pem","name":"edge"}
{"ok":true,"event":"tls-mtls-import","cert_path":"CONFIG_DIR/tls/mtls/serviço-1/cert.pem","key_path":"CONFIG_DIR/tls/mtls/serviço-1/key.pem","name":"serviço-1"}
{"ok":true,"event":"tls-mtls-list","identities":["edge","serviço-1"]}
{"ok":true,"event":"tls-mtls-show","cert_path":"CONFIG_DIR/tls/mtls/edge/cert.pem","key_path":"CONFIG_DIR/tls/mtls/edge/key.pem","name":"edge"}- A name with a bidi override such as U+202E exits 64 and creates no directory
{"exit_code":64,"error_code":"invalid_argument","message":"invalid argument: invalid mTLS identity name: invalid argument: file name must not contain control or invisible formatting characters","error_class":"permanent","retryable":false,"suggestion":"fix CLI arguments, registry state, or schema; do not retry unchanged"}ssh-cli tls mtls importrefuses control, formatting and bidi characters, ordinary Unicode stays acceptedssh-cli tls mtls listshows the stored identity names- Keys and certificates land under
tls/mtls/<name>/in the config directory with private modes shownever prints private key material
- Remove an identity with
ssh-cli tls mtls remove edge --json, it prints{"ok":true,"event":"tls-mtls-remove","name":"edge"} - Attach it to a host with
ssh-cli vps edit web1 --tls --tls-client-cert CERT --tls-client-key KEY - Turn SSH-over-TLS off again with
ssh-cli vps edit web1 --no-tls --json,updatedis["tls"]and the stored SNI and mTLS paths stay until you pass their clear flags
- TLS in HOW_TO_USE.md
- You want to see which host a destructive call would touch before it touches anything
ssh-cli vps remove web1 --dry-run --json
ssh-cli vps rename web1 web-1 --dry-run --json
ssh-cli vps edit web1 --name web-1 --dry-run --json{"ok":true,"event":"dry-run","config_path":"CONFIG_DIR/config.toml","dry_run":true,"executed":false,"host_resolved":"web1","host_source":"argv","name":"web1","operation":"vps-remove","target_resolved":"web1","target_source":"argv"}
{"ok":true,"event":"dry-run","active_rewritten":false,"config_path":"CONFIG_DIR/config.toml","dry_run":true,"executed":false,"host_resolved":"web1","host_source":"argv","name":"web-1","operation":"vps-rename","previous_name":"web1","target_resolved":"web1","target_source":"argv","updated":[]}
{"ok":true,"event":"dry-run","active_rewritten":false,"config_path":"CONFIG_DIR/config.toml","dry_run":true,"executed":false,"host_resolved":"web1","host_source":"argv","name":"web-1","operation":"vps-rename","previous_name":"web1","target_resolved":"web1","target_source":"argv","updated":[]}- Against a registry that does not exist, the three verbs exit 66 and create no directory and no lock file
{"exit_code":66,"error_code":"vps_not_found","message":"vps 'ghost' not found in registry","error_class":"permanent","retryable":false,"suggestion":"the host is not in the registry; if an earlier `vps rename` or `vps remove` may have succeeded, run `vps list` before repeating"}--dry-runis accepted only byssh-cli vps remove,ssh-cli vps import,ssh-cli vps edit,ssh-cli vps rename,ssh-cli sftp rm,ssh-cli sftp rmdir,ssh-cli secrets initandssh-cli secrets reencrypt- Anywhere else it exits 64, so a flag that does nothing never passes for a rehearsal
- The plan runs every validation of the real run and carries
target_resolved,target_source(argv),host_resolvedandhost_source active_rewrittentells whether theactivemarker would move, without moving it- A failed lookup carries no target, because no host was ever chosen
- The remote operations print
vpsandremoteinstead, for examplessh-cli sftp rm prod /srv/app/stale.log --dry-run --json - A refused call looks like
ssh-cli exec prod 'rm -rf /tmp/x' --dry-run --jsonand exits 64
- The dry-run schema in docs/schemas/README.md
- You need a regular file on the host, with proof of what moved
ssh-cli scp upload prod ./payload.bin /tmp/payload.bin --timeout 120000 --json
ssh-cli scp download prod /tmp/payload.bin ./payload.local --json
ssh-cli scp download prod /tmp/missing.bin ./missing --json{"ok":true,"event":"scp-transfer","target_resolved":"prod","target_source":"argv","host_resolved":"prod","host_source":"argv","direction":"upload","vps":"prod","local":"./payload.bin","remote":"/tmp/payload.bin","bytes":8,"duration_ms":111,"mtime_preserved":true,"durable":true}
{"ok":true,"event":"scp-transfer","target_resolved":"prod","target_source":"argv","host_resolved":"prod","host_source":"argv","direction":"download","vps":"prod","local":"./payload.local","remote":"/tmp/payload.bin","bytes":8,"duration_ms":209,"mtime_preserved":true,"durable":true}
{"exit_code":66,"error_code":"file_not_found","message":"file not found: /tmp/missing.bin","error_class":"permanent","retryable":false,"suggestion":"fix CLI arguments, registry state, or schema; do not retry unchanged","target_resolved":"prod","target_source":"argv","host_resolved":"prod","host_source":"argv","active_fallback":false}- SCP moves regular files only, use
sftp --recursivefor trees - A download writes
<path>.ssh-cli.partialand renames it atomically, so a failure leaves the final path untouched - Preserving mtime and mode is best effort and
mtime_preservedanddurablereport the real outcome - Failures with
--jsonprint the error envelope on stderr
- Send several files to one existing remote directory with
ssh-cli scp upload prod f1 f2 /tmp/dir --json, one session and serial transfers, and the event isscp-batchwith oneresultsentry per file - Check the result with
ssh-cli exec prod "ls -l /tmp/dir" --json - Open parallel channels on the same session with the global
--scp-file-concurrency 2placed before the subcommand, the default is 1
- Daily operator loop in HOW_TO_USE.md
- A client-reported byte count does not prove the destination holds the right bytes
ssh-cli sftp upload prod ./payload.bin /tmp/payload.bin --json
ssh-cli exec prod "sha256sum /tmp/payload.bin" --json | jaq -r .stdout
sha256sum ./payload.bin{"ok":true,"event":"sftp-transfer","target_resolved":"prod","target_source":"argv","host_resolved":"prod","host_source":"argv","direction":"upload","vps":"prod","local":"./payload.bin","remote":"/tmp/payload.bin","bytes":8,"duration_ms":1}
d4e4877bac978b7952f0d544fc52ebff5411d351d129f1f056fa43f11da9af2b /tmp/payload.bin
d4e4877bac978b7952f0d544fc52ebff5411d351d129f1f056fa43f11da9af2b ./payload.bin- Both digests are identical, the second
sha256sumline is the local file
- The matching digests are the acceptance criterion, destination effect over client claims (G15)
- G1 fixed upload truncation in 0.5.3, so use 0.5.3 or newer
- Downloads write a partial file and rename it, and local
set_permissionsfailures are surfaced (G18)
- Move a tree with
ssh-cli sftp upload --recursive prod ./dist/tree /opt/app/tree --json, symlinks are not followed - Fan out with
ssh-cli sftp upload --all ./payload.bin /tmp/payload.bin --json
- SFTP in HOW_TO_USE.md
- You need a remote file or a whole directory tree on your machine, with proof that it arrived
ssh-cli sftp download prod /srv/app/f1 ./out/f1.local --json
ssh-cli sftp download --recursive prod /srv/app/tree ./out/tree --json
ssh-cli sftp download prod /srv/app/missing ./out/m --json
sha256sum ./out/f1.local{"ok":true,"event":"sftp-transfer","target_resolved":"prod","target_source":"argv","host_resolved":"prod","host_source":"argv","direction":"download","vps":"prod","local":"./out/f1.local","remote":"/srv/app/f1","bytes":2,"duration_ms":1}
{"ok":true,"event":"sftp-transfer","target_resolved":"prod","target_source":"argv","host_resolved":"prod","host_source":"argv","direction":"download","vps":"prod","local":"./out/tree","remote":"/srv/app/tree","bytes":2,"duration_ms":6,"recursive":true}
{"exit_code":66,"error_code":"file_not_found","message":"file not found: /srv/app/missing","error_class":"permanent","retryable":false,"suggestion":"fix CLI arguments, registry state, or schema; do not retry unchanged","target_resolved":"prod","target_source":"argv","host_resolved":"prod","host_source":"argv","active_fallback":false}- The local digest matches the remote one, here both files hold the same two bytes
- The tree call prints one
sftp-transferdocument withrecursive: trueandbytes - A tree keeps its layout,
./out/tree/sub/fappears for a remotetree/sub/f, and symlinks are not followed - A missing remote path exits 66 with
file not foundand leaves no local file behind - A single download writes a partial file and renames it, so the final path is never half written
- Permission bits are masked with
SFTP_PERM_MASK_UNTRUSTED(0o0777), so a server cannot hand you a setuid file (A3)
- Pull one file from every host with
ssh-cli sftp download --all /var/log/app.log ./app.log --json, each file lands as./app.log.<vps>and the event issftp-batch - Pull several sources with
--hosts web1,web2 --src f1 --src f2 --dest ./out, the files land under./out/<host>/
- The upload recipe above and HOW_TO_USE.md
- A recursive transfer prints one byte total, so it cannot show that every file arrived intact
mkdir -p tree/sub && echo a > tree/a && echo b > tree/sub/f && ln -s a tree/link
ssh-cli sftp upload --recursive prod ./tree /srv/app/tree --json
(cd tree && sha256sum a sub/f)
ssh-cli exec prod "cd /srv/app/tree && sha256sum a sub/f" --json | jaq -r .stdout
ssh-cli exec prod "ls /srv/app/tree" --json | jaq -r .stdout{"ok":true,"event":"sftp-transfer","target_resolved":"prod","target_source":"argv","host_resolved":"prod","host_source":"argv","direction":"upload","vps":"prod","local":"./tree","remote":"/srv/app/tree","bytes":4,"duration_ms":2,"recursive":true}
87428fc522803d31065e7bce3cf03fe475096631e5e07bbd7a0fde60c4cf25c7 a
0263829989b6fd954f72baaf2fc64bc2e2f01d692d4de72986ea808f6e99813f sub/f
87428fc522803d31065e7bce3cf03fe475096631e5e07bbd7a0fde60c4cf25c7 a
0263829989b6fd954f72baaf2fc64bc2e2f01d692d4de72986ea808f6e99813f sub/f
a
sub
- The two digest pairs are identical, the first pair is the local tree, and the listing has no
link
bytesis the sum of the file bytes the call moved, 4 here for two files of 2 bytes, and it proves no content- Equal digests per file are the acceptance criterion, destination effect over client claims
- The symlink is not followed, so it does not reach the remote side
- A recursive call prints one
sftp-transferdocument withrecursive: true
- Check a download the same way with
ssh-cli sftp download --recursive prod /srv/app/tree ./out/tree --jsonand(cd out/tree && sha256sum a sub/f) - Verify every host in one call with
ssh-cli exec --all "cd /srv/app/tree && sha256sum a sub/f" --json --select name,stdout
- The checksum recipe above and HOW_TO_USE.md
- You need to list, create, rename and remove remote paths without a shell
ssh-cli sftp ls prod /tmp/rem --json
ssh-cli sftp stat prod /tmp/rem/payload.bin --json
ssh-cli sftp mkdir prod /tmp/rem/app --json
ssh-cli sftp rename prod /tmp/rem/app /tmp/rem/app.bak --json
ssh-cli sftp rmdir prod /tmp/rem/app.bak --dry-run --json
ssh-cli sftp rm prod /tmp/rem/payload.bin --json{"ok":true,"event":"sftp-list","vps":"prod","path":"/tmp/rem","entries":[{"name":"payload.bin","path":"/tmp/rem/payload.bin","kind":"file","size":8,"mode":33188}]}
{"ok":true,"event":"sftp-fs-op","op":"stat","vps":"prod","path":"/tmp/rem/payload.bin","duration_ms":0,"kind":"file","size":8,"mode":33188,"mtime":1792458836}
{"ok":true,"event":"sftp-fs-op","op":"mkdir","vps":"prod","path":"/tmp/rem/app","duration_ms":318}
{"ok":true,"event":"sftp-fs-op","op":"rename","vps":"prod","path":"/tmp/rem/app","to":"/tmp/rem/app.bak","duration_ms":311}
{"ok":true,"event":"dry-run","dry_run":true,"executed":false,"operation":"sftp-rmdir","remote":"/tmp/rem/app.bak","vps":"prod"}
{"ok":true,"event":"sftp-fs-op","op":"rm","vps":"prod","path":"/tmp/rem/payload.bin","duration_ms":210}ssh-cli sftp lsprints the eventsftp-list, andssh-cli sftp mkdir,ssh-cli sftp rename,ssh-cli sftp rmandssh-cli sftp rmdirprintsftp-fs-opwith theopthey ranssh-cli sftp statprintssftp-fs-opwithopset tostatand addskind,size,modeandmtimessh-cli sftp rmandssh-cli sftp rmdiraccept--dry-run, the other verbs do not- Text mode escapes
\nand\tin remote names, so a forged name cannot add fake lines - Download permission bits are masked with
SFTP_PERM_MASK_UNTRUSTED(0o0777) and upload usesSFTP_PERM_MASK(0o7777)
- A missing path exits 66 with
file not found - Schemas:
sftp-list,sftp-fs-op,sftp-transferandsftp-batch
- SFTP in HOW_TO_USE.md
- You want tab completion for every command and flag
mkdir -p ~/.local/share/bash-completion/completions
ssh-cli completions bash > ~/.local/share/bash-completion/completions/ssh-cli
ssh-cli completions zsh
ssh-cli completions fish
ssh-cli completions powershell
ssh-cli completions elvish- The first line of the bash script, for a quick check
_ssh-cli() {
- The script goes to stdout and nothing is installed for you
- The scripts are generated from the same command tree as
ssh-cli commands
- On Windows redirect
ssh-cli completions powershellinto your profile - Regenerate after every upgrade so new flags complete
- Discover contracts above and HOW_TO_USE.md
- You want messages in one language, once, or for a single call
ssh-cli --lang en locale show --json
ssh-cli locale set pt-BR --json
ssh-cli locale clear --json{"available":["en","pt-BR"],"current":"en","direction":"ltr","persisted_raw":null,"preference_path":"CONFIG_DIR/lang","resolved":"en","script":"Latn","source":"cli_flag","system_raw":"pt-BR"}
{"applies":"next_invocation_unless_overridden","lang":"pt-BR","ok":true,"path":"CONFIG_DIR/lang"}
{"cleared":true,"ok":true}- The preference is the
langfile in the config directory with mode 0600, never an environment variable --langoverrides for one call and persists nothing, sosourceiscli_flagin the first output- Error bodies in JSON stay in English so an agent never depends on the host locale, branch on
error_code
- Accepted tags negotiate to
enorpt-BR, for exampleen-USorpt, as inssh-cli --lang en vps list ssh-cli locale showreports the resolved language andssh-cli locale setpersists a preference for the next callsssh-cli locale clearremoves the stored preference and repeating it is harmless- The winning layer is
cli_flag, then the stored preference, then the system locale
- Locale in HOW_TO_USE.md
- You need to know the TLS stack, where its material lives and the state of an ACME order
ssh-cli tls provider --json
ssh-cli tls paths --json
ssh-cli tls acme account show --json
ssh-cli tls acme list --json
ssh-cli tls acme status --domain example.com --json{"ok":true,"event":"tls-provider","installed":true,"min_rustls":"0.23.18","provider":"aws_lc_rs","stack":"rustls+aws_lc_rs"}
{"ok":true,"event":"tls-paths","acme_account":"CONFIG_DIR/tls/acme/account.json","acme_dir":"CONFIG_DIR/tls/acme","mtls_dir":"CONFIG_DIR/tls/mtls","tls_root":"CONFIG_DIR/tls"}
{"ok":true,"event":"tls-acme-account-show","directory":null,"path":"CONFIG_DIR/tls/acme/account.json","present":false}
{"ok":true,"event":"tls-acme-list","domains":[]}
{"ok":true,"event":"tls-acme-status","domains":[{"cert_path":null,"domain":"example.com","has_cert":false,"has_key":false,"has_pending_order":false,"key_path":null}]}- Before an account exists,
ssh-cli tls acme issueandssh-cli tls acme completefail with exit 66 and these envelopes
ssh-cli tls acme issue --domain example.com --print-challenge --json
ssh-cli tls acme complete --domain example.com --json{"exit_code":66,"error_code":"file_not_found","message":"file not found: ACME account missing at CONFIG_DIR/tls/acme/account.json; run `ssh-cli tls acme account create`","error_class":"permanent","retryable":false,"suggestion":"fix CLI arguments, registry state, or schema; do not retry unchanged"}
{"exit_code":66,"error_code":"file_not_found","message":"file not found: no pending ACME order for example.com; run `tls acme issue --domain example.com --print-challenge` first","error_class":"permanent","retryable":false,"suggestion":"fix CLI arguments, registry state, or schema; do not retry unchanged"}ssh-cli tls providerreports the stack: rustls withaws_lc_rsonly, there is no OpenSSL at runtime- Building from source needs a C compiler, because the
aws_lc_rsprovider compilesaws-lc-sys min_rustlsis the lowest rustls the binary accepts, andCargo.tomlasks for rustls 0.23.25ssh-cli tls acme account showtells whether an account exists,ssh-cli tls acme listlists the domains andssh-cli tls acme statusshows order and certificate state- The ACME commands name the domain with
--domain, a bare positional such asssh-cli tls acme issue example.comexits 2 - Material lives under the config directory in
tls/, andssh-cli tls pathsprints every root
- Create the account with
ssh-cli tls acme account create --contact mailto:ops@<YOUR_DOMAIN>, add--stagingto rehearse - The create call contacts the ACME directory, a contact on a reserved domain such as
example.comexits 64 withinvalidContactand retrying cannot help - Order and finish with
ssh-cli tls acme issue --domain example.com --print-challengeandssh-cli tls acme complete --domain example.com
- TLS in HOW_TO_USE.md
- A changed host key may be a legitimate rotation or an interception
ssh-cli exec prod "echo hi" --json
ssh-cli --replace-host-key exec prod "echo hi" --json{"exit_code":77,"error_code":"host_key_changed","message":"host key changed for prod.example.com:22: expected SHA256:STORED, got SHA256:OFFERED (use --replace-host-key if legitimate)","error_class":"permanent","retryable":false,"suggestion":"change credentials (--key / --password-stdin) or host-key policy; do not blind-retry","target_resolved":"prod","target_source":"argv","host_resolved":"prod","host_source":"argv","active_fallback":false}
{"target_resolved":"prod","target_source":"argv","host_resolved":"prod","host_source":"argv","active_fallback":false,"stdout":"hi\n","stderr":"","exit_code":0,"truncated_stdout":false,"truncated_stderr":false,"duration_ms":92}- The first connection records the key in
known_hostsand a mismatch later exits 77 - Confirm the rotation with a human before you pass
--replace-host-key - The flag replaces the stored key for that one call and later calls pass without it
- Keep
--replace-host-keyout of default scripts, it removes the protection against interception - Do not retry exit 77 blindly, change credentials or the host-key policy first
- Exit codes in HOW_TO_USE.md
- An untrusted workflow must stay unprivileged whatever the host record stores
ssh-cli --disable-sudo exec prod "id -u" --json
ssh-cli --disable-sudo sudo-exec prod "id -u" --json{"target_resolved":"prod","target_source":"argv","host_resolved":"prod","host_source":"argv","active_fallback":false,"stdout":"1000\n","stderr":"","exit_code":0,"truncated_stdout":false,"truncated_stderr":false,"duration_ms":94}
{"exit_code":77,"error_code":"sudo_disabled","message":"sudo/su disabled for this host (disable_sudo)","error_class":"permanent","retryable":false,"suggestion":"fix CLI arguments, registry state, or schema; do not retry unchanged","target_resolved":"prod","target_source":"argv","host_resolved":"prod","host_source":"argv","active_fallback":false}- Plain
ssh-cli execstill runs because it asks for no elevation ssh-cli sudo-execandssh-cli su-execare blocked for this invocation with exit 77- The persistent form is
ssh-cli vps edit prod --disable-sudo, which storesdisable_sudo: true - The global
--disable-sudoplaced beforessh-cli vps addorssh-cli vps editalso writesdisable_sudo: true, so that block outlives the call
- Lift the persistent block with
ssh-cli vps edit prod --enable-sudo - Register a host already blocked with
ssh-cli vps add ... --disable-sudo ssh-cli --disable-sudo vps edit prod --jsonprintsupdatedas["disable_sudo"], and--disableSudois an accepted alias of the global flag
- Safer agent automation in HOW_TO_USE.md
- You want proof against a real sshd without putting a secret in a log
python3 scripts/e2e_real_ssh.py --local-sshd
python3 scripts/e2e_real_ssh.py --host 203.0.113.10 --port 22 --user deploy --password-file /path/to/pw-file- Expected output of the first command, one label per case and a final summary
PASS E01
PASS E02
PASS E03
PASS E04
PASS E05
PASS E06
PASS E07
PASS E08
PASS E09
PASS E10
PASS E11
PASS E12
PASS E13
PASS E14
PASS E15
PASS E16
PASS E17
PASS E18
PASS E19
PASS E20
PASS E21
---
fails=0 soft_sudo=0 tmp_destroyed=yes
- The script prints only PASS, FAIL and SKIP labels, never a host, a user or a password
--local-sshdstarts an unprivileged sshd on loopback, it is Unix only- Without a lab host the script exits 0 with SKIP, offline-safe
- The official matrix is E01 to E21, E10 to E14 cover SCP, E17 and E18 cover SFTP checksum and trees, E19 covers the reduced
vps list --selectitem against the published schema, E20 covers--selectwith--limitand its stderr accounting, and E21 rotates a live profile into the store with--force, proves the round-trip across two processes and proves the inventory written before the rotation stays readable
- On Windows run
py -3 scripts/e2e_real_ssh.py ... - Point at another binary with
--bin, the default istarget/release/ssh-cli - Prefer a local sshd or a throwaway VPS and avoid auth-failure storms on production because of fail2ban
- The catalog of suites in TESTING.md