@black-flag/extensions v3.1.0
@black-flag/extensions 🏴
Black Flag Extensions (BFE) is a collection of surprisingly simple set-theoretic APIs that wrap Black Flag commands' exports to provide a bevy of new declarative features, some of which are heavily inspired by Yargs's GitHub Issues reports. It's like type-fest or jest-extended, but for Black Flag and Yargs!
In exchange for straying a bit from the vanilla Yargs API, BFE greatly increases Black Flag's declarative powers.
Note that BFE does not represent a complete propositional logic and so cannot describe every possible relation between arguments. Nor should it; BFE makes it easy to fall back to using the Yargs API imperatively in those rare instances it's necessary.
Install
To install:
npm install @black-flag/extensionsUsage
See also: differences between BFE and Yargs.
Most of the examples in this section (not including syntax-highlighted code blocks) are using hyphen characters followed by a word joiner character to prevent breaking examples awkwardly across lines. Be cautious copying and pasting.
withBuilderExtensions
⪢ API reference:
withBuilderExtensions
This function enables several additional options-related units of functionality
via analysis of the returned options configuration object and the parsed command
line arguments (i.e. argv).
import { withBuilderExtensions } from '@black-flag/extensions';
export default function command({ state }) {
const [builder, withHandlerExtensions] = withBuilderExtensions(
(blackFlag, helpOrVersionSet, argv) => {
blackFlag.strict(false);
// ▼ The "returned options configuration object"
return {
'my-argument': {
alias: ['arg1'],
demandThisOptionXor: ['arg2'],
string: true
},
arg2: {
boolean: true,
demandThisOptionXor: ['my-argument']
}
};
},
{ disableAutomaticGrouping: true }
);
return {
name: 'my-command',
builder,
handler: withHandlerExtensions(({ myArgument, arg2 }) => {
state.outputManager.log(
'Executing command with arguments: arg1=${myArgument} arg2=${arg2}'
);
})
};
}Note how, in the previous example, the option names passed to configuration
keys, e.g. { demandThisOptionXor: ['my-argument'] }, are represented by their
exact canonical names as defined (e.g. 'my-argument') and not their aliases
('arg1') or camel-case expanded forms ('myArgument'). All BFE configuration
keys expect canonical option names in this way; passing an alias or a camel-case
expansion will result in erroneous behavior.
In the same vein, withBuilderExtensions will throw if you attempt to add a
command option with a name, alias, or camel-case expansion that conflicts with
another of that command's options. This sanity check takes into account the
following yargs-parser configuration settings: camel-case-expansion,
strip-aliased, strip-dashed.
Also note how withBuilderExtensions returns a two-element array of the form:
[builder, withHandlerExtensions]. builder should be exported as your
command's builder function without being invoked. If you want to
implement additional imperative logic, pass a customBuilder function to
withBuilderExtensions as demonstrated in the previous example; otherwise, you
should pass an options configuration object.
On the other hand, withHandlerExtensions should be invoked immediately,
and its return value should be exported as your command's handler
function as demonstrated in the previous example. You should pass a
customHandler to withHandlerExtensions upon invocation, though this is not
required. If you call withHandlerExtensions() without providing a
customHandler, a placeholder function that throws CommandNotImplementedError
will be used instead, indicating that the command has not yet been implemented.
This mirrors Black Flag's default behavior for unimplemented command
handlers.
New Option Configuration Keys
This section details the new configuration keys made available by BFE, each implementing an options-related unit of functionality beyond that offered by vanilla Yargs and Black Flag.
Note that the checks enabled by these configuration keys:
Are run on Black Flag's second parsing pass except where noted. This allows BFE to perform checks against argument values in addition to the argument existence checks enabled by vanilla Yargs.
Will ignore the existence of the
defaultkey (unless it's a custom check). This means you can use keys likerequiresandconflictsalongsidedefaultwithout causing unresolvable CLI errors. This avoids a rather unintuitive Yargs footgun.Will take into account the following yargs-parser settings configuration settings:
camel-case-expansion,strip-aliased,strip-dashed. Note thatdot-notationis not currently recognized or considered by BFE, but may be in the future.
Logical Keys
In the below definitions,
P,Q, andRare arguments (or argument-value pairs) configured via a hypothetical call toblackFlag.options({ P: { [key]: [Q, R] }}). The truth values ofP,Q, andRas described in the "Definition" column represent the existence of each respective argument (and its value) in theargvparse result.gwavis a predicate standing for "given with any value," meaning the argument was given on the command line.
| Key | Definition |
|---|---|
requires | P ⟹ (Q ∧ R) or ¬P ∨ (Q ∧ R) |
conflicts | P ⟹ (¬Q ∧ ¬R) or ¬P ∨ (¬Q ∧ ¬R) |
implies | P ⟹ (Q ∧ R ∧ (gwav(Q) ⟹ Q) ∧ (gwav(R) ⟹ R)) |
demandThisOptionIf | (Q ∨ R) ⟹ P or P ∨ (¬Q ∧ ¬R) |
demandThisOption | P |
demandThisOptionOr | P ∨ Q ∨ R |
demandThisOptionXor | P ⊕ Q ⊕ R |
Relational Keys
| Key |
|---|
check |
subOptionOf |
looseImplications |
vacuousImplications |
requires
⪢ API reference:
requires
requiresis a superset of and replacement for vanilla Yargs'simplies. BFE also has its own implication implementation.
requires enables checks to ensure the specified arguments, or argument-value
pairs, are given conditioned on the existence of another argument. For example:
{
"x": { "requires": "y" }, // ◄ Disallows x without y
"y": {}
}This configuration will trigger a check to ensure that -y is given whenever
-x is given.
requires also supports checks against the parsed values of arguments in
addition to the argument existence checks demonstrated above. For example:
{
// ▼ Disallows x unless y == 'one' and z is given
"x": { "requires": [{ "y": "one" }, "z"] },
"y": {},
"z": { "requires": "y" } // ◄ Disallows z unless y is given
}This configuration allows the following arguments: no arguments (∅),
-y=..., -y=... -z, -xz -y=one; and disallows: -x, -z,
-x -y=..., -xz -y=..., -xz.
Note that, when performing a check using the parsed value of an argument and
that argument is configured as an array ({ array: true }), that array will be
searched for said value. Otherwise, a strict deep equality check is performed.
requires versus implies
Choose BFE's implies over requires when you want one argument to imply
the value of another without requiring the other argument to be explicitly
given in argv (e.g. via the command line).
conflicts
⪢ API reference:
conflicts
conflictsis a superset of vanilla Yargs'sconflicts.
conflicts enables checks to ensure the specified arguments, or argument-value
pairs, are never given conditioned on the existence of another argument. For
example:
{
"x": { "conflicts": "y" }, // ◄ Disallows y if x is given
"y": {}
}This configuration will trigger a check to ensure that -y is never given
whenever -x is given.
conflicts also supports checks against the parsed values of arguments in
addition to the argument existence checks demonstrated above. For example:
{
// ▼ Disallows y == 'one' or z if x is given
"x": { "conflicts": [{ "y": "one" }, "z"] },
"y": {},
"z": { "conflicts": "y" } // ◄ Disallows y if z is given
}This configuration allows the following arguments: no arguments (∅),
-y=..., -x, -z, -x -y=...; and disallows: -y=... -z,
-x -y=one, -xz -y=one, -xz.
Note that, when performing a check using the parsed value of an argument and
that argument is configured as an array ({ array: true }), that array will be
searched for said value. Otherwise, a strict deep equality check is performed.
conflicts versus implies
Choose BFE's implies over conflicts when you want the existence of one
argument to override the default/given value of another argument while not
preventing the two arguments from being given simultaneously.
implies
⪢ API reference:
impliesBFE's
impliesreplaces vanilla Yargs'simpliesin a breaking way. The two implementations are nothing alike. If you're looking for vanilla Yargs's functionality, seerequires.
implies will set a default value for the specified arguments conditioned on
the existence of another argument. This will override the default value of the
specified arguments.
Unless looseImplications is set to true, if any of the specified
arguments are explicitly given in argv (e.g. via the command line), their
values must match the specified argument-value pairs respectively (similar to
requires/conflicts). For this reason, implies only accepts one
or more argument-value pairs and not raw strings. For example:
{
"x": { "implies": { "y": true } }, // ◄ x becomes synonymous with xy
"y": {}
}This configuration makes it so that -x and -x -y=true result in the exact
same argv. Further, unlike requires, implies makes no demands on argument
existence and so allows the following arguments: no arguments (∅), -x,
-y=true, -y=false, -x -y=true; and disallows: -x -y=false.
Note that attempting to imply a value for a non-existent option will throw a framework error.
Additionally, if any of the specified arguments have their own defaults
configured, said defaults will be overridden by the values of implies. For
example:
{
"x": { "implies": { "y": true } },
"y": { "default": false } // ◄ y will still default to true if x is given
}Also note the special behavior of implies specifically in the case where
an argument value in argv is strictly equal to false.
For describing much more intricate implications between various arguments and
their values, see subOptionOf.
Handling Transitive Implications
implies configurations do not cascade transitively. This means if argument
P implies argument Q, and argument Q implies argument R, and P is
given, the only check that will be performed is on P and Q. If P must
imply some value for both Q and R, specify this explicitly in P's
configuration. For example:
{
- P: { "implies": { Q: true } },
+ P: { "implies": { Q: true, R: true } },
Q: { "implies": { R: true } },
R: {}
}This has implications beyond just implies. An implied value will not
transitively satisfy any other BFE logic checks (such as
demandThisOptionXor) or trigger any relational behavior (such as
with subOptionOf). The implied argument-value pair will simply be merged
into argv as if you had done it manually in your command's handler. If
this is a problem, prefer the explicit direct relationships described by other
configuration keys instead of relying on the implicit transitive
relationships described by implies.
Despite this constraint, any per-option checks you've configured, which
are run last (at the very end of withHandlerExtensions), will see the
implied argument-value pairs. Therefore, use check to guarantee any
complex invariants, if necessary; ideally, you shouldn't be setting bad defaults
via implies, but BFE won't stop you from doing so.
Handling Parser Configuration
Like other BFE checks, implies does take into account the yargs-parser
settings camel-case-expansion, strip-aliased, and strip-dashed; but
does not currently pay attention to dot-notation or
duplicate-arguments-array. implies may still work when using the latter
parser configurations, but it is recommended you turn them off instead.
implies versus requires/conflicts
BFE's implies, since it sets arguments in argv if they are not explicitly
given, is a weaker form of requires/conflicts.
Choose requires over BFE's implies when you want one argument to imply the
value of another while requiring the other argument to be explicitly given in
argv (e.g. via the command line).
Choose conflicts over BFE's implies when you think you want to use implies
but you don't actually need to override the default value of the implied
argument and only want the conflict semantics.
Alternatively, choose subOptionOf over BFE's implies when you want the
value of one argument to imply something complex about another argument and/or
its value, such as updating the other argument's options configuration.
looseImplications
If looseImplications is set to true, any of the specified arguments, when
explicitly given in argv (e.g. via the command line), will override any
configured implications instead of causing an error. When looseImplications is
set to false, which is the default, values explicitly given in argv must
match the specified argument-value pairs respectively (similar to
requires/conflicts).
vacuousImplications
By default, an option's configured implications will only take effect if said
option is given in argv with a non-false value. For example:
{
"x": {
"boolean": true,
"implies": { "y": true }
},
"y": {
// This example works regardless of the type of y!
"boolean": true,
//"array": true,
//"count": true,
//"number": true,
//"string": true,
"default": false
}
}If -x (or -x=true) is given, it is synonymous with -x -y (or
-x=true -y=true) being given and vice-versa. However, if -x=false (or
-no-x) is given, the implies key is effectively ignored. This means
-x=false does not imply anything about -y; -x=false -y=true and
-x=false -y=false are both accepted by BFE without incident.
In this way, the configured implications of boolean-type options are
never vacuously satisfied; a strictly false condition does not "imply"
anything about its consequent.
This feature reduces confusion for end users. For instance, suppose we had a CLI
build tool that accepted the arguments -patch and -only-patch. -patch
instructs the tool to patch any output before committing it to disk while
-only-patch instructs the tool to only patch pre-existing output already
on disk. The command's options configuration could look something like the
following:
{
"patch": {
"boolean": true,
"description": "Patch output using the nearest patcher file",
"default": true
},
"only-patch": {
"boolean": true,
"description": "Instead of building new output, only patch existing output",
"default": false,
"implies": { "patch": true }
}
}The following are rightly allowed by BFE (synonymous commands are grouped):
Is building and patching:
build-toolbuild-tool -patchbuild-tool -patch=truebuild-tool -only-patch=falsebuild-tool -no-only-patch
Is building and not patching:
build-tool -patch=falsebuild-tool -no-patchbuild-tool -no-patch -no-only-patch(this is the interesting one)
Is patching and not building:
build-tool -only-patchbuild-tool -only-patch=truebuild-tool -patch -only-patch
On the other hand, the following rightly cause BFE to throw:
build-tool -patch=false -only-patchbuild-tool -no-patch -only-patch
If BFE didn't ignore vacuous implications by default, the command
build-tool -no-patch -no-only-patch would erroneously cause BFE to throw
since implies: { patch: true } means "any time -only-patch is given, set
{ patch: true } in argv", which conflicts with -no-patch which already
sets { patch: false } in argv. This can be confusing for end users since the
command, while redundant, technically makes sense; it is logically
indistinguishable from build-tool -no-only-patch, which does not throw an
error.
To remedy this, BFE simply ignores the implies configurations of options when
their argument value is strictly equal to false in argv. To disable this
behavior for a specific option, set vacuousImplications to true (it is
false by default) or consider using
requires/conflicts/subOptionOf over implies.
demandThisOptionIf
⪢ API reference:
demandThisOptionIf
demandThisOptionIfis a superset of vanilla Yargs'sdemandOption.
demandThisOptionIf enables checks to ensure an argument is given when at least
one of the specified groups of arguments, or argument-value pairs, is also
given. For example:
{
"x": {},
"y": { "demandThisOptionIf": "x" }, // ◄ Demands y if x is given
"z": { "demandThisOptionIf": "x" } // ◄ Demands z if x is given
}This configuration allows the following arguments: no arguments (∅), -y,
-z, -yz, -xyz; and disallows: -x, -xy, -xz.
demandThisOptionIf also supports checks against the parsed values of
arguments in addition to the argument existence checks demonstrated above. For
example:
{
// ▼ Demands x if y == 'one' or z is given
"x": { "demandThisOptionIf": [{ "y": "one" }, "z"] },
"y": {},
"z": {}
}This configuration allows the following arguments: no arguments (∅), -x,
-y=..., -x -y=..., -xz, -xz y=...; and disallows: -z, -y=one,
-y=... -z.
Note that, when performing a check using the parsed value of an argument and
that argument is configured as an array ({ array: true }), that array will be
searched for said value. Otherwise, a strict deep equality check is performed.
Also note that a more powerful implementation of demandThisOptionIf can be
achieved via subOptionOf.
demandThisOption
⪢ API reference:
demandThisOption
demandThisOptionis an alias of vanilla Yargs'sdemandOption.demandOptionis disallowed by intellisense.
demandThisOption enables checks to ensure an argument is always given. This is
equivalent to demandOption from vanilla Yargs. For example:
{
"x": { "demandThisOption": true }, // ◄ Disallows ∅, y
"y": { "demandThisOption": false }
}This configuration will trigger a check to ensure that -x is given.
As an alias of vanilla Yargs's
demandOption, this check is outsourced to Yargs, which means it runs on Black Flag's first and second parsing passes like any other configurations key coming from vanilla Yargs.
demandThisOptionOr
⪢ API reference:
demandThisOptionOr
demandThisOptionOris a superset of vanilla Yargs'sdemandOption.
demandThisOptionOr enables non-optional inclusive disjunction checks per
group. Put another way, demandThisOptionOr enforces a "logical or" relation
within groups of required options. For example:
{
"x": { "demandThisOptionOr": ["y", "z"] }, // ◄ Demands x or y or z
"y": { "demandThisOptionOr": ["x", "z"] }, // ◄ Mirrors the above (discarded)
"z": { "demandThisOptionOr": ["x", "y"] } // ◄ Mirrors the above (discarded)
}This configuration will trigger a check to ensure at least one of x, y, or
z is given. In other words, this configuration allows the following arguments:
-x, -y, -z, -xy, -xz, -yz, -xyz; and disallows: no
arguments (∅).
In the interest of readability, consider mirroring the appropriate
demandThisOptionOr configuration to the other relevant options, though this is
not required (redundant groups are discarded). The previous example demonstrates
proper mirroring.
demandThisOptionOr also supports checks against the parsed values of
arguments in addition to the argument existence checks demonstrated above. For
example:
{
// ▼ Demands x or y == 'one' or z
"x": { "demandThisOptionOr": [{ "y": "one" }, "z"] },
"y": {},
"z": {}
}This configuration allows the following arguments: -x, -y=one, -z,
-x -y=..., -xz, -y=... -z, -xz -y=...; and disallows: no
arguments (∅), -y=....
Note that, when performing a check using the parsed value of an argument and
that argument is configured as an array ({ array: true }), that array will be
searched for said value. Otherwise, a strict deep equality check is performed.
demandThisOptionXor
⪢ API reference:
demandThisOptionXor
demandThisOptionXoris a superset of vanilla Yargs'sdemandOption+conflicts.
demandThisOptionXor enables non-optional exclusive disjunction checks per
exclusivity group. Put another way, demandThisOptionXor enforces mutual
exclusivity within groups of required options. For example:
{
"x": { "demandThisOptionXor": ["y"] }, // ◄ Disallows ∅, z, w, xy, xyw, xyz, xyzw
"y": { "demandThisOptionXor": ["x"] }, // ◄ Mirrors the above (discarded)
"z": { "demandThisOptionXor": ["w"] }, // ◄ Disallows ∅, x, y, zw, xzw, yzw, xyzw
"w": { "demandThisOptionXor": ["z"] } // ◄ Mirrors the above (discarded)
}This configuration will trigger a check to ensure exactly one of -x or
-y is given, and exactly one of -z or -w is given. In other words,
this configuration allows the following arguments: -xz, -xw, -yz,
-yw; and disallows: no arguments (∅), -x, -y, -z, -w, -xy,
-zw, -xyz, -xyw, -xzw, -yzw, -xyzw.
In the interest of readability, consider mirroring the appropriate
demandThisOptionXor configuration to the other relevant options, though this
is not required (redundant groups are discarded). The previous example
demonstrates proper mirroring.
demandThisOptionXor also supports checks against the parsed values of
arguments in addition to the argument existence checks demonstrated above. For
example:
{
// ▼ Demands x xor y == 'one' xor z
"x": { "demandThisOptionXor": [{ "y": "one" }, "z"] },
"y": {},
"z": {}
}This configuration allows the following arguments: -x, -y=one, -z,
-x -y=..., -y=... -z; and disallows: no arguments (∅), -y=...,
-x -y=one, -xz, -y=one -z, -xz -y=....
Note that, when performing a check using the parsed value of an argument and
that argument is configured as an array ({ array: true }), that array will be
searched for said value. Otherwise, a strict deep equality check is performed.
check
⪢ API reference:
check
check is the declarative option-specific version of vanilla Yargs's
yargs::check().
This function receives the currentArgumentValue, which you are free to type as
you please, and the fully parsed argv. If this function throws, the exception
will bubble. If this function returns an instance of Error, a string, or any
non-truthy value (including undefined or not returning anything), Black Flag
will throw a CliError on your behalf.
All check functions are run in definition order and always at the very end of
the second parsing pass, well after all other BFE checks have passed and
all updates to argv have been applied (including from subOptionOf and
BFE's implies). This means check always sees the final version of
argv, which is the same version that the command's handler is passed.
checkfunctions are skipped if their corresponding argument does not exist inargv.
When a check fails, execution of its command's handler function will
cease and configureErrorHandlingEpilogue will be invoked (unless you
threw/returned a GracefulEarlyExitError). For example:
export const [builder, withHandlerExtensions] = withBuilderExtensions({
x: {
number: true,
check: function (currentXArgValue, fullArgv) {
if (currentXArgValue < 0 || currentXArgValue > 10) {
throw new Error(
`"x" must be between 0 and 10 (inclusive), saw: ${currentXArgValue}`
);
}
return true;
}
},
y: {
boolean: true,
default: false,
requires: 'x',
check: function (currentYArgValue, fullArgv) {
if (currentYArgValue && fullArgv.x <= 5) {
throw new Error(
`"x" must be greater than 5 to use 'y', saw: ${fullArgv.x}`
);
}
return true;
}
}
});You may also pass an array of check functions, each being executed after the other. This makes it easy to reuse checks between options. For example:
Providing an array with one or more async check functions will result in them all being awaited concurrently.
export const [builder, withHandlerExtensions] = withBuilderExtensions({
x: {
number: true,
check: [checkArgBetween0And10('x'), checkArgGreaterThan5('x')]
},
y: {
number: true,
check: checkArgBetween0And10('y')
},
z: {
number: true,
check: checkArgGreaterThan5('z')
}
});
function checkArgBetween0And10(argName) {
return function (argValue, fullArgv) {
return (
(argValue >= 0 && argValue <= 10) ||
`"${argName}" must be between 0 and 10 (inclusive), saw: ${argValue}`
);
};
}
function checkArgGreaterThan5(argName) {
return function (argValue, fullArgv) {
return (
argValue > 5 || `"${argName}" must be greater than 5, saw: ${argValue}`
);
};
}See the Yargs documentation on yargs::check() for more information.
subOptionOf
⪢ API reference:
subOptionOf
One of Black Flag's killer features is native support for dynamic options.
However, taking advantage of this feature in a command's builder export
requires a strictly imperative approach.
Take, for example, the init command from @black-flag/demo:
// Taken at 03/23/2025 from @black-flag/demo "myctl" CLI
const PYTHON_DEFAULT_VERSION = '3.13';
const NODE_DEFAULT_VERSION = '23.3';
export function builder(yargs, _helpOrVersionSet, argv) {
// Tell Yargs to leave strings that look like numbers as strings
yargs.parserConfiguration({ 'parse-numbers': false });
// ▼ This imperative logic is a bit of an eyesore...
if (argv?.lang === 'node') {
return {
lang: { choices: ['node'], default: 'node' },
version: {
choices: ['20.18', '22.12', '23.3'],
default: NODE_DEFAULT_VERSION
}
};
} else if (argv?.lang === 'python') {
return {
lang: { choices: ['python'], default: 'python' },
version: {
choices: ['3.11', '3.12', '3.13'],
default: PYTHON_DEFAULT_VERSION
}
};
}
return {
lang: {
choices: ['node', 'python'],
// ▼ Having to use a default description is a little suboptimal...
defaultDescription: '"python"',
// ▼ This imperative logic is a little ugly...
default: argv ? 'python' : undefined
},
version: {
string: true,
// ▼ Having to use a default description is a little suboptimal...
defaultDescription: `"${PYTHON_DEFAULT_VERSION}"`,
// ▼ This imperative logic is a little ugly...
default: argv ? PYTHON_DEFAULT_VERSION : undefined
}
};
}
export function handler(argv) {
console.log(`> initializing new ${argv.lang}@${argv.version} project...`);
// ...
}Taking advantage of dynamic options support like like we did above gifts your CLI with help text more meaningful than anything you could accomplish with vanilla Yargs.
For example:
myctl init --lang node --version=23.3
> initializing new node@23.3 project...myctl init --lang python --version=23.3
Usage: myctl init
Options:
--help Show help text [boolean]
--lang [choices: "python"] [default: "python"]
--version [choices: "3.11", "3.12", "3.13"] [default: "3.13"]
Invalid values:
Argument: version, Given: "23.3", Choices: "3.10", "3.11", "3.12"myctl init --lang fake
Usage: myctl init
Options:
--help Show help text [boolean]
--lang [choices: "node", "python"] [default: "python"]
--version [string] [default: "3.13"]
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"] [default: "python"]
--version [string] [default: "3.13"]myctl init --lang node --help
Usage: myctl init
Options:
--help Show help text [boolean]
--lang [choices: "node"] [default: "node"]
--version [choices: "20.18", "22.12", "23.3"] [default: "23.3"]Ideally, Black Flag would allow us to describe the relationship between
--lang and its suboption --version declaratively, without having to
drop down to imperative interactions with the Yargs API like we did above.
This is the goal of the subOptionOf configuration key. Using subOptionOf,
developers can take advantage of dynamic options without sweating the
implementation details.
subOptionOfupdates are run and applied during Black Flag's second parsing pass.
For example:
/**
* @type {import('@black-flag/core').Configuration['builder']}
*/
export const [builder, withHandlerExtensions] = withBuilderExtensions({
x: {
choices: ['a', 'b', 'c'],
demandThisOption: true,
description: 'A choice'
},
y: {
number: true,
description: 'A number'
},
z: {
// ▼ These configurations are applied as the baseline or "fallback" during
// Black Flag's first parsing pass. The updates within subOptionOf are
// evaluated and applied during Black Flag's second parsing pass.
boolean: true,
description: 'A useful context-sensitive flag',
subOptionOf: {
// ▼ Ignored if x is not given
x: [
{
when: (currentXArgValue, fullArgv) => currentXArgValue === 'a',
update:
// ▼ We can pass an updater function that returns an opt object.
// This object will *replace* the argument's old configuration!
(oldXArgumentConfig, fullArgv) => {
return {
// ▼ We don't want to lose the old config, so we spread it
...oldXArgumentConfig,
description: 'This is a switch specifically for the "a" choice'
};
}
},
{
when: (currentXArgValue, fullArgv) => currentXArgValue !== 'a',
update:
// ▼ Or we can just pass the replacement configuration object. Note
// that, upon multiple `when` matches, the last update in the
// chain will win. If you want merge behavior instead of overwrite,
// spread the old config in the object you return.
{
string: true,
description: 'This former-flag now accepts a string instead'
}
}
],
// ▼ Ignored if y is not given. If x and y ARE given, since this occurs
// after the x config, this update will overwrite any others. Use the
// functional form + object spread to preserve the old configuration.
y: {
when: (currentYArgValue, fullArgv) =>
fullArgv.x === 'a' && currentYArgValue > 5,
update: (oldConfig, fullArgv) => {
return {
array: true,
demandThisOption: true,
description:
'This former-flag now accepts an array of two or more strings',
check: function (currentZArgValue, fullArgv) {
return (
currentZArgValue.length >= 2 ||
`"z" must be an array of two or more strings, only saw: ${currentZArgValue.length ?? 0}`
);
}
};
}
},
// ▼ Since "does-not-exist" is not an option defined anywhere, this will
// always be ignored
'does-not-exist': []
}
}
});You cannot nest
subOptionOfkeys within each other nor return an object containingsubOptionOffrom anupdatethat did not already have one. Doing so will trigger a framework error.
Now we're ready to re-implement the init command from myctl using our new
declarative superpowers:
export const [builder, withHandlerExtensions] = withBuilderExtensions(
function (blackFlag) {
blackFlag.parserConfiguration({ 'parse-numbers': false });
return {
lang: {
// ▼ These two are fallback or "baseline" configurations for --lang
choices: ['node', 'python'],
default: 'python',
subOptionOf: {
// ▼ Yep, --lang is also a suboption of --lang
lang: [
{
when: (lang) => lang === 'node',
// ▼ Remember: updates completely overwrite baseline config...
update: {
choices: ['node'],
default: 'node'
}
},
{
when: (lang) => lang !== 'node',
// ▼ ... though we can still reuse the "old" baseline config
update(oldOptionConfig) {
return {
...oldOptionConfig,
choices: ['python']
};
}
}
]
}
},
version: {
// ▼ These two are fallback or "baseline" configurations for --version
string: true,
default: '3.13',
subOptionOf: {
// ▼ --version is a suboption of --lang
lang: [
{
when: (lang) => lang === 'node',
update: {
choices: ['20.18', '22.12', '23.3'],
default: '23.3'
}
},
{
when: (lang) => lang !== 'node',
update(oldOptionConfig) {
return {
...oldOptionConfig,
choices: ['3.11', '3.12', '3.13']
};
}
}
]
}
}
};
}
);Easy peasy!
Another benefit of subOptionOf: all configuration relevant to an option is
co-located within that option and not spread across some function or file. We
don't have to go looking for the logic that's modifying --version since it's
all right there in one code block. We also don't have to repeat ourselves or
pass around X_DEFAULT_VERSION variables to hold defaults anymore!
See the examples directory for more subOptionOf demonstrations,
including a fleshed out version of myctl implemented using BFE.
Support for default with conflicts/requires/etc
BFE (and, consequently, BF/Yargs when not generating help text) will ignore the
existence of the default key until near the end of BFE's execution.
This means the optional
customBuilderfunction passed towithBuilderExtensionswill not see any defaulted values. However, your command handlers will.An explicitly
undefineddefault, i.e.{ default: undefined }, will be deleted from the configuration object by BFE and completely ignored by Black Flag and Yargs. This differs from BF/Yargs's default behavior, which is to recognizeundefineddefaults.
Defaults are set before any check functions are run, before any
implications are set, and before the relevant command handler is
invoked, but after all other BFE checks have succeeded. This enables the use
of keys like requires and conflicts alongside default
without causing impossible configurations that throw unresolvable CLI
errors.
This workaround avoids a (in my opinion) rather unintuitive Yargs footgun, though there are decent arguments in support of vanilla Yargs's behavior.
Strange and Impossible Configurations
Note that there are no sanity checks performed to prevent options configurations that are unresolvable, so care must be taken not to ask for something insane.
For example, the following configurations are impossible to resolve:
{
"x": { "requires": "y" },
"y": { "conflicts": "x" }
}{
"x": { "requires": "y", "demandThisOptionXor": "y" },
"y": {}
}Similarly, silly configurations like the following, while typically resolvable, are strange and may not work as expected:
{
"x": { "requires": "x", "demandThisOptionXor": "x" }
}{
"x": { "implies": { "x": 5 } }
}Automatic Grouping of Related Options
To support this functionality, options must be described declaratively. Defining options imperatively will break this feature.
BFE supports automatic grouping of related options for improved UX, which is enabled by default. These new groups are:
- "Required Options": options configured with
demandThisOption. - "Required Options (at least one)": options configured with
demandThisOptionOr. - "Required Options (mutually exclusive)": options configured with
demandThisOptionXor. - "Common Options": options provided via
{ commonOptions: [...] }towithBuilderExtensionsas its second parameter:withBuilderExtensions({/*...*/}, { commonOptions }); - "Optional Options": remaining options that do not fall into any of the above categories.
An example from xunnctl:
$ x f b --help
Usage: xunnctl firewall ban
Add an IP from the global hostile IP list.
Required Options:
--ip An ipv4, ipv6, or supported CIDR [array]
Optional Options:
--comment Include custom text with the ban comment where applicable [string]
Common Options:
--help Show help text [boolean]
--hush Set output to be somewhat less verbose [boolean] [default: false]
--quiet Set output to be dramatically less verbose (implies --hush) [boolean] [default: false]
--silent No output will be generated (implies --quiet) [boolean] [default: false]
--config-path Use a custom configuration file
[string] [default: "/home/freelance/.config/xunnctl-nodejs/state.json"]$ x d z u --help
Usage: xunnctl dns zone update
Reinitialize a DNS zones.
Required Options (at least one):
--apex Zero or more zone apex domains [array]
--apex-all-known Include all known zone apex domains [boolean]
Optional Options:
--force Disable protections [boolean]
--purge-first Delete pertinent records on the zone before recreating them [boolean]
Common Options:
--help Show help text [boolean]
--hush Set output to be somewhat less verbose [boolean] [default: false]
--quiet Set output to be dramatically less verbose (implies --hush) [boolean] [default: false]
--silent No output will be generated (implies --quiet) [boolean] [default: false]
--config-path Use a custom configuration file
[string] [default: "/home/freelance/.config/xunnctl-nodejs/state.json"]By including an explicit group property in an option's configuration,
the option will be included in said group in addition to the result of
automatic grouping, e.g.:
const [builder, withHandlerExtensions] = withBuilderExtensions({
'my-option': {
boolean: true,
description: 'mine',
default: true,
// This option will be placed into the "Custom Grouped Options" group AND
// ALSO the "Common Options" group IF it's included in `commonOptions`
group: 'Custom Grouped Options'
}
});Options configured with an explicit
groupproperty will never be automatically included in the "Optional Options" group.
This feature can be disabled entirely by passing
{ disableAutomaticGrouping: true } to withBuilderExtensions as its second
parameter:
const [builder, withHandlerExtensions] = withBuilderExtensions(
{
// ...
},
{ disableAutomaticGrouping: true }
);Automatic Sorting of Options
To support this functionality, options must be described declaratively. Defining options imperatively will break this feature.
BFE supports automatic alpha-sorting of a command's options in help text for improved UX, similar to how Black Flag sorts commands themselves in help text.
This feature is disabled by default, but can be enabled by passing
{ enableAutomaticSorting: true } to withBuilderExtensions as its second
parameter:
const [builder, withHandlerExtensions] = withBuilderExtensions(
{
// ...
},
{ enableAutomaticSorting: true }
);withUsageExtensions
⪢ API reference:
withUsageExtensions
This thin wrapper function is used for more consistent and opinionated usage string generation.
// file: xunnctl/commands/firewall/ban.js
return {
// ...
description: 'Add an IP from the global hostile IP list',
usage: withUsageExtensions(
"$1.\n\nAdditional description text that only appears in this command's help text."
)
};$ x f b --help
Usage: xunnctl firewall ban
Add an IP from the global hostile IP list.
Additional description text that only appears in this command's help text.
Required Options:
--ip An ipv4, ipv6, or supported CIDR [array]
Optional Options:
--comment Include custom text with the ban comment where applicable [string]
Common Options:
--help Show help text [boolean]
--hush Set output to be somewhat less verbose [boolean] [default: false]
--quiet Set output to be dramatically less verbose (implies --hush) [boolean] [default: false]
--silent No output will be generated (implies --quiet) [boolean] [default: false]
--config-path Use a custom configuration file
[string] [default: "/home/freelance/.config/xunnctl-nodejs/state.json"]getInvocableExtendedHandler
⪢ API reference:
getInvocableExtendedHandler
Unlike Black Flag, BFE puts strict constraints on the order in which command
exports must be invoked and evaluated. Specifically: an extended command's
builder export must be invoked twice, with the correct parameters each
time, before that extended command's handler can be invoked.
This can make it especially cumbersome to import an extended command from a file
and then invoke its handler, which is dead simple for normal Black Flag
commands, and can introduce transitive tight-couplings between commands, which
makes bugs more likely and harder to spot.
getInvocableExtendedHandler solves this by returning a version of the extended
command's handler function that is ready to invoke immediately. Said
handler expects a single argv parameter which is "safely" cloned,
merged with several defaults (see API reference), and then passed-through
to your command's handler as-is.
One of those defaults is the value of the context parameter that was supplied
to getInvocableExtendedHandler. Similar to argv, context will be "safely"
cloned.
A "safe" clone is a StructuredClone-like operation that passes through as-is any values that cannot be cloned rather than throwing an error. Since BFE is leveraging
safeDeepCloneunder the hood, all clone operations can be tweaked by configuringcontext.state.extensions.transferappropriately (wherecontextis the ExecutionContext instance passed togetInvocableExtendedHandler).Setting
context.state.extensions.transferis useful when, for instance, you have an object stored incontextthat should not be deep cloned but passed through as-is instead.Command
handlerexports invoked viagetInvocableExtendedHandlerwill receive anargvcontaining the$artificiallyInvokedsymbol. This allows handlers to determine programmatically when the command isn't actually being invoked by Black Flag, which can be useful.However, to get intellisense/TypeScript support for the existence of
$artificiallyInvokedinargv, you must useBfeStrictArguments.Command
handlerexports invoked viagetInvocableExtendedHandlerwill never check the givenargvfor correctness or update any of its keys/values (aside from setting$artificiallyInvoked,$executionContext, and defaults for$0and_if they are omitted).By invoking a command's handler function outside of Black Flag, you're essentially treating it like a normal function. And all handler functions require a "reified argv" parameter, i.e. the object given to a command handler after all BF/BFE checks have passed and all updates to argv have been applied.
If you want to invoke a full Black Flag command programmatically, use
runProgram. If instead you want to call an individual command's (relatively) lightweight handler function directly, usegetInvocableExtendedHandler.
getInvocableExtendedHandler can be used with both BFE and normal Black Flag
command exports.
For example, in JavaScript:
// file: my-cli/commands/command-B.js
export default function command(context) {
const [builder, withHandlerExtensions] = withBuilderExtensions({
// ...
});
return {
builder,
handler: withHandlerExtensions(async function (argv) {
const handler = await getInvocableExtendedHandler(
// This accepts a function, an object, a default export, a Promise, etc
import('./command-A.js'),
context
);
await handler({ somethingElse: true });
// ...
})
};
}Or in TypeScript:
// file: my-cli/commands/command-B.ts
import { type CustomExecutionContext } from '../configure';
import {
default as commandA,
type CustomCliArguments as CommandACliArguments
} from './command-A';
export type CustomCliArguments = {
/* ... */
};
export default function command(context: CustomExecutionContext) {
const [builder, withHandlerExtensions] =
withBuilderExtensions<CustomCliArguments>({
// ...
});
return {
builder,
handler: withHandlerExtensions<CustomCliArguments>(async function (argv) {
const handler = await getInvocableExtendedHandler<
CommandACliArguments,
typeof context
>(commandA, context);
await handler({ somethingElse: true });
// ...
})
};
}Examples
Appendix 🏴
Further documentation can be found under docs/.
Differences between Black Flag Extensions and Yargs
When using BFE, several options function differently, such as implies.
Other options have their effect deferred, like default. coerce
will always receive an array when the same option also has array: true.
See the configuration keys section for a list of changes and their
justifications.
Additionally, command options must be configured by returning an opt
object from your command's builder rather than imperatively invoking
the Yargs API.
For example:
export function builder(blackFlag) {
- // DO NOT use Yargs's imperative API to define options! This *BREAKS* BFE!
- blackFlag.option('f', {
- alias: 'file',
- demandOption: true,
- default: '/etc/passwd',
- describe: 'x marks the spot',
- type: 'string',
- group: 'custom'
- });
-
- // DO NOT use Yargs's imperative API to define options! This *BREAKS* BFE!
- blackFlag
- .alias('f', 'file')
- .demandOption('f')
- .default('f', '/etc/passwd')
- .describe('f', 'x marks the spot')
- .string('f')
- .group('custom');
-
- // DO NOT use Yargs's imperative API to define options! This *BREAKS* BFE!
- blackFlag.options({
- f: {
- alias: 'file',
- demandOption: true,
- default: '/etc/passwd',
- describe: 'x marks the spot',
- type: 'string',
- group: 'custom'
- }
- });
-
+ // INSTEAD, use Yargs / Black Flag's declarative API to define options 🙂
+ return {
+ f: {
+ alias: 'file',
+ demandThisOption: true,
+ default: '/etc/passwd',
+ describe: 'x marks the spot',
+ string: true,
+ group: 'custom'
+ }
+ };
}The Yargs API can and should still be invoked for purposes other than defining options on a command, e.g.
blackFlag.strict(false).
To this end, the following Yargs API functions are soft-disabled via intellisense:
optionoptions
However, no attempt is made by BFE to restrict your use of the Yargs API at
runtime. Therefore, using Yargs's API to work around these artificial
limitations, e.g. in your command's builder function or via the
configureExecutionPrologue hook, will result in undefined behavior.
Black Flag versus Black Flag Extensions
The goal of Black Flag (@black-flag/core) is to be as close to a drop-in
replacement as possible for vanilla Yargs, specifically for users of
yargs::commandDir(). This means Black Flag must go out of its way to
maintain 1:1 parity with the vanilla Yargs API (with a few minor
exceptions).
As a consequence, Yargs's imperative nature tends to leak through Black Flag's
abstraction at certain points, such as with the blackFlag parameter of the
builder export. This is a good thing! Since we want access to all of
Yargs's killer features without Black Flag getting in the way.
However, this comes with costs. For one, the Yargs's API has suffered from a bit of feature creep over the years. A result of this is a rigid API with an abundance of footguns and an inability to address them without introducing massively breaking changes.
BFE takes the "YOLO" approach by exporting several functions that build on top of Black Flag's feature set without worrying too much about maintaining 1:1 parity with the vanilla Yargs's API. This way, one can opt-in to a more opinionated but (in my opinion) cleaner, more consistent, and more intuitive developer experience.
Published Package Details
This is a CJS2 package with statically-analyzable exports
built by Babel for use in Node.js versions that are not end-of-life. For
TypeScript users, this package supports both "Node10" and "Node16" module
resolution strategies.
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
declaration file for TypeScript and IDEs, a
exports[ENTRY].module condition pointing to
(usually ESM) source for Webpack/Rollup, a exports[ENTRY].node and/or
exports[ENTRY].default condition pointing to (usually CJS2) source for Node.js
require/import and for browsers and other environments, and other
conditions not enumerated here. Check the
package.json file to see which export conditions are
supported.
Note that, regardless of the { "type": "..." } specified in
package.json, any JavaScript files written in ESM
syntax (including distributables) will always have the .mjs extension. Note
also that package.json may include the
sideEffects key, which is almost always 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 buy me a beer, I'd appreciate it. Thank you!
See CONTRIBUTING.md and SUPPORT.md for more information.
Contributors
See the table of contributors.
58: