From 9a419dce02c1e164ec767e811f5d18300ee6da04 Mon Sep 17 00:00:00 2001 From: omerakben Date: Sat, 30 May 2026 11:37:59 -0400 Subject: [PATCH] fix(plugins): strip empty-string args in resolver so no-arg slash commands work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every plugin command card renders ` "$ARGUMENTS"`. When the user runs a command with no arguments, Claude Code substitutes an empty $ARGUMENTS, leaving a literal empty positional: bash resolve-code-oz.sh doctor "" The engine's 0.21.1 subcommand dispatcher (doctor split into providers|tools|git|run) treats "" as a subcommand name, matches nothing, and exits 1 ("unknown subcommand ''"). doctor and init break on every no-arg invocation; run and resume carry a latent trailing empty. The engine already does the right thing with zero positionals — the empty string is what breaks it. Fix at the layer that introduces the artifact: the resolver drops empty-string positionals before both the PATH-exec and npx branches. An empty positional is never meaningful to code-oz, so `doctor ""` becomes `doctor`. Array form preserves args containing spaces; ${a[@]+"${a[@]}"} keeps the empty-array expansion safe under `set -u` on bash 3.2 (macOS default). RED-first: two empty-strip tests (PATH + npx branches) plus a spaces-preservation guard. Verified on bash 3.2.57: `resolve-code-oz.sh doctor ""` now exits 0; `init ""` is byte-identical to `init`. 3815 offline tests pass; typecheck clean. A separate, carefully-scoped engine-level normalization (treat an empty subcommand token as absent, without a blanket argv filter that would eat empty flag values) remains a recommended follow-up to harden non-plugin callers. --- plugins/code-oz/scripts/resolve-code-oz.sh | 16 +++++++ tests/plugins/bootstrap-resolver.test.ts | 51 ++++++++++++++++++++++ 2 files changed, 67 insertions(+) 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', () => {