c7c68e81f4
- README: add language specifier to fenced code block - paths.ts: guard npmPackageRootDirectory() against bundle structure drift - OpenCodeInstaller: resolve bundle from import.meta.url, not process.cwd() - OpenCodeInstaller: log warnings on AGENTS.md injection failures - WindsurfHooksInstaller: key registry by full workspace path, not basename - uninstall.ts: poll health endpoint to wait for worker exit before file deletion - uninstall.ts: call IDE-specific uninstallers (Gemini, Windsurf, OpenCode, OpenClaw, Codex) - opencode-plugin: cap session tracking Map at 1000 entries with LRU eviction - GeminiCliHooksInstaller: document intentional JSON double-escaping Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
514 lines
17 KiB
TypeScript
514 lines
17 KiB
TypeScript
/**
|
|
* GeminiCliHooksInstaller - Gemini CLI integration for claude-mem
|
|
*
|
|
* Installs hooks into ~/.gemini/settings.json using the unified CLI:
|
|
* bun worker-service.cjs hook gemini-cli <event>
|
|
*
|
|
* 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<string, string> = {
|
|
'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 <event>
|
|
* → worker-service.ts parses args, ensures worker daemon is running
|
|
* → hookCommand('gemini-cli', '<event>')
|
|
* → 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 <claude-mem-context> tag pattern as CLAUDE.md.
|
|
*/
|
|
function setupGeminiMdContextSection(): void {
|
|
const contextTag = '<claude-mem-context>';
|
|
const contextEndTag = '</claude-mem-context>';
|
|
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<number> {
|
|
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 <event>
|
|
|
|
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?<claude-mem-context>[\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('<claude-mem-context>')) {
|
|
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<number> {
|
|
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 <command>
|
|
|
|
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;
|
|
}
|
|
}
|