clap-ts 0.2.1 → 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,6 +617,63 @@ const cmd = defineCommand({
474
617
  });
475
618
  ```
476
619
 
620
+ ### Deprecating Arguments and Commands
621
+
622
+ ```ts
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' },
628
+ },
629
+ run({ args }) {
630
+ console.log(args.output);
631
+ },
632
+ });
633
+ ```
634
+
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.
638
+
639
+ ### Lazy Subcommands
640
+
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.
644
+
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
+ }),
652
+ });
653
+ ```
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
+
660
+ ### runMain
661
+
662
+ Entry point for CLI applications. Handles argv parsing, subcommand resolution, validation, help/version, and error display.
663
+
664
+ ```ts
665
+ import { runMain } from 'clap-ts';
666
+
667
+ runMain(rootCommand);
668
+
669
+ runMain(rootCommand, {
670
+ argv: ['serve', '--port', '8080'], // override argv (for testing)
671
+ exit: false, // don't call process.exit (for testing)
672
+ showHelpOnEmpty: true, // show help when no args (default: true)
673
+ styles: { /* custom styles */ }, // override terminal colors
674
+ });
675
+ ```
676
+
477
677
  ### Lifecycle Hooks
478
678
 
479
679
  Commands support setup/run/cleanup lifecycle hooks. `cleanup` always runs, even if `run` throws.
@@ -499,7 +699,137 @@ const cmd = defineCommand({
499
699
  });
500
700
  ```
501
701
 
502
- ### Custom Styles
702
+ ### Value Precedence and Sources
703
+
704
+ Arguments are resolved in this order (highest wins):
705
+
706
+ 1. **CLI flags** -- `--port 8080`
707
+ 2. **Environment variables** -- `PORT=8080` (when `env: 'PORT'` is set)
708
+ 3. **Conditional defaults** -- `defaultValueIf: ['env', 'prod', 443]`
709
+ 4. **Static defaults** -- `default: 3000`
710
+
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
739
+
740
+ Errors match clap's format with typo suggestions:
741
+
742
+ ```
743
+ error: unexpected argument '--verbos' found
744
+
745
+ tip: a similar argument exists: '--verbose'
746
+
747
+ Usage: my-tool [OPTIONS]
748
+
749
+ For more information, try '--help'.
750
+ ```
751
+
752
+ ```
753
+ error: the argument '--json' cannot be used with '--yaml'
754
+
755
+ Usage: my-tool [OPTIONS]
756
+
757
+ For more information, try '--help'.
758
+ ```
759
+
760
+ ```
761
+ error: the following required arguments were not provided:
762
+ --name
763
+ --port
764
+
765
+ Usage: my-tool [OPTIONS]
766
+
767
+ For more information, try '--help'.
768
+ ```
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
+
800
+ ## Help Output
801
+
802
+ Help is automatically generated in clap's format, respecting `NO_COLOR`, `TERM=dumb`, and terminal width:
803
+
804
+ ```
805
+ A great CLI tool (my-tool v1.0.0)
806
+
807
+ Usage: my-tool [OPTIONS] <FILE> [COMMAND]
808
+
809
+ Arguments:
810
+ <FILE> Input file path [required]
811
+
812
+ Commands:
813
+ serve (s) Start the development server
814
+ build Build the project
815
+
816
+ Options:
817
+ -n, --name <NAME> User name [required]
818
+ -e, --env <ENV> Environment [possible values: dev, staging, prod]
819
+ -p, --port <PORT> Port number [default: 3000] [env: PORT]
820
+ --verbose Enable verbose output
821
+ -h, --help Print help
822
+ -V, --version Print version
823
+
824
+ Network:
825
+ --host <STRING> Server host
826
+ --proxy <STRING> Proxy URL
827
+
828
+ Examples:
829
+ my-tool -n World serve -p 8080
830
+ ```
831
+
832
+ ### Styles
503
833
 
504
834
  Override the default terminal colors for help and error output:
505
835
 
@@ -515,6 +845,155 @@ runMain(rootCommand, {
515
845
  });
516
846
  ```
517
847
 
848
+ ### Help Template
849
+
850
+ Use `helpTemplate` for full control over help layout:
851
+
852
+ ```ts
853
+ const cmd = defineCommand({
854
+ meta: {
855
+ name: 'tool',
856
+ version: '1.0.0',
857
+ helpTemplate: `{before-help}{name} v{version}
858
+
859
+ {usage}
860
+
861
+ {all-args}
862
+ {commands}
863
+ {after-help}`,
864
+ },
865
+ // ...
866
+ });
867
+ ```
868
+
869
+ Placeholders: `{name}`, `{version}`, `{about}`, `{usage}`, `{all-args}`, `{arguments}`, `{options}`, `{commands}`, `{before-help}`, `{after-help}`
870
+
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
895
+
896
+ ```ts
897
+ import { runMain } from 'clap-ts';
898
+ import { configOptions } from 'clap-ts/config';
899
+
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
+ ```
943
+
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'`.
950
+
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.
954
+
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
+
518
997
  ### Shell Completions
