···6262 'p'
6363);
64646565+devCmd.option('verbose', 'Enable verbose logging', 'v');
6666+6767+// Add a simple quiet option to test basic option API (no handler, no alias)
6868+devCmd.option('quiet', 'Suppress output');
6969+6570// Serve command
6671const serveCmd = t.command('serve', 'Start the server');
6772serveCmd.option(
···86918792// Build command
8893t.command('dev build', 'Build project');
9494+9595+// Start command
9696+t.command('dev start', 'Start development server');
89979098// Copy command with multiple arguments
9199const copyCmd = t
+24-12
src/cac.ts
···44import * as powershell from './powershell';
55import type { CAC } from 'cac';
66import { assertDoubleDashes } from './shared';
77-import { OptionHandler } from './t';
87import { CompletionConfig } from './shared';
98import t from './t';
1010-1111-const noopOptionHandler: OptionHandler = function () {};
1291310const execPath = process.execPath;
1411const processArgs = process.argv.slice(1);
···73707471 // Add command options
7572 for (const option of [...instance.globalCommand.options, ...cmd.options]) {
7676- // Extract short flag from the name if it exists (e.g., "-c, --config" -> "c")
7777- const shortFlag = option.name.match(/^-([a-zA-Z]), --/)?.[1];
7878- const argName = option.name.replace(/^-[a-zA-Z], --/, '');
7373+ // Extract short flag from the rawName if it exists (e.g., "-c, --config" -> "c")
7474+ const shortFlag = option.rawName.match(/^-([a-zA-Z]), --/)?.[1];
7575+ const argName = option.name; // option.name is already clean (e.g., "config")
79768077 // Add option using t.ts API
8178 const targetCommand = isRootCommand ? t : command;
8279 if (targetCommand) {
8383- targetCommand.option(
8484- argName, // Store just the option name without -- prefix
8585- option.description || '',
8686- commandCompletionConfig?.options?.[argName] ?? noopOptionHandler,
8787- shortFlag
8888- );
8080+ const handler = commandCompletionConfig?.options?.[argName];
8181+ if (handler) {
8282+ // Has custom handler → value option
8383+ if (shortFlag) {
8484+ targetCommand.option(
8585+ argName,
8686+ option.description || '',
8787+ handler,
8888+ shortFlag
8989+ );
9090+ } else {
9191+ targetCommand.option(argName, option.description || '', handler);
9292+ }
9393+ } else {
9494+ // No custom handler → boolean flag
9595+ if (shortFlag) {
9696+ targetCommand.option(argName, option.description || '', shortFlag);
9797+ } else {
9898+ targetCommand.option(argName, option.description || '');
9999+ }
100100+ }
89101 }
90102 }
91103 }
+43-18
src/citty.ts
···8585 };
8686}
87878888-const noopOptionHandler: OptionHandler = function () {};
8989-9088async function handleSubCommands(
9189 subCommands: SubCommandsDef,
9290 parentCmd?: string,
···105103106104 // Add command using t.ts API
107105 const commandName = parentCmd ? `${parentCmd} ${cmd}` : cmd;
108108- const command = t.command(cmd, meta.description);
106106+ const command = t.command(commandName, meta.description);
109107110108 // Set args for the command if it has positional arguments
111109 if (isPositional && config.args) {
···138136 if (config.args) {
139137 for (const [argName, argConfig] of Object.entries(config.args)) {
140138 const conf = argConfig as ArgDef;
141141- if (conf.type === 'positional') {
142142- continue;
143143- }
144139 // Extract alias from the config if it exists
145140 const shortFlag =
146141 typeof conf === 'object' && 'alias' in conf
···150145 : undefined;
151146152147 // Add option using t.ts API - store without -- prefix
153153- command.option(
154154- argName,
155155- conf.description ?? '',
156156- subCompletionConfig?.options?.[argName] ?? noopOptionHandler,
157157- shortFlag
158158- );
148148+ const handler = subCompletionConfig?.options?.[argName];
149149+ if (handler) {
150150+ // Has custom handler → value option
151151+ if (shortFlag) {
152152+ command.option(argName, conf.description ?? '', handler, shortFlag);
153153+ } else {
154154+ command.option(argName, conf.description ?? '', handler);
155155+ }
156156+ } else {
157157+ // No custom handler → boolean flag
158158+ if (shortFlag) {
159159+ command.option(argName, conf.description ?? '', shortFlag);
160160+ } else {
161161+ command.option(argName, conf.description ?? '');
162162+ }
163163+ }
159164 }
160165 }
161166 }
···206211207212 if (instance.args) {
208213 for (const [argName, argConfig] of Object.entries(instance.args)) {
209209- const conf = argConfig as PositionalArgDef;
214214+ const conf = argConfig as ArgDef;
215215+216216+ // Extract alias (same logic as subcommands)
217217+ const shortFlag =
218218+ typeof conf === 'object' && 'alias' in conf
219219+ ? Array.isArray(conf.alias)
220220+ ? conf.alias[0]
221221+ : conf.alias
222222+ : undefined;
223223+210224 // Add option using t.ts API - store without -- prefix
211211- t.option(
212212- argName,
213213- conf.description ?? '',
214214- completionConfig?.options?.[argName] ?? noopOptionHandler
215215- );
225225+ const handler = completionConfig?.options?.[argName];
226226+ if (handler) {
227227+ // Has custom handler → value option
228228+ if (shortFlag) {
229229+ t.option(argName, conf.description ?? '', handler, shortFlag);
230230+ } else {
231231+ t.option(argName, conf.description ?? '', handler);
232232+ }
233233+ } else {
234234+ // No custom handler → boolean flag
235235+ if (shortFlag) {
236236+ t.option(argName, conf.description ?? '', shortFlag);
237237+ } else {
238238+ t.option(argName, conf.description ?? '');
239239+ }
240240+ }
216241 }
217242 }
218243
+117-10
src/t.ts
···5757 command: Command;
5858 handler?: OptionHandler;
5959 alias?: string;
6060- // TODO: handle boolean options
6060+ isBoolean?: boolean;
61616262 constructor(
6363 command: Command,
6464 value: string,
6565 description: string,
6666 handler?: OptionHandler,
6767- alias?: string
6767+ alias?: string,
6868+ isBoolean?: boolean
6869 ) {
6970 this.command = command;
7071 this.value = value;
7172 this.description = description;
7273 this.handler = handler;
7374 this.alias = alias;
7575+ this.isBoolean = isBoolean;
7476 }
7577}
7678···8688 this.description = description;
8789 }
88909191+ // Function overloads for better UX
9292+ option(value: string, description: string): Command;
9393+ option(value: string, description: string, alias: string): Command;
9494+ option(value: string, description: string, handler: OptionHandler): Command;
8995 option(
9096 value: string,
9197 description: string,
9292- handler?: OptionHandler,
9898+ handler: OptionHandler,
9999+ alias: string
100100+ ): Command;
101101+ option(
102102+ value: string,
103103+ description: string,
104104+ handlerOrAlias?: OptionHandler | string,
93105 alias?: string
9494- ) {
9595- const option = new Option(this, value, description, handler, alias);
106106+ ): Command {
107107+ let handler: OptionHandler | undefined;
108108+ let aliasStr: string | undefined;
109109+ let isBoolean: boolean;
110110+111111+ // Parse arguments based on types
112112+ if (typeof handlerOrAlias === 'function') {
113113+ handler = handlerOrAlias;
114114+ aliasStr = alias;
115115+ isBoolean = false;
116116+ } else if (typeof handlerOrAlias === 'string') {
117117+ handler = undefined;
118118+ aliasStr = handlerOrAlias;
119119+ isBoolean = true;
120120+ } else {
121121+ handler = undefined;
122122+ aliasStr = undefined;
123123+ isBoolean = true;
124124+ }
125125+126126+ const option = new Option(
127127+ this,
128128+ value,
129129+ description,
130130+ handler,
131131+ aliasStr,
132132+ isBoolean
133133+ );
96134 this.options.set(value, option);
97135 return this;
98136 }
···135173136174 if (arg.startsWith('-')) {
137175 i++; // Skip the option
138138- if (i < args.length && !args[i].startsWith('-')) {
176176+177177+ // Check if this option expects a value (not boolean)
178178+ // We need to check across all commands since we don't know which command context we're in yet
179179+ let isBoolean = false;
180180+181181+ // Check root command options
182182+ const rootOption = this.findOption(this, arg);
183183+ if (rootOption) {
184184+ isBoolean = rootOption.isBoolean ?? false;
185185+ } else {
186186+ // Check all subcommand options
187187+ for (const [, command] of this.commands) {
188188+ const option = this.findOption(command, arg);
189189+ if (option) {
190190+ isBoolean = option.isBoolean ?? false;
191191+ break;
192192+ }
193193+ }
194194+ }
195195+196196+ // Only skip the next argument if this is not a boolean option and the next arg doesn't start with -
197197+ if (!isBoolean && i < args.length && !args[i].startsWith('-')) {
139198 i++; // Skip the option value
140199 }
141200 } else {
···176235 toComplete: string,
177236 endsWithSpace: boolean
178237 ): boolean {
179179- return lastPrevArg?.startsWith('-') || toComplete.startsWith('-');
238238+ // Always complete if the current token starts with a dash
239239+ if (toComplete.startsWith('-')) {
240240+ return true;
241241+ }
242242+243243+ // If the previous argument was an option, check if it expects a value
244244+ if (lastPrevArg?.startsWith('-')) {
245245+ // Find the option to check if it's boolean
246246+ let option = this.findOption(this, lastPrevArg);
247247+ if (!option) {
248248+ // Check all subcommand options
249249+ for (const [, command] of this.commands) {
250250+ option = this.findOption(command, lastPrevArg);
251251+ if (option) break;
252252+ }
253253+ }
254254+255255+ // If it's a boolean option, don't try to complete its value
256256+ if (option && option.isBoolean) {
257257+ return false;
258258+ }
259259+260260+ // Non-boolean options expect values
261261+ return true;
262262+ }
263263+264264+ return false;
180265 }
181266182267 // Determine if we should complete commands
···276361277362 // Handle command completion
278363 private handleCommandCompletion(previousArgs: string[], toComplete: string) {
279279- const commandParts = previousArgs.filter(Boolean);
364364+ const commandParts = this.stripOptions(previousArgs);
280365281366 for (const [k, command] of this.commands) {
282367 if (k === '') continue;
···373458 const previousArgs = args.slice(0, -1);
374459375460 if (endsWithSpace) {
376376- previousArgs.push(toComplete);
461461+ if (toComplete !== '') {
462462+ previousArgs.push(toComplete);
463463+ }
377464 toComplete = '';
378465 }
379466···390477 lastPrevArg
391478 );
392479 } else {
480480+ // Check if we just finished a boolean option with no value expected
481481+ // In this case, don't complete anything
482482+ if (lastPrevArg?.startsWith('-') && toComplete === '' && endsWithSpace) {
483483+ let option = this.findOption(this, lastPrevArg);
484484+ if (!option) {
485485+ // Check all subcommand options
486486+ for (const [, command] of this.commands) {
487487+ option = this.findOption(command, lastPrevArg);
488488+ if (option) break;
489489+ }
490490+ }
491491+492492+ // If it's a boolean option followed by empty space, don't complete anything
493493+ if (option && option.isBoolean) {
494494+ // Don't add any completions, just output the directive
495495+ this.complete(toComplete);
496496+ return;
497497+ }
498498+ }
499499+393500 // 2. Handle command/subcommand completion
394501 if (this.shouldCompleteCommands(toComplete, endsWithSpace)) {
395502 this.handleCommandCompletion(previousArgs, toComplete);
396503 }
397397- // 3. Handle positional arguments
504504+ // 3. Handle positional arguments - always check for root command arguments
398505 if (matchedCommand && matchedCommand.arguments.size > 0) {
399506 this.handlePositionalCompletion(
400507 matchedCommand,
+54-19
tests/__snapshots__/cli.test.ts.snap
···2222`;
23232424exports[`cli completion tests for cac > --config option tests > should complete short flag -c option values 1`] = `
2525-":4
2525+"vite.config.ts Vite config file
2626+vite.config.js Vite config file
2727+:4
2628"
2729`;
28302931exports[`cli completion tests for cac > --config option tests > should complete short flag -c option with partial input 1`] = `
3030-":4
3232+"vite.config.ts Vite config file
3333+vite.config.js Vite config file
3434+:4
3135"
3236`;
3337···4650`;
47514852exports[`cli completion tests for cac > cli option completion tests > should complete option for partial input '{ partial: '-H', expected: '-H' }' 1`] = `
4949-":4
5353+"-H Specify hostname
5454+:4
5055"
5156`;
52575358exports[`cli completion tests for cac > cli option completion tests > should complete option for partial input '{ partial: '-p', expected: '-p' }' 1`] = `
5454-":4
5959+"-p Specify port
6060+:4
5561"
5662`;
5763···188194`;
189195190196exports[`cli completion tests for cac > root command argument tests > should complete root command project argument after options 1`] = `
191191-":4
197197+"dev Start dev server
198198+serve Start the server
199199+copy Copy files
200200+lint Lint project
201201+:4
192202"
193203`;
194204···245255`;
246256247257exports[`cli completion tests for cac > root command option tests > should complete root command short flag -l option values 1`] = `
248248-":4
258258+"info Info level
259259+warn Warn level
260260+error Error level
261261+silent Silent level
262262+:4
249263"
250264`;
251265252266exports[`cli completion tests for cac > root command option tests > should complete root command short flag -m option values 1`] = `
253253-":4
267267+"development Development mode
268268+production Production mode
269269+:4
254270"
255271`;
256272257273exports[`cli completion tests for cac > short flag handling > should handle global short flags 1`] = `
258258-":4
274274+"-c Use specified config file
275275+:4
259276"
260277`;
261278262279exports[`cli completion tests for cac > short flag handling > should handle short flag value completion 1`] = `
263263-":4
280280+"-p Specify port
281281+:4
264282"
265283`;
266284267285exports[`cli completion tests for cac > short flag handling > should handle short flag with equals sign 1`] = `
268268-":4
286286+"-p=3000 Development server port
287287+:4
269288"
270289`;
271290···308327`;
309328310329exports[`cli completion tests for citty > --config option tests > should complete short flag -c option values 1`] = `
311311-":4
330330+"vite.config.ts Vite config file
331331+vite.config.js Vite config file
332332+:4
312333"
313334`;
314335315336exports[`cli completion tests for citty > --config option tests > should complete short flag -c option with partial input 1`] = `
316316-":4
337337+"vite.config.ts Vite config file
338338+vite.config.js Vite config file
339339+:4
317340"
318341`;
319342···470493471494exports[`cli completion tests for citty > root command argument tests > should complete root command project argument 1`] = `
472495"dev Start dev server
473473-build Build project
474496copy Copy files
475497lint Lint project
476498my-app My application
···481503`;
482504483505exports[`cli completion tests for citty > root command argument tests > should complete root command project argument after options 1`] = `
484484-":4
506506+"dev Start dev server
507507+copy Copy files
508508+lint Lint project
509509+:4
485510"
486511`;
487512···542567`;
543568544569exports[`cli completion tests for citty > root command option tests > should complete root command short flag -l option values 1`] = `
545545-":4
570570+"info Info level
571571+warn Warn level
572572+error Error level
573573+silent Silent level
574574+:4
546575"
547576`;
548577549578exports[`cli completion tests for citty > root command option tests > should complete root command short flag -m option values 1`] = `
550550-":4
579579+"development Development mode
580580+production Production mode
581581+:4
551582"
552583`;
553584554585exports[`cli completion tests for citty > short flag handling > should handle global short flags 1`] = `
555555-":4
586586+"-c Use specified config file
587587+:4
556588"
557589`;
558590···579611580612exports[`cli completion tests for citty > should complete cli options 1`] = `
581613"dev Start dev server
582582-build Build project
583614copy Copy files
584615lint Lint project
585616my-app My application
···811842`;
812843813844exports[`cli completion tests for t > root command argument tests > should complete root command project argument after options 1`] = `
814814-":4
845845+"dev Start dev server
846846+serve Start the server
847847+copy Copy files
848848+lint Lint project
849849+:4
815850"
816851`;
817852
+94
tests/cli.test.ts
···8989 });
9090 });
91919292+ describe.runIf(!shouldSkipTest)('boolean option handling', () => {
9393+ it('should not provide value completions for boolean options', async () => {
9494+ const command = `${commandPrefix} dev --verbose ""`;
9595+ const output = await runCommand(command);
9696+ // Boolean options should return just the directive, no completions
9797+ expect(output.trim()).toBe(':4');
9898+ });
9999+100100+ it('should not provide value completions for short boolean options', async () => {
101101+ const command = `${commandPrefix} dev -v ""`;
102102+ const output = await runCommand(command);
103103+ // Boolean options should return just the directive, no completions
104104+ expect(output.trim()).toBe(':4');
105105+ });
106106+107107+ it('should not interfere with command completion after boolean options', async () => {
108108+ const command = `${commandPrefix} dev --verbose s`;
109109+ const output = await runCommand(command);
110110+ // Should complete subcommands that start with 's' even after a boolean option
111111+ expect(output).toContain('start');
112112+ });
113113+ });
114114+115115+ describe.runIf(!shouldSkipTest)('option API overload tests', () => {
116116+ it('should handle basic option (name + description only) as boolean flag', async () => {
117117+ // This tests the case: option('quiet', 'Suppress output')
118118+ const command = `${commandPrefix} dev --quiet ""`;
119119+ const output = await runCommand(command);
120120+ // Should be treated as boolean flag (no value completion)
121121+ expect(output.trim()).toBe(':4');
122122+ });
123123+124124+ it('should handle option with alias only as boolean flag', async () => {
125125+ // This tests the case: option('verbose', 'Enable verbose', 'v')
126126+ const command = `${commandPrefix} dev --verbose ""`;
127127+ const output = await runCommand(command);
128128+ // Should be treated as boolean flag (no value completion)
129129+ expect(output.trim()).toBe(':4');
130130+ });
131131+132132+ it('should handle option with alias only (short flag) as boolean flag', async () => {
133133+ // This tests the short flag version: -v instead of --verbose
134134+ const command = `${commandPrefix} dev -v ""`;
135135+ const output = await runCommand(command);
136136+ // Should be treated as boolean flag (no value completion)
137137+ expect(output.trim()).toBe(':4');
138138+ });
139139+140140+ it('should handle option with handler only as value option', async () => {
141141+ // This tests the case: option('port', 'Port number', handlerFunction)
142142+ const command = `${commandPrefix} dev --port ""`;
143143+ const output = await runCommand(command);
144144+ // Should provide value completions because it has a handler
145145+ expect(output).toContain('3000');
146146+ expect(output).toContain('8080');
147147+ });
148148+149149+ it('should handle option with both handler and alias as value option', async () => {
150150+ // This tests the case: option('config', 'Config file', handlerFunction, 'c')
151151+ const command = `${commandPrefix} --config ""`;
152152+ const output = await runCommand(command);
153153+ // Should provide value completions because it has a handler
154154+ expect(output).toContain('vite.config.ts');
155155+ expect(output).toContain('vite.config.js');
156156+ });
157157+158158+ it('should handle option with both handler and alias (short flag) as value option', async () => {
159159+ // This tests the short flag version with handler: -c instead of --config
160160+ const command = `${commandPrefix} -c ""`;
161161+ const output = await runCommand(command);
162162+ // Should provide value completions because it has a handler
163163+ expect(output).toContain('vite.config.ts');
164164+ expect(output).toContain('vite.config.js');
165165+ });
166166+167167+ it('should correctly detect boolean vs value options in mixed scenarios', async () => {
168168+ // Test that boolean options don't interfere with value options
169169+ const command = `${commandPrefix} dev --verbose --port ""`;
170170+ const output = await runCommand(command);
171171+ // Should complete port values, not be confused by preceding boolean flag
172172+ expect(output).toContain('3000');
173173+ expect(output).toContain('8080');
174174+ });
175175+176176+ it('should correctly handle aliases for different option types', async () => {
177177+ // Mix of boolean flag with alias (-v) and value option with alias (-p)
178178+ const command = `${commandPrefix} dev -v -p ""`;
179179+ const output = await runCommand(command);
180180+ // Should complete port values via short flag
181181+ expect(output).toContain('3000');
182182+ expect(output).toContain('8080');
183183+ });
184184+ });
185185+92186 describe.runIf(!shouldSkipTest)('--config option tests', () => {
93187 it('should complete --config option values', async () => {
94188 const command = `${commandPrefix} --config ""`;