@gunshi/docs 0.28.0 → 0.29.1

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
@@ -38,6 +38,7 @@ Robust, modular, flexible, and localizable CLI library
38
38
 
39
39
  ### API References
40
40
 
41
+ - [combinators](src/api/combinators/index)
41
42
  - [context](src/api/context/index)
42
43
  - [default](src/api/default/index)
43
44
  - [definition](src/api/definition/index)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gunshi/docs",
3
3
  "description": "Documentation for gunshi",
4
- "version": "0.28.0",
4
+ "version": "0.29.1",
5
5
  "author": {
6
6
  "name": "kazuya kawaguchi",
7
7
  "email": "kawakazu80@gmail.com"
@@ -48,10 +48,11 @@ const schema: ArgSchema = {
48
48
  | <a id="conflicts"></a> `conflicts?` | `string` \| `string`[] | Names of other options that conflict with this option. When this option is used together with any of the conflicting options, an `ArgResolveError` with type 'conflict' will be thrown. Conflicts only need to be defined on one side - if option A defines a conflict with option B, the conflict is automatically detected when both are used, regardless of whether B also defines a conflict with A. Supports both single option name or array of option names. Option names must match the property keys in the schema object exactly (no automatic conversion between camelCase and kebab-case). **Examples** Single conflict (bidirectional definition): `{ summer: { type: 'boolean', conflicts: 'autumn' // Cannot use --summer with --autumn }, autumn: { type: 'boolean', conflicts: 'summer' // Can define on both sides for clarity } }` Single conflict (one-way definition): `{ summer: { type: 'boolean', conflicts: 'autumn' // Only defined on summer side }, autumn: { type: 'boolean' // No conflicts defined, but still cannot use with --summer } } // Usage: --summer --autumn will throw error // Error: "Optional argument '--summer' conflicts with '--autumn'"` Multiple conflicts: `{ port: { type: 'number', conflicts: ['socket', 'pipe'], // Cannot use with --socket or --pipe description: 'TCP port number' }, socket: { type: 'string', conflicts: ['port', 'pipe'], // Cannot use with --port or --pipe description: 'Unix socket path' }, pipe: { type: 'string', conflicts: ['port', 'socket'], // Cannot use with --port or --socket description: 'Named pipe path' } } // These three options are mutually exclusive` With kebab-case conversion: `{ summerSeason: { type: 'boolean', toKebab: true, // Accessible as --summer-season conflicts: 'autumnSeason' // Must use property key, not CLI name }, autumnSeason: { type: 'boolean', toKebab: true // Accessible as --autumn-season } } // Error: "Optional argument '--summer-season' conflicts with '--autumn-season'"` |
49
49
  | <a id="default"></a> `default?` | `string` \| `number` \| `boolean` | Default value used when the argument is not provided. The type must match the argument's `type` property: - `string` type: string default - `boolean` type: boolean default - `number` type: number default - `enum` type: must be one of the `choices` values - `positional`/`custom` type: any appropriate default **Example** Default values by type: `{ host: { type: 'string', default: 'localhost' // string default }, verbose: { type: 'boolean', default: false // boolean default }, port: { type: 'number', default: 8080 // number default }, level: { type: 'enum', choices: ['low', 'high'], default: 'low' // must be in choices } }` |
50
50
  | <a id="description"></a> `description?` | `string` | Human-readable description of the argument's purpose. Used for help text generation and documentation. Should be concise but descriptive enough to understand the argument's role. **Example** Descriptive help text: `{ config: { type: 'string', description: 'Path to configuration file' }, timeout: { type: 'number', description: 'Request timeout in milliseconds' } }` |
51
+ | <a id="metavar"></a> `metavar?` | `string` | Display name hint for help text generation. Provides a meaningful type hint for the argument value in help output. Particularly useful for `type: 'custom'` arguments where the type name would otherwise be unhelpful. **Example** Metavar usage: `{ port: { type: 'custom', parse: (v: string) => parseInt(v, 10), metavar: 'integer', description: 'Port number (1-65535)' } } // Help output: --port <integer> Port number (1-65535)` |
51
52
  | <a id="multiple"></a> `multiple?` | `true` | Allows the argument to accept multiple values. When `true`, the resolved value becomes an array. For options: can be specified multiple times (--tag foo --tag bar) For positional: collects remaining positional arguments Note: Only `true` is allowed (not `false`) to make intent explicit. **Example** Multiple values: `{ tags: { type: 'string', multiple: true, // --tags foo --tags bar → ['foo', 'bar'] description: 'Tags to apply' }, files: { type: 'positional', multiple: true // Collects all remaining positional args } }` |
52
53
  | <a id="negatable"></a> `negatable?` | `boolean` | Enables negation for boolean arguments using `--no-` prefix. When `true`, allows users to explicitly set the boolean to `false` using `--no-option-name`. When `false` or omitted, only positive form is available. Only applicable to `type: 'boolean'` arguments. **Example** Negatable boolean: `{ color: { type: 'boolean', negatable: true, default: true, description: 'Enable colorized output' } // Usage: --color (true), --no-color (false) }` |
53
54
  | <a id="parse"></a> `parse?` | (`value`) => `any` | Custom parsing function for `type: 'custom'` arguments. Required when `type: 'custom'`. Receives the raw string value and must return the parsed result. Should throw an Error (or subclass) if parsing fails. The function's return type becomes the resolved argument type. **Throws** Error or subclass when value is invalid **Example** Custom parsing functions: `{ config: { type: 'custom', parse: (value: string) => { try { return JSON.parse(value) // Parse JSON config } catch { throw new Error('Invalid JSON configuration') } }, description: 'JSON configuration object' }, date: { type: 'custom', parse: (value: string) => { const date = new Date(value) if (isNaN(date.getTime())) { throw new Error('Invalid date format') } return date } } }` |
54
- | <a id="required"></a> `required?` | `true` | Marks the argument as required. When `true`, the argument must be provided by the user. If missing, an `ArgResolveError` with type 'required' will be thrown. Note: Only `true` is allowed (not `false`) to make intent explicit. **Example** Required arguments: `{ input: { type: 'string', required: true, // Must be provided: --input file.txt description: 'Input file path' }, source: { type: 'positional', required: true // First positional argument must exist } }` |
55
+ | <a id="required"></a> `required?` | `boolean` | Marks the argument as required. When `true`, the argument must be provided by the user. If missing, an `ArgResolveError` with type 'required' will be thrown. Note: Only `true` is allowed (not `false`) to make intent explicit. **Example** Required arguments: `{ input: { type: 'string', required: true, // Must be provided: --input file.txt description: 'Input file path' }, source: { type: 'positional', required: true // First positional argument must exist } }` |
55
56
  | <a id="short"></a> `short?` | `string` | Single character alias for the long option name. As example, allows users to use `-x` instead of `--extended-option`. Only valid for non-positional argument types. **Example** Short alias usage: `{ verbose: { type: 'boolean', short: 'v' // Enables both --verbose and -v }, port: { type: 'number', short: 'p' // Enables both --port 3000 and -p 3000 } }` |
56
57
  | <a id="tokebab"></a> `toKebab?` | `true` | Converts the argument name from camelCase to kebab-case for CLI usage. When `true`, a property like `maxCount` becomes available as `--max-count`. This allows [CAC](https://github.com/cacjs/cac) user-friendly property names while maintaining CLI conventions. Can be overridden globally with `resolveArgs({ toKebab: true })`. Note: Only `true` is allowed (not `false`) to make intent explicit. **Example** Kebab-case conversion: `{ maxRetries: { type: 'number', toKebab: true, // Accessible as --max-retries description: 'Maximum retry attempts' }, enableLogging: { type: 'boolean', toKebab: true // Accessible as --enable-logging } }` |
57
58
  | <a id="type"></a> `type` | `"string"` \| `"number"` \| `"boolean"` \| `"positional"` \| `"enum"` \| `"custom"` | Type of the argument value. - `'string'`: Text value (default if not specified) - `'boolean'`: `true`/`false` flag (can be negatable with `--no-` prefix) - `'number'`: Numeric value (parsed as integer or float) - `'enum'`: One of predefined string values (requires `choices` property) - `'positional'`: Non-option argument by position - `'custom'`: Custom parsing with user-defined `parse` function **Example** Different argument types: `{ name: { type: 'string' }, // --name value verbose: { type: 'boolean' }, // --verbose or --no-verbose port: { type: 'number' }, // --port 3000 level: { type: 'enum', choices: ['debug', 'info'] }, file: { type: 'positional' }, // first positional arg config: { type: 'custom', parse: JSON.parse } }` |
@@ -171,6 +171,14 @@ Each option can have the following properties:
171
171
  - `parse`: A function to parse and validate the argument value. Required when `type` is 'custom'
172
172
  - `conflicts`: Specify mutually exclusive options that cannot be used together
173
173
 
174
+ <!-- eslint-disable markdown/no-missing-label-refs -->
175
+
176
+ > [!NOTE]
177
+ > For a more type-safe and composable way to define arguments, see [Parser Combinators](../experimentals/parser-combinators.md).
178
+ > Note that this feature is currently **experimental**.
179
+
180
+ <!-- eslint-enable markdown/no-missing-label-refs -->
181
+
174
182
  #### Positional Arguments
175
183
 
176
184
  To define arguments that are identified by their position rather than a name/flag (like `--name`), set their `type` to `'positional'`.
@@ -117,6 +117,14 @@ With `define`:
117
117
 
118
118
  This approach significantly simplifies creating type-safe CLIs with Gunshi.
119
119
 
120
+ <!-- eslint-disable markdown/no-missing-label-refs -->
121
+
122
+ > [!TIP]
123
+ > For even stronger type inference with composable argument schemas, check out [Parser Combinators](../experimentals/parser-combinators.md).
124
+ > Parser combinators provide functional factory functions like `string()`, `integer()`, `withDefault()`, and `required()` that automatically infer precise types without manual annotations. Note that this feature is currently **experimental**.
125
+
126
+ <!-- eslint-enable markdown/no-missing-label-refs -->
127
+
120
128
  ## When to Use `define`
121
129
 
122
130
  Use the `define` function when:
@@ -0,0 +1,1020 @@
1
+ # Parser Combinators
2
+
3
+ > [!WARNING]
4
+ > Parser Combinators are currently **experimental**. The API may change in future versions.
5
+
6
+ Parser combinators provide a functional, composable approach to defining type-safe argument schemas in Gunshi. Instead of writing plain object configurations, you can use factory functions that generate schemas with full type inference.
7
+
8
+ This approach brings several advantages:
9
+
10
+ - **Composable building blocks**: Combine simple parsers to create complex schemas
11
+ - **Type-safe by design**: Full TypeScript type inference without manual annotations
12
+ - **Reusable schema groups**: Define common configurations once, use them everywhere
13
+ - **Functional programming style**: Chain and compose operations naturally
14
+
15
+ ## Import Paths
16
+
17
+ Parser combinators are available through two import paths:
18
+
19
+ ```ts
20
+ // From the main gunshi package
21
+ import { string, integer, boolean, ... } from 'gunshi/combinators'
22
+
23
+ // Or as a standalone package for minimal bundle size
24
+ import { string, integer, boolean, ... } from '@gunshi/combinators'
25
+ ```
26
+
27
+ The standalone `@gunshi/combinators` package is ideal when you only need combinator functionality without the full gunshi CLI framework.
28
+
29
+ > [!NOTE]
30
+ > The `@gunshi/combinators` package needs to be installed separately: `npm install @gunshi/combinators`. The `gunshi/combinators` import path is available as part of the main `gunshi` package.
31
+
32
+ ## Traditional vs Combinator Approach
33
+
34
+ Let's compare the traditional object-based approach with parser combinators to understand the difference:
35
+
36
+ ### Traditional Approach
37
+
38
+ ```ts [traditional.ts]
39
+ import { define } from 'gunshi'
40
+
41
+ const command = define({
42
+ name: 'serve',
43
+ args: {
44
+ host: {
45
+ type: 'string',
46
+ short: 'h',
47
+ description: 'Host to bind',
48
+ default: 'localhost'
49
+ },
50
+ port: {
51
+ type: 'number',
52
+ short: 'p',
53
+ description: 'Port number',
54
+ default: 8080,
55
+ min: 1,
56
+ max: 65535
57
+ },
58
+ verbose: {
59
+ type: 'boolean',
60
+ short: 'v',
61
+ description: 'Enable verbose output'
62
+ }
63
+ },
64
+ run: ctx => {
65
+ // Implementation
66
+ }
67
+ })
68
+ ```
69
+
70
+ ### Combinator Approach
71
+
72
+ ```ts [combinators.ts]
73
+ import { define } from 'gunshi'
74
+ import { string, integer, boolean, withDefault, short } from 'gunshi/combinators'
75
+
76
+ const command = define({
77
+ name: 'serve',
78
+ args: {
79
+ host: withDefault(short(string({ description: 'Host to bind' }), 'h'), 'localhost'),
80
+ port: withDefault(
81
+ short(
82
+ integer({
83
+ min: 1,
84
+ max: 65535,
85
+ description: 'Port number'
86
+ }),
87
+ 'p'
88
+ ),
89
+ 8080
90
+ ),
91
+ verbose: short(boolean({ description: 'Enable verbose output' }), 'v')
92
+ },
93
+ run: ctx => {
94
+ // ctx.values.host is typed as string (non-optional due to withDefault)
95
+ // ctx.values.port is typed as number (non-optional due to withDefault)
96
+ // ctx.values.verbose is typed as boolean | undefined
97
+ }
98
+ })
99
+ ```
100
+
101
+ The combinator approach offers better composability and type inference, while maintaining the same runtime behavior.
102
+
103
+ ## Base Combinators
104
+
105
+ Base combinators create the fundamental argument types. Each returns a `CombinatorSchema` that can be used directly or modified with other combinators.
106
+
107
+ ### string()
108
+
109
+ Creates a string argument parser with optional validation:
110
+
111
+ ```ts [string-example.ts]
112
+ import { define } from 'gunshi'
113
+ import { string } from 'gunshi/combinators'
114
+
115
+ const command = define({
116
+ name: 'example',
117
+ args: {
118
+ // Basic string
119
+ name: string(),
120
+
121
+ // String with constraints
122
+ username: string({
123
+ minLength: 3,
124
+ maxLength: 20,
125
+ pattern: /^[a-zA-Z0-9_]+$/,
126
+ description: 'Username (alphanumeric and underscore only)'
127
+ }),
128
+
129
+ // String with description
130
+ email: string({
131
+ pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
132
+ description: 'Email address'
133
+ })
134
+ },
135
+ run: ctx => {
136
+ console.log(`Name: ${ctx.values.name}`)
137
+ console.log(`Username: ${ctx.values.username}`)
138
+ console.log(`Email: ${ctx.values.email}`)
139
+ }
140
+ })
141
+ ```
142
+
143
+ ### number()
144
+
145
+ Creates a numeric argument parser with optional range validation:
146
+
147
+ ```ts [number-example.ts]
148
+ import { define } from 'gunshi'
149
+ import { number } from 'gunshi/combinators'
150
+
151
+ const command = define({
152
+ name: 'example',
153
+ args: {
154
+ // Any number
155
+ value: number(),
156
+
157
+ // Number with range
158
+ percentage: number({
159
+ min: 0,
160
+ max: 100,
161
+ description: 'Percentage value (0-100)'
162
+ })
163
+ },
164
+ run: ctx => {
165
+ console.log(`Value: ${ctx.values.value}`)
166
+ console.log(`Percentage: ${ctx.values.percentage}%`)
167
+ }
168
+ })
169
+ ```
170
+
171
+ ### integer()
172
+
173
+ Creates an integer-only parser, rejecting decimal values:
174
+
175
+ ```ts [integer-example.ts]
176
+ import { define } from 'gunshi'
177
+ import { integer } from 'gunshi/combinators'
178
+
179
+ const command = define({
180
+ name: 'server',
181
+ args: {
182
+ // Port number must be an integer
183
+ port: integer({
184
+ min: 1,
185
+ max: 65535,
186
+ description: 'Server port'
187
+ }),
188
+
189
+ // Worker count
190
+ workers: integer({
191
+ min: 1,
192
+ description: 'Number of worker processes'
193
+ })
194
+ },
195
+ run: ctx => {
196
+ console.log(`Starting server on port ${ctx.values.port}`)
197
+ console.log(`Using ${ctx.values.workers} workers`)
198
+ }
199
+ })
200
+ ```
201
+
202
+ ### float()
203
+
204
+ Creates a floating-point number parser:
205
+
206
+ ```ts [float-example.ts]
207
+ import { define } from 'gunshi'
208
+ import { float } from 'gunshi/combinators'
209
+
210
+ const command = define({
211
+ name: 'calculate',
212
+ args: {
213
+ // Accepts decimal values
214
+ rate: float({
215
+ min: 0.0,
216
+ max: 1.0,
217
+ description: 'Interest rate (0.0 to 1.0)'
218
+ }),
219
+
220
+ // Scientific notation supported
221
+ threshold: float({
222
+ description: 'Threshold value'
223
+ })
224
+ },
225
+ run: ctx => {
226
+ console.log(`Rate: ${ctx.values.rate}`)
227
+ console.log(`Threshold: ${ctx.values.threshold}`)
228
+ }
229
+ })
230
+ ```
231
+
232
+ ### boolean()
233
+
234
+ Creates a boolean flag parser:
235
+
236
+ ```ts [boolean-example.ts]
237
+ import { define } from 'gunshi'
238
+ import { boolean } from 'gunshi/combinators'
239
+
240
+ const command = define({
241
+ name: 'build',
242
+ args: {
243
+ // Simple boolean flag
244
+ minify: boolean(),
245
+
246
+ // Boolean with negatable option
247
+ cache: boolean({
248
+ negatable: true,
249
+ description: 'Enable caching (use --no-cache to disable)'
250
+ })
251
+ },
252
+ run: ctx => {
253
+ if (ctx.values.minify) {
254
+ console.log('Minification enabled')
255
+ }
256
+
257
+ // With negatable, can explicitly disable
258
+ if (ctx.values.cache === false) {
259
+ console.log('Cache explicitly disabled')
260
+ }
261
+ }
262
+ })
263
+ ```
264
+
265
+ ### choice()
266
+
267
+ Creates an enum-like parser with literal type inference. The `choice()` combinator only accepts string values. For numeric or other types, combine with `map()` to transform the parsed string value:
268
+
269
+ ```ts [choice-example.ts]
270
+ import { define } from 'gunshi'
271
+ import { choice, map } from 'gunshi/combinators'
272
+
273
+ const command = define({
274
+ name: 'deploy',
275
+ args: {
276
+ // Use 'as const' for literal type inference
277
+ environment: choice(['development', 'staging', 'production'] as const, {
278
+ description: 'Deployment environment'
279
+ }),
280
+
281
+ // Map numeric values from string choices
282
+ logLevel: map(
283
+ choice(['0', '1', '2', '3'] as const, {
284
+ description: 'Log level (0=error, 1=warn, 2=info, 3=debug)'
285
+ }),
286
+ v => parseInt(v, 10)
287
+ )
288
+ },
289
+ run: ctx => {
290
+ // ctx.values.environment is typed as 'development' | 'staging' | 'production' | undefined
291
+ console.log(`Deploying to ${ctx.values.environment}`)
292
+
293
+ // ctx.values.logLevel is typed as number | undefined
294
+ if (ctx.values.logLevel !== undefined) {
295
+ console.log(`Log level: ${ctx.values.logLevel}`)
296
+ }
297
+
298
+ // Type-safe switch statements
299
+ switch (ctx.values.environment) {
300
+ case 'production':
301
+ console.log('Running production checks...')
302
+ break
303
+ case 'staging':
304
+ console.log('Deploying to staging environment')
305
+ break
306
+ case 'development':
307
+ console.log('Deploying to dev environment')
308
+ break
309
+ }
310
+ }
311
+ })
312
+ ```
313
+
314
+ > [!TIP]
315
+ > Always use `as const` with choice arrays to get literal type inference. Without it, TypeScript will infer a general string or number type instead of the specific literal values.
316
+
317
+ ### positional()
318
+
319
+ Creates a positional argument that can optionally use a parser:
320
+
321
+ ```ts [positional-example.ts]
322
+ import { define } from 'gunshi'
323
+ import { positional, integer } from 'gunshi/combinators'
324
+
325
+ const command = define({
326
+ name: 'copy',
327
+ args: {
328
+ // Simple positional argument (string by default)
329
+ source: positional({ description: 'Source file' }),
330
+
331
+ // Positional with type parser
332
+ count: positional(integer({ min: 1 }), {
333
+ description: 'Number of copies'
334
+ })
335
+ },
336
+ run: ctx => {
337
+ console.log(`Copying ${ctx.values.source} ${ctx.values.count} times`)
338
+ }
339
+ })
340
+
341
+ // Usage: copy file.txt 3
342
+ ```
343
+
344
+ ### combinator()
345
+
346
+ Creates custom parsers for specialized types:
347
+
348
+ ```ts [custom-combinator.ts]
349
+ import { define } from 'gunshi'
350
+ import { combinator } from 'gunshi/combinators'
351
+
352
+ const command = define({
353
+ name: 'schedule',
354
+ args: {
355
+ // Parse dates
356
+ date: combinator({
357
+ parse: (value: string) => {
358
+ const date = new Date(value)
359
+ if (isNaN(date.getTime())) {
360
+ throw new Error(`Invalid date: ${value}`)
361
+ }
362
+ return date
363
+ },
364
+ metavar: 'date',
365
+ description: 'Schedule date (YYYY-MM-DD or ISO 8601)'
366
+ }),
367
+
368
+ // Parse comma-separated values
369
+ tags: combinator({
370
+ parse: (value: string) => value.split(',').map(s => s.trim()),
371
+ metavar: 'tag1,tag2,...',
372
+ description: 'Comma-separated tags'
373
+ }),
374
+
375
+ // Parse JSON
376
+ config: combinator({
377
+ parse: (value: string) => {
378
+ try {
379
+ return JSON.parse(value)
380
+ } catch (e) {
381
+ throw new Error(`Invalid JSON: ${e.message}`)
382
+ }
383
+ },
384
+ metavar: 'json',
385
+ description: 'JSON configuration'
386
+ })
387
+ },
388
+ run: ctx => {
389
+ // ctx.values.date is typed as Date
390
+ // ctx.values.tags is typed as string[]
391
+ // ctx.values.config is typed as any (or use generics for specific types)
392
+ console.log(`Scheduled for ${ctx.values.date?.toISOString()}`)
393
+ console.log(`Tags: ${ctx.values.tags?.join(', ')}`)
394
+ }
395
+ })
396
+ ```
397
+
398
+ ## Modifier Combinators
399
+
400
+ Modifier combinators wrap base combinators to change their behavior or add constraints.
401
+
402
+ ### required()
403
+
404
+ Makes an argument mandatory:
405
+
406
+ ```ts [required-example.ts]
407
+ import { define } from 'gunshi'
408
+ import { string, required } from 'gunshi/combinators'
409
+
410
+ const command = define({
411
+ name: 'login',
412
+ args: {
413
+ // Username is required
414
+ username: required(string({ description: 'Username' })),
415
+
416
+ // Password is optional
417
+ password: string({ description: 'Password (will prompt if not provided)' })
418
+ },
419
+ run: ctx => {
420
+ // ctx.values.username is typed as string (not optional)
421
+ // ctx.values.password is typed as string | undefined
422
+ console.log(`Logging in as ${ctx.values.username}`)
423
+ }
424
+ })
425
+ ```
426
+
427
+ ### unrequired()
428
+
429
+ Explicitly marks an argument as optional. This is useful when you want to override a `required()` field from a base schema using `extend()`:
430
+
431
+ ```ts [unrequired-example.ts]
432
+ import { define } from 'gunshi'
433
+ import { string, required, unrequired, extend, args } from 'gunshi/combinators'
434
+
435
+ // Base schema with required field
436
+ const base = args({
437
+ name: required(string())
438
+ })
439
+
440
+ // Override to make it optional in specific context
441
+ const relaxed = extend(base, {
442
+ name: unrequired(string())
443
+ })
444
+
445
+ const command = define({
446
+ name: 'example',
447
+ args: relaxed,
448
+ run: ctx => {
449
+ // ctx.values.name is now string | undefined
450
+ console.log(`Name: ${ctx.values.name || 'Anonymous'}`)
451
+ }
452
+ })
453
+ ```
454
+
455
+ ### withDefault()
456
+
457
+ Provides a default value, making the argument always defined:
458
+
459
+ ```ts [default-example.ts]
460
+ import { define } from 'gunshi'
461
+ import { string, integer, boolean, withDefault } from 'gunshi/combinators'
462
+
463
+ const command = define({
464
+ name: 'server',
465
+ args: {
466
+ // Always has a value
467
+ host: withDefault(string(), 'localhost'),
468
+ port: withDefault(integer({ min: 1, max: 65535 }), 3000),
469
+ debug: withDefault(boolean(), false)
470
+ },
471
+ run: ctx => {
472
+ // All values are non-optional due to defaults
473
+ // ctx.values.host is typed as string
474
+ // ctx.values.port is typed as number
475
+ // ctx.values.debug is typed as boolean
476
+ console.log(`Server: ${ctx.values.host}:${ctx.values.port}`)
477
+ if (ctx.values.debug) {
478
+ console.log('Debug mode enabled')
479
+ }
480
+ }
481
+ })
482
+ ```
483
+
484
+ ### short()
485
+
486
+ Adds a single-character alias:
487
+
488
+ ```ts [short-example.ts]
489
+ import { define } from 'gunshi'
490
+ import { string, boolean, short } from 'gunshi/combinators'
491
+
492
+ const command = define({
493
+ name: 'example',
494
+ args: {
495
+ // -v for --verbose
496
+ verbose: short(boolean(), 'v'),
497
+
498
+ // -o for --output
499
+ output: short(string({ description: 'Output file' }), 'o')
500
+ },
501
+ run: ctx => {
502
+ if (ctx.values.verbose) {
503
+ console.log('Verbose mode enabled')
504
+ }
505
+ }
506
+ })
507
+
508
+ // Usage: example -v -o result.txt
509
+ // Or: example --verbose --output result.txt
510
+ ```
511
+
512
+ ### describe()
513
+
514
+ Adds or updates the description of an argument:
515
+
516
+ ```ts [describe-example.ts]
517
+ import { define } from 'gunshi'
518
+ import { string, required, describe } from 'gunshi/combinators'
519
+
520
+ const command = define({
521
+ name: 'example',
522
+ args: {
523
+ // Add description to an existing combinator
524
+ file: describe(required(string()), 'Path to the input file'),
525
+
526
+ // Override existing description
527
+ output: describe(string({ description: 'Old description' }), 'New description for output file')
528
+ },
529
+ run: ctx => {
530
+ console.log(`Processing ${ctx.values.file}`)
531
+ }
532
+ })
533
+ ```
534
+
535
+ ### multiple()
536
+
537
+ Allows multiple values for an argument:
538
+
539
+ ```ts [multiple-example.ts]
540
+ import { define } from 'gunshi'
541
+ import { string, integer, multiple } from 'gunshi/combinators'
542
+
543
+ const command = define({
544
+ name: 'process',
545
+ args: {
546
+ // Accept multiple files
547
+ files: multiple(string({ description: 'Input files' })),
548
+
549
+ // Multiple ports
550
+ ports: multiple(integer({ min: 1, max: 65535 }))
551
+ },
552
+ run: ctx => {
553
+ // ctx.values.files is typed as string[] | undefined
554
+ // ctx.values.ports is typed as number[] | undefined
555
+
556
+ if (ctx.values.files) {
557
+ console.log(`Processing ${ctx.values.files.length} files`)
558
+ ctx.values.files.forEach(file => console.log(` - ${file}`))
559
+ }
560
+ }
561
+ })
562
+
563
+ // Usage: process --files a.txt --files b.txt --ports 8080 --ports 8081
564
+ ```
565
+
566
+ ### map()
567
+
568
+ Transforms the parsed value:
569
+
570
+ ```ts [map-example.ts]
571
+ import { define } from 'gunshi'
572
+ import { string, integer, map } from 'gunshi/combinators'
573
+
574
+ const command = define({
575
+ name: 'convert',
576
+ args: {
577
+ // Convert to uppercase
578
+ name: map(string(), s => s.toUpperCase()),
579
+
580
+ // Transform number to percentage
581
+ fraction: map(integer({ min: 0, max: 100 }), n => n / 100),
582
+
583
+ // Parse and transform in one step
584
+ size: map(string(), s => {
585
+ const match = s.match(/^(\d+)(K|M|G)?$/i)
586
+ if (!match) throw new Error('Invalid size format')
587
+
588
+ const num = parseInt(match[1])
589
+ const unit = match[2]?.toUpperCase()
590
+
591
+ switch (unit) {
592
+ case 'K':
593
+ return num * 1024
594
+ case 'M':
595
+ return num * 1024 * 1024
596
+ case 'G':
597
+ return num * 1024 * 1024 * 1024
598
+ default:
599
+ return num
600
+ }
601
+ })
602
+ },
603
+ run: ctx => {
604
+ // ctx.values.name is uppercase
605
+ // ctx.values.fraction is between 0 and 1
606
+ // ctx.values.size is in bytes
607
+ console.log(`Name: ${ctx.values.name}`)
608
+ console.log(`Fraction: ${ctx.values.fraction}`)
609
+ console.log(`Size: ${ctx.values.size} bytes`)
610
+ }
611
+ })
612
+
613
+ // Usage: convert --name hello --fraction 50 --size 10M
614
+ ```
615
+
616
+ ## Schema Composition
617
+
618
+ Schema composition combinators help you build reusable and maintainable argument configurations.
619
+
620
+ ### args()
621
+
622
+ Creates a type-safe schema object (identity function at runtime, but preserves types):
623
+
624
+ ```ts [args-example.ts]
625
+ import { define } from 'gunshi'
626
+ import { args, string, boolean, short } from 'gunshi/combinators'
627
+
628
+ // Define reusable schema groups
629
+ const debugOptions = args({
630
+ verbose: short(boolean(), 'v'),
631
+ debug: boolean(),
632
+ logLevel: string()
633
+ })
634
+
635
+ const networkOptions = args({
636
+ host: string(),
637
+ port: integer(),
638
+ timeout: integer()
639
+ })
640
+
641
+ // Use directly in command definition
642
+ const command = define({
643
+ name: 'example',
644
+ args: debugOptions, // Can use args() result directly
645
+ run: ctx => {
646
+ if (ctx.values.verbose) {
647
+ console.log('Verbose output enabled')
648
+ }
649
+ }
650
+ })
651
+ ```
652
+
653
+ ### merge()
654
+
655
+ Combines multiple schemas with last-write-wins for conflicts:
656
+
657
+ ```ts [merge-example.ts]
658
+ import { define } from 'gunshi'
659
+ import { args, merge, string, boolean, integer, withDefault, short } from 'gunshi/combinators'
660
+
661
+ // Common options used across commands
662
+ const common = args({
663
+ verbose: short(boolean(), 'v'),
664
+ quiet: short(boolean(), 'q'),
665
+ config: string({ description: 'Config file path' })
666
+ })
667
+
668
+ // Network-related options
669
+ const network = args({
670
+ host: withDefault(string(), 'localhost'),
671
+ port: withDefault(integer({ min: 1, max: 65535 }), 8080),
672
+ secure: boolean()
673
+ })
674
+
675
+ // Database options
676
+ const database = args({
677
+ dbHost: withDefault(string(), 'localhost'),
678
+ dbPort: withDefault(integer(), 5432),
679
+ dbName: required(string())
680
+ })
681
+
682
+ // Compose different combinations for different commands
683
+ const serverCommand = define({
684
+ name: 'server',
685
+ args: merge(common, network),
686
+ run: ctx => {
687
+ console.log(`Server at ${ctx.values.host}:${ctx.values.port}`)
688
+ }
689
+ })
690
+
691
+ const migrateCommand = define({
692
+ name: 'migrate',
693
+ args: merge(common, database),
694
+ run: ctx => {
695
+ console.log(`Migrating database ${ctx.values.dbName}`)
696
+ }
697
+ })
698
+
699
+ // Merge all for complex commands
700
+ const fullCommand = define({
701
+ name: 'full',
702
+ args: merge(
703
+ common,
704
+ network,
705
+ database,
706
+ args({
707
+ // Add command-specific options
708
+ workers: integer({ description: 'Number of workers' })
709
+ })
710
+ ),
711
+ run: ctx => {
712
+ // Has access to all merged options
713
+ console.log('Running with full configuration')
714
+ }
715
+ })
716
+ ```
717
+
718
+ ### extend()
719
+
720
+ Overrides specific fields in a base schema:
721
+
722
+ ```ts [extend-example.ts]
723
+ import { define } from 'gunshi'
724
+ import { args, extend, string, integer, boolean, required, withDefault } from 'gunshi/combinators'
725
+
726
+ // Base configuration
727
+ const baseConfig = args({
728
+ name: string(),
729
+ port: withDefault(integer(), 8080),
730
+ debug: boolean()
731
+ })
732
+
733
+ // Production config: make name required, restrict port range
734
+ const productionConfig = extend(baseConfig, {
735
+ name: required(string({ description: 'Service name (required)' })),
736
+ port: required(
737
+ integer({
738
+ min: 443,
739
+ max: 443,
740
+ description: 'HTTPS port only'
741
+ })
742
+ ),
743
+ debug: withDefault(boolean(), false) // Debug off by default in production
744
+ })
745
+
746
+ // Development config: relaxed settings
747
+ const developmentConfig = extend(baseConfig, {
748
+ port: withDefault(integer({ min: 3000, max: 9999 }), 3000),
749
+ debug: withDefault(boolean(), true) // Debug on by default in development
750
+ })
751
+
752
+ const prodCommand = define({
753
+ name: 'start-prod',
754
+ args: productionConfig,
755
+ run: ctx => {
756
+ // ctx.values.name is required (string)
757
+ // ctx.values.port is required and must be 443
758
+ console.log(`Production service ${ctx.values.name} on port ${ctx.values.port}`)
759
+ }
760
+ })
761
+
762
+ const devCommand = define({
763
+ name: 'start-dev',
764
+ args: developmentConfig,
765
+ run: ctx => {
766
+ // More relaxed requirements for development
767
+ console.log(`Development server on port ${ctx.values.port}`)
768
+ }
769
+ })
770
+ ```
771
+
772
+ ## Complete Example
773
+
774
+ Here's a comprehensive example showing various combinator features working together:
775
+
776
+ ```ts [cli.ts]
777
+ import { cli, define } from 'gunshi'
778
+ import {
779
+ args,
780
+ boolean,
781
+ choice,
782
+ combinator,
783
+ extend,
784
+ integer,
785
+ map,
786
+ merge,
787
+ multiple,
788
+ positional,
789
+ required,
790
+ short,
791
+ string,
792
+ withDefault
793
+ } from 'gunshi/combinators'
794
+
795
+ // Define reusable schema groups
796
+ const commonOptions = args({
797
+ verbose: short(boolean({ description: 'Verbose output' }), 'v'),
798
+ quiet: short(boolean({ description: 'Suppress output' }), 'q'),
799
+ color: withDefault(choice(['auto', 'always', 'never'] as const), 'auto')
800
+ })
801
+
802
+ const networkOptions = args({
803
+ host: withDefault(string({ description: 'Host to bind' }), '0.0.0.0'),
804
+ port: withDefault(
805
+ integer({
806
+ min: 1,
807
+ max: 65535,
808
+ description: 'Port number'
809
+ }),
810
+ 3000
811
+ ),
812
+ secure: boolean({ description: 'Use HTTPS' })
813
+ })
814
+
815
+ // Custom combinator for parsing duration
816
+ const duration = combinator({
817
+ parse: (value: string) => {
818
+ const match = value.match(/^(\d+)(ms|s|m|h)$/)
819
+ if (!match) {
820
+ throw new Error('Invalid duration format (use: 100ms, 5s, 2m, 1h)')
821
+ }
822
+
823
+ const num = parseInt(match[1])
824
+ const unit = match[2]
825
+
826
+ switch (unit) {
827
+ case 'ms':
828
+ return num
829
+ case 's':
830
+ return num * 1000
831
+ case 'm':
832
+ return num * 60 * 1000
833
+ case 'h':
834
+ return num * 60 * 60 * 1000
835
+ default:
836
+ return num
837
+ }
838
+ },
839
+ metavar: '<duration>',
840
+ description: 'Duration (e.g., 100ms, 5s, 2m, 1h)'
841
+ })
842
+
843
+ // Main command definition
844
+ const command = define({
845
+ name: 'serve',
846
+ description: 'Start a development server',
847
+
848
+ args: merge(
849
+ commonOptions,
850
+ networkOptions,
851
+ args({
852
+ // Additional command-specific options
853
+ entry: required(positional({ description: 'Entry file to serve' })),
854
+
855
+ watch: multiple(string({ description: 'Directories to watch' })),
856
+
857
+ timeout: withDefault(duration, 30000), // 30 seconds default
858
+
859
+ mode: choice(['development', 'production', 'test'] as const, { description: 'Server mode' }),
860
+
861
+ headers: map(multiple(string()), headers => {
862
+ // Transform array of "key:value" strings to object
863
+ const result: Record<string, string> = {}
864
+ headers?.forEach(header => {
865
+ const [key, value] = header.split(':')
866
+ if (key && value) {
867
+ result[key.trim()] = value.trim()
868
+ }
869
+ })
870
+ return result
871
+ })
872
+ })
873
+ ),
874
+
875
+ run: ctx => {
876
+ const { entry, host, port, secure, verbose, quiet, mode, timeout, watch, headers } = ctx.values
877
+
878
+ if (!quiet) {
879
+ console.log(`Starting ${mode || 'development'} server`)
880
+ console.log(`Entry: ${entry}`)
881
+ console.log(`Address: ${secure ? 'https' : 'http'}://${host}:${port}`)
882
+ console.log(`Timeout: ${timeout}ms`)
883
+
884
+ if (watch && watch.length > 0) {
885
+ console.log(`Watching: ${watch.join(', ')}`)
886
+ }
887
+
888
+ if (headers && Object.keys(headers).length > 0) {
889
+ console.log('Custom headers:')
890
+ Object.entries(headers).forEach(([key, value]) => {
891
+ console.log(` ${key}: ${value}`)
892
+ })
893
+ }
894
+ }
895
+
896
+ if (verbose) {
897
+ console.log('\nFull configuration:')
898
+ console.log(JSON.stringify(ctx.values, null, 2))
899
+ }
900
+ }
901
+ })
902
+
903
+ // Execute the CLI
904
+ await cli(process.argv.slice(2), command, {
905
+ name: 'serve-cli',
906
+ version: '1.0.0'
907
+ })
908
+ ```
909
+
910
+ Usage examples:
911
+
912
+ ```sh
913
+ # Basic usage with required entry file
914
+ $ serve index.html
915
+
916
+ # Specify host and port
917
+ $ serve index.html --host localhost --port 8080
918
+
919
+ # Production mode with HTTPS
920
+ $ serve dist/index.html --mode production --secure
921
+
922
+ # Watch multiple directories with custom timeout
923
+ $ serve src/index.js --watch src --watch public --timeout 1m
924
+
925
+ # Add custom headers
926
+ $ serve index.html --headers "Cache-Control: no-cache" --headers "X-Custom: value"
927
+
928
+ # Verbose output
929
+ $ serve index.html -v
930
+ ```
931
+
932
+ > [!TIP]
933
+ > The complete example code is available [here](https://github.com/kazupon/gunshi/tree/main/playground/experimentals/combinators).
934
+
935
+ ## Type Inference
936
+
937
+ Parser combinators provide excellent TypeScript type inference:
938
+
939
+ ```ts
940
+ import { define } from 'gunshi'
941
+ import { string, integer, boolean, withDefault, required, multiple } from 'gunshi/combinators'
942
+
943
+ const command = define({
944
+ name: 'example',
945
+ args: {
946
+ // Type: string | undefined
947
+ optional: string(),
948
+
949
+ // Type: string (non-optional due to required)
950
+ mandatory: required(string()),
951
+
952
+ // Type: string (non-optional due to withDefault)
953
+ withDef: withDefault(string(), 'default'),
954
+
955
+ // Type: string[] | undefined
956
+ multi: multiple(string()),
957
+
958
+ // Type: number (non-optional, with constraints)
959
+ port: withDefault(integer({ min: 1, max: 65535 }), 8080)
960
+ },
961
+ run: ctx => {
962
+ // TypeScript knows all the types automatically
963
+ // No manual type annotations needed!
964
+ const { optional, mandatory, withDef, multi, port } = ctx.values
965
+ }
966
+ })
967
+ ```
968
+
969
+ ## Guidelines
970
+
971
+ When using parser combinators, consider these guidelines:
972
+
973
+ 1. **Start simple**: Begin with base combinators and add modifiers as needed
974
+ 2. **Compose for reusability**: Create schema groups with `args()` for common option sets
975
+ 3. **Use `withDefault` for better UX**: Provide sensible defaults to reduce required user input
976
+ 4. **Leverage type safety**: Let TypeScript infer types rather than adding manual annotations
977
+ 5. **Custom combinators for domain logic**: Use `combinator()` for specialized parsing needs
978
+ 6. **Consistent naming**: Use descriptive names for your schema groups
979
+ 7. **Test compositions**: Verify that merged and extended schemas behave as expected
980
+
981
+ ## Migration from Traditional Approach
982
+
983
+ If you have existing commands using the traditional object approach, you can gradually migrate to combinators:
984
+
985
+ ```ts
986
+ // Before: Traditional approach
987
+ const oldCommand = define({
988
+ args: {
989
+ port: {
990
+ type: 'number',
991
+ short: 'p',
992
+ default: 8080,
993
+ min: 1,
994
+ max: 65535,
995
+ description: 'Port number'
996
+ }
997
+ }
998
+ })
999
+
1000
+ // After: Combinator approach
1001
+ import { integer, withDefault, short } from 'gunshi/combinators'
1002
+
1003
+ const newCommand = define({
1004
+ args: {
1005
+ port: withDefault(
1006
+ short(
1007
+ integer({
1008
+ min: 1,
1009
+ max: 65535,
1010
+ description: 'Port number'
1011
+ }),
1012
+ 'p'
1013
+ ),
1014
+ 8080
1015
+ )
1016
+ }
1017
+ })
1018
+ ```
1019
+
1020
+ Both approaches are fully compatible and can be mixed within the same application. Choose the approach that best fits your team's preferences and coding style.