@silvery/commander 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,171 +1,14 @@
1
- // Re-export Commander classes (drop-in replacement for @silvery/commander)
2
- export { Command, Option, Argument, CommanderError, InvalidArgumentError, Help } from "commander"
3
- export type { OptionValues } from "commander"
4
-
5
- // Re-export typed CLI
6
- export { TypedCommand, createCLI } from "./typed.ts"
7
-
8
- /**
9
- * Commander.js help colorization using ANSI escape codes.
10
- *
11
- * Uses Commander's built-in style hooks (styleTitle, styleOptionText, etc.)
12
- * rather than regex post-processing. Works with @silvery/commander
13
- * or plain commander — accepts a minimal CommandLike interface so Commander
14
- * is a peer dependency, not a hard one.
15
- *
16
- * Zero dependencies — only raw ANSI escape codes.
17
- *
18
- * @example
19
- * ```ts
20
- * import { Command } from "@silvery/commander"
21
- * import { colorizeHelp } from "@silvery/commander"
22
- *
23
- * const program = new Command("myapp").description("My CLI tool")
24
- * colorizeHelp(program)
25
- * ```
26
- */
27
-
28
- // Raw ANSI escape codes — no framework dependencies.
29
- const RESET = "\x1b[0m"
30
- const BOLD = "\x1b[1m"
31
- const DIM = "\x1b[2m"
32
- const CYAN = "\x1b[36m"
33
- const GREEN = "\x1b[32m"
34
- const YELLOW = "\x1b[33m"
35
-
36
- /**
37
- * Check if color output should be enabled.
38
- * Uses @silvery/ansi detectColor() if available, falls back to basic
39
- * NO_COLOR/FORCE_COLOR/isTTY checks.
40
- */
41
- let _shouldColorize: boolean | undefined
42
-
43
- export function shouldColorize(): boolean {
44
- if (_shouldColorize !== undefined) return _shouldColorize
45
-
46
- // Try @silvery/ansi for full detection (respects NO_COLOR, FORCE_COLOR, TERM, etc.)
47
- try {
48
- const { detectColor } = require("@silvery/ansi") as { detectColor: (stdout: NodeJS.WriteStream) => string | null }
49
- _shouldColorize = detectColor(process.stdout) !== null
50
- } catch {
51
- // Fallback: basic NO_COLOR / FORCE_COLOR / isTTY checks
52
- if (process.env.NO_COLOR !== undefined) {
53
- _shouldColorize = false
54
- } else if (process.env.FORCE_COLOR !== undefined) {
55
- _shouldColorize = true
56
- } else {
57
- _shouldColorize = process.stdout?.isTTY ?? true
58
- }
59
- }
60
-
61
- return _shouldColorize
62
- }
63
-
64
- /** Wrap a string with ANSI codes, handling nested resets. */
65
- function ansi(text: string, code: string): string {
66
- return `${code}${text}${RESET}`
67
- }
68
-
69
- /**
70
- * Minimal interface for Commander's Command — avoids requiring Commander
71
- * as a direct dependency. Works with both `commander` and
72
- * `@silvery/commander`.
73
- *
74
- * Uses permissive types to ensure structural compatibility with all
75
- * Commander versions, overloads, and generic instantiations.
76
- */
77
- export interface CommandLike {
78
- // biome-ignore lint: permissive to match Commander's overloaded signatures
79
- configureHelp(...args: any[]): any
80
- // biome-ignore lint: permissive to match Commander's overloaded signatures
81
- configureOutput(...args: any[]): any
82
- // biome-ignore lint: permissive to match Commander's Command[] structurally
83
- readonly commands: readonly any[]
84
- }
1
+ // Enhanced Commander
2
+ export { Command } from "./command.ts"
3
+ export { colorizeHelp, shouldColorize, type ColorizeHelpOptions, type CommandLike } from "./colorize.ts"
85
4
 
