clap-ts 0.2.0 → 0.3.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,30 +1,14 @@
1
1
  # clap-ts
2
2
 
3
- A type-safe CLI argument parser for TypeScript, inspired by Rust's [clap](https://docs.rs/clap/latest/clap/) crate.
4
-
5
- Full clap-style parsing, validation, help generation, and subcommand support. Zero runtime dependencies -- built on `node:util parseArgs` with a rich layer on top.
6
-
7
- ## Features
8
-
9
- - **Full type inference** -- `defineCommand` infers exact types for parsed args, no casts needed
10
- - **Clap-compatible argument model** -- boolean, string, number, enum, positional args with short/long flags
11
- - **Subcommands** -- nested command trees with alias support, prefix inference, and external subcommands
12
- - **Validation** -- required, exclusive, conflictsWith, requires, requiredUnlessPresent, requiredIfEq, valueParser, numArgs, argument groups
13
- - **Custom value parsers** -- function-based parsers for custom validation and type conversion
14
- - **Clap-style help** -- colored, terminal-width-aware help with custom headings, templates, and styles
15
- - **Clap-style errors** -- "did you mean?" typo suggestions via Levenshtein distance
16
- - **Environment variable fallback** -- `env` field on args, with CLI > env > default precedence
17
- - **Lifecycle hooks** -- setup/run/cleanup pattern for resource management
18
- - **Actions** -- `set` (default), `append` (collect into array), `count` (e.g. `-vvv` = 3)
19
- - **Value delimiters** -- `--tags=a,b,c` splits into array with `valueDelimiter`
20
- - **Boolean negation** -- `--no-verbose` automatically supported for boolean flags
21
- - **Global args** -- `global: true` args inherited by all subcommands
22
- - **Reusable arg groups** -- `defineArgs()` + spread for sharing args across commands
23
- - **Trailing var args** -- last positional consumes all remaining args
24
- - **Negative numbers** -- `--offset -10` with `allowNegativeNumbers`
25
- - **Hyphen values** -- `--grep -pattern` with `allowHyphenValues`
26
- - **Zero dependencies** -- only uses `node:util` (parseArgs + styleText)
27
- - **Bun and Node.js** -- works on both runtimes
3
+ A type-safe CLI argument parser for TypeScript, modelled on Rust's
4
+ [clap](https://docs.rs/clap/latest/clap/).
5
+
6
+ - **Full type inference.** `defineCommand` infers exact argument types; no casts, no schema to repeat.
7
+ - **Complete clap parity.** Every `Arg` and `Command` builder option, from `numArgs` to `overridesWith` to `multicall`.
8
+ - **Fast.** A single pass over argv against a spec compiled once per command: 40ns for a bare parse, 1.5us for a full pipeline.
9
+ - **Zero dependencies.** Only `node:util`, for colour detection.
10
+ - **Batteries behind subpaths.** Config files, prompts, completions for six shells, man pages, markdown docs, plugins, tables, logging and progress, none of which load unless imported.
11
+ - **Node and Bun.** Requires Node 20 or Bun 1.0.
28
12
 
29
13
  ## Install
30
14
 
@@ -97,7 +81,31 @@ Options:
97
81
  -V, --version Print version
98
82
  ```
99
83
 
100
- ## API Reference
84
+ ## Documentation
85
+
86
+ **Getting started:** [Install](#install) · [Quick Start](#quick-start)
87
+
88
+ **Defining commands:** [defineCommand](#definecommand) · [Arguments](#arguments) ·
89
+ [Constraints](#constraints) · [Positionals](#positionals) ·
90
+ [Subcommands](#subcommands) · [Argument Groups](#argument-groups) ·
91
+ [Deprecation](#deprecating-arguments-and-commands) · [Lazy Subcommands](#lazy-subcommands)
92
+
93
+ **Running:** [runMain](#runmain) · [Lifecycle Hooks](#lifecycle-hooks) ·
94
+ [Value Precedence](#value-precedence-and-sources) · [Exit Codes](#exit-codes) ·
95
+ [Error Messages](#error-messages) · [Low-Level API](#low-level-api)
96
+
97
+ **Help:** [Help Output](#help-output) · [Styles](#styles) · [Templates](#help-template)
98
+
99
+ **Optional modules:** [config](#configuration-files) · [prompt](#interactive-prompts) ·
100
+ [testing](#testing) · [completions](#shell-completions) · [man](#man-pages) ·
101
+ [markdown](#markdown-documentation) · [install](#installing-completions-and-man-pages) ·
102
+ [spec](#machine-readable-spec) · [plugins](#plugins) · [argfile](#response-files-and-stdin) ·
103
+ [output](#terminal-output) · [log and progress](#logging-and-progress)
104
+
105
+ **Reference:** [Performance](#performance) · [Comparison with clap](#comparison-with-rust-clap) ·
106
+ [Upgrading](#upgrading-from-02)
107
+
108
+ ## Defining Commands
101
109
 
102
110
  ### defineCommand
103
111
 
@@ -116,7 +124,18 @@ const cmd = defineCommand({
116
124
  beforeHelp: 'NOTE: Requires auth.', // text before help output
117
125
  afterHelp: 'Examples:\n serve -p 8080', // text after help output
118
126
  hidden: false, // hide from parent help
119
- aliases: ['s', 'start'], // subcommand aliases
127
+ aliases: ['s', 'start'], // subcommand aliases, shown in help
128
+ hiddenAliases: ['srv'], // aliases that work but stay hidden
129
+ author: 'Salama Ashoush', // available to templates as {author}
130
+ longVersion: '1.0.0 (build abc123)', // --version; -V still shows `version`
131
+ propagateVersion: true, // subcommands inherit this version
132
+ displayOrder: 1, // position among sibling subcommands
133
+ shortFlag: 's', // invoke as `tool -s`
134
+ longFlag: 'serve', // invoke as `tool --serve`
135
+ shortFlagAliases: ['S'], // extra forms, hidden from help
136
+ longFlagAliases: ['start'],
137
+ visibleShortFlagAliases: ['r'], // extra forms shown in help
138
+ visibleLongFlagAliases: ['run'],
120
139
 
121
140
  // Subcommand behavior
122
141
  subcommandRequired: true, // error if no subcommand
@@ -126,10 +145,48 @@ const cmd = defineCommand({
126
145
  subcommandNegatesReqs: true, // subcommand waives parent required args
127
146
  argsConflictsWithSubcommands: true, // args and subcommands mutually exclusive
128
147
  argRequiredElseHelp: true, // show help if no args provided
148
+ subcommandPrecedenceOverArg: true, // a subcommand name ends value collection
149
+ multicall: true, // dispatch on the invoked binary name
150
+ noBinaryName: true, // argv carries no binary name to strip
151
+
152
+ // Naming
153
+ binName: 'git stash', // shown in the usage line
154
+ displayName: 'git-stash', // shown in the help header
155
+
156
+ // Parsing behavior
157
+ allowHyphenValues: true, // every arg may take -values
158
+ allowNegativeNumbers: true, // every arg may take negative numbers
159
+ allowMissingPositional: true, // `cp DEST` fills the trailing positional
160
+ argsOverrideSelf: true, // repeating an arg replaces, not errors
129
161
 
130
162
  // Help customization
131
163
  helpTemplate: '{name} v{version}\n{usage}\n{options}',
164
+ beforeLongHelp: 'Shown only with --help',
165
+ afterLongHelp: 'Shown only with --help',
166
+ subcommandHelpHeading: 'Operations', // default "Commands"
167
+ subcommandValueName: 'OP', // usage placeholder, default "COMMAND"
168
+ overrideUsage: 'serve <FILE> [--port N]', // replace the usage line
169
+ overrideHelp: '...', // replace the whole help output
170
+ termWidth: 100, // fixed help width
171
+ maxTermWidth: 120, // cap on the detected terminal width
172
+ disableHelpFlag: true, // drop the built-in -h/--help
173
+ disableVersionFlag: true, // drop the built-in -V/--version
174
+ disableHelpSubcommand: true, // drop the built-in `help` subcommand
175
+ disableColoredHelp: true, // render help without colour
176
+ color: 'always', // 'auto' | 'always' | 'never'
177
+ flattenHelp: true, // summarise subcommand args in place
178
+ nextHelpHeading: 'Global', // default heading for args that set none
179
+ nextDisplayOrder: 100, // starting order for args that set none
180
+ helpExpected: true, // reject a visible arg with no description
181
+
182
+ // Error tolerance
183
+ ignoreErrors: true, // collect on ParseResult.errors, keep going
184
+ dontDelimitTrailingValues: true, // leave values after -- unsplit
132
185
  },
186
+ // Built on first use instead of at definition time
187
+ lazySubCommands: () => ({ deploy: heavyDeployCommand }),
188
+ // Applied to each argument of an external subcommand
189
+ externalSubcommandValueParser: (v) => v.trim(),
133
190
  args: { /* ... */ },
134
191
  subCommands: { /* ... */ },
135
192
  groups: [ /* ... */ ],
@@ -139,7 +196,7 @@ const cmd = defineCommand({
139
196
  });
140
197
  ```
141
198
 
142
- ### Argument Definition
199
+ ### Arguments
143
200
 
144
201
  Each argument is defined with an `ArgDef`:
145
202
 
@@ -286,11 +343,97 @@ const cmd = defineCommand({
286
343
  hidePossibleValues: true,
287
344
  description: 'Output format',
288
345
  },
346
+
347
+ // Multi-value option: --point 1 2 3
348
+ point: {
349
+ type: 'string',
350
+ numArgs: { min: 3, max: 3 },
351
+ valueNames: ['X', 'Y', 'Z'], // help shows --point <X> <Y> <Z>
352
+ description: 'Coordinates',
353
+ },
354
+
355
+ // Value terminator: --cmds ls -la ; file.txt
356
+ cmds: {
357
+ type: 'string',
358
+ action: 'append',
359
+ numArgs: { min: 1, max: 99 },
360
+ allowHyphenValues: true,
361
+ valueTerminator: ';',
362
+ description: 'Command to run',
363
+ },
364
+
365
+ // Require the equals form: --key=value, never --key value
366
+ key: {
367
+ type: 'string',
368
+ requireEquals: true,
369
+ description: 'API key',
370
+ },
371
+
372
+ // Possible values carrying help text, aliases and hidden entries
373
+ mode: {
374
+ type: 'string',
375
+ ignoreCase: true, // --mode FAST matches 'fast'
376
+ valueParser: [
377
+ { name: 'fast', help: 'Skip the slow checks' },
378
+ { name: 'thorough', aliases: ['full'] },
379
+ { name: 'legacy', hidden: true },
380
+ ],
381
+ description: 'Build mode',
382
+ },
383
+
384
+ // The later of the two wins
385
+ dev: { type: 'boolean', overridesWith: ['release'] },
386
+ release: { type: 'boolean', overridesWith: ['dev'] },
387
+
388
+ // Help presentation
389
+ bind: {
390
+ type: 'string',
391
+ longDescription: 'The longer explanation, shown with --help only',
392
+ displayOrder: 1, // position within its help section
393
+ nextLineHelp: true, // description on its own line
394
+ hideDefaultValue: true, // drop the [default: ...] note
395
+ description: 'Address to bind',
396
+ },
397
+ token: {
398
+ type: 'string',
399
+ env: 'TOOL_TOKEN',
400
+ hideEnvValues: true, // show [env: TOOL_TOKEN], not its value
401
+ description: 'API token',
402
+ },
403
+
404
+ // Explicit positional position, and group membership
405
+ dest: { type: 'positional', index: 2 },
406
+ src: { type: 'positional', index: 1 },
407
+ yaml: { type: 'boolean', group: 'format' }, // or groups: ['format', 'output']
408
+
409
+ // Actions beyond set, append and count
410
+ colour: { type: 'boolean', action: 'setTrue' },
411
+ noColour: { type: 'boolean', long: 'no-colour', action: 'setFalse' },
412
+ usage: { type: 'boolean', short: '?', action: 'help' }, // also helpShort, helpLong
413
+ revision: { type: 'boolean', action: 'version' },
414
+
415
+ // Values for a bare multi-value flag
416
+ origin: {
417
+ type: 'string',
418
+ numArgs: { min: 0, max: 3 },
419
+ defaultMissingValues: ['0', '0', '0'],
420
+ },
421
+
422
+ // Requires another arg only at a particular value
423
+ source: {
424
+ type: 'string',
425
+ requiresIf: ['remote', 'url'], // or requiresIfs: [[v, arg], ...]
426
+ },
427
+ url: { type: 'string' },
289
428
  },
290
429
  });
291
430
  ```
292
431
 
293
- ### Argument Constraints
432
+ By default a value shown for `env` in help includes the variable's current value,
433
+ matching clap. Use `hideEnvValues` to show only the name, or `hideEnv` to drop the
434
+ note entirely, when the variable holds a secret.
435
+
436
+ ### Constraints
294
437
 
295
438
  ```ts
296
439
  const cmd = defineCommand({
@@ -359,7 +502,7 @@ const cmd = defineCommand({
359
502
  });
360
503
  ```
361
504
 
362
- ### Trailing Var Args and Last Positional
505
+ ### Positionals
363
506
 
364
507
  ```ts
365
508
  // trailingVarArg: last positional consumes all remaining args
@@ -445,7 +588,7 @@ const git = defineCommand({
445
588
  });
446
589
  ```
447
590
 
448
- ### Reusable Argument Groups
591
+ ### Argument Groups
449
592
 
450
593
  ```ts
451
594
  import { defineArgs, defineCommand } from 'clap-ts';
@@ -474,47 +617,46 @@ const cmd = defineCommand({
474
617
  });
475
618
  ```
476
619
 
477
- ### Lifecycle Hooks
478
-
479
- Commands support setup/run/cleanup lifecycle hooks. `cleanup` always runs, even if `run` throws.
620
+ ### Deprecating Arguments and Commands
480
621
 
481
622
  ```ts
482
- const cmd = defineCommand({
483
- meta: { name: 'server' },
484
- args: { port: { type: 'number', default: 3000 } },
485
-
486
- async setup(ctx) {
487
- ctx.data.db = await connectToDatabase();
488
- },
489
-
490
- async run(ctx) {
491
- const db = ctx.data.db as Database;
492
- await startServer(ctx.args.port, db);
623
+ const main = defineCommand({
624
+ meta: { name: 'my-tool' },
625
+ args: {
626
+ output: { type: 'string', description: 'Where to write' },
627
+ out: { type: 'string', deprecated: 'renamed', replacedBy: 'output' },
493
628
  },
494
-
495
- async cleanup(ctx) {
496
- const db = ctx.data.db as Database;
497
- await db?.close();
629
+ run({ args }) {
630
+ console.log(args.output);
498
631
  },
499
632
  });
500
633
  ```
501
634
 
502
- ### Custom Styles
635
+ Using `--out` warns once on stderr, labels itself `[deprecated: renamed]` in
636
+ help, and forwards its value to `--output`, so a rename keeps working without
637
+ the handler knowing. `deprecated` works the same on `meta` for a whole command.
503
638
 
504
- Override the default terminal colors for help and error output:
639
+ ### Lazy Subcommands
505
640
 
506
- ```ts
507
- import { runMain } from 'clap-ts';
641
+ `lazySubCommands` takes a thunk, so a command tree whose branches each pull in
642
+ heavy modules only builds the branch being run. It is merged with `subCommands`,
643
+ which wins on a name collision.
508
644
 
509
- runMain(rootCommand, {
510
- styles: {
511
- heading: (s) => `\x1b[35m${s}\x1b[0m`, // magenta headings
512
- flag: (s) => `\x1b[36m${s}\x1b[0m`, // cyan flags
513
- command: (s) => `\x1b[1m${s}\x1b[0m`, // bold commands
514
- },
645
+ ```ts
646
+ const main = defineCommand({
647
+ meta: { name: 'tool' },
648
+ lazySubCommands: () => ({
649
+ build: require('./commands/build').default,
650
+ deploy: require('./commands/deploy').default,
651
+ }),
515
652
  });
516
653
  ```
517
654
 
655
+ The thunk runs on the first token that could be a subcommand, so `tool --version`
656
+ never calls it.
657
+
658
+ ## Running
659
+
518
660
  ### runMain
519
661
 
520
662
  Entry point for CLI applications. Handles argv parsing, subcommand resolution, validation, help/version, and error display.
@@ -532,34 +674,32 @@ runMain(rootCommand, {
532
674
  });
533
675
  ```
534
676
 
535
- ### Low-Level API
677
+ ### Lifecycle Hooks
536
678
 
537
- For advanced use cases, you can use the parser and validator directly:
679
+ Commands support setup/run/cleanup lifecycle hooks. `cleanup` always runs, even if `run` throws.
538
680
 
539
681
  ```ts
540
- import { parseArgs, validate, renderHelp, CliParseError } from 'clap-ts';
541
-
542
- const command = defineCommand({
543
- meta: { name: 'tool' },
682
+ const cmd = defineCommand({
683
+ meta: { name: 'server' },
544
684
  args: { port: { type: 'number', default: 3000 } },
545
- });
546
685
 
547
- // Parse without validation
548
- const result = parseArgs(['--port', '8080'], command);
549
- // result.args, result.positionals, result.rest, result.unknown
550
- // result.explicitlySet -- Set of arg keys that were explicitly provided
551
-
552
- // Validate separately
553
- validate(result, command); // throws CliParseError on failure
686
+ async setup(ctx) {
687
+ ctx.data.db = await connectToDatabase();
688
+ },
554
689
 
555
- // Render help text
556
- const helpText = renderHelp(command);
690
+ async run(ctx) {
691
+ const db = ctx.data.db as Database;
692
+ await startServer(ctx.args.port, db);
693
+ },
557
694
 
558
- // Render short help (-h style, hides hideShortHelp args)
559
- const shortHelp = renderHelp(command, undefined, true);
695
+ async cleanup(ctx) {
696
+ const db = ctx.data.db as Database;
697
+ await db?.close();
698
+ },
699
+ });
560
700
  ```
561
701
 
562
- ## Value Precedence
702
+ ### Value Precedence and Sources
563
703
 
564
704
  Arguments are resolved in this order (highest wins):
565
705
 
@@ -568,7 +708,34 @@ Arguments are resolved in this order (highest wins):
568
708
  3. **Conditional defaults** -- `defaultValueIf: ['env', 'prod', 443]`
569
709
  4. **Static defaults** -- `default: 3000`
570
710
 
571
- ## Error Messages
711
+ Every handler gets a `valueSources` map saying where each argument's value came
712
+ from, which is the only way to tell `--port 3000` from a default of the same
713
+ number.
714
+
715
+ ```ts
716
+ defineCommand({
717
+ meta: { name: 'serve' },
718
+ args: {
719
+ port: { type: 'number', default: 3000, env: 'PORT' },
720
+ },
721
+ run({ args, valueSources }) {
722
+ // 'cli' | 'env' | 'default', or undefined when the arg has no value
723
+ if (valueSources.get('port') === 'default') {
724
+ console.log(`Using the default port ${args.port}`);
725
+ }
726
+ },
727
+ });
728
+ ```
729
+
730
+ ### Exit Codes
731
+
732
+ | Code | Meaning |
733
+ |------|---------|
734
+ | 0 | Success |
735
+ | 1 | Runtime error (unhandled exception in run/setup/cleanup) |
736
+ | 2 | Usage error (parse failure, validation failure, unknown flags) |
737
+
738
+ ### Error Messages
572
739
 
573
740
  Errors match clap's format with typo suggestions:
574
741
 
@@ -600,6 +767,36 @@ Usage: my-tool [OPTIONS]
600
767
  For more information, try '--help'.
601
768
  ```
602
769
 
770
+ ### Low-Level API
771
+
772
+ For advanced use cases, you can use the parser and validator directly:
773
+
774
+ ```ts
775
+ import {
776
+ parseArgs, validate, renderHelp, CliParseError,
777
+ subCommandsOf, hasSubCommands, possibleValues,
778
+ } from 'clap-ts';
779
+
780
+ const command = defineCommand({
781
+ meta: { name: 'tool' },
782
+ args: { port: { type: 'number', default: 3000 } },
783
+ });
784
+
785
+ // Parse without validation
786
+ const result = parseArgs(['--port', '8080'], command);
787
+ // result.args, result.positionals, result.rest, result.unknown
788
+ // result.explicitlySet -- Set of arg keys that were explicitly provided
789
+
790
+ // Validate separately
791
+ validate(result, command); // throws CliParseError on failure
792
+
793
+ // Render help text
794
+ const helpText = renderHelp(command);
795
+
796
+ // Render short help (-h style, hides hideShortHelp args)
797
+ const shortHelp = renderHelp(command, undefined, true);
798
+ ```
799
+
603
800
  ## Help Output
604
801
 
605
802
  Help is automatically generated in clap's format, respecting `NO_COLOR`, `TERM=dumb`, and terminal width:
@@ -632,6 +829,22 @@ Examples:
632
829
  my-tool -n World serve -p 8080
633
830
  ```
634
831
 
832
+ ### Styles
833
+
834
+ Override the default terminal colors for help and error output:
835
+
836
+ ```ts
837
+ import { runMain } from 'clap-ts';
838
+
839
+ runMain(rootCommand, {
840
+ styles: {
841
+ heading: (s) => `\x1b[35m${s}\x1b[0m`, // magenta headings
842
+ flag: (s) => `\x1b[36m${s}\x1b[0m`, // cyan flags
843
+ command: (s) => `\x1b[1m${s}\x1b[0m`, // bold commands
844
+ },
845
+ });
846
+ ```
847
+
635
848
  ### Help Template
636
849
 
637
850
  Use `helpTemplate` for full control over help layout:
@@ -655,32 +868,353 @@ const cmd = defineCommand({
655
868
 
656
869
  Placeholders: `{name}`, `{version}`, `{about}`, `{usage}`, `{all-args}`, `{arguments}`, `{options}`, `{commands}`, `{before-help}`, `{after-help}`
657
870
 
658
- ## Exit Codes
871
+ ## Optional Modules
872
+
873
+ Everything past the core parser lives behind its own entry point, so a running
874
+ CLI never loads a generator it does not call. `import 'clap-ts'` is 3.0ms on
875
+ Node; each module below is another 0.5ms to 1ms, and only when imported.
876
+
877
+ | Import | Provides |
878
+ |--------|----------|
879
+ | `clap-ts` | `defineCommand`, `runMain`, parsing, validation, help |
880
+ | `clap-ts/config` | Config file discovery, layered under argv and env |
881
+ | `clap-ts/prompt` | Ask for required arguments argv left out |
882
+ | `clap-ts/testing` | `runCli`, `captureArgs`, no spawning or global patching |
883
+ | `clap-ts/completions` | bash, zsh, fish, powershell, elvish, nushell |
884
+ | `clap-ts/man` | roff man pages |
885
+ | `clap-ts/markdown` | Markdown documentation |
886
+ | `clap-ts/install` | Write completions and man pages where the system finds them |
887
+ | `clap-ts/spec` | The command tree as plain JSON |
888
+ | `clap-ts/plugins` | Subcommands discovered from installed packages |
889
+ | `clap-ts/argfile` | `@file` response files and stdin |
890
+ | `clap-ts/output` | Tables, key-value blocks, trees |
891
+ | `clap-ts/log` | Levelled logger wired to `-v` and `--quiet` |
892
+ | `clap-ts/progress` | Spinners and progress bars |
893
+
894
+ ### Configuration Files
659
895
 
660
- | Code | Meaning |
661
- |------|---------|
662
- | 0 | Success |
663
- | 1 | Runtime error (unhandled exception in run/setup/cleanup) |
664
- | 2 | Usage error (parse failure, validation failure, unknown flags) |
896
+ ```ts
897
+ import { runMain } from 'clap-ts';
898
+ import { configOptions } from 'clap-ts/config';
665
899
 
666
- ## Performance
900
+ await runMain(main, { ...configOptions('mytool') });
901
+ ```
902
+
903
+ Precedence becomes command line, then environment, then config file, then the
904
+ argument's default, and `ctx.valueSources` reports which layer won. By default
905
+ the search looks for `.mytoolrc`, `.mytoolrc.json`, `mytool.config.json`,
906
+ `.config/mytool.json` and a `mytool` key in `package.json`, walking up from the
907
+ working directory.
908
+
909
+ A nested object named for a subcommand scopes its contents to that command,
910
+ while scalar keys stay in scope all the way down:
911
+
912
+ ```json
913
+ {
914
+ "verbose": true,
915
+ "serve": { "port": 8080 }
916
+ }
917
+ ```
918
+
919
+ Only JSON is read out of the box, which is what keeps the package
920
+ dependency-free. Point `parse` at a TOML or YAML reader for those:
921
+
922
+ ```ts
923
+ import { parse as parseToml } from 'smol-toml';
924
+
925
+ configOptions('mytool', {
926
+ files: ['.mytoolrc.toml', 'mytool.toml'],
927
+ parse: (text) => parseToml(text),
928
+ stopAtProjectRoot: true, // stop at the directory holding package.json or .git
929
+ });
930
+ ```
931
+
932
+ `configOptions` returns a thunk rather than the values, so the filesystem is
933
+ only searched when some argument is still on its default. A fully specified
934
+ command line costs nothing: 1.0us against 23us when the search actually runs.
935
+
936
+ ### Interactive Prompts
937
+
938
+ ```ts
939
+ import { promptMissing } from 'clap-ts/prompt';
940
+
941
+ await runMain(main, { fillMissing: promptMissing() });
942
+ ```
667
943
 
668
- clap-ts is built for performance. Core parsing delegates to the native `node:util parseArgs` and adds a thin layer for type coercion, env fallback, and validation. Flag lookup uses precomputed `Map`s for O(1) resolution. All new feature fields short-circuit on `undefined` -- zero overhead when unused.
944
+ A required argument nobody supplied is asked for rather than rejected, and the
945
+ prompt comes from the definition that already exists: a boolean becomes a
946
+ confirm, possible values become a numbered list, an argument marked `secret` is
947
+ read without echoing, and the answer goes through the argument's own
948
+ `valueParser` before being accepted. `ctx.valueSources` reports these as
949
+ `'prompt'`.
669
950
 
670
- Benchmarks on Apple M3 Pro (Bun 1.3):
951
+ Nothing is asked when stdin is not a terminal, so a script or a CI job still
952
+ fails fast with the usual error rather than waiting on input that never comes.
953
+ Pass `force: true` to ask anyway.
671
954
 
672
- | Scenario | Time |
673
- |----------|------|
674
- | Minimal (no args) | ~1.7us |
675
- | Simple (5 flags) | ~11us |
676
- | Complex (22 flags) | ~16us |
677
- | Subcommand detection | ~12us |
678
- | Full pipeline (parse + validate) | ~26us |
955
+ The individual prompts are available on their own, and each takes an `io` so a
956
+ test can script the answers without a terminal:
957
+
958
+ ```ts
959
+ import { input, confirm, select, password, scriptedIO } from 'clap-ts/prompt';
960
+
961
+ const name = await input('Release name');
962
+ const mode = await select('Mode', ['fast', 'safe']);
963
+ const sure = await confirm('Deploy to production', { defaultValue: false });
964
+ const token = await password('API token');
965
+
966
+ // In a test
967
+ await select('Mode', ['fast', 'safe'], { io: scriptedIO(['2']) }); // 'safe'
968
+ ```
969
+
970
+ ### Testing
971
+
972
+ ```ts
973
+ import { runCli, captureArgs } from 'clap-ts/testing';
974
+
975
+ const result = await runCli(main, ['serve', '--port', '8080']);
976
+ expect(result.exitCode).toBe(0);
977
+ expect(result.plainStdout).toContain('listening');
978
+ ```
979
+
980
+ `runCli` returns `stdout`, `stderr`, `plainStdout`, `plainStderr`, `exitCode` and
981
+ `error`. No process is spawned and no global is patched: output goes to
982
+ collectors through `RunOptions.stdout`/`stderr` and the exit code arrives via
983
+ `onExit`. An error thrown by a handler comes back on `error` rather than being
984
+ rethrown, so one assertion style covers success and failure.
985
+
986
+ `captureArgs` reports the parsed arguments without running the handler body,
987
+ which works for a subcommand as well as the root:
988
+
989
+ ```ts
990
+ const { args } = await captureArgs(main, ['serve', '--port', '9']);
991
+ expect(args.port).toBe(9);
992
+ ```
993
+
994
+ Handlers that write through `ctx.stdout` rather than `process.stdout` are
995
+ captured too.
996
+
997
+ ### Shell Completions
998
+
999
+ Add Tab completion for bash, zsh, fish, and powershell with one line:
1000
+
1001
+ ```ts
1002
+ import { defineCommand, runMain, withCompletions } from 'clap-ts';
1003
+
1004
+ const root = defineCommand({
1005
+ meta: { name: 'my-cli', version: '1.0.0' },
1006
+ args: { /* ... */ },
1007
+ subCommands: { /* ... */ },
1008
+ });
1009
+
1010
+ // Auto-adds a `completions` subcommand
1011
+ runMain(withCompletions(root));
1012
+ ```
1013
+
1014
+ Users then enable completions in their shell:
1015
+
1016
+ ```bash
1017
+ # bash - add to ~/.bashrc
1018
+ eval "$(my-cli completions bash)"
1019
+
1020
+ # zsh - add to ~/.zshrc
1021
+ eval "$(my-cli completions zsh)"
1022
+
1023
+ # fish - save to completions dir
1024
+ my-cli completions fish > ~/.config/fish/completions/my-cli.fish
1025
+
1026
+ # powershell - add to $PROFILE
1027
+ my-cli completions powershell >> $PROFILE
1028
+ ```
1029
+
1030
+ Generated scripts support flags, subcommands, aliases, enum values, and value hints (file/dir completion):
1031
+
1032
+ ```ts
1033
+ const cmd = defineCommand({
1034
+ meta: { name: 'tool' },
1035
+ args: {
1036
+ config: { type: 'string', valueHint: 'filePath' }, // Tab completes files
1037
+ outDir: { type: 'string', valueHint: 'dirPath' }, // Tab completes directories
1038
+ host: { type: 'string', valueHint: 'hostname' }, // Tab completes hostnames
1039
+ env: { type: 'string', valueParser: ['dev', 'prod'] }, // Tab shows dev, prod
1040
+ },
1041
+ });
1042
+ ```
1043
+
1044
+ You can also generate scripts manually without the subcommand:
1045
+
1046
+ ```ts
1047
+ import { generateCompletions } from 'clap-ts';
1048
+
1049
+ const bashScript = generateCompletions(root, 'bash');
1050
+ const zshScript = generateCompletions(root, 'zsh', 'custom-binary-name');
1051
+ ```
1052
+
1053
+ ### Man Pages
1054
+
1055
+ ```ts
1056
+ import { renderManPage, generateManPages } from 'clap-ts';
1057
+ import { writeFileSync } from 'node:fs';
1058
+
1059
+ // One page
1060
+ writeFileSync('my-tool.1', renderManPage(main));
1061
+
1062
+ // One page per command, named my-tool.1, my-tool-serve.1, and so on
1063
+ for (const [file, roff] of generateManPages(main, { section: '1' })) {
1064
+ writeFileSync(`man/${file}`, roff);
1065
+ }
1066
+ ```
1067
+
1068
+ The output carries NAME, SYNOPSIS, DESCRIPTION, OPTIONS, SUBCOMMANDS, EXTRA,
1069
+ VERSION and AUTHORS, with possible values as a bullet list and notes for
1070
+ defaults and environment variables. Check it with `man -l my-tool.1`.
1071
+
1072
+ ### Markdown Documentation
1073
+
1074
+ ```ts
1075
+ import { renderMarkdownHelp } from 'clap-ts';
1076
+
1077
+ writeFileSync('docs/cli.md', renderMarkdownHelp(main, {
1078
+ title: 'CLI Reference', // optional, demotes command headings by one level
1079
+ footer: 'Generated from the command definitions.',
1080
+ }));
1081
+ ```
1082
+
1083
+ Produces one heading per command with its usage line and Commands, Arguments and
1084
+ Options lists, nesting subcommands as deeper headings.
1085
+
1086
+ ### Installing Completions and Man Pages
1087
+
1088
+ ```ts
1089
+ import { withInstallers } from 'clap-ts/install';
1090
+
1091
+ runMain(withInstallers(main));
1092
+ // my-tool completions install zsh
1093
+ // my-tool man install --dryRun
1094
+ ```
1095
+
1096
+ Writes to the XDG per-user locations, names the file the way each shell expects,
1097
+ and reports the profile line to add where sourcing is not automatic.
1098
+
1099
+ ### Machine-Readable Spec
1100
+
1101
+ ```ts
1102
+ import { toSpecJson } from 'clap-ts/spec';
1103
+
1104
+ writeFileSync('cli.json', toSpecJson(main));
1105
+ ```
1106
+
1107
+ The whole tree as plain JSON, for a docs site, editor integration, or generating
1108
+ tool definitions from a CLI.
1109
+
1110
+ ### Plugins
1111
+
1112
+ ```ts
1113
+ import { pluginSubCommands } from 'clap-ts/plugins';
1114
+
1115
+ const main = defineCommand({
1116
+ meta: { name: 'my-tool' },
1117
+ lazySubCommands: pluginSubCommands('my-tool'),
1118
+ });
1119
+ ```
1120
+
1121
+ Installing `my-tool-plugin-deploy` makes `my-tool deploy` work. Because it is a
1122
+ `lazySubCommands` thunk, neither the directory scan nor the module import
1123
+ happens until a token could be a subcommand.
1124
+
1125
+ ### Response Files and stdin
1126
+
1127
+ ```ts
1128
+ import { expandArgFiles, readPathOrStdin } from 'clap-ts/argfile';
1129
+
1130
+ await runMain(main, { argv: expandArgFiles() });
1131
+ ```
1132
+
1133
+ `@args.txt` is replaced by that file's contents, following the convention git,
1134
+ gcc and java use: one argument per line, `#` comments and blank lines skipped,
1135
+ quoted runs kept whole, `@@` for a literal at-sign, and nothing after `--`
1136
+ touched. `readPathOrStdin` covers the other half, where `-` means stdin.
1137
+
1138
+ ### Terminal Output
1139
+
1140
+ ```ts
1141
+ import { table, keyValue, tree } from 'clap-ts/output';
1142
+
1143
+ ctx.stdout.write(table(rows, {
1144
+ columns: [{ key: 'name' }, { key: 'size', align: 'right' }],
1145
+ rule: true,
1146
+ }));
1147
+ ```
1148
+
1149
+ Columns size to their widest cell, then the widest shrinks until the table fits
1150
+ the terminal. Padding measures visible width, so styled cells line up with plain
1151
+ ones.
1152
+
1153
+ ### Logging and Progress
1154
+
1155
+ ```ts
1156
+ import { loggerFrom } from 'clap-ts/log';
1157
+ import { spinner } from 'clap-ts/progress';
1158
+
1159
+ const main = defineCommand({
1160
+ meta: { name: 'my-tool' },
1161
+ args: {
1162
+ verbose: { type: 'boolean', short: 'v', action: 'count', description: 'More output' },
1163
+ quiet: { type: 'boolean', short: 'q', description: 'Errors only' },
1164
+ },
1165
+ async run(ctx) {
1166
+ const log = loggerFrom(ctx); // -v climbs a level, --quiet drops to errors
1167
+ log.debug('shown with -v');
1168
+
1169
+ const spin = spinner('Fetching').start();
1170
+ await fetchThings();
1171
+ spin.succeed('Fetched 12 items');
1172
+ },
1173
+ });
1174
+ ```
1175
+
1176
+ Both write to stderr, leaving stdout for real output. Spinners and bars draw
1177
+ only when stderr is a terminal and `CI` is unset, so a piped run never fills a
1178
+ log with redraw escapes, and still reports its final message once.
1179
+
1180
+ ## Performance
1181
+
1182
+ Parsing is a single pass over argv against a spec compiled once per command and
1183
+ cached, so flag lookup, value counts and camelCase keys are all resolved ahead of
1184
+ time. Feature fields short-circuit on `undefined`, so an unused one costs nothing.
1185
+ Subcommand maps are built only when a token could actually be a subcommand, which
1186
+ is also what keeps `lazySubCommands` from running its thunk on a flags-only
1187
+ invocation.
1188
+
1189
+ Config discovery follows the same idea. The search costs one `existsSync` per
1190
+ candidate per directory, so it is O(directories x candidates) and independent of
1191
+ how large those directories are. Listing each directory once with `readdirSync`
1192
+ would be a single syscall per level, but it is O(entries): against a 2000-entry
1193
+ directory that took 117us where four `existsSync` calls took 2.4us, and walking
1194
+ up through a large directory is exactly the case that has to stay cheap.
1195
+
1196
+ Earlier versions delegated to `node:util parseArgs`, which re-validates its whole
1197
+ `options` object on every call, roughly 170ns per declared option regardless of how
1198
+ long argv is. A 33-option command spent 5.8us there before reading a single token.
1199
+
1200
+ Benchmarks on AMD Ryzen 9 9950X3D (Bun 1.4), against that `node:util` baseline:
1201
+
1202
+ | Scenario | Before | After | Change |
1203
+ |----------|--------|-------|--------|
1204
+ | Minimal (no args) | 1.47us | 40ns | 36x |
1205
+ | Simple (5 flags) | 9.01us | 494ns | 18x |
1206
+ | Complex (22 flags) | 14.15us | 1.80us | 8x |
1207
+ | Subcommand detection | 9.96us | 418ns | 24x |
1208
+ | Full pipeline (parse + validate) | 23.01us | 1.45us | 16x |
1209
+
1210
+ Figures are the minimum of nine pinned runs. Microbenchmarks on this machine are
1211
+ bimodal by roughly 1.5x depending on core placement, so a single run tells you
1212
+ very little.
679
1213
 
680
1214
  To run benchmarks yourself:
681
1215
 
682
1216
  ```bash
683
- bun run bench/parse.bench.ts
1217
+ bun run bench
684
1218
  ```
685
1219
 
686
1220
  ## Comparison with Rust clap
@@ -732,20 +1266,102 @@ bun run bench/parse.bench.ts
732
1266
  | hideShortHelp / hideLongHelp | Yes | Yes |
733
1267
  | hidePossibleValues | Yes | Yes |
734
1268
  | Hidden args/commands | Yes | Yes |
1269
+ | Multi-token numArgs (`--point 1 2 3`) | Yes | Yes |
1270
+ | requireEquals | Yes | Yes |
1271
+ | valueTerminator | Yes | Yes |
1272
+ | overridesWith | Yes | Yes |
1273
+ | ignoreCase | Yes | Yes |
1274
+ | Possible values with help/aliases | Yes | Yes |
1275
+ | valueNames, index, displayOrder, nextLineHelp | Yes | Yes |
1276
+ | hideDefaultValue / hideEnv / hideEnvValues | Yes | Yes |
1277
+ | defaultValueIfs, requiredIfEqAny/All | Yes | Yes |
1278
+ | Arg-level group membership | Yes | Yes |
1279
+ | Group conflictsWith / requires | Yes | Yes |
1280
+ | Built-in `help` subcommand | Yes | Yes |
1281
+ | disableHelpFlag / disableVersionFlag | Yes | Yes |
1282
+ | termWidth / maxTermWidth | Yes | Yes |
1283
+ | overrideUsage / overrideHelp | Yes | Yes |
1284
+ | propagateVersion / longVersion | Yes | Yes |
1285
+ | Flag subcommands (`pacman -S`) | Yes | Yes |
1286
+ | allowMissingPositional | Yes | Yes |
1287
+ | argsOverrideSelf | Yes | Yes |
1288
+ | subcommandPrecedenceOverArg | Yes | Yes |
1289
+ | multicall / noBinaryName | Yes | Yes |
735
1290
  | Type-safe parsed args | derive macro | generics |
736
- | Shell completions | Yes | Not yet |
737
- | Man page generation | Yes | Not yet |
1291
+ | Shell completions (bash/zsh/fish/powershell) | Yes | Yes |
1292
+ | Shell completions (elvish, nushell) | Yes | Yes |
1293
+ | Man page generation | Yes | Yes |
1294
+ | Value source (CLI vs env vs default) | Yes | Yes |
1295
+ | setTrue / setFalse / help / version actions | Yes | Yes |
1296
+ | defaultMissingValues, requiresIf, singular group | Yes | Yes |
1297
+ | binName, displayName, color, helpExpected | Yes | Yes |
1298
+ | flattenHelp, ignoreErrors, nextHelpHeading | Yes | Yes |
1299
+ | nextDisplayOrder, dontDelimitTrailingValues | Yes | Yes |
1300
+ | Subcommand flag aliases (short and long) | Yes | Yes |
1301
+ | Lazy subcommand building (`defer`) | Yes | Yes |
1302
+ | externalSubcommandValueParser | Yes | Yes |
1303
+ | Markdown documentation | clap-markdown | Yes |
1304
+ | Config file layering | no | Yes (`clap-ts/config`) |
1305
+ | Interactive prompts for missing args | no | Yes (`clap-ts/prompt`) |
1306
+ | Test harness with no spawning | no | Yes (`clap-ts/testing`) |
1307
+ | Response files (`@file`) | no | Yes (`clap-ts/argfile`) |
1308
+ | Plugin subcommands from packages | no | Yes (`clap-ts/plugins`) |
1309
+ | Tables, logging, progress | no | Yes (`clap-ts/output`, `/log`, `/progress`) |
1310
+ | Derive macro | Yes | n/a in TypeScript |
1311
+ | dontCollapseArgsInUsage | deprecated no-op | Not implemented |
1312
+
1313
+ Two clap settings are deliberately absent. `dont_collapse_args_in_usage` is a
1314
+ deprecated no-op upstream, and this usage line never collapsed positionals in the
1315
+ first place. The derive macro has no analogue: `defineCommand` already infers the
1316
+ parsed argument types from the definition object.
1317
+
1318
+ ## Upgrading from 0.2
1319
+
1320
+ The parser was rewritten to tokenize argv directly, which brought several defaults
1321
+ in line with clap. Each of these was previously wrong or silently permissive:
1322
+
1323
+ - `--version` and `-V` exist only where `meta.version` is set. A subcommand that
1324
+ needs the root's version should set `propagateVersion: true` on the root.
1325
+ - Repeating a single-value arg is an error. `argsOverrideSelf: true` restores the
1326
+ old behaviour of keeping the last one.
1327
+ - A flag missing its value is an error. `--name` with nothing after it used to
1328
+ yield the boolean `true` in a string-typed arg, and `--name --verbose` used to
1329
+ swallow the next flag as the value.
1330
+ - `--flag=true` is now `true`. Every `--flag=<value>` form used to yield `false`.
1331
+ - `-p=80` yields `80`. The leading `=` used to end up in the value.
1332
+ - Flags before a subcommand no longer break dispatch. `app --verbose serve` used
1333
+ to run nothing at all, and a subcommand's flags are now parsed against the
1334
+ subcommand rather than its parent.
1335
+ - `global: true` args declared on an intermediate command now reach its
1336
+ grandchildren, not just the root's.
1337
+ - Errors print the usage line of the command that failed rather than the root's.
1338
+ - Help shows `[env: VAR=value]`; see `hideEnvValues` and `hideEnv`.
1339
+ - The `completions` subcommand validates its shell through `valueParser`, so an
1340
+ unknown one now reports the accepted values and honours a caller's
1341
+ `exit: false` instead of calling `process.exit` from inside its handler.
1342
+ - `ParseResult` gained `valueSources`, `errors`, `subCommandArgs`,
1343
+ `subCommandIsExternal` and `versionIsShort`. Only the low-level API sees these;
1344
+ `defineCommand` and `runMain` are unaffected.
738
1345
 
739
1346
  ## Roadmap
740
1347
 
741
- - Shell completion generation (bash/zsh/fish)
742
- - Man page generation
743
- - Markdown help output
1348
+ Parity with clap's builder API is complete, and the optional modules cover the
1349
+ ergonomics around it. What is left is polish:
1350
+
1351
+ - A `docs` subcommand helper, the way `withInstallers` wraps the generators
1352
+ - Arrow-key selection in `clap-ts/prompt` where the terminal supports it, keeping
1353
+ the numbered list as the fallback
1354
+ - Shell-side dynamic completion, so a value list can come from the running CLI
1355
+ rather than only from the definition
744
1356
 
745
1357
  ## Requirements
746
1358
 
747
1359
  - Node.js >= 20.0.0 or Bun >= 1.0
748
- - TypeScript >= 5.0 (for `const` type parameter inference)
1360
+ - TypeScript >= 5.0, for `const` type parameter inference
1361
+ - ESM only. A CommonJS file has to reach it through a dynamic `import()`; the
1362
+ package sets `"type": "module"` and ships no CJS build.
1363
+
1364
+ Type resolution is verified against `nodenext`, `node16` and `bundler`.
749
1365
 
750
1366
  ## License
751
1367