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
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,32 @@ opt.count(); // -vvv → 3
| `hidden` | `boolean` | Hide from `--help` output |
| `required` | `boolean` | Mark as required (makes the option non-nullable) |

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

All boolean options automatically support a negated form `--no-<flag>` to explicitly set the option to `false`:

```shell
$ my-cli --verbose # verbose: true
$ my-cli --no-verbose # verbose: false
$ my-cli # verbose: undefined (or default)
```

If both `--flag` and `--no-flag` are specified, bargs throws an error:

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

In help output, booleans with `default: true` display as `--no-<flag>` (since that's how users would turn them off):

```typescript
opt.options({
colors: opt.boolean({ default: true, description: 'Use colors' }),
});
// Help output shows: --no-colors Use colors [boolean] default: true
```

### `opt.options(schema)`

Create a parser from an options schema:
Expand Down Expand Up @@ -480,6 +506,36 @@ const globals = map(
);
```

### CamelCase Option Keys

If you prefer camelCase property names instead of kebab-case, use the `camelCaseValues` transform:

```typescript
import { bargs, map, opt, camelCaseValues } from '@boneskull/bargs';

const { values } = await bargs
.create('my-cli')
.globals(
map(
opt.options({
'output-dir': opt.string({ default: '/tmp' }),
'dry-run': opt.boolean(),
}),
camelCaseValues,
),
)
.parseAsync(['--output-dir', './dist', '--dry-run']);

console.log(values.outputDir); // './dist'
console.log(values.dryRun); // true
```

The `camelCaseValues` transform:

- Converts all kebab-case keys to camelCase (`output-dir` → `outputDir`)
- Preserves keys that are already camelCase or have no hyphens
- Is fully type-safe—TypeScript knows the transformed key names

## Epilog

By default, **bargs** displays your package's homepage and repository URLs (from `package.json`) at the end of help output. URLs become clickable hyperlinks in supported terminals.
Expand Down
15 changes: 10 additions & 5 deletions examples/transforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* Demonstrates how to use map() transforms:
*
* - Global transforms via map() applied to globals parser
* - Using camelCaseValues to convert kebab-case to camelCase
* - Command-specific transforms via map() in command parsers
* - Computed/derived values flowing through handlers
* - Full type inference with the (Parser, handler) API
Expand All @@ -15,7 +16,7 @@
*/
import { existsSync, readFileSync } from 'node:fs';

import { bargs, map, opt, pos } from '../src/index.js';
import { bargs, camelCaseValues, map, opt, pos } from '../src/index.js';

// ═══════════════════════════════════════════════════════════════════════════════
// CONFIG TYPE
Expand All @@ -31,15 +32,18 @@ interface Config {
// GLOBAL OPTIONS WITH TRANSFORM
// ═══════════════════════════════════════════════════════════════════════════════

// Global options with transform that loads config from file
// Global options using kebab-case (CLI-friendly)
const baseGlobals = opt.options({
config: opt.string(),
outputDir: opt.string(),
'output-dir': opt.string(), // CLI: --output-dir
verbose: opt.boolean({ default: false }),
});

// Apply transform to add computed properties using map(parser, fn) form
const globals = map(baseGlobals, ({ positionals, values }) => {
// First, convert kebab-case to camelCase for ergonomic property access
const camelGlobals = map(baseGlobals, camelCaseValues);

// Then apply additional transforms for computed properties
const globals = map(camelGlobals, ({ positionals, values }) => {
let fileConfig: Config = {};

// Load config from JSON file if specified
Expand All @@ -49,6 +53,7 @@ const globals = map(baseGlobals, ({ positionals, values }) => {
}

// Return enriched values with file config merged in
// Note: values.outputDir is now camelCase thanks to camelCaseValues!
return {
positionals,
values: {
Expand Down
80 changes: 76 additions & 4 deletions src/bargs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

import type {
CamelCaseKeys,
CliBuilder,
Command,
CreateOptions,
Expand Down Expand Up @@ -155,30 +156,59 @@ export function map<
parserOrFn: Parser<V1, P1> | TransformFn<V1, P1, V2, P2>,
maybeFn?: TransformFn<V1, P1, V2, P2>,
): ((parser: Parser<V1, P1>) => Parser<V2, P2>) | Parser<V2, P2> {
// Helper to compose transforms (chains existing + new)
const composeTransform = (
parser: Parser<V1, P1>,
fn: TransformFn<V1, P1, V2, P2>,
): TransformFn<unknown, readonly unknown[], V2, P2> => {
const existing = (
parser as {
__transform?: (
r: ParseResult<unknown, readonly unknown[]>,
) => ParseResult<V1, P1> | Promise<ParseResult<V1, P1>>;
}
).__transform;

if (!existing) {
return fn as TransformFn<unknown, readonly unknown[], V2, P2>;
}

// Chain: existing transform first, then new transform
return (r: ParseResult<unknown, readonly unknown[]>) => {
const r1 = existing(r);
if (r1 instanceof Promise) {
return r1.then(fn);
}
return fn(r1);
};
};

// Direct form: map(parser, fn) returns Parser
// Check for Parser first since CallableParser is also a function
if (isParser(parserOrFn)) {
const parser = parserOrFn;
const fn = maybeFn!;
const composedTransform = composeTransform(parser, fn);
return {
...parser,
__brand: 'Parser',
__positionals: [] as unknown as P2,
__transform: fn,
__transform: composedTransform,
__values: {} as V2,
} as Parser<V2, P2> & { __transform: typeof fn };
} as Parser<V2, P2> & { __transform: typeof composedTransform };
}

// Curried form: map(fn) returns (parser) => Parser
const fn = parserOrFn;
return (parser: Parser<V1, P1>): Parser<V2, P2> => {
const composedTransform = composeTransform(parser, fn);
return {
...parser,
__brand: 'Parser',
__positionals: [] as unknown as P2,
__transform: fn,
__transform: composedTransform,
__values: {} as V2,
} as Parser<V2, P2> & { __transform: typeof fn };
} as Parser<V2, P2> & { __transform: typeof composedTransform };
};
}
/**
Expand Down Expand Up @@ -307,6 +337,48 @@ export function merge(

return result;
}

// ═══════════════════════════════════════════════════════════════════════════════
// CAMEL CASE HELPER
// ═══════════════════════════════════════════════════════════════════════════════

/**
* Convert kebab-case string to camelCase.
*/
const kebabToCamel = (s: string): string =>
s.replace(/-([a-zA-Z])/g, (_, c: string) => c.toUpperCase());

/**
* Transform for use with `map()` that converts kebab-case option keys to
* camelCase.
*
* @example
*
* ```typescript
* import { bargs, opt, map, camelCaseValues } from '@boneskull/bargs';
*
* const { values } = await bargs
* .create('my-cli')
* .globals(
* map(opt.options({ 'output-dir': opt.string() }), camelCaseValues),
* )
* .parseAsync();
*
* console.log(values.outputDir); // camelCased!
* ```
*/
export const camelCaseValues = <V, P extends readonly unknown[]>(
result: ParseResult<V, P>,
): ParseResult<CamelCaseKeys<V>, P> => ({
...result,
values: Object.fromEntries(
Object.entries(result.values as Record<string, unknown>).map(([k, v]) => [
kebabToCamel(k),
v,
]),
) as CamelCaseKeys<V>,
});

// ═══════════════════════════════════════════════════════════════════════════════
// CLI BUILDER
// ═══════════════════════════════════════════════════════════════════════════════
Expand Down
16 changes: 14 additions & 2 deletions src/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,9 @@ const getTypeLabel = (def: OptionDef): string => {

/**
* Format a single option for help output.
*
* For boolean options with `default: true`, shows `--no-<name>` instead of
* `--<name>` since that's how users would turn it off.
*/
const formatOptionHelp = (
name: string,
Expand All @@ -180,9 +183,18 @@ const formatOptionHelp = (
): string => {
const parts: string[] = [];

// Build flag string: -v, --verbose
// For boolean options with default: true, show --no-<name>
// since that's how users would turn it off
const displayName =
def.type === 'boolean' && def.default === true ? `no-${name}` : name;

// Build flag string: -v, --verbose (or --no-verbose for default:true booleans)
const shortAlias = def.aliases?.find((a) => a.length === 1);
const flagText = shortAlias ? `-${shortAlias}, --${name}` : ` --${name}`;
// Don't show short alias for negated booleans
const flagText =
shortAlias && displayName === name
? `-${shortAlias}, --${displayName}`
: ` --${displayName}`;
parts.push(` ${styler.flag(flagText)}`);

// Pad to align descriptions
Expand Down
5 changes: 4 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
*/

// Main API
export { bargs, handle, map, merge } from './bargs.js';
export { bargs, camelCaseValues, handle, map, merge } from './bargs.js';
export type { TransformFn } from './bargs.js';

// Errors
Expand Down Expand Up @@ -67,6 +67,8 @@ export type {
// Option definitions
ArrayOption,
BooleanOption,
// CamelCase utilities
CamelCaseKeys,
// Parser combinator types
CliBuilder,
CliResult,
Expand All @@ -86,6 +88,7 @@ export type {
InferPositionals,
InferTransformedPositionals,
InferTransformedValues,
KebabToCamel,
NumberOption,
NumberPositional,
OptionDef,
Expand Down
57 changes: 56 additions & 1 deletion src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,13 @@ import type {
PositionalsSchema,
} from './types.js';

import { HelpError } from './errors.js';

/**
* Build parseArgs options config from our options schema.
*
* For boolean options, also adds `no-<name>` variants to support explicit
* negation (e.g., `--no-verbose` sets `verbose` to `false`).
*/
const buildParseArgsConfig = (
schema: OptionsSchema,
Expand Down Expand Up @@ -55,6 +60,11 @@ const buildParseArgsConfig = (
}

config[name] = opt;

// For boolean options, add negated form (--no-<name>)
if (def.type === 'boolean') {
config[`no-${name}`] = { type: 'boolean' };
}
}

return config;
Expand Down Expand Up @@ -183,6 +193,45 @@ const coercePositionals = (
return result;
};

/**
* Process negated boolean options (--no-<name>).
*
* - If `--no-<name>` is true and `--<name>` is not set, sets `<name>` to false
* - If both `--<name>` and `--no-<name>` are set, throws an error
* - Removes all `no-<name>` keys from the result
*/
const processNegatedBooleans = (
values: Record<string, unknown>,
schema: OptionsSchema,
): Record<string, unknown> => {
const result = { ...values };

for (const [name, def] of Object.entries(schema)) {
if (def.type !== 'boolean') {
continue;
}

const negatedKey = `no-${name}`;
const hasPositive = result[name] === true;
const hasNegative = result[negatedKey] === true;

if (hasPositive && hasNegative) {
throw new HelpError(
`Conflicting options: --${name} and --${negatedKey} cannot both be specified`,
);
}

if (hasNegative && !hasPositive) {
result[name] = false;
}

// Always remove the negated key from result
delete result[negatedKey];
}

return result;
};

/**
* Options for parseSimple.
*/
Expand Down Expand Up @@ -221,8 +270,14 @@ export const parseSimple = <
strict: true,
});

// Process negated boolean options (--no-<flag>)
const processedValues = processNegatedBooleans(
values as Record<string, unknown>,
optionsSchema,
);

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

return {
Expand Down
Loading