Skip to content
Open
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
6 changes: 3 additions & 3 deletions hooks/claude-codex-hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-activate.js\"",
"command": "S=$(printf '%s' \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-activate.js\" | tr '\\\\' '/'); command -v node >/dev/null 2>&1 && [ -f \"$S\" ] && node \"$S\" || true",
"commandWindows": "if (Get-Command node -ErrorAction SilentlyContinue) { node \"$env:CLAUDE_PLUGIN_ROOT\\hooks\\ponytail-activate.js\" }",
"timeout": 5,
"statusMessage": "Loading ponytail mode..."
Expand All @@ -19,7 +19,7 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-subagent.js\"",
"command": "S=$(printf '%s' \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-subagent.js\" | tr '\\\\' '/'); command -v node >/dev/null 2>&1 && [ -f \"$S\" ] && node \"$S\" || true",
"commandWindows": "if (Get-Command node -ErrorAction SilentlyContinue) { node \"$env:CLAUDE_PLUGIN_ROOT\\hooks\\ponytail-subagent.js\" }",
"timeout": 5,
"statusMessage": "Loading ponytail mode..."
Expand All @@ -32,7 +32,7 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-mode-tracker.js\"",
"command": "S=$(printf '%s' \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-mode-tracker.js\" | tr '\\\\' '/'); command -v node >/dev/null 2>&1 && [ -f \"$S\" ] && node \"$S\" || true",
"commandWindows": "if (Get-Command node -ErrorAction SilentlyContinue) { node \"$env:CLAUDE_PLUGIN_ROOT\\hooks\\ponytail-mode-tracker.js\" }",
"timeout": 5,
"statusMessage": "Tracking ponytail mode..."
Expand Down
4 changes: 2 additions & 2 deletions hooks/copilot-hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@
"sessionStart": [
{
"type": "command",
"bash": "node \"${PLUGIN_ROOT}/hooks/ponytail-activate.js\"",
"bash": "S=$(printf '%s' \"${PLUGIN_ROOT}/hooks/ponytail-activate.js\" | tr '\\\\' '/'); command -v node >/dev/null 2>&1 && [ -f \"$S\" ] && node \"$S\" || true",
"powershell": "node \"${PLUGIN_ROOT}\\hooks\\ponytail-activate.js\"",
"timeoutSec": 5
}
],
"userPromptSubmitted": [
{
"type": "command",
"bash": "node \"${PLUGIN_ROOT}/hooks/ponytail-mode-tracker.js\"",
"bash": "S=$(printf '%s' \"${PLUGIN_ROOT}/hooks/ponytail-mode-tracker.js\" | tr '\\\\' '/'); command -v node >/dev/null 2>&1 && [ -f \"$S\" ] && node \"$S\" || true",
"powershell": "node \"${PLUGIN_ROOT}\\hooks\\ponytail-mode-tracker.js\"",
"timeoutSec": 5
}
Expand Down
34 changes: 12 additions & 22 deletions tests/hooks-windows.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,34 +43,24 @@ test('every commandWindows uses PowerShell $env: syntax, not cmd.exe %VAR%', ()
}
});

