DEV Community

Cover image for Building a production TypeScript CLI in 2026: oclif vs commander vs custom.
GDS K S
GDS K S

Posted on

Building a production TypeScript CLI in 2026: oclif vs commander vs custom.

Building a production TypeScript CLI in 2026: oclif vs commander vs custom.

I shipped my first Node CLI in 2019 with a 12-line arg slicer and process.argv. It worked until it needed a second command and then collapsed into spaghetti. The other extreme is grabbing a full framework for a tool that runs one command. In 2026 there are three reasonable paths between those extremes, and each one wins on a specific slice of the problem.

This post covers @oclif/core v4, commander v14, and a zero-dependency parser that fits in 30 lines. Same "greet" command in all three. Same distribution steps at the end. Honest tradeoffs throughout.

TL;DR

oclif v4 commander v14 zero-dep
npm install size ~8 MB ~220 kB 0 B
Type inference on flags Full, generated Good, manual Manual
Plugin ecosystem Yes (Heroku, Salesforce) No No
Learning curve High (day 1) Low (hour 1) None
Best for Multi-team, multi-command CLIs Most real-world tools One-shot scripts

1. The decision: framework vs no framework

Reach for a framework when the tool needs subcommands, a plugin system, or auto-generated help text. The second engineer who touches the CLI should be able to find where things live without reading your code twice.

Build your own when the tool does one thing, ships as a one-file script, or lives inside a monorepo where pulling in 8 MB of transitive deps is not welcome. A zero-dep parser also removes the surface area for supply-chain incidents, a real concern on tools that run in CI.

Commander sits in the middle: a 220 kB install that covers most real tools without the scaffolding overhead of oclif.

2. Project skeleton

Every path shares the same bin setup. Start with a package.json that declares the executable:

{
  "name": "greet-cli",
  "version": "1.0.0",
  "bin": {
    "greet": "./dist/cli.js"
  },
  "scripts": {
    "build": "tsc",
    "dev": "tsx src/cli.ts"
  },
  "type": "module"
}
Enter fullscreen mode Exit fullscreen mode

The tsconfig.json for a CLI targets the Node release line you plan to support. Node 24 LTS handles ESM natively, so use "module": "NodeNext" and "moduleResolution": "NodeNext":

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "strict": true,
    "declaration": true
  },
  "include": ["src"]
}
Enter fullscreen mode Exit fullscreen mode

The entry file needs a shebang on line one and must be executable after build:

#!/usr/bin/env node
// src/cli.ts
Enter fullscreen mode Exit fullscreen mode

After tsc, run chmod +x dist/cli.js once. In a proper CI pipeline, add that to the build script. npm link during development installs the greet binary into your PATH so you can test it as a real command.

3. The greet command, three ways

oclif v4

Scaffold with npx oclif generate greet-cli, then replace the generated command:

// src/commands/greet.ts
import { Args, Command, Flags } from "@oclif/core";

export default class Greet extends Command {
  static override description = "Print a greeting";

  static override args = {
    name: Args.string({ description: "Name to greet", required: true }),
  };

  static override flags = {
    loud: Flags.boolean({ char: "l", description: "Uppercase the output" }),
    times: Flags.integer({ char: "t", description: "Repeat N times", default: 1 }),
  };

