@silvery/commander 0.1.0 → 0.3.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,166 @@ 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
+ ## Presets
36
+
37
+ 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
+
39
+ ```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"
48
+ ```
49
+
50
+ ### Standalone usage
51
+
52
+ Presets also work outside Commander for validating env vars, config files, etc. Import from the `@silvery/commander/parse` subpath for tree-shaking:
53
+
54
+ ```typescript
55
+ import { port, csv, oneOf } from "@silvery/commander/parse"
56
+
57
+ // .parse() — returns value or throws
58
+ const dbPort = port.parse(process.env.DB_PORT ?? "5432") // 3000
59
+
60
+ // .safeParse() — returns result object, never throws
61
+ const result = port.safeParse("abc")
62
+ // { success: false, issues: [{ message: 'Expected port (1-65535), got "abc"' }] }
63
+
64
+ // Standard Schema ~standard.validate() also available
65
+ const validated = port["~standard"].validate("8080")
66
+ // { value: 8080 }
67
+ ```
68
+
69
+ ### Available presets
70
+
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 |
85
+
86
+ ### Factory presets
87
+
88
+ ```typescript
89
+ import { intRange, oneOf } from "@silvery/commander"
90
+
91
+ intRange(1, 100) // Preset<number> — integer within bounds
92
+ oneOf(["a", "b", "c"]) // Preset<"a" | "b" | "c"> — enum from values
93
+ ```
94
+
95
+ ## Custom parser type inference
96
+
97
+ When `.option()` is called with a parser function as the third argument, the return type is inferred:
98
+
99
+ ```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[]
104
+ ```
105
+
106
+ Default values can be passed as the fourth argument:
107
+
108
+ ```typescript
109
+ .option("-p, --port <n>", "Port", parseInt, 8080) // → port: number (defaults to 8080)
110
+ ```
111
+
112
+ ## Standard Schema validation
36
113
 
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) |
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:
47
115
 
