CLI Toolkit for building command-line applications in Node.js. The library provides a flexible way to define commands, providing automatic parsing of arguments, flags, stdio handling and help generation. It also provides a rich extension system to extend the functionality of the final application.
Go back to Summary
npm:
# NPM
npm install --save @giancarl021/cli-core
# Yarn
yarn add @giancarl021/cli-core
# PNPM
pnpm add @giancarl021/cli-coreGo back to Summary
// Import the library
import CliCore, { defineCommand, type HelpDescriptor } from './index.js';
// Define some commands
const commands = {
hello: defineCommand(() => 'Hello world!'),
echo: defineCommand(args => args.join(' ')),
complex: defineCommand(async function (args, flags) {
const data = await this.helpers.readJsonFromStdin();
this.logger.json({ stdin: data, args, flags });
return this.NO_OUTPUT;
})
};
// Define the help descriptor
const help: HelpDescriptor = {
hello: 'Prints Hello world!',
echo: {
description: 'Echoes the arguments passed',
args: ['arg1', { name: 'arg2', optional: true, multiple: true }]
},
complex: {
description:
'A complex command that reads JSON from stdin and prints it to stdout',
args: [{ name: 'args', multiple: true, optional: true }],
stdio: {
stdin: 'Any json input (required)'
},
flags: {
any: {
description: 'An example flag',
optional: true,
values: ['any value']
}
}
}
};
// Create a new instance of the CLI Core
const app = CliCore({
appName: 'my-app',
commands,
help
});
// Run the application
app.run().catch(console.error);This will create a command-line application with three commands: hello, echo and complex. The hello command will print Hello world!, the echo command will echo the arguments passed to it, and the complex command will read JSON from stdin and print it to stdout along with the arguments and flags passed.
To see the help for the application, you can run any command or the application itself with the --help flag. This will show a detailed help message with the description of the commands, their arguments and flags.
Go back to Summary
There are multiple options that can be passed to the CLI Core instance:
| Option | Type | Description | Default value |
|---|---|---|---|
appName* |
string |
The name of the application, the name called by the end user | |
commands* |
CliCoreCommand |
The commands of the application, for more information refer to Commands | |
appDescription |
string | null |
The general description of the application, showed in the root help command if truthy | null |
arguments.origin |
string[] |
The origin of the arguments, the default is the process arguments | process.argv |
arguments.ignoreFirst |
number |
The index of the arguments that should be ignored, default is 2, to ignore the node script.js ... |
2 |
arguments.flags.parse |
boolean |
If the flags should be treated separately from the arguments, default is true |
true |
arguments.flags.inferTypes |
boolean |
Try to infer the type of the flag, default is true, example: --flag true will return the boolean true in the flags object if this option is enabled, but the string "true" otherwise |
true |
arguments.flags.ignoreEmptyFlags |
boolean |
Ignore empty flag names, default is false. |
false |
arguments.flags.prefixes |
string[] |
The prefixes for the flags. | ['-', '--'] |
arguments.flags.helpFlags |
string[] |
Flags that will trigger the help command for the current command chain | ['h', 'help', '?'] |
behavior.debugMode |
boolean |
If the application is in debug mode, for more information refer to Debug mode |
false |
behavior.extensionLogging |
boolean |
If the extensions logs must be printed to the console. When false, the extension-specific loggers will not print anything. |
false |
behavior.colorfulOutput |
boolean |
If the output should contain ASCII colors. When false, the output will be plain text. |
true |
help |
HelpDescriptor |
The help descriptor object, for more information refer to Help | {} |
extensions |
CliCoreExtension[] |
A list of extensions to extend the functionality of the commands, for more information refer to Extensions | [] |
* This option is required.
Go back to Summary
The commands are the core of the application. Each command is a function that will be executed when the command is called.
A command can be a function (synchronous or asynchronous) or an object containing multiple functions or another objects (nested commands).
The routing of the commands is done by matching the arguments passed to the application with the command names.
For example, let's say we have the following commands:
const commands = {
hello: defineCommand(() => 'Hello world!'),
sub1: {
sub2: {
sub3: defineCommand(() => 'It is dark down here!')
}
}
};To call the hello command, the user would run:
my-app helloAnd to call the sub3 command, the user would run:
my-app sub1 sub2 sub3The arguments passed to the command are the remaining arguments after the command names. So the sub3 sub-command would not receive any arguments in this case, only after the routing is done: my-app sub1 sub2 sub3 <arguments passed to the sub3 command>.
Each command function receives the following parameters:
function command(
args: string[],
flags: Record<string, boolean | string | number | null>
) {
// ...
}The args parameter is an array of strings containing the arguments passed to the command.
The flags parameter is an object containing the flags passed to the command. The keys are the flag names (without the prefixes) and the values are the flag values. If a flag is passed without a value, it will be set to null. If a flag is not passed, it will not be present in the object.
Also, the command has a this context containing some useful services and constants:
function command() {
this.appName; // The name of the application
this.logger; // The logger service, allowing better logging to the console, even more in debug mode
this.stdio; // The stdio service, exposing the process stdin, stdout and stderr streams
this.extensions; // The extensions service, allowing to use the extensions added to the application
this.helpers.cloneArgs(); // Clone the arguments array
this.helpers.getArgAt(0); // Get a specific argument
this.helpers.getArgOrDefault('default', 0); // Get an argument or a default value
this.helpers.hasArgAt(0); // Check if an argument exists at a specific index
this.helpers.requireArgs('first', 'second'); // Require named arguments, throws if missing and returns an object with the values
this.helpers.hasFlag('any', 'alias1', 'alias2'); // Check if a flag exists
this.helpers.getFlag('any', 'alias1', 'alias2'); // Get the value of a flag
this.helpers.getFlagOrDefault('default', 'any', 'alias1', 'alias2'); // Get a flag value or a default value
this.helpers.whichFlag('any', 'alias1', 'alias2'); // Get the actual flag name used
this.helpers.getStdin(); // Get the stdin stream
this.helpers.getStdout(); // Get the stdout stream
this.helpers.getStderr(); // Get the stderr stream
this.helpers.writeJsonToStdout({ test: true }); // Write JSON to stdout
this.helpers.writeJsonToStderr({ test: true }); // Write JSON to stderr
await this.helpers.readBufferFromStdin(); // Read raw buffer from stdin asynchronously (WARNING: will lock the process until EOF)
await this.helpers.readTextFromStdin(); // Read text from stdin asynchronously (WARNING: will lock the process until EOF)
await this.helpers.readJsonFromStdin(); // Read JSON from stdin asynchronously (WARNING: will lock the process until EOF)
return this.NO_OUTPUT; // A special constant that can be returned to indicate that the command should not print anything to the console
}The command must return a value, which will be printed to the console. If the command does not return anything, it will print undefined. To avoid this, the command can return the special symbol this.NO_OUTPUT, which indicates that the command should not print anything to the console.
Go back to Summary
The help system is automatically generated based on the commands and the help descriptor object passed to the CLI Core instance.
The help can be triggered by passing the --help flag (or any of the flags defined in the arguments.flags.helpFlags option) to any command or the application itself.
Note: The help will also be triggered if a command group (object with subcommands) is the last argument in the command chain. In conjunction with the help showing up, a error status code will be returned.
The help descriptor is an object that describes the commands, their arguments and flags. The example below shows a help descriptor object:
import {
defineMultiCommandHelpDescriptor,
defineSingleCommandHelpDescriptor
} from './index';
const singleCommandHelp = defineSingleCommandHelpDescriptor({
description: 'A simple greet command',
args: [
{
name: 'name',
multiple: false,
optional: false
}
],
flags: {
excited: {
aliases: ['E'],
description: 'Whether to greet excitedly',
optional: true,
values: ['true', 'false']
}
},
stdio: {
stderr: 'Shown when an error occurs',
stdout: 'Shown when the command runs successfully',
stdin: 'Not used'
}
});
const multiCommandHelp = defineMultiCommandHelpDescriptor({
greet: 'Say hello',
farewell: {
description: 'Say goodbye'
},
math: {
description: 'Perform mathematical operations',
subcommands: {
add: {
description: 'Add numbers',
args: ['a', 'b'],
flags: {
verbose: 'Whether to show detailed output'
}
}
}
}
});As you can see, the help descriptor can be defined using two helper functions: defineSingleCommandHelpDescriptor and defineMultiCommandHelpDescriptor. The first one is used to define the help for a single command, while the second one is used to define the help for multiple commands.
Important: The
defineSingleCommandHelpDescriptorbrands the object with a$schemaproperty set to#SingleCommandHelpDescriptorto make it easily identifiable. If the object does not have this property, it will be treated as aMultiCommandHelpDescriptor, so be careful when manually creating the help descriptor object.
There are two JSON schema files available for the help descriptor objects:
These can be used to validate the help descriptor object during development as a JSON file.
Important: Remember to pass the imported JSON object to the
defineSingleCommandHelpDescriptorordefineMultiCommandHelpDescriptorfunctions to brand it correctly.
Go back to Summary
The extension system allows to extend the functionality of the commands. An extension is an object that contains multiple methods that will be added to the command's this.extensions object. The extension can also intercept the cli-core pipeline steps using the interceptor hooks.
To create an extension, you need to create an object that implements the CliCoreExtension interface:
import type { CliCoreExtension } from '@giancarl021/cli-core';
const MyExtension: CliCoreExtension = {
name: 'myExtension', // The name of the extension, must be unique and contain only alphanumeric characters and underscores (not starting with a number)
buildCommandAddons(options) {
// Everything returned here will be available in the command's `this.extensions.myExtension`
return {
myExtensionConst: 1e6,
myExtensionMethod() {
const flag = options.helpers.getFlagOrDefault(
'default',
'flag1',
'alias1'
); // All the helpers are available here
options.logger; // A extension-specific logger is available here
return options.appName + ' is awesome!';
}
};
},
interceptors: {
beforeParsing(options, rawArgs) {
options; // All the options passed to the CliCore instance are available here. Read-only.
options.logger; // A extension-specific logger is available here
rawArgs; // The raw arguments passed to the application, usually process.argv
// You can modify the options and rawArgs here if needed, or do a pre-loading step
return rawArgs;
},
beforeRouting(options, input) {
options; // All the options passed to the CliCore instance are available here. Read-only.
options.logger; // A extension-specific logger is available here
input; // The parsed arguments and flags
// You can modify the input here if needed, or do a pre-routing step
return input;
},
beforeRunning(options, route) {
options; // All the options passed to the CliCore instance are available here. Read-only.
options.logger; // A extension-specific logger is available here
route; // The resolved command route, containing the command callback, args and flags
// You can modify the route here if needed, or do a pre-execution step
// One example is to add dynamic commands, avoiding the need to declare them at startup
return route;
},
beforeError(options, error) {
options; // All the options passed to the CliCore instance are available here. Read-only.
options.logger; // A extension-specific logger is available here
error; // The error thrown during the execution of the command
// You can modify the error here if needed, or do a pre-error step
return error;
},
beforePrinting(options, output) {
options; // All the options passed to the CliCore instance are available here. Read-only.
options.logger; // A extension-specific logger is available here
output; // The output of the command, can be a string or the NO_OUTPUT symbol
// You can modify the output here if needed, or do a pre-printing step
return output;
},
beforeEnding(options) {
options; // All the options passed to the CliCore instance are available here. Read-only.
options.logger; // A extension-specific logger is available here
// This is the last step before ending the process, you can do some cleanup here if needed.
}
}
};Important: As the
options.behavior.extensionLoggingisfalseby default, the extension-specific loggers will beNullLoggerinstances. To see the logs from the extensions, the end user must enable theextensionLoggingoption manually. That being said, it is best practice to use the extension-specific logger only for debug logs, and use the command'sthis.loggerfor important logs that should be seen by the end user.
Note: The
beforePrintingandbeforeErrorinterceptors are mutually exclusive, as thebeforeErrorinterceptor will be called only if an error is thrown during the command execution, while thebeforePrintinginterceptor will be called only if the command executes successfully.
- The extension is created and added to the
extensionsarray in the options passed to theCliCoreinstance. - A
CliCoreapp is created and ran by the user. - The
beforeParsinginterceptors are called in the order they were added. - The arguments are parsed.
- The
beforeRoutinginterceptors are called in the order they were added. - The command is resolved by routing the parsed arguments.
- The
beforeRunninginterceptors are called in the order they were added. - The
buildCommandAddonsmethod of each extension is called, and the returned objects are merged into the command'sthis.extensionsobject. - The command is executed.
- If an error is thrown during the command execution, the
beforeErrorinterceptors are called in the order they were added. Otherwise, thebeforePrintinginterceptors are called in the order they were added. - The output is printed to the console, unless it is the
NO_OUTPUTsymbol. - The
beforeEndinginterceptors are called in the order they were added. - The process ends.
Important: The interceptors are not protected by a try-catch block, so if an error is thrown inside an interceptor, it will propagate to the end user and the application will exit with a non-zero status code, regardless of the
behavior.debugModeoption. It is recommended to handle any potential errors inside the interceptors.
To make TypeScript aware of the extension methods, you need to augment the CliCoreCommandAddons interface:
declare module '@giancarl021/cli-core' {
interface CliCoreCommandAddons {
myExtension: {
myExtensionMethod(): string;
myExtensionConst: number;
};
}
}This will make TypeScript aware of the myExtension object in the command's this.extensions object.
Go back to Summary
The debug mode can be enabled by setting the behavior.debugMode option to true. When enabled, the application will change some behaviors to make debugging easier:
- The logger will print
debuglevel logs - The logger will prefix each message with a timestamp and the log level
- The instance return the result of the command instead of printing it to the console
- Any error thrown will be propagated instead of being caught and printed to the console
- Any
process.exitcalls will be ignored
Go back to Summary
Contributions are welcome! Please open an issue or a pull request on GitHub.
Currently the code is 100% covered by tests, so please make sure to add tests for any new functionality.