cli-nano 1.2.1 → 1.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
@@ -4,7 +4,7 @@
4
4
  [![codecov](https://codecov.io/gh/ghiscoding/cli-nano/branch/main/graph/badge.svg)](https://codecov.io/gh/ghiscoding/cli-nano)
5
5
  [![npm](https://img.shields.io/npm/v/cli-nano.svg)](https://www.npmjs.com/package/cli-nano)
6
6
  [![npm](https://img.shields.io/npm/dy/cli-nano)](https://www.npmjs.com/package/cli-nano)
7
- [![npm bundle size](https://img.shields.io/bundlephobia/minzip/cli-nano?color=success&label=gzip)](https://bundlephobia.com/result?p=cli-nano)
7
+ [![npm bundle size](https://img.shields.io/badge/gzip-2.08kB-1183c4)](https://bundlejs.com/?q=cli-nano)
8
8
  <a href="https://nodejs.org/en/about/previous-releases"><img src="https://img.shields.io/node/v/cli-nano.svg" alt="Node" /></a>
9
9
 
10
10
  ## cli-nano
@@ -13,8 +13,8 @@ Small library to create command-line tool (aka CLI) which is quite similar to [`
13
13
 
14
14
  ### Features
15
15
  - Parses arguments
16
- - Supports defining Positional (input) arguments
17
- - Supports Variadic args (1 or more positional args)
16
+ - Supports defining Positional arguments (input args)
17
+ - Supports Variadic arguments (1 or more positional args)
18
18
  - Automatically converts flags to camelCase to match config options
19
19
  - accepts both `--camelCase` and `--kebab-case`
20
20
  - Negates flags when using the `--no-` prefix
@@ -23,7 +23,24 @@ Small library to create command-line tool (aka CLI) which is quite similar to [`
23
23
  - Supports defining `required` options
24
24
  - Supports `default` values
25
25
  - Supports `group` for grouping command options in help
26
- - No dependencies!
26
+ - No dependencies and very lightweight!
27
+
28
+ ### Recent additions
29
+
30
+ - Grouped short flags: support clustered short aliases (e.g. `-ad`) where the last short may consume a value when appropriate.
31
+ - Kebab/camel duplication: parsed options are available under both `camelCase` and `kebab-case` keys (e.g. both `dryRun` and `dry-run`).
32
+ - `--` passthrough: tokens after `--` are exposed on `result['--']` and are not parsed as options.
33
+ - Raw argv exposure: the original argv slice is available on `result.__rawArgs` for diagnostics or passthrough adapters.
34
+ - Bare long-form behavior: a bare long option is treated as an empty value (e.g. `--opt` → `''`) and a bare long array option becomes `['']`.
35
+ - Boolean negation parity: negated booleans expose both `noFoo` and `no-foo` keys (e.g. `--no-dry-run` sets `dryRun=false` and also exposes `noDryRun=true` and `no-dry-run=true`).
36
+ - Negation guard: single-dash forms like `-no-foo` / `-noFoo` are treated as negated long options rather than short clusters.
37
+ - Error message consistency: option/argument errors use the `Unknown argument: X` wording to match existing positional error messages.
38
+
39
+ Short examples:
40
+
41
+ - Clustered short flags: `-ad` behaves like `-a -d` and `-ab value` lets `b` consume `value` when `b` expects a value.
42
+ - Bare long option: `--bar` (with no following token) results in `bar: ''` for string options.
43
+
27
44
 
28
45
  ### Install
29
46
  ```sh
@@ -69,7 +86,7 @@ const config: Config = {
69
86
  dryRun: {
70
87
  alias: 'd',
71
88
  type: 'boolean',
72
- describe: 'Show what would be done, but do not actually start the server',
89
+ describe: 'Show what would be executed but without starting the server',
73
90
  default: false, // optional default value
74
91
  },
75
92
  display: {
@@ -117,14 +134,15 @@ const config: Config = {
117
134
  helpFlagCasing: 'camel', // show help flag option in which casing (camel/kebab) (defaults to 'kebab')
118
135
  helpDescMinLength: 40, // min description length shown in help (defaults to 50)
119
136
  helpDescMaxLength: 120, // max description length shown in help (defaults to 100), will show ellipsis (...) when greater
137
+ helpUsageSeparator: ':', // defaults to "→"
120
138
  };
121
139
 
122
140
  const args = parseArgs(config);
123
141
  console.log(args);
124
142
 
125
143
  // do something with parsed arguments, for example
126
- // const { input, port, open } = args;
127
- // startServer({ input, port, open });
144
+ const { input, port, open } = args;
145
+ startServer({ input, port, open });
128
146
  ```
129
147
 
130
148
  ### Usage with Type Inference
@@ -140,8 +158,8 @@ const args = parseArgs<typeof config>(config);
140
158
 
141
159
  // TypeScript will infer the correct types:
142
160
  args.input; // [string, ...string[]] (required, variadic)
143
- args.port; // number (optional, has default)
144
- args.verbose; // boolean (optional)
161
+ args.port; // number (optional, has default)
162
+ args.verbose; // boolean (optional)
145
163
  args.display; // boolean (required)
146
164
  ```
147
165
 
@@ -150,12 +168,12 @@ args.display; // boolean (required)
150
168
  > If you use `const config: Config = { ... }`, you get type checking but not full intelliSense for parsed arguments.
151
169
 
152
170
  > [!NOTE]
153
- > For required+variadic positionals, the type is `[string, ...string[]]` (at least one value required). For optional variadic, it's `string[]`. For non-variadic, it's `string`.
171
+ > For required+variadic positionals, the type is `[string, ...string[]]` (at least one value required). For optional variadic, it's `string[]`. And Finally for non-variadic, it's `string`.
154
172
 
155
173
  #### Example CLI Calls
156
174
 
157
175
  ```sh
158
- # Show help guide (created by reading CLI config)
176
+ # Show help guide (creates it by reading CLI config)
159
177
  serve --help
160
178
 
161
179
  # Show version (when defined)
@@ -164,7 +182,7 @@ serve --version
164
182
  # Uses default port 5000
165
183
  serve dist/index.html
166
184
 
167
- # With required and optional positionals
185
+ # With required and optional positional args
168
186
  serve index1.html index2.html 8080 -D value
169
187
 
170
188
  # With boolean and array options entered as camelCase (kebab-case works too)
@@ -178,6 +196,19 @@ serve index.html 7000 -d -e pattern1 -e pattern2 -D value
178
196
 
179
197
  # With number option
180
198
  serve index.html 7000 --up 2 -D value
199
+
200
+ # Clustered short flags / last-short consumption
201
+ serve index.html 7000 -ad
202
+ serve index.html 7000 -ab value
203
+
204
+ # Equals form and bare-long
205
+ serve index.html 7000 --opt=value
206
+ serve index.html 7000 --opt=
207
+ serve index.html 7000 --bar
208
+
209
+ # Negation parity and passthrough
210
+ serve index.html 7000 --no-dry-run
211
+ serve index.html 7000 -- file.txt --not-a-flag
181
212
  ```
182
213
 
183
214
  #### Notes
@@ -192,20 +223,19 @@ serve index.html 7000 --up 2 -D value
192
223
  - **Aliases**: Use `alias` for short flags (e.g., `-d` for `--dryRun`).
193
224
  - **Groups**: Use `group` for grouping some commands in help (e.g., `{ group: 'Extra Commands' }`).
194
225
 
195
- See [examples/](examples/) for more usage patterns.
196
-
197
- ## Used by
226
+ - **Equals form**: `--opt=value` and `--opt=` are supported; an empty value after `=` yields an empty string.
227
+ - **Passthrough**: Tokens after `--` are not parsed and are available on `result['--']`.
228
+ - **Raw argv**: The original argv slice is available on `result.__rawArgs` for diagnostics or passthrough adapters.
229
+ - **Negation duplicates**: Using `--no-foo` sets `foo=false` and also exposes `noFoo` and `no-foo` keys.
230
+ - **Alias**: `alias` must be a single string (multi-char aliases are supported). Array-style aliases are not supported and will be rejected.
198
231
 
199
- `cli-nano` is currently used in these other projects of mine (feel free to edit this list):
200
-
201
- - [native-copyfiles](https://github.com/ghiscoding/native-copyfiles)
202
- - [remove-glob](https://github.com/ghiscoding/remove-glob)
232
+ See [examples/](examples/) for more usage patterns.
203
233
 
204
234
  ## Help Example
205
235
 
206
236
  You can see below an example of a CLI help (which is the result of calling `--help` with the [config](#usage) shown above).
207
237
 
208
- Please note:
238
+ Please note the following expectations:
209
239
 
210
240
  - `<option>` → required
211
241
  - `[option]` → optional
@@ -223,7 +253,7 @@ Arguments:
223
253
  port port to bind on [number]
224
254
 
225
255
  Options:
226
- -d, --dry-run Show what would be done, but do not actually start the server [boolean]
256
+ -d, --dry-run Show what would be executed but without starting the server [boolean]
227
257
  -e, --exclude pattern or glob to exclude (may be passed multiple times) [array]
228
258
  -V, --verbose print more information to console [boolean]
229
259
  -o, --open open browser when starting server [boolean]
@@ -236,3 +266,10 @@ Advanced Options:
236
266
  -D, --display a required display option <boolean>
237
267
  -r, --rainbow Enable rainbow mode [boolean]
238
268
  ```
269
+
270
+ ## Used by
271
+
272
+ `cli-nano` is currently being used by the following projects, which is actually why I created this CLI tool, that I currently maintain as well (feel free to edit this list):
273
+
274
+ - [native-copyfiles](https://github.com/ghiscoding/native-copyfiles)
275
+ - [remove-glob](https://github.com/ghiscoding/remove-glob)
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,EAAc,MAAM,iBAAiB,CAAC;AAEtE,mBAAmB,iBAAiB,CAAC;AAOrC,wBAAgB,SAAS,CAAC,CAAC,SAAS,MAAM,EAAE,MAAM,EAAE,CAAC,GAAG,UAAU,CAAC,CAAC,CAAC,CAgMpE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,EAAc,MAAM,iBAAiB,CAAC;AAEtE,mBAAmB,iBAAiB,CAAC;AAOrC,wBAAgB,SAAS,CAAC,CAAC,SAAS,MAAM,EAAE,MAAM,EAAE,CAAC,GAAG,UAAU,CAAC,CAAC,CAAC,CA6UpE"}
package/dist/index.js CHANGED
@@ -4,14 +4,22 @@ const defaultOptions = {
4
4
  };
5
5
  export function parseArgs(config) {
6
6
  const { command, options, version } = config;
7
+ // Capture raw argv for consumers/diagnostics
8
+ const __rawArgs = process.argv.slice(2);
7
9
  // Normalize args to support --option=value and -o=value
8
- const args = process.argv.slice(2).flatMap(arg => {
10
+ let args = __rawArgs.flatMap(arg => {
9
11
  if (/^--?\w[\w-]*=/.test(arg)) {
10
12
  const [flag, ...rest] = arg.split('=');
11
13
  return [flag, rest.join('=')];
12
14
  }
13
15
  return arg;
14
16
  });
17
+ // Capture `--` passthrough (everything after `--`) and remove from args to avoid parsing
18
+ const dashIndex = args.indexOf('--');
19
+ const dashArgs = dashIndex >= 0 ? args.slice(dashIndex + 1) : [];
20
+ if (dashIndex >= 0) {
21
+ args = args.slice(0, dashIndex);
22
+ }
15
23
  const result = {};
16
24
  // Check for duplicate aliases
17
25
  const aliasMap = new Map();
@@ -116,8 +124,71 @@ export function parseArgs(config) {
116
124
  [option, configKey] = findOption(options, arg);
117
125
  }
118
126
  else if (argOrg.startsWith('-')) {
119
- arg = argOrg.slice(1);
120
- [option, configKey] = findOption(options, arg);
127
+ // Support grouped short flags, e.g. `-ab` where `a` and `b` are aliases.
128
+ // The last short in the cluster may consume the next token as its value
129
+ // when it's not a boolean option.
130
+ const body = argOrg.slice(1);
131
+ arg = body;
132
+ // If this exact token matches an option (rare), prefer that (e.g. -xy where xy is alias)
133
+ [option, configKey] = findOption(options, body);
134
+ // Avoid treating `-no-foo` or `-noFoo` as a short-char cluster — those are
135
+ // negated long forms and should be handled by the negation branch below.
136
+ if (!option && body.length > 1 && !/^no-/.test(body) && !/^no[A-Z]/.test(body)) {
137
+ // Treat as a cluster of single-char aliases
138
+ const chars = body.split('');
139
+ for (let ci = 0; ci < chars.length; ci++) {
140
+ const ch = chars[ci];
141
+ const isLast = ci === chars.length - 1;
142
+ const [cOpt, cKey] = findOption(options, ch);
143
+ if (!cOpt || !cKey) {
144
+ throw new Error(`Unknown argument: ${ch}`);
145
+ }
146
+ if (!isLast) {
147
+ // Middle flags must be boolean
148
+ if (cOpt.type !== 'boolean') {
149
+ throw new Error(`Missing value for option: ${cKey}`);
150
+ }
151
+ if (result[cKey] !== undefined) {
152
+ throw new Error('Providing same negated and truthy argument are not allowed');
153
+ }
154
+ result[cKey] = true;
155
+ }
156
+ else {
157
+ // Last flag: if boolean, set true; otherwise consume next token as its value
158
+ if (cOpt.type === 'boolean') {
159
+ if (result[cKey] !== undefined) {
160
+ throw new Error('Providing same negated and truthy argument are not allowed');
161
+ }
162
+ result[cKey] = true;
163
+ }
164
+ else if (cOpt.type === 'number') {
165
+ if (args[argIndex + 1] === undefined || args[argIndex + 1].startsWith('-')) {
166
+ throw new Error(`Missing value for option: ${cKey}`);
167
+ }
168
+ result[cKey] = Number(args[++argIndex]);
169
+ }
170
+ else if (cOpt.type === 'array') {
171
+ if (!result[cKey]) {
172
+ result[cKey] = [];
173
+ }
174
+ const arrayValue = args[++argIndex];
175
+ if (arrayValue === undefined || arrayValue.startsWith('-')) {
176
+ throw new Error(`Missing value for array option: ${cKey}`);
177
+ }
178
+ result[cKey].push(arrayValue);
179
+ }
180
+ else {
181
+ if (args[argIndex + 1] === undefined || args[argIndex + 1].startsWith('-')) {
182
+ throw new Error(`Missing value for option: ${cKey}`);
183
+ }
184
+ result[cKey] = args[++argIndex];
185
+ }
186
+ }
187
+ }
188
+ // We've consumed any value for the last short (if present), continue to next arg
189
+ argIndex++;
190
+ continue;
191
+ }
121
192
  }
122
193
  // Handle negated boolean in both forms
123
194
  if (!option) {
@@ -131,12 +202,24 @@ export function parseArgs(config) {
131
202
  throw new Error('Providing same negated and truthy argument are not allowed');
132
203
  }
133
204
  result[configKey] = !isNegated;
205
+ // When explicitly negated (e.g. --no-foo) also expose `noFoo` and `no-foo` keys
206
+ if (isNegated) {
207
+ const kebabKey = camelToKebab(configKey);
208
+ const noCamel = `no${configKey[0].toUpperCase()}${configKey.slice(1)}`;
209
+ const noKebab = `no-${kebabKey}`;
210
+ if (result[noCamel] === undefined) {
211
+ result[noCamel] = true;
212
+ }
213
+ if (result[noKebab] === undefined) {
214
+ result[noKebab] = true;
215
+ }
216
+ }
134
217
  argIndex++;
135
218
  continue;
136
219
  }
137
220
  }
138
221
  if (!option || !configKey) {
139
- throw new Error(`Unknown CLI option: ${arg}`);
222
+ throw new Error(`Unknown argument: ${arg}`);
140
223
  }
141
224
  switch (option.type) {
142
225
  case 'boolean':
@@ -144,6 +227,18 @@ export function parseArgs(config) {
144
227
  throw new Error('Providing same negated and truthy argument are not allowed');
145
228
  }
146
229
  result[configKey] = !argOrg.startsWith('--no-') && !argOrg.startsWith('-no-');
230
+ // When a boolean was explicitly negated, also expose `noFoo` and `no-foo` keys
231
+ if (result[configKey] === false) {
232
+ const kebabKey = camelToKebab(configKey);
233
+ const noCamel = `no${configKey[0].toUpperCase()}${configKey.slice(1)}`;
234
+ const noKebab = `no-${kebabKey}`;
235
+ if (result[noCamel] === undefined) {
236
+ result[noCamel] = true;
237
+ }
238
+ if (result[noKebab] === undefined) {
239
+ result[noKebab] = true;
240
+ }
241
+ }
147
242
  break;
148
243
  case 'number':
149
244
  if (args[argIndex + 1] === undefined || args[argIndex + 1].startsWith('-')) {
@@ -152,21 +247,38 @@ export function parseArgs(config) {
152
247
  result[configKey] = Number(args[++argIndex]);
153
248
  break;
154
249
  case 'array': {
155
- if (!result[configKey])
250
+ if (!result[configKey]) {
156
251
  result[configKey] = [];
157
- const arrayValue = args[++argIndex];
158
- if (arrayValue === undefined || arrayValue.startsWith('-')) {
159
- throw new Error(`Missing value for array option: ${configKey}`);
160
252
  }
161
- result[configKey].push(arrayValue);
253
+ const next = args[argIndex + 1];
254
+ if (next === undefined || next.startsWith('-')) {
255
+ // For bare long-form options we allow empty value; short/clustered forms still throw
256
+ if (argOrg.startsWith('--')) {
257
+ result[configKey].push('');
258
+ }
259
+ else {
260
+ throw new Error(`Missing value for array option: ${configKey}`);
261
+ }
262
+ }
263
+ else {
264
+ result[configKey].push(args[++argIndex]);
265
+ }
162
266
  break;
163
267
  }
164
268
  case 'string':
165
269
  default:
166
270
  if (args[argIndex + 1] === undefined || args[argIndex + 1].startsWith('-')) {
167
- throw new Error(`Missing value for option: ${configKey}`);
271
+ if (argOrg.startsWith('--')) {
272
+ // treat bare long option as empty string
273
+ result[configKey] = '';
274
+ }
275
+ else {
276
+ throw new Error(`Missing value for option: ${configKey}`);
277
+ }
278
+ }
279
+ else {
280
+ result[configKey] = args[++argIndex];
168
281
  }
169
- result[configKey] = args[++argIndex];
170
282
  break;
171
283
  }
172
284
  }
@@ -186,6 +298,34 @@ export function parseArgs(config) {
186
298
  throw new Error(`Missing required option: ${aliasStr}--${key}`);
187
299
  }
188
300
  });
301
+ // Expose raw non-option positionals as `._` for convenience
302
+ if (result._ === undefined) {
303
+ result._ = nonOptionArgs.slice();
304
+ }
305
+ // Duplicate parsed keys into both kebab-case and camelCase for parity with yargs.
306
+ // Use a snapshot of keys to avoid iterating over newly-created duplicates.
307
+ const parsedKeys = Object.keys(result);
308
+ for (const key of parsedKeys) {
309
+ if (key === '--' || key === '__rawArgs') {
310
+ continue;
311
+ }
312
+ const value = result[key];
313
+ const kebab = camelToKebab(key);
314
+ const camel = kebabToCamel(key);
315
+ if (kebab !== key && result[kebab] === undefined) {
316
+ result[kebab] = value;
317
+ }
318
+ if (camel !== key && result[camel] === undefined) {
319
+ result[camel] = value;
320
+ }
321
+ }
322
+ // Expose passthrough tokens and raw args for downstream consumers
323
+ if (!result['--']) {
324
+ result['--'] = dashArgs.slice();
325
+ }
326
+ if (result.__rawArgs === undefined) {
327
+ result.__rawArgs = __rawArgs.slice();
328
+ }
189
329
  return result;
190
330
  }
191
331
  /** Format a text to a fixed length, truncating and padding as needed. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cli-nano",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Small command-line tool similar to `yargs` or `parseArgs` from Node.js to create a CLI accepting positional arguments, flags and options.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/index.ts CHANGED
@@ -10,14 +10,24 @@ const defaultOptions: Record<string, FlagOption> = {
10
10
  export function parseArgs<C extends Config>(config: C): ArgsResult<C> {
11
11
  const { command, options, version } = config;
12
12
 
13
+ // Capture raw argv for consumers/diagnostics
14
+ const __rawArgs = process.argv.slice(2);
15
+
13
16
  // Normalize args to support --option=value and -o=value
14
- const args = process.argv.slice(2).flatMap(arg => {
17
+ let args = __rawArgs.flatMap(arg => {
15
18
  if (/^--?\w[\w-]*=/.test(arg)) {
16
19
  const [flag, ...rest] = arg.split('=');
17
20
  return [flag, rest.join('=')];
18
21
  }
19
22
  return arg;
20
23
  });
24
+
25
+ // Capture `--` passthrough (everything after `--`) and remove from args to avoid parsing
26
+ const dashIndex = args.indexOf('--');
27
+ const dashArgs: string[] = dashIndex >= 0 ? args.slice(dashIndex + 1) : [];
28
+ if (dashIndex >= 0) {
29
+ args = args.slice(0, dashIndex);
30
+ }
21
31
  const result: Record<string, any> = {};
22
32
 
23
33
  // Check for duplicate aliases
@@ -125,8 +135,70 @@ export function parseArgs<C extends Config>(config: C): ArgsResult<C> {
125
135
  arg = argOrg.slice(2);
126
136
  [option, configKey] = findOption(options, arg);
127
137
  } else if (argOrg.startsWith('-')) {
128
- arg = argOrg.slice(1);
129
- [option, configKey] = findOption(options, arg);
138
+ // Support grouped short flags, e.g. `-ab` where `a` and `b` are aliases.
139
+ // The last short in the cluster may consume the next token as its value
140
+ // when it's not a boolean option.
141
+ const body = argOrg.slice(1);
142
+ arg = body;
143
+
144
+ // If this exact token matches an option (rare), prefer that (e.g. -xy where xy is alias)
145
+ [option, configKey] = findOption(options, body);
146
+ // Avoid treating `-no-foo` or `-noFoo` as a short-char cluster — those are
147
+ // negated long forms and should be handled by the negation branch below.
148
+ if (!option && body.length > 1 && !/^no-/.test(body) && !/^no[A-Z]/.test(body)) {
149
+ // Treat as a cluster of single-char aliases
150
+ const chars = body.split('');
151
+ for (let ci = 0; ci < chars.length; ci++) {
152
+ const ch = chars[ci];
153
+ const isLast = ci === chars.length - 1;
154
+ const [cOpt, cKey] = findOption(options, ch);
155
+ if (!cOpt || !cKey) {
156
+ throw new Error(`Unknown argument: ${ch}`);
157
+ }
158
+
159
+ if (!isLast) {
160
+ // Middle flags must be boolean
161
+ if (cOpt.type !== 'boolean') {
162
+ throw new Error(`Missing value for option: ${cKey}`);
163
+ }
164
+ if (result[cKey] !== undefined) {
165
+ throw new Error('Providing same negated and truthy argument are not allowed');
166
+ }
167
+ result[cKey] = true;
168
+ } else {
169
+ // Last flag: if boolean, set true; otherwise consume next token as its value
170
+ if (cOpt.type === 'boolean') {
171
+ if (result[cKey] !== undefined) {
172
+ throw new Error('Providing same negated and truthy argument are not allowed');
173
+ }
174
+ result[cKey] = true;
175
+ } else if (cOpt.type === 'number') {
176
+ if (args[argIndex + 1] === undefined || args[argIndex + 1].startsWith('-')) {
177
+ throw new Error(`Missing value for option: ${cKey}`);
178
+ }
179
+ result[cKey] = Number(args[++argIndex]);
180
+ } else if (cOpt.type === 'array') {
181
+ if (!result[cKey]) {
182
+ result[cKey] = [];
183
+ }
184
+ const arrayValue = args[++argIndex];
185
+ if (arrayValue === undefined || arrayValue.startsWith('-')) {
186
+ throw new Error(`Missing value for array option: ${cKey}`);
187
+ }
188
+ result[cKey].push(arrayValue);
189
+ } else {
190
+ if (args[argIndex + 1] === undefined || args[argIndex + 1].startsWith('-')) {
191
+ throw new Error(`Missing value for option: ${cKey}`);
192
+ }
193
+ result[cKey] = args[++argIndex];
194
+ }
195
+ }
196
+ }
197
+
198
+ // We've consumed any value for the last short (if present), continue to next arg
199
+ argIndex++;
200
+ continue;
201
+ }
130
202
  }
131
203
 
132
204
  // Handle negated boolean in both forms
@@ -141,13 +213,25 @@ export function parseArgs<C extends Config>(config: C): ArgsResult<C> {
141
213
  throw new Error('Providing same negated and truthy argument are not allowed');
142
214
  }
143
215
  result[configKey] = !isNegated;
216
+ // When explicitly negated (e.g. --no-foo) also expose `noFoo` and `no-foo` keys
217
+ if (isNegated) {
218
+ const kebabKey = camelToKebab(configKey);
219
+ const noCamel = `no${configKey[0].toUpperCase()}${configKey.slice(1)}`;
220
+ const noKebab = `no-${kebabKey}`;
221
+ if (result[noCamel] === undefined) {
222
+ result[noCamel] = true;
223
+ }
224
+ if (result[noKebab] === undefined) {
225
+ result[noKebab] = true;
226
+ }
227
+ }
144
228
  argIndex++;
145
229
  continue;
146
230
  }
147
231
  }
148
232
 
149
233
  if (!option || !configKey) {
150
- throw new Error(`Unknown CLI option: ${arg}`);
234
+ throw new Error(`Unknown argument: ${arg}`);
151
235
  }
152
236
 
153
237
  switch (option.type) {
@@ -156,6 +240,18 @@ export function parseArgs<C extends Config>(config: C): ArgsResult<C> {
156
240
  throw new Error('Providing same negated and truthy argument are not allowed');
157
241
  }
158
242
  result[configKey] = !argOrg.startsWith('--no-') && !argOrg.startsWith('-no-');
243
+ // When a boolean was explicitly negated, also expose `noFoo` and `no-foo` keys
244
+ if (result[configKey] === false) {
245
+ const kebabKey = camelToKebab(configKey);
246
+ const noCamel = `no${configKey[0].toUpperCase()}${configKey.slice(1)}`;
247
+ const noKebab = `no-${kebabKey}`;
248
+ if (result[noCamel] === undefined) {
249
+ result[noCamel] = true;
250
+ }
251
+ if (result[noKebab] === undefined) {
252
+ result[noKebab] = true;
253
+ }
254
+ }
159
255
  break;
160
256
  case 'number':
161
257
  if (args[argIndex + 1] === undefined || args[argIndex + 1].startsWith('-')) {
@@ -164,20 +260,34 @@ export function parseArgs<C extends Config>(config: C): ArgsResult<C> {
164
260
  result[configKey] = Number(args[++argIndex]);
165
261
  break;
166
262
  case 'array': {
167
- if (!result[configKey]) result[configKey] = [];
168
- const arrayValue = args[++argIndex];
169
- if (arrayValue === undefined || arrayValue.startsWith('-')) {
170
- throw new Error(`Missing value for array option: ${configKey}`);
263
+ if (!result[configKey]) {
264
+ result[configKey] = [];
265
+ }
266
+ const next = args[argIndex + 1];
267
+ if (next === undefined || next.startsWith('-')) {
268
+ // For bare long-form options we allow empty value; short/clustered forms still throw
269
+ if (argOrg.startsWith('--')) {
270
+ result[configKey].push('');
271
+ } else {
272
+ throw new Error(`Missing value for array option: ${configKey}`);
273
+ }
274
+ } else {
275
+ result[configKey].push(args[++argIndex]);
171
276
  }
172
- result[configKey].push(arrayValue);
173
277
  break;
174
278
  }
175
279
  case 'string':
176
280
  default:
177
281
  if (args[argIndex + 1] === undefined || args[argIndex + 1].startsWith('-')) {
178
- throw new Error(`Missing value for option: ${configKey}`);
282
+ if (argOrg.startsWith('--')) {
283
+ // treat bare long option as empty string
284
+ result[configKey] = '';
285
+ } else {
286
+ throw new Error(`Missing value for option: ${configKey}`);
287
+ }
288
+ } else {
289
+ result[configKey] = args[++argIndex];
179
290
  }
180
- result[configKey] = args[++argIndex];
181
291
  break;
182
292
  }
183
293
  } else {
@@ -198,6 +308,37 @@ export function parseArgs<C extends Config>(config: C): ArgsResult<C> {
198
308
  }
199
309
  });
200
310
 
311
+ // Expose raw non-option positionals as `._` for convenience
312
+ if ((result as any)._ === undefined) {
313
+ (result as any)._ = nonOptionArgs.slice();
314
+ }
315
+
316
+ // Duplicate parsed keys into both kebab-case and camelCase for parity with yargs.
317
+ // Use a snapshot of keys to avoid iterating over newly-created duplicates.
318
+ const parsedKeys = Object.keys(result);
319
+ for (const key of parsedKeys) {
320
+ if (key === '--' || key === '__rawArgs') {
321
+ continue;
322
+ }
323
+ const value = result[key];
324
+ const kebab = camelToKebab(key);
325
+ const camel = kebabToCamel(key);
326
+ if (kebab !== key && result[kebab] === undefined) {
327
+ result[kebab] = value;
328
+ }
329
+ if (camel !== key && result[camel] === undefined) {
330
+ result[camel] = value;
331
+ }
332
+ }
333
+
334
+ // Expose passthrough tokens and raw args for downstream consumers
335
+ if (!result['--']) {
336
+ result['--'] = dashArgs.slice();
337
+ }
338
+ if ((result as any).__rawArgs === undefined) {
339
+ (result as any).__rawArgs = __rawArgs.slice();
340
+ }
341
+
201
342
  return result as ArgsResult<C>;
202
343
  }
203
344