@bemoje/cli
v4.0.0
Published
A type-safe CLI framework for building command-line interfaces with typed arguments, options, subcommands, and auto-generated help - without execution coupling.
Maintainers
Readme
@bemoje/cli
A type-safe CLI framework for building command-line interfaces with typed arguments, options, subcommands, and auto-generated help - without execution coupling.
Exports
- Command: a type-safe CLI composer that can parse argv and generate help without execution coupling.
- Help: This is a fork of the Help class from the 'commander' npm package. The Help class method names as well as the expected interface of the Command instance to parse, are both similar, but different and not compatible without custom adaptations,
- findCommand: Finds subcommand by name or alias
- findOption: Finds option by name, short name or long name
- getCommandAncestors: Returns all ancestor commands excluding this command
- getCommandAndAncestors: Returns command and all ancestor commands in hierarchy
- parseOptionFlags: Parses option flags string into its components
Installation
npm install @bemoje/cliFeatures
- Full type inference - Arguments, options, and parsed results are fully typed. The types update as you chain
.addArgument()and.addOption()calls. - Declarative, chainable API - Build commands fluently with method chaining; each call returns a narrowed type.
- Subcommand hierarchies - Nest commands arbitrarily deep. Options are inherited by subcommands. Aliases are auto-generated.
- Auto-generated help - Colorized, grouped, and wrapped help output from your command definitions. Fully customizable via the
Helpclass. - Built-in
--help,--debug, and--versionflags - Automatically added to root commands. - Option hooks - Register side-effect actions that trigger when specific options are set (e.g.,
--helpprints help and setsprocess.exitCode). - Parse-only design -
parseArgv()returns a result object withargs,opts,errors,hooks, and anexecute()method. You control when and whether execution happens. - Validation - Missing required arguments, unknown options, and invalid choices are reported as errors on the result.
- Variadic arguments and options - Both arguments and options support variadic (
...) syntax to collect multiple values into arrays. - Environment variable defaults - Options can fall back to environment variables via the
envoption. - Argument and option choices - Restrict allowed values with
choicesarrays; violations are reported as validation errors. - Negatable boolean flags - Pass
--no-<flag>to set a boolean option tofalse. - kebab-case to camelCase mapping - Multi-word option names like
--output-dirare automatically available asopts.outputDir. - Hidden commands and options - Exclude items from help output while keeping them functional.
- Grouped help sections - Organize options and subcommands into named groups in the help output.
Usage
Basic Command
import { Command } from '@bemoje/cli'
const cli = new Command('greet')
.setDescription('Greet someone')
.addArgument('<name>')
.addOption('-l, --loud', { description: 'Shout the greeting' })
.setAction((name, opts) => {
const msg = `Hello, ${name}!`
console.log(opts.loud ? msg.toUpperCase() : msg)
})
cli.parseArgv(process.argv.slice(2))Arguments
Arguments are positional values parsed from argv. They support required (<name>), optional ([name]), and variadic (<name...>, [name...]) forms.
const cmd = new Command('copy')
.addArgument('<source>') // required string
.addArgument('[destination]', { defaultValue: '.' }) // optional with default
.setAction((source, destination, opts) => {
console.log(`Copying ${source} to ${destination}`)
})
cmd.parseArgv(['file.txt'])
// args: ['file.txt', '.']Variadic arguments collect all remaining positional values into an array. Only the last argument may be variadic.
const cmd = new Command('concat').addArgument('<files...>').setAction((files, opts) => {
// files is string[]
console.log(`Concatenating: ${files.join(', ')}`)
})
cmd.parseArgv(['a.txt', 'b.txt', 'c.txt'])
// args: [['a.txt', 'b.txt', 'c.txt']]Choices can restrict which values are accepted:
const cmd = new Command('deploy').addArgument('<env>', { choices: ['dev', 'staging', 'prod'] })Ordering rules are enforced at both the type level and at runtime:
- Required arguments must come before optional ones
- Only the last argument may be variadic
- A command with arguments cannot have subcommands (and vice versa)
Options
Options are named flags parsed from argv. The format is always -<short>, --<long> [value-syntax].
const cmd = new Command('build')
.addOption('-o, --output <dir>', { description: 'Output directory' }) // required string
.addOption('-m, --minify', { description: 'Minify output' }) // boolean flag
.addOption('-w, --watch [mode]', { description: 'Watch mode', defaultValue: 'poll' }) // optional string with default
.setAction((opts) => {
console.log(opts.output) // string
console.log(opts.minify) // boolean | undefined
console.log(opts.watch) // string
})Variadic options collect multiple values into an array:
const cmd = new Command('lint')
.addOption('-i, --include <patterns...>', { description: 'Include globs' }) // required: string[]
.addOption('-e, --exclude [patterns...]', { description: 'Exclude globs', defaultValue: ['node_modules'] })
cmd.parseArgv(['-i', 'src', 'lib', '-e', 'test'])
// opts.include: ['src', 'lib']
// opts.exclude: ['test']Option choices restrict valid values:
const cmd = new Command('format').addOption('-f, --format <type>', {
description: 'Output format',
choices: ['json', 'xml', 'yaml'],
})Environment variable defaults let options fall back to env vars:
const cmd = new Command('deploy').addOption('-t, --token <value>', {
description: 'Auth token',
env: 'AUTH_TOKEN',
})
// If AUTH_TOKEN is set and --token is not passed, opts.token defaults to process.env.AUTH_TOKENHidden options are excluded from help output:
const cmd = new Command('tool').addOption('-x, --experimental', {
description: 'Experimental feature',
hidden: true,
})Grouped options appear under custom headings in help:
const cmd = new Command('server')
.addOption('-p, --port <n>', { description: 'Port number', group: 'Network:' })
.addOption('-H, --host [addr]', { description: 'Host address', group: 'Network:' })
.addOption('-v, --verbose', { description: 'Verbose logging', group: 'Logging:' })Negatable flags - any boolean option can be negated with --no-<name>:
const cmd = new Command('build').addOption('-c, --color', { description: 'Colorize output' })
cmd.parseArgv(['--no-color'])
// opts.color: falsekebab-case to camelCase - multi-word option names are automatically camelCased:
const cmd = new Command('build').addOption('-o, --output-dir <path>', { description: 'Output directory' })
cmd.parseArgv(['--output-dir', '/tmp'])
// opts.outputDir: '/tmp'Subcommands
Use .command() to create a subcommand and get it back, or .addCommand() to add one via callback and get the parent back for chaining.
Options defined on the parent are automatically inherited by subcommands. Aliases are auto-generated from subcommand name initials (e.g., build-project gets alias bp).
const cli = new Command('git')
.setDescription('A version control system')
.addOption('-v, --verbose', { description: 'Verbose output' })
.addCommand('clone', (sub) =>
sub
.setDescription('Clone a repository')
.addArgument('<url>')
.addOption('-d, --depth <n>', { description: 'Shallow clone depth' })
.setAction((url, opts) => {
// opts.verbose is inherited from parent
console.log(`Cloning ${url} with depth ${opts.depth ?? 'full'}`)
})
)
.addCommand('status', (sub) =>
sub
.setDescription('Show working tree status')
.addOption('-s, --short', { description: 'Short format output' })
.setAction((opts) => {
console.log('Status:', opts.short ? 'clean' : 'On branch main...')
})
)
cli.parseArgv(['clone', 'https://github.com/user/repo', '-d', '1'])Aliases can also be set manually:
const cli = new Command('app')
cli.command('install').setAliases('i', 'add')Parsing and Execution
parseArgv() does not execute the action - it returns a result object:
const cli = new Command('tool')
.addArgument('<input>')
.addOption('-v, --verbose', { description: 'Verbose' })
.setAction((input, opts) => {
console.log(`Processing ${input}`)
})
const result = cli.parseArgv(['file.txt', '-v'])
result.path // string[] - subcommand path segments
result.name // string - command name
result.argv // string[] - the argv that was parsed
result.args // [string] - typed parsed arguments
result.opts // { verbose: true } - typed parsed options
result.errors // string[] | undefined - validation errors
result.hooks // HookDefinition[] - triggered hooks
result.cmd // Command - the matched command instance
// Execute the action (and any triggered hooks) when you're ready:
await result.execute()Validation errors are collected, not thrown. Check result.errors before executing:
const result = cli.parseArgv([])
if (result.errors) {
console.error(result.errors.join('\n'))
process.exitCode = 1
} else {
await result.execute()
}When execute() is called, hooks run first (in order). If any hook sets process.exitCode, execution stops. Then the main action runs inside a timer that provides a logger.
Option Hooks
Hooks let you attach side-effect actions that run when a specific option is set. The built-in --help, --debug, and --version flags are all implemented as hooks.
const cli = new Command('tool')
.addOption('-c, --clean', { description: 'Clean before running' })
.addOptionHook('clean', ({ cmd, opts }) => {
console.log('Cleaning build artifacts...')
// Hook runs before the main action
})
.setAction((opts) => {
console.log('Running tool...')
})Hooks are evaluated in registration order. If a hook sets process.exitCode, subsequent hooks and the main action are skipped.
Help Output
Help is auto-generated with ANSI color support, word wrapping, and section grouping.
const cli = new Command('my-tool')
.setVersion('1.0.0')
.setDescription('A great CLI tool')
.addArgument('<input>')
.addOption('-v, --verbose', { description: 'Verbose output' })
// Render help string (with ANSI colors)
console.log(cli.renderHelp())
// Render plain text (no ANSI)
console.log(cli.renderHelp({ noColor: true }))Customize help rendering via .helpConfiguration():
cli.helpConfiguration((help) => {
help.sortSubcommands = false
help.sortOptions = false
help.helpWidth = 120
})The Help class exposes many styleable and overridable methods for full control over the output format:
| Method | Purpose |
| --------------------------- | --------------------------------------------- |
| styleTitle(str) | Style section headings ("Usage:", "Options:") |
| styleUsage(str) | Style the usage line |
| styleOptionTerm(str) | Style option flags |
| styleSubcommandTerm(str) | Style subcommand entries |
| styleArgumentTerm(str) | Style argument entries |
| styleDescriptionText(str) | Base style for all descriptions |
| formatItem(term, w, desc) | Format a term/description pair with padding |
| boxWrap(str, width) | Wrap text at whitespace to fit width |
Type Safety
The Command class tracks argument and option types through generics that update with each chained call. This means your action handler receives correctly typed parameters:
const cmd = new Command('example')
.addArgument('<name>') // A = [string]
.addArgument('[count]', { defaultValue: '1' }) // A = [string, string]
.addOption('-v, --verbose', {}) // O = { verbose?: boolean, ... }
.addOption('-f, --format <type>', {}) // O = { verbose?: boolean, format: string, ... }
.setAction((name, count, opts) => {
// name: string, count: string, opts: { verbose?: boolean, format: string, ... }
})Invalid argument orderings (e.g., required after optional, anything after variadic) are caught at the type level - TypeScript will reject the code before it runs.
Helpers
import {
findCommand,
findOption,
parseOptionFlags,
getCommandAncestors,
getCommandAndAncestors,
} from '@bemoje/cli'
// Find a subcommand by name or alias
const sub = findCommand(cli, 'clone') // Command | undefined
const sub2 = findCommand(cli, 'c') // also works with aliases
// Find an option by name, short flag, or long flag
const opt = findOption(cli, 'verbose') // Option | undefined
const opt2 = findOption(cli, '-v') // by short flag
const opt3 = findOption(cli, '--verbose') // by long flag
// Parse raw flag syntax into components
parseOptionFlags('-o, --output <dir>')
// => { short: 'o', long: 'output', name: 'output', argName: 'dir' }
// Navigate command hierarchy
getCommandAncestors(sub) // parent commands (excluding self)
getCommandAndAncestors(sub) // [self, parent, grandparent, ...]