Sick of typing out the full scp user@host:/some/long/path every time you want to upload a file? snd lets you save server presets so you can keep uploading files to specific folders without thinking about it. Define your targets once, then just snd your files there.
snd prod plugin.jar
# scp plugin.jar -> deploy@10.0.0.1:/opt/app/uploads
You can also group servers and send to all of them at once, get a confirmation prompt with file info before overwriting, and delete remote files with the same safety net.
git clone <repository-url> snd
cd snd
./install.shAdd completions to your shell rc file:
# Bash (~/.bashrc)source<(COMPLETE=bash snd)# Zsh (~/.zshrc)source<(COMPLETE=zsh snd)# Fish (~/.config/fish/config.fish)
COMPLETE=fish snd |sourceEvery upload, download, delete, and sync operation supports --dry-run. For
uploads, snd plan is a shorter dedicated form that resolves groups, aliases,
relative overrides, and wildcard paths without running scp:
snd plan prod build.jar
snd --dry-run get prod build.jar
snd --dry-run delete prod old.jar
snd --dry-run sync prod ./distMulti-target operations finish with a success/failure summary. Use --jobs N
to fan work out concurrently, --fail-fast to stop scheduling after a failure,
and --retries N for transient transfer failures.
snd --jobs 4 --retries 2 prod build.jarAdd --progress for start/completion timing and native transfer progress. Use
--audit-log FILE to append schema-versioned JSON Lines records for completed
operations:
snd --progress --audit-log .snd-audit.jsonl prod build.jar
snd audit .snd-audit.jsonl
snd audit .snd-audit.jsonl --last 50 --command send --failed--json provides machine-readable output for plans, transfer summaries,
doctor, find, diff, and resolved configuration.
The following typed options are passed safely to the underlying SSH transfer:
--preserve— preserve file modes and timestamps.-C/--compress— enable SSH compression.--limit KBIT/S— cap transfer bandwidth.-i/--identity KEY_FILE— select an SSH identity.-F/--ssh-config FILE— use an alternate SSH config.--atomic— upload regular files to temporary names and rename them into place only after successful transfer/verification.--verify— compare SHA-256 after transferring regular files. The remote needssha256sumorshasum.--resume— resume partial regular-file transfers using SFTP.
Before resuming, snd compares the existing partial file with the matching
prefix of the source. A mismatch is rejected instead of producing a corrupted
result. Atomic and resumable uploads also hold a remote destination lock while
the transfer is active; resumable downloads use a local lock.
Safe resume therefore requires sha256sum or shasum on the remote, just like
--verify.
These compose, so a guarded deployment can be run as:
snd --atomic --verify --jobs 4 prod build.jarDirect sends automatically snapshot the remote files or directories they are about to replace. The snapshot also records destinations that did not exist, so rollback removes newly created files instead of leaving them behind:
snd staging ./out/ExamplePlugin.jar
snd rollback staging # restore the previous ExamplePlugin.jar
snd rollback staging ExamplePlugin.jarOne snapshot covers the whole send, including sends with multiple inputs. Pass one or more destination names to restore only those files:
snd proxy first.jar second.jar third.jar
snd rollback proxy first.jar # second.jar and third.jar stay deployed
snd rollback proxy # restore the remaining two filesA named rollback searches backward for the newest unused snapshot containing
that filename, so it also works when the files were sent in separate commands.
Only the restored entries are consumed. A full rollback consumes every
remaining entry in the latest snapshot, so running it again restores the state
before the preceding send. Failed sends attempt an immediate restore and are
not added to the history. The newest 10 successful send snapshots are kept per
remote path by default; use --backup-keep N to change that (0 keeps all),
or --no-backup when rollback storage is not wanted. Metadata and payloads
live on the remote host under
$HOME/.local/share/snd/targets/<destination-hash>/backups. On a fresh
installation, rollback metadata starts there immediately; it is not first
created inside the destination. Nothing is added to a watched plugin directory
except the files being deployed.
For example, if an added path resolves to /plugins, the deployed JAR and its
rollback snapshot are kept separate:
/plugins/ExamplePlugin.jar # deployed file
$HOME/.local/share/snd/targets/<hash>/backups/... # rollback data
There is no /plugins/.snd directory.
Inspect every available transaction, or only the history for one destination name, without downloading the backed-up payloads:
snd history staging
snd history staging ExamplePlugin.jar
snd --json history stagingsnd diff compares local files with the names they would have at each remote
target. By default it uses file sizes; --hash performs a SHA-256 comparison.
snd diff prod ./build/app.jar
snd diff --hash prod ./build/app.jarsnd sync previews and then applies an rsync directory synchronization. It
requires rsync locally and remotely. Remote deletion is opt-in and always
shown in the preview before confirmation:
snd sync web ./dist
snd sync --delete web ./dist
snd --dry-run sync --delete prod ./distSync supports ordered include/exclude filters and automatically loads
LOCAL_DIR/.sndignore as an rsync exclude file when present:
snd sync --include '*.js' --exclude 'cache/' web ./dist
snd sync --ignore-file ./deploy.ignore web ./dist
snd sync --no-ignore web ./distInterrupted syncs retain reusable partial files under .snd-partial.
snd release uploads regular files into an isolated, versioned directory,
verifies every file, writes a completion marker, and then switches
<target>/.snd/current. Applications should serve or launch through that
symlink for activation and rollback to take effect.
snd release --release 2026-07-15 web ./dist/app.jar ./dist/config.json
snd releases web
snd rollback --release web # activate the recorded previous release
snd rollback --to 2026-07-15 webThe previous pointer is updated during both activation and rollback, so a
second snd rollback --release toggles back. Without --release or --to,
rollback first restores the latest direct send and falls back to the previous
versioned release when no direct-send snapshot exists. --keep N controls
release retention (default 5), while active and previous releases are always
protected. Release names are never reused unless --resume is explicitly
supplied for an incomplete release.
Named operations can be stored in snd.deploy.toml and applied together or by
name. File paths are resolved relative to the manifest:
version = 1
[deploy.web]
target = "prod"files = ["dist/app.jar", "dist/config.json"]
release = trueatomic = trueverify = truekeep = 5
[deploy.assets]
target = "cdn"files = ["dist/assets.tar"]
path = "uploads"snd apply
snd apply ./deploy/production.toml --name web
snd --dry-run --json apply --name websnd init creates a versioned .snd.toml in the current directory. The
nearest project configuration is layered over the global config, allowing a
repository to share deployment aliases without copying personal SSH settings.
snd init
snd config show --resolved # merged effective TOML
snd --json config show --resolved # merged effective JSON
snd config --paths # show global/project config locationsUse --local with configuration mutations to write the nearest project file
instead of the global configuration:
snd --local add staging deploy@staging /srv/app
snd --local add-path staging logs /var/log/app
snd --local add-group test stagingConfiguration utilities validate semantic references after parsing and open
the selected file with $VISUAL, $EDITOR, or vi:
snd config validate
snd config edit
snd --local config editGlobal configuration writes are atomic and retain the previous file as
servers.toml.bak.
Machine output is wrapped in a stable envelope:
{
"schema_version": 1,
"command": "send",
"ok": true,
"data": []
}Exit code 0 means success, 1 means an operation failed or diff found a
difference, and 2 is reserved by the CLI parser for invalid usage. Human
prompts and transfer diagnostics go to stderr when --json is active.
Inspect or clear the asynchronous remote-completion cache without finding its platform-specific directory manually:
snd cache show
snd cache clear
snd cache clear --older-than 7snd <server-or-group> [path-alias] <files...>
snd prod plugin.jar
snd staging build.tar.gz config.yml
snd web logs server.log # uses the "logs" path-alias on webIf the first positional matches a path-alias on the chosen server, that path is used instead of the server's default. Otherwise everything is treated as a file.
Need a path that isn't worth saving as an alias? Put it after the target:
snd web /tmp/release/ build.tar.gz
snd staging '~/inbox/' notes.md # quote to keep ~ literal for the remote
snd prod /opt/drop/ build.jar # group: every member uses /opt/dropThe same positional directory works with ls, cat, get/pull/fetch,
delete, and find. A trailing / makes the directory unambiguous beside file
arguments and is added automatically by completion. -p / --path remains
available for compatibility and for ambiguous cases such as a bare one-off path.
Prefix the override with ./ or ../ to resolve it relative to the resolved server path instead of replacing it entirely. With a group, each member resolves under its own base.
# web's default path is /var/www
snd web ./build/ app.jar # → u@h:/var/www/build
snd web ./logs/today/ error.log # → u@h:/var/www/logs/today
snd web ../shared/ release.tar # → u@h:/var/www/../shared (remote resolves)# Group "prod" with web=/var/www and api=/srv/api
snd prod ./build/ app.jar
# → web sends to /var/www/build, api sends to /srv/api/build./ and ../ directories resolve relative to each target's configured base.
Anything else is used verbatim. For uploads, an existing local file still wins
over positional-directory detection so multi-file sends keep working.
Before scp runs, snd SSHs to each target and stats the destination. If a file with the same name already exists you get its size, modified time, and full remote path, then a confirmation prompt:
$ snd prod build.tar.gz
[prod] deploy@10.0.0.1:/opt/app/uploads — 1 file(s) already exist:
/opt/app/uploads/build.tar.gz 12.3 MB 2026-04-30 09:14:02 +0000
Overwrite? [y/N] y
scp build.tar.gz -> deploy@10.0.0.1:/opt/app/uploads
Flags:
-f/--force— skip the prompt and overwrite without asking.--no-check— skip the SSH stat entirely (faster on slow links, no prompt).
The stat call reuses your existing SSH multiplexing socket (~/.ssh/snd-...), so it doesn't add a fresh connection.
snd doctor checks cached SSH resolution. snd doctor --connect additionally
connects to every server, shows effective SSH host/user/port information,
checks that the default path exists and is writable, verifies required remote
tools, and reports filesystem capacity.
snd doctor --connect
snd --json doctor --connectsnd ls <server> [path] runs ls -lhA over SSH — handy for peeking at a folder
(e.g. the current rotating instance dirs and their IDs) before you send. Without
a path it uses the server's configured default. With no server, snd ls still
lists your configured servers and groups.
snd ls # your servers + groups (same as `snd list`)
snd ls app # ls the server's default path
snd ls app logs # ls a named path-alias
snd ls app node # a glob path lists each matching dir
snd ls app '/srv/app/instances'# ls a one-off pathA group lists every member, labelled per server. Bare paths resolve under each
server's configured path; paths containing / and paths beginning with ~ are
used verbatim. -p / --path remains available for compatibility.
snd cat <server> <files...> prints remote files to stdout (cat over SSH) —
no download, pipe- and redirect-friendly:
snd cat prod config.yml # bare name → under the server path
snd cat prod /etc/nginx/nginx.conf # /-or-~ path → verbatim
snd cat prod logs latest.log # from a path-alias
snd cat -p /var/log prod app.log # one-off path override
snd cat prod config.yml | grep host # it's just stdoutPaths resolve exactly like get/delete (bare names under the server path,
anything with / or ~ verbatim, -p overrides, globs expand). For a group,
each server's output is printed under a [server] host:path header; a single
server is a clean passthrough with no header.
Output is colored automatically when stdout is a terminal. snd ls colors the
long listing locally, while find --grep uses grep's standard color support.
snd cat streams through a locally installed bat (or batcat) for syntax
highlighting; the remote only needs standard ls, grep, and cat. If bat
is unavailable, or output is piped/redirected, the content is passed through
unchanged. Set NO_COLOR=1 to disable color explicitly.
snd get <server-or-group> [path-alias] <files...>
snd pull <server-or-group> [path-alias] <files...># alias: pull / fetch# Pull a file from web's default path into the current dir
snd get web build.tar.gz
# Pull from a named path-alias
snd get web logs error.log
# Pull from a one-off directory
snd get web /var/log/nginx/ error.log
# Absolute / `/`-containing / `~`-prefixed paths are taken as-is
snd get web /etc/nginx/nginx.conf
# Choose a local destination directory
snd get -o ./downloads web build.tar.gz
# Recursive (directories)
snd get -r web stale-buildBare names resolve under the server's path; anything with / or ~ is used
verbatim. A positional directory changes the base for the files that follow.
Remote wildcard file operands are rejected because their expanded local
destinations cannot be checked safely for overwrites; request explicit files.
Escape a glob character with a backslash when it is part of the literal remote
filename (for example, snd get web 'report\[1\].txt').
When the target is a group, downloads land in <dest>/<server-name>/ so files from each member don't collide:
snd get -o ./dl prod build.tar.gz
# → ./dl/web/build.tar.gz# → ./dl/api/build.tar.gzIf a group references multiple paths on the same server, the path alias is
added to the directory name (for example, web-default/ and web-logs/).
Before scp runs, snd get checks each local destination and lists any existing files (size, age, full path) so you can confirm before they're overwritten:
$ snd get web build.tar.gz
Local file(s) already exist:
./build.tar.gz 12.3 MB 4h ago
Overwrite local files? [y/N]
-f skips the prompt, --no-check skips the local check entirely.
Not sure which folder a jar landed in? snd find runs a search over SSH and prints where things are, so you can copy a path straight into snd -p.
snd find <server-or-group> [path-alias] <pattern>
snd search ... # aliasBy default it matches file names, case-insensitively, as a substring — so essentials finds EssentialsX.jar anywhere under the base:
$ snd find prod essentials
[prod] deploy@10.0.0.1:/opt/app — 2 match(es):
/opt/app/plugins/EssentialsX.jar 8.2 MB 2026-07-01 09:14:02 +0000
/opt/app/backup/EssentialsX-old.jar 7.9 MB 2026-05-20 22:03:11 +0000
The search base is the server's configured path — the same resolution as everything else, so a path-alias or positional directory narrows it:
snd find prod plugins essentials # search under the 'plugins' path-alias
snd find prod / worldedit # sweep the whole server
snd find prod ./logs/ error # relative to the configured baseFlags:
-e/--regex— treat the pattern as an extended regex instead of a substring. It matches anywhere in the path unless you anchor it with^/$.--case-sensitive— turn off the default case-insensitivity.-d/--depth N— limit a filename search toNdirectory levels below the base (handy with-p /).-g/--grep— search file contents instead of names (recursivegrep, skips binaries). Output ispath:line:content:
$ snd find -g prod "database.host"
[prod] deploy@10.0.0.1:/opt/app
/opt/app/config.yml:12: database.host: 10.0.0.5
-e and --case-sensitive apply to -g too (extended regex / case-sensitive grep). A plain pattern (no -e) is matched as a fixed string, so . and other regex characters are literal.
If the first positional after the server matches a path-alias it's consumed as the base; quote or use -p if you actually want to search for that word. Searching a group runs the search on every member and labels the results per server.
# Delete files (paths resolved under the server's configured path)
snd delete web build.tar.gz
# Bare names → resolved under the server path. Anything with `/` or `~` → used as-is.
snd delete web /tmp/dump.sql
# Use a one-off directory as the base
snd delete web /tmp/releases/ old-build.jar
# Across an entire group
snd delete prod build.tar.gz config.yml
# Allow directories (recursive)
snd delete -r web stale-build/snd delete always stats each target first, lists what it found (size, modified time, full remote path), and prompts before running rm on the remote.
For remote names beginning with -, place -- before the filename (for
example, snd cat web -- -notes.txt or snd delete web -- -notes.txt).
Safeguards:
- Confirmation is unconditional.
-fdoes not skip the delete prompt. - Directories are refused by default. Anything that stats as a directory is listed and skipped unless you pass
-r/--recursive. - Plain
rmfor files,rm -rfonly for explicit dirs. The two run as separate commands on the remote, so a misclassified file can never be recursively wiped. - Louder prompt for recursive deletes. When directories are in the mix the prompt makes it obvious before you hit
y.
Example:
$ snd delete -r web build.tar.gz old-cache/
[web] deploy@10.0.0.1:/opt/app/uploads — files to delete (1):
/opt/app/uploads/build.tar.gz 12.3 MB 2026-04-30 09:14:02
[web] deploy@10.0.0.1:/opt/app/uploads — DIRECTORIES to delete (recursive) (1):
/opt/app/uploads/old-cache 4.0 KB 2026-04-29 22:00:11 (dir)
This will recursively delete directories. Proceed? [y/N]
A group is a named set of servers. Sending to a group sends to each member sequentially. Each entry is server (uses that server's default path) or server:path_alias (pins to a specific path on that server).
# Create a group from one or more targets
snd add-group prod web api db
snd add-group alllogs web:logs api:logs
# Manage members
snd add-to-group prod cache
snd remove-from-group prod cache
snd remove-group prod
# Send to every server in the group
snd prod build.tar.gz
# Delete on every server in the group
snd delete prod stale.logRemoving a server (snd remove web) automatically prunes that server from any group that referenced it; if a group becomes empty it's deleted too.
The overwrite check runs per-member: each existing destination is listed under its server, and a single Overwrite? prompt covers the whole batch.
# Add a server (path defaults to ~)
snd add <alias><host> [/remote/path]
snd add prod deploy@10.0.0.1 /opt/app/uploads
snd add staging devbox
# Change the host of an existing server (paths preserved)
snd edit <alias><host># Remove a server (also drops it from any groups)
snd remove <alias># alias: snd rm# List configured servers and groups
snd list # alias: snd ls# (with a target, `snd ls <server>` lists that server's remote dir — see above)Each server has a map of named paths and a default. Use path aliases to keep a single server pointed at multiple destinations.
# Add another path on an existing server
snd add-path web logs /var/log/nginx
# Edit / remove paths
snd edit-path web logs /var/log/web
snd remove-path web logs # alias: snd rm-path# Pick which path is the default for `snd web <files>`
snd set-default web logsIn snd list, the active default is marked with *:
web [deploy@10.0.0.1]
* default /opt/app/uploads
logs /var/log/nginx
Groups:
prod
web
api:logs
A remote path can contain a shell glob (*, ?, [...]). Before running,
snd SSHes to the server and expands the pattern to the directories that
actually exist, then fans the operation out to every match. This is built for
setups where the live directory carries an ID suffix that changes on every
restart:
/srv/app/instances/ # live dirs, ID changes each start
app-1_a1b2c3d4/ app-2_e5f6a7b8/ app-3_9c8d7e6f/ app-4_0f1e2d3c/ ...
Point one path at the whole set with a glob and let snd work out the current
IDs:
snd add-path app node '/srv/app/instances/app-*_*/plugins'
snd app node build.jar
# [app] /srv/app/instances/app-*_*/plugins — resolved to 4 path(s) on deploy@10.0.0.1:# /srv/app/instances/app-1_a1b2c3d4/plugins# /srv/app/instances/app-2_e5f6a7b8/plugins# /srv/app/instances/app-3_9c8d7e6f/plugins# /srv/app/instances/app-4_0f1e2d3c/plugins# Send to all 4 resolved path(s)? [y/N] yNotes:
- Quote the pattern when adding it (
'...*...') so your local shell doesn't expand it — you want the*stored in the config and expanded on the remote. - A single glob path already fans out to every match, so you usually don't need a group for "all the instance folders". Groups still compose with globs if you want to mix in other servers.
- Only directories that exist are matched. A pattern that matches nothing is an error (nothing is sent), so a typo can't silently no-op.
- The fan-out is confirmed before a
send(skip with-f).get,delete, andfindexpand the same way;getdrops each match's files into a per-match subdirectory so they don't collide. - Works via
-ptoo:snd -p '/srv/app/instances/app-*_*/plugins' app build.jar.
Completions are dynamic and context-aware:
- Servers and groups —
snd <TAB>completes both, with a hint showing each entry's host or member count. - Path aliases —
snd web <TAB>completes path-aliases configured onweb. - SSH hosts —
snd add myserver <TAB>fuzzy-matches hosts from~/.ssh/config(includingIncluded files), searchable by alias, hostname, IP, or user. - Remote paths and files —
snd ls web <TAB>,snd cat web <TAB>,snd get web <TAB>, andsnd delete web <TAB>browse the configured remote path. Directories retain a trailing/, and selecting a path-alias changes the directory used by later completions. - Group members —
snd remove-from-group prod <TAB>lists current members. - Local files —
snd prod <TAB>completes local file paths.
Remote path completion uses SSH multiplexing (ControlMaster) to reuse connections, keeping repeated tab presses fast.
Configs are stored as TOML in ~/.config/snd/servers.toml:
[servers.web]
host = "deploy@10.0.0.1"default = "default"
[servers.web.paths]
default = "/opt/app/uploads"logs = "/var/log/nginx"
[servers.api]
host = "deploy@10.0.0.2"default = "default"
[servers.api.paths]
default = "/srv/api"logs = "/var/log/api"
[groups.prod]
targets = ["web", "api"]
[groups.alllogs]
targets = ["web:logs", "api:logs"]The CLI is the source of truth — you don't have to edit this file by hand, but it's plain TOML if you want to.