Skip to main content

Guide

cac vs meow vs arg: Lightweight CLI Parsing 2026

cac, meow, and arg compared for lightweight Node.js CLI argument parsing in 2026. TypeScript support, size tradeoffs, and when to skip commander or yargs.

·PkgPulse Team·
0

TL;DR

cac is the lightweight CLI framework — sub-commands, options, help generation, used by Vite and Vitest internally. meow is the CLI helper from Sindre Sorhus — parses flags, auto-generates help from a string template, handles common patterns cleanly. arg is the zero-dependency argument parser — type coercion, aliasing, strict mode, nothing more. In 2026: cac for Vite-ecosystem tools needing sub-commands, meow for simple CLIs with pretty help output, arg for the most minimal argument parsing.

Key Takeaways

  • cac: ~10M weekly downloads — used by Vite, Vitest, sub-commands + options, ~3 KB
  • meow: ~8M weekly downloads — Sindre Sorhus, string-based help, flag parsing, ~5 KB
  • arg: ~10M weekly downloads — zero deps, type coercion, strict parsing, ~2 KB
  • All three are lightweight alternatives to commander (~60M) and yargs (~80M)
  • cac is the only one with sub-command support
  • arg is the most minimal — just parsing, no help generation

arg

arg — minimal argument parser:

Basic usage

import arg from "arg"

const args = arg({
  // Type coercion:
  "--port": Number,
  "--host": String,
  "--verbose": Boolean,
  "--config": String,

  // Aliases:
  "-p": "--port",
  "-h": "--host",
  "-v": "--verbose",
  "-c": "--config",
})

// npx my-cli --port 3000 --verbose src/index.ts
console.log(args)
// {
//   "--port": 3000,
//   "--verbose": true,
//   _: ["src/index.ts"]    // Positional arguments
// }

Strict mode

import arg from "arg"

// Strict mode — throws on unknown flags:
const args = arg({
  "--port": Number,
  "--verbose": Boolean,
}, {
  permissive: false,  // Default: throws on unknown args
})

// npx my-cli --unknown-flag
// ❌ Error: Unknown or unexpected option: --unknown-flag

// Permissive mode — collects unknown args:
const args2 = arg({
  "--port": Number,
}, {
  permissive: true,
})
// Unknown flags end up in args._

Array and custom types

import arg from "arg"

// Array (collect multiple values):
const args = arg({
  "--file": [String],       // Collects: --file a.ts --file b.ts
  "--exclude": [String],
  "--port": Number,
})
// args["--file"] → ["a.ts", "b.ts"]

// Custom type coercion:
function parseDate(value: string): Date {
  const date = new Date(value)
  if (isNaN(date.getTime())) throw new Error(`Invalid date: ${value}`)
  return date
}

const args2 = arg({
  "--since": parseDate,
  "--count": Number,
})
// npx my-cli --since 2026-01-01
// args2["--since"] → Date object

Real-world example

import arg from "arg"

function parseArgs() {
  const args = arg({
    "--help": Boolean,
    "--version": Boolean,
    "--format": String,
    "--output": String,
    "--verbose": Boolean,
    "--limit": Number,

    "-h": "--help",
    "-V": "--version",
    "-f": "--format",
    "-o": "--output",
    "-v": "--verbose",
    "-n": "--limit",
  })

  if (args["--help"]) {
    console.log(`
  Usage: pkgpulse [options] <package>

  Options:
    -f, --format   Output format (json, table, csv)
    -o, --output   Output file path
    -n, --limit    Max results (default: 10)
    -v, --verbose  Verbose output
    -h, --help     Show help
    -V, --version  Show version
    `)
    process.exit(0)
  }

  return {
    packages: args._,
    format: args["--format"] ?? "table",
    output: args["--output"],
    verbose: args["--verbose"] ?? false,
    limit: args["--limit"] ?? 10,
  }
}

cac

cac — lightweight CLI framework:

Basic usage

import cac from "cac"

const cli = cac("pkgpulse")

cli
  .option("--format <format>", "Output format", { default: "table" })
  .option("--verbose", "Verbose output")
  .option("--limit <count>", "Max results", { default: 10 })

cli
  .command("<package>", "Analyze a package")
  .action((package_, options) => {
    console.log(`Analyzing ${package_}...`)
    console.log(`Format: ${options.format}`)
    console.log(`Limit: ${options.limit}`)
  })

cli.help()
cli.version("1.0.0")
cli.parse()

Sub-commands

import cac from "cac"

const cli = cac("pkgpulse")

// Analyze command:
cli
  .command("analyze <package>", "Analyze package health")
  .option("--depth <depth>", "Analysis depth", { default: "full" })
  .option("--format <format>", "Output format", { default: "table" })
  .action((package_, options) => {
    console.log(`Analyzing ${package_} (${options.depth})`)
  })

