Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 52 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ import { opt } from '@boneskull/bargs';
opt.string({ default: 'value' }); // --name value
opt.number({ default: 42 }); // --count 42
opt.boolean({ aliases: ['v'] }); // --verbose, -v
opt.boolean({ aliases: ['v', 'verb'] }); // --verbose, --verb, -v
opt.enum(['a', 'b', 'c']); // --level a
opt.array('string'); // --file x --file y
opt.array(['low', 'medium', 'high']); // --priority low --priority high
Expand All @@ -346,14 +347,57 @@ opt.count(); // -vvv → 3

### Option Properties

| Property | Type | Description |
| ------------- | ---------- | ------------------------------------------------ |
| `aliases` | `string[]` | Short flags (e.g., `['v']` for `-v`) |
| `default` | varies | Default value (makes the option non-nullable) |
| `description` | `string` | Help text description |
| `group` | `string` | Groups options under a custom section header |
| `hidden` | `boolean` | Hide from `--help` output |
| `required` | `boolean` | Mark as required (makes the option non-nullable) |
| Property | Type | Description |
| ------------- | ---------- | ------------------------------------------------------------------ |
| `aliases` | `string[]` | Short (`['v']` for `-v`) or long aliases (`['verb']` for `--verb`) |
| `default` | varies | Default value (makes the option non-nullable) |
| `description` | `string` | Help text description |
| `group` | `string` | Groups options under a custom section header |
| `hidden` | `boolean` | Hide from `--help` output |
| `required` | `boolean` | Mark as required (makes the option non-nullable) |

### Aliases

Options can have both short (single-character) and long (multi-character) aliases:

```typescript
opt.options({
verbose: opt.boolean({ aliases: ['v', 'verb'] }),
output: opt.string({ aliases: ['o', 'out'] }),
});
```

All of these are equivalent:

```shell
$ my-cli -v # verbose: true
$ my-cli --verb # verbose: true
$ my-cli --verbose # verbose: true
$ my-cli -o file.txt # output: "file.txt"
$ my-cli --out file.txt # output: "file.txt"
$ my-cli --output file.txt # output: "file.txt"
```

For non-array options, using both an alias and the canonical name throws an error:

```shell
$ my-cli --verb --verbose
Error: Conflicting options: --verb and --verbose cannot both be specified
```

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:

```typescript
opt.options({
files: opt.array('string', { aliases: ['f', 'file'] }),
});
```

```shell
$ my-cli --file a.txt -f b.txt --files c.txt
# files: ["b.txt", "c.txt", "a.txt"]
# (-f and --files first, then --file appended)
```

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

