Skip to content

Commit 6dfa924

Browse files
boneskullclaude
andcommitted
feat: add automatic --no-<flag> support for boolean options
All boolean options now automatically support a negated form to explicitly set the option to false: --verbose → { verbose: true } --no-verbose → { verbose: false } (neither) → { verbose: default or undefined } If both --flag and --no-flag are specified, throws a HelpError with a clear message about the conflict. In help output, booleans with default: true display as --no-<flag> since that's how users would turn them off. Short aliases are not shown for negated forms. Includes: - Parser support for processing --no-* flags - Help text updates for default:true booleans - Comprehensive tests for both parser and help - README documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent ed83b73 commit 6dfa924

5 files changed

Lines changed: 297 additions & 3 deletions

File tree

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,32 @@ opt.count(); // -vvv → 3
336336
| `hidden` | `boolean` | Hide from `--help` output |
337337
| `required` | `boolean` | Mark as required (makes the option non-nullable) |
338338

339+
### Boolean Negation (`--no-<flag>`)
340+
341+
All boolean options automatically support a negated form `--no-<flag>` to explicitly set the option to `false`:
342+
343+
```shell
344+
$ my-cli --verbose # verbose: true
345+
$ my-cli --no-verbose # verbose: false
346+
$ my-cli # verbose: undefined (or default)
347+
```
348+
349+
If both `--flag` and `--no-flag` are specified, bargs throws an error:
350+
351+
```shell
352+
$ my-cli --verbose --no-verbose
353+
Error: Conflicting options: --verbose and --no-verbose cannot both be specified
354+
```
355+
356+
In help output, booleans with `default: true` display as `--no-<flag>` (since that's how users would turn them off):
357+
358+
```typescript
359+
opt.options({
360+
colors: opt.boolean({ default: true, description: 'Use colors' }),
361+
});
362+
// Help output shows: --no-colors Use colors [boolean] default: true
363+
```
364+
339365
### `opt.options(schema)`
340366

341367
Create a parser from an options schema:

src/help.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,9 @@ const getTypeLabel = (def: OptionDef): string => {
172172

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

183-
// Build flag string: -v, --verbose
186+
// For boolean options with default: true, show --no-<name>
187+
// since that's how users would turn it off
188+
const displayName =
189+
def.type === 'boolean' && def.default === true ? `no-${name}` : name;
190+
191+
// Build flag string: -v, --verbose (or --no-verbose for default:true booleans)
184192
const shortAlias = def.aliases?.find((a) => a.length === 1);
185-
const flagText = shortAlias ? `-${shortAlias}, --${name}` : ` --${name}`;
193+
// Don't show short alias for negated booleans
194+
const flagText =
195+
shortAlias && displayName === name
196+
? `-${shortAlias}, --${displayName}`
197+
: ` --${displayName}`;
186198
parts.push(` ${styler.flag(flagText)}`);
187199

188200
// Pad to align descriptions

src/parser.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,13 @@ import type {
2020
PositionalsSchema,
2121
} from './types.js';
2222

23+
import { HelpError } from './errors.js';
24+
2325
/**
2426
* Build parseArgs options config from our options schema.
27+
*
28+
* For boolean options, also adds `no-<name>` variants to support explicit
29+
* negation (e.g., `--no-verbose` sets `verbose` to `false`).
2530
*/
2631
const buildParseArgsConfig = (
2732
schema: OptionsSchema,
@@ -55,6 +60,11 @@ const buildParseArgsConfig = (
5560
}
5661

5762
config[name] = opt;
63+
64+
// For boolean options, add negated form (--no-<name>)
65+
if (def.type === 'boolean') {
66+
config[`no-${name}`] = { type: 'boolean' };
67+
}
5868
}
5969

6070
return config;
@@ -183,6 +193,45 @@ const coercePositionals = (
183193
return result;
184194
};
185195

196+
/**
197+
* Process negated boolean options (--no-<name>).
198+
*
199+
* - If `--no-<name>` is true and `--<name>` is not set, sets `<name>` to false
200+
* - If both `--<name>` and `--no-<name>` are set, throws an error
201+
* - Removes all `no-<name>` keys from the result
202+
*/
203+
const processNegatedBooleans = (
204+
values: Record<string, unknown>,
205+
schema: OptionsSchema,
206+
): Record<string, unknown> => {
207+
const result = { ...values };
208+
209+
for (const [name, def] of Object.entries(schema)) {
210+
if (def.type !== 'boolean') {
211+
continue;
212+
}
213+
214+
const negatedKey = `no-${name}`;
215+
const hasPositive = result[name] === true;
216+
const hasNegative = result[negatedKey] === true;
217+
218+
if (hasPositive && hasNegative) {
219+
throw new HelpError(
220+
`Conflicting options: --${name} and --${negatedKey} cannot both be specified`,
221+
);
222+
}
223+
224+
if (hasNegative && !hasPositive) {
225+
result[name] = false;
226+
}
227+
228+
// Always remove the negated key from result
229+
delete result[negatedKey];
230+
}
231+
232+
return result;
233+
};
234+
186235
/**
187236
* Options for parseSimple.
188237
*/
@@ -221,8 +270,14 @@ export const parseSimple = <
221270
strict: true,
222271
});
223272

