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 0 picks a free loopback port and opens your browser with a one-time tokenized URL. The token is never printed to the terminal.
  • --credential ALIAS opens the browser directly on that server's edit dialog with the new-credential form expanded — the shortest path to entering a password after server add.
  • --no-open requires --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.
  • --status reports the UI process started by hoploom web or hoploom ui (pid, port, start time). Exit code 0 while it runs, 1 otherwise; stale records are cleaned up automatically.
  • --stop terminates that process and deletes its --url-file if one was written. It is idempotent: exit code 0 whenever no UI is left running.
  • Host actions need to write two private locations: the host database directory (~/Library/Application Support/ssh-server-manager on macOS) and the managed OpenSSH config directory (~/.ssh by 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 ran hoploom 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.
  • list and show read saved metadata without DNS, SSH, or vault access. JSON includes nullable facts, last_success_at, last_resolved_ip, host_key, and manual geo, plus address_scope (public, private, lan, loopback, or unresolved) derived from the literal hostname.
  • --proxy-jump may repeat to build an ordered hop chain; self-jumps and cycles are rejected. Passing --proxy-jump during edit replaces the whole chain.
  • --tag may repeat to assign project, environment, or role tags. On edit, supplied tags replace the existing set; --clear-tags removes all.
  • add --password-credential LABEL creates 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 to hoploom ui --credential ALIAS.
  • import without --apply is 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.
  • test runs a real ssh handshake (NumberOfPasswordPrompts=1, StrictHostKeyChecking) and reports latency_ms plus one of: authentication-failed, host-key-untrusted, timeout, connection-refused, dns-failed, network-unreachable, ssh-not-found, ssh-failed. Exit code 1 on failure. Failed checks replace the latest test result while retaining last_success_at and the last successful diagnostic facts. Explicit tests also save a locally resolved IP when available and host-key fingerprint/state observations (verified, unknown, changed, or revoked). The local DNS result is not a guaranteed peer IP through ProxyJump, and saved fingerprints do not replace OpenSSH host-key verification. test never collects OS or hardware facts; use diagnose when 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.
  • diagnose runs 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 structured os, os_id, os_version, os_family, and package_manager fields (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; --json includes structured check results. A successful remote summary is retained as sanitized facts with observed_at, os_like, and a closed os_logo key for offline reads. Exit code 1 only 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.
  • note lets 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.
  • remove prompts 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-secret changes only the copy saved on this machine and never contacts a server. credential rotate ALIAS is 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 example hoploom connect ALIAS, then passwd), 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 credential LABEL (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-passphrase explicitly 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 rotate use the selected OpenSSH client. On Windows this includes both the inbox System32\OpenSSH client 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.

  • discover scans ~/.agents/skills, ~/.codex/skills, ~/.claude/skills, and directories in SSM_SKILLS_DIRS. It makes no network requests and does not install, register, or bind anything. The base hoploom transport skill (including its legacy ssh-server-manager name) is deliberately omitted and cannot be registered as host-bound guidance.
  • list shows registered skills; --server filters by a bound host. show accepts a case-insensitive name or skill ID and includes its host bindings.
  • add accepts a skill directory or its SKILL.md. It stores the normalized absolute SKILL.md path and frontmatter metadata, not the skill body. Repeated --server options register and bind atomically.
  • refresh re-reads one registered skill. --path moves the registration to another local skill directory or SKILL.md after validation.
  • attach and detach accept one or more positional server aliases and change all requested bindings atomically.
  • remove prompts unless --yes and 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 in registered_id;
  • conflict — its name or path disagrees with another candidate or registry entry;
  • invalid — its frontmatter could not be validated; the reason is in error.

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 APP finds the entry labelled app. Paths are unique per host too, so the same directory cannot be saved twice under different names.
  • add refuses 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.
  • resolve is 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 whose applies_to lists 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:PATH splits on the first colon, so remote filenames may contain colons. A Windows drive letter is never read as an alias: C:\Users\me is local even though C is a legal alias.
  • A destination that is an existing directory receives the remote filename, the way cp behaves. An existing local file is never overwritten without --force.
  • Without --json the transfer streams sftp's own progress meter. With --json it 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; --json reports 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 fusermount on Linux, SSHFS-Win plus WinFsp on Windows. Run hoploom doctor first — the mount row 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.
  • --list reads the operating system's mount table rather than a stored list, so unmounting in Finder or with umount can never leave it out of date.
  • --read-only is 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
  • SPEC is OpenSSH's own spelling, and the host half resolves on the remote side: --local 15432:localhost:5432 sends this machine's port 15432 to localhost:5432 as the target host sees it. --remote publishes a port on the host back here; --dynamic opens a SOCKS proxy.
  • The listening address defaults to 127.0.0.1. Anything else is deliberate: --local 0.0.0.0:15432:localhost:5432 exposes 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-local needs no alias; ALIAS:NAME also works.
  • open waits 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_open with 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.
  • --foreground holds the terminal until Ctrl+C, which is handy when you want the connection's messages in front of you.
  • status reports saved profiles joined with live processes: running: true means this machine has the forward, and orphan: true marks 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 with hoploom server test ALIAS first 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 --apply is given; the preview lists create, update (with the differing field names), and unchanged per 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 newer version are 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 list prints the newest events first; --alias, --event, --since, and --limit narrow it. --json returns {"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 path prints 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 becomes audit.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 ...]
  • connect opens an interactive shell and requires a real terminal. Without a TTY (pipes, agent tool calls, cron) it fails fast with exit code 2 and points to exec instead of hanging.
  • With OpenSSH's normal ask policy, 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 with yes or 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 — write exec web1 -- ls -la /var/log, not a single string.
  • --shell accepts exactly one string and runs it via remote sh -lc; use it for pipelines, redirects, globs, and && chains.
  • --stdin streams UTF-8 text; --stdin-binary streams raw bytes.
  • --reuse N enables OpenSSH ControlMaster for N seconds so consecutive exec calls 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. --json adds stdout, stderr (redacted), latency_ms, and error_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.
  • --log cannot 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 mosh client on this machine and mosh-server on the host. hoploom doctor has a mosh row 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=yes stays on.
  • Mosh needs a UTF-8 locale; when the terminal has none, LC_ALL=en_US.UTF-8 is 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,city per line (198.51.100.0/24,DE,Frankfurt), or start_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 optional geoip2 package (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 --apply the 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-command takes a glob matched against the whole command line, or against argv[0]: --allow-command uptime allows exactly that, and --allow-command 'systemctl status *' allows that shape only.
  • --allow-exec without any --allow-command is 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.1 and refuses a request whose Host header is not a loopback name; there is no address to configure away.
  • Browser origins. Any request carrying an Origin header is refused with 403, 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-file writes 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 gets 202 with 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.

Edit this page on GitHub