@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
package/package.json
CHANGED
|
@@ -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?` | `
|
|
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.
|