From 5e52540320f1a97f49b5517c4f28487e4bba9180 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Wed, 6 Aug 2025 11:41:38 +0330 Subject: [PATCH 1/6] boolean opts --- examples/demo.cac.ts | 3 + examples/demo.citty.ts | 19 +++++- examples/demo.t.ts | 5 ++ src/cac.ts | 7 +- src/citty.ts | 16 +++-- src/t.ts | 97 +++++++++++++++++++++++++--- tests/__snapshots__/cli.test.ts.snap | 19 ++++-- tests/cli.test.ts | 23 +++++++ 8 files changed, 166 insertions(+), 23 deletions(-) diff --git a/examples/demo.cac.ts b/examples/demo.cac.ts index 3297fe3..3bd0649 100644 --- a/examples/demo.cac.ts +++ b/examples/demo.cac.ts @@ -13,6 +13,7 @@ cli .command('dev', 'Start dev server') .option('-H, --host [host]', `Specify hostname`) .option('-p, --port ', `Specify port`) + .option('-v, --verbose', `Enable verbose logging`) .action((options) => {}); cli @@ -23,6 +24,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..7690837 100644 --- a/examples/demo.citty.ts +++ b/examples/demo.citty.ts @@ -53,6 +53,11 @@ const devCommand = defineCommand({ description: 'Specify port', alias: 'p', }, + verbose: { + type: 'boolean', + description: 'Enable verbose logging', + alias: 'v', + }, }, run: () => {}, }); @@ -65,6 +70,14 @@ const buildCommand = defineCommand({ run: () => {}, }); +const startCommand = defineCommand({ + meta: { + name: 'start', + description: 'Start development server', + }, + run: () => {}, +}); + const copyCommand = defineCommand({ meta: { name: 'copy', @@ -100,9 +113,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..aa22a81 100644 --- a/examples/demo.t.ts +++ b/examples/demo.t.ts @@ -62,6 +62,8 @@ devCmd.option( 'p' ); +devCmd.option('verbose', 'Enable verbose logging', undefined, 'v', true); + // Serve command const serveCmd = t.command('serve', 'Start the server'); serveCmd.option( @@ -87,6 +89,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..2b6e646 100644 --- a/src/cac.ts +++ b/src/cac.ts @@ -77,6 +77,10 @@ export default async function tab( const shortFlag = option.name.match(/^-([a-zA-Z]), --/)?.[1]; const argName = option.name.replace(/^-[a-zA-Z], --/, ''); + // Detect if this is a boolean option by checking if it has or [value] in the raw definition + const isBoolean = + !option.rawName.includes('<') && !option.rawName.includes('['); + // Add option using t.ts API const targetCommand = isRootCommand ? t : command; if (targetCommand) { @@ -84,7 +88,8 @@ export default async function tab( argName, // Store just the option name without -- prefix option.description || '', commandCompletionConfig?.options?.[argName] ?? noopOptionHandler, - shortFlag + shortFlag, + isBoolean ); } } diff --git a/src/citty.ts b/src/citty.ts index 731a8eb..6b4f51f 100644 --- a/src/citty.ts +++ b/src/citty.ts @@ -105,7 +105,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 +138,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,11 +147,13 @@ async function handleSubCommands( : undefined; // Add option using t.ts API - store without -- prefix + const isBoolean = conf.type === 'boolean'; command.option( argName, conf.description ?? '', subCompletionConfig?.options?.[argName] ?? noopOptionHandler, - shortFlag + shortFlag, + isBoolean ); } } @@ -206,12 +205,15 @@ 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; // Add option using t.ts API - store without -- prefix + const isBoolean = conf.type === 'boolean'; t.option( argName, conf.description ?? '', - completionConfig?.options?.[argName] ?? noopOptionHandler + completionConfig?.options?.[argName] ?? noopOptionHandler, + undefined, + isBoolean ); } } diff --git a/src/t.ts b/src/t.ts index 5ecdada..6a88263 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; } } @@ -90,9 +92,17 @@ export class Command { value: string, description: string, handler?: OptionHandler, - alias?: string + alias?: string, + isBoolean?: boolean ) { - const option = new Option(this, value, description, handler, alias); + const option = new Option( + this, + value, + description, + handler, + alias, + isBoolean + ); this.options.set(value, option); return this; } @@ -135,7 +145,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 +207,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 +333,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 +430,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 +449,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..c37a5f7 100644 --- a/tests/__snapshots__/cli.test.ts.snap +++ b/tests/__snapshots__/cli.test.ts.snap @@ -188,7 +188,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 " `; @@ -470,7 +474,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 +484,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 " `; @@ -579,7 +585,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 +816,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..4ecc122 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -89,6 +89,29 @@ 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)('--config option tests', () => { it('should complete --config option values', async () => { const command = `${commandPrefix} --config ""`; From 9d4b0e649aa9a407421a1ec4a726216ea578d57a Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Wed, 6 Aug 2025 22:15:09 +0330 Subject: [PATCH 2/6] noop func --- examples/demo.t.ts | 4 ++-- src/t.ts | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/examples/demo.t.ts b/examples/demo.t.ts index aa22a81..20a9981 100644 --- a/examples/demo.t.ts +++ b/examples/demo.t.ts @@ -1,4 +1,4 @@ -import t from '../src/t'; +import t, { noopHandler } from '../src/t'; // Global options t.option( @@ -62,7 +62,7 @@ devCmd.option( 'p' ); -devCmd.option('verbose', 'Enable verbose logging', undefined, 'v', true); +devCmd.option('verbose', 'Enable verbose logging', noopHandler, 'v', true); // Serve command const serveCmd = t.command('serve', 'Start the server'); diff --git a/src/t.ts b/src/t.ts index 6a88263..a7c14d7 100644 --- a/src/t.ts +++ b/src/t.ts @@ -20,6 +20,8 @@ export type OptionHandler = ( options: OptionsMap ) => void; +export const noopHandler: OptionHandler = function () {}; + // Completion result types export type Completion = { description?: string; @@ -63,7 +65,7 @@ export class Option { command: Command, value: string, description: string, - handler?: OptionHandler, + handler: OptionHandler = noopHandler, alias?: string, isBoolean?: boolean ) { @@ -91,7 +93,7 @@ export class Command { option( value: string, description: string, - handler?: OptionHandler, + handler: OptionHandler = noopHandler, alias?: string, isBoolean?: boolean ) { From 09dd61d980dd54bb112649220e5f5841d77cc711 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Wed, 6 Aug 2025 22:27:39 +0330 Subject: [PATCH 3/6] signitures --- examples/demo.t.ts | 4 +-- src/t.ts | 69 ++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/examples/demo.t.ts b/examples/demo.t.ts index 20a9981..f251322 100644 --- a/examples/demo.t.ts +++ b/examples/demo.t.ts @@ -1,4 +1,4 @@ -import t, { noopHandler } from '../src/t'; +import t from '../src/t'; // Global options t.option( @@ -62,7 +62,7 @@ devCmd.option( 'p' ); -devCmd.option('verbose', 'Enable verbose logging', noopHandler, 'v', true); +devCmd.option('verbose', 'Enable verbose logging', 'v', true); // Serve command const serveCmd = t.command('serve', 'Start the server'); diff --git a/src/t.ts b/src/t.ts index a7c14d7..2171ef0 100644 --- a/src/t.ts +++ b/src/t.ts @@ -20,7 +20,8 @@ export type OptionHandler = ( options: OptionsMap ) => void; -export const noopHandler: OptionHandler = function () {}; +// Default no-op handler for options (internal) +const noopHandler: OptionHandler = function () { }; // Completion result types export type Completion = { @@ -65,7 +66,7 @@ export class Option { command: Command, value: string, description: string, - handler: OptionHandler = noopHandler, + handler?: OptionHandler, alias?: string, isBoolean?: boolean ) { @@ -90,20 +91,64 @@ 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 = noopHandler, - alias?: string, + alias: string, + isBoolean: boolean + ): Command; + option(value: string, description: string, handler: OptionHandler): Command; + option( + value: string, + description: string, + handler: OptionHandler, + alias: string + ): Command; + option( + value: string, + description: string, + handler: OptionHandler, + alias: string, + isBoolean: boolean + ): Command; + option( + value: string, + description: string, + handlerOrAlias?: OptionHandler | string, + aliasOrIsBoolean?: string | boolean, isBoolean?: boolean - ) { + ): Command { + let handler: OptionHandler = noopHandler; + let alias: string | undefined; + let isBooleanFlag: boolean | undefined; + + // Parse arguments based on types + if (typeof handlerOrAlias === 'function') { + // handler provided + handler = handlerOrAlias; + alias = aliasOrIsBoolean as string; + isBooleanFlag = isBoolean; + } else if (typeof handlerOrAlias === 'string') { + // alias provided (no handler) + alias = handlerOrAlias; + isBooleanFlag = aliasOrIsBoolean as boolean; + } else if (handlerOrAlias === undefined) { + // neither handler nor alias provided + if (typeof aliasOrIsBoolean === 'boolean') { + isBooleanFlag = aliasOrIsBoolean; + } + } + const option = new Option( this, value, description, handler, alias, - isBoolean + isBooleanFlag ); this.options.set(value, option); return this; @@ -279,9 +324,9 @@ export class RootCommand extends Command { this.completions = toComplete.includes('=') ? suggestions.map((s) => ({ - value: `${optionName}=${s.value}`, - description: s.description, - })) + value: `${optionName}=${s.value}`, + description: s.description, + })) : suggestions; } return; @@ -492,9 +537,9 @@ export class RootCommand extends Command { setup(name: string, executable: string, shell: string) { assert( shell === 'zsh' || - shell === 'bash' || - shell === 'fish' || - shell === 'powershell', + shell === 'bash' || + shell === 'fish' || + shell === 'powershell', 'Unsupported shell' ); From d76f4e103e324b3456db290ddc467e34ac7e1a01 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Thu, 7 Aug 2025 17:00:24 +0330 Subject: [PATCH 4/6] boolean flags --- examples/demo.t.ts | 2 +- src/cac.ts | 33 +++++++++++++++++++++---------- src/citty.ts | 49 ++++++++++++++++++++++++++++------------------ src/t.ts | 45 ++++++++++++------------------------------ 4 files changed, 67 insertions(+), 62 deletions(-) diff --git a/examples/demo.t.ts b/examples/demo.t.ts index f251322..15fdf08 100644 --- a/examples/demo.t.ts +++ b/examples/demo.t.ts @@ -62,7 +62,7 @@ devCmd.option( 'p' ); -devCmd.option('verbose', 'Enable verbose logging', 'v', true); +devCmd.option('verbose', 'Enable verbose logging', 'v'); // Serve command const serveCmd = t.command('serve', 'Start the server'); diff --git a/src/cac.ts b/src/cac.ts index 2b6e646..fc5c390 100644 --- a/src/cac.ts +++ b/src/cac.ts @@ -4,12 +4,10 @@ import * as fish from './fish'; import * as powershell from './powershell'; import type { CAC } from 'cac'; import { assertDoubleDashes } from './shared'; -import { OptionHandler } from './t'; +import { OptionHandler, noopHandler } 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); @@ -84,13 +82,28 @@ export default async function tab( // 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, - isBoolean - ); + // Auto-detection handles boolean vs value options based on handler presence + const customHandler = commandCompletionConfig?.options?.[argName]; + const handler = isBoolean ? noopHandler : customHandler; + + if (shortFlag) { + if (handler) { + targetCommand.option( + argName, + option.description || '', + handler, + shortFlag + ); + } else { + targetCommand.option(argName, option.description || '', shortFlag); + } + } else { + if (handler) { + targetCommand.option(argName, option.description || '', handler); + } else { + targetCommand.option(argName, option.description || ''); + } + } } } } diff --git a/src/citty.ts b/src/citty.ts index 6b4f51f..afe1819 100644 --- a/src/citty.ts +++ b/src/citty.ts @@ -11,7 +11,7 @@ import type { } from 'citty'; import { generateFigSpec } from './fig'; import { CompletionConfig, assertDoubleDashes } from './shared'; -import { OptionHandler, Command, Option, OptionsMap } from './t'; +import { OptionHandler, Command, Option, OptionsMap, noopHandler } from './t'; import t from './t'; function quoteIfNeeded(path: string) { @@ -85,8 +85,6 @@ function convertOptionHandler(handler: any): OptionHandler { }; } -const noopOptionHandler: OptionHandler = function () {}; - async function handleSubCommands( subCommands: SubCommandsDef, parentCmd?: string, @@ -146,15 +144,25 @@ async function handleSubCommands( : conf.alias : undefined; - // Add option using t.ts API - store without -- prefix + // Detect boolean options and use appropriate handler const isBoolean = conf.type === 'boolean'; - command.option( - argName, - conf.description ?? '', - subCompletionConfig?.options?.[argName] ?? noopOptionHandler, - shortFlag, - isBoolean - ); + const customHandler = subCompletionConfig?.options?.[argName]; + const handler = isBoolean ? noopHandler : customHandler; + + // Add option using t.ts API - auto-detection handles boolean vs value options + if (shortFlag) { + if (handler) { + command.option(argName, conf.description ?? '', handler, shortFlag); + } else { + command.option(argName, conf.description ?? '', shortFlag); + } + } else { + if (handler) { + command.option(argName, conf.description ?? '', handler); + } else { + command.option(argName, conf.description ?? ''); + } + } } } } @@ -206,15 +214,18 @@ export default async function tab( if (instance.args) { for (const [argName, argConfig] of Object.entries(instance.args)) { const conf = argConfig as ArgDef; - // Add option using t.ts API - store without -- prefix + + // Detect boolean options and use appropriate handler const isBoolean = conf.type === 'boolean'; - t.option( - argName, - conf.description ?? '', - completionConfig?.options?.[argName] ?? noopOptionHandler, - undefined, - isBoolean - ); + const customHandler = completionConfig?.options?.[argName]; + const handler = isBoolean ? noopHandler : customHandler; + + // Add option using t.ts API - auto-detection handles boolean vs value options + if (handler) { + t.option(argName, conf.description ?? '', handler); + } else { + t.option(argName, conf.description ?? ''); + } } } diff --git a/src/t.ts b/src/t.ts index 2171ef0..d3f410e 100644 --- a/src/t.ts +++ b/src/t.ts @@ -20,8 +20,8 @@ export type OptionHandler = ( options: OptionsMap ) => void; -// Default no-op handler for options (internal) -const noopHandler: OptionHandler = function () { }; +// Default no-op handler for options (exported for integrations) +export const noopHandler: OptionHandler = function () { }; // Completion result types export type Completion = { @@ -94,12 +94,6 @@ export class Command { // Function overloads for better UX option(value: string, description: string): Command; option(value: string, description: string, alias: string): Command; - option( - value: string, - description: string, - alias: string, - isBoolean: boolean - ): Command; option(value: string, description: string, handler: OptionHandler): Command; option( value: string, @@ -107,48 +101,35 @@ export class Command { handler: OptionHandler, alias: string ): Command; - option( - value: string, - description: string, - handler: OptionHandler, - alias: string, - isBoolean: boolean - ): Command; option( value: string, description: string, handlerOrAlias?: OptionHandler | string, - aliasOrIsBoolean?: string | boolean, - isBoolean?: boolean + alias?: string ): Command { let handler: OptionHandler = noopHandler; - let alias: string | undefined; - let isBooleanFlag: boolean | undefined; + let aliasValue: string | undefined; // Parse arguments based on types if (typeof handlerOrAlias === 'function') { - // handler provided + // handler provided, value option handler = handlerOrAlias; - alias = aliasOrIsBoolean as string; - isBooleanFlag = isBoolean; + aliasValue = alias; } else if (typeof handlerOrAlias === 'string') { - // alias provided (no handler) - alias = handlerOrAlias; - isBooleanFlag = aliasOrIsBoolean as boolean; - } else if (handlerOrAlias === undefined) { - // neither handler nor alias provided - if (typeof aliasOrIsBoolean === 'boolean') { - isBooleanFlag = aliasOrIsBoolean; - } + // alias provided, no handler, boolean flag + aliasValue = handlerOrAlias; } + // if no custom handler provided, it's a boolean flag + const isBoolean = handler === noopHandler; + const option = new Option( this, value, description, handler, - alias, - isBooleanFlag + aliasValue, + isBoolean ); this.options.set(value, option); return this; From 5ec5ccc2680b337bb035a1e72c6f2cddf0f71b87 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Thu, 7 Aug 2025 17:05:03 +0330 Subject: [PATCH 5/6] prettier --- src/t.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/t.ts b/src/t.ts index d3f410e..9878773 100644 --- a/src/t.ts +++ b/src/t.ts @@ -21,7 +21,7 @@ export type OptionHandler = ( ) => void; // Default no-op handler for options (exported for integrations) -export const noopHandler: OptionHandler = function () { }; +export const noopHandler: OptionHandler = function () {}; // Completion result types export type Completion = { @@ -305,9 +305,9 @@ export class RootCommand extends Command { this.completions = toComplete.includes('=') ? suggestions.map((s) => ({ - value: `${optionName}=${s.value}`, - description: s.description, - })) + value: `${optionName}=${s.value}`, + description: s.description, + })) : suggestions; } return; @@ -518,9 +518,9 @@ export class RootCommand extends Command { setup(name: string, executable: string, shell: string) { assert( shell === 'zsh' || - shell === 'bash' || - shell === 'fish' || - shell === 'powershell', + shell === 'bash' || + shell === 'fish' || + shell === 'powershell', 'Unsupported shell' ); From b75dd11bc6f37fd00802d25932d4c23f936ffbaa Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Thu, 7 Aug 2025 19:11:55 +0330 Subject: [PATCH 6/6] update --- examples/demo.cac.ts | 1 + examples/demo.citty.ts | 4 ++ examples/demo.t.ts | 3 ++ src/cac.ts | 28 +++++------ src/citty.ts | 50 ++++++++++++-------- src/t.ts | 26 +++++----- tests/__snapshots__/cli.test.ts.snap | 54 +++++++++++++++------ tests/cli.test.ts | 71 ++++++++++++++++++++++++++++ 8 files changed, 174 insertions(+), 63 deletions(-) diff --git a/examples/demo.cac.ts b/examples/demo.cac.ts index 3bd0649..ff95a1a 100644 --- a/examples/demo.cac.ts +++ b/examples/demo.cac.ts @@ -14,6 +14,7 @@ cli .option('-H, --host [host]', `Specify hostname`) .option('-p, --port ', `Specify port`) .option('-v, --verbose', `Enable verbose logging`) + .option('--quiet', `Suppress output`) .action((options) => {}); cli diff --git a/examples/demo.citty.ts b/examples/demo.citty.ts index 7690837..5c17604 100644 --- a/examples/demo.citty.ts +++ b/examples/demo.citty.ts @@ -58,6 +58,10 @@ const devCommand = defineCommand({ description: 'Enable verbose logging', alias: 'v', }, + quiet: { + type: 'boolean', + description: 'Suppress output', + }, }, run: () => {}, }); diff --git a/examples/demo.t.ts b/examples/demo.t.ts index 15fdf08..38f3569 100644 --- a/examples/demo.t.ts +++ b/examples/demo.t.ts @@ -64,6 +64,9 @@ devCmd.option( 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( diff --git a/src/cac.ts b/src/cac.ts index fc5c390..72bbdd5 100644 --- a/src/cac.ts +++ b/src/cac.ts @@ -4,7 +4,6 @@ import * as fish from './fish'; import * as powershell from './powershell'; import type { CAC } from 'cac'; import { assertDoubleDashes } from './shared'; -import { OptionHandler, noopHandler } from './t'; import { CompletionConfig } from './shared'; import t from './t'; @@ -71,23 +70,17 @@ 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], --/, ''); - - // Detect if this is a boolean option by checking if it has or [value] in the raw definition - const isBoolean = - !option.rawName.includes('<') && !option.rawName.includes('['); + // 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) { - // Auto-detection handles boolean vs value options based on handler presence - const customHandler = commandCompletionConfig?.options?.[argName]; - const handler = isBoolean ? noopHandler : customHandler; - - if (shortFlag) { - if (handler) { + const handler = commandCompletionConfig?.options?.[argName]; + if (handler) { + // Has custom handler → value option + if (shortFlag) { targetCommand.option( argName, option.description || '', @@ -95,11 +88,12 @@ export default async function tab( shortFlag ); } else { - targetCommand.option(argName, option.description || '', shortFlag); + targetCommand.option(argName, option.description || '', handler); } } else { - if (handler) { - targetCommand.option(argName, option.description || '', handler); + // 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 afe1819..4ff5e21 100644 --- a/src/citty.ts +++ b/src/citty.ts @@ -11,7 +11,7 @@ import type { } from 'citty'; import { generateFigSpec } from './fig'; import { CompletionConfig, assertDoubleDashes } from './shared'; -import { OptionHandler, Command, Option, OptionsMap, noopHandler } from './t'; +import { OptionHandler, Command, Option, OptionsMap } from './t'; import t from './t'; function quoteIfNeeded(path: string) { @@ -144,21 +144,19 @@ async function handleSubCommands( : conf.alias : undefined; - // Detect boolean options and use appropriate handler - const isBoolean = conf.type === 'boolean'; - const customHandler = subCompletionConfig?.options?.[argName]; - const handler = isBoolean ? noopHandler : customHandler; - - // Add option using t.ts API - auto-detection handles boolean vs value options - if (shortFlag) { - if (handler) { + // Add option using t.ts API - store without -- prefix + 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 ?? '', shortFlag); + command.option(argName, conf.description ?? '', handler); } } else { - if (handler) { - command.option(argName, conf.description ?? '', handler); + // No custom handler → boolean flag + if (shortFlag) { + command.option(argName, conf.description ?? '', shortFlag); } else { command.option(argName, conf.description ?? ''); } @@ -215,16 +213,30 @@ export default async function tab( for (const [argName, argConfig] of Object.entries(instance.args)) { const conf = argConfig as ArgDef; - // Detect boolean options and use appropriate handler - const isBoolean = conf.type === 'boolean'; - const customHandler = completionConfig?.options?.[argName]; - const handler = isBoolean ? noopHandler : customHandler; + // 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 - auto-detection handles boolean vs value options + // Add option using t.ts API - store without -- prefix + const handler = completionConfig?.options?.[argName]; if (handler) { - t.option(argName, conf.description ?? '', handler); + // Has custom handler → value option + if (shortFlag) { + t.option(argName, conf.description ?? '', handler, shortFlag); + } else { + t.option(argName, conf.description ?? '', handler); + } } else { - t.option(argName, conf.description ?? ''); + // 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 9878773..29321a4 100644 --- a/src/t.ts +++ b/src/t.ts @@ -20,9 +20,6 @@ export type OptionHandler = ( options: OptionsMap ) => void; -// Default no-op handler for options (exported for integrations) -export const noopHandler: OptionHandler = function () {}; - // Completion result types export type Completion = { description?: string; @@ -107,28 +104,31 @@ export class Command { handlerOrAlias?: OptionHandler | string, alias?: string ): Command { - let handler: OptionHandler = noopHandler; - let aliasValue: string | undefined; + let handler: OptionHandler | undefined; + let aliasStr: string | undefined; + let isBoolean: boolean; // Parse arguments based on types if (typeof handlerOrAlias === 'function') { - // handler provided, value option handler = handlerOrAlias; - aliasValue = alias; + aliasStr = alias; + isBoolean = false; } else if (typeof handlerOrAlias === 'string') { - // alias provided, no handler, boolean flag - aliasValue = handlerOrAlias; + handler = undefined; + aliasStr = handlerOrAlias; + isBoolean = true; + } else { + handler = undefined; + aliasStr = undefined; + isBoolean = true; } - // if no custom handler provided, it's a boolean flag - const isBoolean = handler === noopHandler; - const option = new Option( this, value, description, handler, - aliasValue, + aliasStr, isBoolean ); this.options.set(value, option); diff --git a/tests/__snapshots__/cli.test.ts.snap b/tests/__snapshots__/cli.test.ts.snap index c37a5f7..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 " `; @@ -249,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 " `; @@ -312,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 " `; @@ -548,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 " `; diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 4ecc122..20e5a00 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -112,6 +112,77 @@ describe.each(cliTools)('cli completion tests for %s', (cliTool) => { }); }); + 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 ""`;