Skip to content

Commit ed83b73

Browse files
boneskullclaude
andcommitted
feat: add camelCaseValues transform for kebab-to-camel option keys
Adds a transform helper for use with map() that converts kebab-case option keys to camelCase in the result values: const parser = map( opt.options({ 'output-dir': opt.string() }), camelCaseValues, ); // --output-dir ./dist → values.outputDir Also fixes map() to properly compose transforms when chaining multiple map() calls (was previously overwriting instead of chaining). Includes: - camelCaseValues transform function - KebabToCamel and CamelCaseKeys type utilities - Tests for the new functionality - README documentation - Updated transforms.ts example to demonstrate usage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 0874d08 commit ed83b73

6 files changed

Lines changed: 220 additions & 19 deletions

File tree

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,36 @@ const globals = map(
480480
);
481481
```
482482

483+
### CamelCase Option Keys
484+
485+
If you prefer camelCase property names instead of kebab-case, use the `camelCaseValues` transform:
486+
487+
```typescript
488+
import { bargs, map, opt, camelCaseValues } from '@boneskull/bargs';
489+
490+
const { values } = await bargs
491+
.create('my-cli')
492+
.globals(
493+
map(
494+
opt.options({
495+
'output-dir': opt.string({ default: '/tmp' }),
496+
'dry-run': opt.boolean(),
497+
}),
498+
camelCaseValues,
499+
),
500+
)
501+
.parseAsync(['--output-dir', './dist', '--dry-run']);
502+
503+
console.log(values.outputDir); // './dist'
504+
console.log(values.dryRun); // true
505+
```
506+
507+
The `camelCaseValues` transform:
508+
509+
- Converts all kebab-case keys to camelCase (`output-dir``outputDir`)
510+
- Preserves keys that are already camelCase or have no hyphens
511+
- Is fully type-safe—TypeScript knows the transformed key names
512+
483513
## Epilog
484514

485515
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.

examples/transforms.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* Demonstrates how to use map() transforms:
66
*
77
* - Global transforms via map() applied to globals parser
8+
* - Using camelCaseValues to convert kebab-case to camelCase
89
* - Command-specific transforms via map() in command parsers
910
* - Computed/derived values flowing through handlers
1011
* - Full type inference with the (Parser, handler) API
@@ -15,7 +16,7 @@
1516
*/
1617
import { existsSync, readFileSync } from 'node:fs';
1718

18-
import { bargs, map, opt, pos } from '../src/index.js';
19+
import { bargs, camelCaseValues, map, opt, pos } from '../src/index.js';
1920

2021
// ═══════════════════════════════════════════════════════════════════════════════
2122
// CONFIG TYPE
@@ -31,15 +32,18 @@ interface Config {
3132
// GLOBAL OPTIONS WITH TRANSFORM
3233
// ═══════════════════════════════════════════════════════════════════════════════
3334

34-
// Global options with transform that loads config from file
35+
// Global options using kebab-case (CLI-friendly)
3536
const baseGlobals = opt.options({
3637
config: opt.string(),
37-
outputDir: opt.string(),
38+
'output-dir': opt.string(), // CLI: --output-dir
3839
verbose: opt.boolean({ default: false }),
3940
});
4041

41-
// Apply transform to add computed properties using map(parser, fn) form
42-
const globals = map(baseGlobals, ({ positionals, values }) => {
42+
// First, convert kebab-case to camelCase for ergonomic property access
43+
const camelGlobals = map(baseGlobals, camelCaseValues);
44+
45+
// Then apply additional transforms for computed properties
46+
const globals = map(camelGlobals, ({ positionals, values }) => {
4347
let fileConfig: Config = {};
4448

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

5155
// Return enriched values with file config merged in
56+
// Note: values.outputDir is now camelCase thanks to camelCaseValues!
5257
return {
5358
positionals,
5459
values: {

src/bargs.ts

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
*/
99

1010
import type {
11+
CamelCaseKeys,
1112
CliBuilder,
1213
Command,
1314
CreateOptions,
@@ -155,30 +156,59 @@ export function map<
155156
parserOrFn: Parser<V1, P1> | TransformFn<V1, P1, V2, P2>,
156157
maybeFn?: TransformFn<V1, P1, V2, P2>,
157158
): ((parser: Parser<V1, P1>) => Parser<V2, P2>) | Parser<V2, P2> {
159+
// Helper to compose transforms (chains existing + new)
160+
const composeTransform = (
161+
parser: Parser<V1, P1>,
162+
fn: TransformFn<V1, P1, V2, P2>,
163+
): TransformFn<unknown, readonly unknown[], V2, P2> => {
164+
const existing = (
165+
parser as {
166+
__transform?: (
167+
r: ParseResult<unknown, readonly unknown[]>,
168+
) => ParseResult<V1, P1> | Promise<ParseResult<V1, P1>>;
169+
}
170+
).__transform;
171+
172+
if (!existing) {
173+
return fn as TransformFn<unknown, readonly unknown[], V2, P2>;
174+
}
175+
176+
// Chain: existing transform first, then new transform
177+
return (r: ParseResult<unknown, readonly unknown[]>) => {
178+
const r1 = existing(r);
179+
if (r1 instanceof Promise) {
180+
return r1.then(fn);
181+
}
182+
return fn(r1);
183+
};
184+
};
185+
158186
// Direct form: map(parser, fn) returns Parser
159187
// Check for Parser first since CallableParser is also a function
160188
if (isParser(parserOrFn)) {
161189
const parser = parserOrFn;
162190
const fn = maybeFn!;
191+
const composedTransform = composeTransform(parser, fn);
163192
return {
164193
...parser,
165194
__brand: 'Parser',
166195
__positionals: [] as unknown as P2,
167-
__transform: fn,
196+
__transform: composedTransform,
168197
__values: {} as V2,
169-
} as Parser<V2, P2> & { __transform: typeof fn };
198+
} as Parser<V2, P2> & { __transform: typeof composedTransform };
170199
}
171200

172201
// Curried form: map(fn) returns (parser) => Parser
173202
const fn = parserOrFn;
174203
return (parser: Parser<V1, P1>): Parser<V2, P2> => {
204+
const composedTransform = composeTransform(parser, fn);
175205
return {
176206
...parser,
177207
__brand: 'Parser',
178208
__positionals: [] as unknown as P2,
179-
__transform: fn,
209+
__transform: composedTransform,
180210
__values: {} as V2,
181-
} as Parser<V2, P2> & { __transform: typeof fn };
211+
} as Parser<V2, P2> & { __transform: typeof composedTransform };
182212
};
183213
}
184214
/**
@@ -307,6 +337,48 @@ export function merge(
307337

308338
return result;
309339
}
340+
341+
// ═══════════════════════════════════════════════════════════════════════════════
342+
// CAMEL CASE HELPER
343+
// ═══════════════════════════════════════════════════════════════════════════════
344+
345+
/**
346+
* Convert kebab-case string to camelCase.
347+
*/
348+
const kebabToCamel = (s: string): string =>
349+
s.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
350+
351+
/**
352+
* Transform for use with `map()` that converts kebab-case option keys to
353+
* camelCase.
354+
*
355+
* @example
356+
*
357+
* ```typescript
358+
* import { bargs, opt, map, camelCaseValues } from '@boneskull/bargs';
359+
*
360+
* const { values } = await bargs
361+
* .create('my-cli')
362+
* .globals(
363+
* map(opt.options({ 'output-dir': opt.string() }), camelCaseValues),
364+
* )
365+
* .parseAsync();
366+
*
367+
* console.log(values.outputDir); // camelCased!
368+
* ```
369+
*/
370+
export const camelCaseValues = <V, P extends readonly unknown[]>(
371+
result: ParseResult<V, P>,
372+
): ParseResult<CamelCaseKeys<V>, P> => ({
373+
...result,
374+
values: Object.fromEntries(
375+
Object.entries(result.values as Record<string, unknown>).map(([k, v]) => [
376+
kebabToCamel(k),
377+
v,
378+
]),
379+
) as CamelCaseKeys<V>,
380+
});
381+
310382
// ═══════════════════════════════════════════════════════════════════════════════
311383
// CLI BUILDER
312384
// ═══════════════════════════════════════════════════════════════════════════════

src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
*/
2525

2626
// Main API
27-
export { bargs, handle, map, merge } from './bargs.js';
27+
export { bargs, camelCaseValues, handle, map, merge } from './bargs.js';
2828
export type { TransformFn } from './bargs.js';
2929

3030
// Errors
@@ -67,6 +67,8 @@ export type {
6767
// Option definitions
6868
ArrayOption,
6969
BooleanOption,
70+
// CamelCase utilities
71+
CamelCaseKeys,
7072
// Parser combinator types
7173
CliBuilder,
7274
CliResult,
@@ -86,6 +88,7 @@ export type {
8688
InferPositionals,
8789
InferTransformedPositionals,
8890
InferTransformedValues,
91+
KebabToCamel,
8992
NumberOption,
9093
NumberPositional,
9194
OptionDef,

src/types.ts

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,22 @@ export interface BooleanOption extends OptionBase {
3636
type: 'boolean';
3737
}
3838

39+
/**
40+
* Transform all keys of an object type from kebab-case to camelCase.
41+
*
42+
* Used with `camelCaseValues` to provide type-safe camelCase option keys.
43+
*
44+
* @example
45+
*
46+
* ```typescript
47+
* type Original = { 'output-dir': string; 'dry-run': boolean };
48+
* type Camel = CamelCaseKeys<Original>; // { outputDir: string; dryRun: boolean }
49+
* ```
50+
*/
51+
export type CamelCaseKeys<T> = {
52+
[K in keyof T as KebabToCamel<K & string>]: T[K];
53+
};
54+
3955
/**
4056
* CLI builder for fluent configuration.
4157
*/
@@ -211,6 +227,10 @@ export interface EnumArrayOption<T extends string = string> extends OptionBase {
211227
type: 'array';
212228
}
213229

230+
// ═══════════════════════════════════════════════════════════════════════════════
231+
// POSITIONAL DEFINITIONS
232+
// ═══════════════════════════════════════════════════════════════════════════════
233+
214234
/**
215235
* Enum option definition with string choices.
216236
*/
@@ -220,10 +240,6 @@ export interface EnumOption<T extends string = string> extends OptionBase {
220240
type: 'enum';
221241
}
222242

223-
// ═══════════════════════════════════════════════════════════════════════════════
224-
// POSITIONAL DEFINITIONS
225-
// ═══════════════════════════════════════════════════════════════════════════════
226-
227243
/**
228244
* Enum positional definition with string choices.
229245
*/
@@ -279,6 +295,10 @@ export type InferOption<T extends OptionDef> = T extends BooleanOption
279295
? number
280296
: never;
281297

298+
// ═══════════════════════════════════════════════════════════════════════════════
299+
// CAMELCASE UTILITIES
300+
// ═══════════════════════════════════════════════════════════════════════════════
301+
282302
/**
283303
* Infer values type from an options schema.
284304
*/
@@ -346,10 +366,6 @@ export type InferTransformedPositionals<
346366
: TPositionalsIn
347367
: TPositionalsIn;
348368

349-
// ═══════════════════════════════════════════════════════════════════════════════
350-
// TYPE INFERENCE
351-
// ═══════════════════════════════════════════════════════════════════════════════
352-
353369
/**
354370
* Infer the output values type from a transforms config.
355371
*/
@@ -358,6 +374,24 @@ export type InferTransformedValues<TValuesIn, TTransforms> =
358374
? TOut
359375
: TValuesIn;
360376

377+
// ═══════════════════════════════════════════════════════════════════════════════
378+
// TYPE INFERENCE
379+
// ═══════════════════════════════════════════════════════════════════════════════
380+
381+
/**
382+
* Convert a kebab-case string type to camelCase.
383+
*
384+
* @example
385+
*
386+
* ```typescript
387+
* type Result = KebabToCamel<'output-dir'>; // 'outputDir'
388+
* type Nested = KebabToCamel<'my-long-option'>; // 'myLongOption'
389+
* ```
390+
*/
391+
export type KebabToCamel<S extends string> = S extends `${infer T}-${infer U}`
392+
? `${T}${Capitalize<KebabToCamel<U>>}`
393+
: S;
394+
361395
/**
362396
* Number option definition.
363397
*/

test/combinators.test.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import { expect } from 'bupkis';
55
import { describe, it } from 'node:test';
66

7-
import { handle, map, merge } from '../src/bargs.js';
7+
import { camelCaseValues, handle, map, merge } from '../src/bargs.js';
88
import { opt, pos } from '../src/opt.js';
99

1010
describe('merge()', () => {
@@ -222,3 +222,60 @@ describe('combined usage', () => {
222222
]);
223223
});
224224
});
225+
226+
describe('camelCaseValues()', () => {
227+
it('converts kebab-case keys to camelCase', () => {
228+
const result = camelCaseValues({
229+
positionals: [] as const,
230+
values: {
231+
'dry-run': true,
232+
'output-dir': '/tmp',
233+
verbose: false,
234+
},
235+
});
236+
237+
expect(result.values, 'to satisfy', {
238+
dryRun: true,
239+
outputDir: '/tmp',
240+
verbose: false,
241+
});
242+
// Original keys should NOT exist
243+
expect(result.values, 'not to have key', 'output-dir');
244+
expect(result.values, 'not to have key', 'dry-run');
245+
});
246+
247+
it('handles nested kebab-case', () => {
248+
const result = camelCaseValues({
249+
positionals: [] as const,
250+
values: { 'my-long-option-name': 'value' },
251+
});
252+
253+
expect(result.values, 'to satisfy', { myLongOptionName: 'value' });
254+
});
255+
256+
it('preserves positionals unchanged', () => {
257+
const result = camelCaseValues({
258+
positionals: ['file1', 'file2'] as const,
259+
values: { 'output-dir': '/tmp' },
260+
});
261+
262+
expect(result.positionals, 'to satisfy', ['file1', 'file2']);
263+
});
264+
265+
it('works with map() for type-safe transforms', () => {
266+
const parser = map(
267+
opt.options({
268+
'dry-run': opt.boolean(),
269+
'output-dir': opt.string({ default: '/tmp' }),
270+
}),
271+
camelCaseValues,
272+
);
273+
274+
expect(parser.__brand, 'to be', 'Parser');
275+
expect(parser.__optionsSchema, 'to satisfy', {
276+
'dry-run': { type: 'boolean' },
277+
'output-dir': { type: 'string' },
278+
});
279+
// Note: schema keeps original keys, transform happens at runtime
280+
});
281+
});

0 commit comments

Comments
 (0)