A nicer S3 CLI

README.md

s3cli #

S3 and S3-compatible object storage — AWS, Cloudflare R2, Hetzner, MinIO, Garage, Scaleway, … — from the command line, and from an interactive shell in the manner of the old ftp: cd around your buckets, ls, get, put, rm, mkdir, with Tab completion of remote paths.

Built to be used by people and by agents alike: every command takes --json and --help, failures leave stable exit codes, and nothing ever asks a question without a terminal to ask on.

Install #

s3cli is one signed file, run by Node ≥ 26.11.1:

bundle install @pipobscure.com/s3cli      # once it is published
# or from a checkout:
npm ci && npm run manifest && npm run pack:nzip && ./build/s3cli.unsigned.nzip --help

Log in once, per service #

s3cli login                                    # asks: endpoint (empty for AWS), region, keys
s3cli login hetzner --endpoint https://fsn1.your-objectstorage.com --region fsn1
s3cli login r2 --endpoint https://<account>.r2.cloudflarestorage.com --bucket site
printf %s "$SECRET" | s3cli login ci --access-key-id AKIA… --secret-stdin --bucket artifacts

A profile is one service and one set of keys. The keys go into the system's credential store (Secret Service or KWallet, the macOS Keychain, Windows Credential Manager — or a 0600 file with s3cli config set secret-store file), never into s3cli's config file.

Login tries the keys at once, and offers to remember the buckets they can see; keys limited to some buckets often cannot list them, so name those with --bucket. Remembered buckets are listed at / and reached with their profile's keys, so buckets at different services sit side by side. Any other bucket is tried with the default profile, or --profile.

s3cli bucket list                 # what is remembered
s3cli bucket list --remote        # what the default profile's keys can see
s3cli bucket add reports          # remember one more
s3cli bucket add www --profile hetzner --as www-hetzner   # the same name at another service
s3cli profile default hetzner

Paths #

One tree: / lists the remembered buckets, /bucket/key is an object. s3://bucket/key works too, and on the command line bucket/key. A key ending in / is a folder — S3 has none, only shared prefixes and the empty folder/ markers consoles make; s3cli treats both as folders.

Commands #

s3cli ls [-l] [-r] [path…]                 # also: dir; patterns like "logs/*.gz"
s3cli stat path…                           # size, type, modified, ETag
s3cli cat path… | less
s3cli get remote [local|-]                 # -r for a folder; '-' to stdout
s3cli get --zip photos/2026/ [out.zip|-]   # a whole folder as one ZIP, streamed
s3cli mget "photos/*.jpg" --to ~/Pictures
s3cli put local [remote]                   # -r for a folder; '-' from stdin
s3cli put --unzip site.zip www/            # unpack an archive into a folder
s3cli mput *.log --to logs/
s3cli rm path… [-r] [--yes]                # -r deletes folders and all beneath
s3cli mkdir path                           # at /, makes a bucket
s3cli rmdir path                           # at /, deletes an empty bucket
s3cli cp [-r] source… destination          # within a bucket, between buckets or services
s3cli mv [-r] source… destination          # copy, then delete the original

Large files go up in parts (setting part-size, 16 MiB), several at once (setting concurrency); an upload that fails or is stopped is aborted at the service. Downloads land in a temporary file first, so a failed one leaves nothing half-written.

Downloading a folder (get -r, get --zip) refuses any key that would land outside it — ../../.bashrc — before writing anything; put --unzip refuses an archive with such a member before uploading anything, and leaves symbolic links out. ZIP archives are read and written with node's own node:zlib.

The shell #

$ s3cli
s3cli 0.0.1 — 3 buckets at /. 'help' lists the commands; Ctrl-D leaves.
s3:/> cd photos/2026
s3:/photos/2026/> ls -l
s3:/photos/2026/> get --zip october/
s3:/photos/2026/> lcd ~/Pictures
s3:/photos/2026/> mput *.jpg
s3:/photos/2026/> !ls

Every command above works in it, with paths relative to the current folder, plus cd, pwd, open, lcd, lpwd, lls, verbose, quit and !command. Tab completes commands, options, remote folders and files, and local paths; history is kept between sessions. Ctrl-C stops the command running, not the shell.

Fed a script on stdin, it runs it and stops at the first failure with its exit code — unless the line starts with - (as in sftp -b):

printf 'cd /logs\nmget *.gz\n-rm *.tmp\n' | s3cli shell

Tab completion in your shell #

eval "$(s3cli completion bash)"          # in ~/.bashrc
eval "$(s3cli completion zsh)"           # in ~/.zshrc
s3cli completion fish | source           # in ~/.config/fish/config.fish
s3cli completion powershell | Out-String | Invoke-Expression   # in $PROFILE

Remote paths are completed from the service, for at most 1.5 seconds per Tab.

For agents and scripts #

  • --json on any command (help included) prints data on stdout; a failure prints {"error": {"code", "message", "exit", "status"?}} there too.
  • Exit codes: 0 ok, 1 error, 2 usage, 3 no usable keys, 4 not found, 5 rate limited, 6 needs input (a question with no terminal; the message names the option that answers it — --yes, --secret-stdin, …).

Settings #

s3cli config list shows them all: format, secret-store, kwallet-wallet, part-size, concurrency, history-size. They live in ~/.config/s3cli/config.json (macOS: ~/Library/Application Support/s3cli, Windows: %APPDATA%\s3cli); S3CLI_CONFIG_DIR points elsewhere.

Development #

The sources are erasable-syntax TypeScript, run directly by node; tsc only checks. No runtime dependencies: the S3 client is ordner.fyi's, vendored into src/vendor/ordner.fyi by node tools/vendor.ts (which records the commit in VENDOR.json); its event log, which writes to stdout, is replaced by src/observe.ts.

npm run check        # typecheck and tests (unit, and end to end against an in-process S3)
npm run manifest     # the archive's file list, checked against what real runs read
npm run pack:nzip    # build/s3cli.unsigned.nzip
npm run audit        # review it before signing

Releasing #

CI (.tangled/workflows/ci.yml) typechecks and tests every push and pull request to main. Releases are made the way tangled releases itself:

npm version patch            # bumps package.json and makes an annotated tag
git push --follow-tags       # the tag starts .tangled/workflows/release.yml

tools/release.sh then checks the tag matches package.json, tests, builds the archive reproducibly (dated by the commit, in UTC), attaches it to the release as s3cli.nzip, attests it as published, and points the @<you>/s3cli bundle listing at it. A version that already has s3cli.nzip is left as it is. Run by hand (tangled run trigger --workflow release.yml), it is a dry run, and so is RELEASE_DRY_RUN=1 ./tools/release.sh locally.

It needs two secrets (tangled secret set <NAME>): TANGLED_HANDLE and TANGLED_APP_PASSWORD, an app password for that account. It uploads with the tangled CLI, installed from its listing only if its author has attested it, and with bundle's state kept inside the run, so a local dry run leaves your own installs alone.

Licence #

EUPL-1.2 © Philipp Dunkel