@black-flag/extensions
Version:
A collection of set-theoretic declarative-first APIs for yargs and Black Flag
1,461 lines (1,161 loc) • 67 kB
Markdown
<!-- symbiote-template-region-start 1 -->
<p align="center" width="100%">
<img width="300" src="https://raw.githubusercontent.com/Xunnamius/black-flag/refs/heads/main/packages/extensions/logo.png">
</p>
<p align="center" width="100%">
<!-- symbiote-template-region-end -->
A collection of set-theoretic declarative-first APIs for Yargs and <a href="https://github.com/Xunnamius/black-flag">Black Flag</a>
<!-- symbiote-template-region-start 2 -->
</p>
<hr />
<div align="center">
[![Black Lives Matter!][x-badge-blm-image]][x-badge-blm-link]
[![Last commit timestamp][x-badge-lastcommit-image]][x-badge-repo-link]
[![Codecov][x-badge-codecov-image]][x-badge-codecov-link]
[![Source license][x-badge-license-image]][x-badge-license-link]
[![Uses Semantic Release!][x-badge-semanticrelease-image]][x-badge-semanticrelease-link]
[![NPM version][x-badge-npm-image]][x-badge-npm-link]
[![Monthly Downloads][x-badge-downloads-image]][x-badge-downloads-link]
</div>
<br />
# @black-flag/extensions 🏴
<!-- symbiote-template-region-end -->
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][1]. 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][2] [propositional logic][3] 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.
<!-- symbiote-template-region-start 3 -->
---
<!-- remark-ignore-start -->
<!-- symbiote-template-region-end -->
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
- [Install](#install)
- [Usage](#usage)
- [`withBuilderExtensions`](#withbuilderextensions)
- [`withUsageExtensions`](#withusageextensions)
- [`getInvocableExtendedHandler`](#getinvocableextendedhandler)
- [Examples](#examples)
- [Appendix 🏴](#appendix-)
- [Differences between Black Flag Extensions and Yargs](#differences-between-black-flag-extensions-and-yargs)
- [Black Flag versus Black Flag Extensions](#black-flag-versus-black-flag-extensions)
- [Published Package Details](#published-package-details)
- [License](#license)
- [Contributing and Support](#contributing-and-support)
- [Contributors](#contributors)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
<!-- symbiote-template-region-start 4 -->
<!-- remark-ignore-end -->
<br />
## Install
<!-- symbiote-template-region-end -->
To install:
```shell
npm install @black-flag/extensions
```
## Usage
> [!NOTE]
>
> See also: [differences between BFE and Yargs][4].
> [!WARNING]
>
> Most of the examples in this section (not including syntax-highlighted code
> blocks) are using hyphen characters followed by a [word joiner character][5]
> to prevent breaking examples awkwardly across lines. Be cautious copying and
> pasting.
### `withBuilderExtensions`
> ⪢ API reference: [`withBuilderExtensions`][6]
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`).
<!-- example-region wbe-js -->
```javascript
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][7] configuration settings: `camel-case-expansion`,
`strip-aliased`, `strip-dashed`.
Also note how `withBuilderExtensions` returns a two-element array of the form:
`[builder, withHandlerExtensions]`. [`builder`][8] should be exported as your
command's [`builder`][8] 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`][9]
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][10].
#### 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][11] 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 [`default`][12] key ([unless it's a custom
check][13]). This means you can use keys like [`requires`][14] and
[`conflicts`][15] alongside [`default`][12] without causing unresolvable CLI
errors. This avoids a rather unintuitive [Yargs footgun][16].
- Will take into account the following [yargs-parser settings][7] configuration
settings: `camel-case-expansion`, `strip-aliased`, `strip-dashed`. Note that
`dot-notation` is _not_ currently recognized or considered by BFE, but may be
in the future.
**Logical Keys**
> [!NOTE]
>
> In the below definitions, `P`, `Q`, and `R` are arguments (or argument-value
> pairs) configured via a hypothetical call to
> [`blackFlag.options({ P: { [key]: [Q, R] }})`][17]. The [truth values][3] of
> `P`, `Q`, and `R` as described in the "Definition" column represent the
> existence of each respective argument (and its value) in the `argv` parse
> result. `gwav` is a predicate standing for "given with any value," meaning the
> argument was given on the command line.
| Key | Definition |
| :-------------------------- | :-------------------------------------------- |
| [`requires`][14] | `P ⟹ (Q ∧ R)` or `¬P ∨ (Q ∧ R)` |
| [`conflicts`][15] | `P ⟹ (¬Q ∧ ¬R)` or `¬P ∨ (¬Q ∧ ¬R)` |
| [`implies`][18] | `P ⟹ (Q ∧ R ∧ (gwav(Q) ⟹ Q) ∧ (gwav(R) ⟹ R))` |
| [`demandThisOptionIf`][19] | `(Q ∨ R) ⟹ P` or `P ∨ (¬Q ∧ ¬R)` |
| [`demandThisOption`][20] | `P` |
| [`demandThisOptionOr`][21] | `P ∨ Q ∨ R` |
| [`demandThisOptionXor`][22] | `P ⊕ Q ⊕ R` |
**Relational Keys**
| Key |
| :-------------------------- |
| [`check`][13] |
| [`subOptionOf`][23] |
| [`looseImplications`][24] |
| [`vacuousImplications`][25] |
---
##### `requires`
> ⪢ API reference: [`requires`][26]
> [!IMPORTANT]
>
> `requires` is a superset of and replacement for vanilla Yargs's
> [`implies`][27]. BFE also has [its own implication implementation][18].
`requires` enables checks to ensure the specified arguments, or argument-value
pairs, are given conditioned on the existence of another argument. For example:
<!-- example-region requires-1-jsonc -->
```jsonc
{
"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:
<!-- example-region requires-2-jsonc -->
```jsonc
{
// ▼ 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`][18] 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`][28]
> [!IMPORTANT]
>
> `conflicts` is a superset of vanilla Yargs's [`conflicts`][29].
`conflicts` enables checks to ensure the specified arguments, or argument-value
pairs, are _never_ given conditioned on the existence of another argument. For
example:
<!-- example-region conflicts-1-jsonc -->
```jsonc
{
"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:
<!-- example-region conflicts-2-jsonc -->
```jsonc
{
// ▼ 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`][18] 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: [`implies`][30]
> [!IMPORTANT]
>
> BFE's `implies` replaces vanilla Yargs's `implies` in a breaking way. The two
> implementations are nothing alike. If you're looking for vanilla Yargs's
> functionality, see [`requires`][14].
`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`][24] 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`][14]/[`conflicts`][15]). For this reason, `implies` only accepts one
or more argument-value pairs and not raw strings. For example:
<!-- example-region implies-1-jsonc -->
```jsonc
{
"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 [`default`][12]s
configured, said defaults will be overridden by the values of `implies`. For
example:
<!-- example-region implies-2-jsonc -->
```jsonc
{
"x": { "implies": { "y": true } },
"y": { "default": false } // ◄ y will still default to true if x is given
}
```
Also note the [special behavior][25] 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`][23].
###### 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:
```diff
{
- 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`][22]) **or trigger any relational behavior** (such as
with [`subOptionOf`][23]). The implied argument-value pair will simply be merged
into `argv` as if you had done it manually in your command's [`handler`][9]. If
this is a problem, prefer the explicit direct relationships described by other
[configuration keys][31] instead of relying on the implicit transitive
relationships described by `implies`.
Despite this constraint, any per-option [`check`][13]s you've configured, which
are run last (at the very end of `withHandlerExtensions`), _will_ see the
implied argument-value pairs. Therefore, use [`check`][13] 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][7] `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`][14]/[`conflicts`][15].
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`][23] 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`][14]/[`conflicts`][15]).
###### `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:
<!-- example-region implies-3-jsonc -->
```jsonc
{
"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`][32]-type options are
_never [vacuously satisfied][33]_; a strictly `false` condition does not "imply"
anything about its [consequent][34].
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:
<!-- example-region implies-4-jsonc -->
```jsonc
{
"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-tool`
- `build-tool -patch`
- `build-tool -patch=true`
- `build-tool -only-patch=false`
- `build-tool -no-only-patch`
_Is building and not patching:_
<!-- TODO: remove these "disable" toggles after fixing list-item-style bug -->
<!-- lint disable list-item-style -->
- `build-tool -patch=false`
- `build-tool -no-patch`
- _`build-tool -no-patch -no-only-patch`_ (this is the interesting one)
<!-- lint enable list-item-style -->
_Is patching and not building:_
- `build-tool -only-patch`
- `build-tool -only-patch=true`
- `build-tool -patch -only-patch`
On the other hand, the following rightly cause BFE to throw:
- `build-tool -patch=false -only-patch`
- `build-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`][14]/[`conflicts`][15]/[`subOptionOf`][23] over `implies`.
---
##### `demandThisOptionIf`
> ⪢ API reference: [`demandThisOptionIf`][35]
> [!IMPORTANT]
>
> `demandThisOptionIf` is a superset of vanilla Yargs's [`demandOption`][32].
`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:
<!-- example-region demandThisOptionIf-1-jsonc -->
```jsonc
{
"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:
<!-- example-region demandThisOptionIf-2-jsonc -->
```jsonc
{
// ▼ 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`][23].
---
##### `demandThisOption`
> ⪢ API reference: [`demandThisOption`][36]
> [!IMPORTANT]
>
> `demandThisOption` is an alias of vanilla Yargs's [`demandOption`][32].
> `demandOption` is disallowed by intellisense.
`demandThisOption` enables checks to ensure an argument is always given. This is
equivalent to `demandOption` from vanilla Yargs. For example:
<!-- example-region demandThisOption-1-jsonc -->
```jsonc
{
"x": { "demandThisOption": true }, // ◄ Disallows ∅, y
"y": { "demandThisOption": false }
}
```
This configuration will trigger a check to ensure that `-x` is given.
> [!NOTE]
>
> As an alias of vanilla Yargs's [`demandOption`][32], 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`][37]
> [!IMPORTANT]
>
> `demandThisOptionOr` is a superset of vanilla Yargs's [`demandOption`][32].
`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:
<!-- example-region demandThisOptionOr-1-jsonc -->
```jsonc
{
"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:
<!-- example-region demandThisOptionOr-2-jsonc -->
```jsonc
{
// ▼ 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`][38]
> [!IMPORTANT]
>
> `demandThisOptionXor` is a superset of vanilla Yargs's [`demandOption`][32] +
> [`conflicts`][29].
`demandThisOptionXor` enables non-optional exclusive disjunction checks per
exclusivity group. Put another way, `demandThisOptionXor` enforces mutual
exclusivity within groups of required options. For example:
<!-- example-region demandThisOptionXor-1-jsonc -->
```jsonc
{
"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:
<!-- example-region demandThisOptionXor-2-jsonc -->
```jsonc
{
// ▼ 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`][39]
`check` is the declarative option-specific version of vanilla Yargs's
[`yargs::check()`][40].
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][11], well after all other BFE checks have passed and
all updates to `argv` have been applied (including from [`subOptionOf`][23] and
[BFE's `implies`][18]). This means `check` always sees the _final_ version of
`argv`, which is the same version that the command's [`handler`][9] is passed.
> [!IMPORTANT]
>
> `check` functions are skipped if their corresponding argument does not exist
> in `argv`.
When a check fails, execution of its command's [`handler`][9] function will
cease and [`configureErrorHandlingEpilogue`][41] will be invoked (unless you
threw/returned a [`GracefulEarlyExitError`][42]). For example:
<!-- example-region check-1-js -->
```javascript
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:
> [!WARNING]
>
> Providing an array with one or more _async_ check functions will result in
> them all being awaited concurrently.
<!-- example-region check-2-js -->
```javascript
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()`][40] for more information.
---
##### `subOptionOf`
> ⪢ API reference: [`subOptionOf`][43]
One of Black Flag's killer features is [native support for dynamic options][44].
However, taking advantage of this feature in a command's [`builder`][8] export
requires a strictly imperative approach.
Take, for example, [the `init` command from @black-flag/demo][45]:
<!-- example-region subOptionOf-1-js -->
```javascript
// 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:
```text
myctl init --lang node --version=23.3
> initializing new node@23.3 project...
```
```text
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"
```
```text
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"
```
```text
myctl init --help
Usage: myctl init
Options:
--help Show help text [boolean]
--lang [choices: "node", "python"] [default: "python"]
--version [string] [default: "3.13"]
```
```text
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.**
> [!NOTE]
>
> `subOptionOf` updates are run and applied during Black Flag's [second parsing
> pass][11].
For example:
<!-- example-region subOptionOf-2-js -->
```javascript
/**
* @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': []
}
}
});
```
> [!IMPORTANT]
>
> You cannot nest `subOptionOf` keys within each other nor return an object
> containing `subOptionOf` from an `update` that 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:
<!-- example-region subOptionOf-3-js -->
```javascript
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][46] 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`][12] key until near the end of BFE's execution.
> [!IMPORTANT]
>
> This means the optional `customBuilder` function passed to
> `withBuilderExtensions` will _not_ see any defaulted values. However, your
> command handlers will.
> [!WARNING]
>
> An explicitly `undefined` default, 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
> recognize `undefined` defaults.
Defaults are set _before_ any [`check`][13] functions are run, _before_ any
[implications][18] are set, and _before_ the relevant command [`handler`][9] is
invoked, but _after_ all other BFE checks have succeeded. This enables the use
of keys like [`requires`][14] and [`conflicts`][15] alongside [`default`][12]
without causing [impossible configurations][47] that throw unresolvable CLI
errors.
This workaround avoids a (in my opinion) rather unintuitive [Yargs footgun][16],
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:
```jsonc
{
"x": { "requires": "y" },
"y": { "conflicts": "x" }
}
```
```jsonc
{
"x": { "requires": "y", "demandThisOptionXor": "y" },
"y": {}
}
```
Similarly, silly configurations like the following, while typically resolvable,
are strange and may not work as expected:
```jsonc
{
"x": { "requires": "x", "demandThisOptionXor": "x" }
}
```
```jsonc
{
"x": { "implies": { "x": 5 } }
}
```
#### Automatic Grouping of Related Options
> [!CAUTION]
>
> To support this functionality, options must be described declaratively.
> [Defining options imperatively][4] will break this feature.
BFE supports automatic [grouping][48] of related options for improved UX, which
is enabled by default. These new groups are:
- **"Required Options"**: options configured with [`demandThisOption`][20].
- **"Required Options (at least one)"**: options configured with
[`demandThisOptionOr`][21].
- **"Required Options (mutually exclusive)"**: options configured with
[`demandThisOptionXor`][22].
- **"Common Options"**: options provided via `{ commonOptions: [...] }` to
`withBuilderExtensions` as its second parameter:
`withBuilderExtensions({/*...*/}, { commonOptions });`
- **"Optional Options"**: remaining options that do not fall into any of the
above categories.
An example from [xunnctl][49]:
```text
$ 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"]
```
```text
$ 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`][48] property in an option's configuration,
the option will be included in said group _in addition to_ the result of
automatic grouping, e.g.:
```typescript
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'
}
});
```
> [!NOTE]
>
> Options configured with an explicit [`group`][48] property 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:
```typescript
const [builder, withHandlerExtensions] = withBuilderExtensions(
{
// ...
},
{ disableAutomaticGrouping: true }
);
```
#### Automatic Sorting of Options
> [!CAUTION]
>
> To support this functionality, options must be described declaratively.
> [Defining options imperatively][4] 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:
```typescript
const [builder, withHandlerExtensions] = withBuilderExtensions(
{
// ...
},
{ enableAutomaticSorting: true }
);
```
### `withUsageExtensions`
> ⪢ API reference: [`withUsageExtensions`][50]
This thin wrapper function is used for more consistent and opinionated usage
string generation.
<!-- example-region wue-1-js -->
```javascript
// 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."
)
};
```
```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`][51]
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`][8] export must be invoked twice, with the correct parameters each
time, before that extended command's [`handler`][9] can be invoked.
This can make it especially cumbersome to import an extended command from a file
and then invoke its [`handler`][9], 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`][9] function that is ready to invoke immediately. Said
[`handler`][9] expects a single `argv` parameter which is "safely" cloned,
merged with several defaults (see [API reference][51]), 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.
> [!TIP]
>
> A "safe" clone is a [StructuredClone-like operation][52] that passes through
> as-is any values that cannot be cloned rather than throwing an error. Since
> BFE is leveraging [`safeDeepClone`][52] under the hood, all clone operations
> can be tweaked by configuring [`context.state.extensions.transfer`][53]
> appropriately (where `context` is the [ExecutionContext][54] instance passed
> to `getInvocableExtendedHandler`).
>
> Setting `context.state.extensions.transfer` is useful when, for instance, you
> have an object stored in `context` that should not be deep cloned but passed
> through as-is instead.
> [!TIP]
>
> Command [`handler`][9] exports invoked via `getInvocableExtendedHandler` will
> receive an `argv` containing the [`$artificiallyInvoked`][55] symbol. This
> allows handlers to determine programmatically when the command isn't actually
> being invoked by Black Flag, which can b