@silvery/commander 0.1.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 ADDED
@@ -0,0 +1,62 @@
1
+ # @silvery/commander
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.
4
+
5
+ ## Usage
6
+
7
+ ```typescript
8
+ import { createCLI } from "@silvery/commander"
9
+
10
+ const cli = createCLI("myapp")
11
+ .description("My CLI tool")
12
+ .version("1.0.0")
13
+ .option("-v, --verbose", "Verbose output")
14
+ .option("-p, --port <number>", "Port to listen on")
15
+ .option("-o, --output [path]", "Output path")
16
+ .option("--no-color", "Disable color output")
17
+
18
+ cli.parse()
19
+ const { verbose, port, output, color } = cli.opts()
20
+ // ^boolean ^string ^string|true ^boolean
21
+ ```
22
+
23
+ Help output is automatically colorized using Commander's built-in `configureHelp()` style hooks (headings bold, flags green, commands cyan, descriptions dim, arguments yellow).
24
+
25
+ You can also use `colorizeHelp()` standalone with a plain Commander `Command`:
26
+
27
+ ```typescript
28
+ import { Command } from "commander"
29
+ import { colorizeHelp } from "@silvery/commander"
30
+
31
+ const program = new Command("myapp").description("My CLI tool")
32
+ colorizeHelp(program) // applies recursively to all subcommands
33
+ ```
34
+
35
+ ## Improvements over @commander-js/extra-typings
36
+
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) |
47
+
48
+ ## What's planned
49
+
50
+ - Custom parser function type inference (`.option("-p, --port <n>", "Port", parseInt)` -> `number`)
51
+ - Typed action handler signatures
52
+ - `.choices()` narrowing to union types
53
+
54
+ ## Credits
55
+
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
58
+ - Uses Commander's built-in `configureHelp()` style hooks (added in Commander 12) for colorization
59
+
60
+ ## License
61
+
62
+ MIT
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@silvery/commander",
3
+ "version": "0.1.0",
4
+ "description": "Colorized Commander.js help output using ANSI escape codes",
5
+ "keywords": [
6
+ "ansi",
7
+ "cli",
8
+ "color",
9
+ "commander",
10
+ "help"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "Bjørn Stabell <bjorn@stabell.org>",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/beorn/silvery.git",
17
+ "directory": "packages/commander"
18
+ },
19
+ "files": [
20
+ "src"
21
+ ],
22
+ "type": "module",
23
+ "exports": {
24
+ ".": "./src/index.ts"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "peerDependencies": {
30
+ "@commander-js/extra-typings": ">=12.0.0",
31
+ "commander": ">=12.0.0"
32
+ },
33
+ "peerDependenciesMeta": {
34
+ "commander": {
35
+ "optional": true
36
+ },
37
+ "@commander-js/extra-typings": {
38
+ "optional": true
39
+ }
40
+ },
41
+ "engines": {
42
+ "node": ">=23.6.0"
43
+ }
44
+ }
package/src/index.ts ADDED
@@ -0,0 +1,140 @@
1
+ // Re-export Commander classes (drop-in replacement for @silvery/commander)
2
+ export { Command, Option, Argument, CommanderError, InvalidArgumentError, Help } from "commander"
3
+ export type { OptionValues } from "commander"
4
+
5
+ // Re-export typed CLI
6
+ export { TypedCommand, createCLI } from "./typed.ts"
7
+
8
+ /**
9
+ * Commander.js help colorization using ANSI escape codes.
10
+ *
11
+ * Uses Commander's built-in style hooks (styleTitle, styleOptionText, etc.)
12
+ * rather than regex post-processing. Works with @silvery/commander
13
+ * or plain commander — accepts a minimal CommandLike interface so Commander
14
+ * is a peer dependency, not a hard one.
15
+ *
16
+ * Zero dependencies — only raw ANSI escape codes.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * import { Command } from "@silvery/commander"
21
+ * import { colorizeHelp } from "@silvery/commander"
22
+ *
23
+ * const program = new Command("myapp").description("My CLI tool")
24
+ * colorizeHelp(program)
25
+ * ```
26
+ */
27
+
28
+ // Raw ANSI escape codes — no framework dependencies.
29
+ const RESET = "\x1b[0m"
30
+ const BOLD = "\x1b[1m"
31
+ const DIM = "\x1b[2m"
32
+ const CYAN = "\x1b[36m"
33
+ const GREEN = "\x1b[32m"
34
+ const YELLOW = "\x1b[33m"
35
+
36
+ /** Wrap a string with ANSI codes, handling nested resets. */
37
+ function ansi(text: string, code: string): string {
38
+ return `${code}${text}${RESET}`
39
+ }
40
+
41
+ /**
42
+ * Minimal interface for Commander's Command — avoids requiring Commander
43
+ * as a direct dependency. Works with both `commander` and
44
+ * `@silvery/commander`.
45
+ *
46
+ * Uses permissive types to ensure structural compatibility with all
47
+ * Commander versions, overloads, and generic instantiations.
48
+ */
49
+ export interface CommandLike {
50
+ // biome-ignore lint: permissive to match Commander's overloaded signatures
51
+ configureHelp(...args: any[]): any
52
+ // biome-ignore lint: permissive to match Commander's overloaded signatures
53
+ configureOutput(...args: any[]): any
54
+ // biome-ignore lint: permissive to match Commander's Command[] structurally
55
+ readonly commands: readonly any[]
56
+ }
57
+
58
+ /** Color scheme for help output. Values are raw ANSI escape sequences. */
59
+ export interface ColorizeHelpOptions {
60
+ /** ANSI code for command/subcommand names. Default: cyan */
61
+ commands?: string
62
+ /** ANSI code for --flags and -short options. Default: green */
63
+ flags?: string
64
+ /** ANSI code for description text. Default: dim */
65
+ description?: string
66
+ /** ANSI code for section headings (Usage:, Options:, etc.). Default: bold */
67
+ heading?: string
68
+ /** ANSI code for <required> and [optional] argument brackets. Default: yellow */
69
+ brackets?: string
70
+ }
71
+
72
+ /**
73
+ * Apply colorized help output to a Commander.js program and all its subcommands.
74
+ *
75
+ * Uses Commander's built-in `configureHelp()` style hooks rather than
76
+ * post-processing the formatted string. This approach is robust against
77
+ * formatting changes in Commander and handles wrapping correctly.
78
+ *
79
+ * @param program - A Commander Command instance (or compatible object)
80
+ * @param options - Override default ANSI color codes for each element
81
+ */
82
+ export function colorizeHelp(program: CommandLike, options?: ColorizeHelpOptions): void {
83
+ const cmds = options?.commands ?? CYAN
84
+ const flags = options?.flags ?? GREEN
85
+ const desc = options?.description ?? DIM
86
+ const heading = options?.heading ?? BOLD
87
+ const brackets = options?.brackets ?? YELLOW
88
+
89
+ const helpConfig: Record<string, unknown> = {
90
+ // Section headings: "Usage:", "Options:", "Commands:", "Arguments:"
91
+ styleTitle(str: string): string {
92
+ return ansi(str, heading)
93
+ },
94
+
95
+ // Command name in usage line and subcommand terms
96
+ styleCommandText(str: string): string {
97
+ return ansi(str, cmds)
98
+ },
99
+
100
+ // Option terms: "-v, --verbose", "--repo <path>", "[options]"
101
+ styleOptionText(str: string): string {
102
+ return ansi(str, flags)
103
+ },
104
+
105
+ // Subcommand names in the commands list
106
+ styleSubcommandText(str: string): string {
107
+ return ansi(str, cmds)
108
+ },
109
+
110
+ // Argument terms: "<file>", "[dir]"
111
+ styleArgumentText(str: string): string {
112
+ return ansi(str, brackets)
113
+ },
114
+
115
+ // Description text for options, subcommands, arguments
116
+ styleDescriptionText(str: string): string {
117
+ return ansi(str, desc)
118
+ },
119
+
120
+ // Command description (the main program description line) — keep normal
121
+ styleCommandDescription(str: string): string {
122
+ return str
123
+ },
124
+ }
125
+
126
+ program.configureHelp(helpConfig)
127
+
128
+ // Tell Commander that color output is supported, even when stdout is not
129
+ // a TTY (e.g., piped output, CI, tests). Without this, Commander strips
130
+ // all ANSI codes from helpInformation() output.
131
+ program.configureOutput({
132
+ getOutHasColors: () => true,
133
+ getErrHasColors: () => true,
134
+ })
135
+
136
+ // Apply recursively to all existing subcommands
137
+ for (const sub of program.commands) {
138
+ colorizeHelp(sub, options)
139
+ }
140
+ }
package/src/typed.ts ADDED
@@ -0,0 +1,288 @@
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 ~60 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")
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: string, 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
+ 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> = 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
82
+
83
+ /** Add a flag to an options record */
84
+ type AddOption<Opts, Flags extends string, Default = undefined> = Opts & {
85
+ [K in ExtractLongName<Flags>]: Default extends undefined ? FlagValueType<Flags> | undefined : FlagValueType<Flags>
86
+ }
87
+
88
+ // --- Typed Command ---
89
+
90
+ /**
91
+ * A Commander Command with inferred option types.
92
+ * Wraps Commander's Command and tracks option types at the type level.
93
+ * Help is automatically colorized.
94
+ */
95
+ export class TypedCommand<Opts = {}> {
96
+ readonly _cmd: BaseCommand
97
+
98
+ constructor(name?: string) {
99
+ this._cmd = new BaseCommand(name)
100
+ colorizeHelp(this._cmd as any)
101
+ }
102
+
103
+ /** Set program description */
104
+ description(str: string, argsDescription?: Record<string, string>): this {
105
+ this._cmd.description(str, argsDescription as any)
106
+ return this
107
+ }
108
+
109
+ /** Set program version */
110
+ version(str: string, flags?: string, description?: string): this {
111
+ this._cmd.version(str, flags, description)
112
+ return this
113
+ }
114
+
115
+ /** Add an option with type inference */
116
+ option<const F extends string, D = undefined>(
117
+ flags: F,
118
+ description?: string,
119
+ defaultValue?: D,
120
+ ): TypedCommand<AddOption<Opts, F, D>> {
121
+ ;(this._cmd as any).option(flags, description ?? "", defaultValue)
122
+ return this as any
123
+ }
124
+
125
+ /** Add a required option */
126
+ requiredOption<const F extends string>(
127
+ flags: F,
128
+ description?: string,
129
+ defaultValue?: string,
130
+ ): TypedCommand<Opts & { [K in ExtractLongName<F>]: FlagValueType<F> }> {
131
+ ;(this._cmd as any).requiredOption(flags, description ?? "", defaultValue)
132
+ return this as any
133
+ }
134
+
135
+ /** Add a subcommand */
136
+ command(nameAndArgs: string, description?: string): TypedCommand<{}> {
137
+ const sub = (this._cmd as any).command(nameAndArgs, description)
138
+ colorizeHelp(sub as any)
139
+ const typed = new TypedCommand<{}>()
140
+ // Replace the internal command with the one Commander created
141
+ ;(typed as any)._cmd = sub
142
+ return typed
143
+ }
144
+
145
+ /** Add an argument */
146
+ argument(name: string, description?: string, defaultValue?: unknown): this {
147
+ this._cmd.argument(name, description ?? "", defaultValue)
148
+ return this
149
+ }
150
+
151
+ /** Set action handler */
152
+ action(fn: (this: TypedCommand<Opts>, ...args: any[]) => void | Promise<void>): this {
153
+ this._cmd.action(fn as any)
154
+ return this
155
+ }
156
+
157
+ /** Get typed parsed options */
158
+ opts(): Prettify<Opts> {
159
+ return this._cmd.opts() as any
160
+ }
161
+
162
+ /** Parse argv */
163
+ parse(argv?: readonly string[], options?: { from?: "node" | "electron" | "user" }): this {
164
+ this._cmd.parse(argv as any, options as any)
165
+ return this
166
+ }
167
+
168
+ /** Parse argv async */
169
+ async parseAsync(argv?: readonly string[], options?: { from?: "node" | "electron" | "user" }): Promise<this> {
170
+ await this._cmd.parseAsync(argv as any, options as any)
171
+ return this
172
+ }
173
+
174
+ /** Get help text */
175
+ helpInformation(): string {
176
+ return this._cmd.helpInformation()
177
+ }
178
+
179
+ /** Allow unknown options */
180
+ allowUnknownOption(allow?: boolean): this {
181
+ this._cmd.allowUnknownOption(allow)
182
+ return this
183
+ }
184
+
185
+ /** Allow excess arguments */
186
+ allowExcessArguments(allow?: boolean): this {
187
+ this._cmd.allowExcessArguments(allow)
188
+ return this
189
+ }
190
+
191
+ /** Pass through options after -- */
192
+ passThroughOptions(passThrough?: boolean): this {
193
+ this._cmd.passThroughOptions(passThrough)
194
+ return this
195
+ }
196
+
197
+ /** Enable positional options */
198
+ enablePositionalOptions(positional?: boolean): this {
199
+ this._cmd.enablePositionalOptions(positional)
200
+ return this
201
+ }
202
+
203
+ /** Hook into lifecycle events */
204
+ hook(event: string, listener: (...args: any[]) => void | Promise<void>): this {
205
+ ;(this._cmd as any).hook(event, listener)
206
+ return this
207
+ }
208
+
209
+ /** Set custom name */
210
+ name(str: string): this {
211
+ this._cmd.name(str)
212
+ return this
213
+ }
214
+
215
+ /** Add alias */
216
+ alias(alias: string): this {
217
+ this._cmd.alias(alias)
218
+ return this
219
+ }
220
+
221
+ /** Add multiple aliases */
222
+ aliases(aliases: readonly string[]): this {
223
+ this._cmd.aliases(aliases as string[])
224
+ return this
225
+ }
226
+
227
+ /** Configure help display */
228
+ configureHelp(config: Record<string, unknown>): this {
229
+ ;(this._cmd as any).configureHelp(config)
230
+ return this
231
+ }
232
+
233
+ /** Configure output streams */
234
+ configureOutput(config: Record<string, unknown>): this {
235
+ ;(this._cmd as any).configureOutput(config)
236
+ return this
237
+ }
238
+
239
+ /** Access underlying Commander Command for advanced use */
240
+ get commands(): readonly BaseCommand[] {
241
+ return this._cmd.commands
242
+ }
243
+
244
+ /** Show help */
245
+ help(context?: { error?: boolean }): never {
246
+ return (this._cmd as any).help(context) as never
247
+ }
248
+
249
+ /** Add help text */
250
+ addHelpText(position: "before" | "after" | "beforeAll" | "afterAll", text: string): this {
251
+ this._cmd.addHelpText(position, text)
252
+ return this
253
+ }
254
+
255
+ /** Show help after error */
256
+ showHelpAfterError(displayHelp?: boolean | string): this {
257
+ this._cmd.showHelpAfterError(displayHelp)
258
+ return this
259
+ }
260
+
261
+ /** Show suggestion after error */
262
+ showSuggestionAfterError(displaySuggestion?: boolean): this {
263
+ this._cmd.showSuggestionAfterError(displaySuggestion)
264
+ return this
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Create a typed, colorized CLI program.
270
+ *
271
+ * @example
272
+ * ```ts
273
+ * import { createCLI } from "@silvery/commander"
274
+ *
275
+ * const program = createCLI("myapp")
276
+ * .description("My tool")
277
+ * .version("1.0.0")
278
+ * .option("-v, --verbose", "Verbose output")
279
+ * .option("-p, --port <number>", "Port")
280
+ *
281
+ * program.parse()
282
+ * const { verbose, port } = program.opts()
283
+ * // ^boolean ^string | undefined
284
+ * ```
285
+ */
286
+ export function createCLI(name?: string): TypedCommand<{}> {
287
+ return new TypedCommand(name)
288
+ }