cli-forge 0.12.0 → 1.0.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.
@@ -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';
@@ -127,7 +126,7 @@ export interface CLI<
127
126
  TArgs,
128
127
  TCommandArgs,
129
128
  TChildHandlerReturn,
130
- any,
129
+ TChildren,
131
130
  CLI<TArgs, THandlerReturn, TChildren, TParent>,
132
131
  TChildChildren
133
132
  >
@@ -436,22 +435,16 @@ export interface CLI<
436
435
  option<
437
436
  TOption extends string,
438
437
  TCoerce,
439
- const TProps extends Record<string, { type: string }>,
440
- TAdditionalProps extends false | 'string' | 'number' | 'boolean' = false
438
+ const TProps extends Record<string, { type: string }>
441
439
  >(
442
440
  name: TOption,
443
- config: ObjectOptionConfig<TCoerce, TProps, TAdditionalProps>
441
+ config: ObjectOptionConfig<TCoerce, TProps>
444
442
  ): CLI<
445
443
  TArgs &
446
444
  MakeUndefinedPropertiesOptional<{
447
445
  [key in TOption]: WithOptional<
448
- unknown extends TCoerce
449
- ? WithAdditionalProperties<
450
- ResolveProperties<TProps>,
451
- TAdditionalProps
452
- >
453
- : TCoerce,
454
- ObjectOptionConfig<TCoerce, TProps, TAdditionalProps>
446
+ unknown extends TCoerce ? ResolveProperties<TProps> : TCoerce,
447
+ ObjectOptionConfig<TCoerce, TProps>
455
448
  >;
456
449
  }>,
457
450
  THandlerReturn,
@@ -525,7 +518,7 @@ export interface CLI<
525
518
  // Generic fallback overload
526
519
  option<
527
520
  TOption extends string,
