@silvery/commander 0.3.0 → 0.5.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,198 +1,178 @@
1
1
  # @silvery/commander
2
2
 
3
- Type-safe, colorized [Commander.js](https://github.com/tj/commander.js) wrapper. Infers option types from `.option()` calls using TypeScript 5.4+ const type parameters and template literal types -- no codegen, no separate type package.
3
+ Enhanced [Commander.js](https://github.com/tj/commander.js) with type-safe options, auto-colorized help, [Standard Schema](https://github.com/standard-schema/standard-schema) validation, and built-in CLI types.
4
+
5
+ Drop-in replacement -- `Command` is a subclass of Commander's `Command` with full type inference for options, arguments, and parsed values. Install once, Commander is included.
4
6
 
5
7
  ## Usage
6
8
 
7
9
  ```typescript
8
- import { createCLI } from "@silvery/commander"
10
+ import { Command, port, csv } from "@silvery/commander"
9
11
 
10
- const cli = createCLI("myapp")
11
- .description("My CLI tool")
12
+ new Command("deploy")
13
+ .description("Deploy the application")
12
14
  .version("1.0.0")
13
- .option("-v, --verbose", "Verbose output")
14
- .option("-p, --port <number>", "Port to listen on", parseInt)
15
- .option("-o, --output [path]", "Output path")
16
- .option("--no-color", "Disable color output")
17
-
18
- cli.parse()
19
- const { verbose, port, output, color } = cli.opts()
20
- // ^boolean ^number ^string|true ^boolean
21
- ```
22
-
23
- Help output is automatically colorized using Commander's built-in `configureHelp()` style hooks (headings bold, flags green, commands cyan, descriptions dim, arguments yellow).
24
-
25
- You can also use `colorizeHelp()` standalone with a plain Commander `Command`:
26
-
27
- ```typescript
28
- import { Command } from "commander"
29
- import { colorizeHelp } from "@silvery/commander"
15
+ .option("-p, --port <n>", "Port", port)
16
+ .option("--tags <t>", "Tags", csv)
17
+ .option("-e, --env <e>", "Env", ["dev", "staging", "prod"])
30
18
 
31
- const program = new Command("myapp").description("My CLI tool")
32
- colorizeHelp(program) // applies recursively to all subcommands
19
+ program.parse()
33
20
  ```
34
21
 
35
- ## Presets
22
+ Help output is automatically colorized -- bold headings, green flags, cyan commands, dim descriptions, yellow arguments. Uses [Commander's](https://github.com/tj/commander.js) built-in `configureHelp()` style hooks.
36
23
 
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.
24
+ Colorization works out of the box with raw ANSI codes. Install [`@silvery/ansi`](https://github.com/beorn/silvery/tree/main/packages/ansi) for full terminal capability detection (respects `NO_COLOR`, `FORCE_COLOR`, and `isTTY`).
38
25
 
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
26
+ ## Validated options with built-in types
51
27
 
52
- Presets also work outside Commander for validating env vars, config files, etc. Import from the `@silvery/commander/parse` subpath for tree-shaking:
28
+ Commander's `.option()` accepts a string and gives you a string back. Our built-in types parse and validate in one step:
53
29
 
54
30
  ```typescript
55
- import { port, csv, oneOf } from "@silvery/commander/parse"
31
+ import { Command, port, csv, int } from "@silvery/commander"
56
32
 
57
- // .parse() — returns value or throws
58
- const dbPort = port.parse(process.env.DB_PORT ?? "5432") // 3000
33
+ new Command("deploy")
34
+ .option("-p, --port <n>", "Port", port) // number (1-65535, validated)
35
+ .option("--tags <t>", "Tags", csv) // string[]
36
+ .option("-r, --retries <n>", "Retries", int) // number (integer)
37
+ .option("-e, --env <e>", "Env", ["dev", "staging", "prod"]) // choices
38
+ ```
59
39
 
60
- // .safeParse() returns result object, never throws
61
- const result = port.safeParse("abc")
62
- // { success: false, issues: [{ message: 'Expected port (1-65535), got "abc"' }] }
40
+ These types are **not part of Commander** -- they're provided by `@silvery/commander`. Each implements [Standard Schema v1](https://github.com/standard-schema/standard-schema), so they work with any schema-aware tooling. They have zero dependencies.
63
41
 
64
- // Standard Schema ~standard.validate() also available
65
- const validated = port["~standard"].validate("8080")
66
- // { value: 8080 }
67
- ```
42
+ ### Available types
68
43
 
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
44
+ | Type | Output | Validation |
45
+ | ------- | ---------- | ---------------------------------------- |
46
+ | `int` | `number` | Integer (coerced from string) |
47
+ | `uint` | `number` | Unsigned integer (>= 0) |
48
+ | `float` | `number` | Any finite number (rejects NaN) |
49
+ | `port` | `number` | Integer 1-65535 |
50
+ | `url` | `string` | Valid URL (via `URL` constructor) |
51
+ | `path` | `string` | Non-empty string |
52
+ | `csv` | `string[]` | Comma-separated, trimmed, empty filtered |
53
+ | `json` | `unknown` | Parsed JSON |
54
+ | `bool` | `boolean` | true/false/yes/no/1/0 (case-insensitive) |
55
+ | `date` | `Date` | Valid date string |
56
+ | `email` | `string` | Basic email validation |
57
+ | `regex` | `RegExp` | Valid regex pattern |
58
+
59
+ ### Factory type
87
60
 
88
61
  ```typescript
89
- import { intRange, oneOf } from "@silvery/commander"
62
+ import { intRange } from "@silvery/commander"
90
63
 
91
- intRange(1, 100) // Preset<number> integer within bounds
92
- oneOf(["a", "b", "c"]) // Preset<"a" | "b" | "c"> — enum from values
64
+ intRange(1, 100) // CLIType<number> -- integer within bounds
93
65
  ```
94
66
 
95
- ## Custom parser type inference
67
+ ### Array choices
96
68
 
97
- When `.option()` is called with a parser function as the third argument, the return type is inferred:
69
+ Pass an array as the third argument to restrict an option to a fixed set of values:
98
70
 
99
71
  ```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[]
72
+ .option("-e, --env <e>", "Env", ["dev", "staging", "prod"])
104
73
  ```
105
74
 
106
- Default values can be passed as the fourth argument:
75
+ Commander validates the choice at parse time and rejects invalid values.
76
+
77
+ ### Standalone usage
78
+
79
+ Types work outside Commander too -- for validating env vars, config files, etc.:
107
80
 
108
81
  ```typescript
109
- .option("-p, --port <n>", "Port", parseInt, 8080) // → port: number (defaults to 8080)
82
+ import { port, csv } from "@silvery/commander/parse"
83
+
84
+ port.parse("3000") // 3000
85
+ port.parse("abc") // throws: 'Expected port (1-65535), got "abc"'
86
+ port.safeParse("3000") // { success: true, value: 3000 }
87
+ port.safeParse("abc") // { success: false, issues: [{ message: "..." }] }
110
88
  ```
111
89
 
90
+ The `/parse` subpath has zero dependencies -- no Commander, no [Zod](https://github.com/colinhacks/zod).
91
+
112
92
  ## Standard Schema validation
113
93
 
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:
94
+ Pass any [Standard Schema v1](https://github.com/standard-schema/standard-schema) schema as the third argument to `.option()`. This works with [Zod](https://github.com/colinhacks/zod) (>=3.24), [Valibot](https://github.com/fabian-hiller/valibot) (>=1.0), [ArkType](https://github.com/arktypeio/arktype) (>=2.0), and any library implementing the protocol:
115
95
 
116
96
  ```typescript
97
+ import { Command } from "@silvery/commander"
117
98
  import { z } from "zod"
118
99
 
119
- const cli = createCLI("deploy")
100
+ new Command("deploy")
120
101
  .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
-
102
+ .option("-e, --env <env>", "Env", z.enum(["dev", "staging", "prod"]))
126
103
  .option(
127
104
  "--tags <t>",
128
105
  "Tags",
129
106
  z.string().transform((v) => v.split(",")),
130
107
  )
131
- // → tags: string[] (transformed)
132
108
  ```
133
109
 
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.
110
+ Schema libraries are optional peer dependencies -- detected at runtime, never imported at the top level.
135
111
 
136
- ## Typed action handlers
112
+ ## Zod CLI types
137
113
 
138
- Action callbacks receive typed arguments and options:
114
+ Import `z` from `@silvery/commander` for [Zod](https://github.com/colinhacks/zod) extended with CLI-specific schemas:
139
115
 
140
116
  ```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
- })
117
+ import { Command, z } from "@silvery/commander"
118
+
119
+ new Command("deploy")
120
+ .option("-p, --port <n>", "Port", z.port)
121
+ .option("--tags <t>", "Tags", z.csv)
122
+ .option("-r, --retries <n>", "Retries", z.int)
123
+ .option("-e, --env <e>", "Env", ["dev", "staging", "prod"])
148
124
  ```
149
125
 
150
- Required arguments (`<name>`) are `string`, optional arguments (`[name]`) are `string | undefined`.
126
+ The `z` export is tree-shakeable -- if you don't import it, [Zod](https://github.com/colinhacks/zod) won't be in your bundle. Requires `zod` as a peer dependency.
151
127
 
152
- ## Choices narrowing
128
+ Available: `z.port`, `z.int`, `z.uint`, `z.float`, `z.csv`, `z.url`, `z.path`, `z.email`, `z.date`, `z.json`, `z.bool`, `z.intRange(min, max)`.
153
129
 
154
- Use `.optionWithChoices()` to restrict an option to a fixed set of values with union type inference:
130
+ ## Function parsers
155
131
 
156
- ```typescript
157
- const cli = createCLI("deploy").optionWithChoices("-e, --env <env>", "Environment", ["dev", "staging", "prod"] as const)
158
- // → env: "dev" | "staging" | "prod" | undefined
132
+ [Commander's](https://github.com/tj/commander.js) standard parser function pattern also works:
159
133
 
160
- cli.parse()
161
- const { env } = cli.opts() // env: "dev" | "staging" | "prod" | undefined
134
+ ```typescript
135
+ new Command("app")
136
+ .option("-p, --port <n>", "Port", parseInt) // number
137
+ .option("--tags <items>", "Tags", (v) => v.split(",")) // string[]
138
+ .option("-p, --port <n>", "Port", parseInt, 8080) // number (with default)
162
139
  ```
163
140
 
164
- Commander validates the choice at parse time and rejects invalid values.
165
-
166
- ## Environment variable support
141
+ ## colorizeHelp()
167
142
 
168
- Chain `.env()` to set an environment variable fallback for the last-added option:
143
+ Use standalone with a plain [Commander](https://github.com/tj/commander.js) `Command` (without subclassing):
169
144
 
170
145
  ```typescript
171
- .option("-p, --port <n>", "Port").env("PORT")
146
+ import { Command } from "commander"
147
+ import { colorizeHelp } from "@silvery/commander"
148
+
149
+ const program = new Command("myapp")
150
+ colorizeHelp(program) // applies recursively to all subcommands
172
151
  ```
173
152
 
174
- ## Improvements over @commander-js/extra-typings
175
-
176
- | Feature | extra-typings | @silvery/commander |
177
- | ---------------------- | --------------------------------------------------- | --------------------------------------------------------------- |
178
- | Type inference | 1536-line .d.ts with recursive generic accumulation | ~120 lines using TS 5.4+ const type params + template literals |
179
- | Custom parser types | Yes (.option with parseFloat -> number) | Yes (parser return type inference) |
180
- | Standard Schema | No | Yes (Zod, Valibot, ArkType, or any Standard Schema v1 library) |
181
- | Built-in presets | No | Yes (port, int, csv, url, oneOf, etc.) |
182
- | Typed action handlers | Yes (full signature inference) | Yes (arguments + options) |
183
- | Choices narrowing | Via .addOption() | Via .optionWithChoices() |
184
- | Colorized help | Not included | Built-in via Commander's native style hooks |
185
- | Package size | Types only (25 lines runtime) | Types + colorizer + schemas (~500 lines, zero required deps) |
186
- | Installation | Separate package alongside commander | Single package, re-exports Commander |
187
- | Negated flags (--no-X) | Partial | Key extraction + boolean type inference |
153
+ ## Import paths
154
+
155
+ | Path | What | Dependencies |
156
+ | -------------------------- | ------------------------------- | ----------------------------------------------- |
157
+ | `@silvery/commander` | Command, colorizeHelp, types, z | [commander](https://github.com/tj/commander.js) |
158
+ | `@silvery/commander/parse` | Types only (.parse/.safeParse) | none |
159
+
160
+ ## Beyond extra-typings
161
+
162
+ Built on the shoulders of [@commander-js/extra-typings](https://github.com/commander-js/extra-typings). We add:
163
+
164
+ - **Auto-colorized help** -- bold headings, green flags, cyan commands
165
+ - **Built-in validation** via [Standard Schema](https://github.com/standard-schema/standard-schema) -- works with [Zod](https://github.com/colinhacks/zod), [Valibot](https://github.com/fabian-hiller/valibot), [ArkType](https://github.com/arktypeio/arktype)
166
+ - **14 CLI types** -- `port`, `csv`, `int`, `url`, `email` and more, usable standalone via `.parse()`/`.safeParse()`
167
+ - **NO_COLOR support** via [`@silvery/ansi`](https://github.com/beorn/silvery/tree/main/packages/ansi) (optional)
168
+ - **Commander included** -- one install, no peer dep setup
188
169
 
189
170
  ## Credits
190
171
 
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
195
- - Uses Commander's built-in `configureHelp()` style hooks (added in Commander 12) for colorization
172
+ - **[Commander.js](https://github.com/tj/commander.js)** by TJ Holowaychuk and contributors -- the underlying CLI framework
173
+ - **[@commander-js/extra-typings](https://github.com/commander-js/extra-typings)** -- inspired the type inference approach
174
+ - **[Standard Schema](https://github.com/standard-schema/standard-schema)** -- universal schema interop protocol
175
+ - **[@silvery/ansi](https://github.com/beorn/silvery/tree/main/packages/ansi)** -- optional terminal capability detection
196
176
 
197
177
  ## License
198
178
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@silvery/commander",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Colorized Commander.js help output using ANSI escape codes",
5
5
  "keywords": [
6
6
  "ansi",
@@ -20,6 +20,7 @@
20
20
  "src"
21
21
  ],
22
22
  "type": "module",
23
+ "sideEffects": false,
23
24
  "exports": {
24
25
  ".": "./src/index.ts",
25
26
  "./parse": "./src/presets.ts"
@@ -27,32 +28,19 @@
27
28
  "publishConfig": {
28
29
  "access": "public"
29
30
  },
31
+ "dependencies": {
32
+ "commander": ">=12.0.0"
33
+ },
30
34
  "peerDependencies": {
31
- "@commander-js/extra-typings": ">=12.0.0",
32
35
  "@silvery/ansi": ">=0.1.0",
33
- "commander": ">=12.0.0",
34
- "zod": ">=3.0.0",
35
- "valibot": ">=1.0.0",
36
- "arktype": ">=2.0.0"
36
+ "zod": ">=3.0.0"
37
37
  },
38
38
  "peerDependenciesMeta": {
39
- "commander": {
40
- "optional": true
41
- },
42
- "@commander-js/extra-typings": {
43
- "optional": true
44
- },
45
39
  "@silvery/ansi": {
46
40
  "optional": true
47
41
  },
48
42
  "zod": {
49
43
  "optional": true
50
- },
51
- "valibot": {
52
- "optional": true
53
- },
54
- "arktype": {
55
- "optional": true
56
44
  }
57
45
  },
58
46
  "engines": {
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Commander.js help colorization using ANSI escape codes.
3
+ *
4
+ * Uses Commander's built-in style hooks (styleTitle, styleOptionText, etc.)
5
+ * rather than regex post-processing. Works with @silvery/commander
6
+ * or plain commander — accepts a minimal CommandLike interface so Commander
7
+ * is a peer dependency, not a hard one.
8
+ *
9
+ * Zero dependencies — only raw ANSI escape codes.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * import { Command } from "@silvery/commander"
14
+ * import { colorizeHelp } from "@silvery/commander"
15
+ *
16
+ * const program = new Command("myapp").description("My CLI tool")
17
+ * colorizeHelp(program)
18
+ * ```
19
+ */
20
+
21
+ // Raw ANSI escape codes — no framework dependencies.
22
+ const RESET = "\x1b[0m"
23
+ const BOLD = "\x1b[1m"
24
+ const DIM = "\x1b[2m"
25
+ const CYAN = "\x1b[36m"
26
+ const GREEN = "\x1b[32m"
27
+ const YELLOW = "\x1b[33m"
28
+
29
+ /**
30
+ * Check if color output should be enabled.
31
+ * Uses @silvery/ansi detectColor() if available, falls back to basic
32
+ * NO_COLOR/FORCE_COLOR/isTTY checks.
33
+ */
34
+ let _shouldColorize: boolean | undefined
35
+
36
+ export function shouldColorize(): boolean {
37
+ if (_shouldColorize !== undefined) return _shouldColorize
38
+
39
+ // Try @silvery/ansi for full detection (respects NO_COLOR, FORCE_COLOR, TERM, etc.)
40
+ try {
41
+ const { detectColor } = require("@silvery/ansi") as { detectColor: (stdout: NodeJS.WriteStream) => string | null }
42
+ _shouldColorize = detectColor(process.stdout) !== null
43
+ } catch {
44
+ // Fallback: basic NO_COLOR / FORCE_COLOR / isTTY checks
45
+ if (process.env.NO_COLOR !== undefined) {
46
+ _shouldColorize = false
47
+ } else if (process.env.FORCE_COLOR !== undefined) {
48
+ _shouldColorize = true
49
+ } else {
50
+ _shouldColorize = process.stdout?.isTTY ?? true
51
+ }
52
+ }
53
+
54
+ return _shouldColorize
55
+ }
56
+
57
+ /** Wrap a string with ANSI codes, handling nested resets. */
58
+ function ansi(text: string, code: string): string {
59
+ return `${code}${text}${RESET}`
60
+ }
61
+
62
+ /**
63
+ * Minimal interface for Commander's Command — avoids requiring Commander
64
+ * as a direct dependency. Works with both `commander` and
65
+ * `@silvery/commander`.
66
+ *
67
+ * Uses permissive types to ensure structural compatibility with all
68
+ * Commander versions, overloads, and generic instantiations.
69
+ */
70
+ export interface CommandLike {
71
+ // biome-ignore lint: permissive to match Commander's overloaded signatures
72
+ configureHelp(...args: any[]): any
73
+ // biome-ignore lint: permissive to match Commander's overloaded signatures
74
+ configureOutput(...args: any[]): any
75
+ // biome-ignore lint: permissive to match Commander's Command[] structurally
76
+ readonly commands: readonly any[]
77
+ }
78
+
79
+ /** Color scheme for help output. Values are raw ANSI escape sequences. */
80
+ export interface ColorizeHelpOptions {
81
+ /** ANSI code for command/subcommand names. Default: cyan */
82
+ commands?: string
83
+ /** ANSI code for --flags and -short options. Default: green */
84
+ flags?: string
85
+ /** ANSI code for description text. Default: dim */
86
+ description?: string
87
+ /** ANSI code for section headings (Usage:, Options:, etc.). Default: bold */
88
+ heading?: string
89
+ /** ANSI code for <required> and [optional] argument brackets. Default: yellow */
90
+ brackets?: string
91
+ }
92
+
93
+ /**
94
+ * Apply colorized help output to a Commander.js program and all its subcommands.
95
+ *
96
+ * Uses Commander's built-in `configureHelp()` style hooks rather than
97
+ * post-processing the formatted string. This approach is robust against
98
+ * formatting changes in Commander and handles wrapping correctly.
99
+ *
100
+ * @param program - A Commander Command instance (or compatible object)
101
+ * @param options - Override default ANSI color codes for each element
102
+ */
103
+ export function colorizeHelp(program: CommandLike, options?: ColorizeHelpOptions): void {
104
+ const cmds = options?.commands ?? CYAN
105
+ const flags = options?.flags ?? GREEN
106
+ const desc = options?.description ?? DIM
107
+ const heading = options?.heading ?? BOLD
108
+ const brackets = options?.brackets ?? YELLOW
109
+
110
+ const helpConfig: Record<string, unknown> = {
111
+ // Section headings: "Usage:", "Options:", "Commands:", "Arguments:"
112
+ styleTitle(str: string): string {
113
+ return ansi(str, heading)
114
+ },
115
+
116
+ // Command name in usage line and subcommand terms
117
+ styleCommandText(str: string): string {
118
+ return ansi(str, cmds)
119
+ },
120
+
121
+ // Option terms: "-v, --verbose", "--repo <path>", "[options]"
122
+ styleOptionText(str: string): string {
123
+ return ansi(str, flags)
124
+ },
125
+
126
+ // Subcommand names in the commands list
127
+ styleSubcommandText(str: string): string {
128
+ return ansi(str, cmds)
129
+ },
130
+
131
+ // Argument terms: "<file>", "[dir]"
132
+ styleArgumentText(str: string): string {
133
+ return ansi(str, brackets)
134
+ },
135
+
136
+ // Description text for options, subcommands, arguments
137
+ styleDescriptionText(str: string): string {
138
+ return ansi(str, desc)
139
+ },
140
+
141
+ // Command description (the main program description line) — keep normal
142
+ styleCommandDescription(str: string): string {
143
+ return str
144
+ },
145
+ }
146
+
147
+ program.configureHelp(helpConfig)
148
+
149
+ // Tell Commander that color output is supported, even when stdout is not
150
+ // a TTY (e.g., piped output, CI, tests). Without this, Commander strips
151
+ // all ANSI codes from helpInformation() output.
152
+ //
153
+ // Callers who want to respect NO_COLOR/FORCE_COLOR should check
154
+ // shouldColorize() before calling colorizeHelp().
155
+ program.configureOutput({
156
+ getOutHasColors: () => true,
157
+ getErrHasColors: () => true,
158
+ })
159
+
160
+ // Apply recursively to all existing subcommands
161
+ for (const sub of program.commands) {
162
+ colorizeHelp(sub, options)
163
+ }
164
+ }
package/src/command.ts ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Enhanced Commander Command with auto-colorized help, Standard Schema support,
3
+ * and array-as-choices detection.
4
+ *
5
+ * Subclasses Commander's Command so `new Command("app")` just works --
6
+ * it's Commander with auto-colorized help, automatic Standard Schema /
7
+ * legacy Zod detection, and array choices in `.option()`.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * import { Command, port, csv } from "@silvery/commander"
12
+ *
13
+ * new Command("deploy")
14
+ * .option("-p, --port <n>", "Port", port)
15
+ * .option("--tags <t>", "Tags", csv)
16
+ * .option("-e, --env <e>", "Env", ["dev", "staging", "prod"])
17
+ *
18
+ * program.parse()
19
+ * ```
20
+ */
21
+
22
+ import { Command as BaseCommand, Option } from "commander"
23
+ import { colorizeHelp } from "./colorize.ts"
24
+ import type { StandardSchemaV1 } from "./presets.ts"
25
+
26
+ // --- Standard Schema support ---
27
+
28
+ /** Runtime check: is this value a Standard Schema v1 object? */
29
+ function isStandardSchema(value: unknown): value is StandardSchemaV1 {
30
+ return typeof value === "object" && value !== null && "~standard" in (value as any)
31
+ }
32
+
33
+ /** Wrap a Standard Schema as a Commander parser function */
34
+ function standardSchemaParser<T>(schema: StandardSchemaV1<T>): (value: string) => T {
35
+ return (value: string) => {
36
+ const result = schema["~standard"].validate(value)
37
+ if ("issues" in result) {
38
+ const msg = result.issues.map((i) => i.message).join(", ")
39
+ throw new Error(msg)
40
+ }
41
+ return result.value
42
+ }
43
+ }
44
+
45
+ // --- Legacy Zod support (pre-3.24, no ~standard) ---
46
+
47
+ /**
48
+ * Duck-type interface for older Zod schemas that don't implement Standard Schema.
49
+ * Any object with `parse(value: string) => T` and `_def` qualifies.
50
+ */
51
+ interface ZodLike<T = any> {
52
+ parse(value: unknown): T
53
+ _def: unknown
54
+ }
55
+
56
+ /** Runtime check: is this value a legacy Zod-like schema (without Standard Schema)? */
57
+ function isLegacyZodSchema(value: unknown): value is ZodLike {
58
+ return (
59
+ typeof value === "object" &&
60
+ value !== null &&
61
+ typeof (value as any).parse === "function" &&
62
+ "_def" in (value as any) &&
63
+ !("~standard" in (value as any))
64
+ )
65
+ }
66
+
67
+ /** Wrap a legacy Zod schema as a Commander parser function */
68
+ function legacyZodParser<T>(schema: ZodLike<T>): (value: string) => T {
69
+ return (value: string) => {
70
+ try {
71
+ return schema.parse(value)
72
+ } catch (err: any) {
73
+ // Format Zod errors as Commander-style messages
74
+ if (err?.issues) {
75
+ const messages = err.issues.map((i: any) => i.message).join(", ")
76
+ throw new Error(messages)
77
+ }
78
+ throw err
79
+ }
80
+ }
81
+ }
82
+
83
+ export class Command extends BaseCommand {
84
+ constructor(name?: string) {
85
+ super(name)
86
+ colorizeHelp(this as any)
87
+ }
88
+
89
+ /**
90
+ * Add an option with smart third-argument detection.
91
+ *
92
+ * The third argument is detected in order:
93
+ * 1. **Array** -- treated as choices (Commander `.choices()`)
94
+ * 2. **Standard Schema v1** -- wrapped as a parser function
95
+ * 3. **Legacy Zod** (pre-3.24, has `_def` + `parse`) -- wrapped as a parser
96
+ * 4. **Function** -- passed through as Commander's parser function
97
+ * 5. **Anything else** -- passed through as a default value
98
+ */
99
+ option(flags: string, description?: string, parseArgOrDefault?: any, defaultValue?: any): this {
100
+ if (Array.isArray(parseArgOrDefault)) {
101
+ const opt = new Option(flags, description ?? "").choices(parseArgOrDefault)
102
+ this.addOption(opt)
103
+ return this
104
+ }
105
+ if (isStandardSchema(parseArgOrDefault)) {
106
+ return super.option(flags, description ?? "", standardSchemaParser(parseArgOrDefault))
107
+ }
108
+ if (isLegacyZodSchema(parseArgOrDefault)) {
109
+ return super.option(flags, description ?? "", legacyZodParser(parseArgOrDefault))
110
+ }
111
+ if (typeof parseArgOrDefault === "function") {
112
+ return super.option(flags, description ?? "", parseArgOrDefault, defaultValue)
113
+ }
114
+ return super.option(flags, description ?? "", parseArgOrDefault)
115
+ }
116
+
117
+ // Subcommands also get colorized help, Standard Schema, and array choices
118
+ createCommand(name?: string): Command {
119
+ return new Command(name)
120
+ }
121
+ }