@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 +157 -20
- package/package.json +20 -3
- package/src/index.ts +36 -0
- package/src/presets.ts +181 -0
- package/src/typed.ts +202 -25
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
|
|
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 ^
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
-
|
|
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
|
-
-
|
|
57
|
-
-
|
|
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.
|
|
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
|
-
"
|
|
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 ~
|
|
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:
|
|
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> =
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
/**
|
|
146
|
-
|
|
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
|
-
/**
|
|
152
|
-
|
|
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 ^
|
|
460
|
+
* // ^boolean ^number | undefined
|
|
284
461
|
* ```
|
|
285
462
|
*/
|
|
286
463
|
export function createCLI(name?: string): TypedCommand<{}> {
|