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,136 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ClackPromptProvider = void 0;
4
+ exports.createClackPromptProvider = createClackPromptProvider;
5
+ /**
6
+ * Creates a prompt provider backed by @clack/prompts.
7
+ * Requires `@clack/prompts` as a peer dependency.
8
+ *
9
+ * The provider uses dynamic imports so that `@clack/prompts` is only
10
+ * loaded when prompting actually occurs.
11
+ */
12
+ function createClackPromptProvider(providerOptions) {
13
+ // Build the common stream options once; spread into every prompt call.
14
+ const streamOpts = {};
15
+ if (providerOptions?.input)
16
+ streamOpts.input = providerOptions.input;
17
+ if (providerOptions?.output)
18
+ streamOpts.output = providerOptions.output;
19
+ return {
20
+ async promptBatch(options) {
21
+ const clack = await import('@clack/prompts');
22
+ const results = {};
23
+ for (const option of options) {
24
+ const message = getLabel(option);
25
+ const defaultValue = getDefault(option.config);
26
+ let value;
27
+ if (option.config.type === 'boolean') {
28
+ value = await clack.confirm({
29
+ ...streamOpts,
30
+ message,
31
+ initialValue: typeof defaultValue === 'boolean' ? defaultValue : undefined,
32
+ });
33
+ }
34
+ else if (hasChoices(option.config)) {
35
+ const choices = getChoices(option.config);
36
+ if (option.config.type === 'array') {
37
+ value = await clack.multiselect({
38
+ ...streamOpts,
39
+ message,
40
+ options: choices.map((c) => ({ value: c, label: String(c) })),
41
+ });
42
+ }
43
+ else {
44
+ value = await clack.select({
45
+ ...streamOpts,
46
+ message,
47
+ options: choices.map((c) => ({ value: c, label: String(c) })),
48
+ });
49
+ }
50
+ }
51
+ else if (option.config.type === 'number') {
52
+ const raw = await clack.text({
53
+ ...streamOpts,
54
+ message,
55
+ placeholder: defaultValue !== undefined ? String(defaultValue) : undefined,
56
+ defaultValue: defaultValue !== undefined ? String(defaultValue) : undefined,
57
+ validate: (val) => {
58
+ if (val && isNaN(Number(val))) {
59
+ return 'Please enter a valid number';
60
+ }
61
+ return undefined;
62
+ },
63
+ });
64
+ if (clack.isCancel(raw)) {
65
+ clack.cancel('Operation cancelled.');
66
+ throw new Error('Prompt cancelled by user');
67
+ }
68
+ value = raw !== undefined && raw !== '' ? Number(raw) : defaultValue;
69
+ }
70
+ else {
71
+ // string, array without choices
72
+ value = await clack.text({
73
+ ...streamOpts,
74
+ message,
75
+ placeholder: defaultValue !== undefined ? String(defaultValue) : undefined,
76
+ defaultValue: defaultValue !== undefined ? String(defaultValue) : undefined,
77
+ });
78
+ }
79
+ // clack returns a Symbol when the user cancels (Ctrl+C)
80
+ if (clack.isCancel(value)) {
81
+ clack.cancel('Operation cancelled.');
82
+ throw new Error('Prompt cancelled by user');
83
+ }
84
+ results[option.name] = value;
85
+ }
86
+ return results;
87
+ },
88
+ };
89
+ }
90
+ /**
91
+ * Default clack prompt provider, uses stdin/stdout
92
+ */
93
+ exports.ClackPromptProvider = createClackPromptProvider();
94
+ /**
95
+ * Determine the label to show for a prompt option.
96
+ * Priority: prompt string > description > option name.
97
+ */
98
+ function getLabel(option) {
99
+ if (typeof option.config.prompt === 'string') {
100
+ return option.config.prompt;
101
+ }
102
+ return option.config.description ?? option.name;
103
+ }
104
+ /**
105
+ * Extract the default value from an option config.
106
+ * Handles the three forms:
107
+ * - Primitive value directly
108
+ * - `{ value: T; description: string }` object
109
+ * - `{ factory: () => T; description: string }` object
110
+ */
111
+ function getDefault(config) {
112
+ if (config.default === undefined)
113
+ return undefined;
114
+ if (typeof config.default === 'object' && config.default !== null) {
115
+ if ('factory' in config.default) {
116
+ return config.default.factory();
117
+ }
118
+ if ('value' in config.default) {
119
+ return config.default.value;
120
+ }
121
+ }
122
+ return config.default;
123
+ }
124
+ function hasChoices(config) {
125
+ return ('choices' in config &&
126
+ config['choices'] !== undefined);
127
+ }
128
+ function getChoices(config) {
129
+ const cfg = config;
130
+ const choices = cfg['choices'];
131
+ if (typeof choices === 'function') {
132
+ return choices();
133
+ }
134
+ return choices ?? [];
135
+ }
136
+ //# sourceMappingURL=clack.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"clack.js","sourceRoot":"","sources":["../../src/prompt-providers/clack.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,16 +1,20 @@
1
1
  {
2
2
  "name": "cli-forge",
3
- "version": "1.2.3",
3
+ "version": "1.3.0",
4
4
  "dependencies": {
5
5
  "tslib": "^2.3.0",
6
- "@cli-forge/parser": "1.2.3"
6
+ "@cli-forge/parser": "1.3.0"
7
7
  },
8
8
  "peerDependencies": {
9
+ "@clack/prompts": "*",
9
10
  "markdown-factory": "^0.2.0",
10
11
  "tsx": "^4.19.0",
11
12
  "zod": "^4.1.13"
12
13
  },
13
14
  "peerDependenciesMeta": {
15
+ "@clack/prompts": {
16
+ "optional": true
17
+ },
14
18
  "markdown-factory": {
15
19
  "optional": true,
16
20
  "dev": true
@@ -53,6 +57,11 @@
53
57
  "require": "./dist/middleware/*.js",
54
58
  "types": "./dist/middleware/*.d.ts"
55
59
  },
60
+ "./prompt-providers/clack": {
61
+ "require": "./dist/prompt-providers/clack.js",
62
+ "types": "./dist/prompt-providers/clack.d.ts",
63
+ "import": "./dist/prompt-providers/clack.js"
64
+ },
56
65
  "./package.json": "./package.json"
57
66
  },
58
67
  "publishConfig": {
package/src/index.ts CHANGED
@@ -9,5 +9,6 @@ export type {
9
9
  ExtractChildren,
10
10
  } from './lib/composable-builder';
11
11
  export type { ArgumentsOf } from './lib/utils';
12
+ export type { PromptConfig, PromptOptionConfig, PromptOption, PromptProvider } from './lib/prompt-types';
12
13
  export { ConfigurationProviders } from './lib/configuration-providers';
13
14
  export type { LocalizationDictionary, LocalizationFunction } from '@cli-forge/parser';
@@ -19,7 +19,7 @@ export type ExtractArgs<T> = T extends CLI<infer A, any, any, any> ? A : never;
19
19
  */
20
20
  export type ComposableBuilder<
21
21
  TArgs2 extends ParsedArgs,
22
- // eslint-disable-next-line @typescript-eslint/ban-types
22
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
23
23
  TAddedChildren = {}
24
24
  > = <TInit extends ParsedArgs, THandlerReturn, TChildren, TParent>(
25
25
  init: CLI<TInit, THandlerReturn, TChildren, TParent>
@@ -39,11 +39,11 @@ export type ComposableBuilder<
39
39
  */
40
40
  export function makeComposableBuilder<
41
41
  TArgs2 extends ParsedArgs,
42
- // eslint-disable-next-line @typescript-eslint/ban-types
42
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
43
43
  TChildren2 = {}
44
44
  >(
45
45
  fn: (
46
- // eslint-disable-next-line @typescript-eslint/ban-types
46
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
47
47
  init: CLI<ParsedArgs, any, {}, any>
48
48
  ) => CLI<TArgs2, any, TChildren2, any>
49
49
  ) {
@@ -1,6 +1,7 @@
1
1
  import { afterEach, describe, expect, it } from 'vitest';
2
2
  import { InternalCLI } from './internal-cli';
3
3
  import { cli } from './public-api';
4
+ import type { PromptProvider } from './prompt-types';
4
5
 
5
6
  const ORIGINAL_CONSOLE_LOG = console.log;
6
7
 
@@ -1250,4 +1251,303 @@ describe('cliForge', () => {
1250
1251
  expect(result.watch).toBe(true);
1251
1252
  });
1252
1253
  });
1254
+
1255
+ describe('prompt providers', () => {
1256
+ it('should register a prompt provider via withPromptProvider', () => {
1257
+ const provider: PromptProvider = {
1258
+ prompt: async () => 'test-value',
1259
+ };
1260
+ const app = cli('test').withPromptProvider(provider);
1261
+ // Should return CLI for chaining
1262
+ expect(app).toBeDefined();
1263
+ });
1264
+
1265
+ it('should throw if provider has neither prompt nor promptBatch', () => {
1266
+ expect(() => {
1267
+ cli('test').withPromptProvider({} as any);
1268
+ }).toThrow(/must implement at least one of/);
1269
+ });
1270
+
1271
+ it('should store prompt config from option registration', () => {
1272
+ const app = cli('test')
1273
+ .option('name', { type: 'string', prompt: true })
1274
+ .option('age', { type: 'number', prompt: 'How old are you?' })
1275
+ .option('debug', { type: 'boolean' });
1276
+
1277
+ const internal = app as unknown as InternalCLI;
1278
+ expect(internal.promptConfigs.get('name')).toBe(true);
1279
+ expect(internal.promptConfigs.get('age')).toBe('How old are you?');
1280
+ expect(internal.promptConfigs.has('debug')).toBe(false);
1281
+ });
1282
+
1283
+ it('should store prompt config from positional registration', () => {
1284
+ const app = cli('test')
1285
+ .positional('file', { type: 'string', prompt: 'Which file?' });
1286
+
1287
+ const internal = app as unknown as InternalCLI;
1288
+ expect(internal.promptConfigs.get('file')).toBe('Which file?');
1289
+ });
1290
+
1291
+ it('should not store prompt config when prompt is not provided', () => {
1292
+ const app = cli('test')
1293
+ .option('name', { type: 'string' });
1294
+
1295
+ const internal = app as unknown as InternalCLI;
1296
+ expect(internal.promptConfigs.size).toBe(0);
1297
+ });
1298
+
1299
+ it('should store prompt callback config', () => {
1300
+ const promptFn = () => 'Enter value';
1301
+ const app = cli('test')
1302
+ .option('token', { type: 'string', prompt: promptFn });
1303
+
1304
+ const internal = app as unknown as InternalCLI;
1305
+ expect(internal.promptConfigs.get('token')).toBe(promptFn);
1306
+ });
1307
+
1308
+ it('should propagate prompt providers to subcommands', async () => {
1309
+ const prompted: string[] = [];
1310
+ const provider: PromptProvider = {
1311
+ prompt: async (option) => {
1312
+ prompted.push(option.name);
1313
+ return 'value';
1314
+ },
1315
+ };
1316
+
1317
+ const app = cli('test')
1318
+ .withPromptProvider(provider)
1319
+ .command('sub', {
1320
+ builder: (cmd) =>
1321
+ cmd.option('name', { type: 'string', required: true }),
1322
+ handler: () => {},
1323
+ });
1324
+
1325
+ await app.forge(['sub']);
1326
+ expect(prompted).toContain('name');
1327
+ });
1328
+ });
1329
+
1330
+ describe('prompt resolution in forge', () => {
1331
+ it('should prompt for required options with no value when provider exists', async () => {
1332
+ const prompted: string[] = [];
1333
+ const provider: PromptProvider = {
1334
+ prompt: async (option) => {
1335
+ prompted.push(option.name);
1336
+ return option.name === 'name' ? 'Alice' : 42;
1337
+ },
1338
+ };
1339
+
1340
+ const app = cli('test', {
1341
+ handler: () => {},
1342
+ })
1343
+ .option('name', { type: 'string', required: true })
1344
+ .option('age', { type: 'number', required: true })
1345
+ .withPromptProvider(provider);
1346
+
1347
+ await app.forge([]);
1348
+ expect(prompted).toContain('name');
1349
+ expect(prompted).toContain('age');
1350
+ });
1351
+
1352
+ it('should not prompt for options with values already provided', async () => {
1353
+ const prompted: string[] = [];
1354
+ const provider: PromptProvider = {
1355
+ prompt: async (option) => {
1356
+ prompted.push(option.name);
1357
+ return 'value';
1358
+ },
1359
+ };
1360
+
1361
+ const app = cli('test', {
1362
+ handler: () => {},
1363
+ })
1364
+ .option('name', { type: 'string', required: true })
1365
+ .withPromptProvider(provider);
1366
+
1367
+ await app.forge(['--name', 'Bob']);
1368
+ expect(prompted).not.toContain('name');
1369
+ });
1370
+
1371
+ it('should prompt when prompt is true even if not required', async () => {
1372
+ const prompted: string[] = [];
1373
+ const provider: PromptProvider = {
1374
+ prompt: async (option) => {
1375
+ prompted.push(option.name);
1376
+ return 'value';
1377
+ },
1378
+ };
1379
+
1380
+ const app = cli('test', {
1381
+ handler: () => {},
1382
+ })
1383
+ .option('name', { type: 'string', prompt: true })
1384
+ .withPromptProvider(provider);
1385
+
1386
+ await app.forge([]);
1387
+ expect(prompted).toContain('name');
1388
+ });
1389
+
1390
+ it('should still prompt when prompt is true even if value already provided', async () => {
1391
+ const prompted: string[] = [];
1392
+ let handlerArgs: any;
1393
+ const provider: PromptProvider = {
1394
+ prompt: async (option) => {
1395
+ prompted.push(option.name);
1396
+ return 'prompted-value';
1397
+ },
1398
+ };
1399
+
1400
+ const app = cli('test', {
1401
+ handler: (args) => {
1402
+ handlerArgs = args;
1403
+ },
1404
+ })
1405
+ .option('name', { type: 'string', prompt: true })
1406
+ .withPromptProvider(provider);
1407
+
1408
+ await app.forge(['--name', 'cli-value']);
1409
+ expect(prompted).toContain('name');
1410
+ expect(handlerArgs.name).toBe('prompted-value');
1411
+ });
1412
+
1413
+ it('should not prompt when prompt is false even if required', async () => {
1414
+ const prompted: string[] = [];
1415
+ const provider: PromptProvider = {
1416
+ prompt: async (option) => {
1417
+ prompted.push(option.name);
1418
+ return 'value';
1419
+ },
1420
+ };
1421
+
1422
+ const app = cli('test', {
1423
+ handler: () => {},
1424
+ })
1425
+ .option('name', { type: 'string', required: true, prompt: false })
1426
+ .withPromptProvider(provider);
1427
+
1428
+ // This will throw due to required validation, but should not prompt
1429
+ await expect(app.forge([])).rejects.toThrow();
1430
+ expect(prompted).not.toContain('name');
1431
+ });
1432
+
1433
+ it('should use prompt callback to resolve config', async () => {
1434
+ const prompted: string[] = [];
1435
+ const provider: PromptProvider = {
1436
+ prompt: async (option) => {
1437
+ prompted.push(option.name);
1438
+ return 'value';
1439
+ },
1440
+ };
1441
+
1442
+ const app = cli('test', {
1443
+ handler: () => {},
1444
+ })
1445
+ .option('token', {
1446
+ type: 'string',
1447
+ prompt: (args: any) => (args.authFile ? false : 'Enter token'),
1448
+ })
1449
+ .withPromptProvider(provider);
1450
+
1451
+ await app.forge([]);
1452
+ expect(prompted).toContain('token');
1453
+ });
1454
+
1455
+ it('should throw when prompting needed but no provider registered', async () => {
1456
+ const app = cli('test', {
1457
+ handler: () => {},
1458
+ })
1459
+ .option('name', { type: 'string', prompt: true });
1460
+
1461
+ await expect(app.forge([])).rejects.toThrow(/no prompt provider/i);
1462
+ });
1463
+
1464
+ it('should use filtered providers before fallback providers', async () => {
1465
+ const calls: Array<{ provider: string; option: string }> = [];
1466
+ const filteredProvider: PromptProvider = {
1467
+ filter: (name) => name === 'secret',
1468
+ prompt: async (option) => {
1469
+ calls.push({ provider: 'filtered', option: option.name });
1470
+ return 'secret-value';
1471
+ },
1472
+ };
1473
+ const fallbackProvider: PromptProvider = {
1474
+ prompt: async (option) => {
1475
+ calls.push({ provider: 'fallback', option: option.name });
1476
+ return 'fallback-value';
1477
+ },
1478
+ };
1479
+
1480
+ const app = cli('test', {
1481
+ handler: () => {},
1482
+ })
1483
+ .option('name', { type: 'string', prompt: true })
1484
+ .option('secret', { type: 'string', prompt: true })
1485
+ .withPromptProvider(filteredProvider)
1486
+ .withPromptProvider(fallbackProvider);
1487
+
1488
+ await app.forge([]);
1489
+ expect(calls).toContainEqual({ provider: 'filtered', option: 'secret' });
1490
+ expect(calls).toContainEqual({ provider: 'fallback', option: 'name' });
1491
+ });
1492
+
1493
+ it('should prefer promptBatch over prompt when available', async () => {
1494
+ let batchCalled = false;
1495
+ const provider: PromptProvider = {
1496
+ promptBatch: async (options) => {
1497
+ batchCalled = true;
1498
+ const result: Record<string, unknown> = {};
1499
+ for (const opt of options) {
1500
+ result[opt.name] = 'batch-value';
1501
+ }
1502
+ return result;
1503
+ },
1504
+ prompt: async () => {
1505
+ throw new Error('Should not be called when promptBatch exists');
1506
+ },
1507
+ };
1508
+
1509
+ const app = cli('test', {
1510
+ handler: () => {},
1511
+ })
1512
+ .option('a', { type: 'string', prompt: true })
1513
+ .option('b', { type: 'string', prompt: true })
1514
+ .withPromptProvider(provider);
1515
+
1516
+ await app.forge([]);
1517
+ expect(batchCalled).toBe(true);
1518
+ });
1519
+
1520
+ it('should prompt, inject values, and pass validation', async () => {
1521
+ let handlerArgs: any;
1522
+ const provider: PromptProvider = {
1523
+ promptBatch: async (options) => {
1524
+ const results: Record<string, unknown> = {};
1525
+ for (const opt of options) {
1526
+ if (opt.config.type === 'number') {
1527
+ results[opt.name] = 42;
1528
+ } else {
1529
+ results[opt.name] = 'prompted-' + opt.name;
1530
+ }
1531
+ }
1532
+ return results;
1533
+ },
1534
+ };
1535
+
1536
+ const app = cli('test', {
1537
+ handler: (args) => {
1538
+ handlerArgs = args;
1539
+ },
1540
+ })
1541
+ .option('name', { type: 'string', required: true })
1542
+ .option('port', { type: 'number', prompt: 'Which port?' })
1543
+ .option('verbose', { type: 'boolean', default: false })
1544
+ .withPromptProvider(provider);
1545
+
1546
+ await app.forge([]);
1547
+
1548
+ expect(handlerArgs.name).toBe('prompted-name');
1549
+ expect(handlerArgs.port).toBe(42);
1550
+ expect(handlerArgs.verbose).toBe(false); // default, not prompted
1551
+ });
1552
+ });
1253
1553
  });
@@ -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
  }