citty vs caporal vs vorpal: Modern CLI Frameworks for Node.js (2026)
TL;DR
citty is the modern CLI framework from the UnJS ecosystem — lightweight, TypeScript-first, composable sub-commands, zero dependencies. caporal is a batteries-included CLI framework — validation, auto-generated help, shell completions, and colored output built-in. vorpal is an interactive CLI framework — builds REPL-style command-line apps where users type commands inside your running program. In 2026: citty for modern TypeScript CLIs, caporal for feature-rich traditional CLIs, vorpal for interactive terminal applications (though vorpal is largely unmaintained).
Key Takeaways
- citty: ~2M weekly downloads — UnJS ecosystem, zero deps, TypeScript-native, composable
- caporal: ~100K weekly downloads — batteries-included, validation, completions, colorful help
- vorpal: ~50K weekly downloads — interactive REPL mode, in-app command execution (largely unmaintained)
- citty is used by Nuxt CLI, unbuild, and other UnJS tools
- For most new CLI projects in 2026: citty or commander/yargs — caporal and vorpal are legacy choices
- Interactive prompts: combine citty with @clack/prompts instead of using vorpal
citty
citty — modern, minimal CLI framework:
Basic command
import { defineCommand, runMain } from "citty"
const main = defineCommand({
meta: {
name: "pkgpulse",
version: "1.0.0",
description: "Package health analyzer",
},
args: {
package: {
type: "positional",
description: "Package name to analyze",
required: true,
},
format: {
type: "string",
description: "Output format",
default: "table",
},
verbose: {
type: "boolean",
description: "Enable verbose output",
alias: "v",
},
},
run({ args }) {
console.log(`Analyzing ${args.package}...`)
console.log(`Format: ${args.format}`)
if (args.verbose) console.log("Verbose mode enabled")
},
})
runMain(main)
# Usage:
pkgpulse react --format json -v
# Analyzing react...
# Format: json
# Verbose mode enabled
Sub-commands
import { defineCommand, runMain } from "citty"
const analyze = defineCommand({
meta: { name: "analyze", description: "Analyze a package" },
args: {
package: { type: "positional", required: true },
depth: { type: "string", default: "full" },
},
run({ args }) {
console.log(`Analyzing ${args.package} (depth: ${args.depth})`)
},
})
const compare = defineCommand({
meta: { name: "compare", description: "Compare two packages" },
args: {
packages: { type: "positional", required: true },
},
run({ args }) {
const [a, b] = args.packages.split(",")
console.log(`Comparing ${a} vs ${b}`)
},
})
const main = defineCommand({
meta: { name: "pkgpulse", version: "1.0.0" },
subCommands: {
analyze,
compare,
},
})
runMain(main)
pkgpulse analyze react --depth shallow
pkgpulse compare "react,vue"
pkgpulse --help # Auto-generated help with sub-commands
Why citty is popular
citty design principles:
✅ Zero dependencies — tiny install size
✅ TypeScript-first — args are fully typed
✅ Composable — sub-commands are just defineCommand()
✅ Auto-generated help — --help works out of the box
✅ Used by UnJS ecosystem (Nuxt CLI, unbuild, nitro)
citty vs commander/yargs:
- Simpler API — defineCommand + runMain
- Better TypeScript types — args inferred from definition
- Smaller — zero deps vs commander's 0 deps (similar) vs yargs' many deps
- Less mature — fewer community examples and plugins
caporal
caporal — batteries-included CLI:
Setup
npm install caporal
Basic command
const prog = require("caporal")
prog
.version("1.0.0")
.description("Package health analyzer")
// Default command:
.argument("<package>", "Package name to analyze")
.option("--format <format>", "Output format", ["json", "table", "csv"], "table")
.option("-v, --verbose", "Enable verbose output")
.action(({ args, options }) => {
console.log(`Analyzing ${args.package}...`)
console.log(`Format: ${options.format}`)
})
prog.parse(process.argv)
Sub-commands with validation
const prog = require("caporal")
prog
.version("1.0.0")
// analyze sub-command:
.command("analyze", "Analyze a package")
.argument("<package>", "Package name", /^[@a-z]/) // Regex validation
.option("--depth <depth>", "Analysis depth", ["shallow", "full"], "full")
.option("--timeout <ms>", "Timeout in milliseconds", prog.INT, 5000)
.action(({ args, options, logger }) => {
logger.info(`Analyzing ${args.package} (depth: ${options.depth})`)
})
// compare sub-command:
.command("compare", "Compare two packages")
.argument("<pkg1>", "First package")
.argument("<pkg2>", "Second package")
.option("--metric <metric>", "Comparison metric", ["downloads", "health", "all"], "all")
.action(({ args, options, logger }) => {
logger.info(`Comparing ${args.pkg1} vs ${args.pkg2}`)
})
prog.parse(process.argv)
Built-in features
// caporal includes out of the box:
// ✅ Colored help output
// ✅ Argument type validation (INT, FLOAT, BOOL, LIST, REPEATABLE)
// ✅ Regex argument validation
// ✅ Shell auto-completion (bash, zsh, fish)
// ✅ Built-in logger with levels (debug, info, warn, error)
// ✅ Fuzzy command matching (typo correction)
// Shell completion setup:
// pkgpulse completion >> ~/.bashrc
// pkgpulse completion >> ~/.zshrc
Caporal limitations
In 2026:
❌ Last major release was 2020 — limited maintenance
❌ CJS-only — no native ESM support
❌ TypeScript types are @types/caporal (community)
❌ Superseded by modern alternatives (citty, commander v12, cleye)
Still useful for:
✅ Existing projects already using caporal
✅ Quick prototypes needing validation + help
✅ Projects that need built-in shell completions
vorpal
vorpal — interactive REPL CLI:
Interactive mode
const vorpal = require("vorpal")()
vorpal
.command("analyze <package>", "Analyze a package")
.option("--format <format>", "Output format")
.action(function (args) {
this.log(`Analyzing ${args.package}...`)
// Simulate async work:
return new Promise((resolve) => {
setTimeout(() => {
this.log(`Health score: 87/100`)
resolve()
}, 1000)
})
})
vorpal
.command("compare <pkg1> <pkg2>", "Compare two packages")
.action(function (args) {
this.log(`${args.pkg1} vs ${args.pkg2}:`)
this.log(` Downloads: 5M vs 2M`)
this.log(` Health: 87 vs 72`)
})
// Start interactive REPL:
vorpal
.delimiter("pkgpulse$")
.show()
# Running the CLI opens an interactive prompt:
$ node cli.js
pkgpulse$ analyze react --format json
Analyzing react...
Health score: 87/100
pkgpulse$ compare react vue
react vs vue:
Downloads: 5M vs 2M
Health: 87 vs 72
pkgpulse$ exit
Interactive prompts
vorpal
.command("setup", "Configure pkgpulse")
.action(function (args) {
// Built-in prompts (like inquirer):
return this.prompt([
{
type: "input",
name: "apiKey",
message: "Enter your API key: ",
},
{
type: "list",
name: "defaultFormat",
message: "Default output format:",
choices: ["table", "json", "csv"],
},
{
type: "confirm",
name: "analytics",
message: "Enable analytics?",
default: true,
},
]).then((answers) => {
this.log(`Saved config: ${JSON.stringify(answers)}`)
})
})
Vorpal status
In 2026:
❌ UNMAINTAINED — last commit ~2018
❌ No TypeScript types
❌ CJS only
❌ Security vulnerabilities in dependencies
❌ Node.js compatibility issues with newer versions
Modern alternatives for interactive CLIs:
✅ @clack/prompts + citty — modern interactive CLI
✅ Ink — React-based terminal UI
✅ enquirer + commander — prompts + parsing
✅ Node.js readline/promises — built-in REPL
Modern Alternative: citty + @clack/prompts
// Instead of vorpal, combine citty + @clack/prompts for interactive CLIs:
import { defineCommand, runMain } from "citty"
import * as p from "@clack/prompts"
const setup = defineCommand({
meta: { name: "setup", description: "Configure pkgpulse" },
async run() {
p.intro("PkgPulse Setup")
const config = await p.group({
apiKey: () => p.text({ message: "API key:", validate: (v) => !v ? "Required" : undefined }),
format: () => p.select({
message: "Default format:",
options: [
{ value: "table", label: "Table" },
{ value: "json", label: "JSON" },
],
}),
analytics: () => p.confirm({ message: "Enable analytics?" }),
})
p.outro(`Config saved! Format: ${config.format}`)
},
})
const main = defineCommand({
meta: { name: "pkgpulse", version: "1.0.0" },
subCommands: { setup },
})
runMain(main)
Feature Comparison
| Feature | citty | caporal | vorpal |
|---|---|---|---|
| TypeScript-first | ✅ | ❌ (@types) | ❌ |
| ESM support | ✅ | ❌ | ❌ |
| Zero dependencies | ✅ | ❌ | ❌ |
| Sub-commands | ✅ | ✅ | ✅ |
| Auto-generated help | ✅ | ✅ (colored) | ✅ |
| Argument validation | Type-based | Regex + types | Basic |
| Shell completions | ❌ | ✅ | ✅ |
| Interactive REPL | ❌ | ❌ | ✅ |
| Built-in prompts | ❌ | ❌ | ✅ |
| Actively maintained | ✅ | ⚠️ | ❌ |
| Weekly downloads | ~2M | ~100K | ~50K |
When to Use Each
Use citty if:
- Building a modern TypeScript CLI tool
- Want zero dependencies and small install footprint
- Already in the UnJS ecosystem (Nuxt, unbuild, nitro)
- Need composable sub-commands with typed arguments
Use caporal if:
- Existing project already using caporal
- Need built-in shell completion generation
- Want argument validation with regex patterns
- Don't need ESM or modern TypeScript support
Use vorpal if:
- Building an interactive terminal application (REPL-style)
- Legacy project — not recommended for new projects
- Better alternative: citty + @clack/prompts or Ink for modern interactive CLIs
For new projects in 2026:
- Simple CLI: citty or commander
- Complex CLI with plugins: oclif
- Interactive terminal UI: Ink or @clack/prompts + citty
- Quick prototype: cleye (TypeScript flag inference)
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on citty v0.x, caporal v2.x, and vorpal v1.x.