  async run(): Promise<void> {
    const { args, flags } = await this.parse(Greet);
    const message = `Hello, ${args.name}!`;
    for (let i = 0; i < flags.times; i++) {
      this.log(flags.loud ? message.toUpperCase() : message);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Run it with ./bin/run.js greet Alice --loud --times 3. Help text generates automatically from the static properties. TypeScript infers the types on flags.times as number and flags.loud as boolean without any manual annotation.

The this.log and this.error methods route through oclif's output system, which makes testing easier: oclif provides a runCommand test helper that captures stdout without mocking console.

commander v14

Install: npm install commander. No generator needed.

#!/usr/bin/env node
// src/cli.ts
import { Command } from "commander";

const program = new Command();

program
  .name("greet")
  .description("Print a greeting")
  .version("1.0.0");

program
  .command("greet <name>")
  .description("Greet someone by name")
  .option("-l, --loud", "Uppercase the output")
  .option("-t, --times <n>", "Repeat N times", "1")
  .action((name: string, opts: { loud?: boolean; times: string }) => {
    const times = parseInt(opts.times, 10);
    const message = `Hello, ${name}!`;
    for (let i = 0; i < times; i++) {
      console.log(opts.loud ? message.toUpperCase() : message);
    }
  });

program.parse();
Enter fullscreen mode Exit fullscreen mode

The string-to-number conversion on opts.times is manual. Commander parses all option values as strings unless you supply a custom parser function. That is the primary friction point for TypeScript users: you get good autocomplete on the option names but the values carry a weaker type until you cast or coerce them.

Commander v14 added .argument() as a chainable first-class citizen, which reads cleaner than embedding arguments in the command string for complex cases. The core API has been stable since v8, so the learning investment carries forward.

Zero-dependency, 30 lines

No install. No generator. Drop this into src/cli.ts:

#!/usr/bin/env node

type ParsedArgs = {
  positional: string[];
  flags: Record<string, string | boolean>;
};

function parseArgs(argv: string[]): ParsedArgs {
  const positional: string[] = [];
  const flags: Record<string, string | boolean> = {};
  let i = 0;
  while (i < argv.length) {
    const arg = argv[i];
    if (arg.startsWith("--")) {
      const key = arg.slice(2);
      const next = argv[i + 1];
      if (next && !next.startsWith("-")) {
        flags[key] = next;
        i += 2;
      } else {
        flags[key] = true;
        i += 1;
      }
    } else if (arg.startsWith("-") && arg.length === 2) {
      flags[arg.slice(1)] = true;
      i += 1;
    } else {
      positional.push(arg);
      i += 1;
    }
  }
  return { positional, flags };
}

const { positional, flags } = parseArgs(process.argv.slice(2));
const [command, name] = positional;

if (command === "greet" && name) {
  const times = flags.times ? parseInt(flags.times as string, 10) : 1;
  const msg = `Hello, ${name}!`;
  for (let i = 0; i < times; i++) {
    console.log(flags.loud ? msg.toUpperCase() : msg);
  }
} else {
  console.log("Usage: greet greet <name> [--loud] [--times <n>]");
  process.exit(1);
}
Enter fullscreen mode Exit fullscreen mode

This handles --loud, --times 3, and positional args. It does not handle --times=3, short-form chaining (-lt), or negated flags (--no-loud). Add those if you need them. Each addition is about 5 lines and you understand every byte.

4. Subcommands, flags, and where each path struggles

Subcommands are where the paths diverge most sharply.

In oclif, each subcommand is a file in src/commands/. A file at src/commands/user/create.ts maps to mycli user create. The directory structure is the routing table. That pattern scales to 30 commands because you can grep for a file name.

In commander, subcommands chain off the root program:

const userCmd = program.command("user");
userCmd.command("create <email>").action((email) => { /* ... */ });
userCmd.command("delete <id>").action((id) => { /* ... */ });
Enter fullscreen mode Exit fullscreen mode

That works well up to around 10 subcommands in a single file. Past that, split into separate files and import each group, then register them. Commander does not enforce any file layout, so naming conventions matter more.

The zero-dep path requires a manual dispatch table. A switch on command covers five subcommands cleanly. Beyond five, the file grows fast and the argument parsing for each command needs its own handling. That is the natural ceiling where migrating to commander or oclif starts paying off.

Prompts (interactive input like password fields or selection lists) sit outside all three. None of them bundle an interactive prompt library. The standard pairing is inquirer for oclif and commander, or Node's built-in readline interface for the zero-dep path.

5. Distribution via npm

Publishing a CLI to npm follows the same steps regardless of which framework you chose.

Log in with npm login, then in package.json confirm:

{
  "name": "@yourscope/greet-cli",
  "version": "1.0.0",
  "bin": { "greet": "./dist/cli.js" },
  "files": ["dist"],
  "engines": { "node": ">=20" }
}
Enter fullscreen mode Exit fullscreen mode

The files array keeps the published tarball small: only dist/ ships, not src/, test files, or dev configs. The engines field documents the Node floor and causes npm install to warn on older versions.

Build and publish:

npm run build
chmod +x dist/cli.js
npm publish --access public
Enter fullscreen mode Exit fullscreen mode

For scoped packages (@yourscope/...), first publish needs --access public. Later publishes omit it.

Users install and run with:

npm install -g @yourscope/greet-cli
greet greet Alice --loud
Enter fullscreen mode Exit fullscreen mode

Or without a global install via npx:

npx @yourscope/greet-cli greet Alice --loud
Enter fullscreen mode Exit fullscreen mode

npx-only distribution is the right default for one-off tools. It avoids polluting the user's global PATH and always runs the version you specify. For tools a developer runs dozens of times a day, a global install still wins on startup time because npx runs a resolution step on every invocation.

If you are distributing a tool that should work offline or in air-gapped environments, vendor the dependencies into the published tarball with bundleDependencies in package.json. Oclif's generated scaffold includes this by default. Commander and zero-dep need it added manually.

6. Comparison

oclif v4 commander v14 zero-dep
Unpacked install size ~8 MB ~220 kB 0
TypeScript flag types Inferred, no casting Manual coercion for numbers Manual
Auto-generated help Yes, rich Yes, basic You write it
Subcommand routing File-based (scales) Code-based (works to ~10) Switch statement
Plugin system Yes No No
Interactive prompts Requires inquirer Requires inquirer readline built-in
Used by Heroku CLI, Salesforce CLI Dozens of open source tools Scripts, one-off tools
Breaking change cadence Moderate (major versions) Low (stable API since v8) None

The bundle size difference matters when the CLI runs inside a Docker image on a tight layer budget, or when install time in CI is a bottleneck. A full oclif project with its generator output and Heroku plugin dependencies can exceed 50 MB unpacked when counting transitive deps. Commander stays well under 1 MB including your own code.

The type inference gap matters when the team touches the CLI infrequently. With oclif, a new contributor gets full TypeScript hints on every flag value and hits a type error immediately when passing a string where a number belongs. With commander, the coercion is a runtime concern that TypeScript cannot see through without a cast.

The bottom line

Use oclif if you are building a CLI that a team of engineers will extend over time, already have the Heroku or Salesforce ecosystem in mind, or need a plugin architecture. The day-one overhead is real, and the generated scaffold is dense, but the structure pays off past the third command.

Use commander if you are building a real tool with 3 to 15 subcommands, want TypeScript without the framework overhead, and are comfortable writing a thin coercion layer for numeric options. It covers most real-world cases and the API has been stable long enough that StackOverflow has an answer for every edge case.

Build zero-dep if the tool does one thing, ships in a monorepo where dep hygiene is strict, or you want to understand exactly what runs in production. The ceiling is around five commands before the code fights you.

Node 24 LTS (v24.16.0) ships native ESM, native fetch, and a built-in test runner, which removes three common reasons to reach for dependencies in the first place. Whatever path you pick, the toolchain in 2026 is cleaner than 2022 by a wide margin.

What is the CLI in your current project running on? A raw process.argv slicer past the 100-line mark signals the time to pick a framework.


GDS K S ยท thegdsks.com ยท follow on X @thegdsks

The right CLI framework is the one that fits the command count, not the one with the best marketing page.

Top comments (0)