Skip to main content

Guide

citty vs caporal vs vorpal (2026)

Compare citty, caporal, and vorpal for building command-line tools in Node.js. Sub-commands, argument parsing, interactive prompts, and which CLI framework.

·PkgPulse Team·
0

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
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

Featurecittycaporalvorpal
TypeScript-first❌ (@types)
ESM support
Zero dependencies
Sub-commands
Auto-generated help✅ (colored)
Argument validationType-basedRegex + typesBasic
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)

TypeScript-First CLI Development

citty's TypeScript integration is a genuine differentiator. When you define arguments with type: "string" or type: "boolean" in defineCommand, the TypeScript types for args.fieldName are inferred correctly — no manual type annotations needed. This means tab completion in VS Code works for argument names and the TypeScript compiler catches typos in argument references. Commander.js (the most popular CLI framework in 2026) has solid TypeScript support via command.opts<OptionsType>() generics, but requires more boilerplate to achieve the same type safety. yargs has decent TypeScript support through method chaining but its inferred types have edge cases. citty's simpler object-based API lends itself to cleaner type inference out of the box, making it the best TypeScript ergonomics among lightweight CLI frameworks.

Distribution and Binary Packaging

Modern CLI tools are typically distributed as npm packages with a bin field in package.json, but the distribution story is evolving. Tools like pkg, bun build --compile, and deno compile can package a Node.js CLI into a single standalone binary that includes the Node.js runtime — users can install without having Node.js at all. citty's zero-dependency design is advantageous here: smaller install size, fewer modules to bundle, faster startup time. For npm-distributed CLIs, the bin entry in package.json maps a command name to a script file. For citty-based tools, this is typically just runMain(main) in a thin entry point. Tools using ESM (as citty and most modern packages do) must include a shebang line #!/usr/bin/env node at the top of the bin script and ensure the script is executable (chmod +x). The UnJS tooling (including citty) integrates with unbuild for correct ESM binary compilation.

Interactive Prompts and User Experience

vorpal's interactive REPL mode served a real need but its approach was ahead of its time and ultimately unmaintained. The modern equivalent combines separate, well-maintained libraries: @clack/prompts for beautiful, accessible interactive prompts, citty or commander for argument parsing, and ink (React for terminal UIs) for complex interactive displays. @clack/prompts in particular has become the standard for modern Node.js interactive CLIs — it provides select menus, text inputs, confirm dialogs, multi-select, and spinner displays with excellent keyboard navigation and screen reader accessibility. For CLIs that need a persistent REPL (like a database shell or debug interface), Node.js's built-in readline/promises module provides the foundations, and vorpal's approach of wrapping readline is still conceptually sound even if the library itself is unmaintained.

Testing CLI Applications

Testing CLI tools requires a different approach than testing library code. The recommended pattern is separating the CLI argument-parsing layer from the business logic that implements each command. Test the business logic functions directly with unit tests, and test the CLI integration with process-level tests that spawn a child process and assert on stdout/stderr. The execa library provides a clean API for spawning processes in tests: const result = await execa("mycli", ["analyze", "react", "--format", "json"]) returns { stdout, stderr, exitCode } that you can assert on. For citty-based CLIs, you can also call the run() function directly from tests if you mock the appropriate dependencies, bypassing process spawning for faster unit-level tests. Always test the --help output as a regression test — unintended changes to help text indicate interface changes that need documenting.

Configuration and Persistent State

Most non-trivial CLI tools need to persist user preferences between invocations — API keys, default output formats, preferred regions, authentication tokens. The standard pattern is storing configuration in a platform-appropriate location using the os.homedir() or environment-specific config directories. The conf library handles cross-platform config file location (following XDG on Linux, ~/Library/Application Support on macOS, %APPDATA% on Windows) and provides a simple key-value API with JSON serialization. For sensitive values like API keys, avoid storing them in plain config files and prefer environment variables or OS keychain integration via the keytar library. citty and commander don't handle configuration persistence — you add it alongside them using conf, dotenv, or cosmiconfig depending on whether configuration is per-user or per-project. This separation of concerns (argument parsing vs configuration management) keeps each library focused on its domain.

Error Handling and Exit Codes in CLI Tools

CLI tools communicate success or failure through exit codes — a convention every developer using your tool in scripts will rely on. Exit code 0 means success; non-zero means failure. Node.js exits with 0 by default, but unhandled promise rejections and uncaught exceptions now cause non-zero exits in Node.js 15+. citty's runMain handles this correctly: if your command's run() function throws or returns a rejected promise, citty catches it, prints the error, and exits with code 1. For more granular control — different exit codes for different error types (1 for general errors, 2 for invalid arguments, 127 for missing dependencies) — you call process.exit(code) explicitly. Document your CLI's exit codes in the help text or README so users can write reliable scripts that check $? after running your tool. Avoid swallowing errors silently in CLI tools; users who pipe output to other commands need accurate exit codes to implement proper error handling in their automation workflows.

Output Formatting and Terminal Detection

Good CLI output adapts to its environment. When stdout is a terminal, use colors and formatted tables. When stdout is piped to a file or another command, output plain text or JSON that downstream tools can parse reliably. The process.stdout.isTTY flag detects whether output is going to an interactive terminal. Libraries like chalk check this automatically and disable color codes when not in a TTY — install the NO_COLOR environment variable standard as an additional escape hatch. For structured output that tools like jq can process, offer a --json flag that switches to clean JSON output regardless of terminal state. citty's argument system makes this straightforward: format: { type: "string", default: "human" } gives users explicit control. Well-designed CLI tools are equally usable in interactive sessions and automated pipelines, which requires intentional design rather than hoping defaults work in both contexts.

Compare CLI frameworks and developer tooling on PkgPulse →

See also: cac vs meow vs arg 2026 and cosmiconfig vs lilconfig vs conf, archiver vs adm-zip vs JSZip (2026).

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.