Skip to content

Commit 4a77c03

Browse files
boneskullclaude
andauthored
feat: support multi-character aliases (#19)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 60ce770 commit 4a77c03

10 files changed

Lines changed: 609 additions & 25 deletions

File tree

README.md

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ import { opt } from '@boneskull/bargs';
338338
opt.string({ default: 'value' }); // --name value
339339
opt.number({ default: 42 }); // --count 42
340340
opt.boolean({ aliases: ['v'] }); // --verbose, -v
341+
opt.boolean({ aliases: ['v', 'verb'] }); // --verbose, --verb, -v
341342
opt.enum(['a', 'b', 'c']); // --level a
342343
opt.array('string'); // --file x --file y
343344
opt.array(['low', 'medium', 'high']); // --priority low --priority high
@@ -346,14 +347,57 @@ opt.count(); // -vvv → 3
346347

347348
### Option Properties
348349

349-
| Property | Type | Description |
350-
| ------------- | ---------- | ------------------------------------------------ |
351-
| `aliases` | `string[]` | Short flags (e.g., `['v']` for `-v`) |
352-
| `default` | varies | Default value (makes the option non-nullable) |
353-
| `description` | `string` | Help text description |
354-
| `group` | `string` | Groups options under a custom section header |
355-
| `hidden` | `boolean` | Hide from `--help` output |
356-
| `required` | `boolean` | Mark as required (makes the option non-nullable) |
350+
| Property | Type | Description |
351+
| ------------- | ---------- | ------------------------------------------------------------------ |
352+
| `aliases` | `string[]` | Short (`['v']` for `-v`) or long aliases (`['verb']` for `--verb`) |
353+
| `default` | varies | Default value (makes the option non-nullable) |
354+
| `description` | `string` | Help text description |
355+
| `group` | `string` | Groups options under a custom section header |
356+
| `hidden` | `boolean` | Hide from `--help` output |
357+
| `required` | `boolean` | Mark as required (makes the option non-nullable) |
358+
359+
### Aliases
360+
361+
Options can have both short (single-character) and long (multi-character) aliases:
362+
363+
```typescript
364+
opt.options({
365+
verbose: opt.boolean({ aliases: ['v', 'verb'] }),
366+
output: opt.string({ aliases: ['o', 'out'] }),
367+
});
368+
```
369+
370+
All of these are equivalent:
371+
372+
```shell
373+
$ my-cli -v # verbose: true
374+
$ my-cli --verb # verbose: true
375+
$ my-cli --verbose # verbose: true
376+
$ my-cli -o file.txt # output: "file.txt"
377+
$ my-cli --out file.txt # output: "file.txt"
378+
$ my-cli --output file.txt # output: "file.txt"
379+
```
380+
381+
For non-array options, using both an alias and the canonical name throws an error:
382+
383+
```shell
384+
$ my-cli --verb --verbose
385+
Error: Conflicting options: --verb and --verbose cannot both be specified
386+
```
387+
388+
For array options, values from all aliases are merged. Single-character aliases and the canonical name are processed first (in command-line order), then multi-character aliases are appended:
389+
390+
```typescript
391+
opt.options({
392+
files: opt.array('string', { aliases: ['f', 'file'] }),
393+
});
394+
```
395+
396+
```shell
397+
$ my-cli --file a.txt -f b.txt --files c.txt
398+
# files: ["b.txt", "c.txt", "a.txt"]
399+
# (-f and --files first, then --file appended)
400+
```
357401

358402
### Boolean Negation (`--no-<flag>`)
359403

src/help.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,9 @@ const getTypeLabel = (def: OptionDef): string => {
188188
* For boolean options with `default: true`, shows `--no-<name>` instead of
189189
* `--<name>` since that's how users would turn it off.
190190
*
191+
* Displays aliases in order: short alias first (-v), then multi-char aliases
192+
* sorted by length (--verb), then the canonical name (--verbose).
193+
*
191194
* @function
192195
*/
193196
const formatOptionHelp = (
@@ -202,17 +205,33 @@ const formatOptionHelp = (
202205
const displayName =
203206
def.type === 'boolean' && def.default === true ? `no-${name}` : name;
204207

205-
// Build flag string: -v, --verbose (or --no-verbose for default:true booleans)
208+
// Separate short and long aliases
206209
const shortAlias = def.aliases?.find((a) => a.length === 1);
210+
const longAliases = (def.aliases ?? [])
211+
.filter((a) => a.length > 1)
212+
.sort((a, b) => a.length - b.length);
213+
214+
// Build flag string: -v, --verb, --verbose
207215
// Don't show short alias for negated booleans
216+
const flagParts: string[] = [];
217+
if (shortAlias && displayName === name) {
218+
flagParts.push(`-${shortAlias}`);
219+
}
220+
for (const alias of longAliases) {
221+
flagParts.push(`--${alias}`);
222+
}
223+
flagParts.push(`--${displayName}`);
224+
225+
// If no short alias and no long aliases, add padding
208226
const flagText =
209-
shortAlias && displayName === name
210-
? `-${shortAlias}, --${displayName}`
211-
: ` --${displayName}`;
227+
flagParts.length === 1 && !shortAlias
228+
? ` ${flagParts[0]}`
229+
: flagParts.join(', ');
212230
parts.push(` ${styler.flag(flagText)}`);
213231

214-
// Pad to align descriptions
215-
const padding = Math.max(0, 24 - flagText.length - 2);
232+
// Pad to align descriptions (increase base padding for longer alias chains)
233+
const basePadding = Math.max(24, flagText.length + 4);
234+
const padding = Math.max(0, basePadding - flagText.length - 2);
216235
parts.push(' '.repeat(padding));
217236

218237
// Description

src/opt.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,23 +34,54 @@ import { BargsError } from './errors.js';
3434
/**
3535
* Validate that no alias conflicts exist in a merged options schema.
3636
*
37+
* Checks for:
38+
*
39+
* - Duplicate aliases across options
40+
* - Aliases that conflict with canonical option names
41+
* - Aliases that conflict with auto-generated boolean negation names
42+
* (--no-<name>)
43+
*
3744
* @function
3845
*/
3946
const validateAliasConflicts = (schema: OptionsSchema): void => {
4047
const aliasToOption = new Map<string, string>();
48+
const canonicalNames = new Set(Object.keys(schema));
49+
50+
// Collect auto-generated boolean negation names (--no-<name>)
51+
const booleanNegations = new Set<string>();
52+
for (const [name, def] of Object.entries(schema)) {
53+
if (def.type === 'boolean') {
54+
booleanNegations.add(`no-${name}`);
55+
}
56+
}
4157

4258
for (const [optionName, def] of Object.entries(schema)) {
4359
if (!def.aliases) {
4460
continue;
4561
}
4662

4763
for (const alias of def.aliases) {
64+
// Check for duplicate aliases
4865
const existing = aliasToOption.get(alias);
4966
if (existing && existing !== optionName) {
5067
throw new BargsError(
5168
`Alias conflict: "-${alias}" is used by both "--${existing}" and "--${optionName}"`,
5269
);
5370
}
71+
// Check for conflicts with canonical option names
72+
if (canonicalNames.has(alias)) {
73+
throw new BargsError(
74+
`Alias conflict: "--${alias}" conflicts with an existing option name`,
75+
);
76+
}
77+
// Check for conflicts with auto-generated boolean negations
78+
if (booleanNegations.has(alias)) {
79+
// alias is "no-<name>", so extract the original option name
80+
const originalOption = alias.replace(/^no-/, '');
81+
throw new BargsError(
82+
`Alias conflict: "--${alias}" conflicts with auto-generated boolean negation for "--${originalOption}"`,
83+
);
84+
}
5485
aliasToOption.set(alias, optionName);
5586
}
5687
}

src/parser.ts

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ import { HelpError } from './errors.js';
2828
* For boolean options, also adds `no-<name>` variants to support explicit
2929
* negation (e.g., `--no-verbose` sets `verbose` to `false`).
3030
*
31+
* Multi-character aliases are registered as separate options with the same type
32+
* and multiple settings, then collapsed back to canonical names after parsing
33+
* via `collapseAliases()`.
34+
*
3135
* @function
3236
*/
3337
const buildParseArgsConfig = (
@@ -42,12 +46,16 @@ const buildParseArgsConfig = (
4246
> = {};
4347

4448
for (const [name, def] of Object.entries(schema)) {
49+
const parseArgsType: 'boolean' | 'string' =
50+
def.type === 'boolean' ? 'boolean' : 'string';
51+
const isMultiple = def.type === 'array';
52+
4553
const opt: {
4654
multiple?: boolean;
4755
short?: string;
4856
type: 'boolean' | 'string';
4957
} = {
50-
type: def.type === 'boolean' ? 'boolean' : 'string',
58+
type: parseArgsType,
5159
};
5260

5361
// First single-char alias becomes short option
@@ -57,13 +65,28 @@ const buildParseArgsConfig = (
5765
}
5866

5967
// Arrays need multiple: true
60-
if (def.type === 'array') {
68+
if (isMultiple) {
6169
opt.multiple = true;
6270
}
6371

6472
config[name] = opt;
6573

74+
// Register multi-character aliases as separate options
75+
for (const alias of def.aliases ?? []) {
76+
if (alias.length > 1) {
77+
const aliasOpt: {
78+
multiple?: boolean;
79+
type: 'boolean' | 'string';
80+
} = { type: parseArgsType };
81+
if (isMultiple) {
82+
aliasOpt.multiple = true;
83+
}
84+
config[alias] = aliasOpt;
85+
}
86+
}
87+
6688
// For boolean options, add negated form (--no-<name>)
89+
// Note: We do NOT add --no-<alias> forms for aliases
6790
if (def.type === 'boolean') {
6891
config[`no-${name}`] = { type: 'boolean' };
6992
}
@@ -240,6 +263,64 @@ const processNegatedBooleans = (
240263
return result;
241264
};
242265

266+
/**
267+
* Collapse multi-character aliases into their canonical option names.
268+
*
269+
* For array options, merges values from all aliases into the canonical name.
270+
* For non-array options, throws HelpError if both alias and canonical were
271+
* provided. Always removes alias keys from the result.
272+
*
273+
* @function
274+
*/
275+
const collapseAliases = (
276+
values: Record<string, unknown>,
277+
schema: OptionsSchema,
278+
): Record<string, unknown> => {
279+
const result = { ...values };
280+
281+
// Build alias-to-canonical mapping (only multi-char aliases)
282+
const aliasToCanonical = new Map<string, string>();
283+
for (const [name, def] of Object.entries(schema)) {
284+
for (const alias of def.aliases ?? []) {
285+
if (alias.length > 1) {
286+
aliasToCanonical.set(alias, name);
287+
}
288+
}
289+
}
290+
291+
// Process each alias found in the values
292+
for (const [alias, canonical] of aliasToCanonical) {
293+
const aliasValue = result[alias];
294+
if (aliasValue === undefined) {
295+
continue;
296+
}
297+
298+
const def = schema[canonical]!;
299+
const canonicalValue = result[canonical];
300+
const isArray = def.type === 'array';
301+
302+
if (isArray) {
303+
// For arrays, merge values
304+
const existingArray = Array.isArray(canonicalValue) ? canonicalValue : [];
305+
const aliasArray = Array.isArray(aliasValue) ? aliasValue : [aliasValue];
306+
result[canonical] = [...existingArray, ...aliasArray];
307+
} else {
308+
// For non-arrays, check for conflict
309+
if (canonicalValue !== undefined) {
310+
throw new HelpError(
311+
`Conflicting options: --${alias} and --${canonical} cannot both be specified`,
312+
);
313+
}
314+
result[canonical] = aliasValue;
315+
}
316+
317+
// Remove the alias key
318+
delete result[alias];
319+
}
320+
321+
return result;
322+
};
323+
243324
/**
244325
* Options for parseSimple.
245326
*/
@@ -286,8 +367,11 @@ export const parseSimple = <
286367
optionsSchema,
287368
);
288369

370+
// Collapse multi-character aliases into canonical names
371+
const collapsedValues = collapseAliases(processedValues, optionsSchema);
372+
289373
// Coerce and apply defaults
290-
const coercedValues = coerceValues(processedValues, optionsSchema);
374+
const coercedValues = coerceValues(collapsedValues, optionsSchema);
291375
const coercedPositionals = coercePositionals(positionals, positionalsSchema);
292376

293377
return {

src/types.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -570,7 +570,18 @@ export interface VariadicPositional extends PositionalBase {
570570
* Base properties shared by all option definitions.
571571
*/
572572
interface OptionBase {
573-
/** Aliases for this option (e.g., ['v'] for --verbose) */
573+
/**
574+
* Short or long aliases for this option.
575+
*
576+
* - Single-character aliases (e.g., `'v'`) become short flags (`-v`)
577+
* - Multi-character aliases (e.g., `'verb'`) become long flags (`--verb`)
578+
*
579+
* @example
580+
*
581+
* ```typescript
582+
* opt.boolean({ aliases: ['v', 'verb'] }); // -v, --verb, --verbose
583+
* ```
584+
*/
574585
aliases?: string[];
575586
/** Option description displayed in help text */
576587
description?: string;

0 commit comments

Comments
 (0)