Cli Tools

CLI Framework Comparison: Commander vs Yargs vs Oclif

An in-depth comparison of the three major Node.js CLI frameworks — Commander, Yargs, and Oclif — with real code, performance benchmarks, and decision criteria.

CLI Framework Comparison: Commander vs Yargs vs Oclif

Every Node.js CLI tool starts with the same question: should I parse arguments myself, or use a framework? Once you decide on a framework, you face three credible options. Commander is the veteran with 500 million weekly downloads. Yargs is the flexible alternative with powerful argument parsing. Oclif is Salesforce's opinionated framework that generates entire CLI project scaffolding.

I have shipped production tools with all three. Each excels in different scenarios. This guide builds the same CLI with each framework so you can see the differences in real code, then compares them on the dimensions that actually matter.

Prerequisites

  • Node.js installed (v14+)
  • npm installed
  • Experience building basic CLI tools
  • Understanding of command-line argument conventions

The Reference CLI

To compare fairly, we will build the same tool with each framework: a task manager CLI with these commands:

  • tasks list — List all tasks (with --status filter and --output format)
  • tasks add <title> — Add a new task (with --priority and --due options)
  • tasks done <id> — Mark a task as complete
  • tasks remove <id> — Delete a task
  • Global options: --verbose, --config, --help, --version

Commander

Commander is the most popular CLI framework in the Node.js ecosystem. It prioritizes simplicity and a clean, chainable API.

npm install commander

Building with Commander

#!/usr/bin/env node

var commander = require("commander");
var fs = require("fs");
var path = require("path");

var program = new commander.Command();

// Shared state
var configPath = "";
var verbose = false;

function loadTasks() {
  try {
    return JSON.parse(fs.readFileSync(configPath, "utf8"));
  } catch (e) {
    return [];
  }
}

function saveTasks(tasks) {
  fs.writeFileSync(configPath, JSON.stringify(tasks, null, 2));
}

function log(msg) {
  if (verbose) console.log("\u001b[90m[debug]\u001b[0m " + msg);
}

// Program setup
program
  .name("tasks")
  .description("Simple task manager CLI")
  .version("1.0.0")
  .option("-v, --verbose", "Show debug output")
  .option("-c, --config <path>", "Config file path", "./tasks.json")
  .hook("preAction", function(thisCommand) {
    var opts = thisCommand.opts();
    configPath = path.resolve(opts.config);
    verbose = opts.verbose || false;
    log("Config: " + configPath);
  });

// List command
program
  .command("list")
  .description("List all tasks")
  .option("-s, --status <status>", "Filter by status (open, done, all)", "all")
  .option("-o, --output <format>", "Output format (table, json)", "table")
  .action(function(options) {
    var tasks = loadTasks();
    log("Loaded " + tasks.length + " tasks");

    if (options.status !== "all") {
      tasks = tasks.filter(function(t) {
        return options.status === "done" ? t.done : !t.done;
      });
    }

    if (options.output === "json") {
      console.log(JSON.stringify(tasks, null, 2));
      return;
    }

    if (tasks.length === 0) {
      console.log("No tasks found.");
      return;
    }

    console.log("");
    for (var i = 0; i < tasks.length; i++) {
      var t = tasks[i];
      var status = t.done ? "\u001b[32m✔\u001b[0m" : "\u001b[90m○\u001b[0m";
      var priority = t.priority === "high" ? " \u001b[31m!\u001b[0m" : "";
      console.log("  " + status + " [" + t.id + "] " + t.title + priority);
    }
    console.log("");
  });

// Add command
program
  .command("add <title>")
  .description("Add a new task")
  .option("-p, --priority <level>", "Priority (low, medium, high)", "medium")
  .option("-d, --due <date>", "Due date (YYYY-MM-DD)")
  .action(function(title, options) {
    var tasks = loadTasks();
    var id = tasks.length > 0 ? Math.max.apply(null, tasks.map(function(t) { return t.id; })) + 1 : 1;

    var task = {
      id: id,
      title: title,
      priority: options.priority,
      due: options.due || null,
      done: false,
      created: new Date().toISOString()
    };

    tasks.push(task);
    saveTasks(tasks);
    console.log("\u001b[32m✔\u001b[0m Added task [" + id + "]: " + title);
  });

// Done command
program
  .command("done <id>")
  .description("Mark a task as complete")
  .action(function(id) {
    var tasks = loadTasks();
    var task = tasks.find(function(t) { return t.id === parseInt(id, 10); });

    if (!task) {
      console.error("Task not found: " + id);
      process.exit(1);
    }

    task.done = true;
    saveTasks(tasks);
    console.log("\u001b[32m✔\u001b[0m Completed: " + task.title);
  });

