What follows is a simple step-by-step guide for building, running, and testing a
brand new CLI tool from scratch. We shall call our new invention myctl
.
Tip
There's also a functional myctl
demo repository. And you can interact
with the published version on NPM: npx -p @black-flag/demo myctl --help
.
This guide is split into two main sections:
Let's make a new CLI project!
Tip
What follows are linux shell commands. The equivalent Windows DOS/PS commands will be different.
mkdir my-cli-project
cd my-cli-project
git init
Add a package.json
file with the bare minimum metadata:
echo '{"name":"myctl","version":"1.0.0","type":"module","bin":{"myctl":"./cli.js"}}' > package.json
npm install @black-flag/core
CJS example
echo '{"name":"myctl","version":"1.0.0","type":"commonjs","bin":{"myctl":"./cli.js"}}' > package.json
npm install @black-flag/core
Let's create the folder that will hold all our commands as well as the entry point Node recognizes:
mkdir commands
touch cli.js
chmod +x cli.js
Where cli.js
has the following content:
#!/usr/bin/env node
import { runProgram } from '@black-flag/core';
export default runProgram(import.meta.resolve('./commands'));
CJS example
#!/usr/bin/env node
const bf = require('@black-flag/core');
const path = require('node:path');
module.exports = bf.runProgram(path.join(__dirname, 'commands'));
Let's create our first command, the root command. Every Black Flag project has
one, it lives at the apex of our commands directory, and it's always named
index.js
(or index.mjs
, index.cjs
, index.ts
, index.mts
, index.cts
).
In vanilla Yargs parlance, this would be the highest-level "default command".
touch commands/index.js
Depending on how you invoke Black Flag (e.g. with Node, Deno, Babel+Node, etc),
all discoverable command files support a subset of the following extensions in
precedence order: .js
, .mjs
, .cjs
, .ts
(but not .d.ts
), .mts
,
.cts
. To keep things simple, we'll be using ES modules as .js
files; note
the "type"
property in package.json
.
Also note that empty files, and files that do not export a handler
function or custom command
string, are picked up by Black Flag as
unfinished or "unimplemented" commands. They will still appear in help text but,
when invoked, will either (1) output an error message explaining that the
command is not implemented if said command has no subcommands or (2) output help
text for the command if said command has one or more subcommands.
This means you can stub out a complex CLI in thirty seconds: just name your directories and empty files accordingly!
With that in mind, let's actually run our skeletal CLI now:
./cli.js
This command is currently unimplemented
Let's try with a bad positional parameter:
./cli.js bad
Usage: myctl
Options:
--help Show help text [boolean]
--version Show version number [boolean]
Unknown argument: bad
How about with a bad option:
./cli.js --bad
Usage: myctl
Options:
--help Show help text [boolean]
--version Show version number [boolean]
Unknown argument: bad
We could publish right now if we wanted to. The CLI would be perfectly
functional in that it would run to completion regardless of its current lack of
useful features. Our new package could then be installed via npm i -g myctl
,
and called from the CLI as myctl
! Let's hold off on that though.
You may have noticed that Black Flag calls yargs::strict(true)
on
auto-discovered commands by default, which is where the "unknown argument"
errors are coming from. In fact, commands are configured with several useful
defaults:
yargs::strict(true)
yargs::scriptName(fullName)
yargs::wrap(yargs::terminalWidth())
- If you want to tweak this across your entire command hierarchy, update
ExecutionContext::state.initialTerminalWidth
directly inconfigureExecutionContext
- If you want to tweak this across your entire command hierarchy, update
yargs::exitProcess(false)
- Black Flag only sets
process.exitCode
and never callsprocess.exit(...)
- Black Flag only sets
yargs::help(false)::option('help', { description })
- Black Flag supervises all help text generation, so this is just cosmetic
yargs::fail(...)
- Black Flag uses a custom failure handler
yargs::showHelpOnFail('short')
- All errors will be reported to the user alongside help text by default. This
can be tweaked by updating
ExecutionContext::state.showHelpOnFail
directly inconfigureExecutionContext
, or by calling Black Flag's customshowHelpOnFail
implementation (it overridesyargs::showHelpOnFail
) in a builder or elsewhere
- All errors will be reported to the user alongside help text by default. This
can be tweaked by updating
yargs::usage(defaultUsageText)
- Defaults to this.
- Note that, as of [email protected], calling
yargs::usage("...")
multiple times (such as inconfigureExecutionPrologue
) will concatenate each invocation's arguments into one long usage string (delimited by newlines). To work around this for "short" versus "full" help text output, we useyargs::usage(null)
to reset the current usage text.
yargs::version(false)
- For the root command,
yargs::version(false)::option('version', { description })
is called instead
- For the root command,
Most of these defaults can be tweaked or overridden via each command's
builder
function, which gives you direct access to the Yargs API. Let's
add one to commands/index.js
along with a handler
function and
usage
string:
// These @type comments give us intellisense support, but they're optional 🙂
// See ./commands/remote/add.js for my preferred export syntax/style.
/**
* @type {Extract<import('@black-flag/core').Configuration['builder'], Function>}
*/
export function builder(blackFlag) {
return blackFlag.strict(false);
}
/**
* @type {Extract<import('@black-flag/core').RootConfiguration['handler'], Function>}
*/
export function handler(argv) {
console.log('Ran root command handler');
}
/**
* Note that `usage` is just a freeform string used in help text. The `command`
* export, on the other hand, supports the Yargs DSL for defining positional
* parameters and the like.
*
* @type {import('@black-flag/core').RootConfiguration['usage']}
*/
export const usage = 'Usage: $0 command [options]\n\nCustom description here.';
CJS example
module.exports = {
builder: function (blackFlag) {
return blackFlag.strict(false);
},
handler: function (argv) {
console.log('Ran root command handler');
},
usage: 'Usage: $0 command [options]\n\nCustom description here.'
};
Tip
The Yargs DSL for declaring and defining positional parameters is described here.
Tip
Looking for more in-depth examples? Check out examples/
for a
collection of recipes solving all sorts of common CLI tasks using Black Flag,
including leveraging TypeScript and reviewing various different ways to define
command modules.
Now let's run the CLI again:
./cli.js
Ran root command handler
And with a "bad" argument (we're no longer in strict mode):
./cli.js --bad --bad2 --bad3
Ran root command handler
Neat. Let's add some more commands:
touch commands/init.js
mkdir commands/remote
touch commands/remote/index.js
touch commands/remote/add.js
touch commands/remote/remove.js
touch commands/remote/show.js
Well, that was easy. Let's run our CLI now:
./cli.js --help
Usage: myctl command [options]
Custom description here.
Commands:
myctl init
myctl remote
Options:
--help Show help text [boolean]
--version Show version number [boolean]
Let's try a child command:
./cli.js remote --help
Usage: myctl remote
Commands:
myctl remote add
myctl remote remove
myctl remote show
Options:
--help Show help text [boolean]
Since different OSes walk different filesystems in different orders, auto-discovered commands will appear in natural sort order in help text rather than in insertion order; command groupings are still respected and each command's options are still enumerated in insertion order.
Tip
Black Flag offers a stronger sorting guarantee than
yargs::parserConfiguration({ 'sort-commands': true })
.
Now let's try a grandchild command:
./cli.js remote show --help
Usage: myctl remote show
Options:
--help Show help text [boolean]
Phew. Alright, but what about trying some commands we know don't exist?
./cli.js remote bad horrible
Usage: myctl remote
Commands:
myctl remote add
myctl remote remove
myctl remote show
Options:
--help Show help text [boolean]
Invalid subcommand: you must call this with a valid subcommand argument
Neat! 📸
Testing if your CLI tool works by running it manually on the command line is nice and all, but if we're serious about building a stable and usable tool, we'll need some automated tests.
Thankfully, with Black Flag, testing your commands is usually easier than writing them.
First, let's install jest. We'll also create a file to hold our tests.
npm install --save-dev jest @babel/plugin-syntax-import-attributes
touch test.cjs
Since we set our root command to non-strict mode, let's test that it doesn't throw in the presence of unknown arguments. Let's also test that it exits with the exit code we expect and sends an expected response to stdout.
Note that we use makeRunner
below, which is a factory function that
returns a curried version of runProgram
that is far less tedious
to invoke successively.
Note
Each invocation of runProgram()
/makeRunner()()
configures and runs your
entire CLI from scratch. Other than stuff like the require cache,
there is no shared state between invocations unless you explicitly make it so.
This makes testing your commands "in isolation" dead simple and avoids a
common Yargs footgun.
const { makeRunner } = require('@black-flag/core/util');
// makeRunner is a factory function that returns runProgram functions with
// curried arguments.
const run = makeRunner({ commandModulesPath: `${__dirname}/commands` });
afterEach(() => {
// Since runProgram (i.e. what is returned by makeRunner) sets
// process.exitCode before returning, let's unset it after each test
process.exitCode = undefined;
});
describe('myctl (root)', () => {
it('emits expected output when called with no arguments', async () => {
expect.hasAssertions();
const logSpy = jest
.spyOn(console, 'log')
.mockImplementation(() => undefined);
const errorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
await run();
expect(errorSpy).not.toHaveBeenCalled();
expect(logSpy.mock.calls).toStrictEqual([['Ran root command handler']]);
});
it('emits expected output when called with unknown arguments', async () => {
expect.hasAssertions();
const logSpy = jest
.spyOn(console, 'log')
.mockImplementation(() => undefined);
const errorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
await run('--unknown');
await run('unknown');
expect(errorSpy).not.toHaveBeenCalled();
expect(logSpy.mock.calls).toStrictEqual([
['Ran root command handler'],
['Ran root command handler']
]);
});
it('still terminates with 0 exit code when called with unknown arguments', async () => {
expect.hasAssertions();
await run('--unknown-argument');
expect(process.exitCode).toBe(0);
});
});
CJS example
const { makeRunner } = require('@black-flag/core/util');
// makeRunner is a factory function that returns runProgram functions with
// curried arguments.
const run = makeRunner({ commandModulesPath: `${__dirname}/commands` });
afterEach(() => {
// Since runProgram (i.e. what is returned by makeRunner) sets
// process.exitCode before returning, let's unset it after each test
process.exitCode = undefined;
});
describe('myctl (root)', () => {
it('emits expected output when called with no arguments', async () => {
expect.hasAssertions();
const logSpy = jest
.spyOn(console, 'log')
.mockImplementation(() => undefined);
const errorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
await run();
expect(errorSpy).not.toHaveBeenCalled();
expect(logSpy.mock.calls).toStrictEqual([['Ran root command handler']]);
});
it('emits expected output when called with unknown arguments', async () => {
expect.hasAssertions();
const logSpy = jest
.spyOn(console, 'log')
.mockImplementation(() => undefined);
const errorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
await run('--unknown');
await run('unknown');
expect(errorSpy).not.toHaveBeenCalled();
expect(logSpy.mock.calls).toStrictEqual([
['Ran root command handler'],
['Ran root command handler']
]);
});
it('still terminates with 0 exit code when called with unknown arguments', async () => {
expect.hasAssertions();
await run('--unknown-argument');
expect(process.exitCode).toBe(0);
});
});
Tip
In our tests above, we took a behavior-driven approach and tested for
errors by looking at what console.error
should be outputting. This is how
users of our CLI will experience errors too, making this the ideal testing
approach in many cases. runProgram
/makeRunner
are configured
for this approach out of the box in that they never throw/reject, even when
an error occurs. Instead, they trigger the configured error handling
behavior (which defaults to console.error
), which is what our tests
check for.
However, in many other cases, a purely test-driven approach is required,
where we're not so interested in what the user should experience but in the
nature of the failure itself. To support this, makeRunner
supports the
errorHandlingBehavior
option. Setting errorHandlingBehavior
to
"throw"
will cause your curried runner functions to throw/reject in
addition to triggering the configured error handling behavior.
It's up to you to choose which approach is best.
Finally, let's run our tests:
NODE_OPTIONS='--no-warnings --experimental-vm-modules' npx jest --testMatch '**/test.cjs' --restoreMocks
PASS ./test.cjs
myctl (root)
✓ emits expected output when called with no arguments (168 ms)
✓ emits expected output when called with unknown arguments (21 ms)
✓ still terminates with 0 exit code when called with unknown arguments (20 ms)
Test Suites: 1 passed, 1 total
Tests: 3 passed, 3 total
Snapshots: 0 total
Time: 0.405 s, estimated 1 s
Ran all test suites.
Important
As of March 2025, we need to use NODE_OPTIONS='--experimental-vm-modules'
until the Node team unflags virtual machine module support in a future
version.
Tip
We use --restoreMocks
to ensure mock state doesn't leak between tests.
We use --testMatch '**/test.cjs'
to make Jest see our CJS files.
Neat! 📸