CLI Builder
A highly customizable command line application builder.
What's new in v8
Key highlights:
- Support standalone CLI
nameandversionare now required and not read frompackage.json.
- Plugins are loaded through config
- This drastically improve startup time, as it does not scan
node_modulesanymore. - Also better support other package manager such as
yarn PnPandpnpm.
- This drastically improve startup time, as it does not scan
keywordsare now used for plugin lookup.- Distribute
ESMalong withCJS.
Feature Highlights
- support default commands and sub-commands
my-cli cmd1 cmd2 cmd3 - configuration file support
- plugin support: write commands in separate packages and reuse by multiple CLI
- type inference and validation for config, arguments, and options
using zod (exported asz)
Install
# npm
npm install clibuilder
# yarn
yarn add clibuilder
# pnpm
pnpm install clibuilder
#rush
rush add -p clibuilder
Usage
You can use clibuilder to create your command line application in many ways.
The most basic way looks like this:
// Define your app
const app = cli({ name: 'app', version: '1.0.0' })
.default({ run() { /* ...snip... */ }})
// Use your app
app.parse(process.argv)
.catch(e => /* handle error */process.exit(e?.code || 1))
You can add additional named commands and sub-commands:
cli({ ... })
.command({ name: 'hello', run() { this.ui.info('hello world') }})
.command({
name: 'repo',
commands:[
command({ name: 'create', run() { /* ..snip.. */ }})
]
})
Command can have alias:
cli({ ... })
.command({
name: 'search-packages',
alias: ['sp'],
/* ..snip.. */
})
// call as: `my-cli sp`
You can specify arguments:
cli({ ... }).default({
arguments: [
// type defaults to string
{ name: 'name', description: 'your name' }
],
run(args) { this.ui.info(`hello, ${args.name}`) }
})
cli({ ... }).command({
name: 'sum',
arguments: [
// using `zod` to specify number[]
{ name: 'values', description: 'values to add', type: z.array(z.number()) }
],
run(args) {
// inferred as number[]
return args.values.reduce((p, v) => p + v, 0)
}
})
Of course, you can also specify options:
cli({ ... }).default({
options: {
// type defaults to boolean
'no-progress': { description: 'disable progress bar' },
run(args) {
if (args['no-progress']) this.ui.info('disable progress bar')
}
}
})
and you can add option alias too:
cli({ ... }).command({
options: {
project: {
alias: ['p']
}
}
})
You can use z to mark argument and/or options as optional
cli({... }).default({
arguments: [{ name: 'a', description: '', type: z.optional(z.string()) }],
options: {
y: { type: z.optional(z.number()) }
}
})
Option types with t
t declares an argument or option type without zod.
Each descriptor converts the raw value, carries its constraints as data that help and usage errors show,
and gives run() the exact type.
It comes from args-minus, which converts and checks every declaration made with it.
import { t } from 'clibuilder'
cli({ ... }).command({
name: 'serve',
arguments: [{ name: 'root', description: 'root folder', type: t.string() }],
options: {
port: { description: 'port', type: t.int({ min: 1, max: 65535 }), default: 8080, alias: ['p'] },
format: { description: 'format', type: t.enum(['toon', 'text', 'json']).optional() },
tag: { description: 'tags', type: t.array(t.string()).optional() },
timeout: { description: 'e.g. 1m30s', type: t.custom(parseDuration).optional() },
email: { description: 'email', type: t.string().optional().check(z.string().email()) }
},
run(args) {
// args.port: number, args.format: 'toon' | 'text' | 'json' | undefined, args.tag: string[] | undefined
}
})
Help shows each constraint, e.g. [-p|--port <int 1..65535>] and [--format <toon|text|json>].
A command can mix z and t: each argument and option is parsed by its own type,
and declarations with z behave exactly as before.
Declarations with t follow args-minus:
- an option that is neither
.optional()nor given adefaultmust be passed, and is reported as missing otherwise; t.boolean()is a flag: it takes no following value, but--dry=falseis accepted;t.array()takes one value per occurrence:--tag a --tag b;.check()andt.custom()may be async; they run after--helpand--versionare answered;- a value that fails conversion, a constraint or a check is an
invalid-valueusage error with the option'sconstraint(andallowedvalues fort.enum). A converter that throws a plainErrorreports its message; any other throw is a bug and propagates.
The z form is removed in v12.
If you invoke a command expecting a config,
the config will be loaded.
Each command defines their own config.
cli({ ... })
.default({
config: z.object({ presets: z.string() }),
run() {
this.ui.info(`presets: ${this.config.presets}`)
}
})
Config
Config file can be written in JSON, YAML, cjs, or mjs.
Common filename are supported:
.{name}.<cjs|mjs|js|json|yaml|yml>.{name}rc.<cjs|mjs|js|json|yaml|yml>{name}.<cjs|mjs|js|json|yaml|yml>{name}rc.<cjs|mjs|js|json|yaml|yml>
You can override the config name too:
cli({ config: 'alt-config.json' })
Plugins
One of the key features of clibuilder is supporting plugins.
Plugins are defined inside the config:
{
"plugins": ["my-cli-plugin"]
}
Plugin support is moving to @clibuilder/plugins, which a CLI adds with
.use(plugins()). Both ways work in v11; v12 keeps only @clibuilder/plugins.
Defining Plugins
clibuilder allows you to build plugins to add commands to your application.
i.e. You can build your application in a distributed fashion.
To create a plugin:
- export a
activate(ctx: PluginActivationContext)function - add the keywords in your
package.jsonto make it searchable
import { command, PluginActivationContext } from 'clibuilder'
// in plugin package
const sing = command({ ... })
const dance = command({ ... })
export function activate({ addCommand }: PluginCli.ActivationContext) {
addCommand({
name: 'miku',
commands: [sing, dance]
})
}
// in plugin's package.json
{
"keywords": ['your-app-plugin', 'vocaloid']
}
The CLI can search for plugins using the keywords values.
Testing
testCommand() can be used to test your command:
import { command, testCommand } from 'clibuilder'
test('some test', async () => {
const { result, messages } = await testCommand(command({
name: 'cmd-a',
run() {
this.ui.info('miku')
return 'x'
}
}), 'cmd-a')
expect(result).toBe('x')
expect(messages).toBe('miku')
})
Startup time
Node can cache the compiled bytecode of every module your CLI loads,
so the second and later runs skip compiling them again.
clibuilder exposes it as an opt-in helper:
#!/usr/bin/env node
import { enableCompileCache } from 'clibuilder/compile-cache'
enableCompileCache()
const { cli } = await import('clibuilder')
cli({ name: 'app', version: '1.0.0' })
.default({ run() { /* ...snip... */ } })
.parse(process.argv)
or in CJS:
#!/usr/bin/env node
require('clibuilder/compile-cache').enableCompileCache()
const { cli } = require('clibuilder')
Measured on the test-apps fixtures (node 24, median of 25 warm runs):
| build | without | with |
|---|---|---|
ESM |
86ms | 81ms |
CJS |
43ms | 37ms |
Two rules make it work, and both are easy to get wrong:
- Call it first. The cache applies to modules compiled after the call,
so it must run before your CLI and its dependencies are loaded.
Calling it later — from your command's
run(), or from insidecli()— caches nothing. That is whyclibuilderdoes not turn it on for you: by the timecli()runs, everything worth caching is already compiled. - In
ESM, useawait import. A staticimportcompiles the entire module graph before any of it runs, so a statically importedcliis already compiled by the timeenableCompileCache()executes.clibuilder/compile-cacheis a separate entry point that pulls in nothing butnode:module, so importing it statically costs nothing.
The helper never throws.
On runtimes without the API (node < 22.1, and currently bun and deno) it
does nothing and reports why:
const { enabled, directory, message } = enableCompileCache()
By default node picks the cache location. Pass a directory to choose your own:
enableCompileCache(path.join(os.homedir(), '.cache/my-cli'))
If the user sets the NODE_COMPILE_CACHE environment variable,
their directory wins and the argument is ignored.
They can also use that variable to switch the cache on for a CLI that does not
call the helper at all.
shebang
To make your CLI easily executable, you can add shebang to your script:
#!/usr/bin/env node
// your code