@silvery/commander 0.3.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 CHANGED
@@ -1,23 +1,34 @@
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
+ Enhanced [Commander.js](https://github.com/tj/commander.js) with auto-colorized help, Standard Schema validation, and CLI presets. Drop-in replacement -- `Command` is a subclass of Commander's `Command`.
4
+
5
+ ## Three layers
6
+
7
+ ```typescript
8
+ // Layer 1: Enhanced Commander (auto-colorized help, Standard Schema support)
9
+ import { Command, port, csv } from "@silvery/commander"
10
+
11
+ // Layer 2: Zero-dep presets (Standard Schema, standalone use)
12
+ import { port, csv, int } from "@silvery/commander/parse"
13
+
14
+ // Layer 3: Zod + CLI presets (batteries included)
15
+ import { Command, z } from "@silvery/commander"
16
+ ```
4
17
 
5
18
  ## Usage
6
19
 
7
20
  ```typescript
8
- import { createCLI } from "@silvery/commander"
21
+ import { Command, port, csv, oneOf } from "@silvery/commander"
9
22
 
10
- const cli = createCLI("myapp")
11
- .description("My CLI tool")
23
+ const program = new Command("deploy")
24
+ .description("Deploy the application")
12
25
  .version("1.0.0")
13
- .option("-v, --verbose", "Verbose output")
14
- .option("-p, --port <number>", "Port to listen on", parseInt)
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 ^number ^string|true ^boolean
26
+ .option("-p, --port <n>", "Port", port) // number (1-65535)
27
+ .option("--tags <t>", "Tags", csv) // string[]
28
+ .option("-e, --env <e>", "Env", oneOf(["dev", "staging", "prod"]))
29
+
30
+ program.parse()
31
+ const opts = program.opts()
21
32
  ```
22
33
 
23
34
  Help output is automatically colorized using Commander's built-in `configureHelp()` style hooks (headings bold, flags green, commands cyan, descriptions dim, arguments yellow).
@@ -32,19 +43,57 @@ const program = new Command("myapp").description("My CLI tool")
32
43
  colorizeHelp(program) // applies recursively to all subcommands
33
44
  ```
34
45
 
46
+ ## Standard Schema validation
47
+
48
+ Pass any [Standard Schema v1](https://github.com/standard-schema/standard-schema) compatible schema as the third argument to `.option()` for combined parsing, validation, and type inference. This works with the built-in presets, Zod (>=3.24), Valibot (>=1.0), ArkType (>=2.0), and any other library implementing the standard:
49
+
50
+ ```typescript
51
+ import { Command } from "@silvery/commander"
52
+ import { z } from "zod"
53
+
54
+ const program = new Command("deploy")
55
+ .option("-p, --port <n>", "Port", z.coerce.number().min(1).max(65535))
56
+ .option("-e, --env <env>", "Env", z.enum(["dev", "staging", "prod"]))
57
+ .option(
58
+ "--tags <t>",
59
+ "Tags",
60
+ z.string().transform((v) => v.split(",")),
61
+ )
62
+ ```
63
+
64
+ Schema libraries are optional peer dependencies -- detected at runtime via the Standard Schema `~standard` interface, never imported at the top level. A legacy fallback supports older Zod versions (pre-3.24) that don't implement Standard Schema yet.
65
+
66
+ ## Zod CLI presets
67
+
68
+ Import `z` from `@silvery/commander` for an extended Zod object with CLI-specific schemas:
69
+
70
+ ```typescript
71
+ import { Command, z } from "@silvery/commander"
72
+
73
+ const program = new Command("deploy")
74
+ .option("-p, --port <n>", "Port", z.port) // z.coerce.number().int().min(1).max(65535)
75
+ .option("--tags <t>", "Tags", z.csv) // z.string().transform(...)
76
+ .option("-e, --env <e>", "Env", z.oneOf(["dev", "staging", "prod"]))
77
+ .option("-r, --retries <n>", "Retries", z.int) // z.coerce.number().int()
78
+ ```
79
+
80
+ The `z` export is tree-shakeable -- if you don't import it, Zod won't be in your bundle.
81
+
82
+ Available `z` CLI presets: `z.port`, `z.int`, `z.uint`, `z.float`, `z.csv`, `z.url`, `z.path`, `z.email`, `z.date`, `z.json`, `z.bool`, `z.intRange(min, max)`, `z.oneOf(values)`.
83
+
35
84
  ## Presets
36
85
 
37
86
  Pre-built validators for common CLI argument patterns. Each preset implements [Standard Schema v1](https://github.com/standard-schema/standard-schema) and works with Commander's `.option()` or standalone.
38
87
 
39
88
  ```typescript
40
- import { createCLI, port, csv, int, url, oneOf } from "@silvery/commander"
41
-
42
- const cli = createCLI("deploy")
43
- .option("-p, --port <n>", "Port", port) // number (1-65535, validated)
44
- .option("-r, --retries <n>", "Retries", int) // number (integer)
45
- .option("--tags <t>", "Tags", csv) // string[]
46
- .option("--callback <url>", "Callback", url) // string (validated URL)
47
- .option("-e, --env <e>", "Env", oneOf(["dev", "staging", "prod"])) // "dev" | "staging" | "prod"
89
+ import { Command, port, csv, int, url, oneOf } from "@silvery/commander"
90
+
91
+ const program = new Command("deploy")
92
+ .option("-p, --port <n>", "Port", port) // number (1-65535, validated)
93
+ .option("-r, --retries <n>", "Retries", int) // number (integer)
94
+ .option("--tags <t>", "Tags", csv) // string[]
95
+ .option("--callback <url>", "Callback", url) // string (validated URL)
96
+ .option("-e, --env <e>", "Env", oneOf(["dev", "staging", "prod"]))
48
97
  ```
49
98
 
50
99
  ### Standalone usage
@@ -55,7 +104,7 @@ Presets also work outside Commander for validating env vars, config files, etc.
55
104
  import { port, csv, oneOf } from "@silvery/commander/parse"
56
105
 
57
106
  // .parse() — returns value or throws
58
- const dbPort = port.parse(process.env.DB_PORT ?? "5432") // 3000
107
+ const dbPort = port.parse(process.env.DB_PORT ?? "5432") // 3000
59
108
 
60
109
  // .safeParse() — returns result object, never throws
61
110
  const result = port.safeParse("abc")
@@ -68,128 +117,50 @@ const validated = port["~standard"].validate("8080")
68
117
 
69
118
  ### Available presets
70
119
 
71
- | Preset | Type | Validation |
72
- | ------ | ---- | ---------- |
73
- | `int` | `number` | Integer (coerced from string) |
74
- | `uint` | `number` | Unsigned integer (>= 0) |
75
- | `float` | `number` | Any finite number (rejects NaN) |
76
- | `port` | `number` | Integer 1-65535 |
77
- | `url` | `string` | Valid URL (via `URL` constructor) |
78
- | `path` | `string` | Non-empty string |
79
- | `csv` | `string[]` | Comma-separated, trimmed, empty filtered |
80
- | `json` | `unknown` | Parsed JSON |
81
- | `bool` | `boolean` | true/false/yes/no/1/0 (case-insensitive) |
82
- | `date` | `Date` | Valid date string |
83
- | `email` | `string` | Basic email validation (has @ and .) |
84
- | `regex` | `RegExp` | Valid regex pattern |
120
+ | Preset | Type | Validation |
121
+ | ------- | ---------- | ---------------------------------------- |
122
+ | `int` | `number` | Integer (coerced from string) |
123
+ | `uint` | `number` | Unsigned integer (>= 0) |
124
+ | `float` | `number` | Any finite number (rejects NaN) |
125
+ | `port` | `number` | Integer 1-65535 |
126
+ | `url` | `string` | Valid URL (via `URL` constructor) |
127
+ | `path` | `string` | Non-empty string |
128
+ | `csv` | `string[]` | Comma-separated, trimmed, empty filtered |
129
+ | `json` | `unknown` | Parsed JSON |
130
+ | `bool` | `boolean` | true/false/yes/no/1/0 (case-insensitive) |
131
+ | `date` | `Date` | Valid date string |
132
+ | `email` | `string` | Basic email validation (has @ and .) |
133
+ | `regex` | `RegExp` | Valid regex pattern |
85
134
 
86
135
  ### Factory presets
87
136
 
88
137
  ```typescript
89
138
  import { intRange, oneOf } from "@silvery/commander"
90
139
 
91
- intRange(1, 100) // Preset<number> — integer within bounds
140
+ intRange(1, 100) // Preset<number> — integer within bounds
92
141
  oneOf(["a", "b", "c"]) // Preset<"a" | "b" | "c"> — enum from values
93
142
  ```
94
143
 
95
144
  ## Custom parser type inference
96
145
 
97
- When `.option()` is called with a parser function as the third argument, the return type is inferred:
146
+ When `.option()` is called with a parser function as the third argument, Commander infers the return type:
98
147
 
99
148
  ```typescript
100
- const cli = createCLI("deploy")
101
- .option("-p, --port <n>", "Port", parseInt) // port: number
102
- .option("-t, --timeout <ms>", "Timeout", Number) // timeout: number
103
- .option("--tags <items>", "Tags", (v) => v.split(",")) // tags: string[]
149
+ const program = new Command("deploy")
150
+ .option("-p, --port <n>", "Port", parseInt) // port: number
151
+ .option("-t, --timeout <ms>", "Timeout", Number) // timeout: number
152
+ .option("--tags <items>", "Tags", (v) => v.split(",")) // tags: string[]
104
153
  ```
105
154
 
106
155
  Default values can be passed as the fourth argument:
107
156
 
108
157
  ```typescript
109
- .option("-p, --port <n>", "Port", parseInt, 8080) // port: number (defaults to 8080)
110
- ```
111
-
112
- ## Standard Schema validation
113
-
114
- Pass any [Standard Schema v1](https://github.com/standard-schema/standard-schema) compatible schema as the third argument for combined parsing, validation, and type inference. This works with Zod (>=3.24), Valibot (>=1.0), ArkType (>=2.0), and any other library implementing the standard:
115
-
116
- ```typescript
117
- import { z } from "zod"
118
-
119
- const cli = createCLI("deploy")
120
- .option("-p, --port <n>", "Port", z.coerce.number().min(1).max(65535))
121
- // → port: number (validated at parse time)
122
-
123
- .option("-e, --env <env>", "Environment", z.enum(["dev", "staging", "prod"]))
124
- // → env: "dev" | "staging" | "prod" (union type)
125
-
126
- .option(
127
- "--tags <t>",
128
- "Tags",
129
- z.string().transform((v) => v.split(",")),
130
- )
131
- // → tags: string[] (transformed)
132
- ```
133
-
134
- Schema libraries are optional peer dependencies -- detected at runtime via the Standard Schema `~standard` interface, never imported at the top level. A legacy fallback supports older Zod versions (pre-3.24) that don't implement Standard Schema yet.
135
-
136
- ## Typed action handlers
137
-
138
- Action callbacks receive typed arguments and options:
139
-
140
- ```typescript
141
- const cli = createCLI("deploy")
142
- .argument("<env>", "Target environment")
143
- .argument("[tag]", "Optional deploy tag")
144
- .option("-f, --force", "Force deploy")
145
- .action((env, tag, opts) => {
146
- // env: string, tag: string | undefined, opts: { force: boolean | undefined }
147
- })
158
+ .option("-p, --port <n>", "Port", parseInt, 8080) // port: number (defaults to 8080)
148
159
  ```
149
160
 
150
- Required arguments (`<name>`) are `string`, optional arguments (`[name]`) are `string | undefined`.
151
-
152
- ## Choices narrowing
153
-
154
- Use `.optionWithChoices()` to restrict an option to a fixed set of values with union type inference:
155
-
156
- ```typescript
157
- const cli = createCLI("deploy").optionWithChoices("-e, --env <env>", "Environment", ["dev", "staging", "prod"] as const)
158
- // → env: "dev" | "staging" | "prod" | undefined
159
-
160
- cli.parse()
161
- const { env } = cli.opts() // env: "dev" | "staging" | "prod" | undefined
162
- ```
163
-
164
- Commander validates the choice at parse time and rejects invalid values.
165
-
166
- ## Environment variable support
167
-
168
- Chain `.env()` to set an environment variable fallback for the last-added option:
169
-
170
- ```typescript
171
- .option("-p, --port <n>", "Port").env("PORT")
172
- ```
173
-
174
- ## Improvements over @commander-js/extra-typings
175
-
176
- | Feature | extra-typings | @silvery/commander |
177
- | ---------------------- | --------------------------------------------------- | --------------------------------------------------------------- |
178
- | Type inference | 1536-line .d.ts with recursive generic accumulation | ~120 lines using TS 5.4+ const type params + template literals |
179
- | Custom parser types | Yes (.option with parseFloat -> number) | Yes (parser return type inference) |
180
- | Standard Schema | No | Yes (Zod, Valibot, ArkType, or any Standard Schema v1 library) |
181
- | Built-in presets | No | Yes (port, int, csv, url, oneOf, etc.) |
182
- | Typed action handlers | Yes (full signature inference) | Yes (arguments + options) |
183
- | Choices narrowing | Via .addOption() | Via .optionWithChoices() |
184
- | Colorized help | Not included | Built-in via Commander's native style hooks |
185
- | Package size | Types only (25 lines runtime) | Types + colorizer + schemas (~500 lines, zero required deps) |
186
- | Installation | Separate package alongside commander | Single package, re-exports Commander |
187
- | Negated flags (--no-X) | Partial | Key extraction + boolean type inference |
188
-
189
161
  ## Credits
190
162
 
191
163
  - [Commander.js](https://github.com/tj/commander.js) by TJ Holowaychuk and contributors -- the underlying CLI framework
192
- - [@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
193
164
  - [Standard Schema](https://github.com/standard-schema/standard-schema) -- universal schema interop protocol for type-safe validation
194
165
  - [@silvery/ansi](https://github.com/beorn/silvery/tree/main/packages/ansi) -- optional ANSI color detection for respecting NO_COLOR/FORCE_COLOR/terminal capabilities
195
166
  - Uses Commander's built-in `configureHelp()` style hooks (added in Commander 12) for colorization
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@silvery/commander",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Colorized Commander.js help output using ANSI escape codes",
5
5
  "keywords": [
6
6
  "ansi",
@@ -20,6 +20,7 @@
20
20
  "src"
21
21
  ],
22
22
  "type": "module",
23
+ "sideEffects": false,
23
24
  "exports": {
24
25
  ".": "./src/index.ts",
25
26
  "./parse": "./src/presets.ts"
@@ -27,32 +28,19 @@
27
28
  "publishConfig": {
28
29
  "access": "public"
29
30
  },
31
+ "dependencies": {
32
+ "commander": ">=12.0.0"
33
+ },
30
34
  "peerDependencies": {
31
- "@commander-js/extra-typings": ">=12.0.0",
32
35
  "@silvery/ansi": ">=0.1.0",
33
- "commander": ">=12.0.0",
34
- "zod": ">=3.0.0",
35
- "valibot": ">=1.0.0",
36
- "arktype": ">=2.0.0"
36
+ "zod": ">=3.0.0"
37
37
  },
38
38
  "peerDependenciesMeta": {
39
- "commander": {
40
- "optional": true
41
- },
42
- "@commander-js/extra-typings": {
43
- "optional": true
44
- },
45
39
  "@silvery/ansi": {
46
40
  "optional": true
47
41
  },
48
42
  "zod": {
49
43
  "optional": true
50
- },
51
- "valibot": {
52
- "optional": true
53
- },
54
- "arktype": {
55
- "optional": true
56
44
  }
57
45
  },
58
46
  "engines": {
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Commander.js help colorization using ANSI escape codes.
3
+ *
4
+ * Uses Commander's built-in style hooks (styleTitle, styleOptionText, etc.)
5
+ * rather than regex post-processing. Works with @silvery/commander
6
+ * or plain commander — accepts a minimal CommandLike interface so Commander
7
+ * is a peer dependency, not a hard one.
8
+ *
9
+ * Zero dependencies — only raw ANSI escape codes.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * import { Command } from "@silvery/commander"
14
+ * import { colorizeHelp } from "@silvery/commander"
15
+ *
16
+ * const program = new Command("myapp").description("My CLI tool")
17
+ * colorizeHelp(program)
18
+ * ```
19
+ */
20
+
21
+ // Raw ANSI escape codes — no framework dependencies.
22
+ const RESET = "\x1b[0m"
23
+ const BOLD = "\x1b[1m"
24
+ const DIM = "\x1b[2m"
25
+ const CYAN = "\x1b[36m"
26
+ const GREEN = "\x1b[32m"
27
+ const YELLOW = "\x1b[33m"
28
+
29
+ /**
30
+ * Check if color output should be enabled.
31
+ * Uses @silvery/ansi detectColor() if available, falls back to basic
32
+ * NO_COLOR/FORCE_COLOR/isTTY checks.
33
+ */
34
+ let _shouldColorize: boolean | undefined
35
+
36
+ export function shouldColorize(): boolean {
37
+ if (_shouldColorize !== undefined) return _shouldColorize
38
+
39
+ // Try @silvery/ansi for full detection (respects NO_COLOR, FORCE_COLOR, TERM, etc.)
40
+ try {
41
+ const { detectColor } = require("@silvery/ansi") as { detectColor: (stdout: NodeJS.WriteStream) => string | null }
42
+ _shouldColorize = detectColor(process.stdout) !== null
43
+ } catch {
44
+ // Fallback: basic NO_COLOR / FORCE_COLOR / isTTY checks
45
+ if (process.env.NO_COLOR !== undefined) {
46
+ _shouldColorize = false
47
+ } else if (process.env.FORCE_COLOR !== undefined) {
48
+ _shouldColorize = true
49
+ } else {
50
+ _shouldColorize = process.stdout?.isTTY ?? true
51
+ }
52
+ }
53
+
54
+ return _shouldColorize
55
+ }
56
+
57
+ /** Wrap a string with ANSI codes, handling nested resets. */
58
+ function ansi(text: string, code: string): string {
59
+ return `${code}${text}${RESET}`
60
+ }
61
+
62
+ /**
63
+ * Minimal interface for Commander's Command — avoids requiring Commander
64
+ * as a direct dependency. Works with both `commander` and
65
+ * `@silvery/commander`.
66
+ *
67
+ * Uses permissive types to ensure structural compatibility with all
68
+ * Commander versions, overloads, and generic instantiations.
69
+ */
70
+ export interface CommandLike {
71
+ // biome-ignore lint: permissive to match Commander's overloaded signatures
72
+ configureHelp(...args: any[]): any
73
+ // biome-ignore lint: permissive to match Commander's overloaded signatures
74
+ configureOutput(...args: any[]): any
75
+ // biome-ignore lint: permissive to match Commander's Command[] structurally
76
+ readonly commands: readonly any[]
77
+ }
78
+
79
+ /** Color scheme for help output. Values are raw ANSI escape sequences. */
80
+ export interface ColorizeHelpOptions {
81
+ /** ANSI code for command/subcommand names. Default: cyan */
82
+ commands?: string
83
+ /** ANSI code for --flags and -short options. Default: green */
84
+ flags?: string
85
+ /** ANSI code for description text. Default: dim */
86
+ description?: string
87
+ /** ANSI code for section headings (Usage:, Options:, etc.). Default: bold */
88
+ heading?: string
89
+ /** ANSI code for <required> and [optional] argument brackets. Default: yellow */
90
+ brackets?: string
91
+ }
92
+
93
+ /**
94
+ * Apply colorized help output to a Commander.js program and all its subcommands.
95
+ *
96
+ * Uses Commander's built-in `configureHelp()` style hooks rather than
97
+ * post-processing the formatted string. This approach is robust against
98
+ * formatting changes in Commander and handles wrapping correctly.
99
+ *
100
+ * @param program - A Commander Command instance (or compatible object)
101
+ * @param options - Override default ANSI color codes for each element
102
+ */
103
+ export function colorizeHelp(program: CommandLike, options?: ColorizeHelpOptions): void {
104
+ const cmds = options?.commands ?? CYAN
105
+ const flags = options?.flags ?? GREEN
106
+ const desc = options?.description ?? DIM
107
+ const heading = options?.heading ?? BOLD
108
+ const brackets = options?.brackets ?? YELLOW
109
+
110
+ const helpConfig: Record<string, unknown> = {
111
+ // Section headings: "Usage:", "Options:", "Commands:", "Arguments:"
112
+ styleTitle(str: string): string {
113
+ return ansi(str, heading)
114
+ },
115
+
116
+ // Command name in usage line and subcommand terms
117
+ styleCommandText(str: string): string {
118
+ return ansi(str, cmds)
119
+ },
120
+
121
+ // Option terms: "-v, --verbose", "--repo <path>", "[options]"
122
+ styleOptionText(str: string): string {
123
+ return ansi(str, flags)
124
+ },
125
+
126
+ // Subcommand names in the commands list
127
+ styleSubcommandText(str: string): string {
128
+ return ansi(str, cmds)
129
+ },
130
+
131
+ // Argument terms: "<file>", "[dir]"
132
+ styleArgumentText(str: string): string {
133
+ return ansi(str, brackets)
134
+ },
135
+
136
+ // Description text for options, subcommands, arguments
137
+ styleDescriptionText(str: string): string {
138
+ return ansi(str, desc)
139
+ },
140
+
141
+ // Command description (the main program description line) — keep normal
142
+ styleCommandDescription(str: string): string {
143
+ return str
144
+ },
145
+ }
146
+
147
+ program.configureHelp(helpConfig)
148
+
149
+ // Tell Commander that color output is supported, even when stdout is not
150
+ // a TTY (e.g., piped output, CI, tests). Without this, Commander strips
151
+ // all ANSI codes from helpInformation() output.
152
+ //
153
+ // Callers who want to respect NO_COLOR/FORCE_COLOR should check
154
+ // shouldColorize() before calling colorizeHelp().
155
+ program.configureOutput({
156
+ getOutHasColors: () => true,
157
+ getErrHasColors: () => true,
158
+ })
159
+
160
+ // Apply recursively to all existing subcommands
161
+ for (const sub of program.commands) {
162
+ colorizeHelp(sub, options)
163
+ }
164
+ }
package/src/command.ts ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Enhanced Commander Command with auto-colorized help and Standard Schema support.
3
+ *
4
+ * Subclasses Commander's Command so `new Command("app")` just works —
5
+ * it's Commander with auto-colorized help and automatic Standard Schema /
6
+ * legacy Zod detection in `.option()`.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * import { Command } from "@silvery/commander"
11
+ * import { port, csv } from "@silvery/commander/parse"
12
+ *
13
+ * const program = new Command("myapp")
14
+ * .description("My CLI tool")
15
+ * .version("1.0.0")
16
+ * .option("-p, --port <n>", "Port", port)
17
+ * .option("--tags <t>", "Tags", csv)
18
+ *
19
+ * program.parse()
20
+ * ```
21
+ */
22
+
23
+ import { Command as BaseCommand } from "commander"
24
+ import { colorizeHelp } from "./colorize.ts"
25
+ import type { StandardSchemaV1 } from "./presets.ts"
26
+
27
+ // --- Standard Schema support ---
28
+
29
+ /** Runtime check: is this value a Standard Schema v1 object? */
30
+ function isStandardSchema(value: unknown): value is StandardSchemaV1 {
31
+ return typeof value === "object" && value !== null && "~standard" in (value as any)
32
+ }
33
+
34
+ /** Wrap a Standard Schema as a Commander parser function */
35
+ function standardSchemaParser<T>(schema: StandardSchemaV1<T>): (value: string) => T {
36
+ return (value: string) => {
37
+ const result = schema["~standard"].validate(value)
38
+ if ("issues" in result) {
39
+ const msg = result.issues.map((i) => i.message).join(", ")
40
+ throw new Error(msg)
41
+ }
42
+ return result.value
43
+ }
44
+ }
45
+
46
+ // --- Legacy Zod support (pre-3.24, no ~standard) ---
47
+
48
+ /**
49
+ * Duck-type interface for older Zod schemas that don't implement Standard Schema.
50
+ * Any object with `parse(value: string) => T` and `_def` qualifies.
51
+ */
52
+ interface ZodLike<T = any> {
53
+ parse(value: unknown): T
54
+ _def: unknown
55
+ }
56
+
57
+ /** Runtime check: is this value a legacy Zod-like schema (without Standard Schema)? */
58
+ function isLegacyZodSchema(value: unknown): value is ZodLike {
59
+ return (
60
+ typeof value === "object" &&
61
+ value !== null &&
62
+ typeof (value as any).parse === "function" &&
63
+ "_def" in (value as any) &&
64
+ !("~standard" in (value as any))
65
+ )
66
+ }
67
+
68
+ /** Wrap a legacy Zod schema as a Commander parser function */
69
+ function legacyZodParser<T>(schema: ZodLike<T>): (value: string) => T {
70
+ return (value: string) => {
71
+ try {
72
+ return schema.parse(value)
73
+ } catch (err: any) {
74
+ // Format Zod errors as Commander-style messages
75
+ if (err?.issues) {
76
+ const messages = err.issues.map((i: any) => i.message).join(", ")
77
+ throw new Error(messages)
78
+ }
79
+ throw err
80
+ }
81
+ }
82
+ }
83
+
84
+ export class Command extends BaseCommand {
85
+ constructor(name?: string) {
86
+ super(name)
87
+ colorizeHelp(this as any)
88
+ }
89
+
90
+ /**
91
+ * Add an option with automatic Standard Schema / legacy Zod detection.
92
+ *
93
+ * When the third argument is a Standard Schema v1 object (Zod >=3.24,
94
+ * Valibot >=1.0, ArkType >=2.0, or @silvery/commander presets), it's
95
+ * automatically wrapped as a Commander parser function.
96
+ *
97
+ * When the third argument is a legacy Zod schema (pre-3.24, has `_def`
98
+ * and `parse` but no `~standard`), it's also wrapped automatically.
99
+ */
100
+ option(flags: string, description?: string, parseArgOrDefault?: any, defaultValue?: any): this {
101
+ if (isStandardSchema(parseArgOrDefault)) {
102
+ return super.option(flags, description ?? "", standardSchemaParser(parseArgOrDefault))
103
+ }
104
+ if (isLegacyZodSchema(parseArgOrDefault)) {
105
+ return super.option(flags, description ?? "", legacyZodParser(parseArgOrDefault))
106
+ }
107
+ if (typeof parseArgOrDefault === "function") {
108
+ return super.option(flags, description ?? "", parseArgOrDefault, defaultValue)
109
+ }
110
+ return super.option(flags, description ?? "", parseArgOrDefault)
111
+ }
112
+
113
+ // Subcommands also get colorized help and Standard Schema support
114
+ createCommand(name?: string): Command {
115
+ return new Command(name)
116
+ }
117
+ }
package/src/index.ts CHANGED
@@ -1,176 +1,14 @@
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"
1
+ // Enhanced Commander
2
+ export { Command } from "./command.ts"
3
+ export { colorizeHelp, shouldColorize, type ColorizeHelpOptions, type CommandLike } from "./colorize.ts"
4
4
 
5
- // Re-export typed CLI
6
- export { TypedCommand, createCLI } from "./typed.ts"
7
- export type { StandardSchemaV1 } from "./typed.ts"
5
+ // Re-export Commander's other classes
6
+ export { Option, Argument, CommanderError, InvalidArgumentError, Help } from "commander"
7
+ export type { OptionValues } from "commander"
8
8
 
9
- // Re-export presets
9
+ // Presets and Standard Schema type
10
10
  export { int, uint, float, port, url, path, csv, json, bool, date, email, regex, intRange, oneOf } from "./presets.ts"
11
- export type { Preset } from "./presets.ts"
12
-
13
- /**
14
- * Commander.js help colorization using ANSI escape codes.
15
- *
16
- * Uses Commander's built-in style hooks (styleTitle, styleOptionText, etc.)
17
- * rather than regex post-processing. Works with @silvery/commander
18
- * or plain commander — accepts a minimal CommandLike interface so Commander
19
- * is a peer dependency, not a hard one.
20
- *
21
- * Zero dependencies — only raw ANSI escape codes.
22
- *
23
- * @example
24
- * ```ts
25
- * import { Command } from "@silvery/commander"
26
- * import { colorizeHelp } from "@silvery/commander"
27
- *
28
- * const program = new Command("myapp").description("My CLI tool")
29
- * colorizeHelp(program)
30
- * ```
31
- */
32
-
33
- // Raw ANSI escape codes — no framework dependencies.
34
- const RESET = "\x1b[0m"
35
- const BOLD = "\x1b[1m"
36
- const DIM = "\x1b[2m"
37
- const CYAN = "\x1b[36m"
38
- const GREEN = "\x1b[32m"
39
- const YELLOW = "\x1b[33m"
40
-
41
- /**
42
- * Check if color output should be enabled.
43
- * Uses @silvery/ansi detectColor() if available, falls back to basic
44
- * NO_COLOR/FORCE_COLOR/isTTY checks.
45
- */
46
- let _shouldColorize: boolean | undefined
47
-
48
- export function shouldColorize(): boolean {
49
- if (_shouldColorize !== undefined) return _shouldColorize
50
-
51
- // Try @silvery/ansi for full detection (respects NO_COLOR, FORCE_COLOR, TERM, etc.)
52
- try {
53
- const { detectColor } = require("@silvery/ansi") as { detectColor: (stdout: NodeJS.WriteStream) => string | null }
54
- _shouldColorize = detectColor(process.stdout) !== null
55
- } catch {
56
- // Fallback: basic NO_COLOR / FORCE_COLOR / isTTY checks
57
- if (process.env.NO_COLOR !== undefined) {
58
- _shouldColorize = false
59
- } else if (process.env.FORCE_COLOR !== undefined) {
60
- _shouldColorize = true
61
- } else {
62
- _shouldColorize = process.stdout?.isTTY ?? true
63
- }
64
- }
65
-
66
- return _shouldColorize
67
- }
68
-
69
- /** Wrap a string with ANSI codes, handling nested resets. */
70
- function ansi(text: string, code: string): string {
71
- return `${code}${text}${RESET}`
72
- }
73
-
74
- /**
75
- * Minimal interface for Commander's Command — avoids requiring Commander
76
- * as a direct dependency. Works with both `commander` and
77
- * `@silvery/commander`.
78
- *
79
- * Uses permissive types to ensure structural compatibility with all
80
- * Commander versions, overloads, and generic instantiations.
81
- */
82
- export interface CommandLike {
83
- // biome-ignore lint: permissive to match Commander's overloaded signatures
84
- configureHelp(...args: any[]): any
85
- // biome-ignore lint: permissive to match Commander's overloaded signatures
86
- configureOutput(...args: any[]): any
87
- // biome-ignore lint: permissive to match Commander's Command[] structurally
88
- readonly commands: readonly any[]
89
- }
90
-
91
- /** Color scheme for help output. Values are raw ANSI escape sequences. */
92
- export interface ColorizeHelpOptions {
93
- /** ANSI code for command/subcommand names. Default: cyan */
94
- commands?: string
95
- /** ANSI code for --flags and -short options. Default: green */
96
- flags?: string
97
- /** ANSI code for description text. Default: dim */
98
- description?: string
99
- /** ANSI code for section headings (Usage:, Options:, etc.). Default: bold */
100
- heading?: string
101
- /** ANSI code for <required> and [optional] argument brackets. Default: yellow */
102
- brackets?: string
103
- }
104
-
105
- /**
106
- * Apply colorized help output to a Commander.js program and all its subcommands.
107
- *
108
- * Uses Commander's built-in `configureHelp()` style hooks rather than
109
- * post-processing the formatted string. This approach is robust against
110
- * formatting changes in Commander and handles wrapping correctly.
111
- *
112
- * @param program - A Commander Command instance (or compatible object)
113
- * @param options - Override default ANSI color codes for each element
114
- */
115
- export function colorizeHelp(program: CommandLike, options?: ColorizeHelpOptions): void {
116
- const cmds = options?.commands ?? CYAN
117
- const flags = options?.flags ?? GREEN
118
- const desc = options?.description ?? DIM
119
- const heading = options?.heading ?? BOLD
120
- const brackets = options?.brackets ?? YELLOW
121
-
122
- const helpConfig: Record<string, unknown> = {
123
- // Section headings: "Usage:", "Options:", "Commands:", "Arguments:"
124
- styleTitle(str: string): string {
125
- return ansi(str, heading)
126
- },
127
-
128
- // Command name in usage line and subcommand terms
129
- styleCommandText(str: string): string {
130
- return ansi(str, cmds)
131
- },
132
-
133
- // Option terms: "-v, --verbose", "--repo <path>", "[options]"
134
- styleOptionText(str: string): string {
135
- return ansi(str, flags)
136
- },
137
-
138
- // Subcommand names in the commands list
139
- styleSubcommandText(str: string): string {
140
- return ansi(str, cmds)
141
- },
142
-
143
- // Argument terms: "<file>", "[dir]"
144
- styleArgumentText(str: string): string {
145
- return ansi(str, brackets)
146
- },
147
-
148
- // Description text for options, subcommands, arguments
149
- styleDescriptionText(str: string): string {
150
- return ansi(str, desc)
151
- },
152
-
153
- // Command description (the main program description line) — keep normal
154
- styleCommandDescription(str: string): string {
155
- return str
156
- },
157
- }
158
-
159
- program.configureHelp(helpConfig)
160
-
161
- // Tell Commander that color output is supported, even when stdout is not
162
- // a TTY (e.g., piped output, CI, tests). Without this, Commander strips
163
- // all ANSI codes from helpInformation() output.
164
- //
165
- // Callers who want to respect NO_COLOR/FORCE_COLOR should check
166
- // shouldColorize() before calling colorizeHelp().
167
- program.configureOutput({
168
- getOutHasColors: () => true,
169
- getErrHasColors: () => true,
170
- })
11
+ export type { Preset, StandardSchemaV1 } from "./presets.ts"
171
12
 
172
- // Apply recursively to all existing subcommands
173
- for (const sub of program.commands) {
174
- colorizeHelp(sub, options)
175
- }
176
- }
13
+ // Tree-shakeable: only evaluated if user imports z
14
+ export { z } from "./z.ts"
package/src/presets.ts CHANGED
@@ -22,7 +22,23 @@
22
22
  * ```
23
23
  */
24
24
 
25
- import type { StandardSchemaV1 } from "./typed.ts"
25
+ /**
26
+ * Standard Schema v1 interface — the universal schema interop protocol.
27
+ * Supports any schema library that implements Standard Schema (Zod >=3.24,
28
+ * Valibot >=1.0, ArkType >=2.0, etc.).
29
+ *
30
+ * Inlined to avoid any dependency on @standard-schema/spec.
31
+ * See: https://github.com/standard-schema/standard-schema
32
+ */
33
+ export interface StandardSchemaV1<T = unknown> {
34
+ readonly "~standard": {
35
+ readonly version: 1
36
+ readonly vendor: string
37
+ readonly validate: (
38
+ value: unknown,
39
+ ) => { value: T } | { issues: ReadonlyArray<{ message: string; path?: ReadonlyArray<unknown> }> }
40
+ }
41
+ }
26
42
 
27
43
  /** A Standard Schema v1 preset with standalone parse/safeParse methods. */
28
44
  export interface Preset<T> extends StandardSchemaV1<T> {
@@ -165,8 +181,7 @@ export const regex = createPreset<RegExp>(VENDOR, (v) => {
165
181
  export function intRange(min: number, max: number): Preset<number> {
166
182
  return createPreset<number>(VENDOR, (v) => {
167
183
  const n = Number(v)
168
- if (!Number.isInteger(n) || n < min || n > max)
169
- throw new Error(`Expected integer ${min}-${max}, got "${v}"`)
184
+ if (!Number.isInteger(n) || n < min || n > max) throw new Error(`Expected integer ${min}-${max}, got "${v}"`)
170
185
  return n
171
186
  })
172
187
  }
package/src/z.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Extended Zod object with CLI presets.
3
+ *
4
+ * Spreads all of Zod's exports and adds CLI-specific schemas.
5
+ * Tree-shakeable — only evaluated if user imports `z`.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { z, Command } from "@silvery/commander"
10
+ *
11
+ * const program = new Command("deploy")
12
+ * .option("-p, --port <n>", "Port", z.port)
13
+ * .option("-e, --env <e>", "Environment", z.oneOf(["dev", "staging", "prod"]))
14
+ * .option("--tags <t>", "Tags", z.csv)
15
+ *
16
+ * program.parse()
17
+ * ```
18
+ */
19
+
20
+ import * as zod from "zod"
21
+
22
+ export const z = {
23
+ ...zod,
24
+ // CLI presets (built on Zod schemas)
25
+ port: zod.coerce.number().int().min(1).max(65535),
26
+ int: zod.coerce.number().int(),
27
+ uint: zod.coerce.number().int().min(0),
28
+ float: zod.coerce.number(),
29
+ csv: zod.string().transform((v: string) =>
30
+ v
31
+ .split(",")
32
+ .map((s: string) => s.trim())
33
+ .filter(Boolean),
34
+ ),
35
+ url: zod.string().url(),
36
+ path: zod.string().min(1),
37
+ email: zod.string().email(),
38
+ date: zod.coerce.date(),
39
+ json: zod.string().transform((v: string) => JSON.parse(v)),
40
+ bool: zod
41
+ .enum(["true", "false", "1", "0", "yes", "no", "y", "n"] as const)
42
+ .transform((v: string) => ["true", "1", "yes", "y"].includes(v)),
43
+ intRange: (min: number, max: number) => zod.coerce.number().int().min(min).max(max),
44
+ oneOf: <const T extends readonly [string, ...string[]]>(values: T) => zod.enum(values),
45
+ }
package/src/typed.ts DELETED
@@ -1,465 +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
- // --- Standard Schema support (v1) ---
103
-
104
- /**
105
- * Standard Schema v1 interface — the universal schema interop protocol.
106
- * Supports any schema library that implements Standard Schema (Zod >=3.24,
107
- * Valibot >=1.0, ArkType >=2.0, etc.).
108
- *
109
- * Inlined to avoid any dependency on @standard-schema/spec.
110
- * See: https://github.com/standard-schema/standard-schema
111
- */
112
- export interface StandardSchemaV1<T = unknown> {
113
- readonly "~standard": {
114
- readonly version: 1
115
- readonly vendor: string
116
- readonly validate: (
117
- value: unknown,
118
- ) => { value: T } | { issues: ReadonlyArray<{ message: string; path?: ReadonlyArray<unknown> }> }
119
- }
120
- }
121
-
122
- /** Type-level extraction: infer the output type from a Standard Schema */
123
- type InferStandardSchema<S> = S extends StandardSchemaV1<infer T> ? T : never
124
-
125
- /** Runtime check: is this value a Standard Schema v1 object? */
126
- function isStandardSchema(value: unknown): value is StandardSchemaV1 {
127
- return typeof value === "object" && value !== null && "~standard" in (value as any)
128
- }
129
-
130
- /** Wrap a Standard Schema as a Commander parser function */
131
- function standardSchemaParser<T>(schema: StandardSchemaV1<T>): (value: string) => T {
132
- return (value: string) => {
133
- const result = schema["~standard"].validate(value)
134
- if ("issues" in result) {
135
- const msg = result.issues.map((i) => i.message).join(", ")
136
- throw new Error(msg)
137
- }
138
- return result.value
139
- }
140
- }
141
-
142
- // --- Legacy Zod support (pre-3.24, no ~standard) ---
143
-
144
- /**
145
- * Duck-type interface for older Zod schemas that don't implement Standard Schema.
146
- * Any object with `parse(value: string) => T` and `_def` qualifies.
147
- */
148
- interface ZodLike<T = any> {
149
- parse(value: unknown): T
150
- _def: unknown
151
- }
152
-
153
- /** Type-level extraction: if Z is a Zod schema, infer its output type */
154
- type InferZodOutput<Z> = Z extends { parse(value: unknown): infer T; _def: unknown } ? T : never
155
-
156
- /** Runtime check: is this value a legacy Zod-like schema (without Standard Schema)? */
157
- function isLegacyZodSchema(value: unknown): value is ZodLike {
158
- return (
159
- typeof value === "object" &&
160
- value !== null &&
161
- typeof (value as any).parse === "function" &&
162
- "_def" in (value as any) &&
163
- !("~standard" in (value as any))
164
- )
165
- }
166
-
167
- /** Wrap a legacy Zod schema as a Commander parser function */
168
- function legacyZodParser<T>(schema: ZodLike<T>): (value: string) => T {
169
- return (value: string) => {
170
- try {
171
- return schema.parse(value)
172
- } catch (err: any) {
173
- // Format Zod errors as Commander-style messages
174
- if (err?.issues) {
175
- const messages = err.issues.map((i: any) => i.message).join(", ")
176
- throw new Error(messages)
177
- }
178
- throw err
179
- }
180
- }
181
- }
182
-
183
- // --- Typed Command ---
184
-
185
- /**
186
- * A Commander Command with inferred option and argument types.
187
- * Wraps Commander's Command and tracks option types at the type level.
188
- * Help is automatically colorized.
189
- *
190
- * @typeParam Opts - Accumulated option types from .option() calls
191
- * @typeParam Args - Accumulated argument types from .argument() calls (tuple)
192
- */
193
- export class TypedCommand<Opts = {}, Args extends any[] = []> {
194
- readonly _cmd: BaseCommand
195
-
196
- constructor(name?: string) {
197
- this._cmd = new BaseCommand(name)
198
- colorizeHelp(this._cmd as any)
199
- }
200
-
201
- /** Set program description */
202
- description(str: string, argsDescription?: Record<string, string>): this {
203
- this._cmd.description(str, argsDescription as any)
204
- return this
205
- }
206
-
207
- /** Set program version */
208
- version(str: string, flags?: string, description?: string): this {
209
- this._cmd.version(str, flags, description)
210
- return this
211
- }
212
-
213
- /**
214
- * Add an option with type inference.
215
- *
216
- * Supports five overload patterns:
217
- * 1. `.option(flags, description?)` — type inferred from flags syntax
218
- * 2. `.option(flags, description, defaultValue)` — removes `undefined` from type
219
- * 3. `.option(flags, description, parser, defaultValue?)` — type inferred from parser return type
220
- * 4. `.option(flags, description, standardSchema)` — type inferred from Standard Schema output
221
- * 5. `.option(flags, description, zodSchema)` — type inferred from Zod schema output (legacy, pre-3.24)
222
- */
223
- option<const F extends string, S extends StandardSchemaV1>(
224
- flags: F,
225
- description: string,
226
- schema: S,
227
- ): TypedCommand<Opts & { [K in ExtractLongName<F>]: InferStandardSchema<S> }, Args>
228
-
229
- option<const F extends string, Z extends ZodLike>(
230
- flags: F,
231
- description: string,
232
- schema: Z,
233
- ): TypedCommand<Opts & { [K in ExtractLongName<F>]: InferZodOutput<Z> }, Args>
234
-
235
- option<const F extends string, P extends (value: string, previous: any) => any>(
236
- flags: F,
237
- description: string,
238
- parseArg: P,
239
- defaultValue?: ReturnType<P>,
240
- ): TypedCommand<Opts & { [K in ExtractLongName<F>]: ReturnType<P> }, Args>
241
-
242
- option<const F extends string, D = undefined>(
243
- flags: F,
244
- description?: string,
245
- defaultValue?: D,
246
- ): TypedCommand<AddOption<Opts, F, D>, Args>
247
-
248
- option(flags: string, description?: string, parseArgOrDefault?: any, defaultValue?: any): any {
249
- if (isStandardSchema(parseArgOrDefault)) {
250
- ;(this._cmd as any).option(flags, description ?? "", standardSchemaParser(parseArgOrDefault))
251
- } else if (isLegacyZodSchema(parseArgOrDefault)) {
252
- ;(this._cmd as any).option(flags, description ?? "", legacyZodParser(parseArgOrDefault))
253
- } else if (typeof parseArgOrDefault === "function") {
254
- ;(this._cmd as any).option(flags, description ?? "", parseArgOrDefault, defaultValue)
255
- } else {
256
- ;(this._cmd as any).option(flags, description ?? "", parseArgOrDefault)
257
- }
258
- return this
259
- }
260
-
261
- /** Add a required option */
262
- requiredOption<const F extends string>(
263
- flags: F,
264
- description?: string,
265
- defaultValue?: string,
266
- ): TypedCommand<Opts & { [K in ExtractLongName<F>]: FlagValueType<F> }, Args> {
267
- ;(this._cmd as any).requiredOption(flags, description ?? "", defaultValue)
268
- return this as any
269
- }
270
-
271
- /**
272
- * Add an option with a fixed set of allowed values (choices).
273
- * The option type is narrowed to a union of the provided values.
274
- *
275
- * @example
276
- * ```ts
277
- * .optionWithChoices("-e, --env <env>", "Environment", ["dev", "staging", "prod"] as const)
278
- * // → env: "dev" | "staging" | "prod" | undefined
279
- * ```
280
- */
281
- optionWithChoices<const F extends string, const C extends readonly string[]>(
282
- flags: F,
283
- description: string,
284
- choices: C,
285
- ): TypedCommand<Opts & { [K in ExtractLongName<F>]: C[number] | undefined }, Args> {
286
- const option = new Option(flags, description).choices(choices as unknown as string[])
287
- ;(this._cmd as any).addOption(option)
288
- return this as any
289
- }
290
-
291
- /** Add a subcommand */
292
- command(nameAndArgs: string, description?: string): TypedCommand<{}> {
293
- const sub = (this._cmd as any).command(nameAndArgs, description)
294
- colorizeHelp(sub as any)
295
- const typed = new TypedCommand<{}>()
296
- // Replace the internal command with the one Commander created
297
- ;(typed as any)._cmd = sub
298
- return typed
299
- }
300
-
301
- /**
302
- * Add an argument with type tracking.
303
- * `<name>` = required (string), `[name]` = optional (string | undefined).
304
- */
305
- argument<const N extends string>(
306
- name: N,
307
- description?: string,
308
- defaultValue?: unknown,
309
- ): TypedCommand<Opts, [...Args, ArgType<N>]> {
310
- this._cmd.argument(name, description ?? "", defaultValue)
311
- return this as any
312
- }
313
-
314
- /**
315
- * Set action handler with typed parameters.
316
- * Callback receives: ...arguments, opts, command.
317
- */
318
- action(fn: (...args: [...Args, Prettify<Opts>, TypedCommand<Opts, Args>]) => void | Promise<void>): this {
319
- this._cmd.action(fn as any)
320
- return this
321
- }
322
-
323
- /** Get typed parsed options */
324
- opts(): Prettify<Opts> {
325
- return this._cmd.opts() as any
326
- }
327
-
328
- /** Parse argv */
329
- parse(argv?: readonly string[], options?: { from?: "node" | "electron" | "user" }): this {
330
- this._cmd.parse(argv as any, options as any)
331
- return this
332
- }
333
-
334
- /** Parse argv async */
335
- async parseAsync(argv?: readonly string[], options?: { from?: "node" | "electron" | "user" }): Promise<this> {
336
- await this._cmd.parseAsync(argv as any, options as any)
337
- return this
338
- }
339
-
340
- /** Get help text */
341
- helpInformation(): string {
342
- return this._cmd.helpInformation()
343
- }
344
-
345
- /** Allow unknown options */
346
- allowUnknownOption(allow?: boolean): this {
347
- this._cmd.allowUnknownOption(allow)
348
- return this
349
- }
350
-
351
- /** Allow excess arguments */
352
- allowExcessArguments(allow?: boolean): this {
353
- this._cmd.allowExcessArguments(allow)
354
- return this
355
- }
356
-
357
- /** Pass through options after -- */
358
- passThroughOptions(passThrough?: boolean): this {
359
- this._cmd.passThroughOptions(passThrough)
360
- return this
361
- }
362
-
363
- /** Enable positional options */
364
- enablePositionalOptions(positional?: boolean): this {
365
- this._cmd.enablePositionalOptions(positional)
366
- return this
367
- }
368
-
369
- /** Hook into lifecycle events */
370
- hook(event: string, listener: (...args: any[]) => void | Promise<void>): this {
371
- ;(this._cmd as any).hook(event, listener)
372
- return this
373
- }
374
-
375
- /** Set custom name */
376
- name(str: string): this {
377
- this._cmd.name(str)
378
- return this
379
- }
380
-
381
- /** Add alias */
382
- alias(alias: string): this {
383
- this._cmd.alias(alias)
384
- return this
385
- }
386
-
387
- /** Add multiple aliases */
388
- aliases(aliases: readonly string[]): this {
389
- this._cmd.aliases(aliases as string[])
390
- return this
391
- }
392
-
393
- /** Configure help display */
394
- configureHelp(config: Record<string, unknown>): this {
395
- ;(this._cmd as any).configureHelp(config)
396
- return this
397
- }
398
-
399
- /** Configure output streams */
400
- configureOutput(config: Record<string, unknown>): this {
401
- ;(this._cmd as any).configureOutput(config)
402
- return this
403
- }
404
-
405
- /** Access underlying Commander Command for advanced use */
406
- get commands(): readonly BaseCommand[] {
407
- return this._cmd.commands
408
- }
409
-
410
- /** Show help */
411
- help(context?: { error?: boolean }): never {
412
- return (this._cmd as any).help(context) as never
413
- }
414
-
415
- /** Add help text */
416
- addHelpText(position: "before" | "after" | "beforeAll" | "afterAll", text: string): this {
417
- this._cmd.addHelpText(position, text)
418
- return this
419
- }
420
-
421
- /** Show help after error */
422
- showHelpAfterError(displayHelp?: boolean | string): this {
423
- this._cmd.showHelpAfterError(displayHelp)
424
- return this
425
- }
426
-
427
- /** Show suggestion after error */
428
- showSuggestionAfterError(displaySuggestion?: boolean): this {
429
- this._cmd.showSuggestionAfterError(displaySuggestion)
430
- return this
431
- }
432
-
433
- /** Set environment variable for the last added option (passthrough) */
434
- env(name: string): this {
435
- // Commander's .env() is on Option, not Command. We apply it to the last option.
436
- const opts = (this._cmd as any).options as any[]
437
- if (opts.length > 0) {
438
- opts[opts.length - 1].envVar = name
439
- opts[opts.length - 1].envVarRequired = false
440
- }
441
- return this
442
- }
443
- }
444
-
445
- /**
446
- * Create a typed, colorized CLI program.
447
- *
448
- * @example
449
- * ```ts
450
- * import { createCLI } from "@silvery/commander"
451
- *
452
- * const program = createCLI("myapp")
453
- * .description("My tool")
454
- * .version("1.0.0")
455
- * .option("-v, --verbose", "Verbose output")
456
- * .option("-p, --port <number>", "Port", parseInt)
457
- *
458
- * program.parse()
459
- * const { verbose, port } = program.opts()
460
- * // ^boolean ^number | undefined
461
- * ```
462
- */
463
- export function createCLI(name?: string): TypedCommand<{}> {
464
- return new TypedCommand(name)
465
- }