cli-forge 1.2.3 → 1.3.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.
@@ -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
+ });
@@ -0,0 +1,156 @@
1
+ import type { InternalOptionConfig } from '@cli-forge/parser';
2
+ import type {
3
+ PromptConfig,
4
+ PromptOption,
5
+ PromptOptionConfig,
6
+ PromptProvider,
7
+ } from './prompt-types';
8
+
9
+ /**
10
+ * Collects options that need prompting, matches them to providers,
11
+ * executes prompts, and returns the prompted values.
12
+ */
13
+ export async function resolvePrompts(opts: {
14
+ configuredOptions: Record<string, InternalOptionConfig>;
15
+ configuredImplies: Record<string, Set<string>>;
16
+ promptConfigs: Map<string, PromptOptionConfig<any>>;
17
+ providers: PromptProvider[];
18
+ currentArgs: Record<string, unknown>;
19
+ }): Promise<Record<string, unknown>> {
20
+ const {
21
+ configuredOptions,
22
+ configuredImplies,
23
+ promptConfigs,
24
+ providers,
25
+ currentArgs,
26
+ } = opts;
27
+
28
+ // Step 1: Collect promptable options
29
+ const promptableOptions: PromptOption[] = [];
30
+
31
+ for (const [name, config] of Object.entries(configuredOptions)) {
32
+ // Skip internal options
33
+ if (
34
+ name === 'help' ||
35
+ name === 'version' ||
36
+ name === 'unmatched' ||
37
+ name === '--'
38
+ ) {
39
+ continue;
40
+ }
41
+
42
+ // Already has a value — skip unless prompt is explicitly true/string
43
+ const hasValue = currentArgs[name] !== undefined;
44
+
45
+ const promptSetting = promptConfigs.get(name);
46
+ let resolved: PromptConfig | null | undefined;
47
+
48
+ if (typeof promptSetting === 'function') {
49
+ resolved = promptSetting(currentArgs);
50
+ // Callback: null/undefined treated as false
51
+ if (resolved === null || resolved === undefined) {
52
+ continue;
53
+ }
54
+ } else if (promptSetting !== undefined) {
55
+ // Static value
56
+ resolved = promptSetting;
57
+ } else {
58
+ // Not specified: prompt only if required and missing value
59
+ if (hasValue) continue;
60
+
61
+ const isRequired = config.required === true;
62
+ const isImplied = isOptionImplied(name, configuredImplies, currentArgs);
63
+
64
+ if (!isRequired && !isImplied) continue;
65
+ if (providers.length === 0) continue; // No providers, let validation handle it
66
+
67
+ resolved = true; // Will prompt
68
+ }
69
+
70
+ if (resolved === false) continue;
71
+ if (hasValue && resolved !== true && typeof resolved !== 'string') continue;
72
+
73
+ promptableOptions.push({
74
+ name,
75
+ config: {
76
+ ...config,
77
+ prompt: resolved === true ? true : resolved ?? undefined,
78
+ },
79
+ });
80
+ }
81
+
82
+ if (promptableOptions.length === 0) {
83
+ return {};
84
+ }
85
+
86
+ // Step 2: Match options to providers
87
+ const filteredProviders = providers.filter((p) => p.filter);
88
+ const fallbackProviders = providers.filter((p) => !p.filter);
89
+
90
+ const providerGroups = new Map<PromptProvider, PromptOption[]>();
91
+ const unmatchedOptions: PromptOption[] = [];
92
+
93
+ for (const option of promptableOptions) {
94
+ let matched = false;
95
+ for (const provider of filteredProviders) {
96
+ if (provider.filter!(option.name, option.config)) {
97
+ if (!providerGroups.has(provider)) {
98
+ providerGroups.set(provider, []);
99
+ }
100
+ providerGroups.get(provider)!.push(option);
101
+ matched = true;
102
+ break;
103
+ }
104
+ }
105
+ if (!matched) {
106
+ unmatchedOptions.push(option);
107
+ }
108
+ }
109
+
110
+ // Assign unmatched options to first fallback provider
111
+ if (unmatchedOptions.length > 0) {
112
+ if (fallbackProviders.length === 0) {
113
+ const names = unmatchedOptions.map((o) => `'${o.name}'`).join(', ');
114
+ throw new Error(
115
+ `Option(s) ${names} require prompting but no prompt provider is available`
116
+ );
117
+ }
118
+ const fallback = fallbackProviders[0];
119
+ if (!providerGroups.has(fallback)) {
120
+ providerGroups.set(fallback, []);
121
+ }
122
+ providerGroups.get(fallback)!.push(...unmatchedOptions);
123
+ }
124
+
125
+ // Step 3: Execute prompts
126
+ const results: Record<string, unknown> = {};
127
+
128
+ for (const [provider, options] of providerGroups) {
129
+ if (provider.promptBatch) {
130
+ const batchResults = await provider.promptBatch(options);
131
+ Object.assign(results, batchResults);
132
+ } else if (provider.prompt) {
133
+ for (const option of options) {
134
+ results[option.name] = await provider.prompt(option);
135
+ }
136
+ }
137
+ }
138
+
139
+ return results;
140
+ }
141
+
142
+ /**
143
+ * Check if an option is implied by another option that has been set.
144
+ */
145
+ function isOptionImplied(
146
+ name: string,
147
+ configuredImplies: Record<string, Set<string>>,
148
+ currentArgs: Record<string, unknown>
149
+ ): boolean {
150
+ for (const [trigger, implied] of Object.entries(configuredImplies)) {
151
+ if (implied.has(name) && currentArgs[trigger] !== undefined) {
152
+ return true;
153
+ }
154
+ }
155
+ return false;
156
+ }