273+
// Process negated boolean options (--no-<flag>)
274+
const processedValues = processNegatedBooleans(
275+
values as Record<string, unknown>,
276+
optionsSchema,
277+
);
278+
224279
// Coerce and apply defaults
225-
const coercedValues = coerceValues(values, optionsSchema);
280+
const coercedValues = coerceValues(processedValues, optionsSchema);
226281
const coercedPositionals = coercePositionals(positionals, positionalsSchema);
227282

228283
return {

test/help.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,92 @@ describe('generateHelp', () => {
130130
expect(help, 'to contain', 'LOGGING');
131131
expect(help, 'to contain', 'NETWORK');
132132
});
133+
134+
describe('boolean negation display', () => {
135+
it('shows --no-<flag> for boolean with default: true', () => {
136+
const help = stripAnsi(
137+
generateHelp({
138+
name: 'my-cli',
139+
options: {
140+
verbose: opt.boolean({
141+
default: true,
142+
description: 'Enable verbose output',
143+
}),
144+
},
145+
}),
146+
);
147+
148+
expect(help, 'to contain', '--no-verbose');
149+
expect(help, 'not to match', /\s--verbose\s/); // should not show --verbose without "no-"
150+
});
151+
152+
it('shows --<flag> for boolean with default: false', () => {
153+
const help = stripAnsi(
154+
generateHelp({
155+
name: 'my-cli',
156+
options: {
157+
verbose: opt.boolean({
158+
default: false,
159+
description: 'Enable verbose output',
160+
}),
161+
},
162+
}),
163+
);
164+
165+
expect(help, 'to contain', '--verbose');
166+
expect(help, 'not to contain', '--no-verbose');
167+
});
168+
169+
it('shows --<flag> for boolean without default', () => {
170+
const help = stripAnsi(
171+
generateHelp({
172+
name: 'my-cli',
173+
options: {
174+
verbose: opt.boolean({ description: 'Enable verbose output' }),
175+
},
176+
}),
177+
);
178+
179+
expect(help, 'to contain', '--verbose');
180+
expect(help, 'not to contain', '--no-verbose');
181+
});
182+
183+
it('does not show short alias for negated boolean', () => {
184+
const help = stripAnsi(
185+
generateHelp({
186+
name: 'my-cli',
187+
options: {
188+
verbose: opt.boolean({
189+
aliases: ['v'],
190+
default: true,
191+
description: 'Enable verbose output',
192+
}),
193+
},
194+
}),
195+
);
196+
197+
// Should show --no-verbose but NOT -v for the negated form
198+
expect(help, 'to contain', '--no-verbose');
199+
expect(help, 'not to match', /-v,\s*--no-verbose/);
200+
});
201+
202+
it('shows short alias for non-negated boolean', () => {
203+
const help = stripAnsi(
204+
generateHelp({
205+
name: 'my-cli',
206+
options: {
207+
verbose: opt.boolean({
208+
aliases: ['v'],
209+
default: false,
210+
description: 'Enable verbose output',
211+
}),
212+
},
213+
}),
214+
);
215+
216+
expect(help, 'to match', /-v,\s*--verbose/);
217+
});
218+
});
133219
});
134220

