Skip to content

🏴 A declarative framework for building fluent, deeply hierarchical command line interfaces with yargs

License

Notifications You must be signed in to change notification settings

Xunnamius/black-flag

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

$ black-pearl hoist the colors --black-flag


Black Lives Matter! Last commit timestamp Codecov Source license Uses Semantic Release!

NPM version Monthly Downloads


Black Flag

Black Flag is a fairly thin library that wraps yargs, extending its capabilities with several powerful declarative features. It can be used to create simple single-level CLIs or deeply nested sprawling interfaces alike.

Black Flag tries to be a drop-in replacement for vanilla yargs, specifically for users of yargs::commandDir().

Tested on Ubuntu and Windows.

If you find yourself a fan of Black Flag's more declarative DX, check out Black Flag Extensions (BFE). BFE also protects you from a couple yargs footguns that Black Flag by itself cannot.


Install

npm install @black-flag/core

And if you're ready to go all in on Black Flag's declarative API, check out Black Flag Extensions:

npm install @black-flag/extensions

Features

Not yet familiar with yargs? Check out their intro documentation before continuing.

Declaratively Build Deep Command Hierarchies ✨

Black Flag provides first-class support for authoring simple one-off executables and sprawling deeply nested tree-like structures of commands and child commands alike.

No more pleading with yargs::commandDir() to behave. Less wrestling with positional parameters. Less tap-dancing around footguns. And no more dealing with help text that unexpectedly changes depending on the OS or the presence of aliases.

myctl --version
myctl init --lang 'node' --version=21.1
myctl remote add origin me@mine.myself
myctl remote add --help
myctl remote remove upstream
myctl remote show
myctl remote --help

Your hierarchy of commands is declared via the filesystem. Each command's configuration file is discovered and loaded automatically (so-called auto-discovery).

By default, commands assume the name of their file or, for index files, their parent directory; the root command assumes the name of the project taken from the nearest package.json file.

my-cli-project
β”œβ”€β”€ cli.ts
β”œβ”€β”€ commands
β”‚Β Β  β”œβ”€β”€ index.ts
β”‚Β Β  β”œβ”€β”€ init.ts
β”‚Β Β  └── remote
β”‚Β Β      β”œβ”€β”€ add.ts
β”‚Β Β      β”œβ”€β”€ index.ts
β”‚Β Β      β”œβ”€β”€ remove.ts
β”‚Β Β      └── show.ts
β”œβ”€β”€ test.ts
└── package.json

That's it. Easy peasy.

Built-In Support for Dynamic Options ✨

Dynamic options are options whose builder configuration relies on the resolved value of other options. Vanilla yargs does not support these, but Black Flag does:

# These two lines are identical
myctl init --lang 'node'
myctl init --lang 'node' --version=21.1
# And these three lines are identical
myctl init
myctl init --lang 'python'
myctl init --lang 'python' --version=3.8

Note how the default value of --version changes depending on the value of --lang. Further note that myctl init is configured to select the pythonic defaults when called without any arguments.

It's Yargs All the Way down ✨

At the end of the day, you're still working with yargs instances, so there's no unfamiliar interface to wrestle with and no brand new things to learn. All of yargs's killer features still work, the yargs documentation still applies, and Black Flag, as a wrapper around yargs, is widely compatible with the existing yargs ecosystem.

For example, Black Flag helps you validate those dynamic options using the same yargs API you already know and love:

// File: my-cli-project/commands/init.ts

// "argv" is a new third argument for builders    vvv
export function builder(yargs, helpOrVersionSet, argv) {
  //                                              ^^^

  // Tell yargs to leave strings that look like numbers as strings
  yargs.parserConfiguration({ 'parse-numbers': false });

  // This first conditional branch will be used to validate any dynamic
  // arguments and trigger the command's handler if validation succeeds
  //
  //   vvv
  if (argv) {
    // ^^^
    if (argv.lang === 'node') {
      return {
        lang: { choices: ['node'] },
        version: { choices: ['19.8', '20.9', '21.1'] }
      };
    } else {
      // Note how we can return a literal options object instead of calling
      // yargs.options(...), but we still can if we want to:
      return yargs.options({
        lang: { choices: ['python'] },
        version: {
          choices: ['3.10', '3.11', '3.12']
        }
      });
    }
  }
  // This else branch will be used for generic help text and first-pass parsing
  else {
    // This next line is the best you'd be able to do when using vanilla yargs.
    // But with Black Flag, it's only the fallback :)
    return {
      lang: { choices: ['node', 'python'] },
      version: { string: true }
    };
  }
}

export function handler(argv) {
  console.log(`> initializing new ${argv.lang}@${argv.version} project...`);
  // ...
}

See the demo repo for the complete implementation of this command.

myctl init --lang 'node' --version=21.1
> initializing new node@21.1 project...
myctl init --lang 'python' --version=21.1
Usage: myctl init

Options:
  --help     Show help text                                            [boolean]
  --lang                                                     [choices: "python"]
  --version                                    [choices: "3.10", "3.11", "3.12"]

Invalid values:
  Argument: version, Given: "21.1", Choices: "3.10", "3.11", "3.12"
myctl init --lang fake
Usage: myctl init

Options:
  --help     Show help text                                            [boolean]
  --lang                                             [choices: "node", "python"]
  --version                                                             [string]

Invalid values:
  Argument: lang, Given: "fake", Choices: "node", "python"
myctl init --help
Usage: myctl init

Options:
  --help     Show help text                                            [boolean]
  --lang                                             [choices: "node", "python"]
  --version                                                             [string]

If builder and handler sound familiar, it's because the exports from your command files are essentially the same as those expected by the yargs::command function: aliases, builder, command, deprecated, description, handler, and two new ones: name and usage.

The complete my-cli-project/commands/init.ts file could look like this:

// File: my-cli-project/commands/init.ts

import type { Configuration, $executionContext } from '@black-flag/core';