Expand Down
31 changes: 25 additions & 6 deletions src/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,9 @@ const getTypeLabel = (def: OptionDef): string => {
* For boolean options with `default: true`, shows `--no-<name>` instead of
* `--<name>` since that's how users would turn it off.
*
* Displays aliases in order: short alias first (-v), then multi-char aliases
* sorted by length (--verb), then the canonical name (--verbose).
*
* @function
*/
const formatOptionHelp = (
Expand All @@ -202,17 +205,33 @@ const formatOptionHelp = (
const displayName =
def.type === 'boolean' && def.default === true ? `no-${name}` : name;

// Build flag string: -v, --verbose (or --no-verbose for default:true booleans)
// Separate short and long aliases
const shortAlias = def.aliases?.find((a) => a.length === 1);
const longAliases = (def.aliases ?? [])
.filter((a) => a.length > 1)
.sort((a, b) => a.length - b.length);

// Build flag string: -v, --verb, --verbose
// Don't show short alias for negated booleans
const flagParts: string[] = [];
if (shortAlias && displayName === name) {
flagParts.push(`-${shortAlias}`);
}
for (const alias of longAliases) {
flagParts.push(`--${alias}`);
}
flagParts.push(`--${displayName}`);

// If no short alias and no long aliases, add padding
const flagText =
shortAlias && displayName === name
? `-${shortAlias}, --${displayName}`
: ` --${displayName}`;
flagParts.length === 1 && !shortAlias
? ` ${flagParts[0]}`
: flagParts.join(', ');
parts.push(` ${styler.flag(flagText)}`);

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

// Description
Expand Down
31 changes: 31 additions & 0 deletions src/opt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,23 +34,54 @@ import { BargsError } from './errors.js';
/**
* Validate that no alias conflicts exist in a merged options schema.
*
* Checks for:
*
* - Duplicate aliases across options
* - Aliases that conflict with canonical option names
* - Aliases that conflict with auto-generated boolean negation names
* (--no-<name>)
*
* @function
*/
const validateAliasConflicts = (schema: OptionsSchema): void => {
const aliasToOption = new Map<string, string>();
const canonicalNames = new Set(Object.keys(schema));

// Collect auto-generated boolean negation names (--no-<name>)
const booleanNegations = new Set<string>();
for (const [name, def] of Object.entries(schema)) {
if (def.type === 'boolean') {
booleanNegations.add(`no-${name}`);
}
}

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

for (const alias of def.aliases) {
// Check for duplicate aliases
const existing = aliasToOption.get(alias);
if (existing && existing !== optionName) {
throw new BargsError(
`Alias conflict: "-${alias}" is used by both "--${existing}" and "--${optionName}"`,
);
}
// Check for conflicts with canonical option names
if (canonicalNames.has(alias)) {
throw new BargsError(
`Alias conflict: "--${alias}" conflicts with an existing option name`,
);
}
// Check for conflicts with auto-generated boolean negations
if (booleanNegations.has(alias)) {
// alias is "no-<name>", so extract the original option name
const originalOption = alias.replace(/^no-/, '');
throw new BargsError(
`Alias conflict: "--${alias}" conflicts with auto-generated boolean negation for "--${originalOption}"`,
);
}
aliasToOption.set(alias, optionName);
}
}
Expand Down
90 changes: 87 additions & 3 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ import { HelpError } from './errors.js';
* For boolean options, also adds `no-<name>` variants to support explicit
* negation (e.g., `--no-verbose` sets `verbose` to `false`).
*
* Multi-character aliases are registered as separate options with the same type
* and multiple settings, then collapsed back to canonical names after parsing
* via `collapseAliases()`.
*
* @function
*/
const buildParseArgsConfig = (
Expand All @@ -42,12 +46,16 @@ const buildParseArgsConfig = (
> = {};

for (const [name, def] of Object.entries(schema)) {
const parseArgsType: 'boolean' | 'string' =
def.type === 'boolean' ? 'boolean' : 'string';
const isMultiple = def.type === 'array';

const opt: {
multiple?: boolean;
short?: string;
type: 'boolean' | 'string';
} = {
type: def.type === 'boolean' ? 'boolean' : 'string',
type: parseArgsType,
};

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

// Arrays need multiple: true
if (def.type === 'array') {
if (isMultiple) {
opt.multiple = true;
}

config[name] = opt;

// Register multi-character aliases as separate options
for (const alias of def.aliases ?? []) {
if (alias.length > 1) {
const aliasOpt: {
multiple?: boolean;
type: 'boolean' | 'string';
} = { type: parseArgsType };
if (isMultiple) {
aliasOpt.multiple = true;
}
config[alias] = aliasOpt;
}
}

// For boolean options, add negated form (--no-<name>)
// Note: We do NOT add --no-<alias> forms for aliases
if (def.type === 'boolean') {
config[`no-${name}`] = { type: 'boolean' };
}
Expand Down Expand Up @@ -240,6 +263,64 @@ const processNegatedBooleans = (
return result;
};

/**
* Collapse multi-character aliases into their canonical option names.
*
* For array options, merges values from all aliases into the canonical name.
* For non-array options, throws HelpError if both alias and canonical were
* provided. Always removes alias keys from the result.
*
* @function
*/
const collapseAliases = (
values: Record<string, unknown>,
schema: OptionsSchema,
): Record<string, unknown> => {
const result = { ...values };

// Build alias-to-canonical mapping (only multi-char aliases)
const aliasToCanonical = new Map<string, string>();
for (const [name, def] of Object.entries(schema)) {
for (const alias of def.aliases ?? []) {
if (alias.length > 1) {
aliasToCanonical.set(alias, name);
}
}
}

// Process each alias found in the values
for (const [alias, canonical] of aliasToCanonical) {
const aliasValue = result[alias];
if (aliasValue === undefined) {
continue;
}

const def = schema[canonical]!;
const canonicalValue = result[canonical];
const isArray = def.type === 'array';

if (isArray) {
// For arrays, merge values
const existingArray = Array.isArray(canonicalValue) ? canonicalValue : [];
const aliasArray = Array.isArray(aliasValue) ? aliasValue : [aliasValue];
result[canonical] = [...existingArray, ...aliasArray];
} else {
// For non-arrays, check for conflict
if (canonicalValue !== undefined) {
throw new HelpError(
`Conflicting options: --${alias} and --${canonical} cannot both be specified`,
);
}
result[canonical] = aliasValue;
}

// Remove the alias key
delete result[alias];
}

return result;
};

/**
* Options for parseSimple.
*/
Expand Down Expand Up @@ -286,8 +367,11 @@ export const parseSimple = <
optionsSchema,
);

// Collapse multi-character aliases into canonical names
const collapsedValues = collapseAliases(processedValues, optionsSchema);

// Coerce and apply defaults
const coercedValues = coerceValues(processedValues, optionsSchema);
const coercedValues = coerceValues(collapsedValues, optionsSchema);
const coercedPositionals = coercePositionals(positionals, positionalsSchema);

return {
Expand Down
13 changes: 12 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -570,7 +570,18 @@ export interface VariadicPositional extends PositionalBase {
* Base properties shared by all option definitions.
*/
interface OptionBase {
/** Aliases for this option (e.g., ['v'] for --verbose) */
/**
* Short or long aliases for this option.
*
* - Single-character aliases (e.g., `'v'`) become short flags (`-v`)
* - Multi-character aliases (e.g., `'verb'`) become long flags (`--verb`)
*
* @example
*
* ```typescript
* opt.boolean({ aliases: ['v', 'verb'] }); // -v, --verb, --verbose
* ```
*/
aliases?: string[];
/** Option description displayed in help text */
description?: string;
Expand Down
Loading