cli-forge 1.1.0 → 1.2.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.
@@ -2,6 +2,8 @@
2
2
  import {
3
3
  ArgvParser,
4
4
  EnvOptionConfig,
5
+ LocalizationDictionary,
6
+ LocalizationFunction,
5
7
  OptionConfig,
6
8
  ParsedArgs,
7
9
  ValidationFailedError,
@@ -9,7 +11,8 @@ import {
9
11
  hideBin,
10
12
  type ConfigurationFiles,
11
13
  } from '@cli-forge/parser';
12
- import { getCallingFile, getParentPackageJson } from './utils';
14
+ import { readOptionGroupsForCLI } from './cli-option-groups';
15
+ import { formatHelp } from './format-help';
13
16
  import { INTERACTIVE_SHELL, InteractiveShell } from './interactive-shell';
14
17
  import {
15
18
  CLI,
@@ -19,8 +22,7 @@ import {
19
22
  ErrorHandler,
20
23
  SDKCommand,
21
24
  } from './public-api';
22
- import { readOptionGroupsForCLI } from './cli-option-groups';
23
- import { formatHelp } from './format-help';
25
+ import { getCallingFile, getParentPackageJson } from './utils';
24
26
 
25
27
  /**
26
28
  * The base class for a CLI application. This class is used to define the structure of the CLI.
@@ -83,7 +85,13 @@ export class InternalCLI<
83
85
  },
84
86
  ];
85
87
 
86
- private registeredMiddleware: Array<(args: TArgs) => void> = [];
88
+ private registeredMiddleware = new Set<
89
+ (args: TArgs) => void | unknown | Promise<void> | Promise<unknown>
90
+ >();
91
+
92
+ private registeredInitHooks: Array<
93
+ (cli: any, args: TArgs) => Promise<void> | void
94
+ > = [];
87
95
 
88
96
  /**
89
97
  * A list of option groups that have been registered with the CLI. Grouped Options are displayed together in the help text.
@@ -167,7 +175,7 @@ export class InternalCLI<
167
175
  configuration: CLICommandOptions<TArgs, TRootCommandArgs>
168
176
  ): InternalCLI<TArgs, THandlerReturn, TChildren, TParent> {
169
177
  this.configuration = configuration;
170
- this.requiresCommand = false;
178
+ this.requiresCommand = configuration.handler ? false : 'IMPLICIT';
171
179
  return this;
172
180
  }
173
181
 
@@ -266,18 +274,18 @@ export class InternalCLI<
266
274
  key
267
275
  ).withRootCommandConfiguration(options as any);
268
276
  cmd._parent = this;
269
-
277
+
270
278
  // Get localized command name
271
279
  const localizedKey = this.getLocalizedCommandName(key);
272
-
280
+
273
281
  // Register under the default key
274
282
  this.registeredCommands[key] = cmd;
275
-
283
+
276
284
  // If localized name is different, also register under localized name as an alias
277
285
  if (localizedKey !== key) {
278
286
  this.registeredCommands[localizedKey] = cmd;
279
287
  }
280
-
288
+
281
289
  if (options.alias) {
282
290
  for (const alias of options.alias) {
283
291
  this.registeredCommands[alias] = cmd;
@@ -285,6 +293,10 @@ export class InternalCLI<
285
293
  }
286
294
  } else if (keyOrCommand instanceof InternalCLI) {
287
295
  const cmd = keyOrCommand;
296
+ if (cmd.name === '$0') {
297
+ this.withRootCommandConfiguration(cmd.configuration as any);
298
+ return this as any;
299
+ }
288
300
  cmd._parent = this;
289
301
  this.registeredCommands[cmd.name] = cmd;
290
302
  if (cmd.configuration?.alias) {
@@ -304,17 +316,7 @@ export class InternalCLI<
304
316
  commands(...a0: Command[] | Command[][]): any {
305
317
  const commands = a0.flat();
306
318
  for (const val of commands) {
307
- if (val instanceof InternalCLI) {
308
- val._parent = this;
309
- this.registeredCommands[val.name] = val;
310
- // Include any options that were defined via cli(...).option() instead of via builder
311
- this.parser.augment(val.parser);
312
- } else {
313
- const { name, ...configuration } = val as {
314
- name: string;
315
- } & CLICommandOptions<any, any>;
316
- this.command(name, configuration);
317
- }
319
+ this.command(val);
318
320
  }
319
321
  return this;
320
322
  }
@@ -367,9 +369,7 @@ export class InternalCLI<
367
369
  }
368
370
 
369
371
  localize(
370
- dictionaryOrFn:
371
- | import('@cli-forge/parser').LocalizationDictionary
372
- | import('@cli-forge/parser').LocalizationFunction,
372
+ dictionaryOrFn: LocalizationDictionary | LocalizationFunction,
373
373
  locale?: string
374
374
  ): CLI<TArgs, THandlerReturn, TChildren, TParent> {
375
375
  if (typeof dictionaryOrFn === 'function') {
@@ -435,34 +435,48 @@ export class InternalCLI<
435
435
  }
436
436
 
437
437
  middleware<TArgs2>(
438
- callback: (args: TArgs) => TArgs2 | Promise<TArgs2>
438
+ callback: (args: TArgs) => TArgs2 | Promise<TArgs2> | void | Promise<void>
439
439
  ): CLI<
440
440
  TArgs2 extends void ? TArgs : TArgs & TArgs2,
441
441
  THandlerReturn,
442
442
  TChildren,
443
443
  TParent
444
444
  > {
445
- this.registeredMiddleware.push(callback);
445
+ this.registeredMiddleware.add(callback);
446
446
  // If middleware returns void, TArgs doesn't change...
447
447
  // If it returns something, we need to merge it into TArgs...
448
448
  // that's not here though, its where we apply the middleware results.
449
449
  return this as any;
450
450
  }
451
451
 
452
+ init(
453
+ callback: (
454
+ cli: CLI<TArgs, THandlerReturn, TChildren, TParent>,
455
+ args: TArgs
456
+ ) => Promise<void> | void
457
+ ): CLI<TArgs, THandlerReturn, TChildren, TParent> {
458
+ this.registeredInitHooks.push(callback);
459
+ return this as unknown as CLI<TArgs, THandlerReturn, TChildren, TParent>;
460
+ }
461
+
452
462
  /**
453
463
  * Runs the current command.
454
464
  * @param cmd The command to run.
455
465
  * @param args The arguments to pass to the command.
456
466
  */
457
- async runCommand<T extends ParsedArgs>(args: T, originalArgV: string[]) {
458
- const middlewares: Array<(args: any) => void> = [
459
- ...this.registeredMiddleware,
460
- ];
467
+ async runCommand<T extends ParsedArgs>(
468
+ args: T,
469
+ originalArgV: string[],
470
+ executedMiddleware?: Set<(args: any) => void>
471
+ ): Promise<T> {
472
+ const middlewares = new Set<(args: any) => void>(this.registeredMiddleware);
461
473
  // eslint-disable-next-line @typescript-eslint/no-this-alias
462
474
  let cmd: InternalCLI<any, any, any, any> = this;
463
475
  for (const command of this.commandChain) {
464
476
  cmd = cmd.registeredCommands[command];
465
- middlewares.push(...cmd.registeredMiddleware);
477
+ for (const mw of cmd.registeredMiddleware) {
478
+ middlewares.add(mw);
479
+ }
466
480
  }
467
481
  try {
468
482
  if (cmd.requiresCommand) {
@@ -472,7 +486,8 @@ export class InternalCLI<
472
486
  }
473
487
  if (cmd.configuration?.handler) {
474
488
  for (const middleware of middlewares) {
475
- const middlewareResult = await middleware(args);
489
+ if (executedMiddleware?.has(middleware)) continue;
490
+ const middlewareResult = await middleware(args as any);
476
491
  if (
477
492
  middlewareResult !== void 0 &&
478
493
  typeof middlewareResult === 'object'
@@ -480,15 +495,25 @@ export class InternalCLI<
480
495
  args = middlewareResult as T;
481
496
  }
482
497
  }
483
- return cmd.configuration.handler(args, {
498
+ await cmd.configuration.handler(args, {
484
499
  command: cmd as any,
485
500
  });
501
+ return args;
486
502
  } else {
487
503
  // We can treat a command as a subshell if it has subcommands
488
504
  if (Object.keys(cmd.registeredCommands).length > 0) {
489
505
  if (!process.stdout.isTTY) {
490
506
  // If we're not in a TTY, we can't run an interactive shell...
491
507
  // Maybe we should warn here?
508
+ } else if (args.unmatched.length > 0) {
509
+ // If there are unmatched args, we don't run an interactive shell...
510
+ // this could represent a user misspelling a subcommand so it gets rather confusing.
511
+ console.warn(
512
+ `Warning: Unrecognized command or arguments: ${args.unmatched.join(
513
+ ' '
514
+ )}`
515
+ );
516
+ cmd.printHelp();
492
517
  } else if (!INTERACTIVE_SHELL) {
493
518
  const tui = new InteractiveShell(
494
519
  this as unknown as InternalCLI<any>,
@@ -519,6 +544,7 @@ export class InternalCLI<
519
544
  console.error(e);
520
545
  this.printHelp();
521
546
  }
547
+ return args;
522
548
  }
523
549
 
524
550
  getChildren(): TChildren {
@@ -692,7 +718,13 @@ export class InternalCLI<
692
718
  chain.unshift(current);
693
719
  current = current._parent;
694
720
  }
695
- return chain.flatMap((c) => c.registeredMiddleware);
721
+ const seen = new Set<(args: any) => unknown | Promise<unknown>>();
722
+ for (const c of chain) {
723
+ for (const mw of c.registeredMiddleware) {
724
+ seen.add(mw);
725
+ }
726
+ }
727
+ return [...seen];
696
728
  }
697
729
 
698
730
  enableInteractiveShell(): CLI<TArgs, THandlerReturn, TChildren, TParent> {
@@ -785,32 +817,128 @@ export class InternalCLI<
785
817
  }
786
818
 
787
819
  /**
788
- * Parses argv and executes the CLI
820
+ * Parses argv and executes the CLI.
821
+ *
822
+ * Execution proceeds in two phases:
823
+ *
824
+ * **Discovery loop** (per command level): builder → parse (non-strict,
825
+ * no validation) → merge → middleware → init hooks → find next
826
+ * subcommand in unmatched tokens → repeat.
827
+ *
828
+ * **Final parse + execution**: parse (with validation, seeded with
829
+ * accumulated args) → help/version check → handler. Middleware that
830
+ * already ran during discovery is skipped.
831
+ *
789
832
  * @param args argv. Defaults to process.argv.slice(2)
790
833
  * @returns Promise that resolves when the handler completes.
791
834
  */
792
835
  forge = (args: string[] = hideBin(process.argv)) =>
793
836
  this.withErrorHandlers(async () => {
794
- // Parsing the args does two things:
795
- // - builds argv to pass to handler
796
- // - fills the command chain + registers commands
797
837
  let argv: TArgs & { help?: boolean; version?: boolean };
798
838
  let validationFailedError: ValidationFailedError<TArgs> | undefined;
839
+
840
+ // Run root builder (may register options, init hooks, commands)
841
+ this.configuration?.builder?.(this as any);
842
+
843
+ // Merge helper: accumulate defined values without overwriting
844
+ const mergeNew = (target: any, source: any) => {
845
+ for (const [key, value] of Object.entries(source)) {
846
+ if (key !== 'unmatched' && value !== undefined && target[key] === undefined) {
847
+ target[key] = value;
848
+ }
849
+ }
850
+ };
851
+
852
+ // Iterative command discovery with init hooks.
853
+ // Each level: non-strict parse filtered args → run middleware
854
+ // → run init hooks → find next command in unmatched tokens
855
+ // → filter down. Builders stay lazy.
856
+ let currentArgs = [...args];
857
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
858
+ let currentCmd: InternalCLI<any, any, any, any> = this;
859
+ const mergedArgs: any = {};
860
+ const executedMiddleware = new Set<(args: any) => void>();
861
+
862
+ // eslint-disable-next-line no-constant-condition
863
+ while (true) {
864
+ // Non-strict parse to get current arg values for init hooks.
865
+ // Seeded with mergedArgs so required options parsed at earlier
866
+ // levels satisfy validation.
867
+ const parsed = this.parser
868
+ .clone({
869
+ ...this.parser.options,
870
+ unmatchedParser: () => false,
871
+ strict: false,
872
+ validate: false,
873
+ })
874
+ .parse(currentArgs, mergedArgs) as any;
875
+
876
+ mergeNew(mergedArgs, parsed);
877
+
878
+ // Run middleware for this command level before init hooks,
879
+ // so init hooks can see middleware-transformed args.
880
+ for (const mw of currentCmd.registeredMiddleware) {
881
+ if (!executedMiddleware.has(mw)) {
882
+ executedMiddleware.add(mw);
883
+ const result = await mw(mergedArgs);
884
+ if (result !== void 0 && typeof result === 'object') {
885
+ Object.assign(mergedArgs, result);
886
+ }
887
+ }
888
+ }
889
+
890
+ // Run init hooks with the full accumulated args
891
+ for (const hook of currentCmd.registeredInitHooks) {
892
+ await hook(currentCmd as any, mergedArgs);
893
+ }
894
+
895
+ // Find the next command in unmatched tokens
896
+ const unmatched: string[] = parsed.unmatched ?? [];
897
+ let nextCmd: InternalCLI<any, any, any, any> | null = null;
898
+ const remainingArgs: string[] = [];
899
+
900
+ for (const token of unmatched) {
901
+ if (!nextCmd && !token.startsWith('-')) {
902
+ const cmd = currentCmd.registeredCommands[token];
903
+ if (cmd && cmd.configuration) {
904
+ cmd.parser = this.parser;
905
+ cmd.configuration.builder?.(cmd as any);
906
+ this.commandChain.push(token);
907
+ nextCmd = cmd;
908
+ continue;
909
+ }
910
+ }
911
+ remainingArgs.push(token);
912
+ }
913
+
914
+ if (!nextCmd) {
915
+ currentArgs = unmatched;
916
+ break;
917
+ }
918
+ currentArgs = remainingArgs;
919
+ currentCmd = nextCmd;
920
+ }
921
+
922
+ // All builders and init hooks have run. The parser now has
923
+ // all options registered. Parse the remaining unmatched tokens
924
+ // seeded with the accumulated values from the discovery loop.
925
+ // The alreadyParsed values ensure proper required-option
926
+ // validation and prevent positional re-consumption.
799
927
  try {
800
- argv = this.parser.parse(args);
928
+ argv = this.parser
929
+ .clone({
930
+ ...this.parser.options,
931
+ unmatchedParser: () => false,
932
+ })
933
+ .parse(currentArgs, mergedArgs) as any;
801
934
  } catch (e) {
802
935
  if (e instanceof ValidationFailedError) {
803
- argv = e.partialArgV as TArgs;
936
+ argv = e.partialArgV as any;
804
937
  validationFailedError = e;
805
938
  } else {
806
939
  throw e;
807
940
  }
808
941
  }
809
- // eslint-disable-next-line @typescript-eslint/no-this-alias
810
- let currentCommand: InternalCLI<any, any, any, any> = this;
811
- for (const command of this.commandChain) {
812
- currentCommand = currentCommand.registeredCommands[command];
813
- }
814
942
 
815
943
  if (argv.version) {
816
944
  this.versionHandler();
@@ -824,16 +952,7 @@ export class InternalCLI<
824
952
  throw validationFailedError;
825
953
  }
826
954
 
827
- const finalArgV =
828
- this.commandChain.length === 0 && this.configuration?.builder
829
- ? (
830
- this.configuration.builder?.(
831
- this as any
832
- ) as unknown as InternalCLI<TArgs, any, any, any>
833
- ).parser.parse(args)
834
- : argv;
835
-
836
- await this.runCommand(finalArgV, args);
955
+ const finalArgV = await this.runCommand(argv, args, executedMiddleware);
837
956
  return finalArgV as TArgs;
838
957
  });
839
958
 
@@ -1,20 +1,20 @@
1
1
  /* eslint-disable @typescript-eslint/ban-types */
2
2
  import {
3
+ ArrayOptionConfig,
4
+ BooleanOptionConfig,
3
5
  type ConfigurationFiles,
6
+ EnvOptionConfig,
7
+ LocalizationDictionary,
8
+ LocalizationFunction,
9
+ MakeUndefinedPropertiesOptional,
10
+ NumberOptionConfig,
11
+ ObjectOptionConfig,
4
12
  OptionConfig,
5
13
  OptionConfigToType,
6
14
  ParsedArgs,
7
- EnvOptionConfig,
8
- ObjectOptionConfig,
9
- StringOptionConfig,
10
- NumberOptionConfig,
11
- BooleanOptionConfig,
12
- ArrayOptionConfig,
13
15
  ResolveProperties,
16
+ StringOptionConfig,
14
17
  WithOptional,
15
- MakeUndefinedPropertiesOptional,
16
- LocalizationDictionary,
17
- LocalizationFunction,
18
18
  } from '@cli-forge/parser';
19
19
 
20
20
  import { InternalCLI } from './internal-cli';
@@ -781,6 +781,21 @@ export interface CLI<
781
781
  TParent
782
782
  >;
783
783
 
784
+ /**
785
+ * Registers an init hook that runs before command resolution.
786
+ * Init hooks receive partially-parsed args (from currently-registered options)
787
+ * and can modify the CLI (register commands, options, middleware) before the
788
+ * full parse runs. This enables plugin loading from config files.
789
+ *
790
+ * @param callback Async function receiving (args, cli). Mutate cli to add commands/options.
791
+ */
792
+ init(
793
+ callback: (
794
+ cli: CLI<TArgs, THandlerReturn, TChildren, TParent>,
795
+ args: TArgs
796
+ ) => Promise<void> | void
797
+ ): CLI<TArgs, THandlerReturn, TChildren, TParent>;
798
+
784
799
  /**
785
800
  * Parses argv and executes the CLI
786
801
  * @param args argv. Defaults to process.argv.slice(2)
@@ -909,7 +924,7 @@ export interface CLICommandOptions<
909
924
  * The type of the arguments that are registered after `builder` is invoked, and the type that is passed to the handler.
910
925
  */
911
926
  TArgs extends TInitial = TInitial,
912
- THandlerReturn = void,
927
+ THandlerReturn = void | Promise<void>,
913
928
  /**
914
929
  * The children commands that exist before the builder runs.
915
930
  */