Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
- name: Test
run: |
pnpm test
NODE_OPTIONS='--import=tsx/esm' pnpm run --use-node-version=18.20.3 test
pnpm run --use-node-version=22.22.2 test

- name: Lint
run: pnpm lint
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@
})

// $ my-script --name John --age 20
parsed.flags.name // string | undefined

Check warning on line 50 in README.md

View workflow job for this annotation

GitHub Actions / Test

Expected an assignment or function call and instead saw an expression
parsed.flags.age // number | undefined

Check warning on line 51 in README.md

View workflow job for this annotation

GitHub Actions / Test

Expected an assignment or function call and instead saw an expression
```

Need a short alias?
Expand All @@ -62,7 +62,7 @@
})

// $ my-script -a 20
parsed.flags.age // number | undefined

Check warning on line 65 in README.md

View workflow job for this annotation

GitHub Actions / Test

Expected an assignment or function call and instead saw an expression
```

Only need one flag? Use `getFlag()` to extract a single typed value:
Expand All @@ -73,7 +73,7 @@
const age = getFlag('-a,--age', Number)

// $ my-script --age 20
age // number | undefined

Check warning on line 76 in README.md

View workflow job for this annotation

GitHub Actions / Test

Expected an assignment or function call and instead saw an expression
```

## Why Type-flag?
Expand Down Expand Up @@ -101,10 +101,10 @@
dateFlag: value => new Date(value)
})

parsed.flags.stringFlag // string | undefined

Check warning on line 104 in README.md

View workflow job for this annotation

GitHub Actions / Test

Expected an assignment or function call and instead saw an expression
parsed.flags.numberFlag // number | undefined

Check warning on line 105 in README.md

View workflow job for this annotation

GitHub Actions / Test

Expected an assignment or function call and instead saw an expression
parsed.flags.booleanFlag // boolean | undefined

Check warning on line 106 in README.md

View workflow job for this annotation

GitHub Actions / Test

Expected an assignment or function call and instead saw an expression
parsed.flags.dateFlag // Date | undefined

Check warning on line 107 in README.md

View workflow job for this annotation

GitHub Actions / Test

Expected an assignment or function call and instead saw an expression
```

Use object syntax when a flag needs an alias or default value:
Expand All @@ -118,7 +118,7 @@
}
})

parsed.flags.port // number

Check warning on line 121 in README.md

View workflow job for this annotation

GitHub Actions / Test

Expected an assignment or function call and instead saw an expression
```

To get `undefined` in parsed flag types, enable [`strict`](https://www.typescriptlang.org/tsconfig/#strict) or [`strictNullChecks`](https://www.typescriptlang.org/tsconfig#strictNullChecks).
Expand All @@ -144,7 +144,7 @@
size: Size
})

parsed.flags.size // 'small' | 'medium' | 'large' | undefined

Check warning on line 147 in README.md

View workflow job for this annotation

GitHub Actions / Test

Expected an assignment or function call and instead saw an expression
```

Custom parsers are also useful for richer values:
Expand Down Expand Up @@ -237,6 +237,21 @@
parsed.flags.someString // ['hello', 'world']
```

Acronyms are treated as a single word, so consecutive capitals stay together in the kebab-case form.

```ts
const parsed = typeFlag({
getID: String,
myURL: String,
getHTTPResponse: String
})

// $ node ./cli --get-id 1 --my-url https://example.com --get-http-response foo
parsed.flags.getID // => '1'
parsed.flags.myURL // => 'https://example.com'
parsed.flags.getHTTPResponse // => 'foo'
```

### Unknown Flags And Forwarding

Unknown flags are returned separately instead of being mixed into `flags`.
Expand All @@ -248,6 +263,8 @@
parsed.unknownFlags // { 'some-flag': [true, '1234'] }
```

Unknown flags are not converted to camelCase to allow for accurate error handling.

Wrapper CLIs often need to consume their own flags and pass everything else to another command. If you pass your own argv array, _type-flag_ removes parsed tokens and leaves ignored tokens behind.