86
- /** Color scheme for help output. Values are raw ANSI escape sequences. */
87
- export interface ColorizeHelpOptions {
88
- /** ANSI code for command/subcommand names. Default: cyan */
89
- commands?: string
90
- /** ANSI code for --flags and -short options. Default: green */
91
- flags?: string
92
- /** ANSI code for description text. Default: dim */
93
- description?: string
94
- /** ANSI code for section headings (Usage:, Options:, etc.). Default: bold */
95
- heading?: string
96
- /** ANSI code for <required> and [optional] argument brackets. Default: yellow */
97
- brackets?: string
98
- }
99
-
100
- /**
101
- * Apply colorized help output to a Commander.js program and all its subcommands.
102
- *
103
- * Uses Commander's built-in `configureHelp()` style hooks rather than
104
- * post-processing the formatted string. This approach is robust against
105
- * formatting changes in Commander and handles wrapping correctly.
106
- *
107
- * @param program - A Commander Command instance (or compatible object)
108
- * @param options - Override default ANSI color codes for each element
109
- */
110
- export function colorizeHelp(program: CommandLike, options?: ColorizeHelpOptions): void {
111
- const cmds = options?.commands ?? CYAN
112
- const flags = options?.flags ?? GREEN
113
- const desc = options?.description ?? DIM
114
- const heading = options?.heading ?? BOLD
115
- const brackets = options?.brackets ?? YELLOW
116
-
117
- const helpConfig: Record<string, unknown> = {
118
- // Section headings: "Usage:", "Options:", "Commands:", "Arguments:"
119
- styleTitle(str: string): string {
120
- return ansi(str, heading)
121
- },
122
-
123
- // Command name in usage line and subcommand terms
124
- styleCommandText(str: string): string {
125
- return ansi(str, cmds)
126
- },
127
-
128
- // Option terms: "-v, --verbose", "--repo <path>", "[options]"
129
- styleOptionText(str: string): string {
130
- return ansi(str, flags)
131
- },
132
-
133
- // Subcommand names in the commands list
134
- styleSubcommandText(str: string): string {
135
- return ansi(str, cmds)
136
- },
137
-
138
- // Argument terms: "<file>", "[dir]"
139
- styleArgumentText(str: string): string {
140
- return ansi(str, brackets)
141
- },
142
-
143
- // Description text for options, subcommands, arguments
144
- styleDescriptionText(str: string): string {
145
- return ansi(str, desc)
146
- },
147
-
148
- // Command description (the main program description line) — keep normal
149
- styleCommandDescription(str: string): string {
150
- return str
151
- },
152
- }
153
-
154
- program.configureHelp(helpConfig)
5
+ // Re-export Commander's other classes
6
+ export { Option, Argument, CommanderError, InvalidArgumentError, Help } from "commander"
7
+ export type { OptionValues } from "commander"
155
8
 
156
- // Tell Commander that color output is supported, even when stdout is not
157
- // a TTY (e.g., piped output, CI, tests). Without this, Commander strips
158
- // all ANSI codes from helpInformation() output.
159
- //
160
- // Callers who want to respect NO_COLOR/FORCE_COLOR should check
161
- // shouldColorize() before calling colorizeHelp().
162
- program.configureOutput({
163
- getOutHasColors: () => true,
164
- getErrHasColors: () => true,
165
- })
9
+ // Presets and Standard Schema type
10
+ export { int, uint, float, port, url, path, csv, json, bool, date, email, regex, intRange, oneOf } from "./presets.ts"
11
+ export type { Preset, StandardSchemaV1 } from "./presets.ts"
166
12
 
167
- // Apply recursively to all existing subcommands
168
- for (const sub of program.commands) {
169
- colorizeHelp(sub, options)
170
- }
171
- }
13
+ // Tree-shakeable: only evaluated if user imports z
14
+ export { z } from "./z.ts"
package/src/presets.ts ADDED
@@ -0,0 +1,196 @@
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
+ /**
26
+ * Standard Schema v1 interface — the universal schema interop protocol.
27
+ * Supports any schema library that implements Standard Schema (Zod >=3.24,
28
+ * Valibot >=1.0, ArkType >=2.0, etc.).
29
+ *
30
+ * Inlined to avoid any dependency on @standard-schema/spec.
31
+ * See: https://github.com/standard-schema/standard-schema
32
+ */
33
+ export interface StandardSchemaV1<T = unknown> {
34
+ readonly "~standard": {
35
+ readonly version: 1
36
+ readonly vendor: string
37
+ readonly validate: (
38
+ value: unknown,
39
+ ) => { value: T } | { issues: ReadonlyArray<{ message: string; path?: ReadonlyArray<unknown> }> }
40
+ }
41
+ }
42
+
43
+ /** A Standard Schema v1 preset with standalone parse/safeParse methods. */
44
+ export interface Preset<T> extends StandardSchemaV1<T> {
45
+ /** Parse and validate a value, throwing on failure. */
46
+ parse(value: unknown): T
47
+ /** Parse and validate a value, returning a result object. */
48
+ safeParse(value: unknown): { success: true; value: T } | { success: false; issues: Array<{ message: string }> }
49
+ }
50
+
51
+ function createPreset<T>(vendor: string, validate: (value: unknown) => T): Preset<T> {
52
+ const schema: Preset<T> = {
53
+ "~standard": {
54
+ version: 1,
55
+ vendor,
56
+ validate: (value) => {
57
+ try {
58
+ return { value: validate(value) }
59
+ } catch (e: any) {
60
+ return { issues: [{ message: e.message }] }
61
+ }
62
+ },
63
+ },
64
+ parse(value: unknown): T {
65
+ const result = schema["~standard"].validate(value)
66
+ if ("issues" in result) throw new Error(result.issues[0]?.message ?? "Validation failed")
67
+ return result.value
68
+ },
69
+ safeParse(value: unknown) {
70
+ const result = schema["~standard"].validate(value)
71
+ if ("issues" in result) return { success: false as const, issues: [...result.issues] }
72
+ return { success: true as const, value: result.value }
73
+ },
74
+ }
75
+ return schema
76
+ }
77
+
78
+ const VENDOR = "@silvery/commander"
79
+
80
+ /** Integer (coerced from string). */
81
+ export const int = createPreset<number>(VENDOR, (v) => {
82
+ const s = String(v).trim()
83
+ if (s === "") throw new Error(`Expected integer, got "${v}"`)
84
+ const n = Number(s)
85
+ if (!Number.isInteger(n)) throw new Error(`Expected integer, got "${v}"`)
86
+ return n
87
+ })
88
+
89
+ /** Unsigned integer (>= 0, coerced from string). */
90
+ export const uint = createPreset<number>(VENDOR, (v) => {
91
+ const s = String(v).trim()
92
+ if (s === "") throw new Error(`Expected unsigned integer (>= 0), got "${v}"`)
93
+ const n = Number(s)
94
+ if (!Number.isInteger(n) || n < 0) throw new Error(`Expected unsigned integer (>= 0), got "${v}"`)
95
+ return n
96
+ })
97
+
98
+ /** Float (coerced from string). */
99
+ export const float = createPreset<number>(VENDOR, (v) => {
100
+ const s = String(v).trim()
101
+ if (s === "" || s === "NaN") throw new Error(`Expected number, got "${v}"`)
102
+ const n = Number(s)
103
+ if (Number.isNaN(n)) throw new Error(`Expected number, got "${v}"`)
104
+ return n
105
+ })
106
+
107
+ /** Port number (1-65535). */
108
+ export const port = createPreset<number>(VENDOR, (v) => {
109
+ const n = Number(v)
110
+ if (!Number.isInteger(n) || n < 1 || n > 65535) throw new Error(`Expected port (1-65535), got "${v}"`)
111
+ return n
112
+ })
113
+
114
+ /** URL (validated via URL constructor). */
115
+ export const url = createPreset<string>(VENDOR, (v) => {
116
+ const s = String(v)
117
+ try {
118
+ new URL(s)
119
+ return s
120
+ } catch {
121
+ throw new Error(`Expected valid URL, got "${v}"`)
122
+ }
123
+ })
124
+
125
+ /** File path (non-empty string). */
126
+ export const path = createPreset<string>(VENDOR, (v) => {
127
+ const s = String(v)
128
+ if (!s) throw new Error("Expected non-empty path")
129
+ return s
130
+ })
131
+
132
+ /** Comma-separated values to string[]. */
133
+ export const csv = createPreset<string[]>(VENDOR, (v) => {
134
+ return String(v)
135
+ .split(",")
136
+ .map((s) => s.trim())
137
+ .filter(Boolean)
138
+ })
139
+
140
+ /** JSON string to parsed value. */
141
+ export const json = createPreset<unknown>(VENDOR, (v) => {
142
+ try {
143
+ return JSON.parse(String(v))
144
+ } catch {
145
+ throw new Error(`Expected valid JSON, got "${v}"`)
146
+ }
147
+ })
148
+
149
+ /** Boolean string ("true"/"false"/"1"/"0"/"yes"/"no"). */
150
+ export const bool = createPreset<boolean>(VENDOR, (v) => {
151
+ const s = String(v).toLowerCase()
152
+ if (["true", "1", "yes", "y"].includes(s)) return true
153
+ if (["false", "0", "no", "n"].includes(s)) return false
154
+ throw new Error(`Expected boolean (true/false/yes/no/1/0), got "${v}"`)
155
+ })
156
+
157
+ /** Date string to Date object. */
158
+ export const date = createPreset<Date>(VENDOR, (v) => {
159
+ const d = new Date(String(v))
160
+ if (isNaN(d.getTime())) throw new Error(`Expected valid date, got "${v}"`)
161
+ return d
162
+ })
163
+
164
+ /** Email address (basic validation). */
165
+ export const email = createPreset<string>(VENDOR, (v) => {
166
+ const s = String(v)
167
+ if (!s.includes("@") || !s.includes(".")) throw new Error(`Expected email address, got "${v}"`)
168
+ return s
169
+ })
170
+
171
+ /** Regex pattern string to RegExp. */
172
+ export const regex = createPreset<RegExp>(VENDOR, (v) => {
173
+ try {
174
+ return new RegExp(String(v))
175
+ } catch {
176
+ throw new Error(`Expected valid regex, got "${v}"`)
177
+ }
178
+ })
179
+
180
+ /** Integer with min/max bounds (factory). */
181
+ export function intRange(min: number, max: number): Preset<number> {
182
+ return createPreset<number>(VENDOR, (v) => {
183
+ const n = Number(v)
184
+ if (!Number.isInteger(n) || n < min || n > max) throw new Error(`Expected integer ${min}-${max}, got "${v}"`)
185
+ return n
186
+ })
187
+ }
188
+
189
+ /** Enum from a fixed set of string values (factory). */
190
+ export function oneOf<const T extends readonly string[]>(values: T): Preset<T[number]> {
191
+ return createPreset<T[number]>(VENDOR, (v) => {
192
+ const s = String(v)
193
+ if (!values.includes(s as any)) throw new Error(`Expected one of [${values.join(", ")}], got "${v}"`)
194
+ return s as T[number]
195
+ })
196
+ }
package/src/z.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Extended Zod object with CLI presets.
3
+ *
4
+ * Spreads all of Zod's exports and adds CLI-specific schemas.
5
+ * Tree-shakeable — only evaluated if user imports `z`.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { z, Command } from "@silvery/commander"
10
+ *
11
+ * const program = new Command("deploy")
12
+ * .option("-p, --port <n>", "Port", z.port)
13
+ * .option("-e, --env <e>", "Environment", z.oneOf(["dev", "staging", "prod"]))
14
+ * .option("--tags <t>", "Tags", z.csv)
15
+ *
16
+ * program.parse()
17
+ * ```
18
+ */
19
+
20
+ import * as zod from "zod"
21
+
22
+ export const z = {
23
+ ...zod,
24
+ // CLI presets (built on Zod schemas)
25
+ port: zod.coerce.number().int().min(1).max(65535),
26
+ int: zod.coerce.number().int(),
27
+ uint: zod.coerce.number().int().min(0),
28
+ float: zod.coerce.number(),
29
+ csv: zod.string().transform((v: string) =>
30
+ v
31
+ .split(",")
32
+ .map((s: string) => s.trim())
33
+ .filter(Boolean),
34
+ ),
35
+ url: zod.string().url(),
36
+ path: zod.string().min(1),
37
+ email: zod.string().email(),
38
+ date: zod.coerce.date(),
39
+ json: zod.string().transform((v: string) => JSON.parse(v)),
40
+ bool: zod
41
+ .enum(["true", "false", "1", "0", "yes", "no", "y", "n"] as const)
42
+ .transform((v: string) => ["true", "1", "yes", "y"].includes(v)),
43
+ intRange: (min: number, max: number) => zod.coerce.number().int().min(min).max(max),
44
+ oneOf: <const T extends readonly [string, ...string[]]>(values: T) => zod.enum(values),
45
+ }