zlye ✨
The fastest, most type-safe CLI framework for Node.js
Performance
| CLI Framework | ops/sec | Average Time (ns) | Margin | Samples | |
|---|---|---|---|---|---|
| 0 | yargs | 18,955 | 52,754.48 | ±1.09% | 9,478 |
| 1 | commander | 355,143 | 2,815.76 | ±1.06% | 177,582 |
| 2 | cac | 633,631 | 1,578.20 | ±0.70% | 316,816 |
| 3 | zlye | 1,403,217 | 712.65 | ±0.90% | 701,609 |
zlye achieves over 1.4 million ops/sec in benchmarks, more than 74× faster than yargs, 4× faster than commander, and 2× faster than cac.
Run benchmarks yourself: bun run bench
Why zlye?
- ✨ Type-safe from input to output - Full TypeScript support with inferred types
- 🚀 Blazing fast - 74x faster than yargs, 4x faster than commander
- 🎯 Zod-like schema validation - Familiar, powerful validation API
- 🎨 Beautiful help & errors - Gorgeous, helpful CLI output with smart suggestions
- 📦 Zero dependencies - Lightweight and reliable
- 🔧 Flexible - Supports commands, flags, positionals, unions, and more
Getting Started
Installation
npm install zlye
# or
yarn add zlye
# or
bun add zlye
Your First CLI
import { cli, z } from 'zlye'
const app = cli()
.name('greet')
.version('1.0.0')
.description('A friendly greeting CLI')
.option('name', z.string().default('World').describe('Name to greet'))
.option('excited', z.boolean().describe('Add excitement!'))
.parse()
if (app) {
const greeting = `Hello, ${app.options.name}${app.options.excited ? '!!!' : '!'}`
console.log(greeting)
}
$ greet --name Alice --excited
Hello, Alice!!!
$ greet --help
A friendly greeting CLI (1.0.0)
Usage: greet [...flags]
Flags:
--name <val> Name to greet (default: "World")
--excited Add excitement!
-h, --help Display this menu and exit
Schema Validation
zlye uses a Zod-like validation system that ensures your CLI inputs are always type-safe.
String Validation
cli()
.option('name', z.string())
.option('email', z.string().regex(/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/))
.option('username', z.string().min(3).max(20))
.option('role', z.string().choices(['admin', 'user', 'guest']))
Number Validation
cli()
.option('port', z.number().min(1024).max(65535).default(3000))
.option('threads', z.number().int().positive())
.option('temperature', z.number().min(-273.15).describe('In Celsius'))
Boolean Flags
cli()
.option('verbose', z.boolean().alias('v'))
.option('quiet', z.boolean().alias('q'))
.option('force', z.boolean().describe('Skip confirmations'))
Arrays
cli()
.option('tags', z.array(z.string()).describe('Add multiple tags'))
.option('ports', z.array(z.number().int()).min(1).max(10))
$ myapp --tags foo,bar,baz
$ myapp --ports 3000,3001,3002
Default Values
Set default values for options with optional custom messages:
cli()
.option('port', z.number().default(3000))
.option('config', z.string())
.option('env', z.string().default(process.env.NODE_ENV, 'process.env.NODE_ENV by default'))
.option('verbose', z.boolean().default(false))
When you provide a custom message as the second parameter to .default(), it will be shown in the help output instead of the raw default value. This is useful for providing more descriptive help text:
$ myapp --help
Flags:
--port <n> Server port (default: 3000)
--config <val> Configuration file
--env <val> Environment mode (process.env.NODE_ENV by default)
--verbose Enable verbose output (default: false)
Objects
// Structured objects with specific properties
cli()
.option('config', z.object({
host: z.string().default('localhost'),
port: z.number().default(3000),
secure: z.boolean().default(false)
}))
// Dynamic objects with any keys
cli()
.option('env', z.object(z.string()).describe('Environment variables'))
$ myapp --config.host api.example.com --config.port 443 --config.secure
$ myapp --env.NODE_ENV production --env.DEBUG true
Unions
Combine multiple types for ultimate flexibility:
cli()
.option('input', z.union(
z.boolean().describe('Enable input'),
z.string().describe('File path'),
z.object({
file: z.string().describe('File path'),
encoding: z.string().choices(['utf8', 'utf16', 'ascii']).default('utf8').describe('File encoding')
})
))
$ myapp --input # boolean: true
$ myapp --input file.txt # string: "file.txt"
$ myapp --input.file data.json --input.encoding utf16 # object
Commands
Build complex CLI applications with subcommands:
import { cli, z } from 'zlye'
cli()
.name('docker')
.version('2.0.0')
.command('build', {
file: z.string().alias('f').default('Dockerfile'),
tag: z.string().alias('t').describe('Image tag'),
noCache: z.boolean().describe('Do not use cache')
})
.positional('context', z.string().describe('Build context directory'))
.action(({ options, positionals }) => {
console.log(`Building ${positionals[0]} with ${options.file}`)
if (options.tag) console.log(`Tagging as ${options.tag}`)
})
.command('run', {
port: z.array(z.string()).alias('p').describe('Port mapping'),
volume: z.array(z.string()).alias('v').describe('Volume mounting'),
detach: z.boolean().alias('d').describe('Run in background')
})
.positional('image', z.string())
.rest('cmd', z.string())
.action(({ options, positionals, rest }) => {
console.log(`Running ${positionals[0]}`)
if (rest.length) console.log(`Command: ${rest.join(' ')}`)
})
.parse()
Beautiful Help Output
zlye automatically generates gorgeous help menus:
$ myapp --help
My awesome CLI tool (v2.1.0)
Usage: myapp <command> [...flags]
Commands:
build docker build . Build a Docker image
run docker run ubuntu:latest Run a container
<command> --help Print help text for command.
Flags:
-c, --config <val> Path to configuration file (default: "./config.json")
-p, --port <n> Server port (min: 1024, max: 65535, default: 3000)
--features <val,...> List of features to enable
--mode <dev|test|prod> Select mode
-h, --help Display this menu and exit
Examples:
myapp --verbose
myapp build --output dist/
Command-specific Help
$ myapp build --help
Usage: myapp build [...flags] <context>
Build a Docker image
Arguments:
<context> Build context directory
Flags:
-f, --file <val> Dockerfile path (default: "./Dockerfile")
-t, --tag <val> Image tag
-c, --cache <val> Cache directory (default: "./cache")
--no-cache Do not use cache
--env.<key> <val> Set environment variables
-h, --help Display this menu and exit
Examples:
myapp build .
myapp build --tag myapp:latest .
myapp build --file ./custom.Dockerfile --no-cache .
Smart Error Messages
zlye provides incredibly helpful error messages with suggestions:
$ myapp --porrt 3000
Error: --porrt is not recognized
Available options:
--port
--prod
--profile
Did you mean --port?
$ myapp --port abc
Error: --port expects a numeric value
Received: --port "abc"
Expected: --port 3000
$ myapp --mode development
Error: --mode must be one of: dev, test, prod
Received: --mode development
Expected: --mode dev
Did you mean dev?
$ myapp -s
Error: -s is not recognized
Available aliases:
-v for --verbose
-q for --quiet
Run with --help for usage information
Options
zlye provides configuration options to customize parsing behavior:
ignoreOptionDefaultValue
When set to true, default values defined with .default() will not be included in the parsed result. Only explicitly provided values will be returned. Default values will still be shown in the help output.
This is useful when you need to distinguish between user-provided values and defaults, for example when merging CLI arguments with configuration files or environment variables.
import { cli, z } from 'zlye'
const app = cli()
.option('port', z.number().default(3000))
.option('host', z.string().default('localhost'))
.with({ ignoreOptionDefaultValue: true })
.parse()
// When parsing with no arguments
// app.options will be {} instead of { port: 3000, host: 'localhost' }
// When providing explicit values: --port 8080
// app.options will be { port: 8080 }
Advanced Features
Transform Values
Transform parsed values with full type safety:
cli()
.option('date',
z.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.transform(str => new Date(str))
)
.option('json',
z.string()
.transform(str => JSON.parse(str))
)
.parse()
Custom Validation & Operations
Transform functions can perform any operation and throw descriptive errors:
import { readFileSync } from 'fs'
cli()
.option('config', z.string()
.transform(path => {
if (!readFileSync(path, 'utf8')) {
throw new Error(`Config file not found: ${path}`)
}
return JSON.parse(readFileSync(path, 'utf8'))
})
)
.parse()
Negation Flags
Automatically handle --no- prefixes for boolean flags:
cli()
.option('color', z.boolean().default(true))
.parse()
$ myapp # color: true (default)
$ myapp --no-color # color: false
Positional Arguments
cli()
.positional('source', z.string().describe('Source file'))
.positional('dest', z.string().describe('Destination'))
.parse()
$ myapp input.txt output.txt
Variadic Arguments
Collect remaining arguments:
cli()
.positional('script', z.string())
.rest('args', z.string())
.parse()
$ myapp build.js --watch --verbose
# script: "build.js"
# rest: ["--watch", "--verbose"]
.rest() is particularly useful when you need to accept multiple values that can appear anywhere in the command line (beginning, middle, end), unlike positional arguments which are order-dependent and limited in count.
// Simple build tool example
const { rest: entries } = cli()
.option('minify', z.boolean())
.option('watch', z.boolean())
.rest('entries', z.string())
.parse()
$ build src/index.ts src/cli.ts --minify
# entries: ["src/index.ts", "src/cli.ts"]
$ build --minify --watch src/app.ts src/utils.ts
# entries: ["src/app.ts", "src/utils.ts"]
Custom Validation Messages
cli()
.option('age',
z.number()
.min(18, 'Must be an adult')
.max(120, 'Invalid age')
)
.option('password',
z.string()
.min(8, 'Password too short')
.regex(/[A-Z]/, 'Must contain uppercase')
)