we shipped our cli in bash. in 2026. on purpose.

we shipped our cli in bash. in 2026. on purpose.


curl -fsSL https://install.5dive.com | sudo bash. That’s it. No node version dance. No per-arch binary. No npm i -g that quietly pulls 800 transitive deps. The 5dive CLI lands as one file in /usr/local/bin/5dive and runs on every linux box that already has bash.

That’s not nostalgia. It’s the product.


bash is the host’s API

5dive’s whole job is orchestrating a linux box. Spinning up a system user. Writing a systemd unit. Reading journalctl -u. Piping useradd → install → chmod. Reaching into ssh-agent. Talking to apt. Parsing the output of systemctl is-active.

That’s useradd, systemctl, sudo, curl, jq, ssh, install. Bash is the API.

A typescript rewrite would spend 80% of its time inside child_process.spawn() simulating what bash does natively. We’d be writing a bash interpreter in TS to call bash. That’s a real thing some people are doing: vercel-labs shipped just-bash, a from-scratch bash + grep/sed/awk/jq reimplementation in typescript, because agents prefer bash. We don’t disagree with the premise. We disagree with the conclusion.

Why simulate bash when the host already runs it.


the trick: split source, single-file delivery

The honest objection to bash isn’t “bash is bad,” it’s that a 10,000-line bash file is unreadable. So we don’t write one.

src/ is split by concern: header.sh, main.sh, twelve cmd_*.sh (one per subcommand: account, agent, auth, compose, doctor, heartbeat, init, org, selfupdate, skill, task, watch), and a lib/ of helpers (agent_setup.sh, registry.sh, state.sh, audit.sh, validation.sh, output.sh, error_codes.sh, tasks_db.sh). Each file has one job. Each subcommand has its own grep target.

build.sh concatenates them in a specific order: header first (it sets set -euo pipefail and every declare -A map), main.sh last (it owns the EXIT trap and main "$@"). The output is one file: 5dive. That’s what install.sh fetches.

The thing that keeps the two in sync is .github/workflows/bundle-drift.yml:

- run: ./build.sh
- run: git diff --exit-code 5dive

Every push, CI rebuilds the bundle and refuses to merge if the committed 5dive doesn’t match. Edit src/, forget to rebuild, CI yells. Edit 5dive directly, src and bundle drift, CI yells. The bundle is byte-identical to what build.sh produces, every commit.

Multi-file readability for us. Single-file delivery for the install path. The build script is under sixty lines.


you can grep our binary at 3am

This is the thing no TS or Rust CLI gives you.

When a customer’s 5dive box does something weird at 3am, you ssh in, vim /usr/local/bin/5dive, search for the error string, find the function, read what it actually does. The production artifact is the source. No source map. No minified bundle. No “let me set up the repo locally so I can match versions.”

For a piece of infrastructure that lives on someone else’s server and runs as root, that property is worth a lot. The thing that’s running and the thing you can read are the same thing.


the dependency tree you didn’t choose

here’s the part that stopped being abstract in 2025.

npm install runs code before you do. preinstall and postinstall hooks execute with your privileges the moment you pull a package, not when you run your app, at install time. and you’re not installing one package, you’re installing its dependencies, and theirs, a few hundred deep. you didn’t pick most of them and you’ve never read any of them.

that tree is the attack surface. a sampling from the last year:

  • may 2026, tanstack. an attacker published 84 malicious versions across 42 @tanstack/* packages in a six-minute window. nobody got phished. they poisoned tanstack’s own ci/cd pipeline and lifted a publish token straight out of the build runner’s memory. (postmortem)
  • sept 2025, chalk + debug. a maintainer clicked a fake npm 2fa-reset email. the attacker shipped a crypto-clipper inside packages with roughly 2.6 billion weekly downloads. almost none of those people typed npm install chalk. it rode in as a dependency of a dependency of a dependency. (writeup)
  • the shai-hulud worm. a postinstall script that scans your machine for tokens, exfiltrates them, then uses your stolen npm token to inject itself into your packages and republish. self-spreading, no attacker in the loop. the november variant backdoored 796 packages across 25,000+ repos. (writeup)
  • nx, aug 2025. a poisoned postinstall hijacked developers’ own ai cli tools and pointed them at hunting secrets on the machine. (writeup)

one audited bash file removes the two things every one of those exploited:

  • no dependency tree. nothing downstream to poison. there’s no dependency-of-a-dependency to ride in on, because there are no dependencies.
  • no install-time execution. the file does nothing until you read it and run it. no postinstall, no node_modules, nothing firing on download.
  • no silent “latest”. you pin one file. a compromised upstream version can’t reach you unless you go fetch it.
  • actually auditable. nobody reads their 1,400-package node_modules. a few hundred lines of bash, you can.

now the honest part, because we ship curl | sudo bash and that’s its own risk class: piping a remote script into a root shell means trusting whatever the server sends in that moment. so pin a versioned url and verify the published checksum, or download-read-run instead of blind-piping. and bash isn’t magic either. it shells out to curl, jq, coreutils, so the trust just moves to your os package manager. that’s a smaller, slower, more-scrutinized base than a four-million-package registry that runs arbitrary code on install. smaller, not zero. we’d rather own that boundary than inherit npm’s.


the discipline

This isn’t bash-of-2003. The dial is turned up.

  • set -euo pipefail in every src/ file (header bakes it in for the bundle).
  • 346 jq invocations across the binary. State that needs structure goes through jq, not through cut -f and string concatenation. The registry at /var/lib/5dive/agents.json is a real JSON document with locking, not a CSV.
  • shellcheck runs in CI on every bundle.
  • The bundle-drift gate keeps source and shipped artifact in lockstep.
  • Subcommands return structured JSON via --json so the dashboard and the agent-to-agent layer can parse instead of regex.

A bash codebase that does these things looks different from the install-script hairball most people picture when they hear “bash CLI.” It looks more like a small linux tool than a sysadmin script.


about the line count

10,070 lines today, about 7,000 of them actual code. That’s not lean. We’re not selling lean. We’re selling that the right tool for orchestrating a linux box is the language linux already speaks, and the size is what it takes to do the orchestration well. A TS port would be smaller in lines and larger in everything that matters: install footprint, debug surface, supply chain.

Own the size, ship the discipline.


the close

The bash-vs-typescript meta-argument is mostly tired. We didn’t pick bash because of a manifesto. We picked it because every line of our cli is a thin layer over a linux primitive, and the layer below was already bash.

If you’re writing a web app, write TypeScript. If you’re writing a service that talks to a hundred-thousand-row dataframe, write Python. If you’re writing a tool whose job is to call systemctl and useradd and ssh in the right order with the right error handling, the host you’re running on has already chosen for you.

Vercel rebuilt bash in TypeScript for AI agents. Our agents already speak bash. That’s the post.


5dive is open source at 5dive-ai/5dive. The bundle is at /5dive. The src is in /src. The CI gate that keeps them honest is .github/workflows/bundle-drift.yml. Patches welcome. Bring your own opinion about the size.