528
- const TOptionConfig extends OptionConfig<any, any, any, any>
521
+ const TOptionConfig extends OptionConfig<any, any, any>
529
522
  >(
530
523
  name: TOption,
531
524
  config: TOptionConfig
@@ -552,22 +545,16 @@ export interface CLI<
552
545
  positional<
553
546
  TOption extends string,
554
547
  TCoerce,
555
- const TProps extends Record<string, { type: string }>,
556
- TAdditionalProps extends false | 'string' | 'number' | 'boolean' = false
548
+ const TProps extends Record<string, { type: string }>
557
549
  >(
558
550
  name: TOption,
559
- config: ObjectOptionConfig<TCoerce, TProps, TAdditionalProps>
551
+ config: ObjectOptionConfig<TCoerce, TProps>
560
552
  ): CLI<
561
553
  TArgs &
562
554
  MakeUndefinedPropertiesOptional<{
563
555
  [key in TOption]: WithOptional<
564
- unknown extends TCoerce
565
- ? WithAdditionalProperties<
566
- ResolveProperties<TProps>,
567
- TAdditionalProps
568
- >
569
- : TCoerce,
570
- ObjectOptionConfig<TCoerce, TProps, TAdditionalProps>
556
+ unknown extends TCoerce ? ResolveProperties<TProps> : TCoerce,
557
+ ObjectOptionConfig<TCoerce, TProps>
571
558
  >;
572
559
  }>,
573
560
  THandlerReturn,
@@ -641,7 +628,7 @@ export interface CLI<
641
628
  // Generic fallback overload
642
629
  positional<
643
630
  TOption extends string,
644
- const TOptionConfig extends OptionConfig<any, any, any, any>
631
+ const TOptionConfig extends OptionConfig<any, any, any>
645
632
  >(
646
633
  name: TOption,
647
634
  config: TOptionConfig
@@ -778,10 +765,54 @@ export interface CLI<
778
765
  */
779
766
  getParent(): TParent;
780
767
 
781
- getBuilder<T extends ParsedArgs = ParsedArgs>(
782
- initialCli?: CLI<T, any, any>
783
- ):
784
- | ((parser: CLI<T, any, any>) => CLI<TArgs, THandlerReturn, TChildren>)
768
+ /**
769
+ * Returns a programmatic SDK for invoking this CLI and its subcommands.
770
+ * The SDK provides typed function calls instead of argv parsing.
771
+ *
772
+ * @example
773
+ * ```ts
774
+ * const myCLI = cli('my-app')
775
+ * .option('verbose', { type: 'boolean' })
776
+ * .command('build', {
777
+ * builder: (cmd) => cmd.option('watch', { type: 'boolean' }),
778
+ * handler: (args) => ({ success: true, files: ['a.js'] })
779
+ * });
780
+ *
781
+ * const sdk = myCLI.sdk();
782
+ *
783
+ * // Invoke root command (if it has a handler)
784
+ * await sdk({ verbose: true });
785
+ *
786
+ * // Invoke subcommand with typed args
787
+ * const result = await sdk.build({ watch: true });
788
+ * console.log(result.files); // ['a.js']
789
+ * console.log(result.$args.watch); // true
790
+ *
791
+ * // Use CLI-style args for -- support
792
+ * await sdk.build(['--watch', '--', 'extra-arg']);
793
+ * ```
794
+ *
795
+ * @returns An SDK object that is callable (if this command has a handler)
796
+ * and has properties for each subcommand.
797
+ */
798
+ sdk(): SDKCommand<TArgs, THandlerReturn, TChildren>;
799
+
800
+ /**
801
+ * Returns the builder function for this command as a composable builder.
802
+ * The returned function can be used with `chain` to compose multiple builders.
803
+ *
804
+ * @example
805
+ * ```ts
806
+ * const siblings = args.getParent().getChildren();
807
+ * const withBuildArgs = siblings.build.getBuilder()!;
808
+ * const withServeArgs = siblings.serve.getBuilder()!;
809
+ * return chain(args, withBuildArgs, withServeArgs);
810
+ * ```
811
+ */
812
+ getBuilder():
813
+ | (<TInit extends ParsedArgs, TInitHandlerReturn, TInitChildren, TInitParent>(
814
+ parser: CLI<TInit, TInitHandlerReturn, TInitChildren, TInitParent>
815
+ ) => CLI<TInit & TArgs, TInitHandlerReturn, TInitChildren & TChildren, TInitParent>)
785
816
  | undefined;
786
817
  getHandler():
787
818
  | ((args: Omit<TArgs, keyof ParsedArgs>) => THandlerReturn)
@@ -910,6 +941,67 @@ export type MiddlewareFunction<TArgs extends ParsedArgs, TArgs2> = (
910
941
  args: TArgs
911
942
  ) => TArgs2 | Promise<TArgs2>;
912
943
 
944
+ // ============================================================================
945
+ // SDK Types
946
+ // ============================================================================
947
+
948
+ /**
949
+ * Result type that conditionally includes $args.
950
+ * Only attaches $args when result is an object type.
951
+ * Uses Awaited<T> to handle async handlers that return Promise<U>.
952
+ */
953
+ export type SDKResult<TArgs, THandlerReturn> =
954
+ Awaited<THandlerReturn> extends object
955
+ ? Awaited<THandlerReturn> & { $args: TArgs }
956
+ : Awaited<THandlerReturn>;
957
+
958
+ /**
959
+ * The callable signature for a command with a handler.
960
+ * Supports both object-style args (typed, skips validation) and
961
+ * string array args (CLI-style, full validation pipeline).
962
+ */
963
+ export type SDKInvokable<TArgs, THandlerReturn> = {
964
+ /**
965
+ * Invoke the command with typed object args.
966
+ * Skips validation (TypeScript handles it), applies defaults, runs middleware.
967
+ */
968
+ (args?: Partial<Omit<TArgs, 'unmatched' | '--'>>): Promise<
969
+ SDKResult<TArgs, THandlerReturn>
970
+ >;
971
+ /**
972
+ * Invoke the command with CLI-style string args.
973
+ * Runs full pipeline: parse → validate → middleware → handler.
974
+ * Use this when you need to pass `--` extra args.
975
+ */
976
+ (args: string[]): Promise<SDKResult<TArgs, THandlerReturn>>;
977
+ };
978
+
979
+ /**
980
+ * Recursively builds SDK type from TChildren.
981
+ * Each child command becomes a property on the SDK object.
982
+ */
983
+ export type SDKChildren<TChildren> = {
984
+ [K in keyof TChildren]: TChildren[K] extends CLI<
985
+ infer A,
986
+ infer R,
987
+ infer C,
988
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
989
+ infer _P
990
+ >
991
+ ? SDKCommand<A, R, C>
992
+ : never;
993
+ };
994
+
995
+ /**
996
+ * A single SDK command - callable if it has a handler, with nested children as properties.
997
+ * Container commands (no handler) are not callable but still provide access to children.
998
+ */
999
+ export type SDKCommand<TArgs, THandlerReturn, TChildren> =
1000
+ // eslint-disable-next-line @typescript-eslint/ban-types
1001
+ // THandlerReturn extends void | undefined
1002
+ // ? SDKChildren<TChildren> // No handler = just children (not callable)
1003
+ SDKInvokable<TArgs, THandlerReturn> & SDKChildren<TChildren>;
1004
+
913
1005
  /**
914
1006
  * Constructs a CLI instance. See {@link CLI} for more information.
915
1007
  * @param name Name for the top level CLI
@@ -0,0 +1,285 @@
1
+ import { cli } from './public-api';
2
+
3
+ describe('CLI.sdk()', () => {
4
+ describe('basic invocation', () => {
5
+ it('should invoke root command with object args', async () => {
6
+ let receivedArgs: any;
7
+ const myCLI = cli('test', {
8
+ builder: (cmd) => cmd.option('name', { type: 'string' }),
9
+ handler: (args) => {
10
+ receivedArgs = args;
11
+ return { success: true };
12
+ },
13
+ });
14
+
15
+ const sdk = myCLI.sdk();
16
+ const result = await sdk({ name: 'world' });
17
+
18
+ expect(result.success).toBe(true);
19
+ expect(receivedArgs.name).toBe('world');
20
+ });
21
+
22
+ it('should invoke root command with string array args', async () => {
23
+ let receivedArgs: any;
24
+ const myCLI = cli('test', {
25
+ builder: (cmd) => cmd.option('name', { type: 'string' }),
26
+ handler: (args) => {
27
+ receivedArgs = args;
28
+ return { success: true };
29
+ },
30
+ });
31
+
32
+ const sdk = myCLI.sdk();
33
+ const result = await sdk(['--name', 'world']);
34
+
35
+ expect(result.success).toBe(true);
36
+ expect(receivedArgs.name).toBe('world');
37
+ });
38
+
39
+ it('should attach $args to object results', async () => {
40
+ const myCLI = cli('test', {
41
+ builder: (cmd) =>
42
+ cmd.option('count', { type: 'number', required: true }),
43
+ handler: (args) => ({ value: args.count * 2 }),
44
+ });
45
+
46
+ const sdk = myCLI.sdk();
47
+ const result = await sdk({ count: 5 });
48
+
49
+ expect(result.value).toBe(10);
50
+ expect(result.$args.count).toBe(5);
51
+ });
52
+
53
+ it('should return primitives without $args', async () => {
54
+ const myCLI = cli('test', {
55
+ handler: () => 42,
56
+ });
57
+
58
+ const sdk = myCLI.sdk();
59
+ const result = await sdk();
60
+
61
+ expect(result).toBe(42);
62
+ });
63
+ });
64
+
65
+ describe('subcommands', () => {
66
+ it('should invoke subcommand via property access', async () => {
67
+ let buildCalled = false;
68
+ const myCLI = cli('test').command('build', {
69
+ builder: (cmd) => cmd.option('watch', { type: 'boolean' }),
70
+ handler: (args) => {
71
+ buildCalled = true;
72
+ return { watching: args.watch };
73
+ },
74
+ });
75
+
76
+ const sdk = myCLI.sdk();
77
+ const result = await sdk.build({ watch: true });
78
+
79
+ expect(buildCalled).toBe(true);
80
+ expect(result.watching).toBe(true);
81
+ });
82
+
83
+ it('should invoke nested subcommands', async () => {
84
+ let migrateCalled = false;
85
+ const myCLI = cli('test').command('db', {
86
+ builder: (cmd) =>
87
+ cmd.command('migrate', {
88
+ builder: (c) => c.option('dry', { type: 'boolean' }),
89
+ handler: (args) => {
90
+ migrateCalled = true;
91
+ return { dryRun: args.dry };
92
+ },
93
+ }),
94
+ });
95
+
96
+ const sdk = myCLI.sdk();
97
+ const result = await sdk.db.migrate({ dry: true });
98
+
99
+ expect(migrateCalled).toBe(true);
100
+ expect(result.dryRun).toBe(true);
101
+ });
102
+
103
+ it('should throw when invoking command without handler', async () => {
104
+ const myCLI = cli('test').command('db', {
105
+ // No handler - container command
106
+ builder: (cmd) =>
107
+ cmd.command('migrate', {
108
+ handler: () => 'done',
109
+ }),
110
+ });
111
+
112
+ const sdk = myCLI.sdk();
113
+
114
+ await expect(sdk.db()).rejects.toThrow("Command 'db' has no handler");
115
+ });
116
+ });
117
+
118
+ describe('defaults', () => {
119
+ it('should apply default values for object args', async () => {
120
+ let receivedArgs: any;
121
+ const myCLI = cli('test', {
122
+ builder: (cmd) =>
123
+ cmd
124
+ .option('host', { type: 'string', default: 'localhost' })
125
+ .option('port', { type: 'number', default: 3000 }),
126
+ handler: (args) => {
127
+ receivedArgs = args;
128
+ return 'ok';
129
+ },
130
+ });
131
+
132
+ const sdk = myCLI.sdk();
133
+ await sdk({ port: 8080 });
134
+
135
+ expect(receivedArgs.host).toBe('localhost');
136
+ expect(receivedArgs.port).toBe(8080);
137
+ });
138
+
139
+ it('should allow overriding all defaults', async () => {
140
+ let receivedArgs: any;
141
+ const myCLI = cli('test', {
142
+ builder: (cmd) =>
143
+ cmd.option('verbose', { type: 'boolean', default: false }),
144
+ handler: (args) => {
145
+ receivedArgs = args;
146
+ },
147
+ });
148
+
149
+ const sdk = myCLI.sdk();
150
+ await sdk({ verbose: true });
151
+
152
+ expect(receivedArgs.verbose).toBe(true);
153
+ });
154
+ });
155
+
156
+ describe('middleware', () => {
157
+ it('should run middleware for object args', async () => {
158
+ let receivedArgs: any;
159
+ const myCLI = cli('test', {
160
+ builder: (cmd) => cmd.option('name', { type: 'string' }),
161
+ handler: (args) => {
162
+ receivedArgs = args;
163
+ },
164
+ }).middleware((args) => ({ ...args, timestamp: 12345 }));
165
+
166
+ const sdk = myCLI.sdk();
167
+ await sdk({ name: 'test' });
168
+
169
+ expect(receivedArgs.timestamp).toBe(12345);
170
+ });
171
+
172
+ it('should run parent middleware for subcommands', async () => {
173
+ let receivedArgs: any;
174
+ const myCLI = cli('test')
175
+ .middleware((args) => ({ ...args, fromRoot: true }))
176
+ .command('child', {
177
+ handler: (args) => {
178
+ receivedArgs = args;
179
+ },
180
+ });
181
+
182
+ const sdk = myCLI.sdk();
183
+ await sdk.child({});
184
+
185
+ expect(receivedArgs.fromRoot).toBe(true);
186
+ });
187
+ });
188
+
189
+ describe('validation', () => {
190
+ it('should validate string array args', async () => {
191
+ const myCLI = cli('test', {
192
+ builder: (cmd) =>
193
+ cmd.option('count', { type: 'number', required: true }),
194
+ handler: () => 'ok',
195
+ });
196
+
197
+ const sdk = myCLI.sdk();
198
+
199
+ // String array args go through full validation
200
+ await expect(sdk([])).rejects.toThrow();
201
+ });
202
+
203
+ it('should skip validation for object args (trust TypeScript)', async () => {
204
+ let receivedArgs: any;
205
+ const myCLI = cli('test', {
206
+ builder: (cmd) =>
207
+ cmd.option('count', { type: 'number', required: true }),
208
+ handler: (args) => {
209
+ receivedArgs = args;
210
+ return 'ok';
211
+ },
212
+ });
213
+
214
+ const sdk = myCLI.sdk();
215
+
216
+ // Object args skip validation - TypeScript should catch this
217
+ // but at runtime we allow it through
218
+ const result = await sdk({});
219
+ expect(result).toBe('ok');
220
+ expect(receivedArgs.count).toBeUndefined();
221
+ });
222
+ });
223
+
224
+ describe('error handling', () => {
225
+ it('should throw handler errors directly', async () => {
226
+ const myCLI = cli('test', {
227
+ handler: () => {
228
+ throw new Error('Handler failed');
229
+ },
230
+ });
231
+
232
+ const sdk = myCLI.sdk();
233
+
234
+ await expect(sdk()).rejects.toThrow('Handler failed');
235
+ });
236
+
237
+ it('should throw for non-existent subcommands', async () => {
238
+ const myCLI = cli('test').command('build', { handler: () => 'ok' });
239
+
240
+ const sdk = myCLI.sdk();
241
+
242
+ // Access non-existent command returns undefined
243
+ expect((sdk as any).nonexistent).toBeUndefined();
244
+ });
245
+ });
246
+
247
+ describe('async handlers', () => {
248
+ it('should handle async handlers', async () => {
249
+ const myCLI = cli('test', {
250
+ handler: async () => {
251
+ await new Promise((r) => setTimeout(r, 10));
252
+ return { async: true };
253
+ },
254
+ });
255
+
256
+ const sdk = myCLI.sdk();
257
+ const result = await sdk();
258
+
259
+ expect(result.async).toBe(true);
260
+ });
261
+ });
262
+
263
+ describe('type safety', () => {
264
+ it('should preserve handler return type', async () => {
265
+ interface BuildResult {
266
+ files: string[];
267
+ success: boolean;
268
+ }
269
+
270
+ const myCLI = cli('test', {
271
+ handler: (): BuildResult => ({
272
+ files: ['a.js', 'b.js'],
273
+ success: true,
274
+ }),
275
+ });
276
+
277
+ const sdk = myCLI.sdk();
278
+ const result = await sdk();
279
+
280
+ // Type should be BuildResult & { $args: ... }
281
+ expect(result.files).toEqual(['a.js', 'b.js']);
282
+ expect(result.success).toBe(true);
283
+ });
284
+ });
285
+ });
@@ -1,3 +1,4 @@
1
+ import { it, describe, expect } from 'vitest';
1
2
  import cli from './public-api';
2
3
  import { TestHarness } from './test-harness';
3
4
 
@@ -1,3 +1,4 @@
1
+ import { it, describe, expect } from 'vitest';
1
2
  import { stringToArgs } from './utils';
2
3
 
3
4
  describe('utils', () => {