test('shared hook commands avoid POSIX-only guard syntax', () => {
const commands = commandHooks()
.map((h) => h.command)
.filter(Boolean);
assert.ok(commands.length > 0, 'expected at least one shared command entry');
for (const cmd of commands) {
assert.doesNotMatch(cmd, POSIX_GUARD_SYNTAX, `command uses POSIX-only guard syntax: ${cmd}`);
test('POSIX hook commands have a PowerShell override when they use POSIX guards', () => {
const hooks = commandHooks().filter((h) => h.command);
assert.ok(hooks.length > 0, 'expected at least one command entry');
for (const hook of hooks) {
if (POSIX_GUARD_SYNTAX.test(hook.command)) {
assert.ok(hook.commandWindows, `POSIX-only command needs commandWindows: ${hook.command}`);
}
}
});

// Issue #527 / #569: the shared `command` field must be shell-agnostic. `exec`
// is a bash/zsh builtin with no PowerShell equivalent, but some hosts run
// `command` through PowerShell on Windows regardless of the commandWindows
// field — VS Code Copilot always does (it never reads commandWindows), and
// native Claude Code launched from Git Bash was seen doing the same. `exec
// node ...` then dies on its first token with CommandNotFoundException, so
// every hook fails on Windows. Plain `node ...` runs natively in both bash and
// PowerShell. The wrapper-process pileup that #461 originally used `exec` to
// avoid is handled separately by each hook's stdin self-exit guard (#443/#477).
test('shared hook commands are shell-agnostic (no bash-only exec prefix)', () => {
test('PowerShell hook commands avoid POSIX-only guard syntax', () => {
const commands = commandHooks()
.map((h) => h.command)
.map((h) => h.commandWindows)
.filter(Boolean);
assert.ok(commands.length > 0, 'expected at least one shared command entry');
assert.ok(commands.length > 0, 'expected at least one commandWindows entry');
for (const cmd of commands) {
assert.doesNotMatch(cmd, /(^|\s)exec\s/, `command must not use the bash-only 'exec' builtin (breaks under PowerShell): ${cmd}`);
assert.match(cmd, /^node\s+/, `command must invoke node directly so it runs in both bash and PowerShell: ${cmd}`);
assert.doesNotMatch(cmd, /;\s*exit 0$/, `command must not leave a shell wrapper waiting on node: ${cmd}`);
assert.doesNotMatch(cmd, POSIX_GUARD_SYNTAX, `commandWindows uses POSIX-only guard syntax: ${cmd}`);
assert.doesNotMatch(cmd, /(^|\s)exec\s/, `commandWindows must not use the bash-only 'exec' builtin: ${cmd}`);
}
});

Expand Down
68 changes: 67 additions & 1 deletion tests/hooks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,72 @@ const home = path.join(temp, 'home');
const pluginData = path.join(temp, 'plugin-data');
fs.mkdirSync(home, { recursive: true });

function collectManifestCommands(file, field) {
const manifest = JSON.parse(fs.readFileSync(path.join(root, file), 'utf8'));
const commands = [];

function visit(value) {
if (!value || typeof value !== 'object') return;
if (typeof value[field] === 'string') commands.push(value[field]);
for (const child of Object.values(value)) {
if (Array.isArray(child)) child.forEach(visit);
else visit(child);
}
}

visit(manifest);
return commands;
}

function runShell(command, env, input = '') {
return spawnSync('/bin/sh', ['-c', command], {
env,
input,
encoding: 'utf8',
});
}

const backslashedRoot = root.split(path.sep).join('\\');
let result;

for (const command of collectManifestCommands('hooks/claude-codex-hooks.json', 'command')) {
result = runShell(command, {
...process.env,
HOME: home,
USERPROFILE: home,
CLAUDE_PLUGIN_ROOT: backslashedRoot,
});
assert.equal(result.status, 0, result.stderr);

result = runShell(command, {
HOME: home,
USERPROFILE: home,
CLAUDE_PLUGIN_ROOT: backslashedRoot,
PATH: '',
});
assert.equal(result.status, 0, result.stderr);
}

for (const command of collectManifestCommands('hooks/copilot-hooks.json', 'bash')) {
result = runShell(command, {
...process.env,
HOME: home,
USERPROFILE: home,
PLUGIN_ROOT: backslashedRoot,
COPILOT_PLUGIN_DATA: path.join(temp, 'copilot-manifest-data'),
});
assert.equal(result.status, 0, result.stderr);

result = runShell(command, {
HOME: home,
USERPROFILE: home,
PLUGIN_ROOT: backslashedRoot,
COPILOT_PLUGIN_DATA: path.join(temp, 'copilot-manifest-data-no-node'),
PATH: '',
});
assert.equal(result.status, 0, result.stderr);
}

// USERPROFILE alongside HOME: os.homedir() reads USERPROFILE on Windows, HOME on POSIX.
const codexEnv = {
HOME: home,
Expand All @@ -55,7 +121,7 @@ const codexEnv = {
};
const codexState = path.join(pluginData, '.ponytail-active');

let result = run('ponytail-activate.js', codexEnv);
result = run('ponytail-activate.js', codexEnv);
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(codexState, 'utf8'), 'ultra');
let output = JSON.parse(result.stdout);
Expand Down
Loading