// Compare command:
cli
  .command("compare <pkg1> <pkg2>", "Compare two packages")
  .option("--metric <metric>", "Comparison metric", { default: "all" })
  .action((pkg1, pkg2, options) => {
    console.log(`Comparing ${pkg1} vs ${pkg2} (${options.metric})`)
  })

// Search command:
cli
  .command("search <query>", "Search packages")
  .option("--limit <count>", "Max results", { default: 10 })
  .action((query, options) => {
    console.log(`Searching: ${query} (limit: ${options.limit})`)
  })

// Default command (no sub-command):
cli
  .command("", "Show help")
  .action(() => cli.outputHelp())

cli.help()
cli.version("1.0.0")
cli.parse()
pkgpulse analyze react --format json
pkgpulse compare react vue --metric downloads
pkgpulse search "state management" --limit 5
pkgpulse --help

Why Vite uses cac

Vite CLI:
  vite              → start dev server
  vite build        → production build
  vite preview      → preview build
  vite optimize     → pre-bundle deps

cac provides:
  ✅ Sub-commands (build, preview, optimize)
  ✅ Auto-generated --help
  ✅ Type coercion for options
  ✅ Tiny (~3 KB) — keeps Vite install small
  ✅ No dependencies

vs commander/yargs:
  commander ~7 KB, yargs ~30 KB + deps
  cac has everything Vite needs in 3 KB

meow

meow — CLI helper:

Basic usage

import meow from "meow"

const cli = meow(`
  Usage
    $ pkgpulse <package>

  Options
    --format, -f   Output format (json, table, csv)
    --limit, -n    Max results (default: 10)
    --verbose, -v  Verbose output

  Examples
    $ pkgpulse react
    $ pkgpulse react --format json
    $ pkgpulse react vue --limit 5
`, {
  importMeta: import.meta,
  flags: {
    format: {
      type: "string",
      shortFlag: "f",
      default: "table",
    },
    limit: {
      type: "number",
      shortFlag: "n",
      default: 10,
    },
    verbose: {
      type: "boolean",
      shortFlag: "v",
      default: false,
    },
  },
})

// cli.input → positional args: ["react"] or ["react", "vue"]
// cli.flags → parsed flags: { format: "table", limit: 10, verbose: false }

console.log(`Packages: ${cli.input.join(", ")}`)
console.log(`Format: ${cli.flags.format}`)

Flag types

import meow from "meow"

const cli = meow(`Usage: ...`, {
  importMeta: import.meta,
  flags: {
    name: {
      type: "string",
      isRequired: true,  // Error if missing
    },
    count: {
      type: "number",
      default: 1,
    },
    tags: {
      type: "string",
      isMultiple: true,  // Collects: --tags a --tags b
    },
    verbose: {
      type: "boolean",
      default: false,
    },
  },
})

// cli.flags.tags → ["a", "b"]

Auto features

// meow automatically handles:

// --help → prints the help string you provided
// --version → prints version from package.json
// Unknown flags → ignored (or error with allowUnknownFlags: false)
// camelCase conversion → --my-flag becomes cli.flags.myFlag

const cli = meow(`...`, {
  importMeta: import.meta,
  allowUnknownFlags: false,  // Strict mode
  autoHelp: true,             // --help (default)
  autoVersion: true,          // --version (default)
  flags: { /* ... */ },
})

Feature Comparison

Featurecacmeowarg
Sub-commands
Auto help✅ (string)❌ (manual)
Auto version
Flag types
Array flags
Required flags
Custom types
Strict mode
Dependencies0Few0
Size~3 KB~5 KB~2 KB
Used byVite, VitestSindre toolsVercel, Next.js
Weekly downloads~10M~8M~10M

When to Use Each

Use cac if:

  • Need sub-commands (like Vite: vite build, vite preview)
  • Want auto-generated help with minimal setup
  • Building Vite/Vitest ecosystem tools
  • Need the lightest option that still has sub-commands

Use meow if:

  • Want pretty help text from a string template
  • Building simple CLIs (no sub-commands needed)
  • Need required flag validation
  • In the Sindre Sorhus ecosystem

Use arg if:

  • Want the absolute minimum — just argument parsing
  • Need custom type coercion functions
  • Want strict mode to reject unknown flags
  • Building TypeScript-first tools (great type inference)
  • Don't need help generation (you'll write it yourself)

For larger CLIs:

  • commander — if you need extensive help formatting and options
  • yargs — if you need middleware, completion, and complex parsing
  • oclif — if you need plugins, hooks, and enterprise features
  • citty — if you want modern UnJS-style CLI definitions

Error Handling and User Experience in CLI Tools

All three parsers handle the mechanics of argument parsing, but they diverge sharply in how they communicate errors to users. This matters because CLI error messages are often the first and only documentation a user encounters.

