diff --git a/examples/demo.cac.ts b/examples/demo.cac.ts index 3297fe3..ff95a1a 100644 --- a/examples/demo.cac.ts +++ b/examples/demo.cac.ts @@ -13,6 +13,8 @@ cli .command('dev', 'Start dev server') .option('-H, --host [host]', `Specify hostname`) .option('-p, --port ', `Specify port`) + .option('-v, --verbose', `Enable verbose logging`) + .option('--quiet', `Suppress output`) .action((options) => {}); cli @@ -23,6 +25,8 @@ cli cli.command('dev build', 'Build project').action((options) => {}); +cli.command('dev start', 'Start development server').action((options) => {}); + cli .command('copy ', 'Copy files') .action((source, destination, options) => {}); diff --git a/examples/demo.citty.ts b/examples/demo.citty.ts index 2442178..5c17604 100644 --- a/examples/demo.citty.ts +++ b/examples/demo.citty.ts @@ -53,6 +53,15 @@ const devCommand = defineCommand({ description: 'Specify port', alias: 'p', }, + verbose: { + type: 'boolean', + description: 'Enable verbose logging', + alias: 'v', + }, + quiet: { + type: 'boolean', + description: 'Suppress output', + }, }, run: () => {}, }); @@ -65,6 +74,14 @@ const buildCommand = defineCommand({ run: () => {}, }); +const startCommand = defineCommand({ + meta: { + name: 'start', + description: 'Start development server', + }, + run: () => {}, +}); + const copyCommand = defineCommand({ meta: { name: 'copy', @@ -100,9 +117,13 @@ const lintCommand = defineCommand({ run: () => {}, }); +devCommand.subCommands = { + build: buildCommand, + start: startCommand, +}; + main.subCommands = { dev: devCommand, - build: buildCommand, copy: copyCommand, lint: lintCommand, } as Record>; diff --git a/examples/demo.t.ts b/examples/demo.t.ts index 21f277d..38f3569 100644 --- a/examples/demo.t.ts +++ b/examples/demo.t.ts @@ -62,6 +62,11 @@ devCmd.option( 'p' ); +devCmd.option('verbose', 'Enable verbose logging', 'v'); + +// Add a simple quiet option to test basic option API (no handler, no alias) +devCmd.option('quiet', 'Suppress output'); + // Serve command const serveCmd = t.command('serve', 'Start the server'); serveCmd.option( @@ -87,6 +92,9 @@ serveCmd.option( // Build command t.command('dev build', 'Build project'); +// Start command +t.command('dev start', 'Start development server'); + // Copy command with multiple arguments const copyCmd = t .command('copy', 'Copy files') diff --git a/src/cac.ts b/src/cac.ts index d69f60a..72bbdd5 100644 --- a/src/cac.ts +++ b/src/cac.ts @@ -4,12 +4,9 @@ import * as fish from './fish'; import * as powershell from './powershell'; import type { CAC } from 'cac'; import { assertDoubleDashes } from './shared'; -import { OptionHandler } from './t'; import { CompletionConfig } from './shared'; import t from './t'; -const noopOptionHandler: OptionHandler = function () {}; - const execPath = process.execPath; const processArgs = process.argv.slice(1); const quotedExecPath = quoteIfNeeded(execPath); @@ -73,19 +70,34 @@ export default async function tab( // Add command options for (const option of [...instance.globalCommand.options, ...cmd.options]) { - // Extract short flag from the name if it exists (e.g., "-c, --config" -> "c") - const shortFlag = option.name.match(/^-([a-zA-Z]), --/)?.[1]; - const argName = option.name.replace(/^-[a-zA-Z], --/, ''); + // Extract short flag from the rawName if it exists (e.g., "-c, --config" -> "c") + const shortFlag = option.rawName.match(/^-([a-zA-Z]), --/)?.[1]; + const argName = option.name; // option.name is already clean (e.g., "config") // Add option using t.ts API const targetCommand = isRootCommand ? t : command; if (targetCommand) { - targetCommand.option( - argName, // Store just the option name without -- prefix - option.description || '', - commandCompletionConfig?.options?.[argName] ?? noopOptionHandler, - shortFlag - ); + const handler = commandCompletionConfig?.options?.[argName]; + if (handler) { + // Has custom handler → value option + if (shortFlag) { + targetCommand.option( + argName, + option.description || '', + handler, + shortFlag + ); + } else { + targetCommand.option(argName, option.description || '', handler); + } + } else { + // No custom handler → boolean flag + if (shortFlag) { + targetCommand.option(argName, option.description || '', shortFlag); + } else { + targetCommand.option(argName, option.description || ''); + } + } } } } diff --git a/src/citty.ts b/src/citty.ts index 731a8eb..4ff5e21 100644 --- a/src/citty.ts +++ b/src/citty.ts @@ -85,8 +85,6 @@ function convertOptionHandler(handler: any): OptionHandler { }; } -const noopOptionHandler: OptionHandler = function () {}; - async function handleSubCommands( subCommands: SubCommandsDef, parentCmd?: string, @@ -105,7 +103,7 @@ async function handleSubCommands( // Add command using t.ts API const commandName = parentCmd ? `${parentCmd} ${cmd}` : cmd; - const command = t.command(cmd, meta.description); + const command = t.command(commandName, meta.description); // Set args for the command if it has positional arguments if (isPositional && config.args) { @@ -138,9 +136,6 @@ async function handleSubCommands( if (config.args) { for (const [argName, argConfig] of Object.entries(config.args)) { const conf = argConfig as ArgDef; - if (conf.type === 'positional') { - continue; - } // Extract alias from the config if it exists const shortFlag = typeof conf === 'object' && 'alias' in conf @@ -150,12 +145,22 @@ async function handleSubCommands( : undefined; // Add option using t.ts API - store without -- prefix - command.option( - argName, - conf.description ?? '', - subCompletionConfig?.options?.[argName] ?? noopOptionHandler, - shortFlag - ); + const handler = subCompletionConfig?.options?.[argName]; + if (handler) { + // Has custom handler → value option + if (shortFlag) { + command.option(argName, conf.description ?? '', handler, shortFlag); + } else { + command.option(argName, conf.description ?? '', handler); + } + } else { + // No custom handler → boolean flag + if (shortFlag) { + command.option(argName, conf.description ?? '', shortFlag); + } else { + command.option(argName, conf.description ?? ''); + } + } } } } @@ -206,13 +211,33 @@ export default async function tab( if (instance.args) { for (const [argName, argConfig] of Object.entries(instance.args)) { - const conf = argConfig as PositionalArgDef; + const conf = argConfig as ArgDef; + + // Extract alias (same logic as subcommands) + const shortFlag = + typeof conf === 'object' && 'alias' in conf + ? Array.isArray(conf.alias) + ? conf.alias[0] + : conf.alias + : undefined; + // Add option using t.ts API - store without -- prefix - t.option( - argName, - conf.description ?? '', - completionConfig?.options?.[argName] ?? noopOptionHandler - ); + const handler = completionConfig?.options?.[argName]; + if (handler) { + // Has custom handler → value option + if (shortFlag) { + t.option(argName, conf.description ?? '', handler, shortFlag); + } else { + t.option(argName, conf.description ?? '', handler); + } + } else { + // No custom handler → boolean flag + if (shortFlag) { + t.option(argName, conf.description ?? '', shortFlag); + } else { + t.option(argName, conf.description ?? ''); + } + } } } diff --git a/src/t.ts b/src/t.ts index 5ecdada..29321a4 100644 --- a/src/t.ts +++ b/src/t.ts @@ -57,20 +57,22 @@ export class Option { command: Command; handler?: OptionHandler; alias?: string; - // TODO: handle boolean options + isBoolean?: boolean; constructor( command: Command, value: string, description: string, handler?: OptionHandler, - alias?: string + alias?: string, + isBoolean?: boolean ) { this.command = command; this.value = value; this.description = description; this.handler = handler; this.alias = alias; + this.isBoolean = isBoolean; } } @@ -86,13 +88,49 @@ export class Command { this.description = description; } + // Function overloads for better UX + option(value: string, description: string): Command; + option(value: string, description: string, alias: string): Command; + option(value: string, description: string, handler: OptionHandler): Command; option( value: string, description: string, - handler?: OptionHandler, + handler: OptionHandler, + alias: string + ): Command; + option( + value: string, + description: string, + handlerOrAlias?: OptionHandler | string, alias?: string - ) { - const option = new Option(this, value, description, handler, alias); + ): Command { + let handler: OptionHandler | undefined; + let aliasStr: string | undefined; + let isBoolean: boolean; + + // Parse arguments based on types + if (typeof handlerOrAlias === 'function') { + handler = handlerOrAlias; + aliasStr = alias; + isBoolean = false; + } else if (typeof handlerOrAlias === 'string') { + handler = undefined; + aliasStr = handlerOrAlias; + isBoolean = true; + } else { + handler = undefined; + aliasStr = undefined; + isBoolean = true; + } + + const option = new Option( + this, + value, + description, + handler, + aliasStr, + isBoolean + ); this.options.set(value, option); return this; } @@ -135,7 +173,28 @@ export class RootCommand extends Command { if (arg.startsWith('-')) { i++; // Skip the option - if (i < args.length && !args[i].startsWith('-')) { + + // Check if this option expects a value (not boolean) + // We need to check across all commands since we don't know which command context we're in yet + let isBoolean = false; + + // Check root command options + const rootOption = this.findOption(this, arg); + if (rootOption) { + isBoolean = rootOption.isBoolean ?? false; + } else { + // Check all subcommand options + for (const [, command] of this.commands) { + const option = this.findOption(command, arg); + if (option) { + isBoolean = option.isBoolean ?? false; + break; + } + } + } + + // Only skip the next argument if this is not a boolean option and the next arg doesn't start with - + if (!isBoolean && i < args.length && !args[i].startsWith('-')) { i++; // Skip the option value } } else { @@ -176,7 +235,33 @@ export class RootCommand extends Command { toComplete: string, endsWithSpace: boolean ): boolean { - return lastPrevArg?.startsWith('-') || toComplete.startsWith('-'); + // Always complete if the current token starts with a dash + if (toComplete.startsWith('-')) { + return true; + } + + // If the previous argument was an option, check if it expects a value + if (lastPrevArg?.startsWith('-')) { + // Find the option to check if it's boolean + let option = this.findOption(this, lastPrevArg); + if (!option) { + // Check all subcommand options + for (const [, command] of this.commands) { + option = this.findOption(command, lastPrevArg); + if (option) break; + } + } + + // If it's a boolean option, don't try to complete its value + if (option && option.isBoolean) { + return false; + } + + // Non-boolean options expect values + return true; + } + + return false; } // Determine if we should complete commands @@ -276,7 +361,7 @@ export class RootCommand extends Command { // Handle command completion private handleCommandCompletion(previousArgs: string[], toComplete: string) { - const commandParts = previousArgs.filter(Boolean); + const commandParts = this.stripOptions(previousArgs); for (const [k, command] of this.commands) { if (k === '') continue; @@ -373,7 +458,9 @@ export class RootCommand extends Command { const previousArgs = args.slice(0, -1); if (endsWithSpace) { - previousArgs.push(toComplete); + if (toComplete !== '') { + previousArgs.push(toComplete); + } toComplete = ''; } @@ -390,11 +477,31 @@ export class RootCommand extends Command { lastPrevArg ); } else { + // Check if we just finished a boolean option with no value expected + // In this case, don't complete anything + if (lastPrevArg?.startsWith('-') && toComplete === '' && endsWithSpace) { + let option = this.findOption(this, lastPrevArg); + if (!option) { + // Check all subcommand options + for (const [, command] of this.commands) { + option = this.findOption(command, lastPrevArg); + if (option) break; + } + } + + // If it's a boolean option followed by empty space, don't complete anything + if (option && option.isBoolean) { + // Don't add any completions, just output the directive + this.complete(toComplete); + return; + } + } + // 2. Handle command/subcommand completion if (this.shouldCompleteCommands(toComplete, endsWithSpace)) { this.handleCommandCompletion(previousArgs, toComplete); } - // 3. Handle positional arguments + // 3. Handle positional arguments - always check for root command arguments if (matchedCommand && matchedCommand.arguments.size > 0) { this.handlePositionalCompletion( matchedCommand, diff --git a/tests/__snapshots__/cli.test.ts.snap b/tests/__snapshots__/cli.test.ts.snap index 0527244..6980b8c 100644 --- a/tests/__snapshots__/cli.test.ts.snap +++ b/tests/__snapshots__/cli.test.ts.snap @@ -22,12 +22,16 @@ vite.config.js Vite config file `; exports[`cli completion tests for cac > --config option tests > should complete short flag -c option values 1`] = ` -":4 +"vite.config.ts Vite config file +vite.config.js Vite config file +:4 " `; exports[`cli completion tests for cac > --config option tests > should complete short flag -c option with partial input 1`] = ` -":4 +"vite.config.ts Vite config file +vite.config.js Vite config file +:4 " `; @@ -46,12 +50,14 @@ exports[`cli completion tests for cac > cli option completion tests > should com `; exports[`cli completion tests for cac > cli option completion tests > should complete option for partial input '{ partial: '-H', expected: '-H' }' 1`] = ` -":4 +"-H Specify hostname +:4 " `; exports[`cli completion tests for cac > cli option completion tests > should complete option for partial input '{ partial: '-p', expected: '-p' }' 1`] = ` -":4 +"-p Specify port +:4 " `; @@ -188,7 +194,11 @@ lint Lint project `; exports[`cli completion tests for cac > root command argument tests > should complete root command project argument after options 1`] = ` -":4 +"dev Start dev server +serve Start the server +copy Copy files +lint Lint project +:4 " `; @@ -245,27 +255,36 @@ exports[`cli completion tests for cac > root command option tests > should compl `; exports[`cli completion tests for cac > root command option tests > should complete root command short flag -l option values 1`] = ` -":4 +"info Info level +warn Warn level +error Error level +silent Silent level +:4 " `; exports[`cli completion tests for cac > root command option tests > should complete root command short flag -m option values 1`] = ` -":4 +"development Development mode +production Production mode +:4 " `; exports[`cli completion tests for cac > short flag handling > should handle global short flags 1`] = ` -":4 +"-c Use specified config file +:4 " `; exports[`cli completion tests for cac > short flag handling > should handle short flag value completion 1`] = ` -":4 +"-p Specify port +:4 " `; exports[`cli completion tests for cac > short flag handling > should handle short flag with equals sign 1`] = ` -":4 +"-p=3000 Development server port +:4 " `; @@ -308,12 +327,16 @@ vite.config.js Vite config file `; exports[`cli completion tests for citty > --config option tests > should complete short flag -c option values 1`] = ` -":4 +"vite.config.ts Vite config file +vite.config.js Vite config file +:4 " `; exports[`cli completion tests for citty > --config option tests > should complete short flag -c option with partial input 1`] = ` -":4 +"vite.config.ts Vite config file +vite.config.js Vite config file +:4 " `; @@ -470,7 +493,6 @@ index.ts Index file exports[`cli completion tests for citty > root command argument tests > should complete root command project argument 1`] = ` "dev Start dev server -build Build project copy Copy files lint Lint project my-app My application @@ -481,7 +503,10 @@ my-tool My tool `; exports[`cli completion tests for citty > root command argument tests > should complete root command project argument after options 1`] = ` -":4 +"dev Start dev server +copy Copy files +lint Lint project +:4 " `; @@ -542,17 +567,24 @@ exports[`cli completion tests for citty > root command option tests > should com `; exports[`cli completion tests for citty > root command option tests > should complete root command short flag -l option values 1`] = ` -":4 +"info Info level +warn Warn level +error Error level +silent Silent level +:4 " `; exports[`cli completion tests for citty > root command option tests > should complete root command short flag -m option values 1`] = ` -":4 +"development Development mode +production Production mode +:4 " `; exports[`cli completion tests for citty > short flag handling > should handle global short flags 1`] = ` -":4 +"-c Use specified config file +:4 " `; @@ -579,7 +611,6 @@ exports[`cli completion tests for citty > short flag handling > should not show exports[`cli completion tests for citty > should complete cli options 1`] = ` "dev Start dev server -build Build project copy Copy files lint Lint project my-app My application @@ -811,7 +842,11 @@ my-tool My tool `; exports[`cli completion tests for t > root command argument tests > should complete root command project argument after options 1`] = ` -":4 +"dev Start dev server +serve Start the server +copy Copy files +lint Lint project +:4 " `; diff --git a/tests/cli.test.ts b/tests/cli.test.ts index da2b863..20e5a00 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -89,6 +89,100 @@ describe.each(cliTools)('cli completion tests for %s', (cliTool) => { }); }); + describe.runIf(!shouldSkipTest)('boolean option handling', () => { + it('should not provide value completions for boolean options', async () => { + const command = `${commandPrefix} dev --verbose ""`; + const output = await runCommand(command); + // Boolean options should return just the directive, no completions + expect(output.trim()).toBe(':4'); + }); + + it('should not provide value completions for short boolean options', async () => { + const command = `${commandPrefix} dev -v ""`; + const output = await runCommand(command); + // Boolean options should return just the directive, no completions + expect(output.trim()).toBe(':4'); + }); + + it('should not interfere with command completion after boolean options', async () => { + const command = `${commandPrefix} dev --verbose s`; + const output = await runCommand(command); + // Should complete subcommands that start with 's' even after a boolean option + expect(output).toContain('start'); + }); + }); + + describe.runIf(!shouldSkipTest)('option API overload tests', () => { + it('should handle basic option (name + description only) as boolean flag', async () => { + // This tests the case: option('quiet', 'Suppress output') + const command = `${commandPrefix} dev --quiet ""`; + const output = await runCommand(command); + // Should be treated as boolean flag (no value completion) + expect(output.trim()).toBe(':4'); + }); + + it('should handle option with alias only as boolean flag', async () => { + // This tests the case: option('verbose', 'Enable verbose', 'v') + const command = `${commandPrefix} dev --verbose ""`; + const output = await runCommand(command); + // Should be treated as boolean flag (no value completion) + expect(output.trim()).toBe(':4'); + }); + + it('should handle option with alias only (short flag) as boolean flag', async () => { + // This tests the short flag version: -v instead of --verbose + const command = `${commandPrefix} dev -v ""`; + const output = await runCommand(command); + // Should be treated as boolean flag (no value completion) + expect(output.trim()).toBe(':4'); + }); + + it('should handle option with handler only as value option', async () => { + // This tests the case: option('port', 'Port number', handlerFunction) + const command = `${commandPrefix} dev --port ""`; + const output = await runCommand(command); + // Should provide value completions because it has a handler + expect(output).toContain('3000'); + expect(output).toContain('8080'); + }); + + it('should handle option with both handler and alias as value option', async () => { + // This tests the case: option('config', 'Config file', handlerFunction, 'c') + const command = `${commandPrefix} --config ""`; + const output = await runCommand(command); + // Should provide value completions because it has a handler + expect(output).toContain('vite.config.ts'); + expect(output).toContain('vite.config.js'); + }); + + it('should handle option with both handler and alias (short flag) as value option', async () => { + // This tests the short flag version with handler: -c instead of --config + const command = `${commandPrefix} -c ""`; + const output = await runCommand(command); + // Should provide value completions because it has a handler + expect(output).toContain('vite.config.ts'); + expect(output).toContain('vite.config.js'); + }); + + it('should correctly detect boolean vs value options in mixed scenarios', async () => { + // Test that boolean options don't interfere with value options + const command = `${commandPrefix} dev --verbose --port ""`; + const output = await runCommand(command); + // Should complete port values, not be confused by preceding boolean flag + expect(output).toContain('3000'); + expect(output).toContain('8080'); + }); + + it('should correctly handle aliases for different option types', async () => { + // Mix of boolean flag with alias (-v) and value option with alias (-p) + const command = `${commandPrefix} dev -v -p ""`; + const output = await runCommand(command); + // Should complete port values via short flag + expect(output).toContain('3000'); + expect(output).toContain('8080'); + }); + }); + describe.runIf(!shouldSkipTest)('--config option tests', () => { it('should complete --config option values', async () => { const command = `${commandPrefix} --config ""`;