diff --git a/plugins/code-oz/scripts/resolve-code-oz.sh b/plugins/code-oz/scripts/resolve-code-oz.sh index 9a3c297..f8ee413 100755 --- a/plugins/code-oz/scripts/resolve-code-oz.sh +++ b/plugins/code-oz/scripts/resolve-code-oz.sh @@ -68,6 +68,22 @@ case "${OS_NAME}" in ;; esac +# --------------------------------------------------------------------------- +# Drop empty-string positional arguments before resolution. A plugin command +# card renders ` "$ARGUMENTS"`; with no user arguments Claude Code +# substitutes an empty $ARGUMENTS, leaving a literal "" that the engine's +# subcommand dispatcher (0.21.1+) rejects as an unknown subcommand. An empty +# positional is never meaningful to code-oz, so the launcher strips it before +# both the PATH-exec and npx branches. The array form preserves args that +# contain spaces; ${a[@]+"${a[@]}"} is the bash-3.2 + `set -u`-safe way to +# expand a possibly-empty array. +# --------------------------------------------------------------------------- +filtered_args=() +for arg in "$@"; do + [ -n "${arg}" ] && filtered_args+=("${arg}") +done +set -- ${filtered_args[@]+"${filtered_args[@]}"} + # --------------------------------------------------------------------------- # 2. code-oz found on PATH — exec directly, forwarding all args. # --------------------------------------------------------------------------- diff --git a/tests/plugins/bootstrap-resolver.test.ts b/tests/plugins/bootstrap-resolver.test.ts index b6c8e3d..9a58bad 100644 --- a/tests/plugins/bootstrap-resolver.test.ts +++ b/tests/plugins/bootstrap-resolver.test.ts @@ -122,6 +122,42 @@ describe('resolve-code-oz.sh — PATH binary present', () => { expect(result.stdout).toContain('--provider') expect(result.stdout).toContain('fake') }) + + test('strips an empty-string positional before exec (no-args plugin card artifact)', async () => { + // A plugin command card renders ` "$ARGUMENTS"`. With no user + // arguments Claude Code substitutes an empty $ARGUMENTS, leaving a literal + // `doctor ""`. The empty positional must not reach the engine, whose 0.21.1 + // subcommand dispatcher rejects '' as an unknown subcommand. The fake wraps + // each forwarded arg in brackets, so an empty arg would appear as `[]`. + const fakeDir = await makeFakeBinDir({ + 'code-oz': `#!/bin/sh\nprintf 'ARGS:'\nfor a in "$@"; do printf '[%s]' "$a"; done\nprintf '\\n'\n`, + }) + + const result = await runResolver({ + path: `${fakeDir}:${SYSTEM_BIN}`, + args: ['doctor', ''], + }) + + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('[doctor]') + expect(result.stdout).not.toContain('[]') + }) + + test('preserves a non-empty positional that contains spaces', async () => { + // The empty-arg filter must not word-split or drop legitimate args. + const fakeDir = await makeFakeBinDir({ + 'code-oz': `#!/bin/sh\nprintf 'ARGS:'\nfor a in "$@"; do printf '[%s]' "$a"; done\nprintf '\\n'\n`, + }) + + const result = await runResolver({ + path: `${fakeDir}:${SYSTEM_BIN}`, + args: ['run', 'fix the login bug'], + }) + + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('[run]') + expect(result.stdout).toContain('[fix the login bug]') + }) }) describe('resolve-code-oz.sh — npx fallback', () => { @@ -165,6 +201,21 @@ describe('resolve-code-oz.sh — npx fallback', () => { // The exact pinned version string must appear in the npx invocation expect(result.stdout).toContain(pinnedVersion) }) + + test('strips an empty-string positional before the npx invocation too', async () => { + const fakeDir = await makeFakeBinDir({ + npx: `#!/bin/sh\nprintf 'ARGS:'\nfor a in "$@"; do printf '[%s]' "$a"; done\nprintf '\\n'\n`, + }) + + const result = await runResolver({ + path: `${fakeDir}:${SYSTEM_BIN}`, + args: ['doctor', ''], + }) + + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('[doctor]') + expect(result.stdout).not.toContain('[]') + }) }) describe('resolve-code-oz.sh — npx failure surfaces scope-routing caveat', () => {