clap-ts 0.3.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.
Files changed (43) hide show
  1. package/dist/parser.js +5 -5
  2. package/dist/types.d.ts +14 -1
  3. package/package.json +17 -1
  4. package/src/__tests__/arg-options.test.ts +687 -0
  5. package/src/__tests__/argfile.test.ts +127 -0
  6. package/src/__tests__/clap-parity.test.ts +682 -0
  7. package/src/__tests__/command-options.test.ts +713 -0
  8. package/src/__tests__/completions.test.ts +423 -0
  9. package/src/__tests__/config.test.ts +261 -0
  10. package/src/__tests__/deprecation.test.ts +104 -0
  11. package/src/__tests__/help.test.ts +312 -0
  12. package/src/__tests__/install.test.ts +120 -0
  13. package/src/__tests__/log.test.ts +189 -0
  14. package/src/__tests__/man.test.ts +135 -0
  15. package/src/__tests__/markdown.test.ts +114 -0
  16. package/src/__tests__/output.test.ts +249 -0
  17. package/src/__tests__/parser.test.ts +627 -0
  18. package/src/__tests__/plugins.test.ts +182 -0
  19. package/src/__tests__/progress.test.ts +221 -0
  20. package/src/__tests__/prompt.test.ts +265 -0
  21. package/src/__tests__/runner.test.ts +459 -0
  22. package/src/__tests__/spec.test.ts +107 -0
  23. package/src/__tests__/testing.test.ts +93 -0
  24. package/src/__tests__/validation.test.ts +267 -0
  25. package/src/argfile.ts +188 -0
  26. package/src/completions.ts +865 -0
  27. package/src/config.ts +184 -0
  28. package/src/help.ts +779 -0
  29. package/src/index.ts +58 -0
  30. package/src/install.ts +226 -0
  31. package/src/log.ts +225 -0
  32. package/src/man.ts +289 -0
  33. package/src/markdown.ts +210 -0
  34. package/src/output.ts +453 -0
  35. package/src/parser.ts +1240 -0
  36. package/src/plugins.ts +193 -0
  37. package/src/progress.ts +295 -0
  38. package/src/prompt.ts +388 -0
  39. package/src/runner.ts +769 -0
  40. package/src/spec.ts +197 -0
  41. package/src/testing.ts +159 -0
  42. package/src/types.ts +618 -0
  43. package/src/validation.ts +627 -0