// Remove command
program
  .command("remove <id>")
  .description("Delete a task")
  .action(function(id) {
    var tasks = loadTasks();
    var index = tasks.findIndex(function(t) { return t.id === parseInt(id, 10); });

    if (index === -1) {
      console.error("Task not found: " + id);
      process.exit(1);
    }

    var removed = tasks.splice(index, 1)[0];
    saveTasks(tasks);
    console.log("\u001b[31m✖\u001b[0m Removed: " + removed.title);
  });

program.parse(process.argv);

Commander Strengths

  • Minimal boilerplate. Commands are defined with chained method calls. The API is self-documenting.
  • Auto-generated help. Commander produces clean, standard help output from your command definitions.
  • Git-style subcommands. Commands can be split into separate files with program.command("deploy", "Deploy the app").
  • TypeScript support. Built-in type definitions with good IntelliSense.
  • Small footprint. Zero dependencies beyond Node.js built-ins.

Commander Weaknesses

  • Limited argument validation. You have to validate argument values manually.
  • No built-in completion. Shell completions require external packages or manual implementation.
  • String-based option parsing. Commander does not coerce types automatically — numbers come as strings unless you add a custom parser.

Yargs

Yargs offers more powerful argument parsing with built-in validation, type coercion, and middleware.

npm install yargs

Building with Yargs

#!/usr/bin/env node

var yargs = require("yargs/yargs");
var helpers = require("yargs/helpers");
var fs = require("fs");
var path = require("path");

var configPath = "";
var verbose = false;

function loadTasks() {
  try {
    return JSON.parse(fs.readFileSync(configPath, "utf8"));
  } catch (e) {
    return [];
  }
}

function saveTasks(tasks) {
  fs.writeFileSync(configPath, JSON.stringify(tasks, null, 2));
}

function log(msg) {
  if (verbose) console.log("\u001b[90m[debug]\u001b[0m " + msg);
}

yargs(helpers.hideBin(process.argv))
  .scriptName("tasks")
  .usage("$0 <command> [options]")
  .version("1.0.0")

  // Global options
  .option("verbose", {
    alias: "v",
    type: "boolean",
    description: "Show debug output"
  })
  .option("config", {
    alias: "c",
    type: "string",
    default: "./tasks.json",
    description: "Config file path"
  })

  // Middleware: runs before every command
  .middleware(function(argv) {
    configPath = path.resolve(argv.config);
    verbose = argv.verbose || false;
    log("Config: " + configPath);
  })

  // List command
  .command("list", "List all tasks", function(yargs) {
    return yargs
      .option("status", {
        alias: "s",
        type: "string",
        choices: ["open", "done", "all"],
        default: "all",
        description: "Filter by status"
      })
      .option("output", {
        alias: "o",
        type: "string",
        choices: ["table", "json"],
        default: "table",
        description: "Output format"
      });
  }, function(argv) {
    var tasks = loadTasks();
    log("Loaded " + tasks.length + " tasks");

    if (argv.status !== "all") {
      tasks = tasks.filter(function(t) {
        return argv.status === "done" ? t.done : !t.done;
      });
    }

    if (argv.output === "json") {
      console.log(JSON.stringify(tasks, null, 2));
      return;
    }

    if (tasks.length === 0) {
      console.log("No tasks found.");
      return;
    }

    console.log("");
    for (var i = 0; i < tasks.length; i++) {
      var t = tasks[i];
      var status = t.done ? "\u001b[32m✔\u001b[0m" : "\u001b[90m○\u001b[0m";
      var priority = t.priority === "high" ? " \u001b[31m!\u001b[0m" : "";
      console.log("  " + status + " [" + t.id + "] " + t.title + priority);
    }
    console.log("");
  })

  // Add command
  .command("add <title>", "Add a new task", function(yargs) {
    return yargs
      .positional("title", {
        describe: "Task title",
        type: "string"
      })
      .option("priority", {
        alias: "p",
        type: "string",
        choices: ["low", "medium", "high"],
        default: "medium",
        description: "Priority level"
      })
      .option("due", {
        alias: "d",
        type: "string",
        description: "Due date (YYYY-MM-DD)"
      })
      .check(function(argv) {
        if (argv.due && !/^\d{4}-\d{2}-\d{2}$/.test(argv.due)) {
          throw new Error("Due date must be in YYYY-MM-DD format");
        }
        return true;
      });
  }, function(argv) {
    var tasks = loadTasks();
    var id = tasks.length > 0 ? Math.max.apply(null, tasks.map(function(t) { return t.id; })) + 1 : 1;

    var task = {
      id: id,
      title: argv.title,
      priority: argv.priority,
      due: argv.due || null,
      done: false,
      created: new Date().toISOString()
    };

    tasks.push(task);
    saveTasks(tasks);
    console.log("\u001b[32m✔\u001b[0m Added task [" + id + "]: " + argv.title);
  })

  // Done command
  .command("done <id>", "Mark a task as complete", function(yargs) {
    return yargs.positional("id", {
      describe: "Task ID",
      type: "number"
    });
  }, function(argv) {
    var tasks = loadTasks();
    var task = tasks.find(function(t) { return t.id === argv.id; });

    if (!task) {
      console.error("Task not found: " + argv.id);
      process.exit(1);
    }

    task.done = true;
    saveTasks(tasks);
    console.log("\u001b[32m✔\u001b[0m Completed: " + task.title);
  })

  // Remove command
  .command("remove <id>", "Delete a task", function(yargs) {
    return yargs.positional("id", {
      describe: "Task ID",
      type: "number"
    });
  }, function(argv) {
    var tasks = loadTasks();
    var index = tasks.findIndex(function(t) { return t.id === argv.id; });

    if (index === -1) {
      console.error("Task not found: " + argv.id);
      process.exit(1);
    }

    var removed = tasks.splice(index, 1)[0];
    saveTasks(tasks);
    console.log("\u001b[31m✖\u001b[0m Removed: " + removed.title);
  })

  .demandCommand(1, "You must specify a command")
  .recommendCommands()
  .strict()
  .help()
  .argv;

