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
- Source: https://github.com/versecafe/zcli
- Install:
bun install @versecafe/zcli zod
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.",
});