cli-forge 0.12.0 → 1.0.1

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.
@@ -17,6 +17,7 @@ import {
17
17
  CLIHandlerContext,
18
18
  Command,
19
19
  ErrorHandler,
20
+ SDKCommand,
20
21
  } from './public-api';
21
22
  import { readOptionGroupsForCLI } from './cli-option-groups';
22
23
  import { formatHelp } from './format-help';
@@ -170,22 +171,19 @@ export class InternalCLI<
170
171
  return this;
171
172
  }
172
173
 
173
- command<TCommandArgs extends TArgs>(
174
- cmd: Command<TArgs, TCommandArgs>
174
+ command<TCommandArgs extends TArgs, TCmdName extends string>(
175
+ cmd: Command<TArgs, TCommandArgs, TCmdName>
175
176
  ): CLI<
176
177
  TArgs,
177
178
  THandlerReturn,
178
- TChildren &
179
- (typeof cmd extends Command<TArgs, infer TCmdArgs, infer TCmdName>
180
- ? {
181
- [key in TCmdName]: CLI<
182
- TCmdArgs,
183
- void,
184
- {},
185
- CLI<TArgs, THandlerReturn, TChildren, TParent>
186
- >;
187
- }
188
- : {}),
179
+ TChildren & {
180
+ [key in TCmdName]: CLI<
181
+ TCommandArgs,
182
+ void,
183
+ {},
184
+ CLI<TArgs, THandlerReturn, TChildren, TParent>
185
+ >;
186
+ },
189
187
  TParent
190
188
  >;
191
189
  command<
@@ -308,7 +306,7 @@ export class InternalCLI<
308
306
 
309
307
  option<
310
308
  TOption extends string,
311
- const TOptionConfig extends OptionConfig<any, any, any, any>
309
+ const TOptionConfig extends OptionConfig<any, any, any>
312
310
  >(name: TOption, config: TOptionConfig) {
313
311
  this.parser.option(name, config);
314
312
  // Interface modifies the return type to reflect new params, cast is necessay.... I think 🤔
@@ -317,7 +315,7 @@ export class InternalCLI<
317
315
 
318
316
  positional<
319
317
  TOption extends string,
320
- const TOptionConfig extends OptionConfig<any, any, any, any>
318
+ const TOptionConfig extends OptionConfig<any, any, any>
321
319
  >(name: TOption, config: TOptionConfig) {
322
320
  this.parser.positional(name, config);
323
321
  // Interface modifies the return type to reflect new params, cast is necessay.... I think 🤔
@@ -497,13 +495,15 @@ export class InternalCLI<
497
495
  return this._parent as TParent;
498
496
  }
499
497
 
500
- getBuilder<T extends ParsedArgs = { unmatched: string[]; '--'?: string[] }>(
501
- // eslint-disable-next-line @typescript-eslint/no-unused-vars -- This is used to help TS infer T correctly
502
- _?: CLI<T, any, any> | undefined
503
- ):
504
- | ((parser: CLI<T, any, any>) => CLI<TArgs, THandlerReturn, TChildren>)
498
+ getBuilder():
499
+ | (<TInit extends ParsedArgs, TInitHandlerReturn, TInitChildren, TInitParent>(
500
+ parser: CLI<TInit, TInitHandlerReturn, TInitChildren, TInitParent>
501
+ ) => CLI<TInit & TArgs, TInitHandlerReturn, TInitChildren & TChildren, TInitParent>)
505
502
  | undefined {
506
- return this.configuration?.builder as any;
503
+ const builder = this.configuration?.builder;
504
+ if (!builder) return undefined;
505
+ // Return a composable builder that preserves input types
506
+ return ((parser: CLI<any, any, any, any>) => builder(parser)) as any;
507
507
  }
508
508
 
509
509
  getHandler():
@@ -523,6 +523,125 @@ export class InternalCLI<
523
523
  ) as THandlerReturn;
524
524
  }
