From 876c78005c035c787f6c1122a545af3db458b3aa Mon Sep 17 00:00:00 2001 From: LeTamanoir Date: Sat, 4 Apr 2026 20:43:55 +0200 Subject: [PATCH 1/3] fix: export COMPLETE and _COMPLETE_INDEX env vars in bash/zsh completion hooks The bash and zsh registration scripts set COMPLETE and _COMPLETE_INDEX as shell variables inside $(...) subshells, but never export them. This means the CLI binary does not see them in process.env, falls through to normal arg parsing, and errors on the -- separator. Fish and nushell are unaffected because they use inline prefix syntax (COMPLETE=fish cmd ...) which always exports to the child. Signed-off-by: LeTamanoir --- src/Completions.test.ts | 4 ++-- src/Completions.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Completions.test.ts b/src/Completions.test.ts index 9d5f5f2..d7870b3 100644 --- a/src/Completions.test.ts +++ b/src/Completions.test.ts @@ -231,7 +231,7 @@ describe('register', () => { test('bash: generates complete -F script with nospace support', () => { const script = Completions.register('bash', 'mycli') expect(script).toContain('_incur_complete_mycli()') - expect(script).toContain('COMPLETE="bash"') + expect(script).toContain('export COMPLETE="bash"') expect(script).toContain('complete -o default -o bashdefault -o nosort -F') expect(script).toContain('"mycli" -- "${COMP_WORDS[@]}"') expect(script).toContain('compopt -o nospace') @@ -240,7 +240,7 @@ describe('register', () => { test('zsh: generates compdef script', () => { const script = Completions.register('zsh', 'mycli') expect(script).toContain('#compdef mycli') - expect(script).toContain('COMPLETE="zsh"') + expect(script).toContain('export COMPLETE="zsh"') expect(script).toContain('compdef _incur_complete_mycli mycli') expect(script).toContain('_describe') }) diff --git a/src/Completions.ts b/src/Completions.ts index 9d654b3..ffbf947 100644 --- a/src/Completions.ts +++ b/src/Completions.ts @@ -232,8 +232,8 @@ function bashRegister(name: string): string { local _COMPLETE_INDEX=\${COMP_CWORD} local _completions _completions=( $( - COMPLETE="bash" - _COMPLETE_INDEX="$_COMPLETE_INDEX" + export COMPLETE="bash" + export _COMPLETE_INDEX="$_COMPLETE_INDEX" "${name}" -- "\${COMP_WORDS[@]}" ) ) if [[ $? != 0 ]]; then @@ -262,8 +262,8 @@ function zshRegister(name: string): string { return `#compdef ${name} _incur_complete_${id}() { local completions=("\${(@f)$( - _COMPLETE_INDEX=$(( CURRENT - 1 )) - COMPLETE="zsh" + export _COMPLETE_INDEX=$(( CURRENT - 1 )) + export COMPLETE="zsh" "${name}" -- "\${words[@]}" 2>/dev/null )}") if [[ -n $completions ]]; then From 9848d50addf48f811214ba7ff859f25b9648d290 Mon Sep 17 00:00:00 2001 From: tmm Date: Tue, 7 Apr 2026 12:54:12 -0400 Subject: [PATCH 2/3] test: add shell completion regression coverage --- src/Completions.test.ts | 95 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/src/Completions.test.ts b/src/Completions.test.ts index d7870b3..1a8fed0 100644 --- a/src/Completions.test.ts +++ b/src/Completions.test.ts @@ -1,3 +1,7 @@ +import { execFile, spawnSync } from 'node:child_process' +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { Cli, Completions, z } from 'incur' const originalIsTTY = process.stdout.isTTY @@ -15,6 +19,45 @@ vi.mock('./SyncSkills.js', async (importOriginal) => { return { ...actual, readHash: () => undefined } }) +function hasShell(shell: string): boolean { + return spawnSync(shell, ['-c', ':'], { stdio: 'ignore' }).status === 0 +} + +const bash = hasShell('bash') +const zsh = hasShell('zsh') +const fish = hasShell('fish') + +function exec(cmd: string, args: string[], env: NodeJS.ProcessEnv): Promise { + return new Promise((resolve, reject) => { + execFile(cmd, args, { env, timeout: 30_000 }, (error, stdout, stderr) => { + if (error) reject(new Error(stderr?.trim() || stdout?.trim() || error.message)) + else resolve(stdout) + }) + }) +} + +async function withFakeCli(run: (dir: string) => Promise) { + const dir = await mkdtemp(join(tmpdir(), 'incur-completions-')) + const bin = join(dir, 'fake-cli') + + try { + await writeFile( + bin, + `#!/bin/sh +if [ -n "$COMPLETE" ]; then + printf '%s' "$COMPLETE:\${_COMPLETE_INDEX:-missing}" +else + printf 'missing' +fi +`, + ) + await chmod(bin, 0o755) + await run(dir) + } finally { + await rm(dir, { recursive: true, force: true }) + } +} + async function serve( cli: { serve: Cli.Cli['serve'] }, argv: string[], @@ -237,6 +280,25 @@ describe('register', () => { expect(script).toContain('compopt -o nospace') }) + test.skipIf(!bash)('bash: exports completion env vars to the CLI subprocess', async () => { + await withFakeCli(async (dir) => { + const output = await exec( + 'bash', + [ + '-lc', + `${Completions.register('bash', 'fake-cli')} +COMP_WORDS=('fake-cli' 'build' '') +COMP_CWORD=2 +_incur_complete_fake_cli +printf '%s' "\${COMPREPLY[*]}"`, + ], + { ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}` }, + ) + + expect(output).toBe('bash:2') + }) + }) + test('zsh: generates compdef script', () => { const script = Completions.register('zsh', 'mycli') expect(script).toContain('#compdef mycli') @@ -245,6 +307,26 @@ describe('register', () => { expect(script).toContain('_describe') }) + test.skipIf(!zsh)('zsh: exports completion env vars to the CLI subprocess', async () => { + await withFakeCli(async (dir) => { + const output = await exec( + 'zsh', + [ + '-lc', + `compdef() { : } +_describe() { print -r -- "\${(j:|:)\${(@P)2}}" } +${Completions.register('zsh', 'fake-cli')} +words=('fake-cli' 'build' '') +CURRENT=3 +_incur_complete_fake_cli`, + ], + { ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}` }, + ) + + expect(output.trim()).toBe('zsh:2') + }) + }) + test('fish: generates complete command', () => { const script = Completions.register('fish', 'mycli') expect(script).toContain('complete --keep-order --exclusive --command mycli') @@ -252,6 +334,19 @@ describe('register', () => { expect(script).toContain('commandline --current-token') }) + test.skipIf(!fish)('fish: passes completion env vars to the CLI subprocess', async () => { + await withFakeCli(async (dir) => { + const output = await exec( + 'fish', + ['-c', `${Completions.register('fish', 'fake-cli')} +complete --do-complete 'fake-cli '`], + { ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}` }, + ) + + expect(output.trim()).toBe('fish:missing') + }) + }) + test('nushell: generates external completer closure', () => { const script = Completions.register('nushell', 'mycli') expect(script).toContain('COMPLETE=nushell') From 390fd358bbd610ec4bf4f5172911d99d516a49ff Mon Sep 17 00:00:00 2001 From: tmm Date: Tue, 7 Apr 2026 12:54:51 -0400 Subject: [PATCH 3/3] chore: add changeset for shell completion fix --- .changeset/swift-rabbits-dance.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/swift-rabbits-dance.md diff --git a/.changeset/swift-rabbits-dance.md b/.changeset/swift-rabbits-dance.md new file mode 100644 index 0000000..3553596 --- /dev/null +++ b/.changeset/swift-rabbits-dance.md @@ -0,0 +1,5 @@ +--- +incur: patch +--- + +Exported shell completion environment variables in bash and zsh hooks.