package/src/help.ts ADDED
@@ -0,0 +1,779 @@
1
+ /**
2
+ * Help renderer - generates clap-style help output.
3
+ * Respects NO_COLOR, TERM=dumb, CI for color output.
4
+ * Wraps text to terminal width.
5
+ * Supports helpHeading grouping, helpTemplate, beforeHelp,
6
+ * hideShortHelp/hideLongHelp, visibleAlias, hidePossibleValues,
7
+ * and custom styles.
8
+ */
9
+
10
+ import { styleText } from 'node:util';
11
+ import type { ArgDef, CommandDef, CommandMeta, OutputSink, StylesDef } from './types.js';
12
+ import { hasSubCommands, possibleValues, subCommandsOf } from './parser.js';
13
+
14
+ // ---- Color Support ----
15
+
16
+ /** Get terminal width, honouring termWidth and maxTermWidth, defaulting to 80. */
17
+ function getTerminalWidth(meta: CommandMeta): number {
18
+ if (meta.termWidth !== undefined && meta.termWidth > 0) {
19
+ return meta.termWidth;
20
+ }
21
+ let width = 80;
22
+ if (typeof process.stdout?.columns === 'number' && process.stdout.columns > 0) {
23
+ width = process.stdout.columns;
24
+ }
25
+ if (meta.maxTermWidth !== undefined && meta.maxTermWidth > 0) {
26
+ width = Math.min(width, meta.maxTermWidth);
27
+ }
28
+ return width;
29
+ }
30
+
31
+ /** Create style functions, merging optional user overrides. */
32
+ const NO_STYLE: StylesDef = {
33
+ bold: (s) => s,
34
+ yellow: (s) => s,
35
+ green: (s) => s,
36
+ cyan: (s) => s,
37
+ heading: (s) => s,
38
+ flag: (s) => s,
39
+ value: (s) => s,
40
+ command: (s) => s,
41
+ };
42
+
43
+ /** Whether colour is on for this command: 'never' off, 'always' forced, else auto. */
44
+ function colourEnabled(meta: CommandMeta): 'off' | 'force' | 'auto' {
45
+ if (meta.color === 'never' || meta.disableColoredHelp === true) {
46
+ return 'off';
47
+ }
48
+ return meta.color === 'always' ? 'force' : 'auto';
49
+ }
50
+
51
+ function createStyles(overrides?: Partial<StylesDef>, mode: 'off' | 'force' | 'auto' = 'auto'): StylesDef {
52
+ if (mode === 'off') {
53
+ return overrides ? { ...NO_STYLE, ...overrides } : NO_STYLE;
54
+ }
55
+ // styleText only colourises when it judges the stream capable; `always` goes
56
+ // around that check with the codes it would have used.
57
+ const paint =
58
+ mode === 'force'
59
+ ? (codes: string | string[], text: string) => styleText(codes as never, text, { validateStream: false })
60
+ : (codes: string | string[], text: string) => styleText(codes as never, text);
61
+ const defaults: StylesDef = {
62
+ bold: (s) => paint('bold', s),
63
+ yellow: (s) => paint('yellow', s),
64
+ green: (s) => paint('green', s),
65
+ cyan: (s) => paint('cyan', s),
66
+ heading: (s) => paint(['bold', 'yellow'], s),
67
+ flag: (s) => paint('green', s),
68
+ value: (s) => paint('cyan', s),
69
+ command: (s) => paint('bold', s),
70
+ };
71
+ if (!overrides) {
72
+ return defaults;
73
+ }
74
+ return { ...defaults, ...overrides };
75
+ }
76
+
77
+ // ---- Help Text Helpers ----
78
+
79
+ /** Wrap text to fit within a given width, preserving leading indent. */
80
+ function wrapText(text: string, maxWidth: number, indent: number): string {
81
+ const available = Math.max(MIN_DESC_WIDTH, maxWidth - indent);
82
+ if (text.length <= available) {
83
+ return text;
84
+ }
85
+
86
+ const words = text.split(/\s+/);
87
+ const lines: string[] = [];
88
+ let currentLine = '';
89
+ const padding = ' '.repeat(indent);
90
+
91
+ for (const word of words) {
92
+ if (currentLine.length === 0) {
93
+ currentLine = word;
94
+ } else if (currentLine.length + 1 + word.length <= available) {
95
+ currentLine += ` ${word}`;
96
+ } else {
97
+ lines.push(currentLine);
98
+ currentLine = word;
99
+ }
100
+ }
101
+ if (currentLine.length > 0) {
102
+ lines.push(currentLine);
103
+ }
104
+
105
+ return lines.join(`\n${padding}`);
106
+ }
107
+
108
+ /** Format a flag string for display: "-s, --long, --visible-alias <VALUE>" */
109
+ function formatArgFlag(key: string, def: ArgDef, styles: StylesDef): { flag: string; rawLen: number } {
110
+ const parts: string[] = [];
111
+ const rawParts: string[] = [];
112
+
113
+ // Short flag
114
+ if (def.short) {
115
+ parts.push(styles.flag(`-${def.short}`));
116
+ rawParts.push(`-${def.short}`);
117
+ }
118
+
119
+ // Long flag
120
+ const longName = def.long ?? key;
121
+ if (def.short) {
122
+ parts.push(`, ${styles.flag(`--${longName}`)}`);
123
+ rawParts.push(`, --${longName}`);
124
+ } else {
125
+ // Pad to align with flags that have short
126
+ parts.push(` ${styles.flag(`--${longName}`)}`);
127
+ rawParts.push(` --${longName}`);
128
+ }
129
+
130
+ // Visible aliases (shown in help, unlike hidden aliases)
131
+ if (def.visibleAlias) {
132
+ for (const alias of def.visibleAlias) {
133
+ if (alias.length === 1) {
134
+ parts.push(`, ${styles.flag(`-${alias}`)}`);
135
+ rawParts.push(`, -${alias}`);
136
+ } else {
137
+ parts.push(`, ${styles.flag(`--${alias}`)}`);
138
+ rawParts.push(`, --${alias}`);
139
+ }
140
+ }
141
+ }
142
+
143
+ // Value placeholder
144
+ if (def.type !== 'boolean' || def.valueName || def.valueNames) {
145
+ const optional = def.numArgs !== undefined && def.numArgs.min === 0;
146
+ const open = optional ? '[' : '<';
147
+ const close = optional ? ']' : '>';
148
+ const names = def.valueNames ?? [def.valueName ?? def.type.toUpperCase()];
149
+ for (const name of names) {
150
+ parts.push(` ${styles.value(`${open}${name}${close}`)}`);
151
+ rawParts.push(` ${open}${name}${close}`);
152
+ }
153
+ }
154
+
155
+ return {
156
+ flag: parts.join(''),
157
+ rawLen: rawParts.join('').length,
158
+ };
159
+ }
160
+
161
+ /** Build the description suffix: [default: x] [env: VAR] [possible values: a, b] */
162
+ function formatArgSuffix(def: ArgDef): string {
163
+ const suffixes: string[] = [];
164
+
165
+ if (def.default !== undefined && def.type !== 'boolean' && !def.hideDefaultValue) {
166
+ const defaultStr = Array.isArray(def.default) ? def.default.join(', ') : String(def.default);
167
+ suffixes.push(`[default: ${defaultStr}]`);
168
+ }
169
+
170
+ if (def.env && !def.hideEnv) {
171
+ const current = def.hideEnvValues || def.secret ? undefined : process.env[def.env];
172
+ suffixes.push(current ? `[env: ${def.env}=${current}]` : `[env: ${def.env}]`);
173
+ }
174
+
175
+ if (!def.hidePossibleValues) {
176
+ const visible = possibleValues(def).filter((v) => !v.hidden);
177
+ if (visible.length > 0) {
178
+ suffixes.push(`[possible values: ${visible.map((v) => v.name).join(', ')}]`);
179
+ }
180
+ }
181
+
182
+ if (def.required) {
183
+ suffixes.push('[required]');
184
+ }
185
+
186
+ if (def.deprecated !== undefined && def.deprecated !== false) {
187
+ suffixes.push(
188
+ typeof def.deprecated === 'string' ? `[deprecated: ${def.deprecated}]` : '[deprecated]',
189
+ );
190
+ }
191
+
192
+ return suffixes.length > 0 ? ` ${suffixes.join(' ')}` : '';
193
+ }
194
+
195
+ /** Description for an arg, preferring the long form in long help. */
196
+ function argDescription(def: ArgDef, isShortHelp: boolean): string {
197
+ const base = (!isShortHelp && def.longDescription) || def.description || '';
198
+ return base + formatArgSuffix(def);
199
+ }
200
+
201
+ /** Sort help entries by displayOrder, keeping declaration order as the tiebreak. */
202
+ function byDisplayOrder(
203
+ entries: (readonly [string, ArgDef])[],
204
+ nextDisplayOrder?: number,
205
+ ): (readonly [string, ArgDef])[] {
206
+ const hasExplicit = entries.some(([, def]) => def.displayOrder !== undefined);
207
+ if (!hasExplicit && nextDisplayOrder === undefined) {
208
+ return entries;
209
+ }
210
+ // Args without an explicit order fall in after nextDisplayOrder, keeping
211
+ // declaration order among themselves.
212
+ const implicitBase = nextDisplayOrder ?? Number.MAX_SAFE_INTEGER;
213
+ return entries
214
+ .map((entry, index) => ({
215
+ entry,
216
+ order: entry[1].displayOrder ?? implicitBase + index,
217
+ index,
218
+ }))
219
+ .sort((a, b) => a.order - b.order || a.index - b.index)
220
+ .map((wrapped) => wrapped.entry);
221
+ }
222
+
223
+ /** Append usage parts for options, positionals, and subcommands. */
224
+ function appendUsageParts(usageParts: string[], command: CommandDef): void {
225
+ const argsDef = command.args ?? {};
226
+ const hasOptions = Object.values(argsDef).some((d) => d.type !== 'positional' && !d.hidden);
227
+ const positionals = Object.entries(argsDef).filter(([_, d]) => d.type === 'positional');
228
+ const hasSubcommands = hasSubCommands(command);
229
+
230
+ if (hasOptions) {
231
+ usageParts.push('[OPTIONS]');
232
+ }
233
+ for (const [key, def] of positionals) {
234
+ const name = def.valueName ?? key.toUpperCase();
235
+ if (def.required) {
236
+ usageParts.push(`<${name}>`);
237
+ } else {
238
+ usageParts.push(`[${name}]`);
239
+ }
240
+ }
241
+ if (hasSubcommands) {
242
+ usageParts.push(`[${command.meta.subcommandValueName ?? 'COMMAND'}]`);
243
+ }
244
+ }
245
+
246
+ /** Check if an arg should be hidden based on help mode. */
247
+ function isArgHiddenForMode(def: ArgDef, isShortHelp: boolean): boolean {
248
+ if (def.hidden) {
249
+ return true;
250
+ }
251
+ if (isShortHelp && def.hideShortHelp) {
252
+ return true;
253
+ }
254
+ if (!isShortHelp && def.hideLongHelp) {
255
+ return true;
256
+ }
257
+ return false;
258
+ }
259
+
260
+ /** Below this many columns beside a flag, help drops to next-line layout. */
261
+ const MIN_DESC_WIDTH = 20;
262
+
263
+ // ---- Entry type for aligned rendering ----
264
+
265
+ interface HelpEntry {
266
+ label: string;
267
+ rawLen: number;
268
+ desc: string;
269
+ /** Put the description on its own line beneath the label. */
270
+ nextLine?: boolean;
271
+ /** Extra indented lines rendered after the description. */
272
+ detail?: readonly string[];
273
+ }
274
+
275
+ /** Render aligned entries (flag + description with padding). */
276
+ function renderAlignedEntries(
277
+ entries: HelpEntry[],
278
+ termWidth: number,
279
+ lines: string[],
280
+ ): void {
281
+ if (entries.length === 0) {
282
+ return;
283
+ }
284
+ const inline = entries.filter((e) => !e.nextLine);
285
+ const maxLen = inline.length > 0 ? Math.max(...inline.map((e) => e.rawLen)) : 0;
286
+ const descIndent = maxLen + 4;
287
+
288
+ // A narrow terminal leaves too little room beside the flag to wrap into, so
289
+ // the whole section drops to next-line help rather than overflowing.
290
+ const forceNextLine = termWidth - (descIndent + 2) < MIN_DESC_WIDTH;
291
+
292
+ for (const entry of entries) {
293
+ if (entry.nextLine || forceNextLine) {
294
+ lines.push(entry.label);
295
+ if (entry.desc) {
296
+ lines.push(` ${wrapText(entry.desc, termWidth, 6)}`);
297
+ }
298
+ } else if (entry.desc) {
299
+ const padding = ' '.repeat(Math.max(2, descIndent - entry.rawLen));
300
+ lines.push(`${entry.label}${padding}${wrapText(entry.desc, termWidth, descIndent)}`);
301
+ } else {
302
+ lines.push(entry.label);
303
+ }
304
+ if (entry.detail) {
305
+ for (const line of entry.detail) {
306
+ lines.push(line);
307
+ }
308
+ }
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Per-value help block, as clap renders under an option in long help:
314
+ *
315
+ * Possible values:
316
+ * - fast: skip the slow checks
317
+ */
318
+ function possibleValueDetail(def: ArgDef, styles: StylesDef, isShortHelp: boolean): string[] | undefined {
319
+ if (isShortHelp || def.hidePossibleValues) {
320
+ return undefined;
321
+ }
322
+ const visible = possibleValues(def).filter((v) => !v.hidden);
323
+ if (!visible.some((v) => v.help)) {
324
+ return undefined;
325
+ }
326
+ const detail = ['', ` ${styles.heading('Possible values:')}`];
327
+ for (const value of visible) {
328
+ detail.push(` - ${styles.value(value.name)}${value.help ? `: ${value.help}` : ''}`);
329
+ }
330
+ detail.push('');
331
+ return detail;
332
+ }
333
+
334
+ // ---- Template Rendering ----
335
+
336
+ /**
337
+ * Render help using a custom template with placeholders.
338
+ * Placeholders: {name}, {version}, {about}, {usage}, {all-args},
339
+ * {arguments}, {options}, {commands}, {before-help}, {after-help}
340
+ */
341
+ function renderHelpTemplate(
342
+ template: string,
343
+ command: CommandDef,
344
+ styles: StylesDef,
345
+ termWidth: number,
346
+ fullName: string,
347
+ isShortHelp: boolean,
348
+ ): string {
349
+ const { meta } = command;
350
+ const argsDef = command.args ?? {};
351
+
352
+ // Build each section as a string
353
+ const usageParts = [styles.heading('Usage:'), styles.command(fullName)];
354
+ appendUsageParts(usageParts, command);
355
+ const usageStr = usageParts.join(' ');
356
+
357
+ const argsLines: string[] = [];
358
+ renderPositionalSection(argsLines, argsDef, styles, termWidth, isShortHelp, meta.nextDisplayOrder);
359
+ const argumentsStr = argsLines.join('\n');
360
+
361
+ const optLines: string[] = [];
362
+ renderOptionsSection(optLines, argsDef, meta, styles, termWidth, isShortHelp);
363
+ const optionsStr = optLines.join('\n');
364
+
365
+ const cmdLines: string[] = [];
366
+ if (hasSubCommands(command)) {
367
+ renderSubcommandSection(cmdLines, command, styles, termWidth, fullName);
368
+ }
369
+ const commandsStr = cmdLines.join('\n');
370
+
371
+ return template
372
+ .replaceAll('{name}', meta.name)
373
+ .replaceAll('{version}', meta.version ?? '')
374
+ .replaceAll('{author}', meta.author ?? '')
375
+ .replaceAll('{about}', meta.about ?? meta.description ?? '')
376
+ .replaceAll('{usage}', usageStr)
377
+ .replaceAll('{all-args}', [argumentsStr, optionsStr].filter(Boolean).join('\n'))
378
+ .replaceAll('{arguments}', argumentsStr)
379
+ .replaceAll('{options}', optionsStr)
380
+ .replaceAll('{commands}', commandsStr)
381
+ .replaceAll('{before-help}', meta.beforeHelp ?? '')
382
+ .replaceAll('{after-help}', meta.afterHelp ?? '');
383
+ }
384
+
385
+ // ---- Section Renderers (extracted for reuse) ----
386
+
387
+ /** Render positional arguments section. */
388
+ function renderPositionalSection(
389
+ lines: string[],
390
+ argsDef: Record<string, ArgDef>,
391
+ styles: StylesDef,
392
+ termWidth: number,
393
+ isShortHelp: boolean,
394
+ nextDisplayOrder?: number,
395
+ ): void {
396
+ const positionals = Object.entries(argsDef).filter(([_, d]) => d.type === 'positional');
397
+ const visiblePositionals = positionals.filter(([_, d]) => !isArgHiddenForMode(d, isShortHelp));
398
+
399
+ if (visiblePositionals.length === 0) {
400
+ return;
401
+ }
402
+
403
+ lines.push(styles.heading('Arguments:'));
404
+
405
+ const entries: HelpEntry[] = [];
406
+ for (const [key, def] of byDisplayOrder(visiblePositionals, nextDisplayOrder)) {
407
+ const name = def.valueName ?? key.toUpperCase();
408
+ const label = ` ${styles.value(`<${name}>`)}`;
409
+ const rawLen = name.length + 4;
410
+ entries.push({
411
+ label,
412
+ rawLen,
413
+ desc: argDescription(def, isShortHelp),
414
+ nextLine: def.nextLineHelp,
415
+ detail: possibleValueDetail(def, styles, isShortHelp),
416
+ });
417
+ }
418
+
419
+ renderAlignedEntries(entries, termWidth, lines);
420
+ }
421
+
422
+ /** Render options section, grouped by helpHeading. */
423
+ function renderOptionsSection(
424
+ lines: string[],
425
+ argsDef: Record<string, ArgDef>,
426
+ meta: CommandMeta,
427
+ styles: StylesDef,
428
+ termWidth: number,
429
+ isShortHelp: boolean,
430
+ ): void {
431
+ const options = Object.entries(argsDef).filter(
432
+ ([_, d]) => d.type !== 'positional' && !isArgHiddenForMode(d, isShortHelp),
433
+ );
434
+
435
+ if (options.length === 0) {
436
+ return;
437
+ }
438
+
439
+ // Group options by helpHeading
440
+ const groups = new Map<string, [string, ArgDef][]>();
441
+ const defaultHeading = meta.nextHelpHeading ?? 'Options';
442
+
443
+ for (const entry of options) {
444
+ const heading = entry[1].helpHeading ?? defaultHeading;
445
+ let group = groups.get(heading);
446
+ if (!group) {
447
+ group = [];
448
+ groups.set(heading, group);
449
+ }
450
+ group.push(entry);
451
+ }
452
+
453
+ for (const [heading, groupOptions] of groups) {
454
+ lines.push('');
455
+ lines.push(styles.heading(`${heading}:`));
456
+
457
+ const entries: HelpEntry[] = [];
458
+
459
+ for (const [key, def] of byDisplayOrder(groupOptions, meta.nextDisplayOrder)) {
460
+ const { flag, rawLen } = formatArgFlag(key, def, styles);
461
+ entries.push({
462
+ label: ` ${flag}`,
463
+ rawLen: rawLen + 2,
464
+ desc: argDescription(def, isShortHelp),
465
+ nextLine: def.nextLineHelp,
466
+ detail: possibleValueDetail(def, styles, isShortHelp),
467
+ });
468
+
469
+ // Boolean negation: --no-flag
470
+ if (def.type === 'boolean' && def.negativeDescription) {
471
+ const longName = def.long ?? key;
472
+ const negFlag = ` ${styles.flag(`--no-${longName}`)}`;
473
+ const negRawLen = longName.length + 10;
474
+ entries.push({
475
+ label: ` ${negFlag}`,
476
+ rawLen: negRawLen + 2,
477
+ desc: def.negativeDescription,
478
+ });
479
+ }
480
+ }
481
+
482
+ // Add built-in --help and --version to the default "Options" group
483
+ if (heading === defaultHeading) {
484
+ if (!meta.disableHelpFlag) {
485
+ const helpFlag = ` ${styles.flag('-h')}, ${styles.flag('--help')}`;
486
+ entries.push({ label: helpFlag, rawLen: ' -h, --help'.length, desc: 'Print help' });
487
+ }
488
+
489
+ if (meta.version && !meta.disableVersionFlag) {
490
+ const versionFlag = ` ${styles.flag('-V')}, ${styles.flag('--version')}`;
491
+ entries.push({
492
+ label: versionFlag,
493
+ rawLen: ' -V, --version'.length,
494
+ desc: 'Print version',
495
+ });
496
+ }
497
+ }
498
+
499
+ renderAlignedEntries(entries, termWidth, lines);
500
+ }
501
+ }
502
+
503
+ // ---- Main Renderer ----
504
+
505
+ /**
506
+ * Render the full help text for a command.
507
+ * Matches clap's help format. Supports helpTemplate override,
508
+ * helpHeading grouping, beforeHelp, and help mode filtering.
509
+ */
510
+ export function renderHelp(
511
+ command: CommandDef,
512
+ parentNames?: string[],
513
+ isShortHelp = false,
514
+ styleOverrides?: Partial<StylesDef>,
515
+ ): string {
516
+ const { meta } = command;
517
+
518
+ if (meta.overrideHelp !== undefined) {
519
+ return meta.overrideHelp.endsWith('\n') ? meta.overrideHelp : `${meta.overrideHelp}\n`;
520
+ }
521
+
522
+ const styles = createStyles(styleOverrides, colourEnabled(meta));
523
+ const termWidth = getTerminalWidth(meta);
524
+ const usageName = meta.binName ?? meta.name;
525
+ const fullName = parentNames ? [...parentNames, usageName].join(' ') : usageName;
526
+
527
+ // Custom template override
528
+ if (meta.helpTemplate) {
529
+ return renderHelpTemplate(meta.helpTemplate, command, styles, termWidth, fullName, isShortHelp);
530
+ }
531
+
532
+ const lines: string[] = [];
533
+
534
+ // Before help text
535
+ const beforeHelp = (!isShortHelp && meta.beforeLongHelp) || meta.beforeHelp;
536
+ if (beforeHelp) {
537
+ lines.push(beforeHelp);
538
+ lines.push('');
539
+ }
540
+
541
+ // Header: "Description (name vX.Y.Z)"
542
+ const headerName = meta.displayName ?? meta.name;
543
+ const nameVersion = meta.version ? `${headerName} v${meta.version}` : headerName;
544
+ const headerDesc = meta.about ?? meta.description ?? '';
545
+ if (headerDesc) {
546
+ lines.push(`${headerDesc} (${nameVersion})`);
547
+ } else {
548
+ lines.push(nameVersion);
549
+ }
550
+
551
+ // Long about (if any, only in long help mode)
552
+ if (meta.longAbout && !isShortHelp) {
553
+ lines.push('');
554
+ lines.push(meta.longAbout);
555
+ }
556
+
557
+ lines.push('');
558
+
559
+ // Usage line
560
+ if (meta.overrideUsage !== undefined) {
561
+ lines.push(`${styles.heading('Usage:')} ${meta.overrideUsage}`);
562
+ } else {
563
+ const usageParts = [styles.heading('Usage:'), styles.command(fullName)];
564
+ appendUsageParts(usageParts, command);
565
+ lines.push(usageParts.join(' '));
566
+ }
567
+
568
+ // Positional arguments
569
+ const argsDef = command.args ?? {};
570
+ const posLines: string[] = [];
571
+ renderPositionalSection(posLines, argsDef, styles, termWidth, isShortHelp, meta.nextDisplayOrder);
572
+ if (posLines.length > 0) {
573
+ lines.push('');
574
+ lines.push(...posLines);
575
+ }
576
+
577
+ // Options (grouped by helpHeading)
578
+ const optLines: string[] = [];
579
+ renderOptionsSection(optLines, argsDef, meta, styles, termWidth, isShortHelp);
580
+ lines.push(...optLines);
581
+
582
+ // Subcommands
583
+ const hasSubcommands = hasSubCommands(command);
584
+ if (hasSubcommands) {
585
+ renderSubcommandSection(lines, command, styles, termWidth, fullName);
586
+ }
587
+
588
+ // After help
589
+ const afterHelp = (!isShortHelp && meta.afterLongHelp) || meta.afterHelp;
590
+ if (afterHelp) {
591
+ lines.push('');
592
+ lines.push(afterHelp);
593
+ }
594
+
595
+ lines.push('');
596
+ return lines.join('\n');
597
+ }
598
+
599
+ /** Render the subcommands section of help output. */
600
+ /**
601
+ * One indented line per visible arg of a subcommand, as clap's flatten_help
602
+ * shows so `git stash --help` can summarise `push` and `pop` in place.
603
+ */
604
+ function flattenedSubcommandArgs(sub: CommandDef, styles: StylesDef): string[] | undefined {
605
+ const argsDef: Record<string, ArgDef> = sub.args ?? {};
606
+ const visible = Object.entries(argsDef).filter(([, def]) => !def.hidden);
607
+ if (visible.length === 0) {
608
+ return undefined;
609
+ }
610
+ const named = visible.map(([key, def]) => ({
611
+ raw:
612
+ def.type === 'positional'
613
+ ? `<${def.valueName ?? key.toUpperCase()}>`
614
+ : `--${def.long ?? key}`,
615
+ def,
616
+ }));
617
+ const width = Math.max(...named.map((n) => n.raw.length));
618
+
619
+ return named.map(({ raw, def }) => {
620
+ const painted = def.type === 'positional' ? styles.value(raw) : styles.flag(raw);
621
+ const padding = ' '.repeat(width - raw.length + 2);
622
+ return ` ${painted}${def.description ? `${padding}${def.description}` : ''}`;
623
+ });
624
+ }
625
+
626
+ function renderSubcommandSection(
627
+ lines: string[],
628
+ command: CommandDef,
629
+ styles: StylesDef,
630
+ termWidth: number,
631
+ fullName: string,
632
+ ): void {
633
+ const flatten = command.meta.flattenHelp === true;
634
+ lines.push('');
635
+ lines.push(styles.heading(`${command.meta.subcommandHelpHeading ?? 'Commands'}:`));
636
+
637
+ const subEntries: HelpEntry[] = [];
638
+ const rendered = new Set<string>();
639
+
640
+ const subs = Object.entries(subCommandsOf(command));
641
+ if (subs.some(([, def]) => def.meta.displayOrder !== undefined)) {
642
+ subs.sort(
643
+ (a, b) =>
644
+ (a[1].meta.displayOrder ?? Number.MAX_SAFE_INTEGER) -
645
+ (b[1].meta.displayOrder ?? Number.MAX_SAFE_INTEGER),
646
+ );
647
+ }
648
+
649
+ for (const [name, def] of subs) {
650
+ if (def.meta.hidden) {
651
+ continue;
652
+ }
653
+ if (rendered.has(name)) {
654
+ continue;
655
+ }
656
+ rendered.add(name);
657
+
658
+ // Visible aliases and any flag form share the parenthesised suffix.
659
+ const extras: string[] = [...(def.meta.aliases ?? [])];
660
+ if (def.meta.shortFlag !== undefined) {
661
+ extras.push(`-${def.meta.shortFlag}`);
662
+ }
663
+ if (def.meta.longFlag !== undefined) {
664
+ extras.push(`--${def.meta.longFlag}`);
665
+ }
666
+ for (const alias of def.meta.visibleShortFlagAliases ?? []) {
667
+ extras.push(`-${alias}`);
668
+ }
669
+ for (const alias of def.meta.visibleLongFlagAliases ?? []) {
670
+ extras.push(`--${alias}`);
671
+ }
672
+
673
+ let label: string;
674
+ let rawLen: number;
675
+ if (extras.length > 0) {
676
+ const aliasStr = extras.join(', ');
677
+ label = ` ${styles.command(name)} (${aliasStr})`;
678
+ rawLen = name.length + aliasStr.length + 5;
679
+ } else {
680
+ label = ` ${styles.command(name)}`;
681
+ rawLen = name.length + 2;
682
+ }
683
+
684
+ const deprecated = def.meta.deprecated;
685
+ const note =
686
+ deprecated === undefined || deprecated === false
687
+ ? ''
688
+ : typeof deprecated === 'string'
689
+ ? ` [deprecated: ${deprecated}]`
690
+ : ' [deprecated]';
691
+
692
+ subEntries.push({
693
+ label,
694
+ rawLen,
695
+ desc: (def.meta.description ?? '') + note,
696
+ detail: flatten ? flattenedSubcommandArgs(def, styles) : undefined,
697
+ });
698
+ }
699
+
700
+ if (!command.meta.disableHelpSubcommand) {
701
+ subEntries.push({
702
+ label: ` ${styles.command('help')}`,
703
+ rawLen: 6,
704
+ desc: 'Print this message or the help of the given subcommand(s)',
705
+ });
706
+ }
707
+
708
+ renderAlignedEntries(subEntries, termWidth, lines);
709
+
710
+ lines.push('');
711
+ lines.push(
712
+ `Use ${styles.command(`${fullName} <command> --help`)} for more information about a command.`,
713
+ );
714
+ }
715
+
716
+ /**
717
+ * Render a short usage message (shown on errors).
718
+ */
719
+ export function renderUsage(
720
+ command: CommandDef,
721
+ parentNames?: string[],
722
+ styleOverrides?: Partial<StylesDef>,
723
+ ): string {
724
+ const { meta } = command;
725
+ const styles = createStyles(styleOverrides, colourEnabled(meta));
726
+
727
+ if (meta.overrideUsage !== undefined) {
728
+ return `${styles.heading('Usage:')} ${meta.overrideUsage}`;
729
+ }
730
+
731
+ const usageName = meta.binName ?? meta.name;
732
+ const fullName = parentNames ? [...parentNames, usageName].join(' ') : usageName;
733
+ const usageParts = [styles.heading('Usage:'), styles.command(fullName)];
734
+ appendUsageParts(usageParts, command);
735
+
736
+ return usageParts.join(' ');
737
+ }
738
+
739
+ /**
740
+ * Print help to stdout.
741
+ */
742
+ export function showHelp(
743
+ command: CommandDef,
744
+ parentNames?: string[],
745
+ isShortHelp = false,
746
+ styleOverrides?: Partial<StylesDef>,
747
+ out: OutputSink = process.stdout,
748
+ ): void {
749
+ out.write(renderHelp(command, parentNames, isShortHelp, styleOverrides));
750
+ }
751
+
752
+ /**
753
+ * Print version to stdout.
754
+ */
755
+ export function showVersion(
756
+ meta: CommandMeta,
757
+ isShort = false,
758
+ out: OutputSink = process.stdout,
759
+ ): void {
760
+ const version = (!isShort && meta.longVersion) || meta.version || '0.0.0';
761
+ out.write(`${meta.displayName ?? meta.name} ${version}\n`);
762
+ }
763
+
764
+ /**
765
+ * Print an error message with usage hint.
766
+ */
767
+ export function showError(
768
+ message: string,
769
+ command: CommandDef,
770
+ parentNames?: string[],
771
+ styleOverrides?: Partial<StylesDef>,
772
+ out: OutputSink = process.stderr,
773
+ ): void {
774
+ const styles = createStyles(styleOverrides, colourEnabled(command.meta));
775
+ const usage = renderUsage(command, parentNames, styleOverrides);
776
+ out.write(
777
+ `${styles.bold('error:')} ${message}\n\n${usage}\n\nFor more information, try '${styles.flag('--help')}'.\n`,
778
+ );
779
+ }