Yargs Strengths

  • Built-in validation. The choices, check, and coerce options handle validation declaratively. Invalid inputs are rejected with helpful messages.
  • Type coercion. Numbers are automatically parsed as numbers, booleans as booleans. No manual parsing needed.
  • recommendCommands(). Yargs has built-in typo detection — tasks lisst suggests list.
  • Middleware. Run shared logic before every command without repeating code.
  • Completion support. yargs.completion() generates bash completion scripts.

Yargs Weaknesses

  • Larger dependency tree. Yargs pulls in several sub-packages. The total install size is larger than Commander.
  • API verbosity. Defining commands with builder functions creates deeper nesting than Commander's chained API.
  • Learning curve. The API surface is large. Features like coerce, implies, conflicts, and normalize take time to discover.

Oclif

Oclif (Open CLI Framework) from Salesforce is a full framework, not just an argument parser. It generates project structure, handles plugins, and enforces conventions.

npx oclif generate tasks-cli
cd tasks-cli

Building with Oclif

Oclif uses a class-based pattern with one file per command. Note: Oclif strongly favors TypeScript, but it works with JavaScript too.

// src/commands/list.js

var Command = require("@oclif/core").Command;
var Flags = require("@oclif/core").Flags;
var fs = require("fs");
var path = require("path");

function loadTasks(configPath) {
  try {
    return JSON.parse(fs.readFileSync(configPath, "utf8"));
  } catch (e) {
    return [];
  }
}

var ListCommand = {
  description: "List all tasks",

  flags: {
    status: Flags.string({
      char: "s",
      description: "Filter by status",
      options: ["open", "done", "all"],
      default: "all"
    }),
    output: Flags.string({
      char: "o",
      description: "Output format",
      options: ["table", "json"],
      default: "table"
    })
  },

  run: function() {
    return async function() {
      var parsed = await this.parse(ListCommand);
      var flags = parsed.flags;
      var configPath = path.resolve("./tasks.json");
      var tasks = loadTasks(configPath);

      if (flags.status !== "all") {
        tasks = tasks.filter(function(t) {
          return flags.status === "done" ? t.done : !t.done;
        });
      }

      if (flags.output === "json") {
        this.log(JSON.stringify(tasks, null, 2));
        return;
      }

      if (tasks.length === 0) {
        this.log("No tasks found.");
        return;
      }

      for (var i = 0; i < tasks.length; i++) {
        var t = tasks[i];
        var status = t.done ? "✔" : "○";
        this.log("  " + status + " [" + t.id + "] " + t.title);
      }
    };
  }
};

In practice, most Oclif projects use TypeScript classes:

// Typical Oclif command structure (conceptual, uses class syntax)
// src/commands/add.js

var Command = require("@oclif/core").Command;
var Args = require("@oclif/core").Args;
var Flags = require("@oclif/core").Flags;

// Oclif generates this boilerplate for you
// Each command is a separate file in src/commands/
// Nested directories create subcommands: src/commands/config/show.js -> mytool config show

Oclif Project Structure

