@silvery/commander 0.6.1 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@silvery/commander",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "description": "Colorized Commander.js help output using ANSI escape codes",
5
5
  "keywords": [
6
6
  "ansi",
@@ -23,14 +23,14 @@
23
23
  "sideEffects": false,
24
24
  "exports": {
25
25
  ".": "./src/index.ts",
26
+ "./plain": "./src/plain.ts",
26
27
  "./parse": "./src/presets.ts"
27
28
  },
28
29
  "publishConfig": {
29
30
  "access": "public"
30
31
  },
31
32
  "dependencies": {
32
- "@silvery/ansi": ">=0.1.0",
33
- "@silvery/style": ">=0.1.0",
33
+ "@silvery/ansi": ">=0.3.0",
34
34
  "commander": ">=12.0.0"
35
35
  },
36
36
  "peerDependencies": {
package/src/colorize.ts CHANGED
@@ -1,57 +1,38 @@
1
1
  /**
2
- * Commander.js help colorization using ANSI escape codes.
2
+ * Commander.js help colorization using @silvery/ansi.
3
3
  *
4
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.
5
+ * rather than regex post-processing.
8
6
  *
9
7
  * @example
10
8
  * ```ts
11
9
  * import { Command } from "@silvery/commander"
10
+ * // Command auto-colorizes in its constructor — no manual call needed.
11
+ * // For plain Commander:
12
12
  * import { colorizeHelp } from "@silvery/commander"
13
- *
14
- * const program = new Command("myapp").description("My CLI tool")
15
13
  * colorizeHelp(program)
16
14
  * ```
17
15
  */
18
16
 
19
- import { MODIFIERS, FG_COLORS } from "@silvery/style"
20
- import { detectColor } from "@silvery/ansi"
17
+ import { createStyle } from "@silvery/ansi"
21
18
 
22
- // Derive ANSI escape sequences from @silvery/style constants.
23
- const RESET = "\x1b[0m"
24
- const BOLD = `\x1b[${MODIFIERS.bold![0]}m`
25
- const DIM = `\x1b[${MODIFIERS.dim![0]}m`
26
- const CYAN = `\x1b[${FG_COLORS.cyan}m`
27
- const GREEN = `\x1b[${FG_COLORS.green}m`
28
- const YELLOW = `\x1b[${FG_COLORS.yellow}m`
19
+ // Auto-detect terminal color level. The Style instance handles the full
20
+ // degradation chain: truecolor → 256 → basic (ANSI 16) → null (no color).
21
+ // When level is null (NO_COLOR), style methods return plain text.
22
+ const s = createStyle()
29
23
 
30
24
  /**
31
25
  * Check if color output should be enabled.
32
- * Uses @silvery/ansi detectColor() for full detection (respects NO_COLOR,
33
- * FORCE_COLOR, TERM, etc.).
26
+ * Delegates to @silvery/ansi's auto-detection (NO_COLOR, FORCE_COLOR, TERM).
34
27
  */
35
- let _shouldColorize: boolean | undefined
36
-
37
28
  export function shouldColorize(): boolean {
38
- if (_shouldColorize !== undefined) return _shouldColorize
39
- _shouldColorize = detectColor(process.stdout) !== null
40
- return _shouldColorize
41
- }
42
-
43
- /** Wrap a string with ANSI codes, handling nested resets. */
44
- function ansi(text: string, code: string): string {
45
- return `${code}${text}${RESET}`
29
+ return s.level > 0
46
30
  }
47
31
 
48
32
  /**
49
33
  * Minimal interface for Commander's Command — avoids requiring Commander
50
34
  * as a direct dependency. Works with both `commander` and
51
35
  * `@silvery/commander`.
52
- *
53
- * Uses permissive types to ensure structural compatibility with all
54
- * Commander versions, overloads, and generic instantiations.
55
36
  */
56
37
  export interface CommandLike {
57
38
  // biome-ignore lint: permissive to match Commander's overloaded signatures
@@ -62,82 +43,57 @@ export interface CommandLike {
62
43
  readonly commands: readonly any[]
63
44
  }
64
45
 
65
- /** Color scheme for help output. Values are raw ANSI escape sequences. */
46
+ /** Color scheme for help output. Each value is a styling function (text → styled text). */
66
47
  export interface ColorizeHelpOptions {
67
- /** ANSI code for command/subcommand names. Default: cyan */
68
- commands?: string
69
- /** ANSI code for --flags and -short options. Default: green */
70
- flags?: string
71
- /** ANSI code for description text. Default: dim */
72
- description?: string
73
- /** ANSI code for section headings (Usage:, Options:, etc.). Default: bold */
74
- heading?: string
75
- /** ANSI code for <required> and [optional] argument brackets. Default: yellow */
76
- brackets?: string
48
+ /** Style for command/subcommand names. Default: primary (yellow without theme) */
49
+ commands?: (text: string) => string
50
+ /** Style for --flags and -short options. Default: secondary (cyan without theme) */
51
+ flags?: (text: string) => string
52
+ /** Style for description text. Default: unstyled (normal foreground) */
53
+ description?: (text: string) => string
54
+ /** Style for section headings (Usage:, Options:, etc.). Default: bold */
55
+ heading?: (text: string) => string
56
+ /** Style for <required> and [optional] argument brackets. Default: accent (magenta without theme) */
57
+ brackets?: (text: string) => string
77
58
  }
78
59
 
79
60
  /**
80
61
  * Apply colorized help output to a Commander.js program and all its subcommands.
81
62
  *
82
63
  * Uses Commander's built-in `configureHelp()` style hooks rather than
83
- * post-processing the formatted string. This approach is robust against
84
- * formatting changes in Commander and handles wrapping correctly.
64
+ * post-processing the formatted string.
85
65
  *
86
66
  * @param program - A Commander Command instance (or compatible object)
87
- * @param options - Override default ANSI color codes for each element
67
+ * @param options - Override default style functions for each element
88
68
  */
89
69
  export function colorizeHelp(program: CommandLike, options?: ColorizeHelpOptions): void {
90
- const cmds = options?.commands ?? CYAN
91
- const flags = options?.flags ?? GREEN
92
- const desc = options?.description ?? DIM
93
- const heading = options?.heading ?? BOLD
94
- const brackets = options?.brackets ?? YELLOW
70
+ // Ensure style generates codes — at minimum basic (ANSI 16).
71
+ // Auto-detected level may be higher (256/truecolor) for richer output.
72
+ // May be 0 if NO_COLOR is set; in that case, force basic since Commander
73
+ // handles the final strip via configureOutput.
74
+ if (s.level === 0) s.level = 1
75
+
76
+ // Semantic token fallback: theme token → named color
77
+ const cmds = options?.commands ?? ((t: string) => s.primary(t))
78
+ const flags = options?.flags ?? ((t: string) => s.secondary(t))
79
+ const desc = options?.description ?? ((t: string) => t)
80
+ const heading = options?.heading ?? ((t: string) => s.bold(t))
81
+ const brackets = options?.brackets ?? ((t: string) => s.accent(t))
95
82
 
96
83
  const helpConfig: Record<string, unknown> = {
97
- // Section headings: "Usage:", "Options:", "Commands:", "Arguments:"
98
- styleTitle(str: string): string {
99
- return ansi(str, heading)
100
- },
101
-
102
- // Command name in usage line and subcommand terms
103
- styleCommandText(str: string): string {
104
- return ansi(str, cmds)
105
- },
106
-
107
- // Option terms: "-v, --verbose", "--repo <path>", "[options]"
108
- styleOptionText(str: string): string {
109
- return ansi(str, flags)
110
- },
111
-
112
- // Subcommand names in the commands list
113
- styleSubcommandText(str: string): string {
114
- return ansi(str, cmds)
115
- },
116
-
117
- // Argument terms: "<file>", "[dir]"
118
- styleArgumentText(str: string): string {
119
- return ansi(str, brackets)
120
- },
121
-
122
- // Description text for options, subcommands, arguments
123
- styleDescriptionText(str: string): string {
124
- return ansi(str, desc)
125
- },
126
-
127
- // Command description (the main program description line) — keep normal
128
- styleCommandDescription(str: string): string {
129
- return str
130
- },
84
+ styleTitle: (str: string) => heading(str),
85
+ styleCommandText: (str: string) => cmds(str),
86
+ styleOptionText: (str: string) => flags(str),
87
+ styleSubcommandText: (str: string) => cmds(str),
88
+ styleArgumentText: (str: string) => brackets(str),
89
+ styleDescriptionText: (str: string) => desc(str),
90
+ styleCommandDescription: (str: string) => s.bold.primary(str),
131
91
  }
132
92
 
133
93
  program.configureHelp(helpConfig)
134
94
 
135
95
  // Tell Commander that color output is supported, even when stdout is not
136
- // a TTY (e.g., piped output, CI, tests). Without this, Commander strips
137
- // all ANSI codes from helpInformation() output.
138
- //
139
- // Callers who want to respect NO_COLOR/FORCE_COLOR should check
140
- // shouldColorize() before calling colorizeHelp().
96
+ // a TTY. Without this, Commander strips ANSI codes from helpInformation().
141
97
  program.configureOutput({
142
98
  getOutHasColors: () => true,
143
99
  getErrHasColors: () => true,
package/src/command.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  * ```
20
20
  */
21
21
 
22
- import { Command as BaseCommand, Option } from "commander"
22
+ import { Command as BaseCommand, Help, Option } from "commander"
23
23
  import { colorizeHelp } from "./colorize.ts"
24
24
  import type { StandardSchemaV1 } from "./presets.ts"
25
25
 
@@ -44,16 +44,11 @@ function standardSchemaParser<T>(schema: StandardSchemaV1<T>): (value: string) =
44
44
 
45
45
  // --- Legacy Zod support (pre-3.24, no ~standard) ---
46
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
47
  interface ZodLike<T = any> {
52
48
  parse(value: unknown): T
53
49
  _def: unknown
54
50
  }
55
51
 
56
- /** Runtime check: is this value a legacy Zod-like schema (without Standard Schema)? */
57
52
  function isLegacyZodSchema(value: unknown): value is ZodLike {
58
53
  return (
59
54
  typeof value === "object" &&
@@ -64,13 +59,11 @@ function isLegacyZodSchema(value: unknown): value is ZodLike {
64
59
  )
65
60
  }
66
61
 
67
- /** Wrap a legacy Zod schema as a Commander parser function */
68
62
  function legacyZodParser<T>(schema: ZodLike<T>): (value: string) => T {
69
63
  return (value: string) => {
70
64
  try {
71
65
  return schema.parse(value)
72
66
  } catch (err: any) {
73
- // Format Zod errors as Commander-style messages
74
67
  if (err?.issues) {
75
68
  const messages = err.issues.map((i: any) => i.message).join(", ")
76
69
  throw new Error(messages)
@@ -80,10 +73,50 @@ function legacyZodParser<T>(schema: ZodLike<T>): (value: string) => T {
80
73
  }
81
74
  }
82
75
 
76
+ // --- Help Section Types ---
77
+
78
+ /** Position for help sections — mirrors Commander's addHelpText positions. */
79
+ export type HelpSectionPosition = "beforeAll" | "before" | "after" | "afterAll"
80
+
81
+ /** Content for a help section: rows of [term, description] pairs, or free-form text. */
82
+ export type HelpSectionContent = [string, string][] | string
83
+
84
+ // Internal storage
85
+ interface StoredSection {
86
+ position: HelpSectionPosition
87
+ title: string
88
+ content: HelpSectionContent
89
+ }
90
+
91
+ /**
92
+ * Style a section term using Commander's style hooks.
93
+ * Splits the term into segments: option-like (-f), argument brackets (<arg>, [opt]),
94
+ * and command words — each styled with the appropriate hook.
95
+ */
96
+ function styleSectionTerm(term: string, helper: any): string {
97
+ // Option-like terms: entire term styled as option
98
+ if (/^\s*-/.test(term)) return helper.styleOptionText(term)
99
+
100
+ // Mixed terms: style <arg>/[opt] as arguments, "quoted" as literal values, rest as commands
101
+ return term.replace(
102
+ /(<[^>]+>|\[[^\]]+\])|("[^"]*")|([^<["[\]]+)/g,
103
+ (_match, bracket: string, quoted: string, text: string) => {
104
+ if (bracket) return helper.styleArgumentText(bracket)
105
+ if (quoted) return quoted // literal values — default foreground (quotes distinguish them)
106
+ if (text) return helper.styleCommandText(text)
107
+ return ""
108
+ },
109
+ )
110
+ }
111
+
83
112
  export class Command extends BaseCommand {
113
+ private _helpSectionList: StoredSection[] = []
114
+ private _helpSectionsInstalled = false
115
+
84
116
  constructor(name?: string) {
85
117
  super(name)
86
118
  colorizeHelp(this as any)
119
+ this._capitalizeBuiltinDescriptions()
87
120
  }
88
121
 
89
122
  /**
@@ -114,8 +147,175 @@ export class Command extends BaseCommand {
114
147
  return super.option(flags, description ?? "", parseArgOrDefault)
115
148
  }
116
149
 
150
+ /**
151
+ * Add a styled help section — like Commander's `addHelpText` but with
152
+ * structured content that participates in global column alignment.
153
+ *
154
+ * @example
155
+ * ```ts
156
+ * // Rows with aligned descriptions (default position: "after")
157
+ * program.addHelpSection("Getting Started:", [
158
+ * ["myapp init", "Initialize a new project"],
159
+ * ["myapp serve", "Start the dev server"],
160
+ * ])
161
+ *
162
+ * // Free-form text
163
+ * program.addHelpSection("Note:", "Requires Node.js 23+")
164
+ *
165
+ * // Explicit position
166
+ * program.addHelpSection("before", "Prerequisites:", [
167
+ * ["node >= 23", "Required runtime"],
168
+ * ])
169
+ * ```
170
+ */
171
+ addHelpSection(title: string, content: HelpSectionContent): this
172
+ addHelpSection(position: HelpSectionPosition, title: string, content: HelpSectionContent): this
173
+ addHelpSection(
174
+ positionOrTitle: HelpSectionPosition | string,
175
+ titleOrContent: string | HelpSectionContent,
176
+ content?: HelpSectionContent,
177
+ ): this {
178
+ let position: HelpSectionPosition
179
+ let title: string
180
+ let body: HelpSectionContent
181
+
182
+ if (content !== undefined) {
183
+ // 3-arg: addHelpSection(position, title, content)
184
+ position = positionOrTitle as HelpSectionPosition
185
+ title = titleOrContent as string
186
+ body = content
187
+ } else {
188
+ // 2-arg: addHelpSection(title, content) — defaults to "after"
189
+ position = "after"
190
+ title = positionOrTitle
191
+ body = titleOrContent as HelpSectionContent
192
+ }
193
+
194
+ this._helpSectionList.push({ position, title, content: body })
195
+ this._installHelpSectionHooks()
196
+ return this
197
+ }
198
+
117
199
  // Subcommands also get colorized help, Standard Schema, and array choices
118
200
  override createCommand(name?: string): Command {
119
201
  return new Command(name)
120
202
  }
203
+
204
+ /**
205
+ * Auto-detect capitalization from user-provided descriptions and match it
206
+ * for built-in options (-V/--version, -h/--help).
207
+ */
208
+ private _capitalizeBuiltinDescriptions(): void {
209
+ const origHelp = this.helpInformation.bind(this)
210
+ this.helpInformation = () => {
211
+ const builtinFlags = new Set(["-V, --version", "-h, --help"])
212
+ const userDescs = this.options
213
+ .filter((opt) => !builtinFlags.has(opt.flags) && opt.description && /^[a-zA-Z]/.test(opt.description))
214
+ .map((opt) => opt.description!)
215
+ for (const cmd of this.commands) {
216
+ if (cmd.description() && /^[a-zA-Z]/.test(cmd.description())) {
217
+ userDescs.push(cmd.description())
218
+ }
219
+ }
220
+ if (userDescs.length > 0) {
221
+ const capitalCount = userDescs.filter((d) => /^[A-Z]/.test(d)).length
222
+ if (capitalCount > userDescs.length / 2) {
223
+ for (const opt of this.options) {
224
+ if (builtinFlags.has(opt.flags) && opt.description && /^[a-z]/.test(opt.description)) {
225
+ opt.description = opt.description[0]!.toUpperCase() + opt.description.slice(1)
226
+ }
227
+ }
228
+ const helpOpt = (this as any)._helpOption
229
+ if (helpOpt?.description && /^[a-z]/.test(helpOpt.description)) {
230
+ helpOpt.description = helpOpt.description[0]!.toUpperCase() + helpOpt.description.slice(1)
231
+ }
232
+ }
233
+ }
234
+ return origHelp()
235
+ }
236
+ }
237
+
238
+ /** Render sections for a given position using the help formatter. */
239
+ private _renderSections(position: HelpSectionPosition, helper: any, termWidth: number): string {
240
+ const sections = this._helpSectionList.filter((s) => s.position === position)
241
+ if (sections.length === 0) return ""
242
+
243
+ const lines: string[] = []
244
+ for (const section of sections) {
245
+ lines.push("")
246
+ lines.push(helper.styleTitle(section.title))
247
+ if (typeof section.content === "string") {
248
+ for (const line of section.content.split("\n")) {
249
+ lines.push(` ${line}`)
250
+ }
251
+ } else {
252
+ for (const [term, desc] of section.content) {
253
+ const styleTerm = styleSectionTerm(term, helper)
254
+ lines.push(helper.formatItem(styleTerm, termWidth, helper.styleDescriptionText(desc), helper))
255
+ }
256
+ }
257
+ }
258
+ lines.push("")
259
+ return lines.join("\n")
260
+ }
261
+
262
+ /** Install hooks once — merges with existing configureHelp, adds addHelpText for before/afterAll. */
263
+ private _installHelpSectionHooks(): void {
264
+ if (this._helpSectionsInstalled) return
265
+ this._helpSectionsInstalled = true
266
+
267
+ const self = this
268
+ const existing = (this as any)._helpConfiguration ?? {}
269
+ const origPadWidth = existing.padWidth
270
+ const origFormatHelp = existing.formatHelp
271
+ const protoFormatHelp = Help.prototype.formatHelp
272
+
273
+ this.configureHelp({
274
+ ...existing,
275
+ // Include section term widths in global column alignment
276
+ padWidth(cmd: any, helper: any) {
277
+ const base = origPadWidth
278
+ ? origPadWidth(cmd, helper)
279
+ : Math.max(
280
+ helper.longestOptionTermLength(cmd, helper),
281
+ helper.longestGlobalOptionTermLength?.(cmd, helper) ?? 0,
282
+ helper.longestSubcommandTermLength(cmd, helper),
283
+ helper.longestArgumentTermLength(cmd, helper),
284
+ )
285
+ let sectionMax = 0
286
+ for (const section of self._helpSectionList) {
287
+ if (typeof section.content !== "string") {
288
+ for (const [term] of section.content) {
289
+ if (term.length > sectionMax) sectionMax = term.length
290
+ }
291
+ }
292
+ }
293
+ return Math.max(base, sectionMax)
294
+ },
295
+ // Render "before" and "after" sections inside formatHelp
296
+ formatHelp(cmd: any, helper: any) {
297
+ const baseHelp = origFormatHelp ? origFormatHelp(cmd, helper) : protoFormatHelp.call(helper, cmd, helper)
298
+ const termWidth = helper.padWidth(cmd, helper)
299
+ const before = self._renderSections("before", helper, termWidth)
300
+ const after = self._renderSections("after", helper, termWidth)
301
+
302
+ // "before" goes after the Usage+Description but before Options.
303
+ // Since we can't inject mid-formatHelp easily, prepend before baseHelp.
304
+ // "after" appends at the end.
305
+ return (before ? before + "\n" : "") + baseHelp + after
306
+ },
307
+ })
308
+
309
+ // "beforeAll" and "afterAll" use Commander's addHelpText (propagates to subcommands)
310
+ this.addHelpText("beforeAll", () => {
311
+ const helper = this.createHelp()
312
+ const termWidth = helper.padWidth(this, helper)
313
+ return this._renderSections("beforeAll", helper, termWidth)
314
+ })
315
+ this.addHelpText("afterAll", () => {
316
+ const helper = this.createHelp()
317
+ const termWidth = helper.padWidth(this, helper)
318
+ return this._renderSections("afterAll", helper, termWidth)
319
+ })
320
+ }
121
321
  }
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // Enhanced Commander
2
- export { Command } from "./command.ts"
2
+ export { Command, type HelpSectionPosition, type HelpSectionContent } from "./command.ts"
3
3
  export { colorizeHelp, shouldColorize, type ColorizeHelpOptions, type CommandLike } from "./colorize.ts"
4
4
 
5
5
  // Re-export Commander's other classes
package/src/plain.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Plain Commander — Standard Schema presets without auto-colorization.
3
+ *
4
+ * Exports the base Commander Command (no auto-colorized help) plus the
5
+ * same typed option presets (int, uint, port, csv, intRange). Does NOT
6
+ * import @silvery/ansi — zero styling overhead.
7
+ *
8
+ * For colorization, import `colorizeHelp` from `@silvery/commander` (main entry).
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import { Command, port, csv } from "@silvery/commander/plain"
13
+ *
14
+ * const program = new Command("myapp")
15
+ * .option("-p, --port <n>", "Port", port)
16
+ * .option("-e, --env <e>", "Env", ["dev", "staging", "prod"])
17
+ * ```
18
+ */
19
+
20
+ export { Command } from "commander"
21
+ export { int, uint, port, csv, intRange } from "./presets.ts"
22
+ export type { StandardSchemaV1 } from "./presets.ts"