/** * GeminiCliHooksInstaller - Gemini CLI integration for claude-mem * * Installs hooks into ~/.gemini/settings.json using the unified CLI: * bun worker-service.cjs hook gemini-cli * * This routes through the hook-command.ts framework: * readJsonFromStdin() → gemini-cli adapter → event handler → POST to worker * * Gemini CLI supports 11 lifecycle hooks; we register 8 that map to * useful memory events. See src/cli/adapters/gemini-cli.ts for the * adapter that normalizes Gemini's stdin JSON to NormalizedHookInput. * * Hook config format (verified against Gemini CLI source): * { * "hooks": { * "AfterTool": [{ * "matcher": "*", * "hooks": [{ "name": "claude-mem", "type": "command", "command": "...", "timeout": 5000 }] * }] * } * } */ import path from 'path'; import { homedir } from 'os'; import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import { logger } from '../../utils/logger.js'; import { findWorkerServicePath, findBunPath } from './CursorHooksInstaller.js'; // ============================================================================ // Types // ============================================================================ /** A single hook entry in a Gemini CLI hook group */ interface GeminiHookEntry { name: string; type: 'command'; command: string; timeout: number; } /** A hook group — matcher selects which tools/events this applies to */ interface GeminiHookGroup { matcher: string; hooks: GeminiHookEntry[]; } /** The hooks section in ~/.gemini/settings.json */ interface GeminiHooksConfig { [eventName: string]: GeminiHookGroup[]; } /** Full ~/.gemini/settings.json structure (partial — we only care about hooks) */ interface GeminiSettingsJson { hooks?: GeminiHooksConfig; [key: string]: unknown; } // ============================================================================ // Constants // ============================================================================ const GEMINI_CONFIG_DIR = path.join(homedir(), '.gemini'); const GEMINI_SETTINGS_PATH = path.join(GEMINI_CONFIG_DIR, 'settings.json'); const GEMINI_MD_PATH = path.join(GEMINI_CONFIG_DIR, 'GEMINI.md'); const HOOK_NAME = 'claude-mem'; const HOOK_TIMEOUT_MS = 10000; /** * Mapping from Gemini CLI hook events to internal claude-mem event types. * * These events are processed by hookCommand() in src/cli/hook-command.ts, * which reads stdin via readJsonFromStdin(), normalizes through the * gemini-cli adapter, and dispatches to the matching event handler. * * Events NOT mapped (too chatty for memory capture): * BeforeModel, AfterModel, BeforeToolSelection */ const GEMINI_EVENT_TO_INTERNAL_EVENT: Record = { 'SessionStart': 'context', 'BeforeAgent': 'user-message', 'AfterAgent': 'observation', 'BeforeTool': 'observation', 'AfterTool': 'observation', 'PreCompress': 'summarize', 'Notification': 'observation', 'SessionEnd': 'session-complete', }; // ============================================================================ // Hook Command Builder // ============================================================================ /** * Build the hook command string for a given Gemini CLI event. * * The command invokes worker-service.cjs with the `hook` subcommand, * which delegates to hookCommand('gemini-cli', event) — the same * framework used by Claude Code and Cursor hooks. * * Pipeline: bun worker-service.cjs hook gemini-cli * → worker-service.ts parses args, ensures worker daemon is running * → hookCommand('gemini-cli', '') * → readJsonFromStdin() reads Gemini's JSON payload * → geminiCliAdapter.normalizeInput() → NormalizedHookInput * → eventHandler.execute(input) * → geminiCliAdapter.formatOutput(result) * → JSON.stringify to stdout */ function buildHookCommand( bunPath: string, workerServicePath: string, geminiEventName: string, ): string { const internalEvent = GEMINI_EVENT_TO_INTERNAL_EVENT[geminiEventName]; if (!internalEvent) { throw new Error(`Unknown Gemini CLI event: ${geminiEventName}`); } // Double-escape backslashes intentionally: this command string is embedded inside // a JSON value, so `\\` in the source becomes `\` when the JSON is parsed by the // IDE. Without double-escaping, Windows paths like C:\Users would lose their // backslashes and break when the IDE deserializes the hook configuration. const escapedBunPath = bunPath.replace(/\\/g, '\\\\'); const escapedWorkerPath = workerServicePath.replace(/\\/g, '\\\\'); return `"${escapedBunPath}" "${escapedWorkerPath}" hook gemini-cli ${internalEvent}`; } /** * Create a hook group entry for a Gemini CLI event. * Uses matcher "*" to match all tools/contexts for that event. */ function createHookGroup(hookCommand: string): GeminiHookGroup { return { matcher: '*', hooks: [{ name: HOOK_NAME, type: 'command', command: hookCommand, timeout: HOOK_TIMEOUT_MS, }], }; } // ============================================================================ // Settings JSON Management // ============================================================================ /** * Read ~/.gemini/settings.json, returning empty object if missing. * Throws on corrupt JSON to prevent silent data loss. */ function readGeminiSettings(): GeminiSettingsJson { if (!existsSync(GEMINI_SETTINGS_PATH)) { return {}; } const content = readFileSync(GEMINI_SETTINGS_PATH, 'utf-8'); try { return JSON.parse(content) as GeminiSettingsJson; } catch (error) { throw new Error(`Corrupt JSON in ${GEMINI_SETTINGS_PATH}, refusing to overwrite user settings`); } } /** * Write settings back to ~/.gemini/settings.json. * Creates the directory if it doesn't exist. */ function writeGeminiSettings(settings: GeminiSettingsJson): void { mkdirSync(GEMINI_CONFIG_DIR, { recursive: true }); writeFileSync(GEMINI_SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n'); } /** * Deep-merge claude-mem hooks into existing settings. * * For each event: * - If the event already has a hook group with a claude-mem hook, update it * - Otherwise, append a new hook group * * Preserves all non-claude-mem hooks and all non-hook settings. */ function mergeHooksIntoSettings( existingSettings: GeminiSettingsJson, newHooks: GeminiHooksConfig, ): GeminiSettingsJson { const settings = { ...existingSettings }; if (!settings.hooks) { settings.hooks = {}; } for (const [eventName, newGroups] of Object.entries(newHooks)) { const existingGroups: GeminiHookGroup[] = settings.hooks[eventName] ?? []; // For each new hook group, check if there's already a group // containing a claude-mem hook — update it in place for (const newGroup of newGroups) { const existingGroupIndex = existingGroups.findIndex((group: GeminiHookGroup) => group.hooks.some((hook: GeminiHookEntry) => hook.name === HOOK_NAME) ); if (existingGroupIndex >= 0) { // Update existing group: replace the claude-mem hook entry const existingGroup: GeminiHookGroup = existingGroups[existingGroupIndex]; const hookIndex = existingGroup.hooks.findIndex((hook: GeminiHookEntry) => hook.name === HOOK_NAME); if (hookIndex >= 0) { existingGroup.hooks[hookIndex] = newGroup.hooks[0]; } else { existingGroup.hooks.push(newGroup.hooks[0]); } } else { // No existing claude-mem group — append existingGroups.push(newGroup); } } settings.hooks[eventName] = existingGroups; } return settings; } // ============================================================================ // GEMINI.md Context Injection // ============================================================================ /** * Append or update the claude-mem context section in ~/.gemini/GEMINI.md. * Uses the same tag pattern as CLAUDE.md. */ function setupGeminiMdContextSection(): void { const contextTag = ''; const contextEndTag = ''; const placeholder = `${contextTag} # Memory Context from Past Sessions *No context yet. Complete your first session and context will appear here.* ${contextEndTag}`; let content = ''; if (existsSync(GEMINI_MD_PATH)) { content = readFileSync(GEMINI_MD_PATH, 'utf-8'); } if (content.includes(contextTag)) { // Already has claude-mem section — leave it alone (may have real context) return; } // Append the section const separator = content.length > 0 && !content.endsWith('\n') ? '\n\n' : content.length > 0 ? '\n' : ''; const newContent = content + separator + placeholder + '\n'; mkdirSync(GEMINI_CONFIG_DIR, { recursive: true }); writeFileSync(GEMINI_MD_PATH, newContent); } // ============================================================================ // Public API // ============================================================================ /** * Install claude-mem hooks into ~/.gemini/settings.json. * * Merges hooks non-destructively: existing settings and non-claude-mem * hooks are preserved. Existing claude-mem hooks are updated in place. * * @returns 0 on success, 1 on failure */ export async function installGeminiCliHooks(): Promise { console.log('\nInstalling Claude-Mem Gemini CLI hooks...\n'); // Find required paths const workerServicePath = findWorkerServicePath(); if (!workerServicePath) { console.error('Could not find worker-service.cjs'); console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/worker-service.cjs'); return 1; } const bunPath = findBunPath(); console.log(` Using Bun runtime: ${bunPath}`); console.log(` Worker service: ${workerServicePath}`); try { // Build hook commands for all mapped events const hooksConfig: GeminiHooksConfig = {}; for (const geminiEvent of Object.keys(GEMINI_EVENT_TO_INTERNAL_EVENT)) { const command = buildHookCommand(bunPath, workerServicePath, geminiEvent); hooksConfig[geminiEvent] = [createHookGroup(command)]; } // Read existing settings and merge const existingSettings = readGeminiSettings(); const mergedSettings = mergeHooksIntoSettings(existingSettings, hooksConfig); // Write back writeGeminiSettings(mergedSettings); console.log(` Merged hooks into ${GEMINI_SETTINGS_PATH}`); // Setup GEMINI.md context injection setupGeminiMdContextSection(); console.log(` Setup context injection in ${GEMINI_MD_PATH}`); // List installed events const eventNames = Object.keys(GEMINI_EVENT_TO_INTERNAL_EVENT); console.log(` Registered ${eventNames.length} hook events:`); for (const event of eventNames) { const internalEvent = GEMINI_EVENT_TO_INTERNAL_EVENT[event]; console.log(` ${event} → ${internalEvent}`); } console.log(` Installation complete! Hooks installed to: ${GEMINI_SETTINGS_PATH} Using unified CLI: bun worker-service.cjs hook gemini-cli Next steps: 1. Start claude-mem worker: claude-mem start 2. Restart Gemini CLI to load the hooks 3. Memory will be captured automatically during sessions Context Injection: Context from past sessions is injected via ~/.gemini/GEMINI.md and automatically included in Gemini CLI conversations. `); return 0; } catch (error) { console.error(`\nInstallation failed: ${(error as Error).message}`); return 1; } } /** * Uninstall claude-mem hooks from ~/.gemini/settings.json. * * Removes only claude-mem hooks — other hooks and settings are preserved. * * @returns 0 on success, 1 on failure */ export function uninstallGeminiCliHooks(): number { console.log('\nUninstalling Claude-Mem Gemini CLI hooks...\n'); try { if (!existsSync(GEMINI_SETTINGS_PATH)) { console.log(' No Gemini CLI settings found — nothing to uninstall.'); return 0; } const settings = readGeminiSettings(); if (!settings.hooks) { console.log(' No hooks found in Gemini CLI settings — nothing to uninstall.'); return 0; } let removedCount = 0; // Remove claude-mem hooks from within each group, preserving other hooks for (const [eventName, groups] of Object.entries(settings.hooks)) { const filteredGroups = groups .map(group => { const remainingHooks = group.hooks.filter(hook => hook.name !== HOOK_NAME); removedCount += group.hooks.length - remainingHooks.length; return { ...group, hooks: remainingHooks }; }) .filter(group => group.hooks.length > 0); if (filteredGroups.length > 0) { settings.hooks[eventName] = filteredGroups; } else { delete settings.hooks[eventName]; } } // Clean up empty hooks object if (Object.keys(settings.hooks).length === 0) { delete settings.hooks; } writeGeminiSettings(settings); console.log(` Removed ${removedCount} claude-mem hook(s) from ${GEMINI_SETTINGS_PATH}`); // Remove claude-mem context section from GEMINI.md if (existsSync(GEMINI_MD_PATH)) { let mdContent = readFileSync(GEMINI_MD_PATH, 'utf-8'); const contextRegex = /\n?[\s\S]*?<\/claude-mem-context>\n?/; if (contextRegex.test(mdContent)) { mdContent = mdContent.replace(contextRegex, ''); writeFileSync(GEMINI_MD_PATH, mdContent); console.log(` Removed context section from ${GEMINI_MD_PATH}`); } } console.log('\nUninstallation complete!\n'); console.log('Restart Gemini CLI to apply changes.'); return 0; } catch (error) { console.error(`\nUninstallation failed: ${(error as Error).message}`); return 1; } } /** * Check Gemini CLI hooks installation status. * * @returns 0 always (informational) */ export function checkGeminiCliHooksStatus(): number { console.log('\nClaude-Mem Gemini CLI Hooks Status\n'); if (!existsSync(GEMINI_SETTINGS_PATH)) { console.log('Gemini CLI settings: Not found'); console.log(` Expected at: ${GEMINI_SETTINGS_PATH}\n`); console.log('No hooks installed. Run: claude-mem install --ide gemini-cli\n'); return 0; } let settings: GeminiSettingsJson; try { settings = readGeminiSettings(); } catch (error) { console.log(`Gemini CLI settings: ${(error as Error).message}\n`); return 0; } if (!settings.hooks) { console.log('Gemini CLI settings: Found, but no hooks configured\n'); console.log('No hooks installed. Run: claude-mem install --ide gemini-cli\n'); return 0; } // Check for claude-mem hooks const installedEvents: string[] = []; for (const [eventName, groups] of Object.entries(settings.hooks)) { const hasClaudeMem = groups.some(group => group.hooks.some(hook => hook.name === HOOK_NAME) ); if (hasClaudeMem) { installedEvents.push(eventName); } } if (installedEvents.length === 0) { console.log('Gemini CLI settings: Found, but no claude-mem hooks\n'); console.log('Run: claude-mem install --ide gemini-cli\n'); return 0; } console.log(`Settings: ${GEMINI_SETTINGS_PATH}`); console.log(`Mode: Unified CLI (bun worker-service.cjs hook gemini-cli)`); console.log(`Events: ${installedEvents.length} of ${Object.keys(GEMINI_EVENT_TO_INTERNAL_EVENT).length} mapped`); for (const event of installedEvents) { const internalEvent = GEMINI_EVENT_TO_INTERNAL_EVENT[event] ?? 'unknown'; console.log(` ${event} → ${internalEvent}`); } // Check GEMINI.md context if (existsSync(GEMINI_MD_PATH)) { const mdContent = readFileSync(GEMINI_MD_PATH, 'utf-8'); if (mdContent.includes('')) { console.log(`Context: Active (${GEMINI_MD_PATH})`); } else { console.log('Context: GEMINI.md exists but missing claude-mem section'); } } else { console.log('Context: No GEMINI.md found'); } console.log(''); return 0; } /** * Handle gemini-cli subcommand for hooks management. */ export async function handleGeminiCliCommand(subcommand: string, _args: string[]): Promise { switch (subcommand) { case 'install': return installGeminiCliHooks(); case 'uninstall': return uninstallGeminiCliHooks(); case 'status': return checkGeminiCliHooksStatus(); default: console.log(` Claude-Mem Gemini CLI Integration Usage: claude-mem gemini-cli Commands: install Install hooks into ~/.gemini/settings.json uninstall Remove claude-mem hooks (preserves other hooks) status Check installation status Examples: claude-mem gemini-cli install # Install hooks claude-mem gemini-cli status # Check if installed claude-mem gemini-cli uninstall # Remove hooks For more info: https://docs.claude-mem.ai/usage/gemini-provider `); return 0; } }