tasks-cli/
  bin/
    run.js          # Entry point
  src/
    commands/
      list.js       # tasks list
      add.js        # tasks add
      done.js       # tasks done
      remove.js     # tasks remove
      config/
        show.js     # tasks config show
        set.js      # tasks config set
    hooks/
      init.js       # Runs before every command
  package.json
  oclif.manifest.json  # Command manifest (generated)

Oclif Strengths

  • Full project scaffolding. oclif generate creates the entire project with CI, tests, and release tooling.
  • Plugin system. Users can install plugins that add commands to your CLI. Salesforce CLI uses this extensively.
  • Auto-generated docs. The oclif readme command generates markdown documentation from your command definitions.
  • Built-in testing utilities. Each command can be tested in isolation with @oclif/test.
  • Performance at scale. Oclif lazy-loads commands, so a CLI with 200 commands still starts fast.

Oclif Weaknesses

  • Heavy. The framework has many dependencies. Install size is significantly larger than Commander or Yargs.
  • Opinionated structure. You must follow Oclif's conventions. One command per file, specific directory layout, manifest generation.
  • TypeScript bias. While JavaScript works, documentation and examples assume TypeScript. The class-based API feels foreign in plain JS.
  • Startup overhead. For simple tools, the framework initialization adds measurable startup latency.

Head-to-Head Comparison

Feature Matrix

Feature                Commander    Yargs       Oclif
───────────────────────────────────────────────────────
Argument parsing       ✔            ✔           ✔
Type coercion          Manual       Built-in    Built-in
Choices validation     Manual       Built-in    Built-in
Typo suggestions       No           Built-in    No
Subcommands            ✔            ✔           ✔ (file-based)
Middleware             Hooks        ✔           Hooks
Shell completion       Manual       Built-in    Built-in
Plugin system          No           No          ✔
Project scaffolding    No           No          ✔
Auto-generated docs    No           No          ✔
Testing utilities      No           No          ✔
Dependencies           0            ~7          ~30
Weekly downloads       ~500M        ~80M        ~2M

Startup Performance

Startup time matters for CLIs that run frequently. Measured on a 2023 MacBook Pro with Node.js 20:

Framework        --version time    --help time    Command time
──────────────────────────────────────────────────────────────
No framework     12ms              12ms           15ms
Commander        18ms              22ms           25ms
Yargs            35ms              42ms           48ms
Oclif            85ms              120ms          135ms

Commander is nearly invisible in terms of overhead. Yargs adds about 20ms. Oclif adds 70-100ms, which is noticeable in shell scripts that call your tool repeatedly.

Install Size

# Fresh install sizes (node_modules)
commander:    180 KB  (0 dependencies)
yargs:        850 KB  (7 dependencies)
@oclif/core:  12 MB   (30+ dependencies)

Code Volume

Lines of code to implement the reference task manager:

Framework     Lines    Files
──────────────────────────────
Commander     120      1
Yargs         135      1
Oclif         200      5+

Commander and Yargs let you build a complete CLI in a single file. Oclif requires separate files per command and configuration files.

When to Use Each

Use Commander When

  • You are building a simple to medium-complexity CLI (1-20 commands)
  • You want minimal dependencies and fast startup
  • You prefer a clean, chainable API
  • You do not need plugins or auto-generated documentation
  • You are comfortable writing your own validation
// Commander is ideal for this pattern:
var program = require("commander");

program
  .command("deploy <target>")
  .option("--dry-run", "Preview changes")
  .action(function(target, opts) {
    // Simple, direct, done
  });

program.parse();

Use Yargs When

  • You need rich argument validation and type coercion
  • You want built-in typo correction and shell completions
  • Your CLI has complex argument relationships (implies, conflicts)
  • You are building developer tools where input validation matters
  • You want middleware for shared pre-command logic
// Yargs is ideal for this pattern:
require("yargs/yargs")(process.argv.slice(2))
  .command("deploy <target>", "Deploy app", function(yargs) {
    return yargs
      .positional("target", { choices: ["staging", "production"] })
      .option("replicas", { type: "number", default: 3 })
      .check(function(argv) {
        if (argv.target === "production" && argv.replicas < 2) {
          throw new Error("Production needs at least 2 replicas");
        }
        return true;
      });
  }, handler)
  .strict()
  .recommendCommands()
  .argv;

Use Oclif When

  • You are building a large CLI with 20+ commands
  • You need a plugin system for extensibility
  • You want auto-generated documentation and man pages
  • Your organization needs standardized CLI structure across teams
  • You are building a commercial CLI product (Salesforce, Heroku-style)

Migration Patterns

