Cilly
The last library you'll ever need for building intuitive, robust and flexible CLI tools with Node.js and TypeScript.
Table of contents
Installation
npm install cilly
Motivation and features
The cilly
package takes a lot of inspiration from great packages such as commander.js.
Cilly presents a small set of strong concepts that simplify the processing flow from input to invoking a command handler.
The primary features that separate cilly
from other libraries are:
- Separate
parse()
and process()
methods - Option sharing and inheritance between commands and subcommands
onParse()
, onProcess()
and validator()
hooks for options and arguments- Custom usage documentation
- Support for generating documentation from command definitions
- Fully typed
- Fully tested
Basic usage
With a file called build.ts
:
#!/usr/local/bin/ts-node
import { CliCommand } from 'cilly'
function buildApartment(args, opts, extra) { ... }
function buildHouse(args, opts, extra) {
return `Built a house with ${opts.numRooms} rooms for ${opts.residents.length} people at ${args.address}, ${args.state}!`
}
const cmd = new CliCommand('build')
.withHandler(() => { cmd.help() })
.withDescription('Builds houses or apartments')
.withOptions({
name: ['-r', '--num-rooms'],
defaultValue: 1,
description: 'The number of rooms',
args: [{ name: 'n', required: true }]
})
.withSubCommands(
new CliCommand('house', { inheritOpts: true })
.withHandler(buildHouse)
.withDescription('Builds a house')
.withArguments(
{ name: 'address', required: true },
{ name: 'state', required: false }
)
.withOptions(
{
name: ['-re', '--residents'],
required: true,
description: 'The people living in the house ',
args: [{ name: 'residents', variadic: true }]
}
),
new CliCommand('apartment', { inheritOpts: true })
.withHandler(buildApartment)
.withDescription('Builds an apartment')
.withArguments({ name: 'address', required: true })
.withOptions(
{ name: ['-f', '--floor'], required: true, description: 'The floor of the apartment' },
{ name: ['-re', '--residents'], required: true, args: [{ name: 'residents', variadic: true }]},
)
)
const result = await cmd.process(process.argv)
Running build.ts --help
gives:
Usage: build [options]
Builds houses or apartments
Options:
-h, --help Display help for command
-r, --n-rooms [n] The number of rooms (default: 1)
Commands:
house <address> [state] [options]
apartment <address> [options]
Running build.ts house --help
:
Usage: house <address> [state] [options]
Builds a house
Options:
-h, --help Display help for command
-r, --n-rooms <n> The number of rooms (default: 1)
-re, --residents [...residents] The people living in the house (required)
Running build.ts house "Code St. 12, CA" -r 4 --residents "Anders Brams" "Joe Schmoe" "Jane Dane"
invokes buildHouse()
with the arguments:
args: {
address: 'Code St. 12',
state: 'CA'
},
opts: {
numRooms: 4,
residents: [
"Anders Brams",
"Joe Schmoe",
"Jane Dane"
]
},
extra: []
Documentation
Before delving into the specifics of the package, a definition of the fundamental concepts is in order: arguments and options.
Arguments are simply values passed directly to a command or an option.
Options are named flags, e.g. --option
that can also be assigned their own arguments.
Commands
Commands are represented by CliCommand
class instances.
The command constructor has the following signature:
CliCommand(name: string, opts?: { inheritOpts?: boolean, consumeUnknownOpts?: boolean })
The CliCommand
API looks as follows:
new CliCommand('command')
.withVersion()
.withDescription()
.withHandler()
.withArguments()
.withOptions()
.withSubCommands()
.withHelpHandler()
.parse()
.process()
.help()
.dump()
parse()
The parse()
method takes the command line arguments (process.argv
) and parses it to produce the {args, opts, extra}
objects passed to handlers.
The parse()
method calls onParse()
hooks for all arguments and options immediately as they are parsed.
It does not call onProcess()
hooks, validators or command handlers, and thus does not require a command handler to be defined.
const cmd = new CliCommand('build')
.withArguments({ name: 'address' })
.withOptions({ name: ['-g', '--garage'], negatable: true })
const { args, opts, extra } = cmd.parse(process.argv)
All arguments that cannot be parsed are put in the extra
argument to command handlers.
If desired, a command can choose to to treat unknown options similarly by setting the consumeUnknownOpts
flag:
const cmd = new CliCommand('build', { consumeUnknownOpts: true })
const { args, opts, extra } = cmd.parse(process.argv)
console.log(extra)
With the input:
build --an --option --that --isnt --defined
The above would print ['--an', '--option', '--that', '--isnt', '--defined']
process()
The process()
method (asynchronous) calls parse()
, runs onProcess()
argument and options hooks, validators, and invokes the appropriate command handler with the output of parse()
. The result of await process()
is whatever the command handler returns.
const cmd = new CliCommand('build')
.withArguments({ name: 'address' })
.withOptions({ name: ['-g', '--garage'], negatable: true })
.withHandler((args, opts, extra) => {
return new House(args.address, opts.garage)
})
const house = await cmd.process(process.argv)
Subcommands
Commands can have an arbitrary number of subcommands, allowing developers to decouple their command handling logic.
These are registered with the withSubCommands()
method:
new CliCommand('build')
.withSubCommands(
new CliCommand('house')...,
new CliCommand('apartment')...,
)
A command cannot have both arguments and subcommands. This is because subcommands are invoked be essentially passing command names as arguments, and there would be no good way to tell the two apart.
Subcommands are displayed in the help text:
Usage: build [options]
Options:
...
Commands:
house <address> [state] [options]
apartment <address> [options]
Option inheritance
Contrary to commander.js
, subcommands can share options and arguments in the parent command(s).
By setting the inheritOpts
flag to true when constructing the command, the command inherits all options from the parent command:
new CliCommand('build')
.withOptions({ name: ['-vb', '--verbose'] })
.withSubCommands(
new CliCommand('house', { inheritOpts: true })
.withOptions({ name: ['-r', '--rooms'] })
)
The opts
object in the house
command handler will contain both verbose
and rooms
:
opts: {
verbose: ...,
rooms: ...
}
Arguments
Arguments are provided to a command with the withArguments()
chain method.
The withArguments()
method takes a list of Argument
type options:
type Argument = {
name: string,
required?: boolean,
variadic?: boolean,
description?: string,
defaultValue?: ArgumentValue,
onParse?: OnParseHook,
onProcess?: OnProcessHook,
validator?: Validator
}
Argument names must be dash-separated, alpabetic strings e.g. my-house
, email
, docker-compose-file
, etc.
After parsing, arguments are accessible through their camelCased names in the args
argument to the command handler, e.g.:
new CliCommand('args-documentation-example')
.withArguments(
{ name: 'my-house' },
{ name: 'email' },
{ name: 'docker-compose-file' }
).withHandler((args, opts, extra) => {
console.log(args.myHouse)
console.log(args.email)
console.log(args.dockerComposeFile)
})
Variadic arguments
To let an argument be parsed as a list of values, mark it as variadic:
new CliCommand('download')
.withArguments({ name: 'websites', variadic: true })
Variadic arguments parse values from the command line until either
- The variadic terminator
--
is parsed - An option name is parsed
- The input stops
So it's perfectly possible to have two variadic arguments, they just need to be terminated:
new CliCommand('download')
.withArguments(
{ name: 'websites', variadic: true },
{ name: 'files', variadic: true }
)
Options
Options are provided to a command with the withOptions()
chain method.
The withOptions()
method takes a list of Option
type arguments:
type Option = {
name: [string, string],
required?: boolean,
negatable?: boolean,
args?: Argument[],
defaultValue?: OptionValue,
description?: string,
onParse?: OnParseHook,
onProcess?: OnProcessHook,
validator?: Validator
}
The option's name is provided as an array of two strings; the short and long flag name.
- Short option names == argument names starting with
-
- Long option names == argument names starting with
--
new CliCommand('build')
.withOptions(
{ name: ['-r', '--rooms'] },
{ name: ['-s', '--street-name'] }
)
Option arguments
Options can take arguments just like a command can.
In the command line, options can be assigned in two ways:
- With
=
assignment, e.g. build house --rooms=4
- With normal assignment, e.g.
build house --rooms 4
Here's an example of an option with three arguments - in the help
text, this would be shown as
Options:
-r, --residents <owner> [...adults] [...children]
new CliCommand('build')
.withHandler((args, opts, extra) => {
console.log(opts)
})
.withOptions(
{ name: ['-r', '--residents'], args: [
{ name: 'owner', required: true }
{ name: 'adults', variadic: true, required: false, },
{ name: 'children', variadic: true, required: false }
]}
)
The above handler would print the following:
{
residents: {
owner: ...,
adults: [...],
children: [...]
}
}
If an option only has a single argument, that argument is then collapsed into the option value so it's simpler to access:
new CliCommand('build')
.withOptions(
{ name: ['-o', '--owner'], args: [
{ name: 'owner', required: true }
]}
)
Parsing the input build --owner=anders
(or build --owner anders
) would produce the following opts
object:
{
owner: 'anders'
}
Negating flags
Sometimes, it's useful to allow users to explicitly negate an option flag.
For example, in the hooks section we cover how hooks can be used to prompt users for option values if they are not provided.
It's good UX to allow the user to explicitly negate the flag when they don't want it so they can avoid being prompted.
To register a negating flag, simply set negatable: true
:
new CliCommand('batman')
.withOptions(
{ name: ['-p', '--parents'], negatable: true, description: 'Name of (living) parents' }
)
With this, users can pass --no-parents
in the command line, which will set opts.parents
to false
.
This is also shown in the help
text:
Usage: batman [options]
Options:
-p, --parents (--no-parents) Name of (living) parents
Validators
Options and arguments can be assigned validators that are called on .process()
.
A validator has the following signature:
type Validator = (value: any, parsed: { args, opts, extra }) => string | boolean | Promise<string | boolean>
- The
value
argument is the value assigned to the option or argument. - The
parsed
argument is the result of .parsed()
; the result of parsing the command line arguments.
If a validator returns true
, the value is interpreted as valid. Otherwise, if the validator returns false
, a ValidationError
is thrown with a default error message.
- If the validator returns a string, that string is used as the error message.
new CliCommand('build')
.withArguments({ name: 'address', validator: (value, parsed) => {
if (!validators.isValidAddress(value)) {
return `The address ${value} is not a valid address.`
}
return true
}})
Hooks
It can be useful to intercept an option or argument before it's passed to the command handler.
To do this, we can use onParse()
and onProcess()
hooks on both options and arguments.
onParse()
When registered on an option or argument, an onParse()
hook is called immediately when that argument or option is parsed from the command line.
This is useful for implementing interrupting flags such as --help
, --version
, and so on.
An OnParseHook
has the following signature:
type OnParseHook = (value: any, parsed: { args, opts, extra }) => void
- The
value
argument is the value assigned to the option or argument. - The
parsed
argument is what the .parse()
method has parsed so far. Note that this object may not be complete when the hook is invoked.
new CliCommand('build')
.withOptions({ name: ['-v', '--version'], onParse: (value, parsed) => {
console.log(version)
process.exit()
}})
onProcess()
Contrary to onParse()
hooks, onProcess()
hooks are run after parse()
has finished.
Hooks also allow you to change the value of an option or argument at processing time, before the command handler is invoked.
This can be very useful for designing "user-proof" CLIs that prompt the users for the information they need in a nice looking and robust manner.
An OnProcessHook
has the following signature:
type OnProcessHook = (value: any, parsed: { args, opts, extra }, assign: (value: any) => Promise<void>) => void | Promise<void>
- The
value
argument is the value assigned to the option or argument - The
parsed
argument is the result of parse()
- The
assign
argument is a function that, when called with a new value:
- Runs the value through the option/argument validator (if one exists)
- Assigns the value to the option/argument
new CliCommadn('build')
.withArguments({ name: 'address', onProcess: async (value, parsed, assign) => {
if (value === undefined) {
const address = await prompts.Prompt('Please enter your address')
await assign(address)
}
}})
Generating documentation
The CliCommand.dump()
method dumps the entire command (and its subcommands) to an easily readable object of type CommandDefinition
. This is useful for generating documentation.
A CommandDefinition
has the following signature:
export type CommandDefinition = {
name: string,
version: string,
description?: string,
opts: OptionDefinition[],
args: ArgumentDefinition[],
subCommands: CommandDefinition[]
}
type OptionDefinition = {
name: [string, string],
args: ArgumentDefinition[],
description?: string,
required?: boolean,
negatable?: boolean,
defaultValue?: any
}
type ArgumentDefinition = {
name: string,
description?: string,
required?: boolean,
defaultValue?: any,
variadic?: boolean
}
When printing the help
text, this is done completely from the CommandDefinition
objects.
While out of scope for this specific package, one could dream of a package that could take a CommandDefinition
object and generate a nice looking documentation page :eyes:
Here's an example of a command dump:
const cmd = new CliCommand('build')
.withDescription('Build a home')
.withArguments({ name: 'address', required: true })
.withOptions(
{ name: ['-r', '--residents'], required: false, args: [ {name: 'residents', variadic: true} ] },
{ name: ['-d', '--doors'], defaultValue: 1 }
)
console.log(cmd.dump())
Produces:
{
"name": "build",
"description": "Build a home",
"opts": [
{
"name": [
"-h",
"--help"
],
"description": "Display help for command",
"args": []
},
{
"name": [
"-r",
"--residents"
],
"required": false,
"args": [
{
"name": "residents",
"variadic": true
}
]
},
{
"name": [
"-d",
"--doors"
],
"args": [],
"defaultValue": 1
}
],
"args": [
{
"name": "address",
"required": true
}
],
"subCommands": []
}
Custom help handlers
You can use the withHelpHandler()
method to override the default help
text.
new CliCommand('build')
.withHelpHandler((command: CommandDefinition) => {
console.log(`This is the documentation for ${command.name} (${command.definition})`)
...
process.exit()
})
Custom version handlers
You can set the version of a command with .withVersion('1.2.3')
. This will set the version and add a --version
option that prints the version.
If you want to override how the version is displayed, you can do so by passing a handler:
new CliCommand('build')
.withVersion('1.2.3', (command: CommandDefinition) => {
console.log(`The version of this command is ${command.version}`)
process.exit()
})
Exception handling
All exceptions thrown by cilly
extend the CillyException
class. If you want to catch each exception and handle them individually, here's the full list of exceptions thrown by cilly
:
class CillyException extends Error
class UnknownOptionException extends CillyException
class UnknownSubcommandException extends CillyException
class InvalidNumOptionNamesException extends CillyException
class InvalidShortOptionNameException extends CillyException
class InvalidLongOptionNameException extends CillyException
class InvalidCommandNameException extends CillyException
class InvalidArgumentNameException extends CillyException
class ExpectedButGotException extends CillyException
class NoCommandHandlerException extends CillyException
class DuplicateArgumentException extends CillyException
class DuplicateOptionException extends CillyException
class DuplicateCommandNameException extends CillyException
class NoArgsAndSubCommandsException extends CillyException
class ValidationError extends CillyException
Contributing
Contributions are greatly appreciated and lovingly welcomed!
In your pull request, make sure to link the issue you're addressing. If no issue exists, make one first so we have a chance to discuss it first.
Always write tests for the functionality you add or change. See the cli-command.test.ts
and token-parser.test.ts
files for examples.
As always, use the linter provided in the project (.eslintrc.json
) and stick to the coding style of the project.
Setup
- Install everything with
npm i
- Run tests with
npm test
Debugging
When debugging, take not that both parse()
and process()
strip the two first arguments off of process.argv
when invoked.
When you want to see how an input would be parsed, set the raw
option in parse()
and process()
:
const { args, opts, extra } = new CliCommand('build').parse(['build', '--unknown-option'], { raw: true })
When raw
is true
, the input array is not changed.
The .vscode/launch.json
file contains a configuration for debugging the test files Mocha Tests
, allowing you to put breakpoints and step through your tests.