Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions plugins/code-oz/scripts/resolve-code-oz.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,22 @@ case "${OS_NAME}" in
;;
esac

# ---------------------------------------------------------------------------
# Drop empty-string positional arguments before resolution. A plugin command
# card renders `<subcommand> "$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[@]}"}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The expression ${filtered_args[@]+"${filtered_args[@]}"} is highly cryptic and relies on subtle bash expansion and quoting rules that can be difficult to maintain or easily broken by future changes (e.g., if someone tries to wrap the outer expression in double quotes, which would introduce an empty string argument when the array is empty).

A much more readable, standard, and robust approach that is fully compatible with set -u and older bash versions (like bash 3.2 on macOS) is to check the array length explicitly using ${#filtered_args[@]}.

Suggested change
set -- ${filtered_args[@]+"${filtered_args[@]}"}
if [ ${#filtered_args[@]} -gt 0 ]; then
set -- "${filtered_args[@]}"
else
set --
fi


# ---------------------------------------------------------------------------
# 2. code-oz found on PATH — exec directly, forwarding all args.
# ---------------------------------------------------------------------------
Expand Down
51 changes: 51 additions & 0 deletions tests/plugins/bootstrap-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<subcommand> "$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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading