Making zcli; A Zod-First CLI Framework

It’s 1am and I’m finishing up a single dependency CLI framework from scratch. Questionable? Probably. But every other zod cli library I found seemed to be for zod 3 and left abandoned.
const greet = command("greet")
  .inputs({
    name: positional(z.string(), 0),
    loud: flag(z.boolean().default(false), "loud", { alias: "l" }),
  })
  .action(({ inputs }) => {
    const greeting = `Hello, ${inputs.name}!`;
    console.log(inputs.loud ? greeting.toUpperCase() : greeting);
  });
The schema is the type definition. No duplication, no drift.

Compile-Time Positional Validation

The part I’m most pleased with is catching positional index errors before runtime. Define two args at index 0? TypeScript complains. Skip from 0 to 2? Type error. This required some creative type-level work in types.ts—tracking used indices through branded types and mapped conditionals to detect gaps and duplicates.

The Parser

Argument parsing is deceptively annoying. Is --flag value a boolean flag followed by a positional, or a flag with a value? What about --no-color negation? Combined short flags like -abc? The -- pass-through separator?
The parser handles all of it in ~170 lines, plus a Levenshtein implementation for “did you mean?” suggestions when you typo a flag name.

Testing

The testCli utility captures stdout/stderr/exitCode without spawning subprocesses. This makes tests fast—300+ tests across 12 files run in 60ms with bun test. Most of those are type-level tests ensuring the compile-time validation actually catches what it should.

Environment Variables & Shell Completions

Flags can bind to environment variables with automatic type coercion—define once, read from --port 8080 or PORT=8080 interchangeably. The framework also generates shell completion scripts for bash, zsh, fish, and PowerShell, which was surprisingly straightforward once the command tree was already structured for help generation.

Traits

For CLIs with shared patterns (verbose flags, config file paths, auth context), traits bundle inputs and context together. Apply the same trait to multiple commands and it deduplicates automatically—no accidental flag conflicts.

Check it Out

Fun Fact

It technically works using the raw .meta() field if you want to, it leaves out some compile time safety due to limitations of zod’s typings but hey if you want to use a schema from somewhere else up in the cli it’s as simple as a quick
const portSchema = z.coerce.number().int().min(1).default(3000).meta({
  flag: "port",
  alias: "p",
  env: "PORT",
  description: "The port the service should expose up it's web UI on.",
});