// Types are also available vvv
const configuration: Configuration = {
  //                        ^^^

  // ALL OF THESE ARE OPTIONAL! Black Flag would still accept this file even if
  // if were completely blank

  // An array of yargs aliases for this command. DO NOT include positional
  // arguments here, those go in `command` just like with vanilla yargs
  aliases: [],

  // Can be a yargs options object or a builder function like below
  builder(yargs, helpOrVersionSet, argv) {
    // We are never forced to return anything...
    // return yargs;
    // ... but we can if we want:
    return yargs.boolean('verbose');
    // We can also just return an options object too:
    return {
      verbose: {
        boolean: true,
        description: '...'
      }
    };
    // Also note you can access ExecutionContext with argv?.[$executionContext]
  },

  // Always a string. All commands must begin with "$0". Defaults to "$0". The
  // given value is also used to replace "$000" during string interpolation for
  // the usage option
  command: '$0 [positional-arg-1] [positional-arg-2]',

  // If true, this command will be considered deprecated. Defaults to false
  deprecated: false,

  // Used as the command's description in its parent command's help text, and
  // when replacing "$1" during string interpolation for the usage option. Set
  // to false to disable the description and hide the command. Defaults to ""
  description: 'initializes stuff',

  // This function is called when the arguments match and pass yargs
  // validation. Defaults to a function that throws CommandNotImplementedError
  handler(argv) {
    console.log(`> initializing new ${argv.lang} project...`);
    // Note that you can access ExecutionContext with argv[$executionContext]
  },

  // Used as the command's name in help text, when parsing arguments, when
  // replacing "$0" during string interpolation, and elsewhere. Usually defaults
  // to a trimmed version of the file/directory name
  name: 'init',

  // Used as the command's usage instructions in its own help text. "$000", if
  // present, will be replaced by the value of the command option. Afterwards,
  // "$1" and then "$0", if present, will be replaced by the description and
  // name options. Defaults to "Usage: $000\n\n$1". Will be trimmed before being
  // output
  usage: 'This is neat.'
};

export default configuration;

Run Your Tool Safely and Consistently ✨

Black Flag not only helps you declaratively build your CLI tool, but run it too.

#!/usr/bin/env node
// File: my-cli-project/cli.ts

import { runProgram } from '@black-flag/core';
// Just point Black Flag at the directory containing your command files
export default runProgram(import.meta.resolve('./commands'));
# This would work thanks to that shebang (#!)
./cli.js remote show origin
# This works after transpiling our .ts files to .js with babel...
node ./cli.js -- remote show origin
# ... and then publishing it and running: npm i -g @black-flag/demo
myctl remote show origin
# Or, if we were using a runtime that supported TypeScript natively
deno ./cli.ts -- remote show origin

The runProgram function bootstraps your CLI whenever you need it, e.g. when testing, in production, when importing your CLI as a dependency, etc.

runProgram never throws, and never calls process.exit since that's dangerous and a disaster for unit testing.

Under the hood, runProgram calls configureProgram, which auto-discovers and collects all the configurations exported from your command files, followed by PreExecutionContext::execute, which is a wrapper around yargs::parseAsync and yargs::hideBin.

import { join } from 'node:path';
import { runProgram, configureProgram } from '@black-flag/core';
import { hideBin, isCliError } from '@black-flag/core/util';

// Note that this example is using CJS-style path resolution. ESM is different.
export default runProgram(join(__dirname, 'commands'));

// ^^^ These are essentially equivalent vvv

let parsedArgv = undefined;

try {
  const commandsDir = join(__dirname, 'commands');
  const preExecutionContext = await configureProgram(commandsDir);
  parsedArgv = await preExecutionContext.execute(hideBin(process.argv));
  process.exitCode = 0;
} catch (error) {
  process.exitCode = isCliError(error) ? error.suggestedExitCode : 1;
}

export default parsedArgv;

Convention over Configuration ✨

Black Flag favors convention over configuration, meaning everything works out the box with sensible defaults and no sprawling configuration files.

However, when additional configuration is required, there are five optional configuration hooks that give Black Flag the flexibility to describe even the most bespoke of command line interfaces.

For instance, suppose we added a my-cli-project/configure.ts file to our project:

import type {
  ConfigureArguments,
  ConfigureErrorHandlingEpilogue,
  ConfigureExecutionContext,
  ConfigureExecutionEpilogue,
  ConfigureExecutionPrologue
} from '@black-flag/core';

// These configuration hooks have been listed in the order they're typically
// executed by Black Flag. They are all entirely optional.

/**
 * This function is called once towards the beginning of the execution of
 * configureProgram and should return what will be used to create the global
 * context singleton. Note that the return value of this function is cloned and
 * then discarded.
 */
export const configureExecutionContext: ConfigureExecutionContext = async (
  context
) => {
  // You can add some state shared between all your command handlers and
  // configuration hooks here.
  context.somethingDifferent = 'cool';
  return context; // <== This is: the "context" ExecutionContext used everywhere
};

/**
 * This function is called once towards the end of the execution of
 * configureProgram, after all commands have been discovered but before any
 * have been executed, and should apply any final configurations to the yargs
 * instances that constitute the command line interface.
 */
export const configureExecutionPrologue: ConfigureExecutionPrologue = async (
  { effector, helper, router }, // <== This is: root yargs instances (see below)
  context
) => {
  // Typically unnecessary and suboptimal to use this hook. Configure commands
  // (including the root command) declaratively using the simple declarative
  // filesystem-based API instead. Otherwise, at this point, you're just using
  // yargs but with extra steps.
};

/**
 * This function is called once towards the beginning of the execution of
 * PreExecutionContext::execute(X) and should return a process.argv-like
 * array.
 */
export const configureArguments: ConfigureArguments = async (
  rawArgv, // <== This is either the X in ::execute(X), or hideBin(process.argv)
  context
) => {
  // This is where yargs middleware and other argument pre-processing can be
  // implemented, if necessary.

  // When PreExecutionContext::execute is invoked without arguments, Black Flag
  // calls the yargs::hideBin helper utility on process.argv for you. Therefore,
  // calling hideBin here would cause a bug. You shouldn't ever need to call
  // hideBin manually, but if you do, it's re-exported from
  // '@black-flag/core/util'.

  return rawArgv; // <== This is: the argv that yargs will be given to parse
};

/**
 * This function is called once after CLI argument parsing completes and either
 * (1) handler execution succeeds or (2) a GracefulEarlyExitError is thrown.
 */
export const configureExecutionEpilogue: ConfigureExecutionEpilogue = async (
  argv, // <== This is: the yargs::parseAsync() result
  context
) => {
  // If a GracefulEarlyExitError was thrown, then
  // context.state.isGracefullyExiting === true.

  return argv; // <== This is: what is returned by PreExecutionContext::execute
};

/**
 * This function is called once at the very end of the error handling process
 * after an error has occurred. However, this function is NOT called when a
 * GracefulEarlyExitError is thrown!
 */