arg throws an ArgError with a code property when it encounters an unknown flag in strict mode or receives a value that fails type coercion. The error message is terse and technical by design — arg expects you to catch the error and format a friendly message yourself. This gives maximum control at the cost of requiring more boilerplate in your CLI entry point. A solid pattern is wrapping your main function in a try/catch that checks err.code === "ARG_UNKNOWN_OPTION" and prints a usage hint before exiting with code 1.

meow handles unknown flags by either ignoring them (default) or throwing when allowUnknownFlags: false is set. The error output uses the help text you provided — when an unknown flag is passed with strict mode enabled, meow prints your full help string automatically before exiting. This is dramatically better UX than raw arg errors, but it means your help string must be comprehensive. If your help string is incomplete, meow's error fallback reveals those gaps to users.

cac's error handling sits between the two. It generates errors with descriptive messages for missing required arguments (when you mark a positional <arg> as required by angle-bracket syntax) and for type mismatches in options. The auto-generated help output formats option descriptions, default values, and sub-command lists in a consistent table. For Vite-style CLIs where the tool ships to end users who may not be developers, cac's combination of descriptive errors and structured help output is the most polished of the three without any custom error handling code.


Real-World Ecosystem Usage and Bundle Impact

The download numbers for all three packages significantly exceed the number of end-user projects that depend on them directly. Most downloads are transitive — arg is bundled into the Next.js CLI, cac powers the Vite and Vitest binaries, and meow appears throughout the Sindre Sorhus utility ecosystem (np, trash-cli, speed-test, and dozens more).

This transitive usage has a practical implication: if your project already depends on Vite or Vitest, cac is already in your node_modules at zero additional cost. Using cac in your own Vite plugin's CLI is therefore free in terms of bundle impact. Similarly, if you are building a Next.js plugin that exposes a CLI, arg is already available.

For projects targeting a published npm package with a CLI binary, bundle size matters more than for internal tools. arg at ~2 KB gzipped is the clear winner here — it adds negligible weight to any package. meow at ~5 KB includes its help formatting logic, which is worthwhile if that formatting saves you from writing equivalent code yourself. cac at ~3 KB hits a sweet spot: sub-commands, help generation, and type coercion in a footprint barely larger than arg.

All three packages are zero-dependency or near-zero-dependency, which matters for supply chain security audits. Heavy CLI frameworks like yargs pull in dozens of transitive dependencies (string-width, wrap-ansi, camelcase, decamelize, and more), creating a non-trivial attack surface. For a CLI that ships as part of a published npm package, choosing arg, cac, or meow keeps your dependency tree shallow and auditable.


TypeScript Integration and Type Inference Patterns

Each parser's TypeScript story differs in ways that matter for large projects.

arg provides the tightest type inference. The spec object you pass to arg() directly determines the return type — { "--port": Number } produces { "--port": number } with no manual typing required. Custom type coercion functions carry their return type through: { "--since": parseDate } correctly types args["--since"] as Date. This inference works without any explicit generics, which means arg configurations read cleanly even in large codebases.

meow generates types from the flags configuration object. The type: "string" | "boolean" | "number" field maps to TypeScript primitives, and isMultiple: true correctly types the flag as an array. However, meow requires the importMeta: import.meta option to function correctly in ESM, and older meow v11 codebases using CommonJS need to switch to {importMeta: {url: 'file://'}} when migrating to ESM. This migration step catches teams off guard when upgrading.

cac's TypeScript support is good but slightly weaker on return types — the parsed options object in .action() callbacks is typed as any without additional generic annotations. For small CLIs this is fine, but for complex tools with many options, consider wrapping cac's parsed output with a Zod schema validation step that both validates at runtime and narrows TypeScript types precisely.


Security Considerations for CLI Argument Parsing

A less-discussed but real concern: argument injection in CLI tools that accept user-supplied arguments and pass them to shell commands or child processes. If your CLI takes a --path flag and passes it to child_process.exec(), a malicious input like ../../etc/passwd; rm -rf / can execute arbitrary commands.

All three libraries (cac, meow, arg) parse arguments but do not sanitize them. The security responsibility lies entirely with the application code. The safe pattern is to use child_process.execFile() (which does not invoke a shell) instead of exec(), and validate/whitelist all argument values before passing them to any OS-level operation. This is a critical concern for CLI tools intended for use in CI pipelines or automated environments where argument sources may not be fully trusted.

Methodology

Download data from npm registry (weekly average, February 2026). Feature comparison based on cac v6.x, meow v13.x, and arg v5.x.

Compare CLI tooling and developer utilities on PkgPulse →

See also: cosmiconfig vs lilconfig vs conf and unimport vs unplugin-auto-import vs babel-plugin-auto-import: Auto-Importing in JavaScript 2026, 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.