Getting Started with CLI Forge
As CLI Forge is focused on first class TypeScript support, this guide will assume you are using 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
- Yarn
- pnpm
npm install cli-forge
yarn add cli-forge
pnpm add 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
Lets take a closer look at each of these files:
-
The
bin/my-cli.ts
acts as the entry point for your CLI. You can start adding commands and options to it right away. -
The
scripts/build.ts
file 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.json
file to thedist
directory. This is done to ensure that thepackage.json
file is included in the final package when publishing to npm. -
The
package.json
file contains the metadata for your CLI. You can add dependencies, scripts, and other metadata to this file.Let's take a closer look at each of the important sections in the
package.json
file: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-cli
ormy-cli
inside of an npm script. Fornpx
to work directly, thebin
entry 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 usenpx
to run your CLI. As such, its a good idea to keep this list as small as possible.cli-forge
is the only direct dependency that is required when using CLI Forge.devDependencies
: These are dependencies that are only needed during development.markdown-factory
will 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 usingtypescript
this 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-lts
configuration, which is optimized for node.js development, and setup building to adist
directory.
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
cli
function bootstraps a new CLI instance. This instance represents the root command of your CLI. - The
command
function adds a new command to the CLI. This function takes a name and an options object. The options object contains abuilder
function that defines the arguments and options for the command, and ahandler
function that is called when the command is executed. - The
builder
function is used to define the arguments and options for the command. In this case, we are defining a single positional argument calledname
that is of typestring
. - The
handler
function is called when the command is executed. In this case, we are logginghello
followed by thename
argument to the console. - The
export default myCLI
line 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-cli
ormy-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.
- TypeScript + TSX
- TypeScript + Build
npx tsx bin/my-cli.ts hello --name world
npm run build
npx bin/my-cli hello --name world
Note: Future commands will only show the
tsx
variant of the CLI invocation. If you wish to see thenpm run build
variant, 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
cli
instance was created without options for the root command. - No root commamnd was added via
.command($0, ...)
- The
.enableInteractiveShell
method was not called on thecli
instance.
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
newline
option is a boolean flag that will print each name on a separate line if provided. - The
repeat
option 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.
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
commandChain
is a representation of the command tree that was executed. E.g.['auth', 'login']
would be the command chain for theauth login
command.
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.