export const configureErrorHandlingEpilogue: ConfigureErrorHandlingEpilogue =
  async ({ error, message, exitCode }, argv, context) => {
    // message === (error?.message || String(error))

    // Bring your own error handling and reporting if you'd like! By default,
    // this hook will dump any error messages to stderr like so:
    console.error(message);
  };

Then our CLI's entry point might look something like this:

#!/usr/bin/env node
// File: my-cli-project/cli.ts

import { runProgram } from '@black-flag/core';

export default runProgram(
  // Note that this example is using ESM-style path resolution. CJS is different
  import.meta.resolve('./commands'),
  // Just pass an object of your configuration hooks. Promises are okay!
  import('./configure.js') // <== Might be ".ts" over ".js" for deno projects
);

Simple Comprehensive Error Handling and Reporting ✨

Black Flag provides unified error handling and reporting across all your commands. Specifically:

  • The ability to suggest an exit code when throwing an error.

    try {
      ...
    } catch(error) {
      // Black Flag sets process.exitCode for you regardless of what's thrown
      throw new 'something bad happened';
      // But you can suggest an exit code by throwing a CliError
      throw new CliError('something bad happened', { suggestedExitCode: 5 });
      // You can also tell Black Flag you'd like help text printed for this error
      throw new CliError('user failed to do something', { showHelp: true });
      // You can even wrap other errors with it
      throw new CliError(error, { suggestedExitCode: 9 });
    }
  • Handling graceful exit events (like when --help or --version is used) as non-errors automatically.

    // Throwing this in your handler or elsewhere will cause Black Flag to exit
    // immediately with a 0 exit code.
    throw new GracefulEarlyExitError();
  • Outputting all error messages to stderr (via console.error) by default.

  • Access to the parsed process arguments at the time the error occurred (if available).

How errors thrown during execution are reported to the user is determined by the optionally-provided configureErrorHandlingEpilogue configuration hook, as well as each command file's optionally-exported builder function.

// File: my-cli-project/cli.ts

await runProgram(import.meta.resolve('./commands'), {
  configureErrorHandlingEpilogue({ error }, argv, context) {
    // Instead of outputting to stderr by default, send all errors elsewhere
    sendJsErrorToLog4J(argv.aMoreDetailedErrorOrSomething ?? error);
  }
});
// File: my-cli-project/commands/index.ts

export function builder(blackFlag) {
  // Turn off outputting help text when an error occurs
  blackFlag.showHelpOnFail(false);
}

Note that framework errors and errors thrown in configureExecutionContext or configureExecutionPrologue, which are always the result of developer error rather than end user error, cannot be handled by configureErrorHandlingEpilogue. If you're using makeRunner/runProgram (which never throws) and a misconfiguration triggers a framework error, your application will set its exit code accordingly and send an error message to stderr. In such a case, use debug mode to gain insight if necessary.

A Pleasant Testing Experience ✨

Black Flag was built with a pleasant unit/integration testing experience in mind.

Auto-discovered commands are just importable JavaScript modules entirely decoupled from yargs and Black Flag, making them dead simple to test in isolation.

// File: my-cli-project/test.ts (with Jest as test runner)

import remoteRemove from './commands/remote/remove';

test('remote remove command works as expected', async () => {
  expect.hasAssertions();

  // Assuming "myctl remote remove" takes a positional argument "removal-target"
  const fakeArgv = { removalTarget: 'upstream' };

  // Run the command's handler with a fake "parsed" arguments object
  await remoteRemove.handler(fakeArgv);
  ...
});

Individual configuration hook functions, if used, are similarly mockable and testable without Black Flag.

Suppose we wrote some configuration hooks in my-cli-project/configure.ts:

// File: my-cli-project/configure.ts

import {
  type ConfigureArguments,
  type ConfigureErrorHandlingEpilogue
} from '@black-flag/core';

export const configureArguments: ConfigureArguments = (rawArgv) => {
  return preprocessInputArgs(rawArgv);

  function preprocessInputArgs(args) {
    // ...
  }
};

export const configureErrorHandlingEpilogue: ConfigureErrorHandlingEpilogue =
  async ({ error, message }, _argv, context) => {
    // ...
  };

Then we could test it with the following:

// File: my-cli-project/test.ts (with Jest as test runner)

import * as conf from './configure';

test('configureArguments returns pre-processed arguments', async () => {
  expect.hasAssertions();
  await expect(conf.configureArguments([1, 2, 3])).resolves.toStrictEqual([3]);
});

test('configureErrorHandlingEpilogue outputs as expected', async () => {
  expect.hasAssertions();

  const errorSpy =
    jest.spyOn(console, 'error').mockImplementation(() => undefined);

  await conf.configureErrorHandlingEpilogue(...);

  expect(errorSpy).toHaveBeenCalledWith(...);
});

And for those who prefer a more holistic behavior-driven testing approach, you can use the same function for testing your CLI that you use as an entry point in production: runProgram.

Black Flag additionally provides the makeRunner utility function so you don't have to tediously copy and paste runProgram(...) and all its arguments between tests.

// File: my-cli-project/test.ts (with Jest as test runner)

import { makeRunner } from '@black-flag/core/util';

let latestError: string | undefined = undefined;
const run = makeRunner(`${__dirname}/commands`, {
  // We run our commands decoupled from our CLI's actual configuration hooks,
  // since they're too heavy for use in our unit tests. Instead, we substitute
  // some bare bones configurations:
  configureExecutionEpilogue(argv, context) { /* Some after-action cleanup */ },
  configureErrorHandlingEpilogue({ message }) { latestError = message; }
});

beforeEach(() => (latestError = undefined));
afterEach(() => (process.exitCode = undefined));

it('supports help text at every level', async () => {
  expect.hasAssertions();

  const logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);

  await run('--help');
  await run('init --help');
  await run('remote --help');
  await run('remote add --help');
  await run('remote remove --help');
  await run('remote show --help');

  expect(logSpy.mock.calls).toStrictEqual([
    // Each "--help" invocation should call console.log once with 1 parameter...
    [expect.stringMatching(/.../)],
    // ... and there should have been 6 invocations total.
    ...,
    ...,
    ...,
    ...,
    ...,
  ]);
});

it('throws on bad init --lang argument', async () => {
  expect.hasAssertions();

  await run(['init', '--lang', 'bad']);
  expect(latestError).toBe('...');
  // Since we didn't disable it, Black Flag will also output help text for this
  // error. We could have tested for that with another jest spy if we wanted to.
});

Built-In debug Integration for Runtime Insights ✨

