feat(#776): Gemini hook events (BeforeAgent/AfterAgent/BeforeModel) + hooksConfig.enabled check (#829)
* feat(#776): Gemini hook events (BeforeAgent/AfterAgent/BeforeModel) + hooksConfig.enabled check Register three new Gemini-CLI hook events on install: - BeforeAgent: fires before agent planning; wired to gsd-context-monitor - AfterAgent: fires after final response generation; wired to gsd-context-monitor - BeforeModel: fires before each LLM call (per-turn); wired to gsd-context-monitor All three reuse gsd-context-monitor.js (no new hook files). Uninstall cleanup loop extended to remove the new events. Non-array guard added for robustness against malformed settings. Also detect hooksConfig.enabled:false in Gemini settings and emit a clear warning — without this check, all registered hooks silently do nothing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: update changeset pr: 829 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(#776): document Gemini hook events Add hook coverage table to the Gemini CLI section of install-on-your-runtime.md, covering the three new events (BeforeAgent/AfterAgent/BeforeModel wired to gsd-context-monitor) plus a callout for the hooksConfig.enabled:false silent failure mode detected by the installer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/776-gemini-hook-events.md
Normal file
5
.changeset/776-gemini-hook-events.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 829
|
||||
---
|
||||
Gemini installs now register three additional hook events — `BeforeAgent`, `AfterAgent`, and `BeforeModel` — wired to `gsd-context-monitor.js` for per-turn context headroom tracking. Previously only `SessionStart`, `BeforeTool`, and `AfterTool` were registered. The installer also detects `hooksConfig.enabled: false` in the user's Gemini `settings.json` and emits a clear warning, surfacing the silent failure mode where all hooks are registered but never execute. (#776)
|
||||
@@ -8239,9 +8239,10 @@ function uninstall(isGlobal, runtime = 'claude') {
|
||||
// Remove GSD hooks from settings — per-hook granularity to preserve
|
||||
// user hooks that share an entry with a GSD hook (#1755 followup).
|
||||
// Includes the 3 Qwen-only events added in #788 (SubagentStop, Stop,
|
||||
// PreCompact) — safe to iterate for all runtimes; non-Qwen installs
|
||||
// simply find no entries and skip.
|
||||
for (const eventName of ['SessionStart', 'PostToolUse', 'AfterTool', 'PreToolUse', 'BeforeTool', 'SubagentStop', 'Stop', 'PreCompact']) {
|
||||
// PreCompact) and the 3 Gemini-only events added in #776 (BeforeAgent,
|
||||
// AfterAgent, BeforeModel) — safe to iterate for all runtimes; non-Qwen
|
||||
// and non-Gemini installs simply find no entries and skip.
|
||||
for (const eventName of ['SessionStart', 'PostToolUse', 'AfterTool', 'PreToolUse', 'BeforeTool', 'SubagentStop', 'Stop', 'PreCompact', 'BeforeAgent', 'AfterAgent', 'BeforeModel']) {
|
||||
if (settings.hooks && settings.hooks[eventName]) {
|
||||
const before = JSON.stringify(settings.hooks[eventName]);
|
||||
settings.hooks[eventName] = settings.hooks[eventName]
|
||||
@@ -11215,8 +11216,74 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
||||
}
|
||||
}
|
||||
// ── end Qwen-only extended hook events ────────────────────────────────────
|
||||
|
||||
// ── Gemini-only extended hook events (#776) ───────────────────────────────
|
||||
// Gemini CLI exposes several hook events beyond BeforeTool/AfterTool that
|
||||
// gsd previously did not register. Three high-value events are added here:
|
||||
//
|
||||
// BeforeAgent — fires after user submits a prompt, before the agent
|
||||
// plans. Wire gsd-context-monitor for context headroom
|
||||
// awareness at prompt time.
|
||||
// AfterAgent — fires once per turn after the model generates its final
|
||||
// response. Wire gsd-context-monitor to track headroom
|
||||
// after each agent turn completes.
|
||||
// BeforeModel — fires before each LLM call (per-turn, not per-session).
|
||||
// Wire gsd-context-monitor for per-turn context injection
|
||||
// — more precise than session-start-only injection.
|
||||
//
|
||||
// All three reuse gsd-context-monitor.js — no new hook files needed.
|
||||
// The `decision:"deny"` retry capability of AfterAgent is intentionally
|
||||
// left to the hook script to implement when triggered (gsd-context-monitor
|
||||
// exits 0 / advisory-only today; an active quality gate is a follow-on).
|
||||
//
|
||||
// Note: BeforeToolSelection is NOT wired. That event does not map to a
|
||||
// gsd hook use case at this time; deferred to a follow-on issue.
|
||||
//
|
||||
// Guard: isGemini is defined at the top of install() (line ~8696).
|
||||
if (isGemini) {
|
||||
for (const geminiEvent of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) {
|
||||
if (!Array.isArray(settings.hooks[geminiEvent])) {
|
||||
settings.hooks[geminiEvent] = [];
|
||||
}
|
||||
const alreadyHasContextMonitor = settings.hooks[geminiEvent].some(entry =>
|
||||
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor'))
|
||||
);
|
||||
if (!alreadyHasContextMonitor && fs.existsSync(contextMonitorFile) && contextMonitorCommand) {
|
||||
settings.hooks[geminiEvent].push({
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: contextMonitorCommand,
|
||||
timeout: 10
|
||||
}
|
||||
]
|
||||
});
|
||||
console.log(` ${green}✓${reset} Configured ${geminiEvent} context monitor hook (Gemini)`);
|
||||
} else if (!alreadyHasContextMonitor && !fs.existsSync(contextMonitorFile)) {
|
||||
console.warn(` ${yellow}⚠${reset} Skipped ${geminiEvent} hook — gsd-context-monitor.js not found at target`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── end Gemini-only extended hook events ──────────────────────────────────
|
||||
}
|
||||
|
||||
// ── Gemini hooksConfig.enabled check (#776) ───────────────────────────────
|
||||
// Detect `hooksConfig.enabled: false` in the already-loaded settings object
|
||||
// and emit a clear warning. When this field is false the Gemini CLI silently
|
||||
// disables ALL hook execution — gsd hooks are registered but will never run.
|
||||
// The check is read-only (warning only; we do not mutate hooksConfig).
|
||||
// Note: we use the in-memory `settings` object (already read from disk and
|
||||
// cleaned up by validateHookFields/cleanupOrphanedHooks above) rather than
|
||||
// re-reading settings.json, avoiding a TOCTOU window between the two reads.
|
||||
if (isGemini && settings && settings.hooksConfig && settings.hooksConfig.enabled === false) {
|
||||
console.warn(
|
||||
` ${yellow}⚠${reset} Warning: hooksConfig.enabled is false in your Gemini settings.json.\n` +
|
||||
` gsd-core hooks are registered but will NOT run until you set\n` +
|
||||
` hooksConfig.enabled: true in ${path.join(targetDir, 'settings.json')}.`
|
||||
);
|
||||
}
|
||||
// ── end hooksConfig.enabled check ────────────────────────────────────────
|
||||
|
||||
// Compute the update-banner hook command alongside the others so
|
||||
// installAllRuntimes can register it at finalize time when the user opts
|
||||
// in (#2795). Computed here (not in finishInstall) so the same buildHookCommand
|
||||
|
||||
@@ -107,6 +107,21 @@ The installer also enriches the generated TOML commands with two native Gemini c
|
||||
GEMINI_CONFIG_DIR=~/.gemini-alt npx @opengsd/gsd-core@latest --gemini --global
|
||||
```
|
||||
|
||||
**Hook coverage**
|
||||
|
||||
GSD registers the following hook events automatically on install:
|
||||
|
||||
| Event | Hook | Purpose |
|
||||
|---|---|---|
|
||||
| `SessionStart` | `gsd-check-update.js`, `gsd-session-state.sh` | Update check, session orientation |
|
||||
| `BeforeTool` | `gsd-prompt-guard.js`, `gsd-read-guard.js`, `gsd-workflow-guard.js`, `gsd-worktree-path-guard.js`, `gsd-validate-commit.sh` | Prompt guard, read-before-edit, workflow + worktree safety, commit validation |
|
||||
| `AfterTool` | `gsd-context-monitor.js`, `gsd-read-injection-scanner.js`, `gsd-phase-boundary.sh`, `gsd-graphify-update.sh` | Context monitoring, read-time scan, phase boundary detection |
|
||||
| `BeforeAgent` | `gsd-context-monitor.js` | Context headroom awareness before the agent begins planning each prompt |
|
||||
| `AfterAgent` | `gsd-context-monitor.js` | Context headroom tracking after each agent turn's final response |
|
||||
| `BeforeModel` | `gsd-context-monitor.js` | Per-turn context injection before each LLM call |
|
||||
|
||||
> **`hooksConfig.enabled: false` warning.** If your Gemini `settings.json` contains `hooksConfig.enabled: false`, the Gemini CLI silently disables all hook execution — GSD hooks are registered but will never run. The installer detects this and emits a warning. To enable hooks, set `hooksConfig.enabled: true` in `~/.gemini/settings.json` (or the directory matching your `GEMINI_CONFIG_DIR`).
|
||||
|
||||
---
|
||||
|
||||
### Gemini CLI — native extension install (#775)
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
"install": {
|
||||
"files": [
|
||||
"bug-410-install-defaults-test-mode-guard.test.cjs",
|
||||
"enh-776-install-gemini-hook-events.test.cjs",
|
||||
"install-minimal-hooks.test.cjs",
|
||||
"install-path-detection.test.cjs",
|
||||
"install-regressions.test.cjs",
|
||||
|
||||
406
tests/enh-776-install-gemini-hook-events.test.cjs
Normal file
406
tests/enh-776-install-gemini-hook-events.test.cjs
Normal file
@@ -0,0 +1,406 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Enhancement #776: Adopt new Gemini hook events + detect hooksConfig.enabled:false.
|
||||
*
|
||||
* Gemini CLI exposes several hook events beyond BeforeTool/AfterTool that gsd
|
||||
* previously did not register. This suite asserts that a Gemini install
|
||||
* registers the 3 new high-value events:
|
||||
* - BeforeAgent — fires before the agent plans (context headroom tracking)
|
||||
* - AfterAgent — fires after final response generation (context tracking)
|
||||
* - BeforeModel — fires before each LLM call (per-turn context awareness)
|
||||
*
|
||||
* All three are wired to gsd-context-monitor.js — the same hook used for
|
||||
* AfterTool — so context headroom warnings surface at these lifecycle moments.
|
||||
*
|
||||
* Also asserts:
|
||||
* - Claude Code installs do NOT gain these Gemini-only events (strict scope guard).
|
||||
* - Reinstalls are idempotent (no hook duplication).
|
||||
* - Uninstall removes the new event registrations.
|
||||
* - hooksConfig.enabled:false warning is emitted during a Gemini install.
|
||||
*
|
||||
* Source: https://github.com/google-gemini/gemini-cli/blob/main/docs/hooks/reference.md
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { install, uninstall, validateHookFields } = require('../bin/install.js');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Extract all hook commands registered under `eventName` from settings. */
|
||||
function hooksForEvent(settings, eventName) {
|
||||
if (!settings || !settings.hooks || !Array.isArray(settings.hooks[eventName])) return [];
|
||||
return settings.hooks[eventName].flatMap(entry =>
|
||||
(entry && Array.isArray(entry.hooks) ? entry.hooks : [])
|
||||
.map(h => h && h.command)
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
// Stub JS hook files that the installer checks with fs.existsSync() so hook
|
||||
// registration guards pass even when hooks/dist/ isn't built.
|
||||
//
|
||||
// For Gemini, the installer migration baseline includes 'hooks/' in its surface
|
||||
// list (unlike Qwen which excludes it). Pre-install stubs placed in .gemini/hooks/
|
||||
// are classified as 'bundled-gsd-hook' and auto-removed by the migration before
|
||||
// registration can succeed. The workaround: run a first install (which writes the
|
||||
// gsd-file-manifest.json), then add the stubs, then run install again. On the
|
||||
// second install the manifest marks the hook files as managed, so migration keeps
|
||||
// them and registration guards (fs.existsSync) pass.
|
||||
const HOOKS_SRC = path.join(__dirname, '..', 'hooks');
|
||||
const STUB_HOOKS = [
|
||||
'gsd-context-monitor.js',
|
||||
'gsd-prompt-guard.js',
|
||||
'gsd-check-update.js',
|
||||
];
|
||||
|
||||
function stubHooksIntoTarget(targetDir) {
|
||||
const hooksDest = path.join(targetDir, 'hooks');
|
||||
fs.mkdirSync(hooksDest, { recursive: true });
|
||||
for (const hookFile of STUB_HOOKS) {
|
||||
const src = path.join(HOOKS_SRC, hookFile);
|
||||
const dest = path.join(hooksDest, hookFile);
|
||||
if (fs.existsSync(src)) {
|
||||
fs.copyFileSync(src, dest);
|
||||
} else {
|
||||
// Minimal stub so existsSync passes
|
||||
fs.writeFileSync(dest, '#!/usr/bin/env node\n// stub\n');
|
||||
}
|
||||
try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-pass Gemini install.
|
||||
*
|
||||
* Pass 1: install() with no hook stubs — writes gsd-file-manifest.json.
|
||||
* Migration runs on an empty hooks/ so nothing gets auto-removed.
|
||||
* Pass 2: stub hooks into the target dir (now manifest-tracked on next scan),
|
||||
* then run install() again. Migration now classifies the hooks as
|
||||
* managed-unchanged and preserves them; registration guards pass.
|
||||
*
|
||||
* Returns the settings from pass 2.
|
||||
*/
|
||||
function twoPassGeminiInstall(tmpDir) {
|
||||
const targetDir = path.join(tmpDir, '.gemini');
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
// Pass 1 — no hook stubs yet; writes the manifest
|
||||
const result1 = install(false, 'gemini');
|
||||
persistSettings(result1.settingsPath, result1.settings);
|
||||
|
||||
// Inject stubs so the registration guards (fs.existsSync) pass on pass 2
|
||||
stubHooksIntoTarget(targetDir);
|
||||
|
||||
// Pass 2 — manifest exists, migration keeps stubs, registration succeeds
|
||||
process.chdir(tmpDir);
|
||||
const result2 = install(false, 'gemini');
|
||||
return result2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist in-memory settings to disk, simulating what finishInstall() does
|
||||
* (finishInstall is not exported). Required for tests that call install()
|
||||
* twice and need the second call to read the first call's hook registrations.
|
||||
*/
|
||||
function persistSettings(settingsPath, settings) {
|
||||
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(validateHookFields(settings), null, 2) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
// ─── Suite 1: Gemini — new events are registered ─────────────────────────────
|
||||
|
||||
describe('enh-776: Gemini install registers 3 new hook events', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
let settings;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-776-gemini-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
// Two-pass install: first pass writes manifest, second pass with stubs
|
||||
// so registration guards (fs.existsSync) pass.
|
||||
const result = twoPassGeminiInstall(tmpDir);
|
||||
settings = result.settings;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('install returns a settings object (not null)', () => {
|
||||
assert.ok(settings !== null && typeof settings === 'object',
|
||||
'Gemini install must return a non-null settings object');
|
||||
});
|
||||
|
||||
test('BeforeAgent event is registered with at least one hook', () => {
|
||||
const cmds = hooksForEvent(settings, 'BeforeAgent');
|
||||
assert.ok(cmds.length > 0,
|
||||
`Expected BeforeAgent hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`);
|
||||
});
|
||||
|
||||
test('AfterAgent event is registered with at least one hook', () => {
|
||||
const cmds = hooksForEvent(settings, 'AfterAgent');
|
||||
assert.ok(cmds.length > 0,
|
||||
`Expected AfterAgent hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`);
|
||||
});
|
||||
|
||||
test('BeforeModel event is registered with at least one hook', () => {
|
||||
const cmds = hooksForEvent(settings, 'BeforeModel');
|
||||
assert.ok(cmds.length > 0,
|
||||
`Expected BeforeModel hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`);
|
||||
});
|
||||
|
||||
test('BeforeAgent / AfterAgent / BeforeModel all use gsd-context-monitor', () => {
|
||||
for (const event of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) {
|
||||
const cmds = hooksForEvent(settings, event);
|
||||
assert.ok(
|
||||
cmds.some(c => c.includes('gsd-context-monitor')),
|
||||
`Event ${event} should use gsd-context-monitor; got commands: ${JSON.stringify(cmds)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 2: Non-Gemini installs do NOT get the new events ──────────────────
|
||||
//
|
||||
// Two runtimes are particularly important to guard:
|
||||
// Claude — the canonical non-Gemini runtime
|
||||
// Antigravity — shares Gemini-style BeforeTool/AfterTool naming and uses
|
||||
// isGemini-adjacent logic; a future accidental
|
||||
// `isGemini || isAntigravity` change must be caught here.
|
||||
|
||||
describe('enh-776: Claude install does NOT register Gemini-only hook events', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
let settings;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-776-claude-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
const result = install(false, 'claude');
|
||||
settings = result && result.settings;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('Claude install does not register BeforeAgent', () => {
|
||||
const cmds = hooksForEvent(settings, 'BeforeAgent');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`Claude should NOT have BeforeAgent; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
|
||||
test('Claude install does not register AfterAgent', () => {
|
||||
const cmds = hooksForEvent(settings, 'AfterAgent');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`Claude should NOT have AfterAgent; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
|
||||
test('Claude install does not register BeforeModel', () => {
|
||||
const cmds = hooksForEvent(settings, 'BeforeModel');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`Claude should NOT have BeforeModel; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enh-776: Antigravity install does NOT register Gemini-only hook events', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
let settings;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-776-antigravity-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
const result = install(false, 'antigravity');
|
||||
settings = result && result.settings;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('Antigravity install does not register BeforeAgent', () => {
|
||||
const cmds = hooksForEvent(settings, 'BeforeAgent');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`Antigravity should NOT have BeforeAgent; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
|
||||
test('Antigravity install does not register AfterAgent', () => {
|
||||
const cmds = hooksForEvent(settings, 'AfterAgent');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`Antigravity should NOT have AfterAgent; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
|
||||
test('Antigravity install does not register BeforeModel', () => {
|
||||
const cmds = hooksForEvent(settings, 'BeforeModel');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`Antigravity should NOT have BeforeModel; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 3: Idempotency — persisted reinstall does not duplicate hooks ──────
|
||||
|
||||
describe('enh-776: Gemini install is idempotent across persisted reinstalls', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-776-idem-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('re-running after persisted first install does not duplicate hook entries', () => {
|
||||
// Two-pass to get hooks installed.
|
||||
const result2 = twoPassGeminiInstall(tmpDir);
|
||||
const s2 = result2.settings;
|
||||
|
||||
// Assert hooks ARE registered after pass 2 (guards against false-pass where
|
||||
// hooks never registered and idempotency passes trivially at count=0).
|
||||
for (const event of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) {
|
||||
const cmds = hooksForEvent(s2, event);
|
||||
assert.strictEqual(cmds.length, 1,
|
||||
`Event ${event} should have exactly 1 hook after two-pass install; got ${cmds.length}: ${JSON.stringify(cmds)}`);
|
||||
}
|
||||
|
||||
persistSettings(result2.settingsPath, s2);
|
||||
|
||||
// Third install: reads the persisted settings.json — dedup guards apply
|
||||
process.chdir(tmpDir);
|
||||
const result3 = install(false, 'gemini');
|
||||
const s3 = result3.settings;
|
||||
|
||||
for (const event of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) {
|
||||
const cmds = hooksForEvent(s3, event);
|
||||
assert.strictEqual(cmds.length, 1,
|
||||
`Event ${event} should have exactly 1 hook command after idempotent reinstall; got ${cmds.length}: ${JSON.stringify(cmds)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 4: Uninstall removes the new event registrations ──────────────────
|
||||
|
||||
describe('enh-776: Gemini uninstall removes new hook event entries', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-776-uninstall-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
// Two-pass install and persist so uninstall has a settings.json to clean
|
||||
const result = twoPassGeminiInstall(tmpDir);
|
||||
persistSettings(result.settingsPath, result.settings);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('settings.json hook entries are removed on uninstall', () => {
|
||||
uninstall(false, 'gemini');
|
||||
const settingsPath = path.join(tmpDir, '.gemini', 'settings.json');
|
||||
if (!fs.existsSync(settingsPath)) return; // file removed entirely is fine
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
for (const event of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) {
|
||||
const cmds = hooksForEvent(settings, event);
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`After uninstall, ${event} should have 0 hooks; got: ${JSON.stringify(cmds)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 5: hooksConfig.enabled:false warning ───────────────────────────────
|
||||
|
||||
describe('enh-776: hooksConfig.enabled:false warning during Gemini install', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
let stderrLines;
|
||||
let originalWarn;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-776-hookscfg-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
const targetDir = path.join(tmpDir, '.gemini');
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
// Capture console.warn output
|
||||
stderrLines = [];
|
||||
originalWarn = console.warn;
|
||||
console.warn = (...args) => { stderrLines.push(args.join(' ')); };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.warn = originalWarn;
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('emits a warning when hooksConfig.enabled is false', () => {
|
||||
// Write a settings.json with hooksConfig.enabled: false BEFORE install
|
||||
// (the check in install() reads the existing settings.json on disk).
|
||||
const targetDir = path.join(tmpDir, '.gemini');
|
||||
const settingsPath = path.join(targetDir, 'settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ hooksConfig: { enabled: false } }, null, 2) + '\n', 'utf8');
|
||||
|
||||
install(false, 'gemini');
|
||||
const warnText = stderrLines.join('\n');
|
||||
assert.ok(
|
||||
warnText.includes('hooksConfig.enabled is false'),
|
||||
`Expected hooksConfig.enabled warning; got console.warn output:\n${warnText}`
|
||||
);
|
||||
});
|
||||
|
||||
test('does NOT emit the hooksConfig warning when hooksConfig.enabled is true', () => {
|
||||
const targetDir = path.join(tmpDir, '.gemini');
|
||||
const settingsPath = path.join(targetDir, 'settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ hooksConfig: { enabled: true } }, null, 2) + '\n', 'utf8');
|
||||
|
||||
install(false, 'gemini');
|
||||
const warnText = stderrLines.join('\n');
|
||||
assert.ok(
|
||||
!warnText.includes('hooksConfig.enabled is false'),
|
||||
`Should NOT warn when hooksConfig.enabled is true; got:\n${warnText}`
|
||||
);
|
||||
});
|
||||
|
||||
test('does NOT emit the hooksConfig warning when hooksConfig is absent', () => {
|
||||
const targetDir = path.join(tmpDir, '.gemini');
|
||||
const settingsPath = path.join(targetDir, 'settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({}, null, 2) + '\n', 'utf8');
|
||||
|
||||
install(false, 'gemini');
|
||||
const warnText = stderrLines.join('\n');
|
||||
assert.ok(
|
||||
!warnText.includes('hooksConfig.enabled is false'),
|
||||
`Should NOT warn when hooksConfig is absent; got:\n${warnText}`
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user