Ignored tokens are left in `argv` exactly as passed, so an `ignore` callback can stop parsing at a command name and preserve the full command tail for another parser.
Expand Down Expand Up @@ -344,6 +361,20 @@

These are the supported delimiters; arbitrary delimiter characters are not treated as value separators.

#### Error Wrapping

When a custom type parser throws, the error is wrapped in a `TypeError` whose message identifies the flag by name. The original error is preserved on `.cause`.

```ts
// $ node ./cli --size huge
try {
typeFlag({ size: Size })
} catch {
// thrown TypeError message => 'Flag "--size": Invalid size: "huge"'
// .cause.message => 'Invalid size: "huge"'
}
```

### Boolean Negation

Enable `booleanNegation` to support `--no-` prefixed flags for booleans.
Expand Down
2 changes: 1 addition & 1 deletion examples/count-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* This example demonstrates how to count the number of flags
*
* Usage:
* $ npx tsx ./examples/count-flags -vvv
* $ node --conditions=development ./examples/count-flags.ts -vvv
*/

import { typeFlag } from '#type-flag';
Expand Down
2 changes: 1 addition & 1 deletion examples/custom-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* This example demonstrates how to create a custom type
*
* Usage:
* $ npx tsx ./examples/count-flags --size medium
* $ node --conditions=development ./examples/custom-type.ts --size medium
*/

import { typeFlag } from '#type-flag';
Expand Down
2 changes: 1 addition & 1 deletion examples/dot-nested.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* This example demonstrates how a dot-nested object can be created
*
* Usage:
* $ npx tsx ./examples/dot-nested --env.TOKEN=123 --env.CI
* $ node --conditions=development ./examples/dot-nested.ts --env.TOKEN=123 --env.CI
*/

import { typeFlag } from '#type-flag';
Expand Down
2 changes: 1 addition & 1 deletion examples/invert-boolean.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* This example demonstrates how to invert a boolean flag
*
* Usage:
* $ npx tsx ./examples/invert-boolean --boolean=false
* $ node --conditions=development ./examples/invert-boolean.ts --boolean=false
*/

