Skip to content

Commit 574a61c

Browse files
authored
feat(command): add enabled option for conditional option registration (#906)
1 parent e7e372e commit 574a61c

5 files changed

Lines changed: 196 additions & 43 deletions

File tree

command/_argument_types.ts

Lines changed: 43 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -224,42 +224,49 @@ export type TypedOption<
224224
TTypes,
225225
TRequired extends boolean | undefined = undefined,
226226
TDefault = undefined,
227-
> = number extends TTypes ? any
228-
: TFlags extends `${string}--${infer Name}=${infer TRestFlags}`
229-
? ValuesOption<
230-
Name,
231-
TRestFlags,
232-
TTypes,
233-
IsRequired<TRequired, TDefault>,
234-
TDefault
235-
>
236-
: TFlags extends `${string}--${infer Name} ${infer TRestFlags}`
237-
? ValuesOption<
238-
Name,
239-
TRestFlags,
240-
TTypes,
241-
IsRequired<TRequired, TDefault>,
242-
TDefault
243-
>
244-
: TFlags extends `${string}--${infer Name}`
245-
? BooleanOption<Name, TOptions, IsRequired<TRequired, TDefault>, TDefault>
246-
: TFlags extends `-${infer Name}=${infer TRestFlags}` ? ValuesOption<
247-
Name,
248-
TRestFlags,
249-
TTypes,
250-
IsRequired<TRequired, TDefault>,
251-
TDefault
252-
>
253-
: TFlags extends `-${infer Name} ${infer TRestFlags}` ? ValuesOption<
254-
Name,
255-
TRestFlags,
256-
TTypes,
257-
IsRequired<TRequired, TDefault>,
258-
TDefault
259-
>
260-
: TFlags extends `-${infer Name}`
261-
? BooleanOption<Name, TOptions, IsRequired<TRequired, TDefault>, TDefault>
262-
: Record<string, unknown>;
227+
TConflicts = undefined,
228+
TEnabled extends boolean | undefined = undefined,
229+
> = [TEnabled] extends [false] ? Record<never, never>
230+
: (boolean extends TEnabled ? false
231+
: undefined extends TConflicts ? TRequired
232+
: false) extends infer TRequired extends boolean | undefined
233+
? number extends TTypes ? any
234+
: TFlags extends `${string}--${infer Name}=${infer TRestFlags}`
235+
? ValuesOption<
236+
Name,
237+
TRestFlags,
238+
TTypes,
239+
IsRequired<TRequired, TDefault>,
240+
TDefault
241+
>
242+
: TFlags extends `${string}--${infer Name} ${infer TRestFlags}`
243+
? ValuesOption<
244+
Name,
245+
TRestFlags,
246+
TTypes,
247+
IsRequired<TRequired, TDefault>,
248+
TDefault
249+
>
250+
: TFlags extends `${string}--${infer Name}`
251+
? BooleanOption<Name, TOptions, IsRequired<TRequired, TDefault>, TDefault>
252+
: TFlags extends `-${infer Name}=${infer TRestFlags}` ? ValuesOption<
253+
Name,
254+
TRestFlags,
255+
TTypes,
256+
IsRequired<TRequired, TDefault>,
257+
TDefault
258+
>
259+
: TFlags extends `-${infer Name} ${infer TRestFlags}` ? ValuesOption<
260+
Name,
261+
TRestFlags,
262+
TTypes,
263+
IsRequired<TRequired, TDefault>,
264+
TDefault
265+
>
266+
: TFlags extends `-${infer Name}`
267+
? BooleanOption<Name, TOptions, IsRequired<TRequired, TDefault>, TDefault>
268+
: Record<string, unknown>
269+
: never;
263270

264271
export type TypedEnv<
265272
TNameAndValue extends string,

command/command.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1731,8 +1731,10 @@ export class Command<
17311731
TFlags,
17321732
TCommandOptions,
17331733
Merge<TParentCommandTypes, Merge<TCommandGlobalTypes, TCommandTypes>>,
1734-
undefined extends TConflicts ? TRequired : false,
1735-
TDefaultValue
1734+
TRequired,
1735+
TDefaultValue,
1736+
TConflicts,
1737+
TEnabled
17361738
>,
17371739
TMappedGlobalOptions extends MapValue<
17381740
TGlobalOptions,
@@ -1742,6 +1744,7 @@ export class Command<
17421744
TRequired extends OptionOptions["required"] = undefined,
17431745
TCollect extends OptionOptions["collect"] = undefined,
17441746
TConflicts extends OptionOptions["conflicts"] = undefined,
1747+
TEnabled extends OptionOptions["enabled"] = undefined,
17451748
const TDefaultValue = undefined,
17461749
TMappedValue = undefined,
17471750
>(
@@ -1767,6 +1770,7 @@ export class Command<
17671770
defaultText?: DefaultText<TDefaultValue>;
17681771
required?: TRequired;
17691772
collect?: TCollect;
1773+
enabled?: TEnabled;
17701774
value?: OptionValueHandler<
17711775
MapTypes<ValueOf<TGlobalOptions>>,
17721776
TMappedValue
@@ -1810,8 +1814,10 @@ export class Command<
18101814
TFlags,
18111815
TCommandOptions,
18121816
Merge<TParentCommandTypes, Merge<TCommandGlobalTypes, TCommandTypes>>,
1813-
undefined extends TConflicts ? TRequired : false,
1814-
TDefaultValue
1817+
TRequired,
1818+
TDefaultValue,
1819+
TConflicts,
1820+
TEnabled
18151821
>,
18161822
TMappedGlobalOptions extends MapValue<
18171823
TGlobalOptions,
@@ -1821,6 +1827,7 @@ export class Command<
18211827
TRequired extends OptionOptions["required"] = undefined,
18221828
TCollect extends OptionOptions["collect"] = undefined,
18231829
TConflicts extends OptionOptions["conflicts"] = undefined,
1830+
TEnabled extends OptionOptions["enabled"] = undefined,
18241831
const TDefaultValue = undefined,
18251832
TMappedValue = undefined,
18261833
>(
@@ -1847,6 +1854,7 @@ export class Command<
18471854
defaultText?: DefaultText<TDefaultValue>;
18481855
required?: TRequired;
18491856
collect?: TCollect;
1857+
enabled?: TEnabled;
18501858
value?: OptionValueHandler<
18511859
MapTypes<ValueOf<TGlobalOptions>>,
18521860
TMappedValue
@@ -1877,13 +1885,16 @@ export class Command<
18771885
TFlags,
18781886
TCommandOptions,
18791887
Merge<TParentCommandTypes, Merge<TCommandGlobalTypes, TCommandTypes>>,
1880-
undefined extends TConflicts ? TRequired : false,
1881-
TDefaultValue
1888+
TRequired,
1889+
TDefaultValue,
1890+
TConflicts,
1891+
TEnabled
18821892
>,
18831893
TMappedOptions extends MapValue<TOptions, TMappedValue, TCollect>,
18841894
TRequired extends OptionOptions["required"] = undefined,
18851895
TCollect extends OptionOptions["collect"] = undefined,
18861896
TConflicts extends OptionOptions["conflicts"] = undefined,
1897+
TEnabled extends OptionOptions["enabled"] = undefined,
18871898
const TDefaultValue = undefined,
18881899
TMappedValue = undefined,
18891900
>(
@@ -1910,6 +1921,7 @@ export class Command<
19101921
required?: TRequired;
19111922
collect?: TCollect;
19121923
conflicts?: TConflicts;
1924+
enabled?: TEnabled;
19131925
value?: OptionValueHandler<MapTypes<ValueOf<TOptions>>, TMappedValue>;
19141926
}
19151927
| OptionValueHandler<MapTypes<ValueOf<TOptions>>, TMappedValue>,
@@ -1933,14 +1945,20 @@ export class Command<
19331945
opts = { value: opts };
19341946
}
19351947

1948+
if (opts?.enabled === false) {
1949+
return this;
1950+
}
1951+
19361952
const result = splitArguments(flags);
19371953

19381954
const args: Argument[] = result.typeDefinition
19391955
? parseArgumentsDefinition(result.typeDefinition)
19401956
: [];
19411957

1958+
const { enabled: _enabled, ...restOpts } = opts ?? {};
1959+
19421960
const option: Option = {
1943-
...opts,
1961+
...restOpts,
19441962
name: "",
19451963
description: desc,
19461964
args,

command/test/command/generic_types_test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,42 @@ import { assertType, type IsAny, type IsExact } from "@std/testing/types";
543543
},
544544
});
545545

