[READ-ONLY] Mirror of https://github.com/bombshell-dev/tab. shell autocompletions for javascript CLIs
4

Configure Feed

Select the types of activity you want to include in your feed.

fix: boolean opts (#27)

* boolean opts

* noop func

* signitures

* boolean flags

* prettier

* update

authored by

AmirHossein Sakhravi and committed by
GitHub
(Aug 8, 2025, 6:33 AM +0330) 32c2f660 571f12ee

+366 -60
+4
examples/demo.cac.ts
··· 13 13 .command('dev', 'Start dev server') 14 14 .option('-H, --host [host]', `Specify hostname`) 15 15 .option('-p, --port <port>', `Specify port`) 16 + .option('-v, --verbose', `Enable verbose logging`) 17 + .option('--quiet', `Suppress output`) 16 18 .action((options) => {}); 17 19 18 20 cli ··· 22 24 .action((options) => {}); 23 25 24 26 cli.command('dev build', 'Build project').action((options) => {}); 27 + 28 + cli.command('dev start', 'Start development server').action((options) => {}); 25 29 26 30 cli 27 31 .command('copy <source> <destination>', 'Copy files')
+22 -1
examples/demo.citty.ts
··· 53 53 description: 'Specify port', 54 54 alias: 'p', 55 55 }, 56 + verbose: { 57 + type: 'boolean', 58 + description: 'Enable verbose logging', 59 + alias: 'v', 60 + }, 61 + quiet: { 62 + type: 'boolean', 63 + description: 'Suppress output', 64 + }, 56 65 }, 57 66 run: () => {}, 58 67 }); ··· 61 70 meta: { 62 71 name: 'build', 63 72 description: 'Build project', 73 + }, 74 + run: () => {}, 75 + }); 76 + 77 + const startCommand = defineCommand({ 78 + meta: { 79 + name: 'start', 80 + description: 'Start development server', 64 81 }, 65 82 run: () => {}, 66 83 }); ··· 100 117 run: () => {}, 101 118 }); 102 119 120 + devCommand.subCommands = { 121 + build: buildCommand, 122 + start: startCommand, 123 + }; 124 + 103 125 main.subCommands = { 104 126 dev: devCommand, 105 - build: buildCommand, 106 127 copy: copyCommand, 107 128 lint: lintCommand, 108 129 } as Record<string, CommandDef<ArgsDef>>;
+8
examples/demo.t.ts
··· 62 62 'p' 63 63 ); 64 64 65 + devCmd.option('verbose', 'Enable verbose logging', 'v'); 66 + 67 + // Add a simple quiet option to test basic option API (no handler, no alias) 68 + devCmd.option('quiet', 'Suppress output'); 69 + 65 70 // Serve command 66 71 const serveCmd = t.command('serve', 'Start the server'); 67 72 serveCmd.option( ··· 86 91 87 92 // Build command 88 93 t.command('dev build', 'Build project'); 94 + 95 + // Start command 96 + t.command('dev start', 'Start development server'); 89 97 90 98 // Copy command with multiple arguments 91 99 const copyCmd = t
+24 -12
src/cac.ts
··· 4 4 import * as powershell from './powershell'; 5 5 import type { CAC } from 'cac'; 6 6 import { assertDoubleDashes } from './shared'; 7 - import { OptionHandler } from './t'; 8 7 import { CompletionConfig } from './shared'; 9 8 import t from './t'; 10 - 11 - const noopOptionHandler: OptionHandler = function () {}; 12 9 13 10 const execPath = process.execPath; 14 11 const processArgs = process.argv.slice(1); ··· 73 70 74 71 // Add command options 75 72 for (const option of [...instance.globalCommand.options, ...cmd.options]) { 76 - // Extract short flag from the name if it exists (e.g., "-c, --config" -> "c") 77 - const shortFlag = option.name.match(/^-([a-zA-Z]), --/)?.[1]; 78 - const argName = option.name.replace(/^-[a-zA-Z], --/, ''); 73 + // Extract short flag from the rawName if it exists (e.g., "-c, --config" -> "c") 74 + const shortFlag = option.rawName.match(/^-([a-zA-Z]), --/)?.[1]; 75 + const argName = option.name; // option.name is already clean (e.g., "config") 79 76 80 77 // Add option using t.ts API 81 78 const targetCommand = isRootCommand ? t : command; 82 79 if (targetCommand) { 83 - targetCommand.option( 84 - argName, // Store just the option name without -- prefix 85 - option.description || '', 86 - commandCompletionConfig?.options?.[argName] ?? noopOptionHandler, 87 - shortFlag 88 - ); 80 + const handler = commandCompletionConfig?.options?.[argName]; 81 + if (handler) { 82 + // Has custom handler → value option 83 + if (shortFlag) { 84 + targetCommand.option( 85 + argName, 86 + option.description || '', 87 + handler, 88 + shortFlag 89 + ); 90 + } else { 91 + targetCommand.option(argName, option.description || '', handler); 92 + } 93 + } else { 94 + // No custom handler → boolean flag 95 + if (shortFlag) { 96 + targetCommand.option(argName, option.description || '', shortFlag); 97 + } else { 98 + targetCommand.option(argName, option.description || ''); 99 + } 100 + } 89 101 } 90 102 } 91 103 }
+43 -18
src/citty.ts
··· 85 85 }; 86 86 } 87 87 88 - const noopOptionHandler: OptionHandler = function () {}; 89 - 90 88 async function handleSubCommands( 91 89 subCommands: SubCommandsDef, 92 90 parentCmd?: string, ··· 105 103 106 104 // Add command using t.ts API 107 105 const commandName = parentCmd ? `${parentCmd} ${cmd}` : cmd; 108 - const command = t.command(cmd, meta.description); 106 + const command = t.command(commandName, meta.description); 109 107 110 108 // Set args for the command if it has positional arguments 111 109 if (isPositional && config.args) { ··· 138 136 if (config.args) { 139 137 for (const [argName, argConfig] of Object.entries(config.args)) { 140 138 const conf = argConfig as ArgDef; 141 - if (conf.type === 'positional') { 142 - continue; 143 - } 144 139 // Extract alias from the config if it exists 145 140 const shortFlag = 146 141 typeof conf === 'object' && 'alias' in conf ··· 150 145 : undefined; 151 146 152 147 // Add option using t.ts API - store without -- prefix 153 - command.option( 154 - argName, 155 - conf.description ?? '', 156 - subCompletionConfig?.options?.[argName] ?? noopOptionHandler, 157 - shortFlag 158 - ); 148 + const handler = subCompletionConfig?.options?.[argName]; 149 + if (handler) { 150 + // Has custom handler → value option 151 + if (shortFlag) { 152 + command.option(argName, conf.description ?? '', handler, shortFlag); 153 + } else { 154 + command.option(argName, conf.description ?? '', handler); 155 + } 156 + } else { 157 + // No custom handler → boolean flag 158 + if (shortFlag) { 159 + command.option(argName, conf.description ?? '', shortFlag); 160 + } else { 161 + command.option(argName, conf.description ?? ''); 162 + } 163 + } 159 164 } 160 165 } 161 166 } ··· 206 211 207 212 if (instance.args) { 208 213 for (const [argName, argConfig] of Object.entries(instance.args)) { 209 - const conf = argConfig as PositionalArgDef; 214 + const conf = argConfig as ArgDef; 215 + 216 + // Extract alias (same logic as subcommands) 217 + const shortFlag = 218 + typeof conf === 'object' && 'alias' in conf 219 + ? Array.isArray(conf.alias) 220 + ? conf.alias[0] 221 + : conf.alias 222 + : undefined; 223 + 210 224 // Add option using t.ts API - store without -- prefix 211 - t.option( 212 - argName, 213 - conf.description ?? '', 214 - completionConfig?.options?.[argName] ?? noopOptionHandler 215 - ); 225 + const handler = completionConfig?.options?.[argName]; 226 + if (handler) { 227 + // Has custom handler → value option 228 + if (shortFlag) { 229 + t.option(argName, conf.description ?? '', handler, shortFlag); 230 + } else { 231 + t.option(argName, conf.description ?? '', handler); 232 + } 233 + } else { 234 + // No custom handler → boolean flag 235 + if (shortFlag) { 236 + t.option(argName, conf.description ?? '', shortFlag); 237 + } else { 238 + t.option(argName, conf.description ?? ''); 239 + } 240 + } 216 241 } 217 242 } 218 243
+117 -10
src/t.ts
··· 57 57 command: Command; 58 58 handler?: OptionHandler; 59 59 alias?: string; 60 - // TODO: handle boolean options 60 + isBoolean?: boolean; 61 61 62 62 constructor( 63 63 command: Command, 64 64 value: string, 65 65 description: string, 66 66 handler?: OptionHandler, 67 - alias?: string 67 + alias?: string, 68 + isBoolean?: boolean 68 69 ) { 69 70 this.command = command; 70 71 this.value = value; 71 72 this.description = description; 72 73 this.handler = handler; 73 74 this.alias = alias; 75 + this.isBoolean = isBoolean; 74 76 } 75 77 } 76 78 ··· 86 88 this.description = description; 87 89 } 88 90 91 + // Function overloads for better UX 92 + option(value: string, description: string): Command; 93 + option(value: string, description: string, alias: string): Command; 94 + option(value: string, description: string, handler: OptionHandler): Command; 89 95 option( 90 96 value: string, 91 97 description: string, 92 - handler?: OptionHandler, 98 + handler: OptionHandler, 99 + alias: string 100 + ): Command; 101 + option( 102 + value: string, 103 + description: string, 104 + handlerOrAlias?: OptionHandler | string, 93 105 alias?: string 94 - ) { 95 - const option = new Option(this, value, description, handler, alias); 106 + ): Command { 107 + let handler: OptionHandler | undefined; 108 + let aliasStr: string | undefined; 109 + let isBoolean: boolean; 110 + 111 + // Parse arguments based on types 112 + if (typeof handlerOrAlias === 'function') { 113 + handler = handlerOrAlias; 114 + aliasStr = alias; 115 + isBoolean = false; 116 + } else if (typeof handlerOrAlias === 'string') { 117 + handler = undefined; 118 + aliasStr = handlerOrAlias; 119 + isBoolean = true; 120 + } else { 121 + handler = undefined; 122 + aliasStr = undefined; 123 + isBoolean = true; 124 + } 125 + 126 + const option = new Option( 127 + this, 128 + value, 129 + description, 130 + handler, 131 + aliasStr, 132 + isBoolean 133 + ); 96 134 this.options.set(value, option); 97 135 return this; 98 136 } ··· 135 173 136 174 if (arg.startsWith('-')) { 137 175 i++; // Skip the option 138 - if (i < args.length && !args[i].startsWith('-')) { 176 + 177 + // Check if this option expects a value (not boolean) 178 + // We need to check across all commands since we don't know which command context we're in yet 179 + let isBoolean = false; 180 + 181 + // Check root command options 182 + const rootOption = this.findOption(this, arg); 183 + if (rootOption) { 184 + isBoolean = rootOption.isBoolean ?? false; 185 + } else { 186 + // Check all subcommand options 187 + for (const [, command] of this.commands) { 188 + const option = this.findOption(command, arg); 189 + if (option) { 190 + isBoolean = option.isBoolean ?? false; 191 + break; 192 + } 193 + } 194 + } 195 + 196 + // Only skip the next argument if this is not a boolean option and the next arg doesn't start with - 197 + if (!isBoolean && i < args.length && !args[i].startsWith('-')) { 139 198 i++; // Skip the option value 140 199 } 141 200 } else { ··· 176 235 toComplete: string, 177 236 endsWithSpace: boolean 178 237 ): boolean { 179 - return lastPrevArg?.startsWith('-') || toComplete.startsWith('-'); 238 + // Always complete if the current token starts with a dash 239 + if (toComplete.startsWith('-')) { 240 + return true; 241 + } 242 + 243 + // If the previous argument was an option, check if it expects a value 244 + if (lastPrevArg?.startsWith('-')) { 245 + // Find the option to check if it's boolean 246 + let option = this.findOption(this, lastPrevArg); 247 + if (!option) { 248 + // Check all subcommand options 249 + for (const [, command] of this.commands) { 250 + option = this.findOption(command, lastPrevArg); 251 + if (option) break; 252 + } 253 + } 254 + 255 + // If it's a boolean option, don't try to complete its value 256 + if (option && option.isBoolean) { 257 + return false; 258 + } 259 + 260 + // Non-boolean options expect values 261 + return true; 262 + } 263 + 264 + return false; 180 265 } 181 266 182 267 // Determine if we should complete commands ··· 276 361 277 362 // Handle command completion 278 363 private handleCommandCompletion(previousArgs: string[], toComplete: string) { 279 - const commandParts = previousArgs.filter(Boolean); 364 + const commandParts = this.stripOptions(previousArgs); 280 365 281 366 for (const [k, command] of this.commands) { 282 367 if (k === '') continue; ··· 373 458 const previousArgs = args.slice(0, -1); 374 459 375 460 if (endsWithSpace) { 376 - previousArgs.push(toComplete); 461 + if (toComplete !== '') { 462 + previousArgs.push(toComplete); 463 + } 377 464 toComplete = ''; 378 465 } 379 466 ··· 390 477 lastPrevArg 391 478 ); 392 479 } else { 480 + // Check if we just finished a boolean option with no value expected 481 + // In this case, don't complete anything 482 + if (lastPrevArg?.startsWith('-') && toComplete === '' && endsWithSpace) { 483 + let option = this.findOption(this, lastPrevArg); 484 + if (!option) { 485 + // Check all subcommand options 486 + for (const [, command] of this.commands) { 487 + option = this.findOption(command, lastPrevArg); 488 + if (option) break; 489 + } 490 + } 491 + 492 + // If it's a boolean option followed by empty space, don't complete anything 493 + if (option && option.isBoolean) { 494 + // Don't add any completions, just output the directive 495 + this.complete(toComplete); 496 + return; 497 + } 498 + } 499 + 393 500 // 2. Handle command/subcommand completion 394 501 if (this.shouldCompleteCommands(toComplete, endsWithSpace)) { 395 502 this.handleCommandCompletion(previousArgs, toComplete); 396 503 } 397 - // 3. Handle positional arguments 504 + // 3. Handle positional arguments - always check for root command arguments 398 505 if (matchedCommand && matchedCommand.arguments.size > 0) { 399 506 this.handlePositionalCompletion( 400 507 matchedCommand,
+54 -19
tests/__snapshots__/cli.test.ts.snap
··· 22 22 `; 23 23 24 24 exports[`cli completion tests for cac > --config option tests > should complete short flag -c option values 1`] = ` 25 - ":4 25 + "vite.config.ts Vite config file 26 + vite.config.js Vite config file 27 + :4 26 28 " 27 29 `; 28 30 29 31 exports[`cli completion tests for cac > --config option tests > should complete short flag -c option with partial input 1`] = ` 30 - ":4 32 + "vite.config.ts Vite config file 33 + vite.config.js Vite config file 34 + :4 31 35 " 32 36 `; 33 37 ··· 46 50 `; 47 51 48 52 exports[`cli completion tests for cac > cli option completion tests > should complete option for partial input '{ partial: '-H', expected: '-H' }' 1`] = ` 49 - ":4 53 + "-H Specify hostname 54 + :4 50 55 " 51 56 `; 52 57 53 58 exports[`cli completion tests for cac > cli option completion tests > should complete option for partial input '{ partial: '-p', expected: '-p' }' 1`] = ` 54 - ":4 59 + "-p Specify port 60 + :4 55 61 " 56 62 `; 57 63 ··· 188 194 `; 189 195 190 196 exports[`cli completion tests for cac > root command argument tests > should complete root command project argument after options 1`] = ` 191 - ":4 197 + "dev Start dev server 198 + serve Start the server 199 + copy Copy files 200 + lint Lint project 201 + :4 192 202 " 193 203 `; 194 204 ··· 245 255 `; 246 256 247 257 exports[`cli completion tests for cac > root command option tests > should complete root command short flag -l option values 1`] = ` 248 - ":4 258 + "info Info level 259 + warn Warn level 260 + error Error level 261 + silent Silent level 262 + :4 249 263 " 250 264 `; 251 265 252 266 exports[`cli completion tests for cac > root command option tests > should complete root command short flag -m option values 1`] = ` 253 - ":4 267 + "development Development mode 268 + production Production mode 269 + :4 254 270 " 255 271 `; 256 272 257 273 exports[`cli completion tests for cac > short flag handling > should handle global short flags 1`] = ` 258 - ":4 274 + "-c Use specified config file 275 + :4 259 276 " 260 277 `; 261 278 262 279 exports[`cli completion tests for cac > short flag handling > should handle short flag value completion 1`] = ` 263 - ":4 280 + "-p Specify port 281 + :4 264 282 " 265 283 `; 266 284 267 285 exports[`cli completion tests for cac > short flag handling > should handle short flag with equals sign 1`] = ` 268 - ":4 286 + "-p=3000 Development server port 287 + :4 269 288 " 270 289 `; 271 290 ··· 308 327 `; 309 328 310 329 exports[`cli completion tests for citty > --config option tests > should complete short flag -c option values 1`] = ` 311 - ":4 330 + "vite.config.ts Vite config file 331 + vite.config.js Vite config file 332 + :4 312 333 " 313 334 `; 314 335 315 336 exports[`cli completion tests for citty > --config option tests > should complete short flag -c option with partial input 1`] = ` 316 - ":4 337 + "vite.config.ts Vite config file 338 + vite.config.js Vite config file 339 + :4 317 340 " 318 341 `; 319 342 ··· 470 493 471 494 exports[`cli completion tests for citty > root command argument tests > should complete root command project argument 1`] = ` 472 495 "dev Start dev server 473 - build Build project 474 496 copy Copy files 475 497 lint Lint project 476 498 my-app My application ··· 481 503 `; 482 504 483 505 exports[`cli completion tests for citty > root command argument tests > should complete root command project argument after options 1`] = ` 484 - ":4 506 + "dev Start dev server 507 + copy Copy files 508 + lint Lint project 509 + :4 485 510 " 486 511 `; 487 512 ··· 542 567 `; 543 568 544 569 exports[`cli completion tests for citty > root command option tests > should complete root command short flag -l option values 1`] = ` 545 - ":4 570 + "info Info level 571 + warn Warn level 572 + error Error level 573 + silent Silent level 574 + :4 546 575 " 547 576 `; 548 577 549 578 exports[`cli completion tests for citty > root command option tests > should complete root command short flag -m option values 1`] = ` 550 - ":4 579 + "development Development mode 580 + production Production mode 581 + :4 551 582 " 552 583 `; 553 584 554 585 exports[`cli completion tests for citty > short flag handling > should handle global short flags 1`] = ` 555 - ":4 586 + "-c Use specified config file 587 + :4 556 588 " 557 589 `; 558 590 ··· 579 611 580 612 exports[`cli completion tests for citty > should complete cli options 1`] = ` 581 613 "dev Start dev server 582 - build Build project 583 614 copy Copy files 584 615 lint Lint project 585 616 my-app My application ··· 811 842 `; 812 843 813 844 exports[`cli completion tests for t > root command argument tests > should complete root command project argument after options 1`] = ` 814 - ":4 845 + "dev Start dev server 846 + serve Start the server 847 + copy Copy files 848 + lint Lint project 849 + :4 815 850 " 816 851 `; 817 852
+94
tests/cli.test.ts
··· 89 89 }); 90 90 }); 91 91 92 + describe.runIf(!shouldSkipTest)('boolean option handling', () => { 93 + it('should not provide value completions for boolean options', async () => { 94 + const command = `${commandPrefix} dev --verbose ""`; 95 + const output = await runCommand(command); 96 + // Boolean options should return just the directive, no completions 97 + expect(output.trim()).toBe(':4'); 98 + }); 99 + 100 + it('should not provide value completions for short boolean options', async () => { 101 + const command = `${commandPrefix} dev -v ""`; 102 + const output = await runCommand(command); 103 + // Boolean options should return just the directive, no completions 104 + expect(output.trim()).toBe(':4'); 105 + }); 106 + 107 + it('should not interfere with command completion after boolean options', async () => { 108 + const command = `${commandPrefix} dev --verbose s`; 109 + const output = await runCommand(command); 110 + // Should complete subcommands that start with 's' even after a boolean option 111 + expect(output).toContain('start'); 112 + }); 113 + }); 114 + 115 + describe.runIf(!shouldSkipTest)('option API overload tests', () => { 116 + it('should handle basic option (name + description only) as boolean flag', async () => { 117 + // This tests the case: option('quiet', 'Suppress output') 118 + const command = `${commandPrefix} dev --quiet ""`; 119 + const output = await runCommand(command); 120 + // Should be treated as boolean flag (no value completion) 121 + expect(output.trim()).toBe(':4'); 122 + }); 123 + 124 + it('should handle option with alias only as boolean flag', async () => { 125 + // This tests the case: option('verbose', 'Enable verbose', 'v') 126 + const command = `${commandPrefix} dev --verbose ""`; 127 + const output = await runCommand(command); 128 + // Should be treated as boolean flag (no value completion) 129 + expect(output.trim()).toBe(':4'); 130 + }); 131 + 132 + it('should handle option with alias only (short flag) as boolean flag', async () => { 133 + // This tests the short flag version: -v instead of --verbose 134 + const command = `${commandPrefix} dev -v ""`; 135 + const output = await runCommand(command); 136 + // Should be treated as boolean flag (no value completion) 137 + expect(output.trim()).toBe(':4'); 138 + }); 139 + 140 + it('should handle option with handler only as value option', async () => { 141 + // This tests the case: option('port', 'Port number', handlerFunction) 142 + const command = `${commandPrefix} dev --port ""`; 143 + const output = await runCommand(command); 144 + // Should provide value completions because it has a handler 145 + expect(output).toContain('3000'); 146 + expect(output).toContain('8080'); 147 + }); 148 + 149 + it('should handle option with both handler and alias as value option', async () => { 150 + // This tests the case: option('config', 'Config file', handlerFunction, 'c') 151 + const command = `${commandPrefix} --config ""`; 152 + const output = await runCommand(command); 153 + // Should provide value completions because it has a handler 154 + expect(output).toContain('vite.config.ts'); 155 + expect(output).toContain('vite.config.js'); 156 + }); 157 + 158 + it('should handle option with both handler and alias (short flag) as value option', async () => { 159 + // This tests the short flag version with handler: -c instead of --config 160 + const command = `${commandPrefix} -c ""`; 161 + const output = await runCommand(command); 162 + // Should provide value completions because it has a handler 163 + expect(output).toContain('vite.config.ts'); 164 + expect(output).toContain('vite.config.js'); 165 + }); 166 + 167 + it('should correctly detect boolean vs value options in mixed scenarios', async () => { 168 + // Test that boolean options don't interfere with value options 169 + const command = `${commandPrefix} dev --verbose --port ""`; 170 + const output = await runCommand(command); 171 + // Should complete port values, not be confused by preceding boolean flag 172 + expect(output).toContain('3000'); 173 + expect(output).toContain('8080'); 174 + }); 175 + 176 + it('should correctly handle aliases for different option types', async () => { 177 + // Mix of boolean flag with alias (-v) and value option with alias (-p) 178 + const command = `${commandPrefix} dev -v -p ""`; 179 + const output = await runCommand(command); 180 + // Should complete port values via short flag 181 + expect(output).toContain('3000'); 182 + expect(output).toContain('8080'); 183 + }); 184 + }); 185 + 92 186 describe.runIf(!shouldSkipTest)('--config option tests', () => { 93 187 it('should complete --config option values', async () => { 94 188 const command = `${commandPrefix} --config ""`;