import { typeFlag } from '#type-flag';
Expand Down
10 changes: 4 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,9 @@
"skills"
],
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
"types": "./dist/index.d.ts",
"require": "./dist/index.cjs",
"import": "./dist/index.mjs"
"default": "./dist/index.mjs"
},
"imports": {
"#type-flag": {
Expand All @@ -49,6 +45,9 @@
"prepare": "skills-npm",
"prepack": "pnpm build && clean-pkg-json"
},
"engines": {
"node": ">=22.22.2"
},
"devDependencies": {
"@types/node": "^24.10.15",
"clean-pkg-json": "^1.4.2",
Expand All @@ -57,7 +56,6 @@
"manten": "^2.0.0",
"pkgroll": "^2.27.0",
"skills-npm": "^1.0.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}
3 changes: 0 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions skills/type-flag/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ Type options:
| `--flag value` / `--flag=value` | Long form |
| `-f value` / `-f=value` | Short form (alias or single-char name) |
| `-abc` | Group: each char matches an alias or single-char name independently |
| `--some-flag` | kebab-case → camelCase (`someFlag`) unless schema key is kebab |
| `--some-flag` | kebab-case → camelCase (`someFlag`) unless schema key is kebab; consecutive capitals treated as one unit (`getID` → `--get-id`, `URLParser` → `--url-parser`) |
| `--flag:value` / `--flag.value` | `:` and `.` also delimit values (useful for `--define:K=V`, `--env.KEY=V`) |
| `-xvalue` (concatenated) | ⚠️ Parsed as GROUP, not `x=value`. Use `-x value` or `-x=value`. |

Expand Down Expand Up @@ -141,7 +141,7 @@ Same argv-mutation behavior as `typeFlag`.
| Reserved chars in names | `\s`, `.`, `:`, `=` forbidden in flag names (they're delimiters). |
| kebab schema key | If schema key is `'some-flag'`, only `--some-flag` / `--someFlag` both map to it, but output key stays kebab. |
| Default functions throw | A throwing `default: () => ...` propagates. |
| Custom-type errors wrap | Parser errors include the flag name in the thrown message. |
| Custom-type errors wrap | Errors thrown by custom type parsers are wrapped in a `TypeError` with message `Flag "--<name>": <original>`; original error is on `.cause`. |

## Related

Expand Down
2 changes: 1 addition & 1 deletion src/get-flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export const getFlag = <Type extends FlagType>(
removeArgvs.push(valueIndex);
}

results.push(applyParser(parser, implicitValue || ''));
results.push(applyParser(parser, implicitValue || '', name));
};

return (
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { typeFlag } from './type-flag.ts';
export { getFlag } from './get-flag.ts';
export { flagNameToKebab } from './utils.ts';
export { createPositionalArguments } from './positional-arguments.ts';
export type {
TypeFlag,
Expand Down
2 changes: 1 addition & 1 deletion src/type-flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export const typeFlag = <Schemas extends Flags>(
}

values.push(
applyParser(parser, value || ''),
applyParser(parser, value || '', name),
);
};

Expand Down
35 changes: 31 additions & 4 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,27 @@ import type {
FlagSchema,
} from './types.ts';

const camelCasePattern = /\B([A-Z])/g;
const camelToKebab = (string: string) => string.replaceAll(camelCasePattern, '-$1').toLowerCase();
/**
* Regex uses zero-width assertions to find positions for hyphen insertion:
* - (?<=[a-z])(?=[A-Z]) → after lowercase, before uppercase
* - (?<=[A-Z])(?=[A-Z][a-z]) → after uppercase, before uppercase+lowercase
*/
const kebabPattern = /(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/g;

/**
* Normalize a schema-declared flag name (e.g. `orgID`, `apiURL`, `fooBar`)
* to the kebab-case form that matches argv tokens (`--org-id`, `--api-url`,
* `--foo-bar`). Preserves acronyms as single segments.
*
* @example
* ```ts
* flagNameToKebab('orgID') // => 'org-id'
* flagNameToKebab('apiURL') // => 'api-url'
* flagNameToKebab('parseJSONData') // => 'parse-json-data'
* flagNameToKebab('fooBar') // => 'foo-bar'
* ```
*/
export const flagNameToKebab = (name: string): string => name.replaceAll(kebabPattern, '-').toLowerCase();

const { hasOwnProperty } = Object.prototype;
export const hasOwn = (
Expand Down Expand Up @@ -42,6 +61,7 @@ export const normalizeBoolean = <T>(
export const applyParser = (
typeFunction: TypeFunction,
value: unknown,
flagName?: string,
) => {
if (typeof value === 'boolean') {
return value;
Expand All @@ -51,7 +71,14 @@ export const applyParser = (
return Number.NaN;
}

return typeFunction(value);
try {
return typeFunction(value);
} catch (error) {
throw new TypeError(
`Flag "--${flagName}": ${error instanceof Error ? error.message : error}`,
{ cause: error },
);
}
};

const reservedCharactersPattern = /[\s.:=]/;
Expand Down Expand Up @@ -114,7 +141,7 @@ export const createRegistry = (

setFlag(registry, flagName, flagData);

const kebabCasing = camelToKebab(flagName);
const kebabCasing = flagNameToKebab(flagName);
if (flagName !== kebabCasing) {
setFlag(registry, kebabCasing, flagData);
}
Expand Down
1 change: 1 addition & 0 deletions tests/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ import { describe } from 'manten';
describe('type-flag', () => {
import('./specs/type-flag/index.ts');
import('./specs/get-flag/index.ts');
import('./specs/flag-name-to-kebab/index.ts');
});
43 changes: 43 additions & 0 deletions tests/specs/flag-name-to-kebab/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { test, expect } from 'manten';
import { flagNameToKebab, typeFlag } from '#type-flag';

test('standard camelCase', () => {
expect(flagNameToKebab('fooBar')).toBe('foo-bar');
});

test('trailing acronym', () => {
expect(flagNameToKebab('orgID')).toBe('org-id');
expect(flagNameToKebab('apiURL')).toBe('api-url');
});

test('middle acronym followed by capitalized word', () => {
expect(flagNameToKebab('someAPIKey')).toBe('some-api-key');
});

test('middle acronym before capitalized word', () => {
expect(flagNameToKebab('parseJSONData')).toBe('parse-json-data');
});

test('all-caps standalone', () => {
expect(flagNameToKebab('ID')).toBe('id');
});

test('already lowercase', () => {
expect(flagNameToKebab('id')).toBe('id');
});

test('single word', () => {
expect(flagNameToKebab('simple')).toBe('simple');
});

test('parses acronym flag names from argv', () => {
const parsed = typeFlag({
orgID: String,
apiURL: String,
}, [
'--org-id=acme',
'--api-url=https://example.com',
]);
expect<string | undefined>(parsed.flags.orgID).toBe('acme');
expect<string | undefined>(parsed.flags.apiURL).toBe('https://example.com');
});
20 changes: 18 additions & 2 deletions tests/specs/type-flag/error-handling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ describe('Error handling', () => {
typeFlag({
custom: ThrowingParser,
}, ['--custom', 'value']);
}).toThrow('Custom parse error');
}).toThrow('Flag "--custom": Custom parse error');
});

test('Custom parser throws on specific value', () => {
Expand All @@ -139,7 +139,23 @@ describe('Error handling', () => {
typeFlag({
number: StrictNumber,
}, ['--number', 'not-a-number']);
}).toThrow('Invalid number: not-a-number');
}).toThrow('Flag "--number": Invalid number: not-a-number');
});

test('Wrapped error preserves original via cause', () => {
const original = new Error('original error');
const ThrowingParser = (_value: string) => {
throw original;
};

try {
typeFlag({
flag: ThrowingParser,
}, ['--flag', 'value']);
} catch (error) {
expect(error).toBeInstanceOf(TypeError);
expect((error as TypeError).cause).toBe(original);
}
});
});

