@silvery/commander 0.2.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
@@ -32,6 +32,66 @@ const program = new Command("myapp").description("My CLI tool")
32
32
  colorizeHelp(program) // applies recursively to all subcommands
33
33
  ```
34
34
 
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
+
35
95
  ## Custom parser type inference
36
96
 
37
97
  When `.option()` is called with a parser function as the third argument, the return type is inferred:
@@ -49,9 +109,9 @@ Default values can be passed as the fourth argument:
49
109
  .option("-p, --port <n>", "Port", parseInt, 8080) // → port: number (defaults to 8080)
50
110
  ```
51
111
 
52
- ## Zod schema validation
112
+ ## Standard Schema validation
53
113
 
54
- Pass a [Zod](https://zod.dev) schema as the third argument for combined parsing, validation, and type inference:
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:
55
115
 
56
116
  ```typescript
57
117
  import { z } from "zod"
@@ -71,7 +131,7 @@ const cli = createCLI("deploy")
71
131
  // → tags: string[] (transformed)
72
132
  ```
73
133
 
74
- Zod is an optional peer dependency -- duck-typed at runtime, never imported at the top level. If Zod validation fails, the error is formatted as a Commander-style error message.
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.
75
135
 
76
136
  ## Typed action handlers
77
137
 
@@ -117,11 +177,12 @@ Chain `.env()` to set an environment variable fallback for the last-added option
117
177
  | ---------------------- | --------------------------------------------------- | --------------------------------------------------------------- |
118
178
  | Type inference | 1536-line .d.ts with recursive generic accumulation | ~120 lines using TS 5.4+ const type params + template literals |
119
179
  | Custom parser types | Yes (.option with parseFloat -> number) | Yes (parser return type inference) |
120
- | Zod schema support | No | Yes (parse + validate + infer from Zod schemas) |
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.) |
121
182
  | Typed action handlers | Yes (full signature inference) | Yes (arguments + options) |
122
183
  | Choices narrowing | Via .addOption() | Via .optionWithChoices() |
123
184
  | Colorized help | Not included | Built-in via Commander's native style hooks |
124
- | Package size | Types only (25 lines runtime) | Types + colorizer + Zod bridge (~300 lines, zero required deps) |
185
+ | Package size | Types only (25 lines runtime) | Types + colorizer + schemas (~500 lines, zero required deps) |
125
186
  | Installation | Separate package alongside commander | Single package, re-exports Commander |
126
187
  | Negated flags (--no-X) | Partial | Key extraction + boolean type inference |
127
188
 
@@ -129,6 +190,7 @@ Chain `.env()` to set an environment variable fallback for the last-added option
129
190
 
130
191
  - [Commander.js](https://github.com/tj/commander.js) by TJ Holowaychuk and contributors -- the underlying CLI framework
131
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
132
194
  - [@silvery/ansi](https://github.com/beorn/silvery/tree/main/packages/ansi) -- optional ANSI color detection for respecting NO_COLOR/FORCE_COLOR/terminal capabilities
133
195
  - Uses Commander's built-in `configureHelp()` style hooks (added in Commander 12) for colorization
134
196
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@silvery/commander",
3
- "version": "0.2.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,7 +21,8 @@
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"
@@ -30,7 +31,9 @@
30
31
  "@commander-js/extra-typings": ">=12.0.0",
31
32
  "@silvery/ansi": ">=0.1.0",
32
33
  "commander": ">=12.0.0",
33
- "zod": ">=3.0.0"
34
+ "zod": ">=3.0.0",
35
+ "valibot": ">=1.0.0",
36
+ "arktype": ">=2.0.0"
34
37
  },
35
38
  "peerDependenciesMeta": {
36
39
  "commander": {
@@ -44,6 +47,12 @@
44
47
  },
45
48
  "zod": {
46
49
  "optional": true
50
+ },
51
+ "valibot": {
52
+ "optional": true
53
+ },
54
+ "arktype": {
55
+ "optional": true
47
56
  }
48
57
  },
49
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.
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
@@ -99,11 +99,50 @@ type ArgType<S extends string> = S extends `<${string}>`
99
99
  /** Resolve accumulated option types for use in action handler signatures */
100
100
  export type TypedOpts<Opts> = Prettify<Opts>
101
101
 
102
- // --- Zod schema support (duck-typed, no import) ---
102
+ // --- Standard Schema support (v1) ---
103
103
 
104
104
  /**
105
- * Duck-type interface for Zod-like schemas.
106
- * Avoids importing zod it's an optional peer dependency.
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.
107
146
  * Any object with `parse(value: string) => T` and `_def` qualifies.
108
147
  */
109
148
  interface ZodLike<T = any> {
@@ -114,18 +153,19 @@ interface ZodLike<T = any> {
114
153
  /** Type-level extraction: if Z is a Zod schema, infer its output type */
115
154
  type InferZodOutput<Z> = Z extends { parse(value: unknown): infer T; _def: unknown } ? T : never
116
155
 
117
- /** Runtime check: is this value a Zod-like schema? */
118
- function isZodSchema(value: unknown): value is ZodLike {
156
+ /** Runtime check: is this value a legacy Zod-like schema (without Standard Schema)? */
157
+ function isLegacyZodSchema(value: unknown): value is ZodLike {
119
158
  return (
120
159
  typeof value === "object" &&
121
160
  value !== null &&
122
161
  typeof (value as any).parse === "function" &&
123
- "_def" in (value as any)
162
+ "_def" in (value as any) &&
163
+ !("~standard" in (value as any))
124
164
  )
125
165
  }
126
166
 
127
- /** Wrap a Zod schema as a Commander parser function */
128
- function zodParser<T>(schema: ZodLike<T>): (value: string) => T {
167
+ /** Wrap a legacy Zod schema as a Commander parser function */
168
+ function legacyZodParser<T>(schema: ZodLike<T>): (value: string) => T {
129
169
  return (value: string) => {
130
170
  try {
131
171
  return schema.parse(value)
@@ -173,12 +213,19 @@ export class TypedCommand<Opts = {}, Args extends any[] = []> {
173
213
  /**
174
214
  * Add an option with type inference.
175
215
  *
176
- * Supports four overload patterns:
216
+ * Supports five overload patterns:
177
217
  * 1. `.option(flags, description?)` — type inferred from flags syntax
178
218
  * 2. `.option(flags, description, defaultValue)` — removes `undefined` from type
179
219
  * 3. `.option(flags, description, parser, defaultValue?)` — type inferred from parser return type
180
- * 4. `.option(flags, description, zodSchema)` — type inferred from Zod schema output
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)
181
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
+
182
229
  option<const F extends string, Z extends ZodLike>(
183
230
  flags: F,
184
231
  description: string,
@@ -199,8 +246,10 @@ export class TypedCommand<Opts = {}, Args extends any[] = []> {
199
246
  ): TypedCommand<AddOption<Opts, F, D>, Args>
200
247
 
201
248
  option(flags: string, description?: string, parseArgOrDefault?: any, defaultValue?: any): any {
202
- if (isZodSchema(parseArgOrDefault)) {
203
- ;(this._cmd as any).option(flags, description ?? "", zodParser(parseArgOrDefault))
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))
204
253
  } else if (typeof parseArgOrDefault === "function") {
205
254
  ;(this._cmd as any).option(flags, description ?? "", parseArgOrDefault, defaultValue)
206
255
  } else {