Black Flag integrates debug, allowing for deep insight into your tool's runtime without significant overhead or code changes. Simply set the DEBUG environment variable to an appropriate value:

# Shows all possible debug output
DEBUG='*' myctl
# Only shows built-in debug output from Black Flag
DEBUG='black-flag*' myctl
# Only shows custom debug output from your tool's command files
DEBUG='myctl*' myctl

Black Flag's truly rich debug output will prove a mighty asset in debugging any framework-related issues, and especially when writing unit/integration tests. When your CLI is crashing or your test is failing in a strange way, consider re-running the failing test or problematic CLI with debugging enabled.

It is also possible to get meaningful debug output from your commands themselves. Just include the debug package in your package.json dependencies and import it in your command files:

// File: my-cli-project/commands/index.ts

// Since it's at the apex of the commands/ directory, this file configures the
// "root command," i.e.:
//   myctl
//   myctl --help
//   myctl --version

import debugFactory from 'debug';

const debug = debugFactory('myctl');

export function handler(argv) {
  debug('beginning to do a bunch of cool stuff...');

  // ...

  const someResult = ...
  debug('saw some result: %O', someResult);

  // ...

  console.log('done!');
}
myctl
done!
DEBUG='myctl*' myctl
myctl beginning to do a bunch of cool stuff... +0ms
myctl saw some result: {
myctl   lists: [],
myctl   api: [Function: api],
myctl   apiHandler: [Function: handler],
myctl   anImportantString: 'very',
myctl } +220ms
done!
DEBUG='*' myctl
... A LOT OF DETAILED DEBUG OUTPUT FROM BLACK FLAG AND MYCTL ...
done!

Extensive Intellisense Support ✨

Black Flag itself is fully typed, and each exposed type is heavily commented. However, your command files are not tightly coupled with Black Flag. An unfortunate side effect of this flexibility is that your command files do not automatically pick up Black Flag's types in your IDE/editor. Fortunately, Black Flag exports all its exposed types, including the generic RootConfiguration, ParentConfiguration, and ChildConfiguration types.

Using these types, your command files themselves can be fully typed and you can enjoy the improved DX that comes with comprehensive intellisense. And for those who do not prefer TypeScript, you can even type your pure JavaScript files thanks to JSDoc syntax. No TypeScript required!

// @ts-check
// This is a pure CJS JavaScript file, no TypeScript allowed!

const { dirname, basename } = require('node:path');
const name = basename(dirname(__filename));

/**
 * @type {import('@black-flag/core').ParentConfiguration}
 */
module.exports = {
  description: `description for program ${name}`,
  builder: (blackFlag) => blackFlag.option(name, { count: true }),
  handler: (argv) => (argv.handled_by = __filename)
};

Child commands (commands not declared in index files) should use ChildConfiguration. Parent commands (commands declared in index files) should use ParentConfiguration. The root parent command (at the apex of the directory storing your command files) should use RootConfiguration.

There's also Configuration, the supertype of the three XConfiguration subtypes.

Similarly, if you're using configuration hooks in a separate file, you can enjoy intellisense with those as well using the ConfigureX generic types.

All of these generic types accept type parameters for validating custom properties you might set during argument parsing or on the shared execution context object.

See the docs for a complete list of Black Flag's exports and details about generics.


And that's Black Flag in a nutshell! Check out a complete demo repository for that snazzy myctl tool here. Or play with the real thing on NPM: npx -p @black-flag/demo myctl --help (also supports DEBUG environment variable). Or check out the step-by-step getting started guide below!

If you want to see an example of a fairly complex CLI built on Black Flag that implements global options, custom rich logging and error output, and support for configuration files, check out my personal CLI tool.

Usage

What follows is a simple step-by-step guide for building, running, and testing the myctl tool from the introductory section.

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.

Building and Running Your CLI

Let's make a new CLI project!

Note: 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

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'));

These examples use ESM syntax. CJS is also supported. For 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, and it's always named index.js. 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), command files support a subset of the following extensions in precedence order: .js, .mjs, .cjs, .ts, .mts, .cts. To keep things simple, we'll be using ES modules as .js files (note the type 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 sub-commands or (2) output help text for the command if said command has one or more sub-commands.

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())
  • yargs::exitProcess(false)
    • Black Flag only sets process.exitCode and never calls process.exit(...)
  • 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(true)
    • Black Flag uses a custom failure handler
  • yargs::usage(defaultUsageText)
    • Defaults to this.
    • Note that, as of yargs@17.7.2, calling yargs::usage(...) multiple times (such as in configureExecutionPrologue) will concatenate each invocation's arguments into one long usage string instead of overwriting previous invocations with later ones
  • yargs::version(false)
    • For the root command, yargs::version(false)::option('version', { description }) is called instead

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:

/**
 * This little comment gives us intellisense support :)
 *
 * Also note how we're using the `export const X = function(...) { ... }` syntax
 * instead of the streamlined `export function X(...) { ... }` syntax. Both of
 * these syntaxes are correct, however JSDoc does not support using "@type" on
 * the latter form for some reason.
 *
 * @type {import('@black-flag/core').Configuration['builder']}
 */
export const builder = function (blackFlag) {
  return blackFlag.strict(false);
};

/**
 * @type {import('@black-flag/core').RootConfiguration['handler']}
 */
