@silvery/commander 0.2.0 → 0.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.
package/src/typed.ts DELETED
@@ -1,416 +0,0 @@
1
- /**
2
- * Type-safe Commander.js wrapper — replaces @commander-js/extra-typings.
3
- *
4
- * Uses TypeScript 5.4+ const type parameters and template literal types
5
- * to infer option types from .option() calls. Inspired by
6
- * @commander-js/extra-typings, which achieves similar results with a
7
- * 1536-line .d.ts using recursive generic accumulation. This
8
- * implementation achieves the same inference in ~100 lines of type-level
9
- * code by leveraging modern TS features (const type params, template
10
- * literal types, conditional mapped types).
11
- *
12
- * @example
13
- * ```ts
14
- * import { createCLI } from "@silvery/commander"
15
- *
16
- * const cli = createCLI("myapp")
17
- * .description("My app")
18
- * .option("-v, --verbose", "Increase verbosity")
19
- * .option("-p, --port <number>", "Port to listen on", parseInt)
20
- * .option("-o, --output [path]", "Output path")
21
- * .option("--no-color", "Disable color output")
22
- *
23
- * cli.parse()
24
- * const opts = cli.opts()
25
- * // ^? { verbose: boolean, port: number, output: string | true, color: boolean }
26
- * ```
27
- */
28
-
29
- import { Command as BaseCommand, Option, Argument } from "commander"
30
- import { colorizeHelp } from "./index.ts"
31
-
32
- // --- Type-level option parsing ---
33
- //
34
- // Approach: Each .option() call captures the flags string as a const
35
- // type parameter. Template literal types extract the flag name and
36
- // determine the value type (boolean for bare flags, string for <value>,
37
- // string | true for [value]). The result accumulates via intersection
38
- // types across chained calls. Prettify<T> flattens the intersections
39
- // for clean hover output.
40
- //
41
- // Negated flags (--no-X) are detected and produce a `X: boolean` key.
42
-
43
- /** Flatten intersection types for clean hover output */
44
- export type Prettify<T> = { [K in keyof T]: T[K] } & {}
45
-
46
- /** Check if a flags string is a negated flag like "--no-color" */
47
- type IsNegated<S extends string> = S extends `${string}--no-${string}` ? true : false
48
-
49
- /**
50
- * Extract the option key name from a flags string like "-p, --port <value>".
51
- *
52
- * Priority: long flag > short flag. Handles negated flags (--no-X → X),
53
- * kebab-case conversion (--dry-run → dryRun), and short-only flags (-v → v).
54
- */
55
- type ExtractLongName<S extends string> = S extends `${string}--no-${infer Rest}`
56
- ? Rest extends `${infer Name} ${string}`
57
- ? CamelCase<Name>
58
- : CamelCase<Rest>
59
- : S extends `${string}--${infer Rest}`
60
- ? Rest extends `${infer Name} ${string}`
61
- ? CamelCase<Name>
62
- : CamelCase<Rest>
63
- : S extends `-${infer Short}`
64
- ? Short extends `${infer C} ${string}`
65
- ? C
66
- : Short
67
- : never
68
-
69
- /** Convert kebab-case to camelCase: "dry-run" → "dryRun" */
70
- type CamelCase<S extends string> = S extends `${infer A}-${infer B}${infer Rest}`
71
- ? `${A}${Uppercase<B>}${CamelCase<Rest>}`
72
- : S
73
-
74
- /** Determine the value type from a flags string */
75
- type FlagValueType<S extends string> =
76
- IsNegated<S> extends true
77
- ? boolean // negated flags are always boolean
78
- : S extends `${string}<${string}>`
79
- ? string // required arg → string
80
- : S extends `${string}[${string}]`
81
- ? string | true // optional arg → string | true
82
- : boolean // no arg → boolean
83
-
84
- /** Add a flag to an options record */
85
- type AddOption<Opts, Flags extends string, Default = undefined> = Opts & {
86
- [K in ExtractLongName<Flags>]: Default extends undefined ? FlagValueType<Flags> | undefined : FlagValueType<Flags>
87
- }
88
-
89
- // --- Type-level argument parsing ---
90
-
91
- /** Extract whether an argument is required (<name>) or optional ([name]) */
92
- type ArgType<S extends string> = S extends `<${string}>`
93
- ? string
94
- : S extends `[${string}]`
95
- ? string | undefined
96
- : string
97
-
98
- // --- Typed opts helper (resolves accumulated Opts for action handlers) ---
99
- /** Resolve accumulated option types for use in action handler signatures */
100
- export type TypedOpts<Opts> = Prettify<Opts>
101
-
102
- // --- Zod schema support (duck-typed, no import) ---
103
-
104
- /**
105
- * Duck-type interface for Zod-like schemas.
106
- * Avoids importing zod — it's an optional peer dependency.
107
- * Any object with `parse(value: string) => T` and `_def` qualifies.
108
- */
109
- interface ZodLike<T = any> {
110
- parse(value: unknown): T
111
- _def: unknown
112
- }
113
-
114
- /** Type-level extraction: if Z is a Zod schema, infer its output type */
115
- type InferZodOutput<Z> = Z extends { parse(value: unknown): infer T; _def: unknown } ? T : never
116
-
117
- /** Runtime check: is this value a Zod-like schema? */
118
- function isZodSchema(value: unknown): value is ZodLike {
119
- return (
120
- typeof value === "object" &&
121
- value !== null &&
122
- typeof (value as any).parse === "function" &&
123
- "_def" in (value as any)
124
- )
125
- }
126
-
127
- /** Wrap a Zod schema as a Commander parser function */
128
- function zodParser<T>(schema: ZodLike<T>): (value: string) => T {
129
- return (value: string) => {
130
- try {
131
- return schema.parse(value)
132
- } catch (err: any) {
133
- // Format Zod errors as Commander-style messages
134
- if (err?.issues) {
135
- const messages = err.issues.map((i: any) => i.message).join(", ")
136
- throw new Error(messages)
137
- }
138
- throw err
139
- }
140
- }
141
- }
142
-
143
- // --- Typed Command ---
144
-
145
- /**
146
- * A Commander Command with inferred option and argument types.
147
- * Wraps Commander's Command and tracks option types at the type level.
148
- * Help is automatically colorized.
149
- *
150
- * @typeParam Opts - Accumulated option types from .option() calls
151
- * @typeParam Args - Accumulated argument types from .argument() calls (tuple)
152
- */
153
- export class TypedCommand<Opts = {}, Args extends any[] = []> {
154
- readonly _cmd: BaseCommand
155
-
156
- constructor(name?: string) {
157
- this._cmd = new BaseCommand(name)
158
- colorizeHelp(this._cmd as any)
159
- }
160
-
161
- /** Set program description */
162
- description(str: string, argsDescription?: Record<string, string>): this {
163
- this._cmd.description(str, argsDescription as any)
164
- return this
165
- }
166
-
167
- /** Set program version */
168
- version(str: string, flags?: string, description?: string): this {
169
- this._cmd.version(str, flags, description)
170
- return this
171
- }
172
-
173
- /**
174
- * Add an option with type inference.
175
- *
176
- * Supports four overload patterns:
177
- * 1. `.option(flags, description?)` — type inferred from flags syntax
178
- * 2. `.option(flags, description, defaultValue)` — removes `undefined` from type
179
- * 3. `.option(flags, description, parser, defaultValue?)` — type inferred from parser return type
180
- * 4. `.option(flags, description, zodSchema)` — type inferred from Zod schema output
181
- */
182
- option<const F extends string, Z extends ZodLike>(
183
- flags: F,
184
- description: string,
185
- schema: Z,
186
- ): TypedCommand<Opts & { [K in ExtractLongName<F>]: InferZodOutput<Z> }, Args>
187
-
188
- option<const F extends string, P extends (value: string, previous: any) => any>(
189
- flags: F,
190
- description: string,
191
- parseArg: P,
192
- defaultValue?: ReturnType<P>,
193
- ): TypedCommand<Opts & { [K in ExtractLongName<F>]: ReturnType<P> }, Args>
194
-
195
- option<const F extends string, D = undefined>(
196
- flags: F,
197
- description?: string,
198
- defaultValue?: D,
199
- ): TypedCommand<AddOption<Opts, F, D>, Args>
200
-
201
- option(flags: string, description?: string, parseArgOrDefault?: any, defaultValue?: any): any {
202
- if (isZodSchema(parseArgOrDefault)) {
203
- ;(this._cmd as any).option(flags, description ?? "", zodParser(parseArgOrDefault))
204
- } else if (typeof parseArgOrDefault === "function") {
205
- ;(this._cmd as any).option(flags, description ?? "", parseArgOrDefault, defaultValue)
206
- } else {
207
- ;(this._cmd as any).option(flags, description ?? "", parseArgOrDefault)
208
- }
209
- return this
210
- }
211
-
212
- /** Add a required option */
213
- requiredOption<const F extends string>(
214
- flags: F,
215
- description?: string,
216
- defaultValue?: string,
217
- ): TypedCommand<Opts & { [K in ExtractLongName<F>]: FlagValueType<F> }, Args> {
218
- ;(this._cmd as any).requiredOption(flags, description ?? "", defaultValue)
219
- return this as any
220
- }
221
-
222
- /**
223
- * Add an option with a fixed set of allowed values (choices).
224
- * The option type is narrowed to a union of the provided values.
225
- *
226
- * @example
227
- * ```ts
228
- * .optionWithChoices("-e, --env <env>", "Environment", ["dev", "staging", "prod"] as const)
229
- * // → env: "dev" | "staging" | "prod" | undefined
230
- * ```
231
- */
232
- optionWithChoices<const F extends string, const C extends readonly string[]>(
233
- flags: F,
234
- description: string,
235
- choices: C,
236
- ): TypedCommand<Opts & { [K in ExtractLongName<F>]: C[number] | undefined }, Args> {
237
- const option = new Option(flags, description).choices(choices as unknown as string[])
238
- ;(this._cmd as any).addOption(option)
239
- return this as any
240
- }
241
-
242
- /** Add a subcommand */
243
- command(nameAndArgs: string, description?: string): TypedCommand<{}> {
244
- const sub = (this._cmd as any).command(nameAndArgs, description)
245
- colorizeHelp(sub as any)
246
- const typed = new TypedCommand<{}>()
247
- // Replace the internal command with the one Commander created
248
- ;(typed as any)._cmd = sub
249
- return typed
250
- }
251
-
252
- /**
253
- * Add an argument with type tracking.
254
- * `<name>` = required (string), `[name]` = optional (string | undefined).
255
- */
256
- argument<const N extends string>(
257
- name: N,
258
- description?: string,
259
- defaultValue?: unknown,
260
- ): TypedCommand<Opts, [...Args, ArgType<N>]> {
261
- this._cmd.argument(name, description ?? "", defaultValue)
262
- return this as any
263
- }
264
-
265
- /**
266
- * Set action handler with typed parameters.
267
- * Callback receives: ...arguments, opts, command.
268
- */
269
- action(fn: (...args: [...Args, Prettify<Opts>, TypedCommand<Opts, Args>]) => void | Promise<void>): this {
270
- this._cmd.action(fn as any)
271
- return this
272
- }
273
-
274
- /** Get typed parsed options */
275
- opts(): Prettify<Opts> {
276
- return this._cmd.opts() as any
277
- }
278
-
279
- /** Parse argv */
280
- parse(argv?: readonly string[], options?: { from?: "node" | "electron" | "user" }): this {
281
- this._cmd.parse(argv as any, options as any)
282
- return this
283
- }
284
-
285
- /** Parse argv async */
286
- async parseAsync(argv?: readonly string[], options?: { from?: "node" | "electron" | "user" }): Promise<this> {
287
- await this._cmd.parseAsync(argv as any, options as any)
288
- return this
289
- }
290
-
291
- /** Get help text */
292
- helpInformation(): string {
293
- return this._cmd.helpInformation()
294
- }
295
-
296
- /** Allow unknown options */
297
- allowUnknownOption(allow?: boolean): this {
298
- this._cmd.allowUnknownOption(allow)
299
- return this
300
- }
301
-
302
- /** Allow excess arguments */
303
- allowExcessArguments(allow?: boolean): this {
304
- this._cmd.allowExcessArguments(allow)
305
- return this
306
- }
307
-
308
- /** Pass through options after -- */
309
- passThroughOptions(passThrough?: boolean): this {
310
- this._cmd.passThroughOptions(passThrough)
311
- return this
312
- }
313
-
314
- /** Enable positional options */
315
- enablePositionalOptions(positional?: boolean): this {
316
- this._cmd.enablePositionalOptions(positional)
317
- return this
318
- }
319
-
320
- /** Hook into lifecycle events */
321
- hook(event: string, listener: (...args: any[]) => void | Promise<void>): this {
322
- ;(this._cmd as any).hook(event, listener)
323
- return this
324
- }
325
-
326
- /** Set custom name */
327
- name(str: string): this {
328
- this._cmd.name(str)
329
- return this
330
- }
331
-
332
- /** Add alias */
333
- alias(alias: string): this {
334
- this._cmd.alias(alias)
335
- return this
336
- }
337
-
338
- /** Add multiple aliases */
339
- aliases(aliases: readonly string[]): this {
340
- this._cmd.aliases(aliases as string[])
341
- return this
342
- }
343
-
344
- /** Configure help display */
345
- configureHelp(config: Record<string, unknown>): this {
346
- ;(this._cmd as any).configureHelp(config)
347
- return this
348
- }
349
-
350
- /** Configure output streams */
351
- configureOutput(config: Record<string, unknown>): this {
352
- ;(this._cmd as any).configureOutput(config)
353
- return this
354
- }
355
-
356
- /** Access underlying Commander Command for advanced use */
357
- get commands(): readonly BaseCommand[] {
358
- return this._cmd.commands
359
- }
360
-
361
- /** Show help */
362
- help(context?: { error?: boolean }): never {
363
- return (this._cmd as any).help(context) as never
364
- }
365
-
366
- /** Add help text */
367
- addHelpText(position: "before" | "after" | "beforeAll" | "afterAll", text: string): this {
368
- this._cmd.addHelpText(position, text)
369
- return this
370
- }
371
-
372
- /** Show help after error */
373
- showHelpAfterError(displayHelp?: boolean | string): this {
374
- this._cmd.showHelpAfterError(displayHelp)
375
- return this
376
- }
377
-
378
- /** Show suggestion after error */
379
- showSuggestionAfterError(displaySuggestion?: boolean): this {
380
- this._cmd.showSuggestionAfterError(displaySuggestion)
381
- return this
382
- }
383
-
384
- /** Set environment variable for the last added option (passthrough) */
385
- env(name: string): this {
386
- // Commander's .env() is on Option, not Command. We apply it to the last option.
387
- const opts = (this._cmd as any).options as any[]
388
- if (opts.length > 0) {
389
- opts[opts.length - 1].envVar = name
390
- opts[opts.length - 1].envVarRequired = false
391
- }
392
- return this
393
- }
394
- }
395
-
396
- /**
397
- * Create a typed, colorized CLI program.
398
- *
399
- * @example
400
- * ```ts
401
- * import { createCLI } from "@silvery/commander"
402
- *
403
- * const program = createCLI("myapp")
404
- * .description("My tool")
405
- * .version("1.0.0")
406
- * .option("-v, --verbose", "Verbose output")
407
- * .option("-p, --port <number>", "Port", parseInt)
408
- *
409
- * program.parse()
410
- * const { verbose, port } = program.opts()
411
- * // ^boolean ^number | undefined
412
- * ```
413
- */
414
- export function createCLI(name?: string): TypedCommand<{}> {
415
- return new TypedCommand(name)
416
- }