@versecafe/zcli is a TypeScript-first CLI framework built on Zod 4. Define your CLI arguments and flags as Zod schemas, and get fully typed, validated inputs automatically.
import { z } from "zod";
import { cli, command, positional, flag } from "@versecafe/zcli";
const greet = command("greet")
.meta({ description: "Greet someone" })
.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);
});
cli("hello", { version: "1.0.0" }).use(greet).run();$ hello greet World --loud
HELLO, WORLD!- Zero external dependencies – only Zod as a peer dependency
- Full type inference – inputs are fully typed from your Zod schemas
- Compile-time validation – catch positional index errors at build time
- Automatic help – generated from your schema metadata
- Shell completions – bash, zsh, fish, and PowerShell
- Environment variables – bind flags to env vars with automatic coercion
- Traits – reusable input + context bundles
- Testing utilities – capture stdout/stderr/exit code
bun install @versecafe/zcli zodRequirements: @versecafe/zcli requires Zod v4 and TypeScript 5+.
import { z } from "zod";
import { command, positional, flag } from "@versecafe/zcli";
const serve = command("serve")
.meta({ description: "Start the server" })
.inputs({
port: flag(z.coerce.number().default(3000), "port", {
alias: "p",
description: "Port to listen on",
env: "PORT",
}),
host: flag(z.string().default("localhost"), "host", {
description: "Host to bind to",
}),
})
.action(({ inputs }) => {
console.log(`Server running at http://${inputs.host}:${inputs.port}`);
});import { cli } from "@versecafe/zcli";
const app = cli("myapp", {
version: "1.0.0",
description: "My awesome CLI",
})
.use(serve)
.use(otherCommand);
app.run();const migrate = command("migrate")
.meta({ description: "Run migrations" })
.action(() => {
/* ... */
});
const seed = command("seed")
.meta({ description: "Seed the database" })
.action(() => {
/* ... */
});
const db = command("db")
.meta({ description: "Database operations" })
.use(migrate)
.use(seed);
// Creates: myapp db migrate, myapp db seedDefine a positional argument at a specific index:
import { positional } from "@versecafe/zcli";
command("copy").inputs({
source: positional(z.string(), 0, { description: "Source file" }),
dest: positional(z.string(), 1, { description: "Destination" }),
});$ myapp copy src/file.ts dist/file.tsDefine a named flag:
import { flag } from "@versecafe/zcli";
command("build").inputs({
watch: flag(z.boolean().default(false), "watch", { alias: "w" }),
outDir: flag(z.string().default("dist"), "out-dir", { alias: "o" }),
});$ myapp build --watch --out-dir=build
$ myapp build -w -o buildflag(z.string(), "token", {
alias: "t", // Short flag: -t
description: "API token", // Shown in help
env: "API_TOKEN", // Read from environment
hidden: true, // Hide from help
});The last positional can be an array to capture remaining arguments:
command("run").inputs({
script: positional(z.string(), 0),
args: positional(z.array(z.string()).default([]), 1),
});$ myapp run build.ts --flag value extra args
# script = "build.ts", args = ["extra", "args"]
# flags after -- are passed throughBoolean flags can be negated with --no- prefix:
flag(z.boolean().default(true), "color", { negatable: true });$ myapp --no-color # color = falseZod enums work seamlessly:
const LogLevel = z.enum(["debug", "info", "warn", "error"]);
command("serve").inputs({
logLevel: flag(LogLevel.default("info"), "log-level"),
});Help output shows available choices:
--log-level <debug | info | warn | error> (default: "info")
Pass typed context to your actions:
interface AppContext {
config: Config;
logger: Logger;
}
cli("myapp")
.context(async () => ({
config: await loadConfig(),
logger: createLogger(),
}))
.use(
command("serve").action(({ ctx }) => {
ctx.logger.info("Starting server...");
}),
);Traits bundle reusable inputs and context:
import { trait, flag } from "@versecafe/zcli";
const verboseTrait = trait({
verbose: flag(z.boolean().default(false), "verbose", { alias: "v" }),
});
const authTrait = trait({
token: flag(z.string(), "token", { env: "API_TOKEN" }),
}).withResolve(({ inputs }) => ({
api: createApiClient(inputs.token),
}));
// Apply to all commands
cli("myapp")
.use(verboseTrait)
.use(authTrait)
.use(
command("deploy").action(({ inputs, ctx }) => {
if (inputs.verbose) console.log("Deploying...");
ctx.api.deploy();
}),
);Traits are deduplicated by name – applying the same trait twice has no effect.
Run code before and after actions:
command("deploy")
.before(({ inputs, ctx }) => {
console.log("Starting deployment...");
})
.action(({ inputs }) => {
// deploy
})
.after(({ inputs, ctx, result }) => {
console.log("Deployment complete!");
});import { CliError, UserError } from "@versecafe/zcli";
// Throw user-facing errors
throw new UserError("Invalid configuration file");
// Custom error handling
cli("myapp").onError(({ error, command }) => {
if (error instanceof NetworkError) {
console.error("Network error:", error.message);
return { handled: true };
}
// Return nothing to use default handling
});| Error | Description |
|---|---|
CliError |
Base error class |
UserError |
User-facing error |
ValidationError |
Zod validation failed |
UnknownFlagError |
Unknown flag provided |
UnknownCommandError |
Unknown command provided |
MissingArgumentError |
Required positional missing |
MissingFlagError |
Required flag missing |
Help is automatically generated from your schema:
$ myapp --help
My awesome CLI
Usage: myapp <command> [options]
Commands:
serve Start the server
build Build the project
Options:
-v, --verbose Enable verbose output
-h, --help Show help
-V, --version Show version$ myapp serve --help
Start the server
Usage: myapp serve [options]
Options:
-p, --port <number> Port to listen on (default: 3000) (env: PORT)
--host <string> Host to bind to (default: "localhost")Generate shell completion scripts:
import { generateCompletionScript } from "@versecafe/zcli";
// In your CLI
command("completion")
.inputs({
shell: positional(z.enum(["bash", "zsh", "fish", "powershell"]), 0),
})
.action(({ inputs }) => {
console.log(generateCompletionScript(app._config, inputs.shell));
});# Install completions
$ myapp completion bash >> ~/.bashrc
$ myapp completion zsh > ~/.zsh/completions/_myapp
$ myapp completion fish > ~/.config/fish/completions/myapp.fishCustom completions for arguments:
flag(z.string(), "config", {
completion: "file", // File path completion
});
positional(z.string(), 0, {
completion: "directory", // Directory completion
});
flag(z.enum(["dev", "prod"]), "env", {
completion: ["development", "staging", "production"],
});
flag(z.string(), "branch", {
completion: (partial) => execSync("git branch").toString().split("\n"),
});Test your CLI without running a subprocess:
import { testCli } from "@versecafe/zcli";
test("greet command", async () => {
const result = await testCli(app, ["greet", "World"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe("Hello, World!");
expect(result.stderr).toBe("");
});
test("shows help", async () => {
const result = await testCli(app, ["--help"]);
expect(result.stdout).toContain("Usage:");
});Enable strict mode to catch unknown flags and commands:
cli("myapp", {
strictFlags: true, // Error on unknown flags
strictCommands: true, // Error on unknown commands
});Arguments after -- are passed through without parsing:
command("run")
.inputs({ script: positional(z.string(), 0) })
.action(({ inputs, passthrough }) => {
// myapp run build.ts -- --extra --flags
// passthrough = ["--extra", "--flags"]
spawn("node", [inputs.script, ...passthrough]);
});| Function | Description |
|---|---|
cli(name, options?) |
Create a CLI instance |
command(name) |
Create a command |
| Function | Description |
|---|---|
positional(schema, index, meta?) |
Define a positional argument |
flag(schema, name, meta?) |
Define a flag |
cliMeta(schema, meta) |
Attach metadata to any schema |
| Function | Description |
|---|---|
generateHelp(config, path?) |
Generate help text |
generateCompletionScript(config, shell) |
Generate shell completion script |
testCli(cmd, argv) |
Test CLI capturing output |
parse(argv, options?) |
Parse argv into structured result |
formatError(error) |
Format error for display |
getExitCode(error) |
Get exit code for error |
| Type | Description |
|---|---|
Cli |
CLI instance type |
CommandBuilder |
Command builder type |
CliConfig |
CLI configuration |
CommandConfig |
Command configuration |
CliMeta |
Input metadata (positional, flag, env, etc.) |
ActionContext |
Context passed to actions |
Trait |
Trait type |
MIT