Rust · TypeScript · Kotlin

Shell out without the footguns

A type-safe, injection-proof shell runner with typed output parsers — three native implementations.

Open source · MIT license · Rust · TypeScript · Kotlin

shell.ts
import { sh } from "@slothlabs/shx";

const branch = await sh`git rev-parse --abbrev-ref HEAD`.text();
const pods   = await sh`kubectl get pods -o json`.json<PodList>();

// Safe even if `id` is "foo; rm -rf / #":
await sh`docker stop ${id}`;
// argv === ["docker", "stop", "foo; rm -rf / #"]  ← one argument, no shell
What it is

shx runs shell commands without a shell: every interpolated value is exactly one argument, so there is nothing to break out of. Parsing (text, json, lines, csv) is a first-class part of running. Native in Rust, TypeScript, and Kotlin.

Shelling out is either unsafe (string concatenation — hello injection) or stringly-typed (you hand-parse every stdout). shx fixes both. Commands are built from an argument list and spawned without a shell, so every interpolated or user-supplied value is exactly one argument. There is no string a caller can pass that breaks out into another command, because there is no shell to break out of.

Output is typed, too: text, lines, json, csv, and custom parsers are part of running a command, not an afterthought. A rich error carries exitCode, stdout, stderr, timedOut, and durationMs, so you never re-run just to see what went wrong. The dependency footprint stays tiny in every language — TS uses tagged templates, Rust a builder plus a cmd! macro, Kotlin varargs — but the one-value-equals-one-argument safety boundary is identical everywhere.

Why shx

Built to get out of your way

🛡️

Injection-proof by construction

Commands spawn directly with an argv array — no shell. sh`docker stop ${id}` is safe even if id is "foo; rm -rf / #": it is passed as one literal argument. There is nothing to escape because there is no shell to escape from.

🧩

Typed output, first-class

text(), lines(), json<T>(), csv, and parse(fn) make parsing part of running. Get a typed PodList from kubectl get pods -o json, or rows from a CSV with the same call that ran the command.

🔎

Errors you can read

A non-zero exit, spawn failure, or timeout throws a rich error carrying exitCode, signal, stdout, stderr, timedOut, and durationMs. Opt out with nothrow/allowExitCodes when a non-zero exit is expected (hello, grep).

Installation

Add it in one line

Same library, three ecosystems. Pick yours.

Rustcargo
cargo add shx --git https://github.com/slothlabsorg/shx --features serde
TypeScriptnpm
npm i @slothlabs/shx
Kotlin / JVMgradle · jitpack
repositories {
    maven("https://jitpack.io")
}

dependencies {
    implementation("com.github.slothlabsorg:shx:v0.1.0")
}

npm packages publish under the @slothlabs scope · JitPack builds from the git tag v0.1.0

In practice

One small example

A type-safe, injection-proof shell runner with typed output parsers — three native implementations.

RustTypeScriptKotlinShellSecurityCLI
shell.ts
import { sh } from "@slothlabs/shx";

const branch = await sh`git rev-parse --abbrev-ref HEAD`.text();
const pods   = await sh`kubectl get pods -o json`.json<PodList>();

// Safe even if `id` is "foo; rm -rf / #":
await sh`docker stop ${id}`;
// argv === ["docker", "stop", "foo; rm -rf / #"]  ← one argument, no shell

Try shx in your stack

Free and open source. Rust, TypeScript, or Kotlin — same semantics everywhere.