519
998
 
520
999
  Add Tab completion for bash, zsh, fish, and powershell with one line:
@@ -571,172 +1050,171 @@ const bashScript = generateCompletions(root, 'bash');
571
1050
  const zshScript = generateCompletions(root, 'zsh', 'custom-binary-name');
572
1051
  ```
573
1052
 
574
- ### runMain
575
-
576
- Entry point for CLI applications. Handles argv parsing, subcommand resolution, validation, help/version, and error display.
1053
+ ### Man Pages
577
1054
 
578
1055
  ```ts
579
- import { runMain } from 'clap-ts';
1056
+ import { renderManPage, generateManPages } from 'clap-ts';
1057
+ import { writeFileSync } from 'node:fs';
580
1058
 
581
- runMain(rootCommand);
1059
+ // One page
1060
+ writeFileSync('my-tool.1', renderManPage(main));
582
1061
 
583
- runMain(rootCommand, {
584
- argv: ['serve', '--port', '8080'], // override argv (for testing)
585
- exit: false, // don't call process.exit (for testing)
586
- showHelpOnEmpty: true, // show help when no args (default: true)
587
- styles: { /* custom styles */ }, // override terminal colors
588
- });
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
+ }
589
1066
  ```
590
1067
 
591
- ### Low-Level API
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`.
592
1071
 
593
- For advanced use cases, you can use the parser and validator directly:
1072
+ ### Markdown Documentation
594
1073
 
595
1074
  ```ts
596
- import { parseArgs, validate, renderHelp, CliParseError } from 'clap-ts';
597
-
598
- const command = defineCommand({
599
- meta: { name: 'tool' },
600
- args: { port: { type: 'number', default: 3000 } },
601
- });
602
-
603
- // Parse without validation
604
- const result = parseArgs(['--port', '8080'], command);
605
- // result.args, result.positionals, result.rest, result.unknown
606
- // result.explicitlySet -- Set of arg keys that were explicitly provided
1075
+ import { renderMarkdownHelp } from 'clap-ts';
607
1076
 
608
- // Validate separately
609
- validate(result, command); // throws CliParseError on failure
610
-
611
- // Render help text
612
- const helpText = renderHelp(command);
613
-
614
- // Render short help (-h style, hides hideShortHelp args)
615
- const shortHelp = renderHelp(command, undefined, true);
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
+ }));
616
1081
  ```
617
1082
 
618
- ## Value Precedence
1083
+ Produces one heading per command with its usage line and Commands, Arguments and
1084
+ Options lists, nesting subcommands as deeper headings.
619
1085
 
620
- Arguments are resolved in this order (highest wins):
1086
+ ### Installing Completions and Man Pages
621
1087
 
622
- 1. **CLI flags** -- `--port 8080`
623
- 2. **Environment variables** -- `PORT=8080` (when `env: 'PORT'` is set)
624
- 3. **Conditional defaults** -- `defaultValueIf: ['env', 'prod', 443]`
625
- 4. **Static defaults** -- `default: 3000`
626
-
627
- ## Error Messages
628
-
629
- Errors match clap's format with typo suggestions:
1088
+ ```ts
1089
+ import { withInstallers } from 'clap-ts/install';
630
1090
 
1091
+ runMain(withInstallers(main));
1092
+ // my-tool completions install zsh
1093
+ // my-tool man install --dryRun
631
1094
  ```
632
- error: unexpected argument '--verbos' found
633
1095
 
634
- tip: a similar argument exists: '--verbose'
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.
635
1098
 
636
- Usage: my-tool [OPTIONS]
1099
+ ### Machine-Readable Spec
637
1100
 
638
- For more information, try '--help'.
639
- ```
1101
+ ```ts
1102
+ import { toSpecJson } from 'clap-ts/spec';
640
1103
 
1104
+ writeFileSync('cli.json', toSpecJson(main));
641
1105
  ```
642
- error: the argument '--json' cannot be used with '--yaml'
643
-
644
- Usage: my-tool [OPTIONS]
645
1106
 
646
- For more information, try '--help'.
647
- ```
1107
+ The whole tree as plain JSON, for a docs site, editor integration, or generating
1108
+ tool definitions from a CLI.
648
1109
 
649
- ```
650
- error: the following required arguments were not provided:
651
- --name
652
- --port
1110
+ ### Plugins
653
1111
 
654
- Usage: my-tool [OPTIONS]
1112
+ ```ts
1113
+ import { pluginSubCommands } from 'clap-ts/plugins';
655
1114
 