546+
test({
547+
name: "[command] - generic types - enabled option",
548+
fn() {
549+
const maybeEnabled = Math.random() > 0.5;
550+
551+
new Command()
552+
.option("--kept <val:string>", "", { enabled: true })
553+
.option("--dropped <val:string>", "", { enabled: false })
554+
.option("--maybe <val:string>", "", { enabled: maybeEnabled })
555+
.action((options, ...args) => {
556+
assertType<IsExact<typeof args, []>>(true);
557+
assertType<
558+
IsExact<typeof options, {
559+
kept?: string;
560+
maybe?: string;
561+
}>
562+
>(true);
563+
});
564+
},
565+
});
566+
567+
test({
568+
name:
569+
"[command] - generic types - single enabled option should widen to a single optional type, not distribute into a `{} | { ... }` union",
570+
fn() {
571+
const maybeEnabled = Math.random() > 0.5;
572+
573+
new Command()
574+
.option("--maybe <val:string>", "", { enabled: maybeEnabled })
575+
.action((options, ...args) => {
576+
assertType<IsExact<typeof args, []>>(true);
577+
assertType<IsExact<typeof options, { maybe?: string }>>(true);
578+
});
579+
},
580+
});
581+
546582
test({
547583
name: "[command] - generic types - just a variadic arg",
548584
fn() {
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { test } from "@cliffy/internal/testing/test";
2+
import { assertEquals, assertRejects, assertStringIncludes } from "@std/assert";
3+
import { assertNotMatch } from "@std/assert/not-match";
4+
import { Command } from "../../command.ts";
5+
import { ValidationError } from "../../_errors.ts";
6+
7+
test("[command] should register an option with enabled: true", async () => {
8+
const { options, args } = await new Command()
9+
.throwErrors()
10+
.option("--flag <value:string>", "description ...", { enabled: true })
11+
.parse(["--flag", "value"]);
12+
13+
assertEquals(options, { flag: "value" });
14+
assertEquals(args, []);
15+
});
16+
17+
test("[command] should not register an option with enabled: false", async () => {
18+
const cmd = new Command()
19+
.throwErrors()
20+
.option("--flag <value:string>", "description ...", { enabled: false })
21+
.action(() => {});
22+
23+
await assertRejects(
24+
() => cmd.parse(["--flag", "value"]),
25+
ValidationError,
26+
`Unknown option "--flag".`,
27+
);
28+
});
29+
30+
test("[command] should register an option with a runtime-true enabled flag", async () => {
31+
const enabled: boolean = true;
32+
const { options } = await new Command()
33+
.throwErrors()
34+
.option("--flag <value:string>", "description ...", { enabled })
35+
.parse(["--flag", "value"]);
36+
37+
assertEquals(options, { flag: "value" });
38+
});
39+
40+
test("[command] should not register an option with a runtime-false enabled flag", async () => {
41+
const enabled: boolean = false;
42+
const cmd = new Command()
43+
.throwErrors()
44+
.option("--flag <value:string>", "description ...", { enabled })
45+
.action(() => {});
46+
47+
await assertRejects(
48+
() => cmd.parse(["--flag", "value"]),
49+
ValidationError,
50+
`Unknown option "--flag".`,
51+
);
52+
});
53+
54+
test("[command] should omit a disabled option from the help output", () => {
55+
const output = new Command()
56+
.throwErrors()
57+
.option("--enabled <value:string>", "I am registered", { enabled: true })
58+
.option("--disabled <value:string>", "Nobody registers me!", {
59+
enabled: false,
60+
})
61+
.getHelp();
62+
63+
assertStringIncludes(output, "--enabled");
64+
assertNotMatch(output, /--disabled/);
65+
});
66+
67+
test("[command] should not register a disabled global option on subcommands", async () => {
68+
const cmd = new Command()
69+
.throwErrors()
70+
.globalOption("--flag <value:string>", "description ...", {
71+
enabled: false,
72+
})
73+
.command("sub")
74+
.action(() => {})
75+
.reset();
76+
77+
await assertRejects(
78+
() => cmd.parse(["sub", "--flag", "value"]),
79+
ValidationError,
80+
`Unknown option "--flag".`,
81+
);
82+
});

command/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,16 @@ export interface GlobalOptionOptions<
274274
> extends Omit<FlagOptions, ExcludedCommandOptions> {
275275
override?: boolean;
276276
hidden?: boolean;
277+
/**
278+
* Conditionally register the option. Defaults to `true`.
279+
*
280+
* - `false` skips registration, so the flag is unknown at parse time and its
281+
* key is dropped from the parsed options type.
282+
* - A non-literal `boolean` (a runtime variable) registers the option only
283+
* when truthy. The parsed value widens to `T | undefined`, since the option
284+
* may not have been registered.
285+
*/
286+
enabled?: boolean;
277287
action?: ActionHandler<
278288
TOptions,
279289
TArguments,

0 commit comments

Comments
 (0)