cli-forge 1.2.3 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/bin/cli.d.ts +1 -1
  2. package/dist/bin/commands/generate-documentation.d.ts +2 -2
  3. package/dist/bin/commands/generate-documentation.js +36 -6
  4. package/dist/bin/commands/generate-documentation.js.map +1 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/lib/configuration-providers.d.ts +3 -2
  8. package/dist/lib/configuration-providers.js +38 -2
  9. package/dist/lib/configuration-providers.js.map +1 -1
  10. package/dist/lib/documentation.d.ts +6 -1
  11. package/dist/lib/documentation.js +4 -0
  12. package/dist/lib/documentation.js.map +1 -1
  13. package/dist/lib/format-help.js +9 -0
  14. package/dist/lib/format-help.js.map +1 -1
  15. package/dist/lib/internal-cli.d.ts +14 -2
  16. package/dist/lib/internal-cli.js +61 -3
  17. package/dist/lib/internal-cli.js.map +1 -1
  18. package/dist/lib/prompt-types.d.ts +44 -0
  19. package/dist/lib/prompt-types.js +3 -0
  20. package/dist/lib/prompt-types.js.map +1 -0
  21. package/dist/lib/public-api.d.ts +45 -12
  22. package/dist/lib/public-api.js.map +1 -1
  23. package/dist/lib/resolve-prompts.d.ts +13 -0
  24. package/dist/lib/resolve-prompts.js +121 -0
  25. package/dist/lib/resolve-prompts.js.map +1 -0
  26. package/dist/prompt-providers/clack.d.ts +29 -0
  27. package/dist/prompt-providers/clack.js +136 -0
  28. package/dist/prompt-providers/clack.js.map +1 -0
  29. package/package.json +11 -2
  30. package/src/bin/commands/generate-documentation.ts +70 -9
  31. package/src/index.ts +1 -0
  32. package/src/lib/composable-builder.ts +3 -3
  33. package/src/lib/configuration-providers.ts +53 -4
  34. package/src/lib/documentation.ts +11 -0
  35. package/src/lib/format-help.ts +10 -0
  36. package/src/lib/internal-cli.spec.ts +300 -0
  37. package/src/lib/internal-cli.ts +80 -7
  38. package/src/lib/prompt-types.ts +48 -0
  39. package/src/lib/public-api.ts +31 -19
  40. package/src/lib/resolve-prompts.spec.ts +311 -0
  41. package/src/lib/resolve-prompts.ts +156 -0
  42. package/src/prompt-providers/clack.spec.ts +376 -0
  43. package/src/prompt-providers/clack.ts +169 -0
  44. package/tsconfig.lib.json.tsbuildinfo +1 -1
  45. package/typedoc.json +10 -0
  46. package/.eslintrc.json +0 -36
