* 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:
5
.changeset/merry-newts-travel.md
Normal file
5
.changeset/merry-newts-travel.md
Normal file
@@ -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)
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>> = {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user