48
- ## What's planned
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
+ })
148
+ ```
149
+
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
49
175
 
50
- - Custom parser function type inference (`.option("-p, --port <n>", "Port", parseInt)` -> `number`)
51
- - Typed action handler signatures
52
- - `.choices()` narrowing to union types
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 |
53
188
 
54
189
  ## Credits
55
190
 
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
191
+ - [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
+ - [Standard Schema](https://github.com/standard-schema/standard-schema) -- universal schema interop protocol for type-safe validation
194
+ - [@silvery/ansi](https://github.com/beorn/silvery/tree/main/packages/ansi) -- optional ANSI color detection for respecting NO_COLOR/FORCE_COLOR/terminal capabilities
58
195
  - Uses Commander's built-in `configureHelp()` style hooks (added in Commander 12) for colorization
59
196
 
60
197
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@silvery/commander",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Colorized Commander.js help output using ANSI escape codes",
5
5
  "keywords": [
6
6
  "ansi",
@@ -21,14 +21,19 @@
21
21
  ],
22
22
  "type": "module",
23
23
  "exports": {
24
- ".": "./src/index.ts"
24
+ ".": "./src/index.ts",
25
+ "./parse": "./src/presets.ts"
25
26
  },
26
27
  "publishConfig": {
27
28
  "access": "public"
28
29
  },
29
30
  "peerDependencies": {
30
31
  "@commander-js/extra-typings": ">=12.0.0",
31
- "commander": ">=12.0.0"
32
+ "@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"
32
37
  },
33
38
  "peerDependenciesMeta": {
34
39
  "commander": {
@@ -36,6 +41,18 @@
36
41
  },
37
42
  "@commander-js/extra-typings": {
38
43
  "optional": true
44
+ },
45
+ "@silvery/ansi": {
46
+ "optional": true
47
+ },
48
+ "zod": {
49
+ "optional": true
50
+ },
51
+ "valibot": {
52
+ "optional": true
53
+ },
54
+ "arktype": {
55
+ "optional": true
39
56
  }
40
57
  },
41
58
  "engines": {
package/src/index.ts CHANGED
@@ -4,6 +4,11 @@ export type { OptionValues } from "commander"
4
4
 
5
5
  // Re-export typed CLI
6
6
  export { TypedCommand, createCLI } from "./typed.ts"
7
+ export type { StandardSchemaV1 } from "./typed.ts"
8
+
9
+ // Re-export presets
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"
7
12
 
8
13
  /**
9
14
  * Commander.js help colorization using ANSI escape codes.
@@ -33,6 +38,34 @@ const CYAN = "\x1b[36m"
33
38
  const GREEN = "\x1b[32m"
34
39
  const YELLOW = "\x1b[33m"
35
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
+
36
69
  /** Wrap a string with ANSI codes, handling nested resets. */
37
70
  function ansi(text: string, code: string): string {
38
71
  return `${code}${text}${RESET}`
@@ -128,6 +161,9 @@ export function colorizeHelp(program: CommandLike, options?: ColorizeHelpOptions
128
161
  // Tell Commander that color output is supported, even when stdout is not
129
162
  // a TTY (e.g., piped output, CI, tests). Without this, Commander strips
130
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().
131
167
  program.configureOutput({
132
168
  getOutHasColors: () => true,
133
169
  getErrHasColors: () => true,
package/src/presets.ts ADDED
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Pre-built Standard Schema v1 presets for common CLI argument patterns.
3
+ *
4
+ * Zero dependencies — validation is manual, no Zod/Valibot/ArkType required.
5
+ * Each preset implements Standard Schema v1 for interop with any schema library,
6
+ * plus standalone `.parse()` and `.safeParse()` convenience methods.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * import { createCLI, port, csv, int, url, oneOf } from "@silvery/commander"
11
+ *
12
+ * const cli = createCLI("deploy")
13
+ * .option("-p, --port <n>", "Port", port) // number (1-65535)
14
+ * .option("-r, --retries <n>", "Retries", int) // number (integer)
15
+ * .option("--tags <t>", "Tags", csv) // string[]
16
+ * .option("--callback <url>", "Callback", url) // string (validated URL)
17
+ * .option("-e, --env <e>", "Env", oneOf(["dev", "staging", "prod"]))
18
+ *
19
+ * // Standalone usage (outside Commander)
20
+ * port.parse("3000") // 3000
21
+ * port.safeParse("abc") // { success: false, issues: [{ message: "..." }] }
22
+ * ```
23
+ */
24
+
25
+ import type { StandardSchemaV1 } from "./typed.ts"
26
+
27
+ /** A Standard Schema v1 preset with standalone parse/safeParse methods. */
28
+ export interface Preset<T> extends StandardSchemaV1<T> {
29
+ /** Parse and validate a value, throwing on failure. */
30
+ parse(value: unknown): T
31
+ /** Parse and validate a value, returning a result object. */
32
+ safeParse(value: unknown): { success: true; value: T } | { success: false; issues: Array<{ message: string }> }
33
+ }
34
+
35
+ function createPreset<T>(vendor: string, validate: (value: unknown) => T): Preset<T> {
36
+ const schema: Preset<T> = {
37
+ "~standard": {
38
+ version: 1,
39
+ vendor,
40
+ validate: (value) => {
41
+ try {
42
+ return { value: validate(value) }
43
+ } catch (e: any) {
44
+ return { issues: [{ message: e.message }] }
45
+ }
46
+ },
47
+ },
48
+ parse(value: unknown): T {
49
+ const result = schema["~standard"].validate(value)
50
+ if ("issues" in result) throw new Error(result.issues[0]?.message ?? "Validation failed")
51
+ return result.value
52
+ },
53
+ safeParse(value: unknown) {
54
+ const result = schema["~standard"].validate(value)
55
+ if ("issues" in result) return { success: false as const, issues: [...result.issues] }
56
+ return { success: true as const, value: result.value }
57
+ },
58
+ }
59
+ return schema
60
+ }
61
+
62
+ const VENDOR = "@silvery/commander"
63
+
64
+ /** Integer (coerced from string). */
65
+ export const int = createPreset<number>(VENDOR, (v) => {
66
+ const s = String(v).trim()
67
+ if (s === "") throw new Error(`Expected integer, got "${v}"`)
68
+ const n = Number(s)
69
+ if (!Number.isInteger(n)) throw new Error(`Expected integer, got "${v}"`)
70
+ return n
71
+ })
72
+
73
+ /** Unsigned integer (>= 0, coerced from string). */
74
+ export const uint = createPreset<number>(VENDOR, (v) => {
75
+ const s = String(v).trim()
76
+ if (s === "") throw new Error(`Expected unsigned integer (>= 0), got "${v}"`)
77
+ const n = Number(s)
78
+ if (!Number.isInteger(n) || n < 0) throw new Error(`Expected unsigned integer (>= 0), got "${v}"`)
79
+ return n
80
+ })
81
+
82
+ /** Float (coerced from string). */
83
+ export const float = createPreset<number>(VENDOR, (v) => {
84
+ const s = String(v).trim()
85
+ if (s === "" || s === "NaN") throw new Error(`Expected number, got "${v}"`)
86
+ const n = Number(s)
87
+ if (Number.isNaN(n)) throw new Error(`Expected number, got "${v}"`)
88
+ return n
89
+ })
90
+
91
+ /** Port number (1-65535). */
92
+ export const port = createPreset<number>(VENDOR, (v) => {
93
+ const n = Number(v)
94
+ if (!Number.isInteger(n) || n < 1 || n > 65535) throw new Error(`Expected port (1-65535), got "${v}"`)
95
+ return n
96
+ })
97
+
98
+ /** URL (validated via URL constructor). */
99
+ export const url = createPreset<string>(VENDOR, (v) => {
100
+ const s = String(v)
101
+ try {
102
+ new URL(s)
103
+ return s
104
+ } catch {
105
+ throw new Error(`Expected valid URL, got "${v}"`)
106
+ }
107
+ })
108
+
109
+ /** File path (non-empty string). */
110
+ export const path = createPreset<string>(VENDOR, (v) => {
111
+ const s = String(v)
112
+ if (!s) throw new Error("Expected non-empty path")
113
+ return s
114
+ })
115
+
116
+ /** Comma-separated values to string[]. */
117
+ export const csv = createPreset<string[]>(VENDOR, (v) => {
118
+ return String(v)
119
+ .split(",")
120
+ .map((s) => s.trim())
121
+ .filter(Boolean)
122
+ })
123
+
124
+ /** JSON string to parsed value. */
125
+ export const json = createPreset<unknown>(VENDOR, (v) => {
126
+ try {
127
+ return JSON.parse(String(v))
128
+ } catch {
129
+ throw new Error(`Expected valid JSON, got "${v}"`)
130
+ }
131
+ })
132
+
133
+ /** Boolean string ("true"/"false"/"1"/"0"/"yes"/"no"). */
134
+ export const bool = createPreset<boolean>(VENDOR, (v) => {
135
+ const s = String(v).toLowerCase()
136
+ if (["true", "1", "yes", "y"].includes(s)) return true
137
+ if (["false", "0", "no", "n"].includes(s)) return false
138
+ throw new Error(`Expected boolean (true/false/yes/no/1/0), got "${v}"`)
139
+ })
140
+
141
+ /** Date string to Date object. */
142
+ export const date = createPreset<Date>(VENDOR, (v) => {
143
+ const d = new Date(String(v))
144
+ if (isNaN(d.getTime())) throw new Error(`Expected valid date, got "${v}"`)
145
+ return d
146
+ })
147
+
148
+ /** Email address (basic validation). */
149
+ export const email = createPreset<string>(VENDOR, (v) => {
150
+ const s = String(v)
151
+ if (!s.includes("@") || !s.includes(".")) throw new Error(`Expected email address, got "${v}"`)
152
+ return s
153
+ })
154
+
155
+ /** Regex pattern string to RegExp. */
156
+ export const regex = createPreset<RegExp>(VENDOR, (v) => {
157
+ try {
158
+ return new RegExp(String(v))
159
+ } catch {
160
+ throw new Error(`Expected valid regex, got "${v}"`)
161
+ }
162
+ })
163
+
164
+ /** Integer with min/max bounds (factory). */
165
+ export function intRange(min: number, max: number): Preset<number> {
166
+ return createPreset<number>(VENDOR, (v) => {
167
+ const n = Number(v)
168
+ if (!Number.isInteger(n) || n < min || n > max)
169
+ throw new Error(`Expected integer ${min}-${max}, got "${v}"`)
170
+ return n
171
+ })
172
+ }
173
+
174
+ /** Enum from a fixed set of string values (factory). */
175
+ export function oneOf<const T extends readonly string[]>(values: T): Preset<T[number]> {
176
+ return createPreset<T[number]>(VENDOR, (v) => {
177
+ const s = String(v)
178
+ if (!values.includes(s as any)) throw new Error(`Expected one of [${values.join(", ")}], got "${v}"`)
179
+ return s as T[number]
180
+ })
181
+ }
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,125 @@ 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
+ // --- 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
+
88
183
  // --- Typed Command ---
89
184
 
90
185
  /**
91
- * A Commander Command with inferred option types.
186
+ * A Commander Command with inferred option and argument types.
92
187
  * Wraps Commander's Command and tracks option types at the type level.
93
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)
94
192
  */
95
- export class TypedCommand<Opts = {}> {
193
+ export class TypedCommand<Opts = {}, Args extends any[] = []> {
96
194
  readonly _cmd: BaseCommand
97
195
 
98
196
  constructor(name?: string) {
@@ -112,14 +210,52 @@ export class TypedCommand<Opts = {}> {
112
210
  return this
113
211
  }
114
212
 
115
- /** Add an option with type inference */
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
+
116
242
  option<const F extends string, D = undefined>(
117
243
  flags: F,
118
244
  description?: string,
119
245
  defaultValue?: D,
120
- ): TypedCommand<AddOption<Opts, F, D>> {
121
- ;(this._cmd as any).option(flags, description ?? "", defaultValue)
122
- return this as any
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
123
259
  }
124
260
 
125
261
  /** Add a required option */
@@ -127,11 +263,31 @@ export class TypedCommand<Opts = {}> {
127
263
  flags: F,
128
264
  description?: string,
129
265
  defaultValue?: string,
130
- ): TypedCommand<Opts & { [K in ExtractLongName<F>]: FlagValueType<F> }> {
266
+ ): TypedCommand<Opts & { [K in ExtractLongName<F>]: FlagValueType<F> }, Args> {
131
267
  ;(this._cmd as any).requiredOption(flags, description ?? "", defaultValue)
132
268
  return this as any
133
269
  }
134
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
+
135
291
  /** Add a subcommand */
136
292
  command(nameAndArgs: string, description?: string): TypedCommand<{}> {
137
293
  const sub = (this._cmd as any).command(nameAndArgs, description)
@@ -142,14 +298,24 @@ export class TypedCommand<Opts = {}> {
142
298
  return typed
143
299
  }
144
300
 
145
- /** Add an argument */
146
- argument(name: string, description?: string, defaultValue?: unknown): this {
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>]> {
147
310
  this._cmd.argument(name, description ?? "", defaultValue)
148
- return this
311
+ return this as any
149
312
  }
150
313
 
151
- /** Set action handler */
152
- action(fn: (this: TypedCommand<Opts>, ...args: any[]) => void | Promise<void>): this {
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 {
153
319
  this._cmd.action(fn as any)
154
320
  return this
155
321
  }
@@ -263,6 +429,17 @@ export class TypedCommand<Opts = {}> {
263
429
  this._cmd.showSuggestionAfterError(displaySuggestion)
264
430
  return this
265
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
+ }
266
443
  }
267
444
 
268
445
  /**
@@ -276,11 +453,11 @@ export class TypedCommand<Opts = {}> {
276
453
  * .description("My tool")
277
454
  * .version("1.0.0")
278
455
  * .option("-v, --verbose", "Verbose output")
279
- * .option("-p, --port <number>", "Port")
456
+ * .option("-p, --port <number>", "Port", parseInt)
280
457
  *
281
458
  * program.parse()
282
459
  * const { verbose, port } = program.opts()
283
- * // ^boolean ^string | undefined
460
+ * // ^boolean ^number | undefined
284
461
  * ```
285
462
  */
286
463
  export function createCLI(name?: string): TypedCommand<{}> {