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" } The tsconfig.json for a CLI targets the Node release line you plan to support. Node 24 LTS handles ESM natively, so use "module":