@guanghechen/commander 3.0.0 → 3.1.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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Change Log
2
2
 
3
+ ## 3.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Implement option bubbling with shift/apply flow
8
+ - Add `shift()` method for bottom-up option consumption (leaf → root)
9
+ - Refactor `run()` with new flow: route → split → shift → apply → action
10
+ - Add `UnexpectedArgument` error type for positional args before `--`
11
+
3
12
  All notable changes to this project will be documented in this file. See
4
13
  [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
14
 
package/lib/cjs/index.cjs CHANGED
@@ -126,34 +126,39 @@ class Command {
126
126
  const { argv, envs, reporter } = params;
127
127
  try {
128
128
  const processedArgv = this.#processHelpSubcommand(argv);
129
- const { command, remaining } = this.#route(processedArgv);
130
- const allOptions = command.#getMergedOptions();
131
- const hasUserHelp = allOptions.some(o => o.long === 'help' && !command.#isBuiltinOption(o));
132
- const hasUserVersion = allOptions.some(o => o.long === 'version' && !command.#isBuiltinOption(o));
133
- if (!hasUserHelp && command.#hasHelpFlag(remaining, allOptions)) {
134
- console.log(command.formatHelp());
129
+ const { chain, remaining } = this.#routeChain(processedArgv);
130
+ const leafCommand = chain[chain.length - 1];
131
+ const { optionTokens, restArgs } = this.#splitAtDoubleDash(remaining);
132
+ const leafOptions = leafCommand.#getMergedOptions();
133
+ const hasUserHelp = leafCommand.#options.some(o => o.long === 'help');
134
+ const hasUserVersion = leafCommand.#options.some(o => o.long === 'version');
135
+ if (!hasUserHelp && leafCommand.#hasHelpFlag(optionTokens, leafOptions)) {
136
+ console.log(leafCommand.formatHelp());
135
137
  return;
136
138
  }
137
- if (!hasUserVersion && command.#hasVersionFlag(remaining, allOptions)) {
138
- console.log(command.version ?? 'unknown');
139
+ if (!hasUserVersion && leafCommand.#hasVersionFlag(optionTokens, leafOptions)) {
140
+ console.log(leafCommand.version ?? 'unknown');
139
141
  return;
140
142
  }
141
- const { opts, args } = command.parse(remaining);
143
+ const optsMap = this.#shiftChain(chain, optionTokens);
142
144
  const ctx = {
143
- cmd: command,
145
+ cmd: leafCommand,
144
146
  envs,
145
147
  reporter: reporter ?? new DefaultReporter(),
146
148
  argv,
147
149
  };
148
- for (const opt of allOptions) {
149
- if (opt.apply && opts[opt.long] !== undefined) {
150
- opt.apply(opts[opt.long], ctx);
151
- }
150
+ this.#applyChain(chain, optsMap, ctx);
151
+ const mergedOpts = this.#mergeOpts(chain, optsMap);
152
+ const args = restArgs;
153
+ const requiredArgs = leafCommand.#arguments.filter(a => a.kind === 'required');
154
+ if (args.length < requiredArgs.length) {
155
+ const missing = requiredArgs.slice(args.length).map(a => a.name);
156
+ throw new CommanderError('MissingRequiredArgument', `missing required argument(s): ${missing.join(', ')}`, leafCommand.#getCommandPath());
152
157
  }
153
- const actionParams = { ctx, opts, args };
154
- if (command.#action) {
158
+ const actionParams = { ctx, opts: mergedOpts, args };
159
+ if (leafCommand.#action) {
155
160
  try {
156
- await command.#action(actionParams);
161
+ await leafCommand.#action(actionParams);
157
162
  }
158
163
  catch (err) {
159
164
  if (err instanceof Error) {
@@ -165,11 +170,11 @@ class Command {
165
170
  process.exit(1);
166
171
  }
167
172
  }
168
- else if (command.#subcommands.length > 0) {
169
- console.log(command.formatHelp());
173
+ else if (leafCommand.#subcommands.length > 0) {
174
+ console.log(leafCommand.formatHelp());
170
175
  }
171
176
  else {
172
- throw new CommanderError('ConfigurationError', `no action defined for command "${command.#getCommandPath()}"`, command.#getCommandPath());
177
+ throw new CommanderError('ConfigurationError', `no action defined for command "${leafCommand.#getCommandPath()}"`, leafCommand.#getCommandPath());
173
178
  }
174
179
  }
175
180
  catch (err) {
@@ -247,6 +252,82 @@ class Command {
247
252
  }
248
253
  return { opts, args };
249
254
  }
255
+ shift(tokens) {
256
+ return this.#shiftWithShadowed(tokens, new Set());
257
+ }
258
+ #shiftWithShadowed(tokens, shadowed) {
259
+ const allDirectOptions = this.#getMergedOptions();
260
+ const directOptions = allDirectOptions.filter(o => !shadowed.has(o.long));
261
+ const opts = {};
262
+ for (const opt of directOptions) {
263
+ if (opt.default !== undefined) {
264
+ opts[opt.long] = opt.default;
265
+ }
266
+ else if (opt.type === 'boolean') {
267
+ opts[opt.long] = false;
268
+ }
269
+ else if (opt.type === 'string[]' || opt.type === 'number[]') {
270
+ opts[opt.long] = [];
271
+ }
272
+ }
273
+ let remaining = [...tokens];
274
+ const resolverOptions = directOptions.filter(o => o.resolver);
275
+ for (const opt of resolverOptions) {
276
+ const result = opt.resolver(remaining);
277
+ opts[opt.long] = result.value;
278
+ remaining = result.remaining;
279
+ }
280
+ const { optionByLong, optionByShort, booleanOptions } = this.#buildOptionMaps(directOptions, true);
281
+ const normalizedTokens = this.#normalizeArgv(remaining, booleanOptions);
282
+ const finalRemaining = [];
283
+ let i = 0;
284
+ while (i < normalizedTokens.length) {
285
+ const token = normalizedTokens[i];
286
+ if (token.startsWith('--')) {
287
+ const consumed = this.#tryConsumeLongOption(normalizedTokens, i, optionByLong, opts);
288
+ if (consumed > 0) {
289
+ i += consumed;
290
+ continue;
291
+ }
292
+ finalRemaining.push(token);
293
+ i += 1;
294
+ continue;
295
+ }
296
+ if (token.startsWith('-') && token.length > 1) {
297
+ const result = this.#tryConsumeShortOption(normalizedTokens, i, optionByShort, opts);
298
+ if (result.consumed) {
299
+ i = result.nextIdx;
300
+ if (result.remainingToken) {
301
+ finalRemaining.push(result.remainingToken);
302
+ }
303
+ continue;
304
+ }
305
+ finalRemaining.push(token);
306
+ i += 1;
307
+ continue;
308
+ }
309
+ finalRemaining.push(token);
310
+ i += 1;
311
+ }
312
+ for (const opt of directOptions) {
313
+ if (opt.required && opts[opt.long] === undefined) {
314
+ throw new CommanderError('MissingRequired', `missing required option "--${opt.long}" for command "${this.#getCommandPath()}"`, this.#getCommandPath());
315
+ }
316
+ }
317
+ for (const opt of directOptions) {
318
+ if (opt.choices && opts[opt.long] !== undefined) {
319
+ const value = opts[opt.long];
320
+ const values = Array.isArray(value) ? value : [value];
321
+ const choices = opt.choices;
322
+ for (const v of values) {
323
+ if (!choices.includes(v)) {
324
+ throw new CommanderError('InvalidChoice', `invalid value "${v}" for option "--${opt.long}". Allowed: ${opt.choices.join(', ')}`, this.#getCommandPath());
325
+ }
326
+ }
327
+ }
328
+ }
329
+ return { opts, remaining: finalRemaining };
330
+ }
250
331
  formatHelp() {
251
332
  const lines = [];
252
333
  const allOptions = this.#getMergedOptions();
@@ -369,7 +450,8 @@ class Command {
369
450
  }
370
451
  return argv;
371
452
  }
372
- #route(argv) {
453
+ #routeChain(argv) {
454
+ const chain = [this];
373
455
  let current = this;
374
456
  let idx = 0;
375
457
  while (idx < argv.length) {
@@ -380,9 +462,62 @@ class Command {
380
462
  if (!entry)
381
463
  break;
382
464
  current = entry.command;
465
+ chain.push(current);
383
466
  idx += 1;
384
467
  }
385
- return { command: current, remaining: argv.slice(idx) };
468
+ return { chain, remaining: argv.slice(idx) };
469
+ }
470
+ #splitAtDoubleDash(tokens) {
471
+ const ddIdx = tokens.indexOf('--');
472
+ if (ddIdx === -1) {
473
+ return { optionTokens: tokens, restArgs: [] };
474
+ }
475
+ return {
476
+ optionTokens: tokens.slice(0, ddIdx),
477
+ restArgs: tokens.slice(ddIdx + 1),
478
+ };
479
+ }
480
+ #shiftChain(chain, tokens) {
481
+ const optsMap = new Map();
482
+ let remaining = [...tokens];
483
+ const shadowed = new Set();
484
+ for (let i = chain.length - 1; i >= 0; i--) {
485
+ const cmd = chain[i];
486
+ const result = cmd.#shiftWithShadowed(remaining, shadowed);
487
+ optsMap.set(cmd, result.opts);
488
+ remaining = result.remaining;
489
+ for (const opt of cmd.#options) {
490
+ shadowed.add(opt.long);
491
+ }
492
+ }
493
+ if (remaining.length > 0) {
494
+ const leafCommand = chain[chain.length - 1];
495
+ const firstToken = remaining[0];
496
+ if (firstToken.startsWith('-')) {
497
+ throw new CommanderError('UnknownOption', `unknown option "${firstToken}" for command "${leafCommand.#getCommandPath()}"`, leafCommand.#getCommandPath());
498
+ }
499
+ else {
500
+ throw new CommanderError('UnexpectedArgument', `unexpected argument "${firstToken}". Positional arguments must come after "--"`, leafCommand.#getCommandPath());
501
+ }
502
+ }
503
+ return optsMap;
504
+ }
505
+ #applyChain(chain, optsMap, ctx) {
506
+ for (const cmd of chain) {
507
+ const opts = optsMap.get(cmd) ?? {};
508
+ for (const opt of cmd.#getMergedOptions()) {
509
+ if (opt.apply && opts[opt.long] !== undefined) {
510
+ opt.apply(opts[opt.long], ctx);
511
+ }
512
+ }
513
+ }
514
+ }
515
+ #mergeOpts(chain, optsMap) {
516
+ const merged = {};
517
+ for (const cmd of chain) {
518
+ Object.assign(merged, optsMap.get(cmd) ?? {});
519
+ }
520
+ return merged;
386
521
  }
387
522
  #parseLongOption(argv, idx, optionByLong, opts) {
388
523
  const token = argv[idx];
@@ -545,9 +680,6 @@ class Command {
545
680
  }
546
681
  }
547
682
  }
548
- #isBuiltinOption(opt) {
549
- return opt === BUILTIN_HELP_OPTION || opt === BUILTIN_VERSION_OPTION;
550
- }
551
683
  #buildOptionMaps(allOptions, excludeResolver = false) {
552
684
  const optionByLong = new Map();
553
685
  const optionByShort = new Map();
@@ -644,6 +776,100 @@ class Command {
644
776
  #getCommandPath() {
645
777
  return this.#name;
646
778
  }
779
+ #tryConsumeLongOption(tokens, idx, optionByLong, opts) {
780
+ const token = tokens[idx];
781
+ const eqIdx = token.indexOf('=');
782
+ let optName;
783
+ let inlineValue;
784
+ if (eqIdx !== -1) {
785
+ optName = token.slice(2, eqIdx);
786
+ inlineValue = token.slice(eqIdx + 1);
787
+ }
788
+ else {
789
+ optName = token.slice(2);
790
+ }
791
+ const opt = optionByLong.get(optName);
792
+ if (!opt) {
793
+ return 0;
794
+ }
795
+ if (opt.type === 'boolean') {
796
+ if (inlineValue !== undefined) {
797
+ if (inlineValue === 'true') {
798
+ opts[optName] = true;
799
+ }
800
+ else if (inlineValue === 'false') {
801
+ opts[optName] = false;
802
+ }
803
+ else {
804
+ throw new CommanderError('InvalidBooleanValue', `invalid value "${inlineValue}" for boolean option "--${optName}". Use "true" or "false"`, this.#getCommandPath());
805
+ }
806
+ }
807
+ else {
808
+ opts[optName] = true;
809
+ }
810
+ return 1;
811
+ }
812
+ let value;
813
+ let consumed = 1;
814
+ if (inlineValue !== undefined) {
815
+ value = inlineValue;
816
+ }
817
+ else if (idx + 1 < tokens.length) {
818
+ value = tokens[idx + 1];
819
+ consumed = 2;
820
+ }
821
+ else {
822
+ throw new CommanderError('MissingValue', `option "--${optName}" requires a value`, this.#getCommandPath());
823
+ }
824
+ this.#applyValue(opt, value, opts);
825
+ return consumed;
826
+ }
827
+ #tryConsumeShortOption(tokens, idx, optionByShort, opts) {
828
+ const token = tokens[idx];
829
+ if (token.includes('=')) {
830
+ const firstFlag = token[1];
831
+ if (!optionByShort.has(firstFlag)) {
832
+ return { consumed: false, nextIdx: idx + 1 };
833
+ }
834
+ throw new CommanderError('UnsupportedShortSyntax', `"-${token.slice(1)}" is not supported. Use "-${token[1]} ${token.slice(3)}" instead`, this.#getCommandPath());
835
+ }
836
+ const flags = token.slice(1);
837
+ let j = 0;
838
+ const consumedFlags = [];
839
+ const unconsumedFlags = [];
840
+ let nextIdx = idx + 1;
841
+ while (j < flags.length) {
842
+ const flag = flags[j];
843
+ const opt = optionByShort.get(flag);
844
+ if (!opt) {
845
+ unconsumedFlags.push(...flags.slice(j).split(''));
846
+ break;
847
+ }
848
+ consumedFlags.push(flag);
849
+ if (opt.type === 'boolean') {
850
+ opts[opt.long] = true;
851
+ j += 1;
852
+ continue;
853
+ }
854
+ if (j < flags.length - 1) {
855
+ throw new CommanderError('UnsupportedShortSyntax', `"-${flags}" is not supported. Use "-${flags.slice(0, j + 1)} ${flags.slice(j + 1)}" or separate options`, this.#getCommandPath());
856
+ }
857
+ if (idx + 1 < tokens.length && !tokens[idx + 1].startsWith('-')) {
858
+ const value = tokens[idx + 1];
859
+ this.#applyValue(opt, value, opts);
860
+ nextIdx = idx + 2;
861
+ }
862
+ else {
863
+ throw new CommanderError('MissingValue', `option "-${flag}" requires a value`, this.#getCommandPath());
864
+ }
865
+ j += 1;
866
+ }
867
+ if (consumedFlags.length > 0) {
868
+ const remainingToken = unconsumedFlags.length > 0 ? `-${unconsumedFlags.join('')}` : undefined;
869
+ return { consumed: true, nextIdx, remainingToken };
870
+ }
871
+ return { consumed: false, nextIdx: idx + 1 };
872
+ }
647
873
  }
648
874
 
649
875
  class CompletionCommand extends Command {
package/lib/esm/index.mjs CHANGED
@@ -104,34 +104,39 @@ class Command {
104
104
  const { argv, envs, reporter } = params;
105
105
  try {
106
106
  const processedArgv = this.#processHelpSubcommand(argv);
107
- const { command, remaining } = this.#route(processedArgv);
108
- const allOptions = command.#getMergedOptions();
109
- const hasUserHelp = allOptions.some(o => o.long === 'help' && !command.#isBuiltinOption(o));
110
- const hasUserVersion = allOptions.some(o => o.long === 'version' && !command.#isBuiltinOption(o));
111
- if (!hasUserHelp && command.#hasHelpFlag(remaining, allOptions)) {
112
- console.log(command.formatHelp());
107
+ const { chain, remaining } = this.#routeChain(processedArgv);
108
+ const leafCommand = chain[chain.length - 1];
109
+ const { optionTokens, restArgs } = this.#splitAtDoubleDash(remaining);
110
+ const leafOptions = leafCommand.#getMergedOptions();
111
+ const hasUserHelp = leafCommand.#options.some(o => o.long === 'help');
112
+ const hasUserVersion = leafCommand.#options.some(o => o.long === 'version');
113
+ if (!hasUserHelp && leafCommand.#hasHelpFlag(optionTokens, leafOptions)) {
114
+ console.log(leafCommand.formatHelp());
113
115
  return;
114
116
  }
115
- if (!hasUserVersion && command.#hasVersionFlag(remaining, allOptions)) {
116
- console.log(command.version ?? 'unknown');
117
+ if (!hasUserVersion && leafCommand.#hasVersionFlag(optionTokens, leafOptions)) {
118
+ console.log(leafCommand.version ?? 'unknown');
117
119
  return;
118
120
  }
119
- const { opts, args } = command.parse(remaining);
121
+ const optsMap = this.#shiftChain(chain, optionTokens);
120
122
  const ctx = {
121
- cmd: command,
123
+ cmd: leafCommand,
122
124
  envs,
123
125
  reporter: reporter ?? new DefaultReporter(),
124
126
  argv,
125
127
  };
126
- for (const opt of allOptions) {
127
- if (opt.apply && opts[opt.long] !== undefined) {
128
- opt.apply(opts[opt.long], ctx);
129
- }
128
+ this.#applyChain(chain, optsMap, ctx);
129
+ const mergedOpts = this.#mergeOpts(chain, optsMap);
130
+ const args = restArgs;
131
+ const requiredArgs = leafCommand.#arguments.filter(a => a.kind === 'required');
132
+ if (args.length < requiredArgs.length) {
133
+ const missing = requiredArgs.slice(args.length).map(a => a.name);
134
+ throw new CommanderError('MissingRequiredArgument', `missing required argument(s): ${missing.join(', ')}`, leafCommand.#getCommandPath());
130
135
  }
131
- const actionParams = { ctx, opts, args };
132
- if (command.#action) {
136
+ const actionParams = { ctx, opts: mergedOpts, args };
137
+ if (leafCommand.#action) {
133
138
  try {
134
- await command.#action(actionParams);
139
+ await leafCommand.#action(actionParams);
135
140
  }
136
141
  catch (err) {
137
142
  if (err instanceof Error) {
@@ -143,11 +148,11 @@ class Command {
143
148
  process.exit(1);
144
149
  }
145
150
  }
146
- else if (command.#subcommands.length > 0) {
147
- console.log(command.formatHelp());
151
+ else if (leafCommand.#subcommands.length > 0) {
152
+ console.log(leafCommand.formatHelp());
148
153
  }
149
154
  else {
150
- throw new CommanderError('ConfigurationError', `no action defined for command "${command.#getCommandPath()}"`, command.#getCommandPath());
155
+ throw new CommanderError('ConfigurationError', `no action defined for command "${leafCommand.#getCommandPath()}"`, leafCommand.#getCommandPath());
151
156
  }
152
157
  }
153
158
  catch (err) {
@@ -225,6 +230,82 @@ class Command {
225
230
  }
226
231
  return { opts, args };
227
232
  }
233
+ shift(tokens) {
234
+ return this.#shiftWithShadowed(tokens, new Set());
235
+ }
236
+ #shiftWithShadowed(tokens, shadowed) {
237
+ const allDirectOptions = this.#getMergedOptions();
238
+ const directOptions = allDirectOptions.filter(o => !shadowed.has(o.long));
239
+ const opts = {};
240
+ for (const opt of directOptions) {
241
+ if (opt.default !== undefined) {
242
+ opts[opt.long] = opt.default;
243
+ }
244
+ else if (opt.type === 'boolean') {
245
+ opts[opt.long] = false;
246
+ }
247
+ else if (opt.type === 'string[]' || opt.type === 'number[]') {
248
+ opts[opt.long] = [];
249
+ }
250
+ }
251
+ let remaining = [...tokens];
252
+ const resolverOptions = directOptions.filter(o => o.resolver);
253
+ for (const opt of resolverOptions) {
254
+ const result = opt.resolver(remaining);
255
+ opts[opt.long] = result.value;
256
+ remaining = result.remaining;
257
+ }
258
+ const { optionByLong, optionByShort, booleanOptions } = this.#buildOptionMaps(directOptions, true);
259
+ const normalizedTokens = this.#normalizeArgv(remaining, booleanOptions);
260
+ const finalRemaining = [];
261
+ let i = 0;
262
+ while (i < normalizedTokens.length) {
263
+ const token = normalizedTokens[i];
264
+ if (token.startsWith('--')) {
265
+ const consumed = this.#tryConsumeLongOption(normalizedTokens, i, optionByLong, opts);
266
+ if (consumed > 0) {
267
+ i += consumed;
268
+ continue;
269
+ }
270
+ finalRemaining.push(token);
271
+ i += 1;
272
+ continue;
273
+ }
274
+ if (token.startsWith('-') && token.length > 1) {
275
+ const result = this.#tryConsumeShortOption(normalizedTokens, i, optionByShort, opts);
276
+ if (result.consumed) {
277
+ i = result.nextIdx;
278
+ if (result.remainingToken) {
279
+ finalRemaining.push(result.remainingToken);
280
+ }
281
+ continue;
282
+ }
283
+ finalRemaining.push(token);
284
+ i += 1;
285
+ continue;
286
+ }
287
+ finalRemaining.push(token);
288
+ i += 1;
289
+ }
290
+ for (const opt of directOptions) {
291
+ if (opt.required && opts[opt.long] === undefined) {
292
+ throw new CommanderError('MissingRequired', `missing required option "--${opt.long}" for command "${this.#getCommandPath()}"`, this.#getCommandPath());
293
+ }
294
+ }
295
+ for (const opt of directOptions) {
296
+ if (opt.choices && opts[opt.long] !== undefined) {
297
+ const value = opts[opt.long];
298
+ const values = Array.isArray(value) ? value : [value];
299
+ const choices = opt.choices;
300
+ for (const v of values) {
301
+ if (!choices.includes(v)) {
302
+ throw new CommanderError('InvalidChoice', `invalid value "${v}" for option "--${opt.long}". Allowed: ${opt.choices.join(', ')}`, this.#getCommandPath());
303
+ }
304
+ }
305
+ }
306
+ }
307
+ return { opts, remaining: finalRemaining };
308
+ }
228
309
  formatHelp() {
229
310
  const lines = [];
230
311
  const allOptions = this.#getMergedOptions();
@@ -347,7 +428,8 @@ class Command {
347
428
  }
348
429
  return argv;
349
430
  }
350
- #route(argv) {
431
+ #routeChain(argv) {
432
+ const chain = [this];
351
433
  let current = this;
352
434
  let idx = 0;
353
435
  while (idx < argv.length) {
@@ -358,9 +440,62 @@ class Command {
358
440
  if (!entry)
359
441
  break;
360
442
  current = entry.command;
443
+ chain.push(current);
361
444
  idx += 1;
362
445
  }
363
- return { command: current, remaining: argv.slice(idx) };
446
+ return { chain, remaining: argv.slice(idx) };
447
+ }
448
+ #splitAtDoubleDash(tokens) {
449
+ const ddIdx = tokens.indexOf('--');
450
+ if (ddIdx === -1) {
451
+ return { optionTokens: tokens, restArgs: [] };
452
+ }
453
+ return {
454
+ optionTokens: tokens.slice(0, ddIdx),
455
+ restArgs: tokens.slice(ddIdx + 1),
456
+ };
457
+ }
458
+ #shiftChain(chain, tokens) {
459
+ const optsMap = new Map();
460
+ let remaining = [...tokens];
461
+ const shadowed = new Set();
462
+ for (let i = chain.length - 1; i >= 0; i--) {
463
+ const cmd = chain[i];
464
+ const result = cmd.#shiftWithShadowed(remaining, shadowed);
465
+ optsMap.set(cmd, result.opts);
466
+ remaining = result.remaining;
467
+ for (const opt of cmd.#options) {
468
+ shadowed.add(opt.long);
469
+ }
470
+ }
471
+ if (remaining.length > 0) {
472
+ const leafCommand = chain[chain.length - 1];
473
+ const firstToken = remaining[0];
474
+ if (firstToken.startsWith('-')) {
475
+ throw new CommanderError('UnknownOption', `unknown option "${firstToken}" for command "${leafCommand.#getCommandPath()}"`, leafCommand.#getCommandPath());
476
+ }
477
+ else {
478
+ throw new CommanderError('UnexpectedArgument', `unexpected argument "${firstToken}". Positional arguments must come after "--"`, leafCommand.#getCommandPath());
479
+ }
480
+ }
481
+ return optsMap;
482
+ }
483
+ #applyChain(chain, optsMap, ctx) {
484
+ for (const cmd of chain) {
485
+ const opts = optsMap.get(cmd) ?? {};
486
+ for (const opt of cmd.#getMergedOptions()) {
487
+ if (opt.apply && opts[opt.long] !== undefined) {
488
+ opt.apply(opts[opt.long], ctx);
489
+ }
490
+ }
491
+ }
492
+ }
493
+ #mergeOpts(chain, optsMap) {
494
+ const merged = {};
495
+ for (const cmd of chain) {
496
+ Object.assign(merged, optsMap.get(cmd) ?? {});
497
+ }
498
+ return merged;
364
499
  }
365
500
  #parseLongOption(argv, idx, optionByLong, opts) {
366
501
  const token = argv[idx];
@@ -523,9 +658,6 @@ class Command {
523
658
  }
524
659
  }
525
660
  }
526
- #isBuiltinOption(opt) {
527
- return opt === BUILTIN_HELP_OPTION || opt === BUILTIN_VERSION_OPTION;
528
- }
529
661
  #buildOptionMaps(allOptions, excludeResolver = false) {
530
662
  const optionByLong = new Map();
531
663
  const optionByShort = new Map();
@@ -622,6 +754,100 @@ class Command {
622
754
  #getCommandPath() {
623
755
  return this.#name;
624
756
  }
757
+ #tryConsumeLongOption(tokens, idx, optionByLong, opts) {
758
+ const token = tokens[idx];
759
+ const eqIdx = token.indexOf('=');
760
+ let optName;
761
+ let inlineValue;
762
+ if (eqIdx !== -1) {
763
+ optName = token.slice(2, eqIdx);
764
+ inlineValue = token.slice(eqIdx + 1);
765
+ }
766
+ else {
767
+ optName = token.slice(2);
768
+ }
769
+ const opt = optionByLong.get(optName);
770
+ if (!opt) {
771
+ return 0;
772
+ }
773
+ if (opt.type === 'boolean') {
774
+ if (inlineValue !== undefined) {
775
+ if (inlineValue === 'true') {
776
+ opts[optName] = true;
777
+ }
778
+ else if (inlineValue === 'false') {
779
+ opts[optName] = false;
780
+ }
781
+ else {
782
+ throw new CommanderError('InvalidBooleanValue', `invalid value "${inlineValue}" for boolean option "--${optName}". Use "true" or "false"`, this.#getCommandPath());
783
+ }
784
+ }
785
+ else {
786
+ opts[optName] = true;
787
+ }
788
+ return 1;
789
+ }
790
+ let value;
791
+ let consumed = 1;
792
+ if (inlineValue !== undefined) {
793
+ value = inlineValue;
794
+ }
795
+ else if (idx + 1 < tokens.length) {
796
+ value = tokens[idx + 1];
797
+ consumed = 2;
798
+ }
799
+ else {
800
+ throw new CommanderError('MissingValue', `option "--${optName}" requires a value`, this.#getCommandPath());
801
+ }
802
+ this.#applyValue(opt, value, opts);
803
+ return consumed;
804
+ }
805
+ #tryConsumeShortOption(tokens, idx, optionByShort, opts) {
806
+ const token = tokens[idx];
807
+ if (token.includes('=')) {
808
+ const firstFlag = token[1];
809
+ if (!optionByShort.has(firstFlag)) {
810
+ return { consumed: false, nextIdx: idx + 1 };
811
+ }
812
+ throw new CommanderError('UnsupportedShortSyntax', `"-${token.slice(1)}" is not supported. Use "-${token[1]} ${token.slice(3)}" instead`, this.#getCommandPath());
813
+ }
814
+ const flags = token.slice(1);
815
+ let j = 0;
816
+ const consumedFlags = [];
817
+ const unconsumedFlags = [];
818
+ let nextIdx = idx + 1;
819
+ while (j < flags.length) {
820
+ const flag = flags[j];
821
+ const opt = optionByShort.get(flag);
822
+ if (!opt) {
823
+ unconsumedFlags.push(...flags.slice(j).split(''));
824
+ break;
825
+ }
826
+ consumedFlags.push(flag);
827
+ if (opt.type === 'boolean') {
828
+ opts[opt.long] = true;
829
+ j += 1;
830
+ continue;
831
+ }
832
+ if (j < flags.length - 1) {
833
+ throw new CommanderError('UnsupportedShortSyntax', `"-${flags}" is not supported. Use "-${flags.slice(0, j + 1)} ${flags.slice(j + 1)}" or separate options`, this.#getCommandPath());
834
+ }
835
+ if (idx + 1 < tokens.length && !tokens[idx + 1].startsWith('-')) {
836
+ const value = tokens[idx + 1];
837
+ this.#applyValue(opt, value, opts);
838
+ nextIdx = idx + 2;
839
+ }
840
+ else {
841
+ throw new CommanderError('MissingValue', `option "-${flag}" requires a value`, this.#getCommandPath());
842
+ }
843
+ j += 1;
844
+ }
845
+ if (consumedFlags.length > 0) {
846
+ const remainingToken = unconsumedFlags.length > 0 ? `-${unconsumedFlags.join('')}` : undefined;
847
+ return { consumed: true, nextIdx, remainingToken };
848
+ }
849
+ return { consumed: false, nextIdx: idx + 1 };
850
+ }
625
851
  }
626
852
 
627
853
  class CompletionCommand extends Command {
@@ -112,8 +112,15 @@ interface IParseResult {
112
112
  /** Parsed positional arguments */
113
113
  args: string[];
114
114
  }
115
+ /** shift() method result */
116
+ interface IShiftResult {
117
+ /** Options consumed by this command */
118
+ opts: Record<string, unknown>;
119
+ /** Tokens not consumed, to be passed to parent */
120
+ remaining: string[];
121
+ }
115
122
  /** Error kinds for command parsing */
116
- type ICommanderErrorKind = 'UnknownOption' | 'MissingValue' | 'InvalidType' | 'UnsupportedShortSyntax' | 'OptionConflict' | 'MissingRequired' | 'InvalidChoice' | 'InvalidBooleanValue' | 'MissingRequiredArgument' | 'ConfigurationError';
123
+ type ICommanderErrorKind = 'UnknownOption' | 'UnexpectedArgument' | 'MissingValue' | 'InvalidType' | 'UnsupportedShortSyntax' | 'OptionConflict' | 'MissingRequired' | 'InvalidChoice' | 'InvalidBooleanValue' | 'MissingRequiredArgument' | 'ConfigurationError';
117
124
  /** Commander error with structured information */
118
125
  declare class CommanderError extends Error {
119
126
  readonly kind: ICommanderErrorKind;
@@ -177,6 +184,11 @@ declare class Command implements ICommand {
177
184
  subcommand(name: string, cmd: Command): this;
178
185
  run(params: IRunParams): Promise<void>;
179
186
  parse(argv: string[]): IParseResult;
187
+ /**
188
+ * Shift options from tokens that this command recognizes.
189
+ * Unrecognized tokens are returned in `remaining` for parent commands.
190
+ */
191
+ shift(tokens: string[]): IShiftResult;
180
192
  formatHelp(): string;
181
193
  getCompletionMeta(): ICompletionMeta;
182
194
  }
@@ -227,4 +239,4 @@ declare class PwshCompletion {
227
239
  }
228
240
 
229
241
  export { BashCompletion, Command, CommanderError, CompletionCommand, FishCompletion, PwshCompletion };
230
- export type { IAction, IActionParams, IArgument, IArgumentKind, ICommand, ICommandConfig, ICommandContext, ICommanderErrorKind, ICompletionCommandConfig, ICompletionMeta, ICompletionOptionMeta, ICompletionPaths, IOption, IOptionType, IParseResult, IReporter, IRunParams, IShellType };
242
+ export type { IAction, IActionParams, IArgument, IArgumentKind, ICommand, ICommandConfig, ICommandContext, ICommanderErrorKind, ICompletionCommandConfig, ICompletionMeta, ICompletionOptionMeta, ICompletionPaths, IOption, IOptionType, IParseResult, IReporter, IRunParams, IShellType, IShiftResult };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanghechen/commander",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "A minimal, type-safe command-line interface builder with fluent API",
5
5
  "author": {
6
6
  "name": "guanghechen",