export const handler = function (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.';

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

Wow, that was easy. Let's run our CLI now:

./cli.js --help

Usage: myctl command [options]

Custom description here.

Commands:
  myctl                                                                [default]
  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                                                         [default]
  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 alpha-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.

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                                                         [default]
  myctl remote add
  myctl remote remove
  myctl remote show

Options:
  --help  Show help text                                               [boolean]

Invalid command: you must call this command with a valid sub-command argument

Neat! πŸ“Έ

Testing Your CLI

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.

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({ commandModulePath: `${__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);
  });
});

Finally, let's run our tests:

npx --node-options='--experimental-vm-modules' jest --testMatch '**/test.cjs' --restoreMocks

As of January 2024, we need to use --node-options='--experimental-vm-modules' until the Node team unflags virtual machine module support in a future version.

We use --restoreMocks to ensure mock state doesn't leak between tests. We use --testMatch '**/test.cjs' to make Jest see our CJS files.


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.

Neat! πŸ“Έ

Appendix 🏴

Further documentation can be found under docs/.

Terminology

Term Description
command A "command" is a functional unit associated with a configuration file and represented internally as a trio of programs: effector, helper, and router. Further, each command is classified as one of: "pure parent" (root and parent), "parent-child" (parent and child), or "pure child" (child).
program A "program" is a yargs instance wrapped in a Proxy granting the instance an expanded set of features. Programs are represented internally by the Program type.
root The tippy top command in your hierarchy of commands and the entry point for any Black Flag application. Also referred to as the "root command".
default command A "default command" is yargs parlance for the CLI entry point. Technically there is no concept of a "default command" at the Black Flag level, though there is the root command.

Differences between Black Flag and Yargs

Note that yargs is a dependency of Black Flag. Black Flag is not a fork of yargs!

Aside from the expanded feature set, there are some minor differences between yargs and Black Flag. They should not be relevant given proper use of Black Flag, but are noted below nonetheless.

Minor Differences

  • The yargs::argv magic property is soft-disabled (always returns undefined) because having such an expensive "hot" getter is not optimal in a language where properties can be accessed unpredictably. For instance, deep cloning a yargs instance results in yargs::parse (and the handlers of any registered commands!) getting invoked several times, even after an error occurred in an earlier invocation. This can lead to undefined or even dangerous behavior.

    Who in their right mind is out here cloning yargs instances, you may ask? Jest does so whenever you use certain asymmetric matchers.

    Regardless, you should never have to reach below Black Flag's abstraction over yargs to call methods like yargs::parse, yargs::parseAsync, yargs::argv, etc. Instead, just use Black Flag as intended.

    Therefore, this is effectively a non-issue with proper declarative use of Black Flag.

  • Yargs middleware isn't supported since the functionality is mostly covered by configuration hooks and I didn't notice yargs had this feature until after I wrote Black Flag.

    If you have a yargs middleware function you want run with a specific command, either pass it to yargs::middleware via that command's builder function or just call the middleware function right then and there. If you want the middleware to apply globally, invoke the function directly in configureArguments. If neither solution is desirable, you can also muck around with the relevant yargs instances manually in configureExecutionPrologue.

  • By default, Black Flag enables the --help and --version options same as vanilla yargs. However, since vanilla yargs lacks the ability to modify or remove options added by yargs::option, calling yargs::help/yargs::version will throw. If you require the functionality of yargs::help/yargs::version to disable or modify the --help/--version option, update context.state.globalHelpOption/context.state.globalVersionOption directly in configureExecutionContext.

    Note: Black Flag enables built-in help and version options, never a help or version command.

    Note: only the root command has default support for the built-in --version option. Calling --version on a child command will have no effect unless you make it so. This dodges another yargs footgun, and setting context.state.globalVersionOption = undefined will prevent yargs from clobbering any custom version arguments on the root command too.

Irrelevant Differences

  • A bug in yargs@17.7.2 prevents yargs::showHelp/--help from printing anything when using an async builder function (or promise-returning function) for a default command.

    Black Flag addresses this with its types, in that attempting to pass an async builder will be flagged as problematic by intellisense. Moreover, Black Flag supports an asynchronous function as the value of module.exports in CJS code, and top-level await in ESM code, so if you really do need an async builder function, hoist the async logic to work around this bug for now.

  • A bug? in yargs@17.7.2 causes yargs::showHelp to erroneously print the second element in the aliases array of the default command when said command also has child commands.

    Black Flag addresses this by using a "helper" program to generate help text more consistently than vanilla yargs. For instance, the default help text for a Black Flag command includes the full command and description strings while the commands under "Commands:" are listed in alpha-sort order as their full canonical names only; unlike vanilla yargs, no positional arguments or aliases will be confusingly mixed into help text output unless you make it so.

  • As of yargs@17.7.2, attempting to add two sibling commands with the exact same name causes all sorts of runtime insanity, especially if the commands also have aliases.

    Black Flag prevents you from shooting yourself in the foot with this. Specifically: Black Flag will throw if you attempt to add a command with a name or alias that conflicts with its sibling commands' name or alias.

  • As of yargs@17.7.2, and similar to the above point, attempting to add two options with conflicting names/aliases to the same command leads to undefined and potentially dangerous runtime behavior from yargs.

    Unfortunately, since yargs allows adding options through a wide variety of means, Black Flag cannot protect you from this footgun. However, Black Flag Extensions (BFE) can.

    Specifically: BFE will throw if you attempt to add a command option with a name or alias that conflicts another of that command's options. BFE also takes into account the following yargs-parser settings configuration settings: camel-case-expansion, strip-aliased, strip-dashed. See BFE's documentation for details.

  • Unfortunately, yargs@17.7.2 doesn't really support calling yargs::parse or yargs::parseAsync multiple times on the same instance if it's using the commands-based API. This might be a regression since, among other things, there are comments within yargs's source that indicate these functions were intended to be called multiple times.

    Black Flag addresses this in two ways. First, the runProgram helper takes care of state isolation for you, making it safe to call runProgram multiple times. Easy peasy. Second, PreExecutionContext::execute (the wrapper around yargs::parseAsync) will throw if invoked more than once.

  • One of Black Flag's features is simple comprehensive error reporting via the configureErrorHandlingEpilogue configuration hook. Therefore, the yargs::showHelpOnFail method will ignore the redundant "message" parameter. If you want that functionality, use said hook to output an epilogue after yargs outputs an error message, or use yargs::epilogue/yargs::example. Also, any invocation of yargs::showHelpOnFail applies globally to all commands in your hierarchy.

  • Since every auto-discovered command translates into its own yargs instances, the command property, if exported by your command file(s), must start with "$0" or an error will be thrown. This is also enforced by intellisense.

  • The yargs::check, yargs::global, and yargs::onFinishCommand methods, while they may work as expected on commands and their direct child commands, will not function "globally" across your entire command hierarchy since there are several distinct yargs instances in play when Black Flag executes.

    If you want a uniform check or so-called "global" argument to apply to every command across your entire hierarchy, the "Black Flag way" would be to just use normal JavaScript instead: export a shared builder function from a utility file and call it in each of your command files. If you want something fancier than that, you can leverage configureExecutionPrologue to call yargs::global or yargs::check by hand.

    Similarly, yargs::onFinishCommand should only be called when the argv parameter in builder is not undefined (i.e. only on effector programs). This would prevent the callback from being executed twice. Further, the "Black Flag way" would be to ditch yargs::onFinishCommand entirely and use plain old JavaScript and/or the configureExecutionPrologue configuration hook instead.

  • Since Black Flag is built from the ground up to be asynchronous, calling yargs::parseSync will throw immediately. You shouldn't be calling the yargs::parseX functions directly anyway.

  • Black Flag sets several defaults compared to vanilla yargs. These defaults are detailed in the Usage section.

Advanced Usage

Note: you shouldn't need to reach below Black Flag's declarative abstraction layer when building your tool. If you feel that you do, consider opening a new issue!

Since Black Flag is just a bunch of yargs instances stacked on top of each other wearing a trench coat, you can muck around with the internal yargs instances directly if you want.

For example, you can retrieve a mapping of all commands known to Black Flag and their corresponding yargs instances in the OS-specific order they were encountered during auto-discovery:

import { runCommand, $executionContext } from '@black-flag/core';

const argv = await runCommand('./commands');

// The next two function calls result in identical console output

console.log('commands:', argv[$executionContext].commands);

await runCommand('./commands', {
  configureExecutionEpilogue(_argv, { commands }) {
    console.log('commands:', commands);
  }
});
commands: Map(6) {
  'myctl' => { programs: [Object], metadata: [Object] },
  'myctl init' => { programs: [Object], metadata: [Object] },
  'myctl remote' => { programs: [Object], metadata: [Object] },
  'myctl remote add' => { programs: [Object], metadata: [Object] },
  'myctl remote remove' => { programs: [Object], metadata: [Object] },
  'myctl remote show' => { programs: [Object], metadata: [Object] }
}

Each of these six commands is actually three programs:

  1. The effector (programs.effector) programs are responsible for second-pass arguments parsing and comprehensive validation, executing each command's actual handler function, generating specific help text during errors, and ensuring the final parse result bubbles up to the router program.

  2. The helper (programs.helper) programs are responsible for generating generic help text as well as first-pass arguments parsing and initial validation. Said parse result is used as the argv third parameter passed to the builder functions of effectors.

  3. The router (programs.router) programs are responsible for proxying control to other routers and to helpers, and for ensuring exceptions and final parse results bubble up to the root Black Flag execution context (PreExecutionContext::execute) for handling.

See the flow chart below for a visual overview.

These three programs representing the root command are accessible from the PreExecutionContext::rootPrograms property. They are also always the first item in the PreExecutionContext::commands map.

const preExecutionContext = configureProgram('./commands', {
  configureExecutionEpilogue(_argv, { commands }) {
    assert(preExecutionContext.rootPrograms === commands.get('myctl').programs);
    assert(
      preExecutionContext.rootPrograms ===
        commands.get(Array.from(commands.keys())[0])
    );
  }
});

await preExecutionContext.execute();

Effectors do the heavy lifting in that they actually execute their command's handler. They are accessible via the programs.effector property of each object in PreExecutionContext::commands, and can be configured as one might a typical yargs instance.

Helpers are "clones" of their respective effectors and are accessible via the programs.helper property of each object in PreExecutionContext::commands. These instances have been reconfigured to address a couple bugs in yargs help text output by excluding aliases from certain output lines and excluding positional arguments from certain others. A side-effect of this is that only effectors recognize top-level positional arguments, which isn't a problem Black Flag users have to worry about unless they're dangerously tampering with these programs directly.

Routers are partially-configured just enough to proxy control to other routers or to helpers and are accessible via the programs.router property of each object in PreExecutionContext::commands. They cannot and must not have any configured strictness or validation logic.

Therefore: if you want to tamper with the program responsible for running a command's handler, operate on the effector program. If you want to tamper with a command's generic stdout help text, operate on the helper program. If you want to tamper with validation and parsing, operate on both the helper and effectors. If you want to tamper with the routing of control between commands, operate on the router program.

See the docs for more details on Black Flag's internals.

Motivation

Rather than chain singular yargs instances together, the delegation of responsibility between helper and effectors facilitates the double-parsing necessary for dynamic options support. In implementing dynamic options, Black Flag accurately parses the given arguments with the helper program on the first pass and feeds the result to the builder function of the effector on the second pass (via builder's new third parameter).

In the same vein, hoisting routing responsibilities to the router program allows Black Flag to make certain guarantees:

  • An end user trying to invoke a parent command with no implementation, or a non-existent child command of such a parent, will cause help text to be printed and an exception to be thrown with default error exit code. E.g.: myctl parent child1 and myctl parent child2 work but we want myctl parent to show help text listing the available commands ("child1" and "child2") and exit with an error indicating the given command was not found.

  • An end user trying to invoke a non-existent child of a strict pure child command will cause help text to be printed and an exception to be thrown with default error exit code. E.g.: we want myctl exists noexist and myctl noexist to show help text listing the available commands ("exists") and exit with an error indicating bad arguments.

  • The right command gets to generate help and version text when triggered via arguments. To this end, passing --help/--version or equivalent arguments is effectively ignored by routers.

With vanilla yargs's strict mode, attempting to meet these guarantees would require disallowing any arguments unrecognized by the yargs instances earlier in the chain, even if the instances down-chain do recognize said arguments. This would break Black Flag's support for deep "chained" command hierarchies entirely.

However, without vanilla yargs's strict mode, attempting to meet these guarantees would require allowing attempts to invoke non-existent child commands without throwing an error or throwing the wrong/confusing error. Worse, it would require a more rigid set of assumptions for the yargs instances, meaning some API features would be unnecessarily disabled. This would result in a deeply flawed experience for developers and users.

Hence the need for a distinct routing program which allows parent commands to recursively chain/route control to child commands in your hierarchy even when ancestor commands are not aware of the syntax accepted by their distant descendantsβ€”while still properly throwing an error when the end user tries to invoke a child command that does not exist or invoke a child command with gibberish arguments.

Generating Help Text

Effectors are essentially yargs instances with a registered default command. Unfortunately, when vanilla yargs is asked to generate help text for a default command that has aliases and/or top-level positional arguments, you get the following:

Vanilla yargs parseAsync help text example

This is not ideal output for several reasons. For one, the "cmd" alias of the root command is being reported alongside subcmd as if it were a child command when in actuality it's just an alias for the default command.

Worse, the complete command string ('$0 root-positional') is also dumped into output, potentially without any explanatory text. And even with explanatory text for root-positional, what if the subcmd command has its own positional argument also called root-positional?

...
Commands:
  fake-name cmd root-positional     Root description                   [default]
  fake-name subcmd root-positional  Sub description
                                                  [aliases: sub, s] [deprecated]

Positionals:
  root-positional  Some description                                     [string]
...

It gets even worse. What if the description of subcmd's root-positional argument is different than the root command's version, and with entirely different functionality? At that point the help text is actually lying to the user, which could have drastic consequences when invoking powerful CLI commands with permanent effects.

On the other hand, given the same configuration, Black Flag outputs the following:

Black Flag runProgram help text example

Note 1: in this example, runProgram is a function returned by makeRunner.

Note 2: in the above image, the first line under "Commands:" is the root command. In more recent versions of Black Flag, the root command is omitted from the list of sub-commands.

Execution Flow Diagram

What follows is a flow diagram illustrating Black Flag's execution flow using the myctl example from the previous sections.

                           `myctl --verbose`
                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                 β”‚                 2                 β”‚
                 β”‚             β”Œβ”€β”€β”€β”€β”€β–Ίβ”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”‚             β”‚      β”‚           β”‚  β”‚
β”‚          β”‚  1  β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”     β”‚           β”‚  β”‚
β”‚   USER   β”œβ”€β”€β”€β”€β”€β”Όβ”€β–Ί   ROOT     β”‚     β”‚  ROUTER   β”‚  β”‚
β”‚ TERMINAL β”‚  R1 β”‚ β”‚  COMMAND   β”‚  R2 β”‚  (yargs)  β”‚  β”‚
β”‚          ◄─────┼──(Black Flag)◄──────           β”‚  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β”‚           β”‚  β”‚
                 β”‚                    β””β”¬β”€β”€β–²β”€β”€β”€β”¬β”€β”€β–²β”˜  β”‚
                 β”‚                 3A  β”‚  β”‚   β”‚  β”‚   β”‚
                 β”‚      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚   β”‚  β”‚   β”‚
                 β”‚      β”‚          R3A    β”‚   β”‚  β”‚   β”‚
                 β”‚      β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚  β”‚   β”‚
                 β”‚      β”‚ β”‚        3B         β”‚  β”‚   β”‚
                 β”‚      β”‚ β”‚     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚   β”‚
                 β”‚      β”‚ β”‚     β”‚  R3B           β”‚   β”‚
                 β”‚      β”‚ β”‚     β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
                 β”‚      β”‚ β”‚     β”‚ β”‚                  β”‚
                 β”‚      β”‚ β”‚ β”Œβ”€β”€β”€β–Όβ”€β”΄β”€β”€β” 4A β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
                 β”‚      β”‚ β”‚ β”‚ HELPER β”œβ”€β”€β”€β”€β–ΊEFFECTORβ”‚ β”‚
                 β”‚      β”‚ β”‚ β”‚ (yargs)β”‚ R4Aβ”‚ (yargs)β”‚ β”‚
                 β”‚      β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜β—„β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
                 β”‚      β”‚ β”‚                          β”‚
                 β””β”€β”€β”€β”€β”€β”€β”Όβ”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                        β”‚ β”‚
                        β”‚ β”‚`myctl remote --help`
                 β”Œβ”€β”€β”€β”€β”€β”€β”Όβ”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                 β”‚      β”‚ β”‚        4B                β”‚
                 β”‚      β”‚ β”‚    β”Œβ”€β”€β”€β”€β”€β–Ίβ”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
                 β”‚      β”‚ β”‚    β”‚      β”‚           β”‚  β”‚
                 β”‚ β”Œβ”€β”€β”€β”€β–Όβ”€β”΄β”€β”€β”€β”€β”΄β”     β”‚           β”‚  β”‚
                 β”‚ β”‚PARENT-CHILDβ”‚     β”‚  ROUTER   β”‚  β”‚
                 β”‚ β”‚  COMMAND   β”‚  R4Bβ”‚  (yargs)  β”‚  β”‚
                 β”‚ β”‚(Black Flag)◄──────           β”‚  β”‚
                 β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β”‚           β”‚  β”‚
                 β”‚                    β””β”¬β”€β”€β–²β”€β”€β”€β”¬β”€β”€β–²β”˜  β”‚
                 β”‚                 5A  β”‚  β”‚   β”‚  β”‚   β”‚
                 β”‚      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚   β”‚  β”‚   β”‚
                 β”‚      β”‚          R5A    β”‚   β”‚  β”‚   β”‚
                 β”‚      β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚  β”‚   β”‚
                 β”‚      β”‚ β”‚        5B         β”‚  β”‚   β”‚
                 β”‚      β”‚ β”‚     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚   β”‚
                 β”‚      β”‚ β”‚     β”‚  R5B           β”‚   β”‚
                 β”‚      β”‚ β”‚     β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
                 β”‚      β”‚ β”‚     β”‚ β”‚                  β”‚
                 β”‚      β”‚ β”‚ β”Œβ”€β”€β”€β–Όβ”€β”΄β”€β”€β” 6A β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
                 β”‚      β”‚ β”‚ β”‚ HELPER β”œβ”€β”€β”€β”€β–ΊEFFECTORβ”‚ β”‚
                 β”‚      β”‚ β”‚ β”‚ (yargs)β”‚ R6Aβ”‚ (yargs)β”‚ β”‚
                 β”‚      β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜β—„β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
                 β”‚      β”‚ β”‚                          β”‚
                 β””β”€β”€β”€β”€β”€β”€β”Όβ”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                        β”‚ β”‚
                        β”‚ β”‚`myctl remote remove origin`
                 β”Œβ”€β”€β”€β”€β”€β”€β”Όβ”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                 β”‚      β”‚ β”‚        6B                β”‚
                 β”‚      β”‚ β”‚    β”Œβ”€β”€β”€β”€β”€β–Ίβ”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
                 β”‚      β”‚ β”‚    β”‚      β”‚           β”‚  β”‚
                 β”‚ β”Œβ”€β”€β”€β”€β–Όβ”€β”΄β”€β”€β”€β”€β”΄β”     β”‚           β”‚  β”‚
                 β”‚ β”‚   CHILD    β”‚     β”‚  ROUTER   β”‚  β”‚
                 β”‚ β”‚  COMMAND   β”‚  R6Bβ”‚  (yargs)  β”‚  β”‚
                 β”‚ β”‚(Black Flag)◄──────           β”‚  β”‚
                 β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β”‚           β”‚  β”‚
                 β”‚                    β””β”€β”€β”€β”€β”¬β”€β”€β–²β”€β”€β”€β”˜  β”‚
                 β”‚                 7       β”‚  β”‚      β”‚
                 β”‚              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚      β”‚
                 β”‚              β”‚  R7         β”‚      β”‚
                 β”‚              β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜      β”‚
                 β”‚              β”‚ β”‚                  β”‚
                 β”‚          β”Œβ”€β”€β”€β–Όβ”€β”΄β”€β”€β” 8  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
                 β”‚          β”‚ HELPER β”œβ”€β”€β”€β”€β–ΊEFFECTORβ”‚ β”‚
                 β”‚          β”‚ (yargs)β”‚ R8 β”‚ (yargs)β”‚ β”‚
                 β”‚          β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜β—„β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
                 β”‚                                   β”‚
                 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Suppose the user executes myctl --verbose.πŸ‘’1 Black Flag (using runProgram) calls your configuration hooks, discovers all available commands, and creates three programs per discovered command: the "router", "helper", and "effector". If there was an error during discovery/configuration or hook execution, an internal error handling routine would execute before the process exited with the appropriate code.1πŸ‘’R1 This is how all errors that bubble up are handled. Otherwise, Black Flag calls the root RouterProgram::parseAsync.1πŸ‘’2 The router detects that the given arguments refer to the current command and so calls HelperProgram::parseAsync.2πŸ‘’3B If the helper throws (e.g. due to a validation error), the exception bubbles up to the root command.R3BπŸ‘’R1 Otherwise, the helper will parse the given arguments before calling EffectorProgram::parseAsync.3BπŸ‘’4A The effector will re-parse the given arguments, this time with the third argv parameter available to builder, before throwing an error, outputting help/version text, or in this case, calling the current command's handler function. The result of calling EffectorProgram::parseAsync bubbles up to the root commandR4AπŸ‘’R2 where it is then communicated to the user.R2πŸ‘’R1

The myctl command is the root command, and as such is the only command that doesn't have a parent command, making it a "pure parent".

Suppose instead the user executes myctl remote --help.πŸ‘’1 Black Flag (using runProgram) sets everything up and calls RouterProgram::parseAsync the same as the previous example.1πŸ‘’2 However, this time the router detects that the given arguments refer to a child command and so relinquishes control to the trio of programs representing the myctl remote command.2->3A Black Flag notes the user asked to generate generic help text (by having passed the --help argument) before calling RouterProgram::parseAsync.3A->4B myctl remote's router detects that the given arguments refer to the current command and that we're only generating generic help text so calls HelperProgram::showHelp4BπŸ‘’5B and throws a GracefulEarlyExitError that bubbles up to the root commandR5BπŸ‘’R2 where it is then communicated to the user.R2πŸ‘’R1

The myctl remote child command is a child command of the root myctl command, but it also has its own child commands, making it a parent and a child command (i.e. a "parent-child").

Finally, suppose the user executes myctl remote remove origin.πŸ‘’1 Black Flag (using runProgram) sets everything up and calls the root RouterProgram::parseAsync the same as the previous example.1πŸ‘’2 The router detects that the given arguments refer to a child command and so relinquishes control to the trio of programs representing the myctl remote command.2->3A The parent-child router detects that the given arguments refer to a child command and so relinquishes control to the trio of programs representing the myctl remote show command.3A->4B->5A myctl remote show's router detects that the given arguments refer to the current command5A->6B and so calls HelperProgram::parseAsync.6BπŸ‘’7 If the helper throws (e.g. due to a validation error), the exception bubbles up to the root command.R7πŸ‘’R1 Otherwise, the helper will parse the given arguments before calling EffectorProgram::parseAsync.7πŸ‘’8 The effector will re-parse the given arguments, this time with the third argv parameter available to builder, before calling the current command's handler function. The result of calling EffectorProgram::parseAsync bubbles up to the root commandR8πŸ‘’R2 where it is then communicated to the user.R2πŸ‘’R1

The myctl remote show child command is a child command of the parent-child myctl remote command. It has no children itself, making it a "pure child" command.

The ascii art diagram was built using https://asciiflow.com

Inspiration

I love yargs πŸ’• Yargs is the greatest! I've made over a dozen CLI tools with yargs, each with drastically different interfaces and requirements. A couple help manage critical systems.

Recently, as I was copying-and-pasting some configs from past projects for yet another tool, I realized the (irritatingly disparate πŸ˜–) structures of my CLI projects up until this point were converging on a set of conventions around yargs. And, as I'm always eager to "optimize" my workflows, I wondered how much of the boilerplate behind my "conventional use" of yargs could be abstracted away, making my next CLIs more stable upon release, much faster to build, and more pleasant to test. But perhaps most importantly, I could ensure my previous CLIs once upgraded would remain simple and consistent to maintain by myself and others in perpetuity.

Throw in a re-watch of the PotC series and Black Flag was born! πŸ΄β€β˜ πŸΎ

Published Package Details

This is a CJS2 package with statically-analyzable exports built by Babel for Node.js versions that are not end-of-life. For TypeScript users, this package supports both "Node10" and "Node16" module resolution strategies.

Expand details

That means both CJS2 (via require(...)) and ESM (via import { ... } from ... or await import(...)) source will load this package from the same entry points when using Node. This has several benefits, the foremost being: less code shipped/smaller package size, avoiding dual package hazard entirely, distributables are not packed/bundled/uglified, a drastically less complex build process, and CJS consumers aren't shafted.

Each entry point (i.e. ENTRY) in package.json's exports[ENTRY] object includes one or more export conditions. These entries may or may not include: an exports[ENTRY].types condition pointing to a type declarations file for TypeScript and IDEs, an exports[ENTRY].module condition pointing to (usually ESM) source for Webpack/Rollup, an exports[ENTRY].node condition pointing to (usually CJS2) source for Node.js require and import, an exports[ENTRY].default condition pointing to source for browsers and other environments, and other conditions not enumerated here. Check the package.json file to see which export conditions are supported.

Though package.json includes { "type": "commonjs" }, note that any ESM-only entry points will be ES module (.mjs) files. Finally, package.json also includes the sideEffects key, which is false for optimal tree shaking where appropriate.

License

See LICENSE.

Contributing and Support

New issues and pull requests are always welcome and greatly appreciated! 🀩 Just as well, you can star 🌟 this project to let me know you found it useful! ✊🏿 Or you could buy me a beer πŸ₯Ί Thank you!

See CONTRIBUTING.md and SUPPORT.md for more information.

Contributors

All Contributors

Thanks goes to these wonderful people (emoji key):

Bernard
Bernard

πŸš‡ πŸ’» πŸ“– 🚧 ⚠️ πŸ‘€
Add your contributions

This project follows the all-contributors specification. Contributions of any kind welcome!