fix(node): mirror official Gemini hooks
This commit is contained in:
1 parent
48a830ff4d
commit
2d8111d823
15 files changed
+1999
-144
No files matched your search
+68
-142
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user