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
12 changes: 6 additions & 6 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"hooks": [
{
"type": "command",
"command": "node \"$HOME/.gemini/extensions/everything-gemini-code/scripts/hooks/suggest-compact.js\" 2>/dev/null || node \"$HOME/.gemini/scripts/hooks/suggest-compact.js\" 2>/dev/null || true"
"command": "node \"$HOME/.gemini/extensions/everything-gemini-code/scripts/hooks/run.js\" suggest-compact"
Comment thread
Jamkris marked this conversation as resolved.
}
],
"description": "Suggest manual compaction at logical intervals"
Expand All @@ -59,7 +59,7 @@
"hooks": [
{
"type": "command",
"command": "node \"$HOME/.gemini/extensions/everything-gemini-code/scripts/hooks/pre-compact.js\" 2>/dev/null || node \"$HOME/.gemini/scripts/hooks/pre-compact.js\" 2>/dev/null || true"
"command": "node \"$HOME/.gemini/extensions/everything-gemini-code/scripts/hooks/run.js\" pre-compact"
}
],
"description": "Run pre-compression logic"
Expand All @@ -71,7 +71,7 @@
"hooks": [
{
"type": "command",
"command": "node \"$HOME/.gemini/extensions/everything-gemini-code/scripts/hooks/session-start.js\" 2>/dev/null || node \"$HOME/.gemini/scripts/hooks/session-start.js\" 2>/dev/null || true"
"command": "node \"$HOME/.gemini/extensions/everything-gemini-code/scripts/hooks/run.js\" session-start"
}
],
"description": "Load previous context and detect package manager on new session"
Expand Down Expand Up @@ -137,7 +137,7 @@
"hooks": [
{
"type": "command",
"command": "node \"$HOME/.gemini/extensions/everything-gemini-code/scripts/hooks/check-console-log.js\" 2>/dev/null || node \"$HOME/.gemini/scripts/hooks/check-console-log.js\" 2>/dev/null || true"
"command": "node \"$HOME/.gemini/extensions/everything-gemini-code/scripts/hooks/run.js\" check-console-log"
}
],
"description": "Check for console.log in modified files after each response"
Expand All @@ -149,7 +149,7 @@
"hooks": [
{
"type": "command",
"command": "node \"$HOME/.gemini/extensions/everything-gemini-code/scripts/hooks/session-end.js\" 2>/dev/null || node \"$HOME/.gemini/scripts/hooks/session-end.js\" 2>/dev/null || true"
"command": "node \"$HOME/.gemini/extensions/everything-gemini-code/scripts/hooks/run.js\" session-end"
}
],
"description": "Persist session state on end"
Expand All @@ -159,7 +159,7 @@
"hooks": [
{
"type": "command",
"command": "node \"$HOME/.gemini/extensions/everything-gemini-code/scripts/hooks/evaluate-session.js\" 2>/dev/null || node \"$HOME/.gemini/scripts/hooks/evaluate-session.js\" 2>/dev/null || true"
"command": "node \"$HOME/.gemini/extensions/everything-gemini-code/scripts/hooks/run.js\" evaluate-session"
}
],
"description": "Evaluate session for extractable patterns"
Expand Down
53 changes: 53 additions & 0 deletions scripts/hooks/run.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/usr/bin/env node
/**
* Cross-platform hook launcher.
*
* Invoked from hooks.json as:
* node "<extension-root>/scripts/hooks/run.js" <hook-name>
*
* Resolves the target hook file relative to this launcher (__dirname) so the
* same hooks.json works regardless of whether the extension was installed to
* ~/.gemini/extensions/... or copied under ~/.gemini/ directly. Avoids
* POSIX-only shell constructs (`||`, `2>/dev/null`, `true`) that break on
* Windows PowerShell 5.x. See issue #42.
*/

'use strict';

const path = require('node:path');
const fs = require('node:fs');
const { spawnSync } = require('node:child_process');

