@silvery/commander 0.1.0 → 0.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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @silvery/commander
2
2
 
3
- Type-safe, colorized [Commander.js](https://github.com/tj/commander.js) wrapper. Infers option types from `.option()` calls using TypeScript 5.4+ const type parameters and template literal types no codegen, no separate type package.
3
+ Type-safe, colorized [Commander.js](https://github.com/tj/commander.js) wrapper. Infers option types from `.option()` calls using TypeScript 5.4+ const type parameters and template literal types -- no codegen, no separate type package.
4
4
 
5
5
  ## Usage
6
6
 
@@ -11,13 +11,13 @@ const cli = createCLI("myapp")
11
11
  .description("My CLI tool")
12
12
  .version("1.0.0")
13
13
  .option("-v, --verbose", "Verbose output")
14
- .option("-p, --port <number>", "Port to listen on")
14
+ .option("-p, --port <number>", "Port to listen on", parseInt)
15
15
  .option("-o, --output [path]", "Output path")
16
16
  .option("--no-color", "Disable color output")
17
17
 
18
18
  cli.parse()
19
19
  const { verbose, port, output, color } = cli.opts()
20
- // ^boolean ^string ^string|true ^boolean
20
+ // ^boolean ^number ^string|true ^boolean
21
21
  ```
22
22
 
23
23
  Help output is automatically colorized using Commander's built-in `configureHelp()` style hooks (headings bold, flags green, commands cyan, descriptions dim, arguments yellow).
@@ -32,29 +32,104 @@ const program = new Command("myapp").description("My CLI tool")
32
32
  colorizeHelp(program) // applies recursively to all subcommands
33
33
  ```
34
34
 
35
- ## Improvements over @commander-js/extra-typings
35
+ ## Custom parser type inference
36
+
37
+ When `.option()` is called with a parser function as the third argument, the return type is inferred:
38
+
39
+ ```typescript
40
+ const cli = createCLI("deploy")
41
+ .option("-p, --port <n>", "Port", parseInt) // → port: number
42
+ .option("-t, --timeout <ms>", "Timeout", Number) // → timeout: number
43
+ .option("--tags <items>", "Tags", (v) => v.split(",")) // → tags: string[]
44
+ ```
45
+
46
+ Default values can be passed as the fourth argument:
47
+
48
+ ```typescript
49
+ .option("-p, --port <n>", "Port", parseInt, 8080) // → port: number (defaults to 8080)
50
+ ```
51
+
52
+ ## Zod schema validation
53
+
54
+ Pass a [Zod](https://zod.dev) schema as the third argument for combined parsing, validation, and type inference:
55
+
56
+ ```typescript
57
+ import { z } from "zod"
58
+
59
+ const cli = createCLI("deploy")
60
+ .option("-p, --port <n>", "Port", z.coerce.number().min(1).max(65535))
61
+ // → port: number (validated at parse time)
62
+
63
+ .option("-e, --env <env>", "Environment", z.enum(["dev", "staging", "prod"]))
64
+ // → env: "dev" | "staging" | "prod" (union type)
65
+
66
+ .option(
67
+ "--tags <t>",
68
+ "Tags",
69
+ z.string().transform((v) => v.split(",")),
70
+ )
71
+ // → tags: string[] (transformed)
72
+ ```
73
+
74
+ Zod is an optional peer dependency -- duck-typed at runtime, never imported at the top level. If Zod validation fails, the error is formatted as a Commander-style error message.
75
+
76
+ ## Typed action handlers
36
77
 
37
- | Feature | extra-typings | @silvery/commander |
38
- |---|---|---|
39
- | Type inference | 1536-line .d.ts with recursive generic accumulation | ~60 lines using TS 5.4+ const type params + template literals |
40
- | Colorized help | Not included | Built-in via Commander's native style hooks |
41
- | Package size | Types only (25 lines runtime) | Types + colorizer (~200 lines, zero deps) |
42
- | Installation | Separate package alongside commander | Single package, re-exports Commander |
43
- | React dependency | None | None |
44
- | Negated flags (--no-X) | Partial | Key extraction + boolean type inference |
45
- | Typed action handlers | Yes (full signature inference) | Not yet (planned) |
46
- | Custom parser types | Yes (.option with parseFloat -> number) | Not yet (planned) |
78
+ Action callbacks receive typed arguments and options:
47
79
 
48
- ## What's planned
80
+ ```typescript
81
+ const cli = createCLI("deploy")
82
+ .argument("<env>", "Target environment")
83
+ .argument("[tag]", "Optional deploy tag")
84
+ .option("-f, --force", "Force deploy")
85
+ .action((env, tag, opts) => {
86
+ // env: string, tag: string | undefined, opts: { force: boolean | undefined }
87
+ })
88
+ ```
89
+
90
+ Required arguments (`<name>`) are `string`, optional arguments (`[name]`) are `string | undefined`.
91
+
92
+ ## Choices narrowing
93
+
94
+ Use `.optionWithChoices()` to restrict an option to a fixed set of values with union type inference:
95
+
96
+ ```typescript
97
+ const cli = createCLI("deploy").optionWithChoices("-e, --env <env>", "Environment", ["dev", "staging", "prod"] as const)
98
+ // → env: "dev" | "staging" | "prod" | undefined
99
+
100
+ cli.parse()
101
+ const { env } = cli.opts() // env: "dev" | "staging" | "prod" | undefined
102
+ ```
103
+
104
+ Commander validates the choice at parse time and rejects invalid values.
105
+
106
+ ## Environment variable support
107
+
108
+ Chain `.env()` to set an environment variable fallback for the last-added option:
109
+
110
+ ```typescript
111
+ .option("-p, --port <n>", "Port").env("PORT")
112
+ ```
113
+
114
+ ## Improvements over @commander-js/extra-typings
49
115
 
50
- - Custom parser function type inference (`.option("-p, --port <n>", "Port", parseInt)` -> `number`)
51
- - Typed action handler signatures
52
- - `.choices()` narrowing to union types
116
+ | Feature | extra-typings | @silvery/commander |
117
+ | ---------------------- | --------------------------------------------------- | --------------------------------------------------------------- |
118
+ | Type inference | 1536-line .d.ts with recursive generic accumulation | ~120 lines using TS 5.4+ const type params + template literals |
119
+ | Custom parser types | Yes (.option with parseFloat -> number) | Yes (parser return type inference) |
120
+ | Zod schema support | No | Yes (parse + validate + infer from Zod schemas) |
121
+ | Typed action handlers | Yes (full signature inference) | Yes (arguments + options) |
122
+ | Choices narrowing | Via .addOption() | Via .optionWithChoices() |
123
+ | Colorized help | Not included | Built-in via Commander's native style hooks |
124
+ | Package size | Types only (25 lines runtime) | Types + colorizer + Zod bridge (~300 lines, zero required deps) |
125
+ | Installation | Separate package alongside commander | Single package, re-exports Commander |
126
+ | Negated flags (--no-X) | Partial | Key extraction + boolean type inference |
53
127
 
54
128
  ## Credits
55
129
 
56
- - **Commander.js** by TJ Holowaychuk and contributors -- the underlying CLI framework
57
- - **@commander-js/extra-typings** -- inspired the type inference approach; our implementation uses modern TypeScript features (const type parameters, template literal types) to achieve similar results in fewer lines
130
+ - [Commander.js](https://github.com/tj/commander.js) by TJ Holowaychuk and contributors -- the underlying CLI framework
131
+ - [@commander-js/extra-typings](https://github.com/commander-js/extra-typings) -- inspired the type inference approach; our implementation uses modern TypeScript features (const type parameters, template literal types) to achieve similar results in fewer lines
132
+ - [@silvery/ansi](https://github.com/beorn/silvery/tree/main/packages/ansi) -- optional ANSI color detection for respecting NO_COLOR/FORCE_COLOR/terminal capabilities
58
133
  - Uses Commander's built-in `configureHelp()` style hooks (added in Commander 12) for colorization
59
134
 
60
135
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@silvery/commander",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Colorized Commander.js help output using ANSI escape codes",
5
5
  "keywords": [
6
6
  "ansi",
@@ -28,7 +28,9 @@
28
28
  },
29
29
  "peerDependencies": {
30
30
  "@commander-js/extra-typings": ">=12.0.0",
31
- "commander": ">=12.0.0"
31
+ "@silvery/ansi": ">=0.1.0",
32
+ "commander": ">=12.0.0",
33
+ "zod": ">=3.0.0"
32
34
  },
33
35
  "peerDependenciesMeta": {
34
36
  "commander": {
@@ -36,6 +38,12 @@
36
38
  },
37
39
  "@commander-js/extra-typings": {
38
40
  "optional": true
41
+ },
42
+ "@silvery/ansi": {
43
+ "optional": true
44
+ },
45
+ "zod": {
46
+ "optional": true
39
47
  }
40
48
  },
41
49
  "engines": {
package/src/index.ts CHANGED
@@ -33,6 +33,34 @@ const CYAN = "\x1b[36m"
33
33
  const GREEN = "\x1b[32m"
34
34
  const YELLOW = "\x1b[33m"
35
35
 
36
+ /**
37
+ * Check if color output should be enabled.
38
+ * Uses @silvery/ansi detectColor() if available, falls back to basic
39
+ * NO_COLOR/FORCE_COLOR/isTTY checks.
40
+ */
41
+ let _shouldColorize: boolean | undefined
42
+
43
+ export function shouldColorize(): boolean {
44
+ if (_shouldColorize !== undefined) return _shouldColorize
45
+
46
+ // Try @silvery/ansi for full detection (respects NO_COLOR, FORCE_COLOR, TERM, etc.)
47
+ try {
48
+ const { detectColor } = require("@silvery/ansi") as { detectColor: (stdout: NodeJS.WriteStream) => string | null }
49
+ _shouldColorize = detectColor(process.stdout) !== null
50
+ } catch {
51
+ // Fallback: basic NO_COLOR / FORCE_COLOR / isTTY checks
52
+ if (process.env.NO_COLOR !== undefined) {
53
+ _shouldColorize = false
54
+ } else if (process.env.FORCE_COLOR !== undefined) {
55
+ _shouldColorize = true
56
+ } else {
57
+ _shouldColorize = process.stdout?.isTTY ?? true
58
+ }
59
+ }
60
+
61
+ return _shouldColorize
62
+ }
63
+
36
64
  /** Wrap a string with ANSI codes, handling nested resets. */
37
65
  function ansi(text: string, code: string): string {
38
66
  return `${code}${text}${RESET}`
@@ -128,6 +156,9 @@ export function colorizeHelp(program: CommandLike, options?: ColorizeHelpOptions
128
156
  // Tell Commander that color output is supported, even when stdout is not
129
157
  // a TTY (e.g., piped output, CI, tests). Without this, Commander strips
130
158
  // all ANSI codes from helpInformation() output.
159
+ //
160
+ // Callers who want to respect NO_COLOR/FORCE_COLOR should check
161
+ // shouldColorize() before calling colorizeHelp().
131
162
  program.configureOutput({
132
163
  getOutHasColors: () => true,
133
164
  getErrHasColors: () => true,
package/src/typed.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * to infer option types from .option() calls. Inspired by
6
6
  * @commander-js/extra-typings, which achieves similar results with a
7
7
  * 1536-line .d.ts using recursive generic accumulation. This
8
- * implementation achieves the same inference in ~60 lines of type-level
8
+ * implementation achieves the same inference in ~100 lines of type-level
9
9
  * code by leveraging modern TS features (const type params, template
10
10
  * literal types, conditional mapped types).
11
11
  *
@@ -16,13 +16,13 @@
16
16
  * const cli = createCLI("myapp")
17
17
  * .description("My app")
18
18
  * .option("-v, --verbose", "Increase verbosity")
19
- * .option("-p, --port <number>", "Port to listen on")
19
+ * .option("-p, --port <number>", "Port to listen on", parseInt)
20
20
  * .option("-o, --output [path]", "Output path")
21
21
  * .option("--no-color", "Disable color output")
22
22
  *
23
23
  * cli.parse()
24
24
  * const opts = cli.opts()
25
- * // ^? { verbose: boolean, port: string, output: string | true, color: boolean }
25
+ * // ^? { verbose: boolean, port: number, output: string | true, color: boolean }
26
26
  * ```
27
27
  */
28
28
 
@@ -41,7 +41,7 @@ import { colorizeHelp } from "./index.ts"
41
41
  // Negated flags (--no-X) are detected and produce a `X: boolean` key.
42
42
 
43
43
  /** Flatten intersection types for clean hover output */
44
- type Prettify<T> = { [K in keyof T]: T[K] } & {}
44
+ export type Prettify<T> = { [K in keyof T]: T[K] } & {}
45
45
 
46
46
  /** Check if a flags string is a negated flag like "--no-color" */
47
47
  type IsNegated<S extends string> = S extends `${string}--no-${string}` ? true : false
@@ -72,27 +72,85 @@ type CamelCase<S extends string> = S extends `${infer A}-${infer B}${infer Rest}
72
72
  : S
73
73
 
74
74
  /** Determine the value type from a flags string */
75
- type FlagValueType<S extends string> = IsNegated<S> extends true
76
- ? boolean // negated flags are always boolean
77
- : S extends `${string}<${string}>`
78
- ? string // required arg → string
79
- : S extends `${string}[${string}]`
80
- ? string | true // optional arg → string | true
81
- : boolean // no arg → boolean
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
82
83
 
83
84
  /** Add a flag to an options record */
84
85
  type AddOption<Opts, Flags extends string, Default = undefined> = Opts & {
85
86
  [K in ExtractLongName<Flags>]: Default extends undefined ? FlagValueType<Flags> | undefined : FlagValueType<Flags>
86
87
  }
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
+
88
143
  // --- Typed Command ---
89
144
 
90
145
  /**
91
- * A Commander Command with inferred option types.
146
+ * A Commander Command with inferred option and argument types.
92
147
  * Wraps Commander's Command and tracks option types at the type level.
93
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)
94
152
  */
95
- export class TypedCommand<Opts = {}> {
153
+ export class TypedCommand<Opts = {}, Args extends any[] = []> {
96
154
  readonly _cmd: BaseCommand
97
155
 
98
156
  constructor(name?: string) {
@@ -112,14 +170,43 @@ export class TypedCommand<Opts = {}> {
112
170
  return this
113
171
  }
114
172
 
115
- /** Add an option with type inference */
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
+
116
195
  option<const F extends string, D = undefined>(
117
196
  flags: F,
118
197
  description?: string,
119
198
  defaultValue?: D,
120
- ): TypedCommand<AddOption<Opts, F, D>> {
121
- ;(this._cmd as any).option(flags, description ?? "", defaultValue)
122
- return this as any
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
123
210
  }
124
211
 
125
212
  /** Add a required option */
@@ -127,11 +214,31 @@ export class TypedCommand<Opts = {}> {
127
214
  flags: F,
128
215
  description?: string,
129
216
  defaultValue?: string,
130
- ): TypedCommand<Opts & { [K in ExtractLongName<F>]: FlagValueType<F> }> {
217
+ ): TypedCommand<Opts & { [K in ExtractLongName<F>]: FlagValueType<F> }, Args> {
131
218
  ;(this._cmd as any).requiredOption(flags, description ?? "", defaultValue)
132
219
  return this as any
133
220
  }
134
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
+
135
242
  /** Add a subcommand */
136
243
  command(nameAndArgs: string, description?: string): TypedCommand<{}> {
137
244
  const sub = (this._cmd as any).command(nameAndArgs, description)
@@ -142,14 +249,24 @@ export class TypedCommand<Opts = {}> {
142
249
  return typed
143
250
  }
144
251
 
145
- /** Add an argument */
146
- argument(name: string, description?: string, defaultValue?: unknown): this {
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>]> {
147
261
  this._cmd.argument(name, description ?? "", defaultValue)
148
- return this
262
+ return this as any
149
263
  }
150
264
 
151
- /** Set action handler */
152
- action(fn: (this: TypedCommand<Opts>, ...args: any[]) => void | Promise<void>): this {
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 {
153
270
  this._cmd.action(fn as any)
154
271
  return this
155
272
  }
@@ -263,6 +380,17 @@ export class TypedCommand<Opts = {}> {
263
380
  this._cmd.showSuggestionAfterError(displaySuggestion)
264
381
  return this
265
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
+ }
266
394
  }
267
395
 
268
396
  /**
@@ -276,11 +404,11 @@ export class TypedCommand<Opts = {}> {
276
404
  * .description("My tool")
277
405
  * .version("1.0.0")
278
406
  * .option("-v, --verbose", "Verbose output")
279
- * .option("-p, --port <number>", "Port")
407
+ * .option("-p, --port <number>", "Port", parseInt)
280
408
  *
281
409
  * program.parse()
282
410
  * const { verbose, port } = program.opts()
283
- * // ^boolean ^string | undefined
411
+ * // ^boolean ^number | undefined
284
412
  * ```
285
413
  */
286
414
  export function createCLI(name?: string): TypedCommand<{}> {