/** * @file AgentHub's hooks in Antigravity (`~/.gemini/config/hooks.json`, the * global file `agy` and the Antigravity IDE both load), installed and removed * only on the user's click. The file maps hook NAMES to their events; ours is * the one named `agenthub`, and every other name is left as it is. * * Only three events, all observational: `PreInvocation` (a model call starts * — the first of a run is the user's prompt), `PostToolUse` and `Stop`. * NEVER `PreToolUse`: its answer is a permission decision, and agy DENIES the * tool when a PreToolUse hook answers `{}` (verified on 1.2.13) — `allow` * would skip the user's own permission rules and `ask` would force a prompt, * so there is no answer that leaves agy's behaviour alone. agy has no hook * for a permission prompt either, so AgentHub cannot show one. * * The command is a stable path, `'/config/agenthub-hook.sh' `; * the script runs this build's `scripts/hook-handler.js antigravity` * with the server's node and CLAUDE_HOME, prints the `{}` agy reads, and every * server start rewrites it when stale — same scheme as codex-hooks.js, though * agy asks for no trust. A script there that is not ours is never overwritten * or removed (`EFOREIGN`), and hooks.json keeps its mode. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const { getGeminiDir } = require("./antigravity-home"); const { getClaudeHome } = require("./claude-home"); const { shq, writeAtomic, scriptIsOurs, isLink } = require("./codex-hooks"); const HOOK_NAME = "agenthub"; const HOOK_EVENTS = ["PreInvocation", "PostToolUse", "Stop"]; const TOOL_EVENTS = new Set(["PostToolUse"]); // take {matcher, hooks} groups const TIMEOUT_S = 5; // the handler gives up after 2.5 s const HANDLER = path.resolve(__dirname, "..", "..", "scripts", "hook-handler.js"); const MARKER = "# AgentHub's Antigravity hook forwarder."; const configDir = () => path.join(getGeminiDir(), "config"); const hooksPath = () => path.join(configDir(), "hooks.json"); const scriptPath = () => path.join(configDir(), "agenthub-hook.sh"); const commandFor = (event) => `${shq(scriptPath())} ${event}`; function scriptBody() { return [ "#!/bin/sh", `${MARKER} Rewritten by AgentHub on every start.`, `N=${shq(process.execPath)}; [ -x "$N" ] || N=node`, `CLAUDE_HOME=${shq(getClaudeHome())} "$N" ${shq(HANDLER)} "$1" antigravity >/dev/null 2>&1`, "# agy reads a JSON answer; an empty one changes nothing for these events.", "echo '{}'", "", ].join("\n"); } function fail(code, message) { return Object.assign(new Error(message), { code }); } /** hooks.json as an object, `{}` when absent; throws EBADJSON otherwise. */ function readHooks() { let text; try { text = fs.readFileSync(hooksPath(), "utf8"); } catch (err) { if (err.code === "ENOENT") return {}; throw err; } let file; try { file = JSON.parse(text); } catch { file = null; } if (!file || typeof file !== "object" || Array.isArray(file)) { throw fail("EBADJSON", `${hooksPath()} is not a JSON object; fix or move it first`); } return file; } function assertSupported() { if (process.platform === "win32") { throw fail("EUNSUPPORTED", "Antigravity hooks from AgentHub need a POSIX shell"); } } function ourDefinition() { const def = { enabled: true }; for (const event of HOOK_EVENTS) { const handler = { type: "command", command: commandFor(event), timeout: TIMEOUT_S }; def[event] = TOOL_EVENTS.has(event) ? [{ matcher: ".*", hooks: [handler] }] : [handler]; } return def; } /** Install (or reinstall) AgentHub's named hook. @returns the new status. */ function install() { assertSupported(); const file = readHooks(); if (!scriptIsOurs(scriptPath(), MARKER)) { throw fail("EFOREIGN", `${scriptPath()} is not AgentHub's; move it first`); } file[HOOK_NAME] = ourDefinition(); fs.mkdirSync(configDir(), { recursive: true }); writeAtomic(scriptPath(), scriptBody(), 0o755); writeAtomic(hooksPath(), JSON.stringify(file, null, 2) + "\n", 0o644, { keepMode: true }); return status(); } /** Take AgentHub's named hook out; every other one stays. @returns the new status. */ function remove() { const file = readHooks(); if (HOOK_NAME in file) { delete file[HOOK_NAME]; if (Object.keys(file).length === 0 && !isLink(hooksPath())) fs.rmSync(hooksPath(), { force: true }); else writeAtomic(hooksPath(), JSON.stringify(file, null, 2) + "\n", 0o644, { keepMode: true }); } if (scriptIsOurs(scriptPath(), MARKER)) fs.rmSync(scriptPath(), { force: true }); return status(); } /** Whether `list` (a flat handler list, or {matcher, hooks} groups) runs our command. */ function runsOurs(list, event) { if (!Array.isArray(list)) return false; return list.some((h) => Array.isArray(h?.hooks) ? h.hooks.some((x) => x?.command === commandFor(event)) : h?.command === commandFor(event) ); } /** Per event: is our handler there; plus whether our hook is switched off. */ function status() { const base = { supported: process.platform !== "win32", path: hooksPath(), script: scriptPath(), }; let file; try { file = readHooks(); } catch (err) { return { ...base, readable: false, error: err.message, installed: false, enabled: false, events: {}, }; } const ours = file[HOOK_NAME] && typeof file[HOOK_NAME] === "object" ? file[HOOK_NAME] : {}; const events = {}; for (const event of HOOK_EVENTS) events[event] = { installed: runsOurs(ours[event], event) }; const installed = HOOK_EVENTS.every((e) => events[e].installed) && fs.existsSync(scriptPath()); return { ...base, readable: true, installed, enabled: installed && ours.enabled !== false, events, }; } /** * Rewrite the forwarder when it is installed and stale or lost its exec bit. * Never creates it, and never touches a file by that name that is not ours. * @returns {"absent"|"foreign"|"exists"|"written"|"failed"} */ function refreshScript() { try { const current = fs.readFileSync(scriptPath(), "utf8"); if (!current.split("\n", 2)[1]?.startsWith(MARKER)) return "foreign"; const executable = (fs.statSync(scriptPath()).mode & 0o111) !== 0; if (current === scriptBody() && executable) return "exists"; writeAtomic(scriptPath(), scriptBody(), 0o755); return "written"; } catch (err) { return err.code === "ENOENT" ? "absent" : "failed"; } } module.exports = { HOOK_NAME, HOOK_EVENTS, commandFor, install, remove, status, refreshScript };