525
525
 
526
+ sdk(): SDKCommand<TArgs, THandlerReturn, TChildren> {
527
+ return this.buildSDKProxy(this) as SDKCommand<
528
+ TArgs,
529
+ THandlerReturn,
530
+ TChildren
531
+ >;
532
+ }
533
+
534
+ private buildSDKProxy(targetCmd: InternalCLI<any, any, any, any>): unknown {
535
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
536
+ const self = this;
537
+
538
+ const invoke = async (
539
+ argsOrArgv?: Record<string, unknown> | string[]
540
+ ): Promise<THandlerReturn & { $args?: TArgs }> => {
541
+ // Clone the target command to avoid mutating the original
542
+ const cmd = targetCmd.clone();
543
+
544
+ const handler = cmd._configuration?.handler;
545
+ if (!handler) {
546
+ throw new Error(`Command '${cmd.name}' has no handler`);
547
+ }
548
+
549
+ let parsedArgs: any;
550
+
551
+ if (Array.isArray(argsOrArgv)) {
552
+ // String array: full pipeline (parse → validate → middleware)
553
+ // Run the builder first if present
554
+ if (cmd._configuration?.builder) {
555
+ cmd._configuration.builder(cmd as any);
556
+ }
557
+ parsedArgs = cmd.parser.parse(argsOrArgv);
558
+ } else {
559
+ // Object args: skip validation, apply defaults, run middleware
560
+ // Run the builder first to register options and get defaults
561
+ if (cmd._configuration?.builder) {
562
+ cmd._configuration.builder(cmd as any);
563
+ }
564
+ // Build defaults from configured options
565
+ const defaults: Record<string, unknown> = {};
566
+ for (const [key, config] of Object.entries(
567
+ cmd.parser.configuredOptions
568
+ )) {
569
+ if (config.default !== undefined) {
570
+ defaults[key] = config.default;
571
+ }
572
+ }
573
+ parsedArgs = {
574
+ ...defaults,
575
+ ...argsOrArgv,
576
+ unmatched: [],
577
+ };
578
+ }
579
+
580
+ // Collect and run middleware from the command chain
581
+ const middlewares = self.collectMiddlewareChain(targetCmd);
582
+ for (const mw of middlewares) {
583
+ const middlewareResult = await mw(parsedArgs);
584
+ if (
585
+ middlewareResult !== void 0 &&
586
+ typeof middlewareResult === 'object'
587
+ ) {
588
+ parsedArgs = middlewareResult;
589
+ }
590
+ }
591
+
592
+ // Execute handler
593
+ const context: CLIHandlerContext<any, any> = {
594
+ command: cmd as unknown as CLI<any, any, any, any>,
595
+ };
596
+ const result = await handler(parsedArgs, context);
597
+
598
+ // Try to attach $args to the result (fails silently for primitives)
599
+ if (result !== null && typeof result === 'object') {
600
+ try {
601
+ (result as any).$args = parsedArgs;
602
+ } catch {
603
+ // Cannot attach to frozen objects or primitives, return as-is
604
+ }
605
+ }
606
+
607
+ return result as any as THandlerReturn & { $args?: TArgs };
608
+ };
609
+
610
+ // Ensure builder has run to register all subcommands
611
+ if (targetCmd._configuration?.builder) {
612
+ targetCmd._configuration.builder(targetCmd as any);
613
+ }
614
+
615
+ // Create proxy that is both callable and has child properties
616
+ return new Proxy(invoke, {
617
+ get(_, prop: string) {
618
+ // Handle special properties
619
+ if (prop === 'then' || prop === 'catch' || prop === 'finally') {
620
+ // Don't intercept Promise methods - this prevents issues with await
621
+ return undefined;
622
+ }
623
+
624
+ const child = targetCmd.registeredCommands[prop];
625
+ if (child) {
626
+ return self.buildSDKProxy(child);
627
+ }
628
+ return undefined;
629
+ },
630
+ });
631
+ }
632
+
633
+ private collectMiddlewareChain(
634
+ cmd: InternalCLI<any, any, any, any>
635
+ ): Array<(args: any) => unknown | Promise<unknown>> {
636
+ const chain: InternalCLI<any, any, any, any>[] = [];
637
+ let current: InternalCLI<any, any, any, any> | undefined = cmd;
638
+ while (current) {
639
+ chain.unshift(current);
640
+ current = current._parent;
641
+ }
642
+ return chain.flatMap((c) => c.registeredMiddleware);
643
+ }
644
+
526
645
  enableInteractiveShell(): CLI<TArgs, THandlerReturn, TChildren, TParent> {
527
646
  if (this.requiresCommand === 'EXPLICIT') {
528
647
  throw new Error(
@@ -13,7 +13,6 @@ import {
13
13
  ResolveProperties,
14
14
  WithOptional,
15
15
  MakeUndefinedPropertiesOptional,
16
- WithAdditionalProperties,
17
16
  } from '@cli-forge/parser';
18
17
 
19
18
  import { InternalCLI } from './internal-cli';
@@ -90,22 +89,19 @@ export interface CLI<
90
89
  TChildren = {},
91
90
  TParent = undefined
92
91
  > {
93
- command<TCommandArgs extends TArgs>(
94
- cmd: Command<TArgs, TCommandArgs>
92
+ command<TCommandArgs extends TArgs, TCmdName extends string>(
93
+ cmd: Command<TArgs, TCommandArgs, TCmdName>
95
94
  ): CLI<
96
95
  TArgs,
97
96
  THandlerReturn,
98
- TChildren &
99
- (typeof cmd extends Command<TArgs, infer TCommandArgs, infer TCmdName>
100
- ? {
101
- [key in TCmdName]: CLI<
102
- TCommandArgs,
103
- void,
104
- {},
105
- CLI<TArgs, THandlerReturn, TChildren, TParent>
106
- >;
107
- }
108
- : {}),
97
+ TChildren & {
98
+ [key in TCmdName]: CLI<
99
+ TCommandArgs,
100
+ void,
101
+ {},
102
+ CLI<TArgs, THandlerReturn, TChildren, TParent>
103
+ >;
104
+ },
109
105
  TParent
110
106
  >;
111
107
 
@@ -127,7 +123,7 @@ export interface CLI<
127
123
  TArgs,
128
124
  TCommandArgs,
129
125
  TChildHandlerReturn,
130
- any,
126
+ TChildren,
131
127
  CLI<TArgs, THandlerReturn, TChildren, TParent>,
132
128
  TChildChildren
133
129
  >
@@ -436,22 +432,16 @@ export interface CLI<
436
432
  option<
437
433
  TOption extends string,
438
434
  TCoerce,
439
- const TProps extends Record<string, { type: string }>,
440
- TAdditionalProps extends false | 'string' | 'number' | 'boolean' = false
435
+ const TProps extends Record<string, { type: string }>
441
436
  >(
442
437
  name: TOption,
443
- config: ObjectOptionConfig<TCoerce, TProps, TAdditionalProps>
438
+ config: ObjectOptionConfig<TCoerce, TProps>
444
439
  ): CLI<
445
440
  TArgs &
446
441
  MakeUndefinedPropertiesOptional<{
447
442
  [key in TOption]: WithOptional<
448
- unknown extends TCoerce
449
- ? WithAdditionalProperties<
450
- ResolveProperties<TProps>,
451
- TAdditionalProps
452
- >
453
- : TCoerce,
454
- ObjectOptionConfig<TCoerce, TProps, TAdditionalProps>
443
+ unknown extends TCoerce ? ResolveProperties<TProps> : TCoerce,
444
+ ObjectOptionConfig<TCoerce, TProps>
455
445
  >;
456
446
  }>,
457
447
  THandlerReturn,
@@ -525,7 +515,7 @@ export interface CLI<
525
515
  // Generic fallback overload
526
516
  option<
527
517
  TOption extends string,
528
- const TOptionConfig extends OptionConfig<any, any, any, any>
518
+ const TOptionConfig extends OptionConfig<any, any, any>
529
519
  >(
530
520
  name: TOption,
531
521
  config: TOptionConfig
@@ -552,22 +542,16 @@ export interface CLI<
552
542
  positional<
553
543
  TOption extends string,
554
544
  TCoerce,
555
- const TProps extends Record<string, { type: string }>,
556
- TAdditionalProps extends false | 'string' | 'number' | 'boolean' = false
545
+ const TProps extends Record<string, { type: string }>
557
546
  >(
558
547
  name: TOption,
559
- config: ObjectOptionConfig<TCoerce, TProps, TAdditionalProps>
548
+ config: ObjectOptionConfig<TCoerce, TProps>
560
549
  ): CLI<
561
550
  TArgs &
562
551
  MakeUndefinedPropertiesOptional<{
563
552
  [key in TOption]: WithOptional<
564
- unknown extends TCoerce
565
- ? WithAdditionalProperties<
566
- ResolveProperties<TProps>,
567
- TAdditionalProps
568
- >
569
- : TCoerce,
570
- ObjectOptionConfig<TCoerce, TProps, TAdditionalProps>
553
+ unknown extends TCoerce ? ResolveProperties<TProps> : TCoerce,
554
+ ObjectOptionConfig<TCoerce, TProps>
571
555
  >;
572
556
  }>,
573
557
  THandlerReturn,
@@ -641,7 +625,7 @@ export interface CLI<
641
625
  // Generic fallback overload
642
626
  positional<
643
627
  TOption extends string,
644
- const TOptionConfig extends OptionConfig<any, any, any, any>
628
+ const TOptionConfig extends OptionConfig<any, any, any>
645
629
  >(
646
630
  name: TOption,
647
631
  config: TOptionConfig
@@ -778,10 +762,64 @@ export interface CLI<
778
762
  */
779
763
  getParent(): TParent;
780
764
 
781
- getBuilder<T extends ParsedArgs = ParsedArgs>(
782
- initialCli?: CLI<T, any, any>
783
- ):
784
- | ((parser: CLI<T, any, any>) => CLI<TArgs, THandlerReturn, TChildren>)
765
+ /**
766
+ * Returns a programmatic SDK for invoking this CLI and its subcommands.
767
+ * The SDK provides typed function calls instead of argv parsing.
768
+ *
769
+ * @example
770
+ * ```ts
771
+ * const myCLI = cli('my-app')
772
+ * .option('verbose', { type: 'boolean' })
773
+ * .command('build', {
774
+ * builder: (cmd) => cmd.option('watch', { type: 'boolean' }),
775
+ * handler: (args) => ({ success: true, files: ['a.js'] })
776
+ * });
777
+ *
778
+ * const sdk = myCLI.sdk();
779
+ *
780
+ * // Invoke root command (if it has a handler)
781
+ * await sdk({ verbose: true });
782
+ *
783
+ * // Invoke subcommand with typed args
784
+ * const result = await sdk.build({ watch: true });
785
+ * console.log(result.files); // ['a.js']
786
+ * console.log(result.$args.watch); // true
787
+ *
788
+ * // Use CLI-style args for -- support
789
+ * await sdk.build(['--watch', '--', 'extra-arg']);
790
+ * ```
791
+ *
792
+ * @returns An SDK object that is callable (if this command has a handler)
793
+ * and has properties for each subcommand.
794
+ */
795
+ sdk(): SDKCommand<TArgs, THandlerReturn, TChildren>;
796
+
797
+ /**
798
+ * Returns the builder function for this command as a composable builder.
799
+ * The returned function can be used with `chain` to compose multiple builders.
800
+ *
801
+ * @example
802
+ * ```ts
803
+ * const siblings = args.getParent().getChildren();
804
+ * const withBuildArgs = siblings.build.getBuilder()!;
805
+ * const withServeArgs = siblings.serve.getBuilder()!;
806
+ * return chain(args, withBuildArgs, withServeArgs);
807
+ * ```
808
+ */
809
+ getBuilder():
810
+ | (<
811
+ TInit extends ParsedArgs,
812
+ TInitHandlerReturn,
813
+ TInitChildren,
814
+ TInitParent
815
+ >(
816
+ parser: CLI<TInit, TInitHandlerReturn, TInitChildren, TInitParent>
817
+ ) => CLI<
818
+ TInit & TArgs,
819
+ TInitHandlerReturn,
820
+ TInitChildren & TChildren,
821
+ TInitParent
822
+ >)
785
823
  | undefined;
786
824
  getHandler():
787
825
  | ((args: Omit<TArgs, keyof ParsedArgs>) => THandlerReturn)
@@ -910,6 +948,67 @@ export type MiddlewareFunction<TArgs extends ParsedArgs, TArgs2> = (
910
948
  args: TArgs
911
949
  ) => TArgs2 | Promise<TArgs2>;
912
950
 
951
+ // ============================================================================
952
+ // SDK Types
953
+ // ============================================================================
954
+
955
+ /**
956
+ * Result type that conditionally includes $args.
957
+ * Only attaches $args when result is an object type.
958
+ * Uses `Awaited<T>` to handle async handlers that return `Promise<U>`.
959
+ */
960
+ export type SDKResult<TArgs, THandlerReturn> =
961
+ Awaited<THandlerReturn> extends object
962
+ ? Awaited<THandlerReturn> & { $args: TArgs }
963
+ : Awaited<THandlerReturn>;
964
+
965
+ /**
966
+ * The callable signature for a command with a handler.
967
+ * Supports both object-style args (typed, skips validation) and
968
+ * string array args (CLI-style, full validation pipeline).
969
+ */
970
+ export type SDKInvokable<TArgs, THandlerReturn> = {
971
+ /**
972
+ * Invoke the command with typed object args.
973
+ * Skips validation (TypeScript handles it), applies defaults, runs middleware.
974
+ */
975
+ (args?: Partial<Omit<TArgs, 'unmatched' | '--'>>): Promise<
976
+ SDKResult<TArgs, THandlerReturn>
977
+ >;
978
+ /**
979
+ * Invoke the command with CLI-style string args.
980
+ * Runs full pipeline: parse → validate → middleware → handler.
981
+ * Use this when you need to pass `--` extra args.
982
+ */
983
+ (args: string[]): Promise<SDKResult<TArgs, THandlerReturn>>;
984
+ };
985
+
986
+ /**
987
+ * Recursively builds SDK type from TChildren.
988
+ * Each child command becomes a property on the SDK object.
989
+ */
990
+ export type SDKChildren<TChildren> = {
991
+ [K in keyof TChildren]: TChildren[K] extends CLI<
992
+ infer A,
993
+ infer R,
994
+ infer C,
995
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
996
+ infer _P
997
+ >
998
+ ? SDKCommand<A, R, C>
999
+ : never;
1000
+ };
1001
+
1002
+ /**
1003
+ * A single SDK command - callable if it has a handler, with nested children as properties.
1004
+ * Container commands (no handler) are not callable but still provide access to children.
1005
+ */
1006
+ export type SDKCommand<TArgs, THandlerReturn, TChildren> =
1007
+ // eslint-disable-next-line @typescript-eslint/ban-types
1008
+ // THandlerReturn extends void | undefined
1009
+ // ? SDKChildren<TChildren> // No handler = just children (not callable)
1010
+ SDKInvokable<TArgs, THandlerReturn> & SDKChildren<TChildren>;
1011
+
913
1012
  /**
914
1013
  * Constructs a CLI instance. See {@link CLI} for more information.
915
1014
  * @param name Name for the top level CLI