135221
describe('generateHelp positionals', () => {

test/parser.test.ts

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

7+
import { HelpError } from '../src/errors.js';
78
import { opt } from '../src/opt.js';
89
import { parseSimple } from '../src/parser.js';
910
import { validatePositionalsSchema } from '../src/validate.js';
@@ -31,6 +32,120 @@ describe('parseSimple', () => {
3132
expect(result.values, 'to deeply equal', { verbose: true });
3233
});
3334

35+
describe('boolean negation (--no-<flag>)', () => {
36+
it('sets flag to false with --no-<flag>', () => {
37+
const result = parseSimple({
38+
args: ['--no-verbose'],
39+
options: {
40+
verbose: opt.boolean(),
41+
},
42+
});
43+
44+
expect(result.values, 'to deeply equal', { verbose: false });
45+
});
46+
47+
it('--no-<flag> overrides default: true', () => {
48+
const result = parseSimple({
49+
args: ['--no-verbose'],
50+
options: {
51+
verbose: opt.boolean({ default: true }),
52+
},
53+
});
54+
55+
expect(result.values, 'to deeply equal', { verbose: false });
56+
});
57+
58+
it('--<flag> sets flag to true', () => {
59+
const result = parseSimple({
60+
args: ['--verbose'],
61+
options: {
62+
verbose: opt.boolean(),
63+
},
64+
});
65+
66+
expect(result.values, 'to deeply equal', { verbose: true });
67+
});
68+
69+
it('throws HelpError when both --<flag> and --no-<flag> are specified', () => {
70+
expect(
71+
() =>
72+
parseSimple({
73+
args: ['--verbose', '--no-verbose'],
74+
options: {
75+
verbose: opt.boolean(),
76+
},
77+
}),
78+
'to throw a',
79+
HelpError,
80+
);
81+
});
82+
83+
it('error message mentions conflicting options', () => {
84+
expect(
85+
() =>
86+
parseSimple({
87+
args: ['--no-verbose', '--verbose'],
88+
options: {
89+
verbose: opt.boolean(),
90+
},
91+
}),
92+
'to throw',
93+
/Conflicting options.*--verbose.*--no-verbose/,
94+
);
95+
});
96+
97+
it('negated keys never appear in result values', () => {
98+
const result = parseSimple({
99+
args: ['--no-verbose'],
100+
options: {
101+
verbose: opt.boolean(),
102+
},
103+
});
104+
105+
expect(Object.keys(result.values), 'not to contain', 'no-verbose');
106+
expect(result.values, 'to have keys', ['verbose']);
107+
});
108+
109+
it('works with multiple boolean options', () => {
110+
const result = parseSimple({
111+
args: ['--verbose', '--no-quiet', '--no-debug'],
112+
options: {
113+
debug: opt.boolean({ default: true }),
114+
quiet: opt.boolean(),
115+
verbose: opt.boolean(),
116+
},
117+
});
118+
119+
expect(result.values, 'to deeply equal', {
120+
debug: false,
121+
quiet: false,
122+
verbose: true,
123+
});
124+
});
125+
126+
it('applies default when neither flag nor negation provided', () => {
127+
const result = parseSimple({
128+
args: [],
129+
options: {
130+
verbose: opt.boolean({ default: true }),
131+
},
132+
});
133+
134+
expect(result.values, 'to deeply equal', { verbose: true });
135+
});
136+
137+
it('returns undefined when no flag, no negation, and no default', () => {
138+
const result = parseSimple({
139+
args: [],
140+
options: {
141+
verbose: opt.boolean(),
142+
},
143+
});
144+
145+
expect(result.values.verbose, 'to be', undefined);
146+
});
147+
});
148+
34149
it('parses number options', () => {
35150
const result = parseSimple({
36151
args: ['--count', '5'],

0 commit comments

Comments
 (0)