From e35534c3ecba5ca9ded9a508404f415c53f8e137 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 14 Jul 2026 15:16:55 -0400 Subject: [PATCH] fix(#2236): add hookShell parameter for PowerShell call operator (#2261) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(#2236): add hookShell parameter for PowerShell call operator Windows Claude Code with a PowerShell hook runner failed on every hook with 'Unexpected token' — the installer emitted bare quoted paths that PowerShell parses as string literals, not commands. The root cause was that hookCommandNeedsPowerShellCallOperator returned false unconditionally — the &/no-& decision was keyed on (platform, runtime) only, with no signal for the effective hook-execution shell. One runtime (Claude Code) hosts either Git Bash or PowerShell on Windows. Fix: thread a new hookShell parameter through the projection chain. When hookShell='powershell', the & call operator is prepended. Default (Git Bash, no prefix) is unchanged and regression-locked. * docs: backfill changeset PR number (#2261) * merge: keep up to date with next * fix: regenerate stale capability-registry after next merge --- .changeset/merry-newts-travel.md | 5 ++ src/runtime-hooks-surface.cts | 6 +- src/shell-command-projection.cts | 36 +++++------ ...shell-command-projection-dispatch.test.cjs | 61 +++++++++++++++++++ 4 files changed, 90 insertions(+), 18 deletions(-) create mode 100644 .changeset/merry-newts-travel.md diff --git a/.changeset/merry-newts-travel.md b/.changeset/merry-newts-travel.md new file mode 100644 index 000000000..a87a5b1b8 --- /dev/null +++ b/.changeset/merry-newts-travel.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2261 +--- +**Windows Claude Code hooks now work under PowerShell** — when Claude Code's hook runner resolves to PowerShell (not Git Bash), every GSD-installed hook failed with `Unexpected token` because the installer emitted bare quoted paths with no PowerShell call operator. The fix adds a `hookShell` parameter to the hook-command projection chain; when `hookShell='powershell'`, the `&` call operator is prepended. Default behavior (Git Bash, no prefix) is unchanged. (#2236) diff --git a/src/runtime-hooks-surface.cts b/src/runtime-hooks-surface.cts index a5bee923c..aa6ee60c1 100644 --- a/src/runtime-hooks-surface.cts +++ b/src/runtime-hooks-surface.cts @@ -49,7 +49,7 @@ const { isManagedHookBasename: (scriptPath: string, opts?: { surface?: string }) => boolean; isManagedHookCommand: (cmd: string | null | undefined, opts?: { surface?: string; includeLegacyAliases?: boolean; configDir?: string }) => boolean; projectLegacySettingsHookCommand: (opts: { absoluteRunner: string; scriptPath: string; scriptToken: string; runtime: string; platform: string }) => string | null; - projectManagedHookCommand: (opts: { absoluteRunner: string; scriptPath: string; runtime: string; platform: string }) => string | null; + projectManagedHookCommand: (opts: { absoluteRunner: string; scriptPath: string; runtime: string; platform: string; hookShell?: string }) => string | null; projectPortableHookBaseDir: (opts: { configDir: string; homeDir: string }) => string; projectCodexHookTomlCommand: (opts: { absoluteRunner: string; scriptPath: string; platform: string }) => string; shellHookOmitsBashRunner: (opts: { platform: string; runtime: string; isShellHook: boolean }) => boolean; @@ -828,6 +828,7 @@ interface BuildHookCommandOpts { portableHooks?: boolean; platform?: string; runtime?: string; + hookShell?: string; env?: NodeJS.ProcessEnv; existsSync?: (p: string) => boolean; } @@ -836,6 +837,7 @@ function buildHookCommand(configDir: string, hookName: string, opts?: BuildHookC if (!opts) opts = {}; const platform = opts.platform || process.platform; const runtime = opts.runtime || 'generic'; + const hookShell = opts.hookShell; const isShellHook = hookName.endsWith('.sh'); if (shellHookOmitsBashRunner({ platform, runtime, isShellHook })) { @@ -863,6 +865,7 @@ function buildHookCommand(configDir: string, hookName: string, opts?: BuildHookC scriptPath: `${portableBaseDir}/hooks/${hookName}`, runtime: opts.runtime || 'generic', platform, + hookShell, }); } @@ -872,6 +875,7 @@ function buildHookCommand(configDir: string, hookName: string, opts?: BuildHookC scriptPath: hooksPath, runtime, platform, + hookShell, }); } diff --git a/src/shell-command-projection.cts b/src/shell-command-projection.cts index 2f0d00cc6..d66c929d2 100644 --- a/src/shell-command-projection.cts +++ b/src/shell-command-projection.cts @@ -60,27 +60,25 @@ export function posixNormalize(p: string): string { * call operator so a quoted executable token is invokable by the target * runtime/shell combination. * - * Current evidence-backed policy: - * - Claude Code on Windows does NOT need it: its hook commands execute under - * bash/Git Bash and `& ` breaks there (#3413). - * - #1928: Gemini CLI — the ONLY runtime with a verified need for the `& ` - * prefix on Windows — was removed (Google sunset it 2026-06-18). No currently - * supported runtime has a verified need, so this seam is now inert. It is - * retained (not deleted) so a future runtime with a verified need is a - * one-line re-enable, per the conservative policy below. Note: Antigravity — - * the Gemini-backend successor — never matched the old `runtime === 'gemini'` - * check, so its behavior (no prefix) is unchanged. + * The `&`/no-`&` decision is keyed on the **effective hook-execution shell** + * (`opts.hookShell`), not on runtime alone — a single runtime (Claude Code) + * can host either Git Bash or PowerShell on Windows, and no single static + * command string is valid in both (#2236): + * - Git Bash: `"node.exe" "hook.js"` works; `& "node.exe" …` → syntax error. + * - PowerShell: `& "node.exe" "hook.js"` works; bare `"node.exe" …` → + * `Unexpected token`. * - * Keep the policy conservative until another runtime has a verified need. + * Default is `false` (Git Bash form) for backward compatibility. Set + * `opts.hookShell = 'powershell'` to emit the PowerShell call-operator form. */ -export function hookCommandNeedsPowerShellCallOperator(_opts: { platform?: string; runtime?: string } = {}): boolean { - return false; +export function hookCommandNeedsPowerShellCallOperator(opts: { platform?: string; runtime?: string; hookShell?: string } = {}): boolean { + return opts.hookShell === 'powershell'; } /** * Project a fully-assembled hook command string for the target runtime. */ -export function formatHookCommandForRuntime(command: string, opts: { platform?: string; runtime?: string } = {}): string { +export function formatHookCommandForRuntime(command: string, opts: { platform?: string; runtime?: string; hookShell?: string } = {}): string { return hookCommandNeedsPowerShellCallOperator(opts) ? `& ${command}` : command; } @@ -164,22 +162,25 @@ export function projectShellCommandText({ argTokens = [], runtime = 'generic', platform = process.platform, + hookShell, }: { runnerToken?: string | null; argTokens?: (string | null | undefined)[]; runtime?: string; platform?: string; + hookShell?: string; }): string | null { if (!runnerToken) return null; const parts = [runnerToken, ...argTokens.filter(Boolean)] as string[]; - return formatHookCommandForRuntime(parts.join(' '), { platform, runtime }); + return formatHookCommandForRuntime(parts.join(' '), { platform, runtime, hookShell }); } -export function projectManagedHookCommand({ absoluteRunner, scriptPath, runtime = 'generic', platform = process.platform }: { +export function projectManagedHookCommand({ absoluteRunner, scriptPath, runtime = 'generic', platform = process.platform, hookShell }: { absoluteRunner?: string | null; scriptPath?: string | null; runtime?: string; platform?: string; + hookShell?: string; }): string | null { if (!absoluteRunner || !scriptPath) return null; const normalizedScriptPath = platform === 'win32' ? posixNormalize(scriptPath) : scriptPath; @@ -188,7 +189,8 @@ export function projectManagedHookCommand({ absoluteRunner, scriptPath, runtime argTokens: [JSON.stringify(normalizedScriptPath)], runtime, platform, - }); + hookShell, +}); } const MANAGED_HOOK_BASENAMES_BY_SURFACE: Record> = { diff --git a/tests/shell-command-projection-dispatch.test.cjs b/tests/shell-command-projection-dispatch.test.cjs index 3103b5597..b0d1b620b 100644 --- a/tests/shell-command-projection-dispatch.test.cjs +++ b/tests/shell-command-projection-dispatch.test.cjs @@ -534,6 +534,67 @@ describe('bug #3413: Shell Command Projection Module uses runtime-aware hook pol }); }); +describe('bug #2236: hookShell parameter routes PowerShell call operator', () => { + test('hookShell=powershell on Windows returns true for any runtime', () => { + assert.equal( + hookCommandNeedsPowerShellCallOperator({ platform: 'win32', runtime: 'claude', hookShell: 'powershell' }), + true, + 'PowerShell hook shell must trigger the call operator regardless of runtime', + ); + assert.equal( + hookCommandNeedsPowerShellCallOperator({ platform: 'win32', runtime: 'antigravity', hookShell: 'powershell' }), + true, + ); + }); + + test('hookShell=bash (default) stays false — Git-Bash form preserved', () => { + assert.equal( + hookCommandNeedsPowerShellCallOperator({ platform: 'win32', runtime: 'claude', hookShell: 'bash' }), + false, + ); + assert.equal( + hookCommandNeedsPowerShellCallOperator({ platform: 'win32', runtime: 'claude' }), + false, + ); + }); + + test('formatHookCommandForRuntime prepends & when hookShell=powershell', () => { + const cmd = '"C:/Program Files/nodejs/node.exe" "C:/Users/me/.claude/hooks/gsd-context-monitor.js"'; + assert.equal( + formatHookCommandForRuntime(cmd, { platform: 'win32', runtime: 'claude', hookShell: 'powershell' }), + `& ${cmd}`, + ); + }); + + test('formatHookCommandForRuntime omits & when hookShell unset (backward compat)', () => { + const cmd = '"C:/Program Files/nodejs/node.exe" "C:/Users/me/.claude/hooks/gsd-context-monitor.js"'; + assert.equal( + formatHookCommandForRuntime(cmd, { platform: 'win32', runtime: 'claude' }), + cmd, + ); + }); + + test('buildHookCommand emits & prefix when hookShell=powershell', () => { + const cmd = buildHookCommand('C:/Users/me/.claude', 'gsd-check-update.js', { + platform: 'win32', + runtime: 'claude', + hookShell: 'powershell', + }); + assert.ok(cmd, 'buildHookCommand should return a command'); + assert.ok(cmd.startsWith('& '), `PowerShell hook command must start with '& ': ${cmd}`); + }); + + test('buildHookCommand omits & prefix when hookShell=bash (regression lock)', () => { + const cmd = buildHookCommand('C:/Users/me/.claude', 'gsd-check-update.js', { + platform: 'win32', + runtime: 'claude', + hookShell: 'bash', + }); + assert.ok(cmd, 'buildHookCommand should return a command'); + assert.equal(cmd.startsWith('& '), false, `Git-Bash hook command must NOT start with '& ': ${cmd}`); + }); +}); + describe('bug #3413: installer hook surfaces consume runtime-aware projection', () => { test('buildHookCommand emits shell-neutral Claude hook command on Windows', () => { const cmd = buildHookCommand('C:/Users/me/.claude', 'gsd-check-update.js', {