Expand Down
32 changes: 32 additions & 0 deletions tests/specs/type-flag/parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,38 @@ describe('Parsing', () => {
expect<string[]>(argv).toStrictEqual([]);
});

test('acronyms in camelCase convert to kebab-case correctly', () => {
// getID should accept --get-id, not --get-i-d
const argv1 = ['--get-id=123'];
const parsed1 = typeFlag({ getID: String }, argv1);
expect<string | undefined>(parsed1.flags.getID).toBe('123');
expect<string[]>(argv1).toStrictEqual([]);

// parseURL should accept --parse-url, not --parse-u-r-l
const argv2 = ['--parse-url=https://example.com'];
const parsed2 = typeFlag({ parseURL: String }, argv2);
expect<string | undefined>(parsed2.flags.parseURL).toBe('https://example.com');
expect<string[]>(argv2).toStrictEqual([]);

// XMLParser should accept --xml-parser
const argv3 = ['--xml-parser=true'];
const parsed3 = typeFlag({ XMLParser: Boolean }, argv3);
expect<boolean | undefined>(parsed3.flags.XMLParser).toBe(true);
expect<string[]>(argv3).toStrictEqual([]);

// getIDNumber should accept --get-id-number
const argv4 = ['--get-id-number=456'];
const parsed4 = typeFlag({ getIDNumber: Number }, argv4);
expect<number | undefined>(parsed4.flags.getIDNumber).toBe(456);
expect<string[]>(argv4).toStrictEqual([]);

// someAPIKey should accept --some-api-key
const argv5 = ['--some-api-key=secret'];
const parsed5 = typeFlag({ someAPIKey: String }, argv5);
expect<string | undefined>(parsed5.flags.someAPIKey).toBe('secret');
expect<string[]>(argv5).toStrictEqual([]);
});

test('flag=', () => {
const argv = ['--string=hello', '-s=bye', '--string=', '--boolean=true', '--boolean=false', '--boolean=', 'world', '--number=3.14', '--number='];
const parsed = typeFlag(
Expand Down
Loading