[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: prevent duplicate empty args in generated completions (#132)

authored by

AmirHossein Sakhravi and committed by
GitHub
(Jun 18, 2026, 7:41 PM +0330) 998ab6fe 9db9d994

+579 -12
+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
+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)
+2
package.json
··· 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 .",
+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"
+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 + });