Getting Started with CLI Forge
As CLI Forge is focused on first class TypeScript support, the examples below use TypeScript. If you are not using TypeScript, you can still use CLI Forge, and can pass --format js to the cli-forge init command when getting started. The runtime functionality of your CLI will not be affected by this choice, but you will lose out on the type safety and intellisense that TypeScript provides.
Manual installation
If adding a cli to an existing project, you may wish to install CLI Forge with npm or yarn:
npm install cli-forge
Automatic installation (cli-forge init)
To get started with a new CLI project, run the following command:
npx cli-forge init my-cli
This will create a new directory called my-cli with the following structure:
my-cli/
├── bin/
│ └── my-cli.ts
├── scripts/
│ └── build.ts
├── package.json
├── tsconfig.json
└── README.md
Here's what each file does:
-
The
bin/my-cli.tsacts as the entry point for your CLI. You can start adding commands and options to it right away. -
The
scripts/build.tsfile is a helper script that will compile your CLI using typescript. You can run this script withnpx tsx scripts/build.ts, or via the npm script we added vianpm run build.This script invokes
tsc, and copies thepackage.jsonfile to thedistdirectory. This is done to ensure that thepackage.jsonfile is included in the final package when publishing to npm. -
The
package.jsonfile contains the metadata for your CLI. You can add dependencies, scripts, and other metadata to this file.The important sections in the
package.jsonfile:name: The name of your CLI. This should be a unique name on npm.bin: Describes the CLI commands made available by your package. By default, this will be set to{"my-cli": "./bin/my-cli"}. This enables running your CLI vianpx my-cliormy-cliinside of an npm script. Fornpxto work directly, thebinentry should match the name of the package. If it doesn't, you can usenpx -p {package-name} {command}to run the CLI.dependencies: A list of dependencies that your CLI depends on. This list will be installed when someone installs your CLI via npm, or when they usenpxto run your CLI. As such, its a good idea to keep this list as small as possible.cli-forgeis the only direct dependency that is required when using CLI Forge.devDependencies: These are dependencies that are only needed during development.markdown-factorywill be added as a dev dependency to generate documentation for your CLI. It can be removed if you don't wish to generate documentation. By default when usingtypescriptthis list will contain the following packages:typescript: The typescript compiler.tsx: A typescript loader that allows you to import typescript files without needing to compile them first.@tsconfig/node-lts: A typescript configuration that is optimized for node.js development.
tsconfig.json: The typescript configuration file for your CLI. This file is used by the typescript compiler to determine how to compile your typescript files. By default, this file will extend the@tsconfig/node-ltsconfiguration, which is optimized for node.js development, and setup building to adistdirectory.
Writing your first command
Let's examine the bin/my-cli.ts file that was generated for you:
import { cli } from 'cli-forge';
const myCLI = cli('my-cli').command('hello', {
builder: (args) => args.positional('name', { type: 'string' }),
handler: (args) => {
console.log('hello', args.name);
},
});
export default myCLI;
if (require.main === module) {
myCLI.forge();
}
This file is doing a few interesting things:
- The
clifunction bootstraps a new CLI instance. This instance represents the root command of your CLI. - The
commandfunction adds a new command to the CLI. This function takes a name and an options object. The options object contains abuilderfunction that defines the arguments and options for the command, and ahandlerfunction that is called when the command is executed. - The
builderfunction is used to define the arguments and options for the command. In this case, we are defining a single positional argument callednamethat is of typestring. - The
handlerfunction is called when the command is executed. In this case, we are logginghellofollowed by thenameargument to the console. - The
export default myCLIline exports the CLI instance so that it can be used by the CLI Forge CLI to generate documentation. - The
if (require.main === module)block ensures that the CLI is only executed when run directly vianpx my-cliormy-cli. This is done to prevent the CLI from running when the file is imported as a module (e.g. when generating documentation).
We can customize this to add a new command called goodbye that takes an optional name argument:
import { cli } from 'cli-forge';
const myCLI = cli('my-cli')
.command('hello', {
builder: (args) => args.positional('name', { type: 'string' }),
handler: (args) => {
console.log('hello', args.name);
},
})
.command('goodbye', {
builder: (args) => args.positional('name', { type: 'string', default: 'world' }),
handler: (args) => {
console.log('goodbye', args.name);
},
});
export default myCLI;
if (require.main === module) {
myCLI.forge();
}
Invoking your CLI
To run your CLI without building it, you can use tsx. If you'd rather build your CLI first, you can run npm run build to compile your typescript files to javascript.
Using TSX (no build step):
npx tsx bin/my-cli.ts hello --name world
Using a build step:
npm run build
npx bin/my-cli hello --name world
Note: Future commands will only show the
tsxvariant of the CLI invocation. If you wish to see thenpm run buildvariant, you can refer back to this section.
Let's try running the cli without providing a command:
npx tsx bin/my-cli.ts
Woah! The CLI threw an error:
Error: my-cli requires a command
at InternalCLI.runCommand (/Users/agentender/repos/cli-forge/tmp/e2e/default/packages/cli-forge/src/lib/internal-cli.ts:314:15)
at <anonymous> (/Users/agentender/repos/cli-forge/tmp/e2e/default/packages/cli-forge/src/lib/internal-cli.ts:492:18)
at InternalCLI.withErrorHandlers (/Users/agentender/repos/cli-forge/tmp/e2e/default/packages/cli-forge/src/lib/internal-cli.ts:389:20)
at InternalCLI.forge (/Users/agentender/repos/cli-forge/tmp/e2e/default/packages/cli-forge/src/lib/internal-cli.ts:451:10)
at <anonymous> (/Users/agentender/repos/cli-forge/tmp/e2e/default/my-cli/bin/my-cli.ts:14:9)
at Object.<anonymous> (/Users/agentender/repos/cli-forge/tmp/e2e/default/my-cli/bin/my-cli.ts:15:1)
at Module._compile (node:internal/modules/cjs/loader:1467:14)
at Object.transformer (/Users/agentender/repos/cli-forge/tmp/e2e/default/my-cli/node_modules/tsx/dist/register-C1urN2EO.cjs:2:1122)
at Module.load (node:internal/modules/cjs/loader:1282:32)
at Module._load (node:internal/modules/cjs/loader:1098:12)
Usage: my-cli
Commands:
hello
Options:
--help - Show help for the current command
--version - Show the version number for the CLI
Run `my-cli [command] --help` for more information on a command
Disecting the error message, it has two parts:
- The first part is the error message itself:
my-cli requires a command. - The second part is the help message that is displayed when the CLI errors.
The help message shows the available commands and options for the CLI. In this case, the CLI has a single command called hello, and two options: --help and --version.
Our CLI requires a command to be provided because all of the following are true:
- The
cliinstance was created without options for the root command. - No root commamnd was added via
.command($0, ...) - The
.enableInteractiveShellmethod was not called on thecliinstance.
Note, registering the root command can be done by either providing options to the cli function or by adding a command via .command($0, ...). The result is equivalent.
The interactive shell
The interactive shell is a feature of CLI Forge that allows you to run your CLI in an interactive mode. This mode is useful for users which will run your CLI multiple times with different arguments, or for users who are not familiar with the CLI and want to explore the available commands and options.
To enable the interactive shell, you can call the .enableInteractiveShell method on the cli instance:
import { cli } from 'cli-forge';
const myCLI = cli('my-cli')
.enableInteractiveShell()
.command('hello', {
builder: (args) => args.positional('name', { type: 'string' }),
handler: (args) => {
console.log('hello', args.name);
},
})
.command('goodbye', {
builder: (args) => args.positional('name', { type: 'string', default: 'world' }),
handler: (args) => {
console.log('goodbye', args.name);
},
});
export default myCLI;
if (require.main === module) {
myCLI.forge();
}
Now, when you run the CLI without providing a command, the interactive shell will be started:
npx tsx bin/my-cli.ts
my-cli >
From here, you can type help to see the available commands and options, or type exit to exit the interactive shell.
my-cli > help
Usage: my-cli
Commands:
hello
goodbye
Options:
--help - Show help for the current command
--version - Show the version number for the CLI
You can run the hello and goodbye commands as you would normally:
my-cli > hello world
hello world
> my-cli > goodbye earth
goodbye earth
Note that the interactive shell is completely optional, and may not be suitable for all CLIs. If you don't want to use the interactive shell, you can remove the .enableInteractiveShell call from your CLI.
Adding options
Options are additional flags that can be passed to a command. They can be either boolean flags (e.g. --verbose), or flags that take a value (e.g. --output-file file.txt). CLI Forge supports several types of options out of the box, including:
-
string: A string value. (e.g.--name john) -
number: A number value. (e.g.--count 42) -
boolean: A boolean value. (e.g.--verbose,--verbose true,--no-verbose) -
array: An array of values.Arrays accept either numbers or strings for their items. They can be passed in 3 ways:
--array 1 2 3--array 1,2,3--array 1 --array 2 --array 3
-
object: An object value.Objects are the most complex type of option, and should be used sparingly. They are passed in via dot-notation:
--object.key value--object.key1 value1 --object.key2 value2
Nested objects are supported, and would look like this:
--object.key1.key2 value
Let's add a few options to the hello command:
const myCLI = cli('my-cli').command('hello', {
builder: (args) =>
args
.positional('people', { type: 'array', items: 'string' })
.option('newline', {
type: 'boolean',
description: 'Print greetings on separate lines',
})
.option('repeat', {
type: 'number',
description: 'Repeat the name n times',
default: 1,
}),
handler: (args) => {
const parts = ['hello'];
const names = args.people.join(args.newline ? '\n' : ', ');
console.log('hello', names.repeat(args.repeat));
},
});
In this example, we added two options to the hello command:
- The
newlineoption is a boolean flag that will print each name on a separate line if provided. - The
repeatoption is a number flag that will repeat the name n times if provided.
We also swapped the name positional argument for a people array argument. This allows us to greet multiple people at once.
The new CLI can be invoked like this:
npx tsx bin/my-cli.ts hello --people john jane --newline --repeat 3
Adding subcommands
Subcommands are commands that are nested under another command. They allow you to group related commands together, and can be used to create complex CLI structures.
We've already been looking at subcommands as the hello and goodbye commands are subcommands of the root command, but they feel a bit different so we can look at a slightly more complex example.
Note options that are registered are valid for the command they are registered on, as well as any subcommands that are added to the command. Lets look at an example:
import { cli } from 'cli-forge';
cli('my-cli')
.command('auth', {
builder: (args) =>
args
.option('host', { type: 'string', default: 'localhost' })
.command('login', {
builder: (args) => args.option('username', { type: 'string' }).option('password', { type: 'string' }),
handler: (args) => {
console.log('login', args.username, args.password);
},
})
.command('logout', {
handler: () => {
console.log('logout');
},
}),
})
.forge();
In this example, we added an auth command with two subcommands: login and logout. The login command takes two extra options: username and password, while the logout command takes no additional options. The host option is registered on the auth command, and is available to all subcommands.
The new CLI can be invoked like this:
npx tsx bin/my-cli.ts auth login --username john --password secret
Testing your CLI
Manual testing
We've been testing our CLI manually by running it with npx tsx bin/my-cli.ts. This is a good way to test your CLI as you are developing it, but it can be tedious to run the CLI manually every time you make a change to validate that it works as expected.
If you use Bun, the same workflow works with bun run bin/my-cli.ts.
Automated testing (unit tests)
CLI Forge is no different from any other node compatible library, and can be tested using any testing framework you like. The examples within the docs use node's built-in assert and test modules, as they are available without any additional dependencies.
CLI Forge provides a TestHarness class that can be used to test how your CLI commands behave. It is recommended that to use this class to test how your arguments are parsed, but to extract the logic of your handlers into separate functions that can be tested independently.
Here is an example of how you can test the hello command from the previous example:
import { TestHarness } from 'cli-forge';
import { describe, it } from 'node:test';
import * as assert from 'node:assert';
import { myCLI } from './my-cli';
const harness = new TestHarness(myCLI);
describe('hello', () => {
it('should greet people', async () => {
const { args, commandChain } = await harness.parse(['hello', '--people', 'john', 'jane', '--newline']);
assert.deepStrictEqual(args.people, ['john', 'jane']);
assert.deepStrictEqual(args.newline, true);
assert.deepStrictEqual(commandChain, ['hello']);
});
});
In this example, we are using the TestHarness class to test the hello command. We are testing that the people argument is parsed correctly, that the newline option is set to true, and that the command chain is correct.
The
commandChainis a representation of the command tree that was executed. E.g.['auth', 'login']would be the command chain for theauth logincommand.
Automated testing (end-to-end tests)
End-to-end tests are a great way to test your CLI in a real-world scenario. They can be used to test how your CLI behaves when run from the command line, and can be used to test the output of your CLI.
If your CLI is going to be published to npm and ran via npx, you can use a tool like verdaccio to create a local npm registry to test your CLI in a real-world scenario.
The exact setup for e2e is out of scope for this guide, but you can look at the e2e directory in the CLI Forge repository for an example of how to set up e2e tests for your CLI, including running generated CLIs with Bun.