@@ -1,4 +1,4 @@
1
- /* eslint-disable @typescript-eslint/ban-types */
1
+ /* eslint-disable @typescript-eslint/no-empty-object-type */
2
2
  import {
3
3
  ArgvParser,
4
4
  EnvOptionConfig,
@@ -22,6 +22,8 @@ import {
22
22
  ErrorHandler,
23
23
  SDKCommand,
24
24
  } from './public-api';
25
+ import type { PromptProvider, PromptOptionConfig } from './prompt-types';
26
+ import { resolvePrompts } from './resolve-prompts';
25
27
  import { getCallingFile, getParentPackageJson } from './utils';
26
28
 
27
29
  /**
@@ -55,7 +57,7 @@ const CLI_FORGE_BRAND = Symbol.for('cli-forge:InternalCLI');
55
57
  export class InternalCLI<
56
58
  TArgs extends ParsedArgs = ParsedArgs,
57
59
  THandlerReturn = void,
58
- // eslint-disable-next-line @typescript-eslint/ban-types
60
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
59
61
  TChildren = {},
60
62
  TParent = undefined
61
63
  > implements CLI<TArgs, THandlerReturn, TChildren, TParent>
@@ -122,6 +124,14 @@ export class InternalCLI<
122
124
  (cli: any, args: TArgs) => Promise<void> | void
123
125
  > = [];
124
126
 
127
+ registeredPromptProviders: PromptProvider[] = [];
128
+
129
+ /**
130
+ * Stores prompt config for each option, keyed by option name.
131
+ * Set when .option() is called with a `prompt` property.
132
+ */
133
+ promptConfigs: Map<string, PromptOptionConfig<any>> = new Map();
134
+
125
135
  /**
126
136
  * Set when a `$0` alias replaces the root builder via `.command()`.
127
137
  * The $0 builder should only run if no explicit subcommand is given,
@@ -287,7 +297,7 @@ export class InternalCLI<
287
297
  CLI<TArgs, THandlerReturn, TChildren, TParent>
288
298
  >;
289
299
  }
290
- : // eslint-disable-next-line @typescript-eslint/ban-types
300
+ : // eslint-disable-next-line @typescript-eslint/no-empty-object-type
291
301
  {}),
292
302
  TParent
293
303
  > {
@@ -372,8 +382,12 @@ export class InternalCLI<
372
382
  option<
373
383
  TOption extends string,
374
384
  const TOptionConfig extends OptionConfig<any, any, any>
375
- >(name: TOption, config: TOptionConfig) {
376
- this.parser.option(name, config);
385
+ >(name: TOption, config: TOptionConfig & { prompt?: PromptOptionConfig<TArgs> }) {
386
+ const { prompt, ...parserConfig } = config;
387
+ if (prompt !== undefined) {
388
+ this.promptConfigs.set(name, prompt);
389
+ }
390
+ this.parser.option(name, parserConfig as TOptionConfig);
377
391
  // Interface modifies the return type to reflect new params, cast is necessay.... I think 🤔
378
392
  return this as any;
379
393
  }
@@ -381,8 +395,12 @@ export class InternalCLI<
381
395
  positional<
382
396
  TOption extends string,
383
397
  const TOptionConfig extends OptionConfig<any, any, any>
384
- >(name: TOption, config: TOptionConfig) {
385
- this.parser.positional(name, config);
398
+ >(name: TOption, config: TOptionConfig & { prompt?: PromptOptionConfig<TArgs> }) {
399
+ const { prompt, ...parserConfig } = config;
400
+ if (prompt !== undefined) {
401
+ this.promptConfigs.set(name, prompt);
402
+ }
403
+ this.parser.positional(name, parserConfig as TOptionConfig);
386
404
  // Interface modifies the return type to reflect new params, cast is necessay.... I think 🤔
387
405
  return this as any;
388
406
  }
@@ -829,6 +847,18 @@ export class InternalCLI<
829
847
  return this as unknown as CLI<TArgs, THandlerReturn, TChildren, TParent>;
830
848
  }
831
849
 
850
+ withPromptProvider(
851
+ provider: PromptProvider
852
+ ): CLI<TArgs, THandlerReturn, TChildren, TParent> {
853
+ if (!provider.prompt && !provider.promptBatch) {
854
+ throw new Error(
855
+ "Prompt provider must implement at least one of 'prompt' or 'promptBatch'"
856
+ );
857
+ }
858
+ this.registeredPromptProviders.push(provider);
859
+ return this as unknown as CLI<TArgs, THandlerReturn, TChildren, TParent>;
860
+ }
861
+
832
862
  group(
833
863
  labelOrConfigObject:
834
864
  | string
@@ -1025,6 +1055,47 @@ export class InternalCLI<
1025
1055
  // seeded with the accumulated values from the discovery loop.
1026
1056
  // The alreadyParsed values ensure proper required-option
1027
1057
  // validation and prevent positional re-consumption.
1058
+
1059
+ // Prompt for missing option values before final validation.
1060
+ // Collect prompt providers from the full command chain.
1061
+ const allPromptProviders: PromptProvider[] = [
1062
+ ...this.registeredPromptProviders,
1063
+ ];
1064
+ const allPromptConfigs = new Map(this.promptConfigs);
1065
+ {
1066
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
1067
+ let walkCmd: InternalCLI<any, any, any, any> = this;
1068
+ for (const command of this.commandChain) {
1069
+ walkCmd = walkCmd.registeredCommands[command];
1070
+ for (const p of walkCmd.registeredPromptProviders) {
1071
+ allPromptProviders.push(p);
1072
+ }
1073
+ for (const [k, v] of walkCmd.promptConfigs) {
1074
+ allPromptConfigs.set(k, v);
1075
+ }
1076
+ }
1077
+ }
1078
+
1079
+ if (allPromptProviders.length > 0 || allPromptConfigs.size > 0) {
1080
+ const promptedValues = await resolvePrompts({
1081
+ configuredOptions: this.parser.configuredOptions as Record<
1082
+ string,
1083
+ any
1084
+ >,
1085
+ configuredImplies: this.parser.configuredImplies,
1086
+ promptConfigs: allPromptConfigs,
1087
+ providers: allPromptProviders,
1088
+ currentArgs: mergedArgs,
1089
+ });
1090
+
1091
+ // Inject prompted values into accumulated args
1092
+ for (const [key, value] of Object.entries(promptedValues)) {
1093
+ if (value !== undefined) {
1094
+ mergedArgs[key] = value;
1095
+ }
1096
+ }
1097
+ }
1098
+
1028
1099
  try {
1029
1100
  argv = this.parser
1030
1101
  .clone({
@@ -1079,6 +1150,8 @@ export class InternalCLI<
1079
1150
  }
1080
1151
  clone.commandChain = [...this.commandChain];
1081
1152
  clone.requiresCommand = this.requiresCommand;
1153
+ clone.registeredPromptProviders = [...this.registeredPromptProviders];
1154
+ clone.promptConfigs = new Map(this.promptConfigs);
1082
1155
  return clone;
1083
1156
  }
1084
1157
  }
@@ -0,0 +1,48 @@
1
+ import type { InternalOptionConfig } from '@cli-forge/parser';
2
+
3
+ /**
4
+ * Static prompt configuration for an option.
5
+ * - `true` — always prompt
6
+ * - `string` — always prompt with this label
7
+ * - `false` — never prompt
8
+ */
9
+ export type PromptConfig = boolean | string;
10
+
11
+ /**
12
+ * Full prompt configuration, including dynamic resolution.
13
+ * When a function is provided, it receives accumulated args and returns
14
+ * a static PromptConfig. Returning null/undefined from the callback
15
+ * is treated as falsy (don't prompt).
16
+ */
17
+ export type PromptOptionConfig<TArgs = unknown> =
18
+ | PromptConfig
19
+ | ((args: Partial<TArgs>) => PromptConfig | null | undefined);
20
+
21
+ /**
22
+ * An option that needs prompting, passed to prompt providers.
23
+ */
24
+ export interface PromptOption {
25
+ /** The option name (key) */
26
+ name: string;
27
+ /** The full option config from the parser, with resolved prompt value */
28
+ config: InternalOptionConfig & { prompt?: PromptConfig };
29
+ }
30
+
31
+ /**
32
+ * A prompt provider that can fulfill missing option values interactively.
33
+ */
34
+ export interface PromptProvider {
35
+ /**
36
+ * If provided, this provider only handles options where filter returns true.
37
+ * Providers without filters act as fallbacks.
38
+ */
39
+ filter?: (name: string, config: InternalOptionConfig) => boolean;
40
+ /**
41
+ * Prompt for a single option. Called per-option if promptBatch is not defined.
42
+ */
43
+ prompt?: (option: PromptOption) => Promise<unknown>;
44
+ /**
45
+ * Prompt for multiple options at once. Preferred over prompt when available.
46
+ */
47
+ promptBatch?: (options: PromptOption[]) => Promise<Record<string, unknown>>;
48
+ }
@@ -1,4 +1,4 @@
1
- /* eslint-disable @typescript-eslint/ban-types */
1
+ /* eslint-disable @typescript-eslint/no-empty-object-type */
2
2
  import {
3
3
  ArrayOptionConfig,
4
4
  BooleanOptionConfig,
@@ -18,6 +18,7 @@ import {
18
18
  } from '@cli-forge/parser';
19
19
 
20
20
  import { InternalCLI } from './internal-cli';
21
+ import type { PromptOptionConfig, PromptProvider } from './prompt-types';
21
22
 
22
23
  /**
23
24
  * Extracts the command name from a Command type.
@@ -87,7 +88,7 @@ export type CommandToChildEntry<T, TParentCLI = undefined> = {
87
88
  export interface CLI<
88
89
  TArgs extends ParsedArgs = ParsedArgs,
89
90
  THandlerReturn = void,
90
- // eslint-disable-next-line @typescript-eslint/ban-types
91
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
91
92
  TChildren = {},
92
93
  TParent = undefined
93
94
  > {
@@ -121,7 +122,7 @@ export interface CLI<
121
122
  TCommandArgs extends TArgs,
122
123
  TChildHandlerReturn,
123
124
  TKey extends string,
124
- // eslint-disable-next-line @typescript-eslint/ban-types
125
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
125
126
  TChildChildren = {}
126
127
  >(
127
128
  key: TKey,
@@ -424,6 +425,17 @@ export interface CLI<
424
425
  handler: ErrorHandler
425
426
  ): CLI<TArgs, THandlerReturn, TChildren, TParent>;
426
427
 
428
+ /**
429
+ * Registers a prompt provider for interactive option fulfillment.
430
+ * Multiple providers can be registered. Filtered providers are checked first
431
+ * (in registration order), then fallback providers (no filter).
432
+ *
433
+ * @param provider The prompt provider to register.
434
+ */
435
+ withPromptProvider(
436
+ provider: PromptProvider
437
+ ): CLI<TArgs, THandlerReturn, TChildren, TParent>;
438
+
427
439
  /**
428
440
  * Registers a new option for the CLI command. This option will be accessible
429
441
  * within the command handler, as well as any subcommands.
@@ -441,7 +453,7 @@ export interface CLI<
441
453
  const TProps extends Record<string, { type: string }>
442
454
  >(
443
455
  name: TOption,
444
- config: ObjectOptionConfig<TCoerce, TProps>
456
+ config: ObjectOptionConfig<TCoerce, TProps> & { prompt?: PromptOptionConfig<TArgs> }
445
457
  ): CLI<
446
458
  TArgs &
447
459
  MakeUndefinedPropertiesOptional<{
@@ -460,7 +472,7 @@ export interface CLI<
460
472
  const TConfig extends StringOptionConfig<any, any>
461
473
  >(
462
474
  name: TOption,
463
- config: TConfig
475
+ config: TConfig & { prompt?: PromptOptionConfig<TArgs> }
464
476
  ): CLI<
465
477
  TArgs &
466
478
  MakeUndefinedPropertiesOptional<{
@@ -476,7 +488,7 @@ export interface CLI<
476
488
  const TConfig extends NumberOptionConfig<any, any>
477
489
  >(
478
490
  name: TOption,
479
- config: TConfig
491
+ config: TConfig & { prompt?: PromptOptionConfig<TArgs> }
480
492
  ): CLI<
481
493
  TArgs &
482
494
  MakeUndefinedPropertiesOptional<{
@@ -492,7 +504,7 @@ export interface CLI<
492
504
  const TConfig extends BooleanOptionConfig<any, any>
493
505
  >(
494
506
  name: TOption,
495
- config: TConfig
507
+ config: TConfig & { prompt?: PromptOptionConfig<TArgs> }
496
508
  ): CLI<
497
509
  TArgs &
498
510
  MakeUndefinedPropertiesOptional<{
@@ -508,7 +520,7 @@ export interface CLI<
508
520
  const TConfig extends ArrayOptionConfig<any, any>
509
521
  >(
510
522
  name: TOption,
511
- config: TConfig
523
+ config: TConfig & { prompt?: PromptOptionConfig<TArgs> }
512
524
  ): CLI<
513
525
  TArgs &
514
526
  MakeUndefinedPropertiesOptional<{
@@ -524,7 +536,7 @@ export interface CLI<
524
536
  const TOptionConfig extends OptionConfig<any, any, any>
525
537
  >(
526
538
  name: TOption,
527
- config: TOptionConfig
539
+ config: TOptionConfig & { prompt?: PromptOptionConfig<TArgs> }
528
540
  ): CLI<
529
541
  TArgs &
530
542
  MakeUndefinedPropertiesOptional<{
@@ -551,7 +563,7 @@ export interface CLI<
551
563
  const TProps extends Record<string, { type: string }>
552
564
  >(
553
565
  name: TOption,
554
- config: ObjectOptionConfig<TCoerce, TProps>
566
+ config: ObjectOptionConfig<TCoerce, TProps> & { prompt?: PromptOptionConfig<TArgs> }
555
567
  ): CLI<
556
568
  TArgs &
557
569
  MakeUndefinedPropertiesOptional<{
@@ -570,7 +582,7 @@ export interface CLI<
570
582
  const TConfig extends StringOptionConfig<any, any>
571
583
  >(
572
584
  name: TOption,
573
- config: TConfig
585
+ config: TConfig & { prompt?: PromptOptionConfig<TArgs> }
574
586
  ): CLI<
575
587
  TArgs &
576
588
  MakeUndefinedPropertiesOptional<{
@@ -586,7 +598,7 @@ export interface CLI<
586
598
  const TConfig extends NumberOptionConfig<any, any>
587
599
  >(
588
600
  name: TOption,
589
- config: TConfig
601
+ config: TConfig & { prompt?: PromptOptionConfig<TArgs> }
590
602
  ): CLI<
591
603
  TArgs &
592
604
  MakeUndefinedPropertiesOptional<{
@@ -602,7 +614,7 @@ export interface CLI<
602
614
  const TConfig extends BooleanOptionConfig<any, any>
603
615
  >(
604
616
  name: TOption,
605
- config: TConfig
617
+ config: TConfig & { prompt?: PromptOptionConfig<TArgs> }
606
618
  ): CLI<
607
619
  TArgs &
608
620
  MakeUndefinedPropertiesOptional<{
@@ -618,7 +630,7 @@ export interface CLI<
618
630
  const TConfig extends ArrayOptionConfig<any, any>
619
631
  >(
620
632
  name: TOption,
621
- config: TConfig
633
+ config: TConfig & { prompt?: PromptOptionConfig<TArgs> }
622
634
  ): CLI<
623
635
  TArgs &
624
636
  MakeUndefinedPropertiesOptional<{
@@ -634,7 +646,7 @@ export interface CLI<
634
646
  const TOptionConfig extends OptionConfig<any, any, any>
635
647
  >(
636
648
  name: TOption,
637
- config: TOptionConfig
649
+ config: TOptionConfig & { prompt?: PromptOptionConfig<TArgs> }
638
650
  ): CLI<
639
651
  TArgs &
640
652
  MakeUndefinedPropertiesOptional<{
@@ -928,13 +940,13 @@ export interface CLICommandOptions<
928
940
  /**
929
941
  * The children commands that exist before the builder runs.
930
942
  */
931
- // eslint-disable-next-line @typescript-eslint/ban-types
943
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
932
944
  TInitialChildren = {},
933
945
  TParent = any,
934
946
  /**
935
947
  * The children commands after the builder runs (includes TInitialChildren plus any added by builder).
936
948
  */
937
- // eslint-disable-next-line @typescript-eslint/ban-types
949
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
938
950
  TChildren = {}
939
951
  > {
940
952
  /**
@@ -1080,7 +1092,7 @@ export type SDKChildren<TChildren> = {
1080
1092
  * Container commands (no handler) are not callable but still provide access to children.
1081
1093
  */
1082
1094
  export type SDKCommand<TArgs, THandlerReturn, TChildren> =
1083
- // eslint-disable-next-line @typescript-eslint/ban-types
1095
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
1084
1096
  // THandlerReturn extends void | undefined
1085
1097
  // ? SDKChildren<TChildren> // No handler = just children (not callable)
1086
1098
  SDKInvokable<TArgs, THandlerReturn> & SDKChildren<TChildren>;
@@ -1094,7 +1106,7 @@ export type SDKCommand<TArgs, THandlerReturn, TChildren> =
1094
1106
  export function cli<
1095
1107
  TArgs extends ParsedArgs,
1096
1108
  THandlerReturn = void,
1097
- // eslint-disable-next-line @typescript-eslint/ban-types
1109
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
1098
1110
  TChildren = {},
1099
1111
  TName extends string = string
1100
1112
  >(
@@ -0,0 +1,311 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { resolvePrompts } from './resolve-prompts';
3
+ import type { InternalOptionConfig } from '@cli-forge/parser';
4
+ import type { PromptProvider } from './prompt-types';
5
+
6
+ function makeConfig(
7
+ overrides: Partial<InternalOptionConfig> & { key: string }
8
+ ): InternalOptionConfig {
9
+ return {
10
+ type: 'string',
11
+ ...overrides,
12
+ } as InternalOptionConfig;
13
+ }
14
+
15
+ describe('resolvePrompts', () => {
16
+ it('should return empty object when no options need prompting', async () => {
17
+ const result = await resolvePrompts({
18
+ configuredOptions: {
19
+ name: makeConfig({ key: 'name' }),
20
+ },
21
+ configuredImplies: {},
22
+ promptConfigs: new Map(),
23
+ providers: [],
24
+ currentArgs: { name: 'Alice' },
25
+ });
26
+ expect(result).toEqual({});
27
+ });
28
+
29
+ it('should prompt for required options missing values', async () => {
30
+ const provider: PromptProvider = {
31
+ prompt: vi.fn().mockResolvedValue('prompted-value'),
32
+ };
33
+
34
+ const result = await resolvePrompts({
35
+ configuredOptions: {
36
+ name: makeConfig({ key: 'name', required: true }),
37
+ },
38
+ configuredImplies: {},
39
+ promptConfigs: new Map(),
40
+ providers: [provider],
41
+ currentArgs: {},
42
+ });
43
+
44
+ expect(result).toEqual({ name: 'prompted-value' });
45
+ expect(provider.prompt).toHaveBeenCalledOnce();
46
+ });
47
+
48
+ it('should not prompt for required options that already have values', async () => {
49
+ const provider: PromptProvider = {
50
+ prompt: vi.fn().mockResolvedValue('prompted-value'),
51
+ };
52
+
53
+ const result = await resolvePrompts({
54
+ configuredOptions: {
55
+ name: makeConfig({ key: 'name', required: true }),
56
+ },
57
+ configuredImplies: {},
58
+ promptConfigs: new Map(),
59
+ providers: [provider],
60
+ currentArgs: { name: 'existing' },
61
+ });
62
+
63
+ expect(result).toEqual({});
64
+ expect(provider.prompt).not.toHaveBeenCalled();
65
+ });
66
+
67
+ it('should call prompt callback with current args', async () => {
68
+ const promptFn = vi.fn().mockReturnValue('Enter token');
69
+ const provider: PromptProvider = {
70
+ prompt: vi.fn().mockResolvedValue('token-value'),
71
+ };
72
+
73
+ await resolvePrompts({
74
+ configuredOptions: {
75
+ token: makeConfig({ key: 'token' }),
76
+ },
77
+ configuredImplies: {},
78
+ promptConfigs: new Map([['token', promptFn]]),
79
+ providers: [provider],
80
+ currentArgs: { someFlag: true },
81
+ });
82
+
83
+ expect(promptFn).toHaveBeenCalledWith({ someFlag: true });
84
+ });
85
+
86
+ it('should not prompt when callback returns null', async () => {
87
+ const promptFn = vi.fn().mockReturnValue(null);
88
+ const provider: PromptProvider = {
89
+ prompt: vi.fn().mockResolvedValue('value'),
90
+ };
91
+
92
+ const result = await resolvePrompts({
93
+ configuredOptions: {
94
+ token: makeConfig({ key: 'token' }),
95
+ },
96
+ configuredImplies: {},
97
+ promptConfigs: new Map([['token', promptFn]]),
98
+ providers: [provider],
99
+ currentArgs: {},
100
+ });
101
+
102
+ expect(result).toEqual({});
103
+ expect(provider.prompt).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it('should prompt for implied options when trigger is set and implied option is missing', async () => {
107
+ const provider: PromptProvider = {
108
+ prompt: vi.fn().mockResolvedValue('value'),
109
+ };
110
+
111
+ const result = await resolvePrompts({
112
+ configuredOptions: {
113
+ output: makeConfig({ key: 'output' }),
114
+ format: makeConfig({ key: 'format' }),
115
+ },
116
+ configuredImplies: { output: new Set(['format']) },
117
+ promptConfigs: new Map(),
118
+ providers: [provider],
119
+ currentArgs: { output: '/tmp/out' },
120
+ });
121
+
122
+ expect(result).toEqual({ format: 'value' });
123
+ });
124
+
125
+ it('should not prompt for implied options when trigger is not set', async () => {
126
+ const provider: PromptProvider = {
127
+ prompt: vi.fn().mockResolvedValue('value'),
128
+ };
129
+
130
+ const result = await resolvePrompts({
131
+ configuredOptions: {
132
+ output: makeConfig({ key: 'output' }),
133
+ format: makeConfig({ key: 'format' }),
134
+ },
135
+ configuredImplies: { output: new Set(['format']) },
136
+ promptConfigs: new Map(),
137
+ providers: [provider],
138
+ currentArgs: {},
139
+ });
140
+
141
+ expect(result).toEqual({});
142
+ expect(provider.prompt).not.toHaveBeenCalled();
143
+ });
144
+
145
+ it('should group options by matched provider for batch calls', async () => {
146
+ const batchProvider: PromptProvider = {
147
+ filter: (name) => name.startsWith('db'),
148
+ promptBatch: vi
149
+ .fn()
150
+ .mockResolvedValue({ dbHost: 'localhost', dbPort: 5432 }),
151
+ };
152
+ const fallbackProvider: PromptProvider = {
153
+ prompt: vi.fn().mockResolvedValue('fallback'),
154
+ };
155
+
156
+ const result = await resolvePrompts({
157
+ configuredOptions: {
158
+ dbHost: makeConfig({ key: 'dbHost' }),
159
+ dbPort: makeConfig({ key: 'dbPort' }),
160
+ name: makeConfig({ key: 'name' }),
161
+ },
162
+ configuredImplies: {},
163
+ promptConfigs: new Map([
164
+ ['dbHost', true],
165
+ ['dbPort', true],
166
+ ['name', true],
167
+ ]),
168
+ providers: [batchProvider, fallbackProvider],
169
+ currentArgs: {},
170
+ });
171
+
172
+ expect(batchProvider.promptBatch).toHaveBeenCalledOnce();
173
+ expect(fallbackProvider.prompt).toHaveBeenCalledOnce();
174
+ expect(result).toEqual({
175
+ dbHost: 'localhost',
176
+ dbPort: 5432,
177
+ name: 'fallback',
178
+ });
179
+ });
180
+
181
+ it('should skip options where prompt is false', async () => {
182
+ const provider: PromptProvider = {
183
+ prompt: vi.fn().mockResolvedValue('value'),
184
+ };
185
+
186
+ const result = await resolvePrompts({
187
+ configuredOptions: {
188
+ name: makeConfig({ key: 'name', required: true }),
189
+ },
190
+ configuredImplies: {},
191
+ promptConfigs: new Map([['name', false]]),
192
+ providers: [provider],
193
+ currentArgs: {},
194
+ });
195
+
196
+ expect(result).toEqual({});
197
+ expect(provider.prompt).not.toHaveBeenCalled();
198
+ });
199
+
200
+ it('should throw when options need prompting but no provider matches', async () => {
201
+ await expect(
202
+ resolvePrompts({
203
+ configuredOptions: {
204
+ name: makeConfig({ key: 'name' }),
205
+ },
206
+ configuredImplies: {},
207
+ promptConfigs: new Map([['name', true]]),
208
+ providers: [],
209
+ currentArgs: {},
210
+ })
211
+ ).rejects.toThrow(/no prompt provider/i);
212
+ });
213
+
214
+ it('should skip internal options (help, version, unmatched, --)', async () => {
215
+ const provider: PromptProvider = {
216
+ prompt: vi.fn().mockResolvedValue('value'),
217
+ };
218
+
219
+ const result = await resolvePrompts({
220
+ configuredOptions: {
221
+ help: makeConfig({ key: 'help', required: true }),
222
+ version: makeConfig({ key: 'version', required: true }),
223
+ unmatched: makeConfig({ key: 'unmatched', required: true }),
224
+ '--': makeConfig({ key: '--', required: true }),
225
+ },
226
+ configuredImplies: {},
227
+ promptConfigs: new Map(),
228
+ providers: [provider],
229
+ currentArgs: {},
230
+ });
231
+
232
+ expect(result).toEqual({});
233
+ expect(provider.prompt).not.toHaveBeenCalled();
234
+ });
235
+
236
+ it('should prefer promptBatch over prompt when both are available', async () => {
237
+ let batchCalled = false;
238
+ const provider: PromptProvider = {
239
+ promptBatch: vi.fn().mockImplementation(async (options) => {
240
+ batchCalled = true;
241
+ const result: Record<string, unknown> = {};
242
+ for (const opt of options) {
243
+ result[opt.name] = 'batch-value';
244
+ }
245
+ return result;
246
+ }),
247
+ prompt: vi.fn().mockImplementation(async () => {
248
+ throw new Error('Should not be called when promptBatch exists');
249
+ }),
250
+ };
251
+
252
+ const result = await resolvePrompts({
253
+ configuredOptions: {
254
+ a: makeConfig({ key: 'a' }),
255
+ b: makeConfig({ key: 'b' }),
256
+ },
257
+ configuredImplies: {},
258
+ promptConfigs: new Map([
259
+ ['a', true],
260
+ ['b', true],
261
+ ]),
262
+ providers: [provider],
263
+ currentArgs: {},
264
+ });
265
+
266
+ expect(batchCalled).toBe(true);
267
+ expect(provider.prompt).not.toHaveBeenCalled();
268
+ expect(result).toEqual({ a: 'batch-value', b: 'batch-value' });
269
+ });
270
+
271
+ it('should prompt with string label when prompt config is a string', async () => {
272
+ const provider: PromptProvider = {
273
+ prompt: vi.fn().mockResolvedValue('value'),
274
+ };
275
+
276
+ await resolvePrompts({
277
+ configuredOptions: {
278
+ name: makeConfig({ key: 'name' }),
279
+ },
280
+ configuredImplies: {},
281
+ promptConfigs: new Map([['name', 'What is your name?']]),
282
+ providers: [provider],
283
+ currentArgs: {},
284
+ });
285
+
286
+ expect(provider.prompt).toHaveBeenCalledOnce();
287
+ const calledOption = (provider.prompt as ReturnType<typeof vi.fn>).mock
288
+ .calls[0][0];
289
+ expect(calledOption.name).toBe('name');
290
+ expect(calledOption.config.prompt).toBe('What is your name?');
291
+ });
292
+
293
+ it('should not prompt for non-required options without explicit prompt config', async () => {
294
+ const provider: PromptProvider = {
295
+ prompt: vi.fn().mockResolvedValue('value'),
296
+ };
297
+
298
+ const result = await resolvePrompts({
299
+ configuredOptions: {
300
+ name: makeConfig({ key: 'name' }),
301
+ },
302
+ configuredImplies: {},
303
+ promptConfigs: new Map(),
304
+ providers: [provider],
305
+ currentArgs: {},
306
+ });
307
+
308
+ expect(result).toEqual({});
309
+ expect(provider.prompt).not.toHaveBeenCalled();
310
+ });
311
+ });