[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.

Merge remote-tracking branch 'origin/main' into feat/emit-completions

AmirSa12 (Jun 18, 2026, 7:49 PM +0330) 077881be ee3848d8

+1278 -259
-5
.changeset/chatty-wings-melt.md
··· 1 - --- 2 - '@bomb.sh/tab': patch 3 - --- 4 - 5 - fix positional argument completion broken after flags
+5
.changeset/fast-lamps-march.md
··· 1 + --- 2 + '@bomb.sh/tab': patch 3 + --- 4 + 5 + prevent duplicate empty args in generated completions
+1 -1
.github/workflows/ci.yml
··· 34 34 run: pnpm install 35 35 36 36 - name: Run tests 37 - run: pnpm test 37 + run: pnpm test:unit 38 38 39 39 typecheck: 40 40 name: Lint and Type Check
+58
.github/workflows/shell-completions.yml
··· 1 + name: Shell Completions 2 + 3 + on: 4 + push: 5 + branches: 6 + - main 7 + pull_request: 8 + branches: 9 + - main 10 + 11 + jobs: 12 + shell-argv-protocol: 13 + name: Shell argv protocol 14 + runs-on: ubuntu-latest 15 + 16 + steps: 17 + - name: Checkout 18 + uses: actions/checkout@v4 19 + 20 + - name: Install shell dependencies 21 + shell: bash 22 + run: | 23 + sudo apt-get update 24 + sudo apt-get install -y zsh fish wget apt-transport-https software-properties-common 25 + 26 + - name: Install PowerShell 27 + shell: bash 28 + run: | 29 + source /etc/os-release 30 + wget -q https://packages.microsoft.com/config/ubuntu/$VERSION_ID/packages-microsoft-prod.deb 31 + sudo dpkg -i packages-microsoft-prod.deb 32 + rm packages-microsoft-prod.deb 33 + sudo apt-get update 34 + sudo apt-get install -y powershell 35 + 36 + - name: Install pnpm 37 + uses: pnpm/action-setup@v4.0.0 38 + 39 + - name: Set node version to 20 40 + uses: actions/setup-node@v4 41 + with: 42 + node-version: 20 43 + registry-url: https://registry.npmjs.org/ 44 + cache: pnpm 45 + 46 + - name: Install deps 47 + run: pnpm install 48 + 49 + - name: Print shell versions 50 + shell: bash 51 + run: | 52 + bash --version 53 + zsh --version 54 + fish --version 55 + pwsh --version 56 + 57 + - name: Run shell argv protocol tests 58 + run: pnpm test tests/shell-empty-argv.test.ts
+20
CHANGELOG.md
··· 1 1 # @bombsh/tab 2 2 3 + ## 0.0.17 4 + 5 + ### Patch Changes 6 + 7 + - 53ea5e1: feat(commander): support completions for positional arguments 8 + - 314d836: feat(commander): use commander for determining integration details (#131) 9 + 10 + ## 0.0.16 11 + 12 + ### Patch Changes 13 + 14 + - 04153b8: refactor: commander adapter - remove parse/parseAsync and add optional `completionCommandName` config 15 + - e784d62: fix: parseasync() commander's adapter 16 + 17 + ## 0.0.15 18 + 19 + ### Patch Changes 20 + 21 + - a9d4f04: fix positional argument completion broken after flags 22 + 3 23 ## 0.0.14 4 24 5 25 ### Patch Changes
+51 -2
README.md
··· 6 6 7 7 Additionally, tab supports autocompletions for `pnpm`, `npm`, `yarn`, and `bun`. 8 8 9 - Modern CLI libraries like [Gunshi](https://github.com/kazupon/gunshi) include tab completion natively in their core. 9 + Tab has already been adopted by major tools and CLI frameworks, including: 10 + 11 + <table align="center"> 12 + <tr> 13 + <td align="center"> 14 + <a href="https://www.cloudflare.com/"> 15 + <img src="https://github.com/cloudflare.png?size=200" alt="Cloudflare" width="64"><br> 16 + Cloudflare 17 + </a> 18 + </td> 19 + <td align="center"> 20 + <a href="https://nuxt.com/"> 21 + <img src="https://github.com/nuxt.png?size=200" alt="Nuxt" width="64"><br> 22 + Nuxt 23 + </a> 24 + </td> 25 + <td align="center"> 26 + <a href="https://astro.build/"> 27 + <img src="https://github.com/withastro.png?size=200" alt="Astro" width="64"><br> 28 + Astro 29 + </a> 30 + </td> 31 + <td align="center"> 32 + <a href="https://vitest.dev/"> 33 + <img src="https://github.com/vitest-dev.png?size=200" alt="Vitest" width="64"><br> 34 + Vitest 35 + </a> 36 + </td> 37 + <td align="center"> 38 + <a href="https://github.com/kazupon/gunshi"> 39 + <img src="https://raw.githubusercontent.com/kazupon/gunshi/main/assets/logo.png" alt="Gunshi" width="64"><br> 40 + Gunshi 41 + </a> 42 + </td> 43 + <td align="center"> 44 + <a href="https://github.com/clercjs/clerc"> 45 + <img src="https://raw.githubusercontent.com/clercjs/clerc/main/docs/public/logo.webp" alt="Clerc" width="64"><br> 46 + Clerc 47 + </a> 48 + </td> 49 + </tr> 50 + </table> 10 51 11 52 As CLI tooling authors, if we can spare our users a second or two by not checking documentation or writing the `-h` flag, we're doing them a huge favor. The unconscious mind loves hitting the [TAB] key and always expects feedback. When nothing happens, it breaks the user's flow - a frustration apparent across the whole JavaScript CLI tooling ecosystem. 12 53 ··· 220 261 program.parse(); 221 262 ``` 222 263 264 + The Commander integration supports customising the command name to generate the shell completion script. The default is `complete`. If you use a custom name 265 + like `completion` then it will be visible in the help as `completion <shell>`, while the runtime suggestions will be hidden (`complete -- [args...]`). 266 + You'll need to use your custom command when following examples on this page to generate the shell completion script. 267 + 268 + ```javascript 269 + const completion = tab(program, { completionCommandName: 'completion' }); 270 + ``` 271 + 223 272 ## Bring Your Own Completion Logic 224 273 225 274 If your CLI framework already implements the logic for figuring out what to ··· 275 324 A working integration with [stricli](https://github.com/bloomberg/stricli) is 276 325 in `examples/demo.stricli.ts`. 277 326 278 - --- 327 + ### Custom Integrations 279 328 280 329 tab uses a standardized completion protocol that any CLI can implement: 281 330
+27
examples/demo.commander-async.ts
··· 1 + import { Command } from 'commander'; 2 + import tab from '../src/commander'; 3 + 4 + const program = new Command('my-cli'); 5 + 6 + program 7 + .command('greet [name]') 8 + .description('Say hello') 9 + .action(async (name) => { 10 + // async handler requires parseAsync() 11 + console.log(`Hello, ${name ?? 'world'}!`); 12 + }); 13 + 14 + const completion = tab(program); 15 + 16 + for (const command of completion.commands.values()) { 17 + if (command.value === 'greet') { 18 + for (const [_argName, arg] of command.arguments.entries()) { 19 + arg.handler = (complete) => { 20 + complete('alice', 'Alice'); 21 + complete('bob', 'Bob'); 22 + }; 23 + } 24 + } 25 + } 26 + 27 + await program.parseAsync();
+101 -42
examples/demo.commander.ts
··· 1 - import { Command } from 'commander'; 1 + import { Command, Option } from 'commander'; 2 2 import tab from '../src/commander'; 3 3 4 4 // Create a new Commander program ··· 7 7 8 8 // Add global options 9 9 program 10 - .option('-c, --config <file>', 'specify config file') 11 - .option('-d, --debug', 'enable debugging') 12 - .option('-v, --verbose', 'enable verbose output'); 10 + .option('-c, --config <file>', 'Use specified config file') 11 + .option('-m, --mode <mode>', 'Set env mode') 12 + .addOption( 13 + new Option('-l, --logLevel <level>', 'Specify log level').choices([ 14 + 'info', 15 + 'warn', 16 + 'error', 17 + 'silent', 18 + ]) 19 + ); 13 20 14 21 // Add commands 22 + const devCommand = program 23 + .command('dev') 24 + .description('Start dev server') 25 + .option('-H, --host [host]', `Specify hostname`) 26 + .option('-p, --port <port>', `Specify port`) 27 + .option('-v, --verbose', `Enable verbose logging`) 28 + .option('--quiet', `Suppress output`) 29 + .action((options) => {}); 30 + // subcommands of dev 31 + devCommand 32 + .command('start') 33 + .description('Start development server') 34 + .action((options) => {}); 35 + devCommand 36 + .command('build') 37 + .description('Build project') 38 + .action((options) => {}); 39 + 15 40 program 16 41 .command('serve') 17 42 .description('Start the server') ··· 56 81 console.log('Linting files...'); 57 82 }); 58 83 84 + // Command with multiple required positional arguments 85 + program 86 + .command('copy <source> <destination>') 87 + .description('Copy files') 88 + .action((source, destination) => { 89 + console.log(`Copying ${source} to ${destination}...`); 90 + }); 91 + 59 92 // Initialize tab completion 60 93 const completion = tab(program); 61 94 62 95 // Configure custom completions 63 - for (const command of completion.commands.values()) { 64 - if (command.value === 'lint') { 65 - // Note: Direct handler assignment is not supported in the current API 66 - // Custom completion logic would need to be implemented differently 96 + // Options on root command 97 + const configOption = completion.options.get('config'); 98 + if (configOption) { 99 + configOption.handler = (complete) => { 100 + complete('vite.config.ts', 'Vite config file'); 101 + complete('vite.config.js', 'Vite config file'); 102 + }; 103 + } 104 + const modeOption = completion.options.get('mode'); 105 + if (modeOption) { 106 + modeOption.handler = (complete) => { 107 + complete('development', 'Development mode'); 108 + complete('production', 'Production mode'); 109 + }; 110 + } 111 + // Note: loglevel automatically gets completions because uses ".choices()" in option definition. 112 + 113 + // Options on dev command 114 + const devCommandInstance = completion.commands.get('dev'); 115 + if (devCommandInstance) { 116 + const portOption = devCommandInstance.options.get('port'); 117 + if (portOption) { 118 + portOption.handler = (complete) => { 119 + complete('3000', 'Development server port'); 120 + complete('8080', 'Alternative port'); 121 + }; 122 + } 123 + const hostOption = devCommandInstance.options.get('host'); 124 + if (hostOption) { 125 + hostOption.handler = (complete) => { 126 + complete('localhost', 'Localhost'); 127 + complete('127.0.0.1', 'Localhost IP'); 128 + }; 67 129 } 130 + } 68 131 69 - for (const [option, config] of command.options.entries()) { 70 - if (option === '--port') { 71 - config.handler = () => { 72 - return [ 73 - { value: '3000', description: 'Default port' }, 74 - { value: '8080', description: 'Alternative port' }, 75 - ]; 76 - }; 77 - } 78 - if (option === '--host') { 79 - config.handler = () => { 80 - return [ 81 - { value: 'localhost', description: 'Local development' }, 82 - { value: '0.0.0.0', description: 'All interfaces' }, 83 - ]; 84 - }; 85 - } 86 - if (option === '--mode') { 87 - config.handler = () => { 88 - return [ 89 - { value: 'development', description: 'Development mode' }, 90 - { value: 'production', description: 'Production mode' }, 91 - { value: 'test', description: 'Test mode' }, 92 - ]; 93 - }; 94 - } 95 - if (option === '--config') { 96 - config.handler = () => { 97 - return [ 98 - { value: 'config.json', description: 'JSON config file' }, 99 - { value: 'config.yaml', description: 'YAML config file' }, 100 - ]; 101 - }; 102 - } 132 + // Positional arguments on lint command 133 + const lintCommandInstance = completion.commands.get('lint'); 134 + if (lintCommandInstance) { 135 + const filesArg = lintCommandInstance.arguments.get('files'); 136 + if (filesArg) { 137 + filesArg.handler = (complete) => { 138 + complete('main.ts', 'Main file'); 139 + complete('index.ts', 'Index file'); 140 + }; 141 + } 142 + } 143 + 144 + // Positional arguments on copy command 145 + const copyCommandInstance = completion.commands.get('copy'); 146 + if (copyCommandInstance) { 147 + const sourceArg = copyCommandInstance.arguments.get('source'); 148 + if (sourceArg) { 149 + sourceArg.handler = (complete) => { 150 + complete('src/', 'Source directory'); 151 + complete('dist/', 'Distribution directory'); 152 + complete('public/', 'Public assets'); 153 + }; 154 + } 155 + const destinationArg = copyCommandInstance.arguments.get('destination'); 156 + if (destinationArg) { 157 + destinationArg.handler = (complete) => { 158 + complete('build/', 'Build output'); 159 + complete('release/', 'Release directory'); 160 + complete('backup/', 'Backup location'); 161 + }; 103 162 } 104 163 } 105 164
+17 -8
examples/demo.t.ts
··· 120 120 true 121 121 ); // Variadic argument for multiple files 122 122 123 + const supportedShells = ['zsh', 'bash', 'fish', 'powershell']; 124 + const completeUsage = 'Usage: vite complete <shell> | vite complete -- <argv>'; 125 + 126 + function printCompleteUsageAndExit() { 127 + console.error(completeUsage); 128 + process.exit(1); 129 + } 130 + 123 131 // Handle completion command 124 132 if (process.argv[2] === 'complete') { 125 - const shell = process.argv[3]; 126 - if (shell && ['zsh', 'bash', 'fish', 'powershell'].includes(shell)) { 127 - t.setup('vite', 'pnpm tsx examples/demo.t.ts', shell); 133 + const mode = process.argv[3]; 134 + 135 + if (mode === '--') { 136 + // Runtime completion request from the generated shell script. 137 + t.parse(process.argv.slice(4)); 138 + } else if (mode && supportedShells.includes(mode)) { 139 + // Shell script generation. 140 + t.setup('vite', 'pnpm tsx examples/demo.t.ts', mode); 128 141 } else { 129 - // Parse completion arguments (everything after --) 130 - const separatorIndex = process.argv.indexOf('--'); 131 - const completionArgs = 132 - separatorIndex !== -1 ? process.argv.slice(separatorIndex + 1) : []; 133 - t.parse(completionArgs); 142 + printCompleteUsageAndExit(); 134 143 } 135 144 } else { 136 145 // Regular CLI usage (just show help for demo)
+3 -1
package.json
··· 1 1 { 2 2 "name": "@bomb.sh/tab", 3 - "version": "0.0.14", 3 + "version": "0.0.17", 4 4 "type": "module", 5 5 "bin": { 6 6 "tab": "./dist/bin/cli.mjs" 7 7 }, 8 8 "scripts": { 9 9 "test": "vitest run", 10 + "test:unit": "vitest run --exclude tests/shell-empty-argv.test.ts", 11 + "test:shell": "vitest run tests/shell-empty-argv.test.ts", 10 12 "type-check": "tsc --noEmit", 11 13 "format": "prettier --write .", 12 14 "format:check": "prettier --check .",
+130 -150
src/commander.ts
··· 1 - import * as zsh from './zsh'; 2 - import * as bash from './bash'; 3 - import * as fish from './fish'; 4 - import * as powershell from './powershell'; 5 - import type { Command as CommanderCommand, ParseOptions } from 'commander'; 6 - import t, { type RootCommand } from './t'; 7 - import { assertDoubleDashes } from './shared'; 1 + import type { Command as CommanderCommand } from 'commander'; 2 + import t, { Command as TabCommand, type RootCommand, OptionHandler } from './t'; 3 + 4 + // rawArgs is available on (just) the Commander root command, but is not included in the TypeScript types. 5 + interface CommandWithRawArgs extends CommanderCommand { 6 + rawArgs: string[]; 7 + } 8 8 9 9 const execPath = process.execPath; 10 10 const processArgs = process.argv.slice(1); ··· 18 18 return path.includes(' ') ? `'${path}'` : path; 19 19 } 20 20 21 - export default function tab(instance: CommanderCommand): RootCommand { 21 + export default function tab( 22 + instance: CommanderCommand, 23 + completionConfig?: { completionCommandName?: string } 24 + ): RootCommand { 22 25 const programName = instance.name(); 23 26 24 - // Process the root command 25 - processRootCommand(instance); 26 - 27 - // Process all subcommands 28 - processSubcommands(instance); 29 - 30 - // Add the complete command for normal shell script generation 31 - instance 32 - .command('complete [shell]') 27 + // Make a `completion` command with a required command-argument. 28 + const completionCommandName = 29 + completionConfig?.completionCommandName ?? 'complete'; 30 + const completionCommand = instance 31 + .createCommand(completionCommandName) 33 32 .description('Generate shell completion scripts') 34 - .action(async (shell) => { 35 - switch (shell) { 36 - case 'zsh': { 37 - const script = zsh.generate(programName, x); 38 - console.log(script); 39 - break; 40 - } 41 - case 'bash': { 42 - const script = bash.generate(programName, x); 43 - console.log(script); 44 - break; 45 - } 46 - case 'fish': { 47 - const script = fish.generate(programName, x); 48 - console.log(script); 49 - break; 50 - } 51 - case 'powershell': { 52 - const script = powershell.generate(programName, x); 53 - console.log(script); 54 - break; 55 - } 56 - case 'debug': { 57 - // Debug mode to print all collected commands 58 - const commandMap = new Map<string, CommanderCommand>(); 59 - collectCommands(instance, '', commandMap); 60 - console.log('Collected commands:'); 61 - for (const [path, cmd] of commandMap.entries()) { 62 - console.log( 63 - `- ${path || '<root>'}: ${cmd.description() || 'No description'}` 64 - ); 65 - } 66 - break; 67 - } 68 - default: { 69 - console.error(`Unknown shell: ${shell}`); 70 - console.error('Supported shells: zsh, bash, fish, powershell'); 71 - process.exit(1); 72 - } 33 + .addArgument( 34 + instance 35 + .createArgument('<shell>', 'Shell type for completion script') 36 + .choices(['zsh', 'bash', 'fish', 'powershell']) 37 + ) 38 + .action((shell) => { 39 + t.setup(programName, x, shell); 40 + }); 41 + completionCommand.copyInheritedSettings(instance); 42 + 43 + // Make a `complete` command for generating tab-time complete suggestions. 44 + const completeCommand = instance 45 + .createCommand('complete') 46 + .description('Generate completion suggestions') 47 + .usage('-- [args...]') 48 + .argument('[args...]') 49 + .action((args) => { 50 + if (completionCommandName !== 'complete') { 51 + // Check for user trying to generate shell completion script, since not using usual tab overloaded complete `command`. 52 + const rawArgs = (instance as CommandWithRawArgs).rawArgs; 53 + if (args.length === 1 && !rawArgs.includes('--')) 54 + instance.error( 55 + `error: completion requests are called like \`complete -- [args]\`.\n(Did you mean \`${completionCommandName} ${args[0]}\` to generate shell script?)` 56 + ); 73 57 } 58 + 59 + t.parse(args); 74 60 }); 61 + completeCommand.copyInheritedSettings(instance); 75 62 76 - // Override the parse method to handle completion requests before normal parsing 77 - const originalParse = instance.parse.bind(instance); 78 - instance.parse = function (argv?: readonly string[], options?: ParseOptions) { 79 - const args = argv || process.argv; 80 - const completeIndex = args.findIndex((arg) => arg === 'complete'); 81 - const dashDashIndex = args.findIndex((arg) => arg === '--'); 63 + if (completionCommandName !== 'complete') { 64 + // We have indepdendent commands so can hook them up directly. 65 + instance.addCommand(completionCommand); 66 + instance.addCommand(completeCommand, { hidden: true }); 67 + } else { 68 + // We need to add a dual-use command, work out calling pattern, and dispatch. 69 + instance 70 + .command('complete') 71 + .description('Generate shell completion scripts') 72 + .argument( 73 + '[shell]', 74 + 'shell type (choices: "zsh", "bash", "fish", "powershell")' 75 + ) 76 + .allowExcessArguments() 77 + .action((_shell, _options, cmd) => { 78 + // Work out how we are being called, by user or by script as completion handler. 79 + const rawArgs = (instance as CommandWithRawArgs).rawArgs; 80 + const completeIndex = rawArgs.indexOf('complete'); 81 + const dashDashIndex = rawArgs.indexOf('--'); 82 82 83 - if ( 84 - completeIndex !== -1 && 85 - dashDashIndex !== -1 && 86 - dashDashIndex > completeIndex 87 - ) { 88 - // This is a completion request, handle it directly 89 - const extra = args.slice(dashDashIndex + 1); 83 + if ( 84 + completeIndex !== -1 && 85 + dashDashIndex !== -1 && 86 + dashDashIndex === completeIndex + 1 87 + ) { 88 + // Commander stripped `--`, so put it back for reparse 89 + completeCommand.parse(['--', ...cmd.args], { from: 'user' }); 90 + } else { 91 + completionCommand.parse(cmd.args, { 92 + from: 'user', 93 + }); 94 + } 95 + }); 96 + } 90 97 91 - // Handle the completion directly 92 - assertDoubleDashes(programName); 93 - t.parse(extra); 94 - return instance; 95 - } 98 + // Now we have added complete and completion command... 99 + // Process the root command 100 + processRootCommand(instance); 96 101 97 - // Normal parsing 98 - return originalParse(argv, options); 99 - }; 102 + // Process all subcommands 103 + processSubcommands(instance); 100 104 101 105 return t; 102 106 } 103 107 104 - /** 105 - * Detect whether a commander option flag expects a value argument. 106 - * Options with `<value>` or `[value]` in their flags are value-taking. 107 - */ 108 - function optionTakesValue(flags: string): boolean { 109 - return flags.includes('<') || flags.includes('['); 110 - } 108 + function processOptions(t: TabCommand, cmd: CommanderCommand): void { 109 + // visibleOptions handles hidden options and built-in help option 110 + const visibleOptions = cmd.createHelp().visibleOptions(cmd); 111 + for (const option of visibleOptions) { 112 + // Commander has at least one of short and long option flags, but can have just one. 113 + // Commander also allows special case, shortish long and long like '--ws, --workspace'. 114 + // Remove the leading dashes to get the names. 115 + let shortName = option.short?.slice(1); 116 + if (shortName && shortName[0] === '-') shortName = undefined; // ignore shortish long 117 + const longName = option.long?.slice(2); 118 + if (longName) { 119 + const optionTakesValue = option.required || option.optional; 120 + const choices = option.argChoices ?? []; 111 121 112 - /** 113 - * Register a commander option with the tab library, correctly setting 114 - * isBoolean based on whether the option takes a value. 115 - * 116 - * The tab Command.option() method infers isBoolean from the argument types: 117 - * - string arg → alias, isBoolean=true 118 - * - function arg → handler, isBoolean=false 119 - * So for value-taking options with an alias, we pass a no-op handler 120 - * and the alias separately to get isBoolean=false. 121 - */ 122 - function registerOption( 123 - tabCommand: { 124 - option: ( 125 - value: string, 126 - description: string, 127 - handlerOrAlias?: ((...args: unknown[]) => void) | string, 128 - alias?: string 129 - ) => unknown; 130 - }, 131 - flags: string, 132 - longFlag: string, 133 - description: string, 134 - shortFlag?: string 135 - ): void { 136 - const takesValue = optionTakesValue(flags); 137 - if (shortFlag) { 138 - if (takesValue) { 139 - // Pass a no-op handler to force isBoolean=false, with alias as 4th arg 140 - tabCommand.option(longFlag, description, () => {}, shortFlag); 141 - } else { 142 - tabCommand.option(longFlag, description, shortFlag); 143 - } 144 - } else { 145 - if (takesValue) { 146 - tabCommand.option(longFlag, description, () => {}); 147 - } else { 148 - tabCommand.option(longFlag, description); 122 + let optionHandler: OptionHandler | undefined = undefined; 123 + if (optionTakesValue && choices.length > 0) { 124 + optionHandler = (complete) => { 125 + for (const choice of choices) complete(choice, ''); 126 + }; 127 + } else if (optionTakesValue) { 128 + optionHandler = () => {}; 129 + } 130 + 131 + if (optionHandler) { 132 + t.option(longName, option.description, optionHandler, shortName); 133 + } else { 134 + t.option(longName, option.description, shortName); 135 + } 149 136 } 150 137 } 151 138 } 152 139 153 140 function processRootCommand(command: CommanderCommand): void { 154 - // Add root command options to the root t instance 155 - for (const option of command.options) { 156 - // Extract short flag from the name if it exists (e.g., "-c, --config" -> "c") 157 - const flags = option.flags; 158 - const shortFlag = flags.match(/^-([a-zA-Z]), --/)?.[1]; 159 - const longFlag = flags.match(/--([a-zA-Z0-9-]+)/)?.[1]; 141 + processOptions(t, command); 142 + processArguments(t, command); 143 + } 160 144 161 - if (longFlag) { 162 - registerOption(t, flags, longFlag, option.description || '', shortFlag); 145 + function processArguments(tabCommand: TabCommand, cmd: CommanderCommand): void { 146 + for (const arg of cmd.registeredArguments) { 147 + const choices = arg.argChoices; 148 + if (choices?.length) { 149 + tabCommand.argument( 150 + arg.name(), 151 + (complete) => { 152 + for (const choice of choices) complete(choice, ''); 153 + }, 154 + arg.variadic 155 + ); 156 + } else { 157 + tabCommand.argument(arg.name(), undefined, arg.variadic); 163 158 } 164 159 } 165 160 } ··· 178 173 // Add command using t.ts API 179 174 const command = t.command(path, cmd.description() || ''); 180 175 181 - // Add command options 182 - for (const option of cmd.options) { 183 - // Extract short flag from the name if it exists (e.g., "-c, --config" -> "c") 184 - const flags = option.flags; 185 - const shortFlag = flags.match(/^-([a-zA-Z]), --/)?.[1]; 186 - const longFlag = flags.match(/--([a-zA-Z0-9-]+)/)?.[1]; 187 - 188 - if (longFlag) { 189 - registerOption( 190 - command, 191 - flags, 192 - longFlag, 193 - option.description || '', 194 - shortFlag 195 - ); 196 - } 197 - } 176 + // Add command options and arguments 177 + processOptions(command, cmd); 178 + processArguments(command, cmd); 198 179 } 199 180 } 200 181 ··· 207 188 commandMap.set(parentPath, command); 208 189 209 190 // Process subcommands 210 - for (const subcommand of command.commands) { 211 - // Skip the completion command 212 - if (subcommand.name() === 'complete') continue; 213 - 191 + // visibleCommands handles hidden commands and built-in help command 192 + const visibleCommands = command.createHelp().visibleCommands(command); 193 + for (const subcommand of visibleCommands) { 214 194 // Build the full path for this subcommand 215 195 const subcommandPath = parentPath 216 196 ? `${parentPath} ${subcommand.name()}`
+4 -2
src/powershell.ts
··· 93 93 # Remove the flag part 94 94 $Flag, $WordToComplete = $WordToComplete.Split("=", 2) 95 95 } 96 - 97 - if ( $WordToComplete -eq "" -And ( -Not $IsEqualFlag )) { 96 + $HasTrailingEmptyArg = $QuotedArgs -match "(^| )''$" 97 + __${name}_debug "HasTrailingEmptyArg: $HasTrailingEmptyArg" 98 + 99 + if ( $WordToComplete -eq "" -And ( -Not $IsEqualFlag ) -And ( -Not $HasTrailingEmptyArg )) { 98 100 # If the last parameter is complete (there is a space following it) 99 101 # We add an extra empty parameter so we can indicate this to the go method. 100 102 __${name}_debug "Adding extra empty parameter"
+1 -1
src/zsh.ts
··· 48 48 49 49 # Prepare the command to obtain completions, ensuring arguments are quoted for eval 50 50 local -a args_to_quote=("\${(@)words[2,-1]}") 51 - if [ "\${lastChar}" = "" ]; then 51 + if [ "\${lastChar}" = "" ] && [ "\${args_to_quote[-1]}" != "" ]; then 52 52 # If the last parameter is complete (there is a space following it) 53 53 # We add an extra empty parameter so we can indicate this to the go completion code. 54 54 __${name}_debug "Adding extra empty parameter"
+325
tests/__snapshots__/cli.test.ts.snap
··· 626 626 " 627 627 `; 628 628 629 + exports[`cli completion tests for commander > --config option tests > should complete --config option values 1`] = ` 630 + "vite.config.ts Vite config file 631 + vite.config.js Vite config file 632 + :4 633 + " 634 + `; 635 + 636 + exports[`cli completion tests for commander > --config option tests > should complete --config option with equals sign 1`] = ` 637 + "vite.config.ts Vite config file 638 + vite.config.js Vite config file 639 + :4 640 + " 641 + `; 642 + 643 + exports[`cli completion tests for commander > --config option tests > should complete --config option with partial input 1`] = ` 644 + "vite.config.ts Vite config file 645 + vite.config.js Vite config file 646 + :4 647 + " 648 + `; 649 + 650 + exports[`cli completion tests for commander > --config option tests > should complete short flag -c option values 1`] = ` 651 + "vite.config.ts Vite config file 652 + vite.config.js Vite config file 653 + :4 654 + " 655 + `; 656 + 657 + exports[`cli completion tests for commander > --config option tests > should complete short flag -c option with partial input 1`] = ` 658 + "vite.config.ts Vite config file 659 + vite.config.js Vite config file 660 + :4 661 + " 662 + `; 663 + 664 + exports[`cli completion tests for commander > --config option tests > should not suggest --config after it has been used 1`] = ` 665 + "--version output the version number 666 + --config Use specified config file 667 + --mode Set env mode 668 + --logLevel Specify log level 669 + --help display help for command 670 + :4 671 + " 672 + `; 673 + 674 + exports[`cli completion tests for commander > cli option completion tests > should complete option for partial input '{ partial: '--p', expected: '--port' }' 1`] = ` 675 + "--port Specify port 676 + :4 677 + " 678 + `; 679 + 680 + exports[`cli completion tests for commander > cli option completion tests > should complete option for partial input '{ partial: '-H', expected: '-H' }' 1`] = ` 681 + "-H Specify hostname 682 + :4 683 + " 684 + `; 685 + 686 + exports[`cli completion tests for commander > cli option completion tests > should complete option for partial input '{ partial: '-p', expected: '-p' }' 1`] = ` 687 + "-p Specify port 688 + :4 689 + " 690 + `; 691 + 692 + exports[`cli completion tests for commander > cli option exclusion tests > should not suggest already specified option '{ specified: '--config', shouldNotContain: '--config' }' 1`] = ` 693 + ":4 694 + " 695 + `; 696 + 697 + exports[`cli completion tests for commander > cli option value handling > should handle unknown options with no completions 1`] = `":4"`; 698 + 699 + exports[`cli completion tests for commander > cli option value handling > should not show duplicate options 1`] = ` 700 + "--version output the version number 701 + --config Use specified config file 702 + --mode Set env mode 703 + --logLevel Specify log level 704 + --help display help for command 705 + :4 706 + " 707 + `; 708 + 709 + exports[`cli completion tests for commander > cli option value handling > should resolve config option values correctly 1`] = ` 710 + "vite.config.ts Vite config file 711 + vite.config.js Vite config file 712 + :4 713 + " 714 + `; 715 + 716 + exports[`cli completion tests for commander > cli option value handling > should resolve port value correctly 1`] = ` 717 + "3000 Development server port 718 + :4 719 + " 720 + `; 721 + 722 + exports[`cli completion tests for commander > copy command argument handlers > should complete destination argument with build suggestions 1`] = ` 723 + "build/ Build output 724 + release/ Release directory 725 + backup/ Backup location 726 + :4 727 + " 728 + `; 729 + 730 + exports[`cli completion tests for commander > copy command argument handlers > should complete source argument with directory suggestions 1`] = ` 731 + "src/ Source directory 732 + dist/ Distribution directory 733 + public/ Public assets 734 + :4 735 + " 736 + `; 737 + 738 + exports[`cli completion tests for commander > copy command argument handlers > should filter destination suggestions when typing partial input 1`] = ` 739 + "build/ Build output 740 + backup/ Backup location 741 + :4 742 + " 743 + `; 744 + 745 + exports[`cli completion tests for commander > copy command argument handlers > should filter source suggestions when typing partial input 1`] = ` 746 + "src/ Source directory 747 + :4 748 + " 749 + `; 750 + 751 + exports[`cli completion tests for commander > edge case completions for end with space > should keep suggesting the --port option if user typed partial but didn't end with space 1`] = ` 752 + "--port Specify port 753 + :4 754 + " 755 + `; 756 + 757 + exports[`cli completion tests for commander > edge case completions for end with space > should suggest port values if user ends with space after \`--port\` 1`] = ` 758 + "3000 Development server port 759 + 8080 Alternative port 760 + :4 761 + " 762 + `; 763 + 764 + exports[`cli completion tests for commander > edge case completions for end with space > should suggest port values if user typed \`--port=\` and hasn't typed a space or value yet 1`] = ` 765 + "3000 Development server port 766 + 8080 Alternative port 767 + :4 768 + " 769 + `; 770 + 771 + exports[`cli completion tests for commander > lint command argument handlers > should complete files argument with file suggestions 1`] = ` 772 + "main.ts Main file 773 + index.ts Index file 774 + :4 775 + " 776 + `; 777 + 778 + exports[`cli completion tests for commander > lint command argument handlers > should continue completing variadic files argument after first file 1`] = ` 779 + "main.ts Main file 780 + index.ts Index file 781 + :4 782 + " 783 + `; 784 + 785 + exports[`cli completion tests for commander > lint command argument handlers > should continue completing variadic suggestions after first file 1`] = ` 786 + "index.ts Index file 787 + :4 788 + " 789 + `; 790 + 791 + exports[`cli completion tests for commander > lint command argument handlers > should filter file suggestions when typing partial input 1`] = ` 792 + "main.ts Main file 793 + :4 794 + " 795 + `; 796 + 797 + exports[`cli completion tests for commander > positional argument completions > should complete multiple positional arguments when ending with part of the value 1`] = ` 798 + "index.ts Index file 799 + :4 800 + " 801 + `; 802 + 803 + exports[`cli completion tests for commander > positional argument completions > should complete multiple positional arguments when ending with space 1`] = ` 804 + "main.ts Main file 805 + index.ts Index file 806 + :4 807 + " 808 + `; 809 + 810 + exports[`cli completion tests for commander > positional argument completions > should complete single positional argument when ending with space 1`] = ` 811 + "main.ts Main file 812 + index.ts Index file 813 + :4 814 + " 815 + `; 816 + 817 + exports[`cli completion tests for commander > root command argument tests > should complete root command project argument 1`] = ` 818 + "dev Start dev server 819 + serve Start the server 820 + build Build the project 821 + deploy Deploy the application 822 + lint Lint source files 823 + copy Copy files 824 + complete Generate shell completion scripts 825 + help display help for command 826 + :4 827 + " 828 + `; 829 + 830 + exports[`cli completion tests for commander > root command argument tests > should complete root command project argument after options 1`] = ` 831 + "dev Start dev server 832 + serve Start the server 833 + build Build the project 834 + deploy Deploy the application 835 + lint Lint source files 836 + copy Copy files 837 + complete Generate shell completion scripts 838 + help display help for command 839 + :4 840 + " 841 + `; 842 + 843 + exports[`cli completion tests for commander > root command argument tests > should complete root command project argument with options and partial input 1`] = ` 844 + ":4 845 + " 846 + `; 847 + 848 + exports[`cli completion tests for commander > root command argument tests > should complete root command project argument with partial input 1`] = ` 849 + ":4 850 + " 851 + `; 852 + 853 + exports[`cli completion tests for commander > root command option tests > should complete root command --logLevel option values 1`] = ` 854 + "info 855 + warn 856 + error 857 + silent 858 + :4 859 + " 860 + `; 861 + 862 + exports[`cli completion tests for commander > root command option tests > should complete root command --logLevel option with partial input 1`] = ` 863 + "info 864 + :4 865 + " 866 + `; 867 + 868 + exports[`cli completion tests for commander > root command option tests > should complete root command --mode option values 1`] = ` 869 + "development Development mode 870 + production Production mode 871 + :4 872 + " 873 + `; 874 + 875 + exports[`cli completion tests for commander > root command option tests > should complete root command --mode option with partial input 1`] = ` 876 + "development Development mode 877 + :4 878 + " 879 + `; 880 + 881 + exports[`cli completion tests for commander > root command option tests > should complete root command options after project argument 1`] = ` 882 + "--version output the version number 883 + --config Use specified config file 884 + --mode Set env mode 885 + --logLevel Specify log level 886 + --help display help for command 887 + :4 888 + " 889 + `; 890 + 891 + exports[`cli completion tests for commander > root command option tests > should complete root command options with partial input after project argument 1`] = ` 892 + "--mode Set env mode 893 + :4 894 + " 895 + `; 896 + 897 + exports[`cli completion tests for commander > root command option tests > should complete root command short flag -l option values 1`] = ` 898 + "info 899 + warn 900 + error 901 + silent 902 + :4 903 + " 904 + `; 905 + 906 + exports[`cli completion tests for commander > root command option tests > should complete root command short flag -m option values 1`] = ` 907 + "development Development mode 908 + production Production mode 909 + :4 910 + " 911 + `; 912 + 913 + exports[`cli completion tests for commander > short flag handling > should handle global short flags 1`] = ` 914 + "-c Use specified config file 915 + :4 916 + " 917 + `; 918 + 919 + exports[`cli completion tests for commander > short flag handling > should handle short flag value completion 1`] = ` 920 + "-p Specify port 921 + :4 922 + " 923 + `; 924 + 925 + exports[`cli completion tests for commander > short flag handling > should handle short flag with equals sign 1`] = ` 926 + "3000 Development server port 927 + :4 928 + " 929 + `; 930 + 931 + exports[`cli completion tests for commander > short flag handling > should not show duplicate options when short flag is used 1`] = ` 932 + "--version output the version number 933 + --config Use specified config file 934 + --mode Set env mode 935 + --logLevel Specify log level 936 + --help display help for command 937 + :4 938 + " 939 + `; 940 + 941 + exports[`cli completion tests for commander > should complete cli options 1`] = ` 942 + "dev Start dev server 943 + serve Start the server 944 + build Build the project 945 + deploy Deploy the application 946 + lint Lint source files 947 + copy Copy files 948 + complete Generate shell completion scripts 949 + help display help for command 950 + :4 951 + " 952 + `; 953 + 629 954 exports[`cli completion tests for t > --config option tests > should complete --config option values 1`] = ` 630 955 "vite.config.ts Vite config file 631 956 vite.config.js Vite config file
+44 -47
tests/cli.test.ts
··· 16 16 const cliTools = ['t', 'citty', 'cac', 'commander']; 17 17 18 18 describe.each(cliTools)('cli completion tests for %s', (cliTool) => { 19 - // For Commander, we need to skip most of the tests since it handles completion differently 20 - const shouldSkipTest = cliTool === 'commander'; 21 - 22 - // Commander uses a different command structure for completion 23 - // TODO: why commander does that? our convention is the -- part which should be always there. 24 - const commandPrefix = 25 - cliTool === 'commander' 26 - ? `pnpm tsx examples/demo.${cliTool}.ts complete` 27 - : `pnpm tsx examples/demo.${cliTool}.ts complete --`; 19 + const commandPrefix = `pnpm tsx examples/demo.${cliTool}.ts complete --`; 28 20 29 - it.runIf(!shouldSkipTest)('should complete cli options', async () => { 21 + it('should complete cli options', async () => { 30 22 const output = await runCommand(`${commandPrefix}`); 31 23 expect(output).toMatchSnapshot(); 32 24 }); 33 25 34 - describe.runIf(!shouldSkipTest)('cli option completion tests', () => { 26 + describe('cli option completion tests', () => { 35 27 const optionTests = [ 36 28 { partial: '--p', expected: '--port' }, 37 29 { partial: '-p', expected: '-p' }, // Test short flag completion ··· 48 40 ); 49 41 }); 50 42 51 - describe.runIf(!shouldSkipTest)('cli option exclusion tests', () => { 43 + describe('cli option exclusion tests', () => { 52 44 const alreadySpecifiedTests = [ 53 45 { specified: '--config', shouldNotContain: '--config' }, 54 46 ]; ··· 63 55 ); 64 56 }); 65 57 66 - describe.runIf(!shouldSkipTest)('cli option value handling', () => { 58 + describe('cli option value handling', () => { 67 59 it('should resolve port value correctly', async () => { 68 60 const command = `${commandPrefix} dev --port=3`; 69 61 const output = await runCommand(command); ··· 89 81 }); 90 82 }); 91 83 92 - describe.runIf(!shouldSkipTest)('boolean option handling', () => { 84 + describe('boolean option handling', () => { 93 85 it('should complete subcommands and arguments after boolean options', async () => { 94 86 const command = `${commandPrefix} dev --verbose ""`; 95 87 const output = await runCommand(command); ··· 118 110 it('should not interfere with option completion after boolean options', async () => { 119 111 const command = `${commandPrefix} dev --verbose --h`; 120 112 const output = await runCommand(command); 121 - // Should complete subcommands that start with 's' even after a boolean option 113 + // Should complete options that start with '--h' even after a boolean option 122 114 expect(output).toContain('--host'); 123 115 }); 124 116 }); 125 117 126 - describe.runIf(!shouldSkipTest)('option API overload tests', () => { 118 + describe('option API overload tests', () => { 127 119 it('should handle basic option (name + description only) as boolean flag', async () => { 128 120 // This tests the case: option('quiet', 'Suppress output') 129 121 const command = `${commandPrefix} dev --quiet ""`; ··· 197 189 }); 198 190 }); 199 191 200 - describe.runIf(!shouldSkipTest)('--config option tests', () => { 192 + describe('--config option tests', () => { 201 193 it('should complete --config option values', async () => { 202 194 const command = `${commandPrefix} --config ""`; 203 195 const output = await runCommand(command); ··· 235 227 }); 236 228 }); 237 229 238 - describe.runIf(!shouldSkipTest)('root command argument tests', () => { 230 + describe('root command argument tests', () => { 239 231 it('should complete root command project argument', async () => { 240 232 const command = `${commandPrefix} ""`; 241 233 const output = await runCommand(command); ··· 261 253 }); 262 254 }); 263 255 264 - describe.runIf(!shouldSkipTest)('root command option tests', () => { 256 + describe('root command option tests', () => { 265 257 it('should complete root command --mode option values', async () => { 266 258 const command = `${commandPrefix} --mode ""`; 267 259 const output = await runCommand(command); ··· 311 303 }); 312 304 }); 313 305 314 - describe.runIf(!shouldSkipTest)( 315 - 'edge case completions for end with space', 316 - () => { 317 - it('should suggest port values if user ends with space after `--port`', async () => { 318 - const command = `${commandPrefix} dev --port ""`; 319 - const output = await runCommand(command); 320 - expect(output).toMatchSnapshot(); 321 - }); 306 + describe('edge case completions for end with space', () => { 307 + it('should suggest port values if user ends with space after `--port`', async () => { 308 + const command = `${commandPrefix} dev --port ""`; 309 + const output = await runCommand(command); 310 + expect(output).toMatchSnapshot(); 311 + }); 322 312 323 - it("should keep suggesting the --port option if user typed partial but didn't end with space", async () => { 324 - const command = `${commandPrefix} dev --po`; 325 - const output = await runCommand(command); 326 - expect(output).toMatchSnapshot(); 327 - }); 313 + it("should keep suggesting the --port option if user typed partial but didn't end with space", async () => { 314 + const command = `${commandPrefix} dev --po`; 315 + const output = await runCommand(command); 316 + expect(output).toMatchSnapshot(); 317 + }); 328 318 329 - it("should suggest port values if user typed `--port=` and hasn't typed a space or value yet", async () => { 330 - const command = `${commandPrefix} dev --port=`; 331 - const output = await runCommand(command); 332 - expect(output).toMatchSnapshot(); 333 - }); 334 - } 335 - ); 319 + it("should suggest port values if user typed `--port=` and hasn't typed a space or value yet", async () => { 320 + const command = `${commandPrefix} dev --port=`; 321 + const output = await runCommand(command); 322 + expect(output).toMatchSnapshot(); 323 + }); 324 + }); 336 325 337 - describe.runIf(!shouldSkipTest)('short flag handling', () => { 326 + describe('short flag handling', () => { 338 327 it('should handle short flag value completion', async () => { 339 328 const command = `${commandPrefix} dev -p `; 340 329 const output = await runCommand(command); ··· 360 349 }); 361 350 }); 362 351 363 - describe.runIf(!shouldSkipTest)('positional argument completions', () => { 352 + describe('positional argument completions', () => { 364 353 it('should complete multiple positional arguments when ending with space', async () => { 365 354 const command = `${commandPrefix} lint ""`; 366 355 const output = await runCommand(command); ··· 380 369 }); 381 370 }); 382 371 383 - describe.runIf(!shouldSkipTest)('copy command argument handlers', () => { 372 + describe('copy command argument handlers', () => { 384 373 it('should complete source argument with directory suggestions', async () => { 385 374 const command = `${commandPrefix} copy ""`; 386 375 const output = await runCommand(command); ··· 406 395 }); 407 396 }); 408 397 409 - describe.runIf(!shouldSkipTest)('lint command argument handlers', () => { 398 + describe('lint command argument handlers', () => { 410 399 it('should complete files argument with file suggestions', async () => { 411 400 const command = `${commandPrefix} lint ""`; 412 401 const output = await runCommand(command); ··· 444 433 }); 445 434 446 435 it('should handle subcommands', async () => { 447 - // First, we need to check if deploy is recognized as a command 436 + // Check subcommands of root command. 448 437 const command1 = `pnpm tsx examples/demo.commander.ts complete -- deploy`; 449 438 const output1 = await runCommand(command1); 450 439 expect(output1).toContain('deploy'); 451 440 expect(output1).toContain('Deploy the application'); 452 441 453 - // Then we need to check if the deploy command has subcommands 454 - // We can check this by running the deploy command with --help 455 - const command2 = `pnpm tsx examples/demo.commander.ts deploy --help`; 442 + // Check subcommands of subcommand. 443 + const command2 = `pnpm tsx examples/demo.commander.ts complete -- deploy ""`; 456 444 const output2 = await runCommand(command2); 457 445 expect(output2).toContain('staging'); 458 446 expect(output2).toContain('production'); 447 + }); 448 + 449 + it('should intercept completion when using parseAsync', async () => { 450 + const command = `pnpm tsx examples/demo.commander-async.ts complete -- `; 451 + const output = await runCommand(command); 452 + expect(output).toContain('greet'); 453 + expect(output).toContain('Say hello'); 454 + // directive line must be present, proving t.parse was invoked 455 + expect(output).toMatch(/:\d+\s*$/); 459 456 }); 460 457 }); 461 458
+491
tests/shell-empty-argv.test.ts
··· 1 + import { execFile } from 'node:child_process'; 2 + import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; 3 + import { tmpdir } from 'node:os'; 4 + import { join } from 'node:path'; 5 + import { describe, expect, it } from 'vitest'; 6 + 7 + import * as bash from '../src/bash'; 8 + import * as fish from '../src/fish'; 9 + import * as powershell from '../src/powershell'; 10 + import * as zsh from '../src/zsh'; 11 + 12 + type ExecResult = { 13 + code: number | string | null; 14 + stdout: string; 15 + stderr: string; 16 + }; 17 + 18 + type Fixture = { 19 + dir: string; 20 + scriptPath: string; 21 + capturePath: string; 22 + }; 23 + 24 + type CompletionCase = { 25 + label: string; 26 + expected: string[]; 27 + 28 + // zsh / bash simulation state 29 + words: string[]; 30 + current: number; 31 + 32 + // fish / PowerShell simulation state 33 + line: string; 34 + }; 35 + 36 + const cases: CompletionCase[] = [ 37 + { 38 + label: 'root empty completion', 39 + words: ['demo', ''], 40 + current: 2, 41 + line: 'demo ', 42 + expected: [''], 43 + }, 44 + { 45 + label: 'typed root prefix', 46 + words: ['demo', 'd'], 47 + current: 2, 48 + line: 'demo d', 49 + expected: ['d'], 50 + }, 51 + { 52 + label: 'next word after completed command', 53 + words: ['demo', 'dev', ''], 54 + current: 3, 55 + line: 'demo dev ', 56 + expected: ['dev', ''], 57 + }, 58 + ]; 59 + 60 + function execFileAsync( 61 + command: string, 62 + args: string[], 63 + env: NodeJS.ProcessEnv = {} 64 + ): Promise<ExecResult> { 65 + return new Promise((resolve) => { 66 + execFile( 67 + command, 68 + args, 69 + { 70 + env: { 71 + ...process.env, 72 + ...env, 73 + }, 74 + timeout: 20_000, 75 + }, 76 + (error, stdout, stderr) => { 77 + resolve({ 78 + code: error ? ((error as NodeJS.ErrnoException).code ?? 1) : 0, 79 + stdout, 80 + stderr, 81 + }); 82 + } 83 + ); 84 + }); 85 + } 86 + 87 + async function findExecutable(candidates: string[]): Promise<string | null> { 88 + for (const candidate of candidates) { 89 + const result = await execFileAsync(candidate, ['--version']); 90 + 91 + if (result.code === 0) { 92 + return candidate; 93 + } 94 + } 95 + 96 + return null; 97 + } 98 + 99 + function shQuote(value: string): string { 100 + return `'${value.replace(/'/g, `'\\''`)}'`; 101 + } 102 + 103 + function psQuote(value: string): string { 104 + return `'${value.replace(/'/g, `''`)}'`; 105 + } 106 + 107 + function formatArgs(args: string[]): string { 108 + return JSON.stringify(args); 109 + } 110 + 111 + function logShellCase( 112 + shell: string, 113 + testCase: CompletionCase, 114 + expected: string[], 115 + received: string[] 116 + ) { 117 + const passed = JSON.stringify(received) === JSON.stringify(expected); 118 + const status = passed ? 'PASS' : 'FAIL'; 119 + 120 + console.log( 121 + `[${shell}] ${status} ${testCase.label}\n` + 122 + ` expected: ${formatArgs(expected)}\n` + 123 + ` received: ${formatArgs(received)}` 124 + ); 125 + } 126 + 127 + async function createFixture( 128 + shell: 'bash' | 'fish' | 'powershell' | 'zsh' 129 + ): Promise<Fixture> { 130 + const dir = await mkdtemp(join(tmpdir(), 'tab-shell-empty-argv-')); 131 + const helperPath = join(dir, 'capture-argv.cjs'); 132 + const scriptPath = join( 133 + dir, 134 + shell === 'powershell' ? 'powershell.completion.ps1' : `${shell}.completion` 135 + ); 136 + const capturePath = join(dir, 'captured-argv.jsonl'); 137 + 138 + await writeFile( 139 + helperPath, 140 + ` 141 + const fs = require('node:fs'); 142 + 143 + const capturePath = process.env.TAB_ARGV_CAPTURE; 144 + if (!capturePath) { 145 + throw new Error('TAB_ARGV_CAPTURE is not set'); 146 + } 147 + 148 + const separatorIndex = process.argv.indexOf('--'); 149 + const completionArgs = 150 + separatorIndex === -1 ? [] : process.argv.slice(separatorIndex + 1); 151 + 152 + fs.appendFileSync(capturePath, JSON.stringify(completionArgs) + '\\n'); 153 + 154 + // Emit one matching completion so shells that use native filtering still exit successfully. 155 + // The argv capture is what this test actually asserts. 156 + process.stdout.write('dev\\tStart dev server\\n:4\\n'); 157 + `.trimStart() 158 + ); 159 + 160 + const posixExec = `${shQuote(process.execPath)} ${shQuote(helperPath)}`; 161 + const powerShellExec = `${psQuote(process.execPath)} ${psQuote(helperPath)}`; 162 + 163 + const generatedScript = 164 + shell === 'bash' 165 + ? bash.generate('demo', posixExec) 166 + : shell === 'fish' 167 + ? fish.generate('demo', posixExec) 168 + : shell === 'powershell' 169 + ? powershell.generate('demo', powerShellExec) 170 + : zsh.generate('demo', posixExec); 171 + 172 + await writeFile(scriptPath, generatedScript); 173 + 174 + return { 175 + dir, 176 + scriptPath, 177 + capturePath, 178 + }; 179 + } 180 + 181 + async function readLastCapturedArgs(capturePath: string): Promise<string[]> { 182 + let content: string; 183 + 184 + try { 185 + content = await readFile(capturePath, 'utf8'); 186 + } catch (error) { 187 + throw new Error( 188 + `No argv capture found at ${capturePath}. The generated completer probably did not invoke the fake backend.`, 189 + { 190 + cause: error, 191 + } 192 + ); 193 + } 194 + 195 + const lines = content.trim().split(/\r?\n/); 196 + const lastLine = lines.at(-1); 197 + 198 + if (!lastLine) { 199 + throw new Error(`Argv capture file was empty: ${capturePath}`); 200 + } 201 + 202 + return JSON.parse(lastLine); 203 + } 204 + 205 + async function withFixture( 206 + shell: 'bash' | 'fish' | 'powershell' | 'zsh', 207 + fn: (fixture: Fixture) => Promise<void> 208 + ) { 209 + const fixture = await createFixture(shell); 210 + 211 + try { 212 + await fn(fixture); 213 + } finally { 214 + await rm(fixture.dir, { 215 + recursive: true, 216 + force: true, 217 + }); 218 + } 219 + } 220 + 221 + async function assertZshCase( 222 + shell: string, 223 + fixture: Fixture, 224 + testCase: CompletionCase 225 + ) { 226 + const wordsLiteral = `(${testCase.words.map(shQuote).join(' ')})`; 227 + 228 + const script = ` 229 + function compdef() { :; } 230 + function _describe() { return 1; } 231 + function _arguments() { return 0; } 232 + 233 + source ${shQuote(fixture.scriptPath)} 234 + 235 + words=${wordsLiteral} 236 + CURRENT=${testCase.current} 237 + 238 + _demo >/dev/null 239 + `; 240 + 241 + const result = await execFileAsync(shell, ['-f', '-c', script], { 242 + TAB_ARGV_CAPTURE: fixture.capturePath, 243 + }); 244 + 245 + expect( 246 + result.code, 247 + `stdout:\n${result.stdout}\nstderr:\n${result.stderr}` 248 + ).toBe(0); 249 + 250 + const capturedArgs = await readLastCapturedArgs(fixture.capturePath); 251 + 252 + logShellCase('zsh', testCase, testCase.expected, capturedArgs); 253 + 254 + expect( 255 + capturedArgs, 256 + `zsh did not send expected argv for case: ${testCase.label}` 257 + ).toEqual(testCase.expected); 258 + } 259 + 260 + async function assertBashCase( 261 + shell: string, 262 + fixture: Fixture, 263 + testCase: CompletionCase 264 + ) { 265 + const wordsLiteral = `(${testCase.words.map(shQuote).join(' ')})`; 266 + 267 + const script = ` 268 + function _get_comp_words_by_ref() { 269 + local names=("$@") 270 + local len=\${#names[@]} 271 + 272 + local curvar=\${names[$((len - 4))]} 273 + local prevvar=\${names[$((len - 3))]} 274 + local wordsvar=\${names[$((len - 2))]} 275 + local cwordvar=\${names[$((len - 1))]} 276 + 277 + printf -v "$curvar" '%s' "\${COMP_WORDS[$COMP_CWORD]}" 278 + printf -v "$prevvar" '%s' "\${COMP_WORDS[$((COMP_CWORD - 1))]}" 279 + eval "$wordsvar=(\\"\\\${COMP_WORDS[@]}\\")" 280 + printf -v "$cwordvar" '%s' "$COMP_CWORD" 281 + } 282 + 283 + function compopt() { :; } 284 + 285 + source ${shQuote(fixture.scriptPath)} 286 + 287 + COMP_WORDS=${wordsLiteral} 288 + COMP_CWORD=${testCase.current - 1} 289 + 290 + __demo_complete >/dev/null 291 + `; 292 + 293 + const result = await execFileAsync(shell, ['-c', script], { 294 + TAB_ARGV_CAPTURE: fixture.capturePath, 295 + }); 296 + 297 + expect( 298 + result.code, 299 + `stdout:\n${result.stdout}\nstderr:\n${result.stderr}` 300 + ).toBe(0); 301 + 302 + const capturedArgs = await readLastCapturedArgs(fixture.capturePath); 303 + 304 + logShellCase('bash', testCase, testCase.expected, capturedArgs); 305 + 306 + expect( 307 + capturedArgs, 308 + `bash did not send expected argv for case: ${testCase.label}` 309 + ).toEqual(testCase.expected); 310 + } 311 + 312 + async function assertFishCase( 313 + shell: string, 314 + fixture: Fixture, 315 + testCase: CompletionCase 316 + ) { 317 + const script = ` 318 + set -gx TAB_ARGV_CAPTURE ${shQuote(fixture.capturePath)} 319 + source ${shQuote(fixture.scriptPath)} 320 + 321 + complete --do-complete ${shQuote(testCase.line)} >/dev/null 322 + `; 323 + 324 + const result = await execFileAsync(shell, ['-c', script]); 325 + 326 + expect( 327 + result.code, 328 + `stdout:\n${result.stdout}\nstderr:\n${result.stderr}` 329 + ).toBe(0); 330 + 331 + const capturedArgs = await readLastCapturedArgs(fixture.capturePath); 332 + 333 + logShellCase('fish', testCase, testCase.expected, capturedArgs); 334 + 335 + expect( 336 + capturedArgs, 337 + `fish did not send expected argv for case: ${testCase.label}` 338 + ).toEqual(testCase.expected); 339 + } 340 + 341 + async function assertPowerShellCase( 342 + shell: string, 343 + fixture: Fixture, 344 + testCase: CompletionCase 345 + ) { 346 + const cursorPosition = testCase.line.length; 347 + const wordToComplete = testCase.line.endsWith(' ') 348 + ? '' 349 + : (testCase.line.split(/\s+/).at(-1) ?? ''); 350 + 351 + const script = ` 352 + $env:TAB_ARGV_CAPTURE = ${psQuote(fixture.capturePath)} 353 + . ${psQuote(fixture.scriptPath)} 354 + 355 + $tokens = $null 356 + $errors = $null 357 + $ast = [System.Management.Automation.Language.Parser]::ParseInput( 358 + ${psQuote(testCase.line)}, 359 + [ref]$tokens, 360 + [ref]$errors 361 + ) 362 + 363 + $commandAst = $ast.EndBlock.Statements[0].PipelineElements[0] 364 + 365 + & $__demoCompleterBlock ${psQuote(wordToComplete)} $commandAst ${cursorPosition} | Out-Null 366 + `; 367 + 368 + const result = await execFileAsync( 369 + shell, 370 + ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], 371 + { 372 + TAB_ARGV_CAPTURE: fixture.capturePath, 373 + } 374 + ); 375 + 376 + expect( 377 + result.code, 378 + `stdout:\n${result.stdout}\nstderr:\n${result.stderr}` 379 + ).toBe(0); 380 + 381 + const capturedArgs = await readLastCapturedArgs(fixture.capturePath); 382 + 383 + logShellCase('powershell', testCase, testCase.expected, capturedArgs); 384 + 385 + expect( 386 + capturedArgs, 387 + `PowerShell did not send expected argv for case: ${testCase.label}` 388 + ).toEqual(testCase.expected); 389 + } 390 + 391 + async function collectCaseFailures( 392 + shellName: string, 393 + casesToRun: CompletionCase[], 394 + runCase: (testCase: CompletionCase) => Promise<void> 395 + ): Promise<string[]> { 396 + const failures: string[] = []; 397 + 398 + for (const testCase of casesToRun) { 399 + try { 400 + await runCase(testCase); 401 + } catch (error) { 402 + failures.push( 403 + `[${shellName}] ${testCase.label}\n${ 404 + error instanceof Error ? error.message : String(error) 405 + }` 406 + ); 407 + } 408 + } 409 + 410 + return failures; 411 + } 412 + 413 + describe('generated shell argv protocol', () => { 414 + it('zsh sends the expected argv for every completion case', async () => { 415 + const shell = await findExecutable(['zsh']); 416 + 417 + if (!shell) { 418 + console.warn('Skipping zsh argv protocol test: zsh is not installed'); 419 + return; 420 + } 421 + 422 + let failures: string[] = []; 423 + 424 + await withFixture('zsh', async (fixture) => { 425 + failures = await collectCaseFailures('zsh', cases, (testCase) => 426 + assertZshCase(shell, fixture, testCase) 427 + ); 428 + }); 429 + 430 + expect(failures.join('\n\n')).toBe(''); 431 + }); 432 + 433 + it('bash sends the expected argv for every completion case', async () => { 434 + const shell = await findExecutable(['bash']); 435 + 436 + if (!shell) { 437 + console.warn('Skipping bash argv protocol test: bash is not installed'); 438 + return; 439 + } 440 + 441 + let failures: string[] = []; 442 + 443 + await withFixture('bash', async (fixture) => { 444 + failures = await collectCaseFailures('bash', cases, (testCase) => 445 + assertBashCase(shell, fixture, testCase) 446 + ); 447 + }); 448 + 449 + expect(failures.join('\n\n')).toBe(''); 450 + }); 451 + 452 + it('fish sends the expected argv for every completion case', async () => { 453 + const shell = await findExecutable(['fish']); 454 + 455 + if (!shell) { 456 + console.warn('Skipping fish argv protocol test: fish is not installed'); 457 + return; 458 + } 459 + 460 + let failures: string[] = []; 461 + 462 + await withFixture('fish', async (fixture) => { 463 + failures = await collectCaseFailures('fish', cases, (testCase) => 464 + assertFishCase(shell, fixture, testCase) 465 + ); 466 + }); 467 + 468 + expect(failures.join('\n\n')).toBe(''); 469 + }); 470 + 471 + it('PowerShell sends the expected argv for every completion case', async () => { 472 + const shell = await findExecutable(['pwsh', 'powershell']); 473 + 474 + if (!shell) { 475 + console.warn( 476 + 'Skipping PowerShell argv protocol test: pwsh/powershell is not installed' 477 + ); 478 + return; 479 + } 480 + 481 + let failures: string[] = []; 482 + 483 + await withFixture('powershell', async (fixture) => { 484 + failures = await collectCaseFailures('powershell', cases, (testCase) => 485 + assertPowerShellCase(shell, fixture, testCase) 486 + ); 487 + }); 488 + 489 + expect(failures.join('\n\n')).toBe(''); 490 + }, 30_000); 491 + });