const hookName = process.argv[2];
if (!hookName || !/^[a-z][a-z0-9-]*$/i.test(hookName)) {
// No hook name, or a suspicious value — exit silently.
process.exit(0);
}

const hookPath = path.join(__dirname, `${hookName}.js`);
if (!fs.existsSync(hookPath)) {
// Optional hook not installed — stay quiet, matches the prior `|| true` behavior.
process.exit(0);
}

// Run the hook in its own Node process so it inherits stdin/stdout/stderr
// unchanged (hooks read JSON payloads on stdin and may write to stdout/stderr).
// Mirror the previous "fail soft" semantics: a hook error never fails the
// parent Gemini CLI action. Surface via GEMINI_HOOK_DEBUG=1 when diagnosing.
// Bound execution time so a hung hook can't stall a Gemini session. Override
// via GEMINI_HOOK_TIMEOUT_MS (e.g. set to a larger value for slow hooks, or
// to 0 to disable the bound).
const DEFAULT_HOOK_TIMEOUT_MS = 30_000;
const parsedTimeout = Number.parseInt(process.env.GEMINI_HOOK_TIMEOUT_MS || '', 10);
const timeout = Number.isFinite(parsedTimeout) && parsedTimeout >= 0
? parsedTimeout
: DEFAULT_HOOK_TIMEOUT_MS;

const spawnOptions = { stdio: 'inherit' };
if (timeout > 0) spawnOptions.timeout = timeout;

const result = spawnSync(process.execPath, [hookPath], spawnOptions);
if (result.error && process.env.GEMINI_HOOK_DEBUG) {
console.error(`[hook:${hookName}] spawn error:`, result.error);
}
process.exit(0);
59 changes: 59 additions & 0 deletions tests/hooks/hooks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,65 @@ async function runTests() {
}
})) passed++; else failed++;

if (test('hook script commands avoid POSIX-only shell syntax (Windows PS compat)', () => {
// Regression guard for #42. Hook commands that invoke a script file must
// not chain with `||`, redirect with `2>/dev/null`, or rely on `true` —
// Windows PowerShell 5.x parses `||` as an error and has no /dev/null.
// Inline `node -e "..."` payloads are exempt because the JS string is
// consumed by Node, not the shell.
const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
const hooksObj = hooks.hooks || hooks;

const checkHooks = (hookArray) => {
if (!hookArray) return;
for (const entry of hookArray) {
for (const hook of entry.hooks) {
if (hook.type !== 'command') continue;
if (hook.command.startsWith('node -e ')) continue;
// Regex guards catch variants like `||true`, `cmd|| true`, `2> /dev/null`.
const bad = [
{ pattern: /\|\|/, label: '||' },
{ pattern: /2>\s*\/dev\/null\b/, label: '2>/dev/null' },
{ pattern: /(^|\s)true(\s|$)/, label: 'trailing `true`' },
];
for (const { pattern, label } of bad) {
assert.ok(
!pattern.test(hook.command),
`Hook command must not use POSIX-only "${label}" (breaks on Windows PowerShell): ${hook.command.substring(0, 80)}...`
);
}
}
}
};

if (Array.isArray(hooksObj)) {
checkHooks(hooksObj);
} else {
for (const [, hookArray] of Object.entries(hooksObj)) {
checkHooks(hookArray);
}
}
})) passed++; else failed++;

if (test('run.js launcher resolves hook files from its own directory', () => {
const launcher = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'run.js');
assert.ok(fs.existsSync(launcher), 'scripts/hooks/run.js must exist');
const src = fs.readFileSync(launcher, 'utf8');
// Strip block + line comments so header doc can't satisfy the assertions.
const code = src
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*\/\/.*$/gm, '');
assert.ok(
/path\.join\(\s*__dirname/.test(code),
'launcher should resolve hooks via path.join(__dirname, ...)'
);
assert.ok(
/process\.argv\[2\]/.test(code),
'launcher should read hook name from process.argv[2]'
);
})) passed++; else failed++;

// plugin.json validation
console.log('\nplugin.json Validation:');

Expand Down
Loading