hoploom command reference#
The canonical command is hoploom, installed by the hoploom package.
serverctl remains a silent compatibility entry point; its arguments,
exit codes and JSON behavior are the same. See migration.
All commands accept --json for machine-readable output unless noted.
Errors exit with code 2 and, under --json, print
{"ok": false, "error": "<Type>", "message": "..."} on stdout.
Global#
hoploom --version
hoploom doctor [--json]
doctor checks: hoploom version, platform (and whether connection reuse
is available), database path, ssh / sftp / ssh-keygen presence and client version,
original and managed config paths, vault backend safety, the Python dependencies
needed by the UI, and the two pieces of local state the newer pages read —
tunnels (runtime directory writable, and whether a process command line can
actually be read here, which is what separates a live forward from an orphan)
and audit (log path, writability, how many segments exist and how large they
are, and the rotation threshold). Exit code is 0 only if every check passes.
For agent-driven installation, that exit code means the local installation
is healthy; it is not the end of first-run onboarding and does not prove any
remote host login works. After a successful doctor, an installing agent
should read hoploom server list --json, summarize the real inventory, and
give the user a few natural-language examples using those aliases. Do not add
remote tests only for onboarding or turn the handoff into a CLI tutorial.
Web UI#
hoploom web [--port PORT] [--no-open --url-file PATH]
hoploom web --credential ALIAS [--port PORT] [--no-open --url-file PATH]
hoploom web --status [--json]
hoploom web --stop [--json]
hoploom ui [--port PORT] [--no-open --url-file PATH]
hoploom ui --credential ALIAS [--port PORT] [--no-open --url-file PATH]
hoploom ui --status [--json]
hoploom ui --stop [--json]
web names the browser shell explicitly. ui remains a compatibility alias;
both use the same bundled React frontend, options, and lifecycle record. Launching a
second managed runtime fails with guidance to check its status or stop it, even
when two launches start at the same moment.
- Default
--port 0picks a free loopback port and opens your browser with a one-time tokenized URL. The token is never printed to the terminal. --credential ALIASopens the browser directly on that server's edit dialog with the new-credential form expanded — the shortest path to entering a password afterserver add.--no-openrequires--url-file; the URL is written to a mode-600 file that is deleted once the token is used or the server stops.- If an explicit port is busy the command fails fast; retry with
--port 0. --statusreports the UI process started byhoploom weborhoploom ui(pid, port, start time). Exit code0while it runs,1otherwise; stale records are cleaned up automatically.--stopterminates that process and deletes its--url-fileif one was written. It is idempotent: exit code0whenever no UI is left running.- Host actions need to write two private locations: the host database
directory (
~/Library/Application Support/ssh-server-manageron macOS) and the managed OpenSSH config directory (~/.sshby default). macOS attaches Full Disk Access to the application that starts the command — a terminal window you typed into, or the agent app (ChatGPT/Codex, Claude, an IDE) when a tool launch ranhoploom web. Grant that app under System Settings › Privacy & Security › Full Disk Access, quit and reopen it, then relaunch the UI. When the managed config directory alone is unavailable, remote commands render the config in the private cache and keep working; when the database is unavailable nothing can record results, so the launch prints a warning and the Files view reports it instead of failing each request.
Servers#
hoploom server list [--json]
hoploom server show ALIAS [--json]
hoploom server add ALIAS --hostname HOST --username USER [--port PORT]
[--credential LABEL|ID | --password-credential LABEL] [--proxy-jump ALIAS ...] [--tag TAG ...] [--notes TEXT]
hoploom server edit ALIAS [--new-alias A] [--hostname H] [--username U]
[--port P] [--credential LABEL|'none'] [--proxy-jump ALIAS ...]
[--tag TAG ... | --clear-tags] [--notes TEXT]
hoploom server remove ALIAS [--yes]
hoploom server import [--config PATH] [--apply] [--overwrite] [--json]
hoploom server test ALIAS [--timeout SECONDS] [--json]
hoploom server diagnose ALIAS [--timeout SECONDS] [--json]
hoploom server note ALIAS (--text TEXT [--append] | --clear) [--json]
Notes:
- Aliases are case-insensitively unique; whitespace, shell metacharacters, and OpenSSH wildcards are rejected.
listandshowread saved metadata without DNS, SSH, or vault access. JSON includes nullablefacts,last_success_at,last_resolved_ip,host_key, and manualgeo, plusaddress_scope(public,private,lan,loopback, orunresolved) derived from the literal hostname.--proxy-jumpmay repeat to build an ordered hop chain; self-jumps and cycles are rejected. Passing--proxy-jumpduringeditreplaces the whole chain.--tagmay repeat to assign project, environment, or role tags. Onedit, supplied tags replace the existing set;--clear-tagsremoves all.add --password-credential LABELcreates a password credential with the hidden local prompt and attaches it in one step. It requires a real terminal; without a TTY it fails fast and points tohoploom ui --credential ALIAS.importwithout--applyis always a preview. Conflicting aliases are skipped unless--overwrite. Overwrite preserves saved credential bindings, notes, tags, directories and Skill bindings. Preview and apply return authentication warnings with the selected source config and IdentityFile paths; import does not create managed credentials, read private-key contents or verify login. The source config remains an OpenSSH fallback, including when selected with--config. Test explicitly before treating an imported host as usable.testruns a realsshhandshake (NumberOfPasswordPrompts=1, StrictHostKeyChecking) and reportslatency_msplus one of:authentication-failed,host-key-untrusted,timeout,connection-refused,dns-failed,network-unreachable,ssh-not-found,ssh-failed. Exit code1on failure. Failed checks replace the latest test result while retaininglast_success_atand the last successful diagnostic facts. Explicit tests also save a locally resolved IP when available and host-key fingerprint/state observations (verified,unknown,changed, orrevoked). The local DNS result is not a guaranteed peer IP through ProxyJump, and saved fingerprints do not replace OpenSSH host-key verification.testnever collects OS or hardware facts; usediagnosewhen those are needed. For an explicitly requested fleet backfill in the UI, select the hosts and choose Collect host info (1-64 hosts, concurrency 1-4). It runs the same read-only system probe without the SFTP check. Failed collection retains prior facts; the CLI remains single-host for diagnosis. The browser UI also lazily observes a host after a deliberate name hover, with field loading states, a 60-second cache and a 30-second failure cooldown. Opening the inventory/details, restoring focus, and hidden details never collect. Hide details keeps fixed masked columns and disables copying.diagnoseruns read-only checks for the local profile, SSH handshake, SFTP home-directory access, and a fixed remote system-summary command. The result identifies the operating system across Linux distributions (os-release with legacy-release fallbacks), macOS (sw_vers), BSDs, and Windows OpenSSH hosts, reporting structuredos,os_id,os_version,os_family, andpackage_managerfields (e.g.debian/apt,rhel/dnf,suse/zypper,arch/pacman,alpine/apk,macos/brew) alongside kernel, CPU, uptime/load, memory, and root-disk details when the host exposes them. It never returns credentials or raw remote output;--jsonincludes structured check results. A successful remote summary is retained as sanitizedfactswithobserved_at,os_like, and a closedos_logokey for offline reads. Exit code1only when at least one check fails.- Changing a host's hostname or port clears saved observations and test history for the old endpoint. User notes, tags, and manual geography remain. Partial metadata updates and ProxyJump validation run within one SQLite write transaction so concurrent edits retain independent changes.
notelets a user or agent set, append, or clear the server's human-readable note without changing its SSH connection settings. Notes are local metadata; never put passwords, key passphrases, or other secrets in them.removeprompts unless--yes; in non-interactive contexts (no TTY) it fails closed instead of assuming consent.
Credentials#
hoploom credential list [--json]
hoploom credential add-password LABEL
hoploom credential add-key LABEL --key-path PATH [--store-passphrase]
hoploom credential add-agent LABEL
hoploom credential edit LABEL|ID [--label NEW] [--key-path PATH]
[--replace-secret] [--replace-passphrase] [--clear-passphrase]
hoploom credential remove LABEL|ID [--yes]
hoploom credential rotate ALIAS [--new-label LABEL] [--timeout SECONDS] [--json]
Notes:
-
Two different "password changes":
credential edit --replace-secretchanges only the copy saved on this machine and never contacts a server.credential rotate ALIASis for a host whose login password is changing. It takes the new password at a hidden prompt, waits while you change it on the server with the account's own tools (for examplehoploom connect ALIAS, thenpasswd), proves a fresh password-only login with it, and only then saves it. Press Enter to check, Enter again to re-check after a rejection, or Ctrl-C to stop; until a login succeeds the saved password is untouched. A rejection is followed by one check of the saved password, so the message says whether the server still has the old one. When another attempt cannot help — the server rejects both passwords, or a jump host rejects its own saved credential (named in the message) — the command stops instead of offering a retry, so it does not run the account into a lockout rule. If the host or its credential binding changes during the wait, nothing is saved. When other hosts share the credential they keep the old password, and ALIAS is moved to a new credentialLABEL (ALIAS)(or--new-label). It needs a terminal and exits 2 without one; it exits 1 when stopped without saving. -
Secrets are always collected through a hidden local prompt (
getpass), never through arguments or environment variables. - There is no command that prints a stored secret. Reveal requires the web UI plus passkey / master-password re-authentication.
- Private keys are locally parsed before saving to verify format and passphrase and cache public metadata. Only the key path and metadata are stored; private-key contents are never displayed or copied to the database. Removing the credential never deletes the file. Unsupported formats fail explicitly.
- Blank replacement input preserves a saved password/passphrase. Whitespace is
significant;
--clear-passphraseexplicitly clears a saved passphrase. - CLI removal still refuses credentials referenced by any server. The Web UI offers a separate reference preview and explicit Detach and delete action. Its durable journal reports committed deletion with pending vault/config cleanup accurately and supports retries across runtime restarts.
- Independent credential testing, key Browse/Inspect/Validate and public agent
identities are Web/API features, without new CLI subcommands. A Web credential
test temporarily chooses that credential for any saved host, runs no remote
command, and changes neither the host binding nor its health record. Agent
tests use only agent keys without default-key fallback; ordinary connections
retain managed OpenSSH behavior. See the Web UI.
A new credential need not be bound before testing. Inconclusive tests leave
TCP/host-key checkpoints unconfirmed rather than reporting success or blaming
the target credential; a rejected jump may prevent reaching the target.
Credential tests and the pre-save check in
credential rotateuse the selected OpenSSH client. On Windows this includes both the inboxSystem32\OpenSSHclient and Git/MSYS OpenSSH, even when a jump host makes them run local helper commands.
Host-bound skills#
hoploom skill discover [--json]
hoploom skill list [--server ALIAS] [--json]
hoploom skill show NAME|ID [--json]
hoploom skill add PATH [--server ALIAS ...] [--json]
hoploom skill refresh NAME|ID [--path PATH] [--json]
hoploom skill attach NAME|ID SERVER [SERVER ...] [--json]
hoploom skill detach NAME|ID SERVER [SERVER ...] [--json]
hoploom skill remove NAME|ID [--yes] [--json]
hoploom skill resolve ALIAS [ALIAS ...] [--json]
Host-bound skills let environment-specific operating guidance follow the managed hosts where it is relevant. The relationship is many-to-many: one skill can cover a group of related nodes, and one host can use several skills. Nothing is hard-coded for a particular provider, cluster, or alias.
discoverscans~/.agents/skills,~/.codex/skills,~/.claude/skills, and directories inSSM_SKILLS_DIRS. It makes no network requests and does not install, register, or bind anything. The basehoploomtransport skill (including its legacyssh-server-managername) is deliberately omitted and cannot be registered as host-bound guidance.listshows registered skills;--serverfilters by a bound host.showaccepts a case-insensitive name or skill ID and includes its host bindings.addaccepts a skill directory or itsSKILL.md. It stores the normalized absoluteSKILL.mdpath and frontmatter metadata, not the skill body. Repeated--serveroptions register and bind atomically.refreshre-reads one registered skill.--pathmoves the registration to another local skill directory orSKILL.mdafter validation.attachanddetachaccept one or more positional server aliases and change all requested bindings atomically.removeprompts unless--yesand is blocked while any host is still bound; detach those hosts first. It removes only the registry entry, never the local skill files. In a non-interactive context it fails closed without--yes.- Names are case-insensitively unique. Discovery reports same-name candidates at different paths as conflicts, and registration refuses to guess between them.
discover --json returns {"candidates": [...], "conflicts": [...]}. Every
candidate has path and status; a valid candidate also has name and
description. Candidate status is:
available— valid local skill that is not registered;registered— its name and path match a registry entry, whose ID is inregistered_id;conflict— its name or path disagrees with another candidate or registry entry;invalid— its frontmatter could not be validated; the reason is inerror.
Each conflict has a type. A name conflict reports name and paths; a path
conflict reports path and names. Conflicts against an existing registry
entry also include registered_id. Discovery status describes candidates,
not whether a registered skill is currently usable.
Agents call resolve only after selecting the target alias or aliases. Its
JSON has both a per-host view and a deduplicated view:
{
"ok": true,
"hosts": [
{"id": "...", "alias": "gpu-lab-01", "skills": [{"id": "...", "name": "gpu-ops", "path": "/.../gpu-ops/SKILL.md", "description": "...", "status": "ready"}]}
],
"skills": [
{"id": "...", "name": "gpu-ops", "path": "/.../gpu-ops/SKILL.md", "description": "...", "status": "ready", "applies_to": ["gpu-lab-01"]}
]
}
The response contains metadata and local paths, never skill bodies. Each
skill's applies_to list is authoritative for a multi-host task. Current file
status is ready, missing, invalid, or name_mismatch; any non-ready
registration makes ok false and exits 1 instead of silently substituting a
different path. skill list and both views in skill resolve include a
status_message for non-ready entries; ready entries omit it. skill show
returns stored registry metadata and host bindings without calculating
readiness.
Saved working directories#
hoploom path list ALIAS [--json]
hoploom path add ALIAS PATH --label LABEL [--note TEXT] [--json]
hoploom path edit ALIAS LABEL|ID [--label NEW] [--path NEW] [--note TEXT] [--json]
hoploom path remove ALIAS LABEL|ID [--yes] [--json]
hoploom path resolve ALIAS [ALIAS ...] [--json]
A saved working directory records where you work on a host, so the browser and the agent both stop guessing. Unlike skills, the relationship is one-to-many: a path is a claim about one machine's filesystem, and removing the host removes its saved directories.
- Labels are unique per host, case-insensitively, and resolve the same way
skill names do —
hoploom path edit web1 APPfinds the entry labelledapp. Paths are unique per host too, so the same directory cannot be saved twice under different names. addrefuses an empty path. Everywhere else an empty remote path means the home directory; a saved directory has to name something, so write~explicitly if that is what you mean.resolveis the command an agent should reach for before touching files. It answers "where does this fleet keep its application" in one read, with no connection: identical paths across hosts collapse into a single entry whoseapplies_tolists every host that has it.- Ordering is by most recent use, then alphabetical. Directories that have never been opened stay in alphabetical order rather than jumping to the top.
Example resolve output:
{
"ok": true,
"hosts": [
{"id": "...", "alias": "web1", "paths": [{"label": "app", "path": "/srv/app", "notes": "", "last_used_at": null}]},
{"id": "...", "alias": "web2", "paths": [{"label": "app", "path": "/srv/app", "notes": "", "last_used_at": null}]}
],
"paths": [
{"path": "/srv/app", "label": "app", "applies_to": ["web1", "web2"]}
]
}
File transfer#
hoploom cp SOURCE DEST [--force] [--timeout SECONDS] [--json]
hoploom get ALIAS:REMOTE [LOCAL] [--force] [--timeout SECONDS] [--json]
hoploom put LOCAL ALIAS:REMOTE [--force] [--timeout SECONDS] [--json]
Transfers go through the same OpenSSH SFTP path as everything else, so vault
passwords and key passphrases are injected for you. This is the reason to
prefer them over scp and rsync, which prompt because they never see the
vault.
- Exactly one side names a host as
ALIAS:PATH. Host-to-host copies are refused rather than silently staged through this machine. ALIAS:PATHsplits on the first colon, so remote filenames may contain colons. A Windows drive letter is never read as an alias:C:\Users\meis local even thoughCis a legal alias.- A destination that is an existing directory receives the remote filename, the
way
cpbehaves. An existing local file is never overwritten without--force. - Without
--jsonthe transfer streams sftp's own progress meter. With--jsonit is captured and reported as bytes and duration. - One file per invocation, or a whole tree with
--recursive(see below).
Browser transfers use a separate 100 MiB limit, advertised by
/api/v1/runtime. HTTP uploads refuse existing destinations by default;
explicit overwrite=true permits an authorized replacement. The CLI command
surface is unchanged: CLI transfers have no web size cap, and CLI put can
replace an existing remote file. Default web uploads require SFTP hard-link
support and fail safely when unavailable; web downloads refuse an unverifiable
symlink target size. See the web UI for the versioned file API.
Recursive transfer#
hoploom put --recursive LOCAL_DIR ALIAS:REMOTE_DIR
hoploom get --recursive ALIAS:REMOTE_DIR [LOCAL_DIR]
hoploom cp --recursive ...
--recursive copies a whole tree over the same SFTP path, credentials, and
quoting as a single file, and creates any missing destination directory.
- Symlinks anywhere inside the tree are refused rather than followed. A link can point outside the tree you think you are copying (a private key, a mounted volume), and silently following it is the classic way to ship something you never meant to. Resolve the target and copy it explicitly if that is what you want.
- One call is capped at 4000 entries. Past that the batch stops being the right
tool: archive remotely (
hoploom exec ALIAS -- tar czf /tmp/tree.tgz DIR), transfer the archive, and unpack it here. - Download refuses anything that is neither a file nor a directory (a socket, a device), and upload refuses a symlinked root directory.
- Progress and errors come from sftp itself;
--jsonreports files, directories, bytes, and latency instead.
Mounting#
hoploom mount ALIAS[:REMOTE] [MOUNTPOINT] [--read-only] [--json]
hoploom mount --list [--json]
hoploom unmount ALIAS|MOUNTPOINT [--json]
Mounting exposes a remote directory to Finder, Explorer, and your editor through sshfs, reusing the managed config and the same credential boundary as every other connection.
- This is the only feature that needs software we cannot install for you:
macFUSE on macOS, libfuse and
fusermounton Linux, SSHFS-Win plus WinFsp on Windows. Runhoploom doctorfirst — themountrow names the missing piece rather than failing at mount time. - The default mount point is
~/mnt/ALIAS. It is created if missing and refused if it already has contents. --listreads the operating system's mount table rather than a stored list, so unmounting in Finder or withumountcan never leave it out of date.--read-onlyis worth using whenever you only need to read; it removes the possibility of an editor writing to production by accident.
Port forwarding#
hoploom tunnel list [--server ALIAS] [--json]
hoploom tunnel show NAME|ID|ALIAS:NAME [--json]
hoploom tunnel add NAME --server ALIAS (--local SPEC | --remote SPEC | --dynamic SPEC)
[--note TEXT] [--json]
hoploom tunnel edit NAME [--name NEW] [--server ALIAS] [--note TEXT]
[--spec SPEC] [--kind KIND] [--json]
hoploom tunnel open NAME [--timeout SECONDS] [--foreground] [--json]
hoploom tunnel close NAME [--json]
hoploom tunnel status [--server ALIAS] [--json]
hoploom tunnel remove NAME [--yes] [--json]
A tunnel is a saved forwarding profile plus, when you ask for it, one detached
ssh -N process that realises the profile. Saving and opening are separate
steps: add records the intent without contacting the host, open starts it,
close stops it.
hoploom tunnel add db1-local --server db1 --local 15432:localhost:5432
hoploom tunnel open db1-local # opened db1-local (...) for db1 (pid 41902)
hoploom tunnel status # db1-local db1 local 127.0.0.1:15432:localhost:5432 running pid=41902
hoploom tunnel close db1-local
SPECis OpenSSH's own spelling, and the host half resolves on the remote side:--local 15432:localhost:5432sends this machine's port 15432 tolocalhost:5432as the target host sees it.--remotepublishes a port on the host back here;--dynamicopens a SOCKS proxy.- The listening address defaults to
127.0.0.1. Anything else is deliberate:--local 0.0.0.0:15432:localhost:5432exposes the forward to your network. Because that is a real exposure, it is never implied. - Names are unique across all hosts, so
tunnel open db1-localneeds no alias;ALIAS:NAMEalso works. openwaits a few seconds for ssh to authenticate. An unknown host key, a refused bind, or a bad credential fails here with ssh's own message rather than leaving a silent half-open tunnel; the command's exit code is non-zero.- Opening an already-open profile reports
already_openwith the original pid instead of starting a duplicate. Closing a profile that is not running is a no-op, so teardown scripts can be re-run. --foregroundholds the terminal until Ctrl+C, which is handy when you want the connection's messages in front of you.statusreports saved profiles joined with live processes:running: truemeans this machine has the forward, andorphan: truemarks a process whose profile was deleted. Runtime records live in the private runtime directory and are pruned when the process is gone, so tunnels never look open after a reboot or in a database copied from another machine.- Host-key verification is enforced (
StrictHostKeyChecking=yes) and never relaxed for a tunnel. Authenticate withhoploom server test ALIASfirst when a host is new, because a background forward has no terminal in which to confirm a key. - Removing a host stops its tunnels before deleting the profiles.
Export and import#
hoploom export [--output PATH] [--json]
hoploom import [FILE|-] [--apply] [--overwrite] [--json]
export writes the inventory as a deterministic JSON document: hosts sorted by
alias, a fixed key order, no timestamps. Exporting the same inventory twice
produces identical bytes, so a commit in git shows what actually changed.
hoploom export --output inventory.json
hoploom import inventory.json # preview: what would change
hoploom import inventory.json --apply # write it
hoploom export | ssh other-host hoploom import --apply # or pipe it
What travels: alias, hostname, port, username, ProxyJump hops, tags, notes,
manual geography, saved working directories, and forwarding profiles.
What never travels:
- Secrets. They live in this machine's OS keychain and are not in the file.
- Credential ids. A host carries its credential label; the id names a row in one database. On import the label is matched against the credentials this machine already has — an unknown label produces a warning and the host is imported without a credential, never with an invented one.
- Remote observations. Facts, last check results, resolved IPs, and host keys describe one machine's view of one moment, so they are not exported.
Import semantics:
- Nothing is written unless
--applyis given; the preview listscreate,update(with the differing field names), andunchangedper host. - An existing host is left alone unless
--overwrite. - Saved directories and forwarding profiles are merged, not replaced: a file that omits one never deletes what exists here.
- A bad entry is reported and the rest of the file still applies; the exit code is non-zero when anything failed.
- Unknown fields, a different
format, or a newerversionare refused, so a typo cannot be silently ignored. - The written file is mode 600 — it names your hosts and users, even though it holds no secrets. Widen it yourself if your repository is public.
Audit log#
hoploom audit list [--limit N] [--alias ALIAS] [--event KIND] [--since TIMESTAMP] [--json]
hoploom audit path [--json]
hoploom audit rotate [--force] [--json]
Privileged actions are recorded locally, whether the command came from your shell or from an agent:
| event | when |
|---|---|
exec / exec_result |
a remote command was attempted, and what it returned |
reveal / reveal_denied |
a stored secret was handed out, or a reveal was refused |
connect |
an interactive session was opened |
tunnel_open / tunnel_close |
a saved forward was started or stopped |
hoploom audit listprints the newest events first;--alias,--event,--since, and--limitnarrow it.--jsonreturns{"ok", "path", "count", "events"}for scripts and agents.- The log lives beside the database as
audit.log(mode 600, one JSON object per line).audit pathprints the exact file. - It never stores a secret value. A reveal is recorded by credential label and
slot; credentials inside a recorded command line are masked before the line is
written (
--password=X,MYSQL_PWD=X,Authorization: Bearer ..., and similar shapes). - If the file cannot be written, the command still runs and a single warning names the problem — the audit log must never be the reason work cannot happen.
- The log rotates itself: once the live file reaches 5 MiB (
SSM_AUDIT_MAX_BYTES) it becomesaudit.log.1, older segments shift up, and three are kept. Reading always spans every segment, so rotating never hides history.audit rotate [--force]does the same on demand.
Connect and execute#
hoploom connect ALIAS [--timeout SECONDS] [--mosh] [--log] [--log-path FILE]
hoploom exec ALIAS [--shell] [--stdin | --stdin-binary]
[--timeout SECONDS] [--reuse SECONDS] [--json] -- COMMAND [ARG ...]
connectopens an interactive shell and requires a real terminal. Without a TTY (pipes, agent tool calls, cron) it fails fast with exit code2and points toexecinstead of hanging.- With OpenSSH's normal
askpolicy, the first connection shows the server's host-key fingerprint in that terminal, including when a saved password or passphrase is supplied by AskPass. Check it against a trusted source before confirming withyesor the matching fingerprint. A missing terminal or declined confirmation fails closed. Stricter SSH policies still apply; changed or revoked keys require investigation rather than automatic removal. - Everything after
--is the remote command. Without--shell, each argument is quoted for a POSIX remote shell — writeexec web1 -- ls -la /var/log, not a single string. --shellaccepts exactly one string and runs it via remotesh -lc; use it for pipelines, redirects, globs, and&&chains.--stdinstreams UTF-8 text;--stdin-binarystreams raw bytes.--reuse Nenables OpenSSH ControlMaster forNseconds so consecutiveexeccalls skip re-authentication. macOS/Linux only; on Windows a warning is printed and the option is ignored.- Exit code mirrors the remote command's return code.
--jsonaddsstdout,stderr(redacted),latency_ms, anderror_code.
Session transcripts#
hoploom connect ALIAS --log records the session to a mode-600 file under
sessions/ in the data directory, named <timestamp>-<alias>.log;
--log-path FILE chooses the file. A warning naming the destination is printed
before the session starts.
- Only the terminal's output stream is recorded. A password typed at a prompt with echo turned off is never echoed, so it never reaches the file — while the commands you type do, because the remote terminal echoes them.
- Terminal control sequences are stripped (so the file reads and diffs cleanly) and credential-shaped values in a line are masked. That masking is best effort: treat a transcript as containing whatever the session displayed.
- It needs a POSIX pty. On Windows, and in environments that deny a pty, the command says so instead of recording nothing.
--logcannot be combined with--mosh; record the terminal separately.- Starting a recording is itself written to the audit log.
Mosh sessions#
hoploom connect ALIAS --mosh runs a mosh session instead of plain ssh, for
roaming laptops and high-latency or lossy links where a TCP session would stall.
- It needs the
moshclient on this machine andmosh-serveron the host.hoploom doctorhas amoshrow that names what is missing; the command fails rather than quietly falling back to ssh, because silently changing the transport is not what "connect with mosh" asked for. - Mosh is not a second authentication path. It bootstraps through the managed
ssh config with the same askpass environment, so a vault password or key
passphrase works unchanged, and
StrictHostKeyChecking=yesstays on. - Mosh needs a UTF-8 locale; when the terminal has none,
LC_ALL=en_US.UTF-8is set for the session rather than leaving mosh to fail on it. - Not offered on Windows.
Host locations (local GeoIP)#
hoploom geo status [--json]
hoploom geo resolve HOST [HOST ...] [--apply] [--overwrite] [--json]
hoploom geo resolve --all [--apply] [--overwrite] [--json]
geo resolve turns a host's address into a country (and city when the data has
one) using a local file. There is no web service call, no bundled database,
and no lookup while reading the inventory — opening a host never resolves a name
or reads a geo file.
Point it at one of:
SSM_GEOIP_CSV— a CSV of ranges:cidr,country_code,cityper line (198.51.100.0/24,DE,Frankfurt), orstart_ip,end_ip,country_code,city. A header row,#comments, blank lines, a BOM, and IPv6 are all accepted.SSM_GEOIP_MMDB— a MaxMind-style.mmdb, read through the optionalgeoip2package (pip install geoip2). Nothing is downloaded; you supply the file.
By default the path is <data dir>/geoip.csv or <data dir>/geoip.mmdb, so
dropping a file there is enough. hoploom geo status and the doctor geoip
row report which backend is configured and what is missing.
Behaviour worth knowing:
- Without
--applythe lookup is only reported, never stored. - A stored lookup carries
"source": "geoip"; a value you set yourself carries"source": "manual". A lookup never overwrites a manual value — that needs--overwrite, because a human assertion should beat a guess. - A host that does not resolve is reported per host, and the exit code is non-zero; other hosts in the same run still get their result.
- GeoIP is a guess about routing, not about where your data lives. Say "reported as", not "is in".
MCP server#
For DeepSeek Harness, install the separately versioned dsh-hoploom plugin
with dsh plugin --profile web add dsh-hoploom. It discovers these MCP tools
from the local Hoploom executable and defaults to metadata-only access.
Remote checks and allowlisted execution are explicit plugin settings; disabled
tools must not be bypassed with a shell. See DeepSeek Harness.
Plugin 0.2.0 and later check existing installs and automatically prepares a private Python
runtime with the bundled backend when needed. The Hoploom sidebar shows setup
progress, then embeds the original host manager directly inside dsh. hoploom_setup provides status,
prepare and cancel actions in terminal sessions. Custom command overrides
remain explicit and must provide mcp serve.
hoploom mcp serve [--allow-remote] [--allow-exec] [--allow-command PATTERN]
[--http] [--port N] [--token-file PATH]
hoploom mcp tools [--allow-remote] [--allow-exec] [--allow-command PATTERN] [--json]
A thin Model Context Protocol server over the same core, for clients that cannot read an Agent Skill. Transport is MCP over stdio: one JSON-RPC 2.0 message per line, so nothing but protocol messages may go to stdout.
| tool | needs | notes |
|---|---|---|
list_hosts |
— | local metadata; filter by query or tag |
host_route |
— | ProxyJump hops for one host |
saved_paths |
— | the directories the user saved |
host_skills |
— | bound Skills metadata, never a body |
list_tunnels |
— | profiles and whether they are running |
runtime_capabilities |
— | mount/mosh support and this server's policy |
test_host |
--allow-remote |
contacts the host |
run_command |
--allow-exec + --allow-command |
argv array, no shell |
- Read-only by default: the read tools never open a connection or the vault.
- There is no reveal tool, and no tool result contains a secret.
--allow-commandtakes a glob matched against the whole command line, or againstargv[0]:--allow-command uptimeallows exactly that, and--allow-command 'systemctl status *'allows that shape only.--allow-execwithout any--allow-commandis refused at startup, because it would enable a tool that can never succeed.- Acting calls are audited under actor
mcp. - An unknown host key still cannot be confirmed through MCP; verify the host
once in a terminal with
hoploom server test ALIAS.
MCP over HTTP#
hoploom mcp serve --http adds a loopback HTTP endpoint for clients that
cannot spawn a process. It is the same server, the same policy and the same audit
trail as the stdio binding — only the transport differs.
hoploom mcp serve --http --token-file ~/.cache/mcp-token \
--allow-exec --allow-command 'uptime'
# MCP endpoint: http://127.0.0.1:PORT/mcp
What it refuses, by construction:
- Anything but loopback. It binds
127.0.0.1and refuses a request whoseHostheader is not a loopback name; there is no address to configure away. - Browser origins. Any request carrying an
Originheader is refused with403, so a web page cannot drive the server even if the token leaks into it. - A missing or wrong token. Every request needs
Authorization: Bearer <token>. The token is generated per launch and printed once to stderr;--token-filewrites it to a mode-600 file instead, which is what a client config should read. Treat the printed copy as a disclosure. - Everything but POST on
/mcp. There is no browse surface and no streaming endpoint; a notification gets202with no body.
The token is a cacheable, local-file secret, not an authorization system: it
decides whether a client may talk to this listener, while --allow-remote and
--allow-exec decide what it may ask for. Keep both narrow, and remember that
an HTTP client can hold the token in its config — prefer stdio where the client
can spawn a process.
Config#
hoploom config render [--json]
Re-renders the managed OpenSSH config (also done automatically after every mutation) and prints its path. The file is written atomically with user-only permissions. Imported hosts retain their selected source config; unrelated aliases and defaults use the original config. Source paths are formatted for the selected SSH client, including native Windows and Git/MSYS OpenSSH. SFTP explicitly uses the same SSH client. Your source configs are never rewritten.