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
@@ -7,8 +7,8 @@ import { pathToFileURL } from 'node:url';
7
7
 
8
8
  import cli, { ArgumentsOf, CLI } from '../..';
9
9
  import { Documentation, generateDocumentation } from '../../lib/documentation';
10
- import { ensureDirSync } from '../utils/fs';
11
10
  import { InternalCLI } from '../../lib/internal-cli';
11
+ import { ensureDirSync } from '../utils/fs';
12
12
 
13
13
  type mdfactory = typeof import('markdown-factory');
14
14
 
@@ -68,6 +68,10 @@ export const generateDocumentationCommand = cli('generate-documentation', {
68
68
  const documentation = generateDocumentation(cli);
69
69
  if (args.format === 'md') {
70
70
  await generateMarkdownDocumentation(documentation, args);
71
+
72
+ if (args.llms) {
73
+ generateLlmsTxt(documentation, args);
74
+ }
71
75
  } else if (args.format === 'json') {
72
76
  const outfile = args.output.endsWith('json')
73
77
  ? args.output
@@ -76,10 +80,6 @@ export const generateDocumentationCommand = cli('generate-documentation', {
76
80
  ensureDirSync(outdir);
77
81
  writeFileSync(outfile, JSON.stringify(documentation, null, 2));
78
82
  }
79
-
80
- if (args.llms) {
81
- generateLlmsTxt(documentation, args);
82
- }
83
83
  },
84
84
  });
85
85
 
@@ -116,7 +116,9 @@ function generateLlmsTxtContent(
116
116
  lines.push(docs.description);
117
117
  lines.push('');
118
118
  }
119
- lines.push('This document describes the CLI commands and options for AI agent consumption.');
119
+ lines.push(
120
+ 'This document describes the CLI commands and options for AI agent consumption.'
121
+ );
120
122
  lines.push('');
121
123
  } else {
122
124
  lines.push(`${indent}## ${fullCommand}`);
@@ -154,7 +156,9 @@ function generateLlmsTxtContent(
154
156
  for (const [, opt] of optionEntries) {
155
157
  const typeStr = formatOptionType(opt);
156
158
  const aliasStr = opt.alias?.length
157
- ? ` (aliases: ${opt.alias.map((a) => (a.length === 1 ? `-${a}` : `--${a}`)).join(', ')})`
159
+ ? ` (aliases: ${opt.alias
160
+ .map((a) => (a.length === 1 ? `-${a}` : `--${a}`))
161
+ .join(', ')})`
158
162
  : '';
159
163
  const reqStr =
160
164
  opt.required && opt.default === undefined ? ' [required]' : '';
@@ -184,7 +188,9 @@ function generateLlmsTxtContent(
184
188
  for (const opt of group.keys) {
185
189
  const typeStr = formatOptionType(opt);
186
190
  const aliasStr = opt.alias?.length
187
- ? ` (aliases: ${opt.alias.map((a) => (a.length === 1 ? `-${a}` : `--${a}`)).join(', ')})`
191
+ ? ` (aliases: ${opt.alias
192
+ .map((a) => (a.length === 1 ? `-${a}` : `--${a}`))
193
+ .join(', ')})`
188
194
  : '';
189
195
  const reqStr =
190
196
  opt.required && opt.default === undefined ? ' [required]' : '';
@@ -200,6 +206,16 @@ function generateLlmsTxtContent(
200
206
  }
201
207
  }
202
208
 
209
+ // Configuration sources
210
+ if (docs.configurationSources && docs.configurationSources.length > 0) {
211
+ lines.push(`${indent}Configuration:`);
212
+ for (const section of docs.configurationSources) {
213
+ lines.push(`${indent} ${section.heading}`);
214
+ lines.push(`${indent} ${section.body}`);
215
+ }
216
+ lines.push('');
217
+ }
218
+
203
219
  // Examples
204
220
  if (docs.examples.length > 0) {
205
221
  lines.push(`${indent}Examples:`);
@@ -214,7 +230,9 @@ function generateLlmsTxtContent(
214
230
  lines.push(`${indent}Subcommands:`);
215
231
  for (const sub of docs.subcommands) {
216
232
  lines.push(
217
- `${indent} ${sub.name}${sub.description ? ` - ${sub.description}` : ''}`
233
+ `${indent} ${sub.name}${
234
+ sub.description ? ` - ${sub.description}` : ''
235
+ }`
218
236
  );
219
237
  }
220
238
  lines.push('');
@@ -269,12 +287,19 @@ async function generateMarkdownForSingleCommand(
269
287
  md
270
288
  )
271
289
  ),
290
+ getConfigurationSourcesLink(
291
+ docs.configurationSources,
292
+ outdir,
293
+ docsRoot,
294
+ md
295
+ ),
272
296
  getSubcommandsFragment(docs.subcommands, outdir, docsRoot, md),
273
297
  getExamplesFragment(docs.examples, md),
274
298
  getEpilogueFragment(docs.epilogue, md),
275
299
  ].filter(isTruthy)
276
300
  )
277
301
  );
302
+ writeConfigurationSourcesFile(docs.configurationSources, outdir, md);
278
303
  for (const subcommand of docs.subcommands) {
279
304
  await generateMarkdownForSingleCommand(
280
305
  subcommand,
@@ -347,6 +372,42 @@ function getFlagArgsFragment(
347
372
  );
348
373
  }
349
374
 
375
+ function getConfigurationSourcesLink(
376
+ sources: Documentation['configurationSources'],
377
+ outdir: string,
378
+ docsRoot: string,
379
+ md: mdfactory
380
+ ) {
381
+ if (!sources || sources.length === 0) {
382
+ return undefined;
383
+ }
384
+ const linkPath =
385
+ './' +
386
+ joinPathFragments(
387
+ normalize(relative(docsRoot, outdir)),
388
+ 'configuration.md'
389
+ );
390
+ return md.h2('Configuration', md.link(linkPath, 'Configuration'));
391
+ }
392
+
393
+ function writeConfigurationSourcesFile(
394
+ sources: Documentation['configurationSources'],
395
+ outdir: string,
396
+ md: mdfactory
397
+ ) {
398
+ if (!sources || sources.length === 0) {
399
+ return;
400
+ }
401
+ ensureDirSync(outdir);
402
+ writeFileSync(
403
+ join(outdir, 'configuration.md'),
404
+ md.h1(
405
+ 'Configuration',
406
+ ...sources.map((section) => md.h2(section.heading, section.body))
407
+ )
408
+ );
409
+ }
410
+
350
411
  function getSubcommandsFragment(
351
412
  subcommands: Documentation['subcommands'],
352
413
  outdir: string,
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,5 +1,13 @@
1
1
  import { ConfigurationFiles } from '@cli-forge/parser';
2
2
 
3
+ let md: typeof import('markdown-factory') | undefined;
4
+ try {
5
+ // markdown-factory is an optional peer dependency
6
+ md = require('markdown-factory');
7
+ } catch {
8
+ // not available
9
+ }
10
+
3
11
  /**
4
12
  * A collection of built-in configuration provider factories. These should be invoked and passed to
5
13
  * {@link CLI.config} to load configuration from various sources. For custom configuration providers, see
@@ -25,12 +33,53 @@ export const ConfigurationProviders = {
25
33
  /**
26
34
  * Load configuration from a JSON file.
27
35
  *
28
- * @param filename The filename of the JSON file to load.
36
+ * @param filename The filename (or array of possible filenames) of the JSON file to load.
37
+ * When an array is provided, the nearest matching file wins.
29
38
  * @param key The key in the JSON file to load as configuration. By default, the entire JSON object is loaded.
30
39
  */
31
- JsonFile<T>(filename: string, key?: string) {
32
- return ConfigurationFiles.getJsonFileConfigLoader<T>(filename, (json) =>
33
- key ? json[key] : json
40
+ JsonFile<T>(filename: string | string[], key?: string) {
41
+ const loader = ConfigurationFiles.getJsonFileConfigLoader<T>(
42
+ filename,
43
+ key ? (json) => json[key] : undefined,
44
+ key ? (json, config) => ({ ...json, [key]: config }) : undefined
34
45
  );
46
+ if (key) {
47
+ const filenames = Array.isArray(filename) ? filename : [filename];
48
+ const fileList = filenames.join(', ');
49
+ loader.describeConfig = () => {
50
+ const heading = `JSON File: ${fileList} (key: "${key}")`;
51
+ if (md) {
52
+ return {
53
+ heading,
54
+ body: md.lines(
55
+ filenames.length > 1
56
+ ? `Searches for one of: ${filenames.map((f) => md.code(f)).join(', ')}`
57
+ : `Searches for ${md.code(filenames[0])}`,
58
+ 'Resolution walks up the directory tree from the working directory, using the nearest match.',
59
+ `Reads the ${md.code(`"${key}"`)} key from the JSON file.`,
60
+ `Supports ${md.code('"extends"')} for configuration inheritance.`,
61
+ '',
62
+ md.bold('Example:'),
63
+ md.codeBlock(
64
+ JSON.stringify({ [key]: { option: 'value' } }, null, 2),
65
+ 'json'
66
+ )
67
+ ),
68
+ };
69
+ }
70
+ return {
71
+ heading,
72
+ body: [
73
+ filenames.length > 1
74
+ ? `Searches for one of: ${fileList}`
75
+ : `Searches for ${filenames[0]}`,
76
+ 'Resolution walks up the directory tree from the working directory, using the nearest match.',
77
+ `Reads the "${key}" key from the JSON file.`,
78
+ 'Supports "extends" for configuration inheritance.',
79
+ ].join('\n\n'),
80
+ };
81
+ };
82
+ }
83
+ return loader;
35
84
  },
36
85
  };
@@ -3,6 +3,7 @@ import {
3
3
  OptionConfigToType,
4
4
  readDefaultValue,
5
5
  LocalizationDictionary,
6
+ ConfigurationFiles,
6
7
  } from '@cli-forge/parser';
7
8
  import { InternalCLI } from './internal-cli';
8
9
  import { CLI } from './public-api';
@@ -20,6 +21,11 @@ export type Documentation = {
20
21
  keys: Array<NormalizedOptionConfig>;
21
22
  }>;
22
23
  subcommands: Documentation[];
24
+ /**
25
+ * Describes how configuration is loaded for this command.
26
+ * Each section is produced by a provider's `describeConfig` method.
27
+ */
28
+ configurationSources?: ConfigurationFiles.ConfigurationDocSection[];
23
29
  /**
24
30
  * Localized keys for options and commands. Maps from default key to full localization entry.
25
31
  * Only present if localization is configured.
@@ -146,6 +152,11 @@ export function generateDocumentation(
146
152
  subcommands,
147
153
  };
148
154
 
155
+ const configDocs = parser.getConfigurationDocs();
156
+ if (configDocs.length > 0) {
157
+ result.configurationSources = configDocs;
158
+ }
159
+
149
160
  if (localizedKeys) {
150
161
  result.localizedKeys = localizedKeys;
151
162
  }
@@ -76,6 +76,16 @@ export function formatHelp(parentCLI: InternalCLI<any>): string {
76
76
  }
77
77
  }
78
78
 
79
+ const configDocs = command.parser.getConfigurationDocs();
80
+ if (configDocs.length > 0) {
81
+ help.push('');
82
+ help.push('Configuration:');
83
+ for (const section of configDocs) {
84
+ help.push(` ${section.heading}`);
85
+ help.push(` ${section.body}`);
86
+ }
87
+ }
88
+
79
89
  if (Object.keys(command.registeredCommands).length > 0) {
80
90
  help.push(' ');
81
91
  help.push(
@@ -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
  });