Commander to Yargs

// Commander
program
  .command("deploy <target>")
  .option("-f, --force", "Skip confirmation")
  .option("-r, --replicas <n>", "Number of replicas", parseInt)
  .action(function(target, opts) { /* ... */ });

// Yargs equivalent
yargs.command("deploy <target>", "Deploy app", function(y) {
  return y
    .positional("target", { type: "string" })
    .option("force", { alias: "f", type: "boolean" })
    .option("replicas", { alias: "r", type: "number" });
}, function(argv) { /* ... */ });

Yargs to Commander

// Yargs
yargs.option("output", {
  choices: ["json", "table"],
  default: "table"
});

// Commander equivalent (manual validation)
program.option("-o, --output <format>", "Output format", "table");
// Then validate manually in the action:
if (["json", "table"].indexOf(opts.output) === -1) {
  console.error("Invalid output format");
  process.exit(1);
}

Complete Working Example: Multi-Framework Benchmark

#!/usr/bin/env node

// benchmark.js - Compare startup time of each framework

var childProcess = require("child_process");
var fs = require("fs");
var path = require("path");

function timeCommand(cmd, iterations) {
  var times = [];
  for (var i = 0; i < iterations; i++) {
    var start = process.hrtime.bigint();
    childProcess.execSync(cmd, { stdio: "pipe" });
    var end = process.hrtime.bigint();
    times.push(Number(end - start) / 1e6); // Convert to ms
  }

  times.sort(function(a, b) { return a - b; });
  // Remove outliers (first and last)
  var trimmed = times.slice(1, -1);
  var avg = trimmed.reduce(function(s, t) { return s + t; }, 0) / trimmed.length;
  var median = trimmed[Math.floor(trimmed.length / 2)];

  return {
    avg: avg.toFixed(1),
    median: median.toFixed(1),
    min: trimmed[0].toFixed(1),
    max: trimmed[trimmed.length - 1].toFixed(1)
  };
}

var iterations = 20;

console.log("\nBenchmarking CLI frameworks (" + iterations + " iterations each)\n");

var frameworks = [
  { name: "node (baseline)", cmd: "node -e \"process.exit(0)\"" },
  { name: "commander", cmd: "node -e \"require('commander')\"" },
  { name: "yargs", cmd: "node -e \"require('yargs')\"" }
];

for (var i = 0; i < frameworks.length; i++) {
  var fw = frameworks[i];
  try {
    var result = timeCommand(fw.cmd, iterations);
    console.log(
      "  " + fw.name.padEnd(20) +
      " avg: " + result.avg.padStart(6) + "ms" +
      "  median: " + result.median.padStart(6) + "ms" +
      "  range: " + result.min + "-" + result.max + "ms"
    );
  } catch (e) {
    console.log("  " + fw.name.padEnd(20) + " (not installed)");
  }
}

console.log("");

Common Issues and Troubleshooting

Commander option values coming as true instead of the value

This happens when you forget the <value> placeholder in the option definition:

// Wrong: --output gets true instead of "json"
program.option("--output", "Output format");

// Correct: --output gets the value
program.option("--output <format>", "Output format");

Yargs strict() rejecting valid options

When using strict(), any option not explicitly defined causes an error. Global options not repeated in subcommands get rejected:

Fix: Define shared options at the top level with .option() before .command(), or use .global().

Oclif commands not found

Oclif uses a manifest file to discover commands. If you add a new command and it is not found:

npx oclif manifest

Fix: Regenerate the manifest after adding commands. Add this to your build step.

Commander subcommand help shows wrong info

Commander processes help at the program level. Subcommand help requires calling .helpCommand():

Fix: Use program.helpCommand("help [command]", "Show help") or add .addHelpText() to individual commands.

Best Practices

  • Start with Commander for most projects. It covers 90% of use cases with the least overhead. Switch to Yargs or Oclif only when you need their specific features.
  • Use Yargs when input validation is critical. If your CLI accepts complex arguments that need type checking, range validation, or mutual exclusion, Yargs saves significant code.
  • Choose Oclif for enterprise CLIs. If you are building a CLI that will have plugins, auto-updates, and 50+ commands, Oclif's structure pays for itself.
  • Benchmark startup time. If your tool runs in tight loops or shell scripts, the 100ms Oclif overhead adds up. Commander's near-zero overhead is a genuine advantage.
  • Do not mix frameworks. Stick with one. Converting between them later is straightforward — the command definitions are similar across all three.
  • Test your CLI programmatically. All three frameworks support testing. Commander and Yargs can parse argument arrays. Oclif has dedicated testing utilities.

References

Powered by Contentful