TL;DR
oclif is the enterprise-grade CLI framework from Salesforce — powers the Heroku CLI, Salesforce CLI, and many others. Plugin system, auto-generated help, command classes with typed flags. gluegun is the developer-friendly toolkit — ships with prompts, filesystem utilities, template generation, and a plugin system out of the box. cleye is the modern, minimal TypeScript-first flag parser and command router — no frills, zero runtime dependencies, excellent type inference. In 2026: oclif for large multi-command CLIs, cleye for small-to-medium tools, gluegun if you need code generation in your CLI.
Key Takeaways
- oclif: ~300K weekly downloads — Salesforce, class-based commands, plugin ecosystem, generators
- gluegun: ~100K weekly downloads — all-in-one toolkit, template generation, prompts, spinner built in
- cleye: ~100K weekly downloads — minimal, TypeScript-first, zero deps, excellent flag type inference
- oclif uses class-based commands — each command is a class with a static
run()method - gluegun ships with file system, HTTP, prompts, print, semver utilities — batteries included
- cleye focuses purely on flag parsing and command routing — bring your own prompts/spinner
When to Use a CLI Framework
When NOT to use a framework:
Simple one-command scripts → commander.js or yargs is fine
Internal dev scripts → plain node:parseArgs or even process.argv
When a framework helps:
Multi-command CLIs (git-style: mycli init, mycli deploy, mycli status)
CLIs with plugin systems (third parties can add commands)
CLIs that ship with generators/scaffolding (like create-react-app)
Team-built CLIs with multiple maintainers and help text requirements
oclif
oclif — enterprise CLI framework (Salesforce):
Create a new CLI
npx oclif generate mycli
cd mycli
# Project structure:
# src/commands/ ← Each file = one command
# src/hooks/ ← Lifecycle hooks
# src/index.ts ← Entry point
# bin/run.js ← Executable
Command structure
// src/commands/packages/compare.ts
import { Command, Flags, Args } from "@oclif/core"
export default class PackagesCompare extends Command {
static description = "Compare two npm packages on PkgPulse"
static examples = [
"$ mycli packages compare react vue",
"$ mycli packages compare react vue --format json",
]
static args = {
packageA: Args.string({ required: true, description: "First package" }),
packageB: Args.string({ required: true, description: "Second package" }),
}
static flags = {
format: Flags.string({
char: "f",
options: ["table", "json"],
default: "table",
description: "Output format",
}),
limit: Flags.integer({
char: "l",
default: 10,
description: "Number of metrics to show",
}),
verbose: Flags.boolean({
char: "v",
description: "Verbose output",
}),
}
async run(): Promise<void> {
const { args, flags } = await this.parse(PackagesCompare)
this.log(`Comparing ${args.packageA} vs ${args.packageB}...`)
const data = await this.fetchComparison(args.packageA, args.packageB)
if (flags.format === "json") {
this.log(JSON.stringify(data, null, 2))
} else {
this.printTable(data, flags.limit)
}
}
private async fetchComparison(a: string, b: string) {
// ...
}
private printTable(data: unknown, limit: number) {
// oclif has built-in table utilities
}
}
Auto-generated help
$ mycli packages compare --help
Compare two npm packages on PkgPulse
USAGE
$ mycli packages compare PACKAGEA PACKAGEB
ARGUMENTS
PACKAGEA First package
PACKAGEB Second package
FLAGS
-f, --format=<option> [default: table] Output format
<options: table|json>
-l, --limit=<value> [default: 10] Number of metrics to show
-v, --verbose Verbose output
EXAMPLES
$ mycli packages compare react vue
$ mycli packages compare react vue --format json
Plugin system
// oclif supports third-party plugins that add commands:
// package.json:
{
"oclif": {
"plugins": ["@pkgpulse/oclif-plugin-health"]
}
}
// The plugin adds commands automatically:
// $ mycli health check react
// $ mycli health score react --threshold 80
gluegun
gluegun — batteries-included CLI toolkit:
Setup
npm install gluegun
CLI entry point
// src/cli.ts
import { build } from "gluegun"
const cli = build()
.brand("pkgpulse")
.src(__dirname)
.plugins("./node_modules", { matching: "pkgpulse-*", hidden: true })
.help()
.version()
.create()
export { cli }
Command structure
// src/commands/compare.ts
import type { GluegunCommand } from "gluegun"
const command: GluegunCommand = {
name: "compare",
description: "Compare two npm packages",
run: async (toolbox) => {
const { print, prompt, filesystem, http, parameters, strings } = toolbox
const { packageA, packageB } = await prompt.ask([
{
type: "input",
name: "packageA",
message: "First package:",
initial: parameters.first,
},
{
type: "input",
name: "packageB",
message: "Second package:",
initial: parameters.second,
},
])
const spinner = print.spin(`Fetching ${packageA} vs ${packageB}...`)
try {
const api = http.create({ baseURL: "https://api.pkgpulse.com" })
const result = await api.get(`/compare?a=${packageA}&b=${packageB}`)
spinner.succeed("Done!")
print.table(
[
["Metric", packageA, packageB],
["Downloads/wk", result.data.a.downloads, result.data.b.downloads],
["Health score", result.data.a.score, result.data.b.score],
["Stars", result.data.a.stars, result.data.b.stars],
],
{ format: "markdown" }
)
} catch (error) {
spinner.fail(`Error: ${error.message}`)
process.exit(1)
}
},
}
export default command
Code generation (gluegun's killer feature)
// src/commands/init.ts — scaffold a new project:
import type { GluegunCommand } from "gluegun"
const command: GluegunCommand = {
name: "init",
run: async (toolbox) => {
const { template, filesystem, print, prompt } = toolbox
const { projectName } = await prompt.ask({
type: "input",
name: "projectName",
message: "Project name:",
})
// Generate files from templates:
await template.generate({
template: "package.json.ejs",
target: `${projectName}/package.json`,
props: { name: projectName, version: "1.0.0" },
})
await template.generate({
template: "index.ts.ejs",
target: `${projectName}/src/index.ts`,
props: { name: projectName },
})
filesystem.dir(`${projectName}/src`)
print.success(`✓ Created ${projectName}`)
},
}
<!-- src/templates/package.json.ejs -->
{
"name": "<%= props.name %>",
"version": "<%= props.version %>",
"scripts": {
"build": "tsup",
"dev": "tsx src/index.ts"
}
}
cleye
cleye — TypeScript-first flag parsing:
Basic usage
import { cli } from "cleye"
const argv = cli({
name: "pkgpulse",
version: "1.0.0",
parameters: ["<packageA>", "<packageB>"], // Required positional args
flags: {
format: {
type: String,
alias: "f",
description: "Output format",
default: "table",
},
limit: {
type: Number,
alias: "l",
description: "Number of metrics to show",
default: 10,
},
verbose: {
type: Boolean,
alias: "v",
description: "Verbose output",
},
},
})
// Fully typed — TypeScript knows the types:
const packageA: string = argv._.packageA // string
const packageB: string = argv._.packageB // string
const format: string = argv.flags.format // string
const limit: number = argv.flags.limit // number
const verbose: boolean = argv.flags.verbose // boolean
Sub-commands
import { cli } from "cleye"
const argv = cli({
name: "pkgpulse",
commands: [compareCommand, searchCommand, initCommand],
})
// compare command:
import { command } from "cleye"
const compareCommand = command(
{
name: "compare",
parameters: ["<packageA>", "<packageB>"],
flags: {
format: { type: String, default: "table" },
},
},
(argv) => {
const { packageA, packageB } = argv._
const { format } = argv.flags
console.log(`Comparing ${packageA} vs ${packageB} (${format})`)
}
)
Type inference in action
import { cli } from "cleye"
// cleye infers types from the type property:
const argv = cli({
flags: {
count: { type: Number }, // argv.flags.count: number
tags: { type: [String] }, // argv.flags.tags: string[]
dryRun: { type: Boolean }, // argv.flags.dryRun: boolean
config: { type: String, default: ".config.json" }, // string (not string | undefined)
},
})
// TypeScript catches this at compile time:
// argv.flags.count.toFixed(2) ✅
// argv.flags.count.split(",") ❌ Property 'split' does not exist on type 'number'
Feature Comparison
| Feature | oclif | gluegun | cleye |
|---|---|---|---|
| Multi-command routing | ✅ | ✅ | ✅ |
| Plugin system | ✅ (first-class) | ✅ | ❌ |
| Code generation / templates | ✅ (generators) | ✅ (built-in) | ❌ |
| Interactive prompts | ✅ (via plugins) | ✅ (built-in) | ❌ (bring your own) |
| Spinner / progress | ✅ | ✅ | ❌ |
| File system utilities | ❌ | ✅ | ❌ |
| HTTP client | ❌ | ✅ (apisauce) | ❌ |
| TypeScript flag types | ✅ | ✅ | ✅ (best-in-class) |
| Auto help generation | ✅ | ✅ | ✅ |
| Zero dependencies | ❌ | ❌ | ✅ |
| Weekly downloads | ~300K | ~100K | ~100K |
When to Use Each
Choose oclif if:
- Building a large, production CLI distributed to thousands of users
- Need a plugin ecosystem (third parties extend your CLI)
- Salesforce/Heroku-style multi-command hierarchy
- Want auto-generated help, update notifications, and command testing
Choose gluegun if:
- Building a scaffolding or code generation tool (like
create-app) - Need interactive prompts, spinners, and filesystem in one package
- Want an opinionated toolkit vs assembling individual packages
Choose cleye if:
- Want the best TypeScript flag type inference
- Building a smaller CLI (1-10 commands) with zero dependencies
- Plan to bring your own prompt/spinner libraries (clack, ora, etc.)
- Codebase values minimal dependencies
Also consider:
- commander.js — most battle-tested, vast documentation, slightly more verbose
- yargs — great for complex flag parsing with automatic help
- cac — minimal commander alternative, good TypeScript support
Testing CLI Applications
CLI testing is an under-discussed dimension when choosing a framework, because the ergonomics differ significantly across the three options. Oclif ships with a dedicated testing helper, @oclif/test, that wraps Mocha and provides test.command() to invoke your commands programmatically and assert on stdout, stderr, and exit codes. This is purpose-built for oclif's class-based command architecture — you instantiate the command, pass mock arguments, and assert on the structured output without spawning a child process. For large CLIs with many commands, this test isolation keeps your test suite fast and avoids global state leakage between test cases.
Gluegun commands are pure functions that receive a toolbox object, which makes unit testing straightforward: you mock the toolbox properties (print, filesystem, prompt, http) and call the command's run function directly. The downside is that your mocks must replicate the full toolbox shape, which can be verbose. In practice, most gluegun projects settle on a thin integration test using execa to spawn the actual CLI binary — simpler to write but slower to run.
Cleye's minimal footprint means there is no framework-owned test helper at all. Because cleye only handles argument parsing, your command logic sits in plain functions that receive typed arguments. This is the easiest to test in isolation: call the function with a mock argv object and assert on whatever your function produces. The CLI entry point itself can be tested with execa or Node's built-in child_process.spawn. The absence of a testing layer is a feature here — you're not locked into any test runner.
Distribution and Package Setup
Publishing and distributing your CLI is where the frameworks diverge sharply. Oclif's generator sets up the full publishing pipeline: @oclif/core handles command discovery via the file system, package.json oclif config block registers the bin name, and oclif manifest generates a manifest used by the auto-update and help system. Oclif CLIs are commonly distributed via npm install, Homebrew tap, or Oclif's own install scripts. The manifest system means users get accurate help and completion even without running the command first.
Gluegun CLIs are published as normal npm packages with a bin entry in package.json. The build step compiles TypeScript and resolves the template directory into the output. Distribution is simple — npm publish and users npm install -g your package. Gluegun's lack of a manifest means auto-generated help is slightly less sophisticated, but for most scaffolding tools this is a non-issue.
Cleye is the most vanilla: it's just argument parsing, so your distribution strategy is entirely your own. This is ideal for CLIs distributed as part of a monorepo (e.g., packages/my-cli) where you want zero framework overhead in the published artifact. Many teams combine cleye with tsup for bundling and pkg or @vercel/ncc for producing a self-contained binary. Because cleye adds no runtime framework dependency, the resulting binary is smaller and starts faster than oclif or gluegun equivalents.
Error Handling and User-Facing Error Messages
The quality of error messages in a CLI is often what separates professional tooling from throwaway scripts. Users who receive cryptic stack traces when they mistype a flag are less likely to trust or adopt your tool. Oclif handles many common error cases automatically: passing an unknown flag triggers a formatted error with a suggestion (Did you mean --format?), missing required arguments produce a descriptive usage message, and flag value validation (for flags with options: ["json", "table"]) outputs the list of valid values alongside the error.
Performance and Startup Time Considerations
CLI startup time matters more than application startup time because users invoke CLIs interactively and notice latency above 200ms. Oclif's class-based command discovery scans the filesystem at startup to find command files, loads plugins, and reads configuration from the manifest. This discovery cost is front-loaded on every invocation. For large CLIs like the Heroku CLI (which oclif powers), this startup overhead is mitigated by lazy-loading commands: the manifest pre-indexes all commands, so only the invoked command's file is loaded at runtime. Without lazy loading, a CLI with 50 commands that requires all of them at startup can add 300-500ms before the command begins executing.
Gluegun's startup cost depends on how many built-in toolbox extensions are loaded. The full build().src() initialization chain loads prompts, HTTP, filesystem, print, template, and semver utilities regardless of which command is invoked. For scaffolding CLIs where users invoke my-cli init once and the full startup overhead is acceptable, this is fine. For interactive CLIs used dozens of times per hour, it is worth profiling whether the unused toolbox extensions (particularly the HTTP client and template system) add measurable overhead.
Cleye has the lowest startup cost of the three by a significant margin — it is a single JavaScript file with zero runtime dependencies that parses process.argv synchronously. There is no command discovery, no plugin loading, and no extension initialization. The first line of your CLI's actual business logic executes faster with cleye than with any framework-based alternative. This makes cleye particularly well-suited for CLIs distributed as part of CI pipelines where startup time contributes to total job duration, and for CLIs packaged as single binaries using tools like @vercel/ncc where minimizing module initialization overhead directly reduces binary size.
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on oclif v4.x, gluegun v5.x, and cleye v2.x.
Compare CLI and developer tooling packages on PkgPulse →
See also: cac vs meow vs arg 2026 and cosmiconfig vs lilconfig vs conf, archiver vs adm-zip vs JSZip (2026).