fix(#2236): add hookShell parameter for PowerShell call operator (#2261)

* 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
This commit is contained in:
Tom Boucher
2026-07-14 15:16:55 -04:00
committed by GitHub
parent 27415c780e
commit e35534c3ec
4 changed files with 90 additions and 18 deletions

View File

@@ -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,
});
}

View File

@@ -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<string, Set<string>> = {