656
- For more information, try '--help'.
1115
+ const main = defineCommand({
1116
+ meta: { name: 'my-tool' },
1117
+ lazySubCommands: pluginSubCommands('my-tool'),
1118
+ });
657
1119
  ```
658
1120
 
659
- ## Help Output
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.
660
1124
 
661
- Help is automatically generated in clap's format, respecting `NO_COLOR`, `TERM=dumb`, and terminal width:
1125
+ ### Response Files and stdin
662
1126
 
663
- ```
664
- A great CLI tool (my-tool v1.0.0)
665
-
666
- Usage: my-tool [OPTIONS] <FILE> [COMMAND]
1127
+ ```ts
1128
+ import { expandArgFiles, readPathOrStdin } from 'clap-ts/argfile';
667
1129
 
668
- Arguments:
669
- <FILE> Input file path [required]
1130
+ await runMain(main, { argv: expandArgFiles() });
1131
+ ```
670
1132
 
671
- Commands:
672
- serve (s) Start the development server
673
- build Build the project
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.
674
1137
 
675
- Options:
676
- -n, --name <NAME> User name [required]
677
- -e, --env <ENV> Environment [possible values: dev, staging, prod]
678
- -p, --port <PORT> Port number [default: 3000] [env: PORT]
679
- --verbose Enable verbose output
680
- -h, --help Print help
681
- -V, --version Print version
1138
+ ### Terminal Output
682
1139
 
683
- Network:
684
- --host <STRING> Server host
685
- --proxy <STRING> Proxy URL
1140
+ ```ts
1141
+ import { table, keyValue, tree } from 'clap-ts/output';
686
1142
 
687
- Examples:
688
- my-tool -n World serve -p 8080
1143
+ ctx.stdout.write(table(rows, {
1144
+ columns: [{ key: 'name' }, { key: 'size', align: 'right' }],
1145
+ rule: true,
1146
+ }));
689
1147
  ```
690
1148
 
691
- ### Help Template
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.
692
1152
 
693
- Use `helpTemplate` for full control over help layout:
1153
+ ### Logging and Progress
694
1154
 
695
1155
  ```ts
696
- const cmd = defineCommand({
697
- meta: {
698
- name: 'tool',
699
- version: '1.0.0',
700
- helpTemplate: `{before-help}{name} v{version}
1156
+ import { loggerFrom } from 'clap-ts/log';
1157
+ import { spinner } from 'clap-ts/progress';
701
1158
 
702
- {usage}
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');
703
1168
 
704
- {all-args}
705
- {commands}
706
- {after-help}`,
1169
+ const spin = spinner('Fetching').start();
1170
+ await fetchThings();
1171
+ spin.succeed('Fetched 12 items');
707
1172
  },
708
- // ...
709
1173
  });
710
1174
  ```
711
1175
 
712
- Placeholders: `{name}`, `{version}`, `{about}`, `{usage}`, `{all-args}`, `{arguments}`, `{options}`, `{commands}`, `{before-help}`, `{after-help}`
713
-
714
- ## Exit Codes
715
-
716
- | Code | Meaning |
717
- |------|---------|
718
- | 0 | Success |
719
- | 1 | Runtime error (unhandled exception in run/setup/cleanup) |
720
- | 2 | Usage error (parse failure, validation failure, unknown flags) |
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.
721
1179
 
722
1180
  ## Performance
723
1181
 
724
- 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.
725
-
726
- Benchmarks on Apple M3 Pro (Bun 1.3):
727
-
728
- | Scenario | Time |
729
- |----------|------|
730
- | Minimal (no args) | ~1.7us |
731
- | Simple (5 flags) | ~11us |
732
- | Complex (22 flags) | ~16us |
733
- | Subcommand detection | ~12us |
734
- | Full pipeline (parse + validate) | ~26us |
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.
735
1213
 
736
1214
  To run benchmarks yourself:
737
1215
 
738
1216
  ```bash
739
- bun run bench/parse.bench.ts
1217
+ bun run bench
740
1218
  ```
741
1219
 
742
1220
  ## Comparison with Rust clap
@@ -788,19 +1266,102 @@ bun run bench/parse.bench.ts
788
1266
  | hideShortHelp / hideLongHelp | Yes | Yes |
789
1267
  | hidePossibleValues | Yes | Yes |
790
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 |
791
1290
  | Type-safe parsed args | derive macro | generics |
792
1291
  | Shell completions (bash/zsh/fish/powershell) | Yes | Yes |
793
- | Man page generation | Yes | Not yet |
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.
794
1345
 
795
1346
  ## Roadmap
796
1347
 
797
- - Man page generation
798
- - 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
799
1356
 
800
1357
  ## Requirements
801
1358
 
802
1359
  - Node.js >= 20.0.0 or Bun >= 1.0
803
- - 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`.
804
1365
 
805
1366
  ## License
806
1367