@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/README.md +105 -72
- package/package.json +7 -10
- package/src/colorize.ts +164 -0
- package/src/command.ts +117 -0
- package/src/index.ts +11 -168
- package/src/presets.ts +196 -0
- package/src/z.ts +45 -0
- package/src/typed.ts +0 -416
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
|
-
}
|