@silvery/commander 0.7.0 → 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.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Colorized Commander.js help output using ANSI escape codes",
5
5
  "keywords": [
6
6
  "ansi",
@@ -23,13 +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": ">=1.0.0",
33
+ "@silvery/ansi": ">=0.3.0",
33
34
  "commander": ">=12.0.0"
34
35
  },
35
36
  "peerDependencies": {
package/src/colorize.ts CHANGED
@@ -45,15 +45,15 @@ export interface CommandLike {
45
45
 
46
46
  /** Color scheme for help output. Each value is a styling function (text → styled text). */
47
47
  export interface ColorizeHelpOptions {
48
- /** Style for command/subcommand names. Default: cyan */
48
+ /** Style for command/subcommand names. Default: primary (yellow without theme) */
49
49
  commands?: (text: string) => string
50
- /** Style for --flags and -short options. Default: green */
50
+ /** Style for --flags and -short options. Default: secondary (cyan without theme) */
51
51
  flags?: (text: string) => string
52
- /** Style for description text. Default: dim */
52
+ /** Style for description text. Default: unstyled (normal foreground) */
53
53
  description?: (text: string) => string
54
54
  /** Style for section headings (Usage:, Options:, etc.). Default: bold */
55
55
  heading?: (text: string) => string
56
- /** Style for <required> and [optional] argument brackets. Default: yellow */
56
+ /** Style for <required> and [optional] argument brackets. Default: accent (magenta without theme) */
57
57
  brackets?: (text: string) => string
58
58
  }
59
59
 
@@ -73,11 +73,12 @@ export function colorizeHelp(program: CommandLike, options?: ColorizeHelpOptions
73
73
  // handles the final strip via configureOutput.
74
74
  if (s.level === 0) s.level = 1
75
75
 
76
- const cmds = options?.commands ?? ((t: string) => s.cyan(t))
77
- const flags = options?.flags ?? ((t: string) => s.green(t))
78
- const desc = options?.description ?? ((t: string) => s.dim(t))
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)
79
80
  const heading = options?.heading ?? ((t: string) => s.bold(t))
80
- const brackets = options?.brackets ?? ((t: string) => s.yellow(t))
81
+ const brackets = options?.brackets ?? ((t: string) => s.accent(t))
81
82
 
82
83
  const helpConfig: Record<string, unknown> = {
83
84
  styleTitle: (str: string) => heading(str),
@@ -86,7 +87,7 @@ export function colorizeHelp(program: CommandLike, options?: ColorizeHelpOptions
86
87
  styleSubcommandText: (str: string) => cmds(str),
87
88
  styleArgumentText: (str: string) => brackets(str),
88
89
  styleDescriptionText: (str: string) => desc(str),
89
- styleCommandDescription: (str: string) => str,
90
+ styleCommandDescription: (str: string) => s.bold.primary(str),
90
91
  }
91
92
 
92
93
  program.configureHelp(helpConfig)
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"