fix(node): mirror official Gemini hooks

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 07:55:24 -04:00
1 parent 48a830ff4d
commit 2d8111d823
15 files changed
+1999 -144

No files matched your search

+68 -142
View File
@@ -1,169 +1,95 @@
import fsp from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
import {
AGENT_KEY,
AUDIT_HOOK_NAME,
GUARD_HOOK_NAME,
buildGeminiGroup,
disabledManagedGeminiEntries,
geminiRuntimeContracts,
managedGeminiHooksEnabled,
type GeminiGroup,
type ManagedGeminiEvent,
} from './gemini-contract.js';
import { renderGeminiDocument, type GeminiDocument } from './gemini-config.js';
import {
commitGeminiInstallation,
preflightGeminiInstallation,
prepareGeminiInstallation,
} from './gemini-installation.js';
import {
geminiRuntimeFilesExist,
readGeminiDocument,
writeGeminiDocument,
} from './gemini-io.js';
import { SUPPORTED_AGENTS } from './registry.js';
const AGENT_KEY = 'gemini';
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
function resolveConfigDir(): string {
return entry.configDir.replace(/^~/, os.homedir());
}
function resolveConfigPath(): string {
return path.join(resolveConfigDir(), entry.configFile);
}
function buildHookCommand(scriptPath: string): string {
return `node "${scriptPath}"`;
}
function isElydoraCommand(cmd: string, agentId?: string): boolean {
if (!cmd.includes('elydora')) return false;
if (agentId) {
return cmd.includes(agentId);
function requireEnabledHooks(document: GeminiDocument): void {
if (!document.hookControls.enabled) {
throw new Error(`Gemini CLI hooks are disabled by hooksConfig.enabled: ${document.filePath}`);
}
const disabled = disabledManagedGeminiEntries(document.hookControls);
if (disabled.length > 0) {
throw new Error(
`Gemini CLI hooks are disabled by hooksConfig.disabled: ${disabled.join(', ')}`,
);
}
return true;
}
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> {
return arr.filter((entry) => {
if (Array.isArray(entry.hooks)) {
const cmds = entry.hooks as Array<Record<string, unknown>>;
return !cmds.some((h) => typeof h.command === 'string' && isElydoraCommand(h.command, agentId));
}
if (typeof entry.command === 'string') return !isElydoraCommand(entry.command, agentId);
return true;
});
function installedGroups(guardPath: string, auditPath: string): ReadonlyMap<
ManagedGeminiEvent,
GeminiGroup
> {
return new Map([
['BeforeTool', buildGeminiGroup(guardPath, GUARD_HOOK_NAME)],
['AfterTool', buildGeminiGroup(auditPath, AUDIT_HOOK_NAME)],
]);
}
export const geminiPlugin: AgentPlugin = {
managesRuntime: true,
async preflightInstall(config: InstallConfig): Promise<void> {
const document = await readGeminiDocument();
requireEnabledHooks(document);
await preflightGeminiInstallation(config, document.filePath);
},
async install(config: InstallConfig): Promise<void> {
const configDir = resolveConfigDir();
await fsp.mkdir(configDir, { recursive: true });
const configPath = resolveConfigPath();
let settings: Record<string, unknown> = {};
try {
const raw = await fsp.readFile(configPath, 'utf-8');
settings = JSON.parse(raw);
} catch {
// Start fresh
}
if (!settings.hooks || typeof settings.hooks !== 'object') {
settings.hooks = {};
}
const hooks = settings.hooks as Record<string, unknown>;
// --- BeforeTool (guard — freeze enforcement) ---
if (!Array.isArray(hooks.BeforeTool)) {
hooks.BeforeTool = [];
}
const preFiltered = filterElydoraEntries(hooks.BeforeTool as Array<Record<string, unknown>>);
preFiltered.push({
hooks: [{ type: 'command', command: buildHookCommand(config.guardScriptPath) }],
});
hooks.BeforeTool = preFiltered;
// --- AfterTool (audit logging) ---
if (!Array.isArray(hooks.AfterTool)) {
hooks.AfterTool = [];
}
const postFiltered = filterElydoraEntries(hooks.AfterTool as Array<Record<string, unknown>>);
postFiltered.push({
hooks: [{ type: 'command', command: buildHookCommand(config.hookScriptPath) }],
});
hooks.AfterTool = postFiltered;
settings.hooks = hooks;
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
const document = await readGeminiDocument();
requireEnabledHooks(document);
const paths = await preflightGeminiInstallation(config, document.filePath);
const rendered = renderGeminiDocument(
document,
undefined,
installedGroups(paths.guardPath, paths.auditPath),
);
await commitGeminiInstallation(await prepareGeminiInstallation(config, rendered));
console.log(` Gemini CLI hooks: ${document.filePath}`);
console.log(' Gemini CLI verification: run /hooks list.');
},
async uninstall(agentId?: string): Promise<void> {
const configPath = resolveConfigPath();
let settings: Record<string, unknown>;
try {
const raw = await fsp.readFile(configPath, 'utf-8');
settings = JSON.parse(raw);
} catch {
return;
}
const hooks = settings.hooks as Record<string, unknown> | undefined;
if (!hooks) return;
if (Array.isArray(hooks.BeforeTool)) {
hooks.BeforeTool = filterElydoraEntries(hooks.BeforeTool as Array<Record<string, unknown>>, agentId);
}
if (Array.isArray(hooks.AfterTool)) {
hooks.AfterTool = filterElydoraEntries(hooks.AfterTool as Array<Record<string, unknown>>, agentId);
}
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
const document = await readGeminiDocument();
await writeGeminiDocument(renderGeminiDocument(document, agentId, new Map()));
},
async status(): Promise<PluginStatus> {
const configPath = resolveConfigPath();
let hookConfigured = false;
let hookScriptPath = '';
try {
const raw = await fsp.readFile(configPath, 'utf-8');
const settings = JSON.parse(raw);
const hooks = settings.hooks as Record<string, unknown> | undefined;
if (hooks) {
const checkArr = (arr: unknown) => {
if (!Array.isArray(arr)) return false;
return (arr as Array<Record<string, unknown>>).some((entry) => {
if (Array.isArray(entry.hooks)) {
return (entry.hooks as Array<Record<string, unknown>>).some(
(h) => typeof h.command === 'string' && h.command.includes('elydora'),
);
}
return typeof entry.command === 'string' && entry.command.includes('elydora');
});
};
hookConfigured = checkArr(hooks.BeforeTool) && checkArr(hooks.AfterTool);
// Extract hook script path from AfterTool command
if (hookConfigured && Array.isArray(hooks.AfterTool)) {
for (const e of hooks.AfterTool as Array<Record<string, unknown>>) {
if (Array.isArray(e.hooks)) {
for (const h of e.hooks as Array<Record<string, unknown>>) {
const cmd = h.command as string;
if (cmd && cmd.includes('elydora')) {
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
}
}
}
}
}
}
} catch {
// Config not readable
}
let hookScriptExists = false;
if (hookScriptPath) {
try {
await fsp.access(hookScriptPath);
hookScriptExists = true;
} catch {
// File doesn't exist
}
}
const document = await readGeminiDocument();
const contracts = geminiRuntimeContracts(document.hooks);
const hookConfigured = managedGeminiHooksEnabled(document.hookControls)
&& contracts.length > 0;
const hookScriptExists = hookConfigured
? await geminiRuntimeFilesExist(contracts)
: false;
return {
installed: hookConfigured && hookScriptExists,
agentName: AGENT_KEY,
displayName: entry.name,
hookConfigured,
hookScriptExists,
configPath,
configPath: document.filePath,
};
},
};