refactor: consolidate MCP factory, add non-TTY support, auto-detect transcript watchers

- Phase 1: Replace 5 duplicate MCP installers with config-driven factory, extract
  shared context-injection and json-utils utilities, fix process.execPath usage
- Phase 2: Add non-TTY fallback for @clack/prompts to prevent ENOENT in CI/Docker
- Phase 3: Wire GeminiCliHooksInstaller through hook command framework with adapter
- Phase 4: Auto-start transcript watchers on worker boot when config exists

Net -107 lines via DRY consolidation of duplicated installer logic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-04-04 00:35:55 -07:00
parent a2ac116aac
commit 2495f98496
10 changed files with 959 additions and 972 deletions
+78 -36
View File
@@ -12,6 +12,34 @@ import pc from 'picocolors';
import { execSync } from 'child_process';
import { cpSync, existsSync, readFileSync } from 'fs';
import { join } from 'path';
// Non-TTY detection: @clack/prompts crashes with ENOENT in non-TTY environments
const isInteractive = process.stdin.isTTY === true;
/** Run a list of tasks, falling back to plain console.log when non-TTY */
interface TaskDescriptor {
title: string;
task: (message: (msg: string) => void) => Promise<string>;
}
async function runTasks(tasks: TaskDescriptor[]): Promise<void> {
if (isInteractive) {
await p.tasks(tasks);
} else {
for (const t of tasks) {
const result = await t.task((msg: string) => console.log(` ${msg}`));
console.log(` ${result}`);
}
}
}
/** Log helpers that fall back to console.log in non-TTY */
const log = {
info: (msg: string) => isInteractive ? p.log.info(msg) : console.log(` ${msg}`),
success: (msg: string) => isInteractive ? p.log.success(msg) : console.log(` ${msg}`),
warn: (msg: string) => isInteractive ? p.log.warn(msg) : console.warn(` ${msg}`),
error: (msg: string) => isInteractive ? p.log.error(msg) : console.error(` ${msg}`),
};
import {
claudeSettingsPath,
ensureDirectoryExists,
@@ -23,10 +51,10 @@ import {
npmPackageRootDirectory,
pluginCacheDirectory,
pluginsDirectory,
readJsonFileSafe,
readPluginVersion,
writeJsonFileAtomic,
} from '../utils/paths.js';
import { readJsonSafe } from '../../utils/json-utils.js';
import { detectInstalledIDEs } from './ide-detection.js';
// ---------------------------------------------------------------------------
@@ -34,7 +62,7 @@ import { detectInstalledIDEs } from './ide-detection.js';
// ---------------------------------------------------------------------------
function registerMarketplace(): void {
const knownMarketplaces = readJsonFileSafe(knownMarketplacesPath());
const knownMarketplaces = readJsonSafe<Record<string, any>>(knownMarketplacesPath(), {});
knownMarketplaces['thedotmack'] = {
source: {
@@ -51,7 +79,7 @@ function registerMarketplace(): void {
}
function registerPlugin(version: string): void {
const installedPlugins = readJsonFileSafe(installedPluginsPath());
const installedPlugins = readJsonSafe<Record<string, any>>(installedPluginsPath(), {});
if (!installedPlugins.version) installedPlugins.version = 2;
if (!installedPlugins.plugins) installedPlugins.plugins = {};
@@ -73,7 +101,7 @@ function registerPlugin(version: string): void {
}
function enablePluginInClaudeSettings(): void {
const settings = readJsonFileSafe(claudeSettingsPath());
const settings = readJsonSafe<Record<string, any>>(claudeSettingsPath(), {});
if (!settings.enabledPlugins) settings.enabledPlugins = {};
settings.enabledPlugins['claude-mem@thedotmack'] = true;
@@ -91,21 +119,21 @@ async function setupIDEs(selectedIDEs: string[]): Promise<void> {
case 'claude-code':
// Claude Code picks up the plugin via marketplace registration — nothing
// else to do beyond what registerMarketplace / registerPlugin already did.
p.log.success('Claude Code: plugin registered via marketplace.');
log.success('Claude Code: plugin registered via marketplace.');
break;
case 'cursor':
p.log.info('Cursor: hook configuration available after first launch.');
p.log.info(` Run: npx claude-mem cursor-setup (coming soon)`);
log.info('Cursor: hook configuration available after first launch.');
log.info(` Run: npx claude-mem cursor-setup (coming soon)`);
break;
case 'gemini-cli': {
const { installGeminiCliHooks } = await import('../../services/integrations/GeminiCliHooksInstaller.js');
const geminiResult = await installGeminiCliHooks();
if (geminiResult === 0) {
p.log.success('Gemini CLI: hooks installed.');
log.success('Gemini CLI: hooks installed.');
} else {
p.log.error('Gemini CLI: hook installation failed.');
log.error('Gemini CLI: hook installation failed.');
}
break;
}
@@ -114,9 +142,9 @@ async function setupIDEs(selectedIDEs: string[]): Promise<void> {
const { installOpenCodeIntegration } = await import('../../services/integrations/OpenCodeInstaller.js');
const openCodeResult = await installOpenCodeIntegration();
if (openCodeResult === 0) {
p.log.success('OpenCode: plugin installed.');
log.success('OpenCode: plugin installed.');
} else {
p.log.error('OpenCode: plugin installation failed.');
log.error('OpenCode: plugin installation failed.');
}
break;
}
@@ -125,9 +153,9 @@ async function setupIDEs(selectedIDEs: string[]): Promise<void> {
const { installWindsurfHooks } = await import('../../services/integrations/WindsurfHooksInstaller.js');
const windsurfResult = await installWindsurfHooks();
if (windsurfResult === 0) {
p.log.success('Windsurf: hooks installed.');
log.success('Windsurf: hooks installed.');
} else {
p.log.error('Windsurf: hook installation failed.');
log.error('Windsurf: hook installation failed.');
}
break;
}
@@ -136,9 +164,9 @@ async function setupIDEs(selectedIDEs: string[]): Promise<void> {
const { installOpenClawIntegration } = await import('../../services/integrations/OpenClawInstaller.js');
const openClawResult = await installOpenClawIntegration();
if (openClawResult === 0) {
p.log.success('OpenClaw: plugin installed.');
log.success('OpenClaw: plugin installed.');
} else {
p.log.error('OpenClaw: plugin installation failed.');
log.error('OpenClaw: plugin installation failed.');
}
break;
}
@@ -147,9 +175,9 @@ async function setupIDEs(selectedIDEs: string[]): Promise<void> {
const { installCodexCli } = await import('../../services/integrations/CodexCliInstaller.js');
const codexResult = await installCodexCli();
if (codexResult === 0) {
p.log.success('Codex CLI: transcript watching configured.');
log.success('Codex CLI: transcript watching configured.');
} else {
p.log.error('Codex CLI: integration setup failed.');
log.error('Codex CLI: integration setup failed.');
}
break;
}
@@ -168,9 +196,9 @@ async function setupIDEs(selectedIDEs: string[]): Promise<void> {
const ideInfo = allIDEs.find((i) => i.id === ideId);
const ideLabel = ideInfo?.label ?? ideId;
if (mcpResult === 0) {
p.log.success(`${ideLabel}: MCP integration installed.`);
log.success(`${ideLabel}: MCP integration installed.`);
} else {
p.log.error(`${ideLabel}: MCP integration failed.`);
log.error(`${ideLabel}: MCP integration failed.`);
}
}
break;
@@ -180,7 +208,7 @@ async function setupIDEs(selectedIDEs: string[]): Promise<void> {
const allIDEs = detectInstalledIDEs();
const ide = allIDEs.find((i) => i.id === ideId);
if (ide && !ide.supported) {
p.log.warn(`Support for ${ide.label} coming soon.`);
log.warn(`Support for ${ide.label} coming soon.`);
}
break;
}
@@ -197,7 +225,7 @@ async function promptForIDESelection(): Promise<string[]> {
const detected = detectedIDEs.filter((ide) => ide.detected);
if (detected.length === 0) {
p.log.warn('No supported IDEs detected. Installing for Claude Code by default.');
log.warn('No supported IDEs detected. Installing for Claude Code by default.');
return ['claude-code'];
}
@@ -295,7 +323,7 @@ function runSmartInstall(): void {
const smartInstallPath = join(marketplaceDirectory(), 'plugin', 'scripts', 'smart-install.js');
if (!existsSync(smartInstallPath)) {
p.log.warn('smart-install.js not found — skipping Bun/uv auto-install.');
log.warn('smart-install.js not found — skipping Bun/uv auto-install.');
return;
}
@@ -305,7 +333,7 @@ function runSmartInstall(): void {
...(IS_WINDOWS ? { shell: true as const } : {}),
});
} catch {
p.log.warn('smart-install encountered an issue. You may need to install Bun/uv manually.');
log.warn('smart-install encountered an issue. You may need to install Bun/uv manually.');
}
}
@@ -321,9 +349,13 @@ export interface InstallOptions {
export async function runInstallCommand(options: InstallOptions = {}): Promise<void> {
const version = readPluginVersion();
p.intro(pc.bgCyan(pc.black(' claude-mem install ')));
p.log.info(`Version: ${pc.cyan(version)}`);
p.log.info(`Platform: ${process.platform} (${process.arch})`);
if (isInteractive) {
p.intro(pc.bgCyan(pc.black(' claude-mem install ')));
} else {
console.log('claude-mem install');
}
log.info(`Version: ${pc.cyan(version)}`);
log.info(`Platform: ${process.platform} (${process.arch})`);
// Check for existing installation
const marketplaceDir = marketplaceDirectory();
@@ -335,9 +367,9 @@ export async function runInstallCommand(options: InstallOptions = {}): Promise<v
const existingPluginJson = JSON.parse(
readFileSync(join(marketplaceDir, 'plugin', '.claude-plugin', 'plugin.json'), 'utf-8'),
);
p.log.warn(`Existing installation detected (v${existingPluginJson.version ?? 'unknown'}).`);
log.warn(`Existing installation detected (v${existingPluginJson.version ?? 'unknown'}).`);
} catch {
p.log.warn('Existing installation detected.');
log.warn('Existing installation detected.');
}
if (process.stdin.isTTY) {
@@ -360,12 +392,12 @@ export async function runInstallCommand(options: InstallOptions = {}): Promise<v
const allIDEs = detectInstalledIDEs();
const match = allIDEs.find((i) => i.id === options.ide);
if (match && !match.supported) {
p.log.error(`Support for ${match.label} coming soon.`);
log.error(`Support for ${match.label} coming soon.`);
process.exit(1);
}
if (!match) {
p.log.error(`Unknown IDE: ${options.ide}`);
p.log.info(`Available IDEs: ${allIDEs.map((i) => i.id).join(', ')}`);
log.error(`Unknown IDE: ${options.ide}`);
log.info(`Available IDEs: ${allIDEs.map((i) => i.id).join(', ')}`);
process.exit(1);
}
} else if (process.stdin.isTTY) {
@@ -376,7 +408,7 @@ export async function runInstallCommand(options: InstallOptions = {}): Promise<v
}
// Run tasks
await p.tasks([
await runTasks([
{
title: 'Copying plugin files',
task: async (message) => {
@@ -450,7 +482,12 @@ export async function runInstallCommand(options: InstallOptions = {}): Promise<v
`IDEs: ${pc.cyan(selectedIDEs.join(', '))}`,
];
p.note(summaryLines.join('\n'), 'Installation Complete');
if (isInteractive) {
p.note(summaryLines.join('\n'), 'Installation Complete');
} else {
console.log('\n Installation Complete');
summaryLines.forEach(l => console.log(` ${l}`));
}
const nextSteps = [
'Open Claude Code and start a conversation -- memory is automatic!',
@@ -459,7 +496,12 @@ export async function runInstallCommand(options: InstallOptions = {}): Promise<v
`Start worker: ${pc.bold('npx claude-mem start')}`,
];
p.note(nextSteps.join('\n'), 'Next Steps');
p.outro(pc.green('claude-mem installed successfully!'));
if (isInteractive) {
p.note(nextSteps.join('\n'), 'Next Steps');
p.outro(pc.green('claude-mem installed successfully!'));
} else {
console.log('\n Next Steps');
nextSteps.forEach(l => console.log(` ${l}`));
console.log('\nclaude-mem installed successfully!');
}
}
+4 -4
View File
@@ -18,9 +18,9 @@ import {
knownMarketplacesPath,
marketplaceDirectory,
pluginsDirectory,
readJsonFileSafe,
writeJsonFileAtomic,
} from '../utils/paths.js';
import { readJsonSafe } from '../../utils/json-utils.js';
// ---------------------------------------------------------------------------
// Cleanup helpers
@@ -45,7 +45,7 @@ function removeCacheDirectory(): boolean {
}
function removeFromKnownMarketplaces(): void {
const knownMarketplaces = readJsonFileSafe(knownMarketplacesPath());
const knownMarketplaces = readJsonSafe<Record<string, any>>(knownMarketplacesPath(), {});
if (knownMarketplaces['thedotmack']) {
delete knownMarketplaces['thedotmack'];
writeJsonFileAtomic(knownMarketplacesPath(), knownMarketplaces);
@@ -53,7 +53,7 @@ function removeFromKnownMarketplaces(): void {
}
function removeFromInstalledPlugins(): void {
const installedPlugins = readJsonFileSafe(installedPluginsPath());
const installedPlugins = readJsonSafe<Record<string, any>>(installedPluginsPath(), {});
if (installedPlugins.plugins?.['claude-mem@thedotmack']) {
delete installedPlugins.plugins['claude-mem@thedotmack'];
writeJsonFileAtomic(installedPluginsPath(), installedPlugins);
@@ -61,7 +61,7 @@ function removeFromInstalledPlugins(): void {
}
function removeFromClaudeSettings(): void {
const settings = readJsonFileSafe(claudeSettingsPath());
const settings = readJsonSafe<Record<string, any>>(claudeSettingsPath(), {});
if (settings.enabledPlugins?.['claude-mem@thedotmack'] !== undefined) {
delete settings.enabledPlugins['claude-mem@thedotmack'];
writeJsonFileAtomic(claudeSettingsPath(), settings);
+5 -8
View File
@@ -137,14 +137,11 @@ export function ensureDirectoryExists(directoryPath: string): void {
}
}
export function readJsonFileSafe(filepath: string): any {
if (!existsSync(filepath)) return {};
try {
return JSON.parse(readFileSync(filepath, 'utf-8'));
} catch {
return {};
}
}
/**
* @deprecated Use `readJsonSafe` from `../../utils/json-utils.js` instead.
* Kept as re-export for backward compatibility.
*/
export { readJsonSafe } from '../../utils/json-utils.js';
export function writeJsonFileAtomic(filepath: string, data: any): void {
ensureDirectoryExists(dirname(filepath));
@@ -1,148 +1,270 @@
/**
* GeminiCliHooksInstaller - First-class Gemini CLI integration for claude-mem
* GeminiCliHooksInstaller - Gemini CLI integration for claude-mem
*
* Installs claude-mem hooks into ~/.gemini/settings.json using deep merge
* to preserve any existing user configuration.
* Installs hooks into ~/.gemini/settings.json using the unified CLI:
* bun worker-service.cjs hook gemini-cli <event>
*
* Gemini CLI hook config format:
* {
* "hooks": {
* "AfterTool": [{
* "matcher": "*",
* "hooks": [{ "name": "claude-mem", "type": "command", "command": "...", "timeout": 5000, "description": "..." }]
* }]
* 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 }]
* }]
* }
* }
* }
*
* Registers 8 of 11 Gemini CLI hooks:
* SessionStart — inject memory context (via hookSpecificOutput.additionalContext)
* BeforeAgent — capture user prompt
* AfterAgent — capture full agent response
* BeforeTool — capture tool intent before execution
* AfterTool — capture all tool results (matcher: "*")
* Notification — capture system events (ToolPermission, etc.)
* PreCompress — trigger summary generation
* SessionEnd — finalize session
*
* Skipped (model-level, too chatty):
* BeforeModel, AfterModel, BeforeToolSelection
*/
import path from 'path';
import { homedir } from 'os';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { logger } from '../../utils/logger.js';
import { replaceTaggedContent } from '../../utils/claude-md-utils.js';
import { findBunPath, findWorkerServicePath } from './CursorHooksInstaller.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;
description?: string;
}
interface GeminiHookMatcher {
/** A hook group — matcher selects which tools/events this applies to */
interface GeminiHookGroup {
matcher: string;
hooks: GeminiHookEntry[];
}
interface GeminiSettingsJson {
hooks?: Record<string, GeminiHookMatcher[]>;
[otherKeys: string]: unknown;
/** The hooks section in ~/.gemini/settings.json */
interface GeminiHooksConfig {
[eventName: string]: GeminiHookGroup[];
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** 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 GEMINI_DIR = path.join(homedir(), '.gemini');
const GEMINI_SETTINGS_PATH = path.join(GEMINI_DIR, 'settings.json');
const GEMINI_MD_PATH = path.join(GEMINI_DIR, 'GEMINI.md');
const HOOK_NAME = 'claude-mem';
const HOOK_TIMEOUT_MS = 5000;
const HOOK_TIMEOUT_MS = 10000;
/**
* Gemini CLI events claude-mem internal events.
* Mapping from Gemini CLI hook events to internal claude-mem event types.
*
* We register 8 of 11 hooks. Skipped: BeforeModel, AfterModel, BeforeToolSelection
* (model-level events fire per-LLM-call — too chatty for observation capture).
* 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
*/
interface GeminiEventConfig {
claudeMemEvent: string;
description: string;
}
const GEMINI_EVENTS: Record<string, GeminiEventConfig> = {
'SessionStart': { claudeMemEvent: 'context', description: 'Inject memory context from past sessions' },
'BeforeAgent': { claudeMemEvent: 'session-init', description: 'Initialize session and capture user prompt' },
'AfterAgent': { claudeMemEvent: 'observation', description: 'Capture full agent response' },
'BeforeTool': { claudeMemEvent: 'observation', description: 'Capture tool intent before execution' },
'AfterTool': { claudeMemEvent: 'observation', description: 'Capture tool results after execution' },
'Notification': { claudeMemEvent: 'observation', description: 'Capture system events (permissions, etc.)' },
'PreCompress': { claudeMemEvent: 'summarize', description: 'Generate session summary before compression' },
'SessionEnd': { claudeMemEvent: 'session-complete', description: 'Finalize session and persist memory' },
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',
};
// ---------------------------------------------------------------------------
// Deep Merge for Hook Arrays
// ---------------------------------------------------------------------------
/**
* Merge claude-mem hooks into an existing event's hook matcher array.
* If a matcher with the same `matcher` value already has a hook named "claude-mem",
* it is replaced. Otherwise, the hook is appended.
*/
function mergeHookMatchers(
existingMatchers: GeminiHookMatcher[],
newMatcher: GeminiHookMatcher,
): GeminiHookMatcher[] {
const result = [...existingMatchers];
const existingMatcherIndex = result.findIndex(
(m) => m.matcher === newMatcher.matcher,
);
if (existingMatcherIndex !== -1) {
// Matcher exists — replace or add our hook within it
const existing = result[existingMatcherIndex];
const hookIndex = existing.hooks.findIndex((h) => h.name === HOOK_NAME);
if (hookIndex !== -1) {
existing.hooks[hookIndex] = newMatcher.hooks[0];
} else {
existing.hooks.push(newMatcher.hooks[0]);
}
} else {
// No matching matcher — add the whole entry
result.push(newMatcher);
}
return result;
}
// ---------------------------------------------------------------------------
// Hook Installation
// ---------------------------------------------------------------------------
// ============================================================================
// Hook Command Builder
// ============================================================================
/**
* Build the hook command string for a given Gemini CLI event.
*
* Invokes: <bun-path> <worker-service.cjs> hook 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, claudeMemEvent: string): string {
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}`);
}
// Escape backslashes for JSON compatibility on Windows
const escapedBunPath = bunPath.replace(/\\/g, '\\\\');
const escapedWorkerPath = workerServicePath.replace(/\\/g, '\\\\');
return `"${escapedBunPath}" "${escapedWorkerPath}" hook gemini-cli ${claudeMemEvent}`;
return `"${escapedBunPath}" "${escapedWorkerPath}" hook gemini-cli ${internalEvent}`;
}
/**
* Install claude-mem hooks into Gemini CLI's settings.json.
* Deep-merges with existing configuration — never overwrites.
* 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/corrupt.
*/
function readGeminiSettings(): GeminiSettingsJson {
if (!existsSync(GEMINI_SETTINGS_PATH)) {
return {};
}
try {
const content = readFileSync(GEMINI_SETTINGS_PATH, 'utf-8');
return JSON.parse(content) as GeminiSettingsJson;
} catch (error) {
logger.warn('GEMINI', `Failed to parse ${GEMINI_SETTINGS_PATH}, treating as empty`, {});
return {};
}
}
/**
* 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
*/
@@ -162,80 +284,47 @@ export async function installGeminiCliHooks(): Promise<number> {
console.log(` Worker service: ${workerServicePath}`);
try {
// Ensure ~/.gemini exists
mkdirSync(GEMINI_DIR, { recursive: true });
// Read existing settings (deep merge, never overwrite)
let settings: GeminiSettingsJson = {};
if (existsSync(GEMINI_SETTINGS_PATH)) {
try {
settings = JSON.parse(readFileSync(GEMINI_SETTINGS_PATH, 'utf-8'));
} catch (parseError) {
logger.error('GEMINI', 'Corrupt settings.json, creating backup', { path: GEMINI_SETTINGS_PATH }, parseError as Error);
// Back up corrupt file
const backupPath = `${GEMINI_SETTINGS_PATH}.backup.${Date.now()}`;
writeFileSync(backupPath, readFileSync(GEMINI_SETTINGS_PATH));
console.warn(` Backed up corrupt settings.json to ${backupPath}`);
settings = {};
}
// 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)];
}
// Initialize hooks object if missing
if (!settings.hooks) {
settings.hooks = {};
// 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}`);
}
// Register each event
for (const [geminiEvent, config] of Object.entries(GEMINI_EVENTS)) {
const command = buildHookCommand(bunPath, workerServicePath, config.claudeMemEvent);
const newMatcher: GeminiHookMatcher = {
matcher: '*',
hooks: [{
name: HOOK_NAME,
type: 'command',
command,
timeout: HOOK_TIMEOUT_MS,
description: config.description,
}],
};
const existingMatchers = settings.hooks[geminiEvent] ?? [];
settings.hooks[geminiEvent] = mergeHookMatchers(existingMatchers, newMatcher);
}
// Write merged settings
writeFileSync(GEMINI_SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
console.log(` Updated ${GEMINI_SETTINGS_PATH}`);
console.log(` Registered hooks for: ${Object.keys(GEMINI_EVENTS).join(', ')}`);
// Inject context into GEMINI.md
injectGeminiMdContext();
console.log(`
Installation complete! (8 hooks registered)
Installation complete!
Hooks installed to: ${GEMINI_SETTINGS_PATH}
Using unified CLI: bun worker-service.cjs hook gemini-cli <event>
Registered hooks:
SessionStart → Inject memory context from past sessions
BeforeAgent → Capture user prompt for memory
AfterAgent → Capture full agent response
BeforeTool → Capture tool intent before execution
AfterTool → Capture tool results after execution
Notification → Capture system events (permissions, etc.)
PreCompress → Generate session summary before compression
SessionEnd → Finalize session and persist memory
Next steps:
1. Start claude-mem worker: npx claude-mem start
1. Start claude-mem worker: claude-mem start
2. Restart Gemini CLI to load the hooks
3. Memory capture is now automatic!
3. Memory will be captured automatically during sessions
Context Injection:
Memory from past sessions is injected via hookSpecificOutput.additionalContext
on SessionStart, and persisted in ${GEMINI_MD_PATH} for static context.
Context from past sessions is injected via ~/.gemini/GEMINI.md
and automatically included in Gemini CLI conversations.
`);
return 0;
@@ -245,48 +334,10 @@ Context Injection:
}
}
// ---------------------------------------------------------------------------
// Context Injection (GEMINI.md)
// ---------------------------------------------------------------------------
/**
* Inject claude-mem context section into ~/.gemini/GEMINI.md.
* Uses the same <claude-mem-context> tag pattern as CLAUDE.md.
* Preserves any existing user content outside the tags.
*/
function injectGeminiMdContext(): void {
try {
let existingContent = '';
if (existsSync(GEMINI_MD_PATH)) {
existingContent = readFileSync(GEMINI_MD_PATH, 'utf-8');
}
// Initial placeholder content — will be populated after first session
const contextContent = [
'# Recent Activity',
'',
'<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->',
'',
'*No context yet. Complete your first session and context will appear here.*',
].join('\n');
const finalContent = replaceTaggedContent(existingContent, contextContent);
writeFileSync(GEMINI_MD_PATH, finalContent);
console.log(` Injected context placeholder into ${GEMINI_MD_PATH}`);
} catch (error) {
// Non-fatal — hooks still work without context injection
logger.warn('GEMINI', 'Failed to inject GEMINI.md context', { error: (error as Error).message });
console.warn(` Warning: Could not inject context into GEMINI.md: ${(error as Error).message}`);
}
}
// ---------------------------------------------------------------------------
// Uninstallation
// ---------------------------------------------------------------------------
/**
* Remove claude-mem hooks from Gemini CLI settings.json.
* Preserves all other hooks and settings.
* 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
*/
@@ -295,44 +346,31 @@ export function uninstallGeminiCliHooks(): number {
try {
if (!existsSync(GEMINI_SETTINGS_PATH)) {
console.log(' No settings.json found — nothing to uninstall.');
console.log(' No Gemini CLI settings found — nothing to uninstall.');
return 0;
}
let settings: GeminiSettingsJson;
try {
settings = JSON.parse(readFileSync(GEMINI_SETTINGS_PATH, 'utf-8'));
} catch {
console.error(' Could not parse settings.json');
return 1;
}
const settings = readGeminiSettings();
if (!settings.hooks) {
console.log(' No hooks configured — nothing to uninstall.');
console.log(' No hooks found in Gemini CLI settings — nothing to uninstall.');
return 0;
}
let removedCount = 0;
// Remove claude-mem hooks from each event
for (const eventName of Object.keys(settings.hooks)) {
const matchers = settings.hooks[eventName];
if (!Array.isArray(matchers)) continue;
for (const matcher of matchers) {
if (!Array.isArray(matcher.hooks)) continue;
const beforeLength = matcher.hooks.length;
matcher.hooks = matcher.hooks.filter((h) => h.name !== HOOK_NAME);
removedCount += beforeLength - matcher.hooks.length;
}
// Clean up empty matchers
settings.hooks[eventName] = matchers.filter(
(m) => m.hooks.length > 0,
for (const [eventName, groups] of Object.entries(settings.hooks)) {
const filteredGroups = groups.filter(group =>
!group.hooks.some(hook => hook.name === HOOK_NAME)
);
// Clean up empty event arrays
if (settings.hooks[eventName].length === 0) {
if (filteredGroups.length < groups.length) {
removedCount += groups.length - filteredGroups.length;
}
if (filteredGroups.length > 0) {
settings.hooks[eventName] = filteredGroups;
} else {
delete settings.hooks[eventName];
}
}
@@ -342,15 +380,22 @@ export function uninstallGeminiCliHooks(): number {
delete settings.hooks;
}
writeFileSync(GEMINI_SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
console.log(` Removed ${removedCount} claude-mem hook(s) from settings.json`);
writeGeminiSettings(settings);
console.log(` Removed ${removedCount} claude-mem hook group(s) from ${GEMINI_SETTINGS_PATH}`);
// Remove context section from GEMINI.md
removeGeminiMdContext();
console.log('\nUninstallation complete!');
console.log('Restart Gemini CLI to apply changes.\n');
// 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}`);
@@ -358,46 +403,6 @@ export function uninstallGeminiCliHooks(): number {
}
}
/**
* Remove claude-mem context section from GEMINI.md.
* Preserves user content outside the <claude-mem-context> tags.
*/
function removeGeminiMdContext(): void {
try {
if (!existsSync(GEMINI_MD_PATH)) return;
const content = readFileSync(GEMINI_MD_PATH, 'utf-8');
const startTag = '<claude-mem-context>';
const endTag = '</claude-mem-context>';
const startIdx = content.indexOf(startTag);
const endIdx = content.indexOf(endTag);
if (startIdx === -1 || endIdx === -1) return;
// Remove the tagged section and any surrounding blank lines
const before = content.substring(0, startIdx).replace(/\n+$/, '');
const after = content.substring(endIdx + endTag.length).replace(/^\n+/, '');
const finalContent = (before + (after ? '\n\n' + after : '')).trim();
if (finalContent) {
writeFileSync(GEMINI_MD_PATH, finalContent + '\n');
} else {
// File would be empty — leave it empty rather than deleting
// (user may have other tooling that expects it to exist)
writeFileSync(GEMINI_MD_PATH, '');
}
console.log(` Removed context section from ${GEMINI_MD_PATH}`);
} catch (error) {
logger.warn('GEMINI', 'Failed to clean GEMINI.md context', { error: (error as Error).message });
}
}
// ---------------------------------------------------------------------------
// Status Check
// ---------------------------------------------------------------------------
/**
* Check Gemini CLI hooks installation status.
*
@@ -407,64 +412,93 @@ export function checkGeminiCliHooksStatus(): number {
console.log('\nClaude-Mem Gemini CLI Hooks Status\n');
if (!existsSync(GEMINI_SETTINGS_PATH)) {
console.log('Status: Not installed');
console.log(` No settings file at ${GEMINI_SETTINGS_PATH}`);
console.log('\nRun: npx claude-mem install --ide gemini-cli\n');
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;
}
try {
const settings: GeminiSettingsJson = JSON.parse(readFileSync(GEMINI_SETTINGS_PATH, 'utf-8'));
const settings = readGeminiSettings();
if (!settings.hooks) {
console.log('Status: Not installed');
console.log(' settings.json exists but has no hooks section.');
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);
}
}
const installedEvents: string[] = [];
for (const [eventName, matchers] of Object.entries(settings.hooks)) {
if (!Array.isArray(matchers)) continue;
for (const matcher of matchers) {
if (matcher.hooks?.some((h: GeminiHookEntry) => h.name === HOOK_NAME)) {
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;
}
if (installedEvents.length === 0) {
console.log('Status: Not installed');
console.log(' settings.json exists but no claude-mem hooks found.');
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('Status: Installed');
console.log(` Config: ${GEMINI_SETTINGS_PATH}`);
console.log(` Events: ${installedEvents.join(', ')}`);
// 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 no context tags`);
}
} else {
console.log(` Context: No GEMINI.md file`);
}
// Check expected vs actual events
const expectedEvents = Object.keys(GEMINI_EVENTS);
const missingEvents = expectedEvents.filter((e) => !installedEvents.includes(e));
if (missingEvents.length > 0) {
console.log(` Warning: Missing events: ${missingEvents.join(', ')}`);
console.log(' Run install again to add missing hooks.');
}
console.log('Context: GEMINI.md exists but missing claude-mem section');
}
} catch {
console.log('Status: Unknown');
console.log(' Could not parse settings.json.');
} 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;
}
}
+121 -373
View File
@@ -21,14 +21,13 @@ import { homedir } from 'os';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { logger } from '../../utils/logger.js';
import { findMcpServerPath } from './CursorHooksInstaller.js';
import { readJsonSafe } from '../../utils/json-utils.js';
import { injectContextIntoMarkdownFile } from '../../utils/context-injection.js';
// ============================================================================
// Shared Constants
// ============================================================================
const CONTEXT_TAG_OPEN = '<claude-mem-context>';
const CONTEXT_TAG_CLOSE = '</claude-mem-context>';
const PLACEHOLDER_CONTEXT = `# claude-mem: Cross-Session Memory
*No context yet. Complete your first session and context will appear here.*
@@ -45,57 +44,11 @@ Use claude-mem's MCP search tools for manual memory queries.`;
*/
function buildMcpServerEntry(mcpServerPath: string): { command: string; args: string[] } {
return {
command: 'node',
command: process.execPath,
args: [mcpServerPath],
};
}
/**
* Read a JSON file safely, returning a default value if it doesn't exist or is corrupt.
*/
function readJsonSafe<T>(filePath: string, defaultValue: T): T {
if (!existsSync(filePath)) return defaultValue;
try {
return JSON.parse(readFileSync(filePath, 'utf-8'));
} catch (error) {
logger.error('MCP', `Corrupt JSON file, using default`, { path: filePath }, error as Error);
return defaultValue;
}
}
/**
* Inject or update a <claude-mem-context> section in a markdown file.
* Creates the file if it doesn't exist. Preserves content outside the tags.
*/
function injectContextIntoMarkdownFile(filePath: string, contextContent: string): void {
const parentDirectory = path.dirname(filePath);
mkdirSync(parentDirectory, { recursive: true });
const wrappedContent = `${CONTEXT_TAG_OPEN}\n${contextContent}\n${CONTEXT_TAG_CLOSE}`;
if (existsSync(filePath)) {
let existingContent = readFileSync(filePath, 'utf-8');
const tagStartIndex = existingContent.indexOf(CONTEXT_TAG_OPEN);
const tagEndIndex = existingContent.indexOf(CONTEXT_TAG_CLOSE);
if (tagStartIndex !== -1 && tagEndIndex !== -1) {
// Replace existing section
existingContent =
existingContent.slice(0, tagStartIndex) +
wrappedContent +
existingContent.slice(tagEndIndex + CONTEXT_TAG_CLOSE.length);
} else {
// Append section
existingContent = existingContent.trimEnd() + '\n\n' + wrappedContent + '\n';
}
writeFileSync(filePath, existingContent, 'utf-8');
} else {
writeFileSync(filePath, wrappedContent + '\n', 'utf-8');
}
}
/**
* Write a standard MCP JSON config file, merging with existing config.
* Supports both { "mcpServers": { ... } } and { "servers": { ... } } formats.
@@ -120,147 +73,142 @@ function writeMcpJsonConfig(
}
// ============================================================================
// Copilot CLI
// MCP Installer Factory (Phase 1D)
// ============================================================================
/**
* Get the Copilot CLI MCP config path.
* Copilot CLI uses ~/.github/copilot/mcp.json for user-level MCP config.
* Configuration for a JSON-based MCP IDE integration.
*/
function getCopilotCliMcpConfigPath(): string {
return path.join(homedir(), '.github', 'copilot', 'mcp.json');
interface McpInstallerConfig {
ideId: string;
ideLabel: string;
configPath: string;
configKey: 'servers' | 'mcpServers';
contextFile?: {
path: string;
isWorkspaceRelative: boolean;
};
}
/**
* Get the Copilot CLI context injection path for the current workspace.
* Copilot reads instructions from .github/copilot-instructions.md in the workspace.
* Factory function that creates an MCP installer for any JSON-config-based IDE.
* Handles MCP config writing and optional context injection.
*/
function getCopilotCliContextPath(): string {
return path.join(process.cwd(), '.github', 'copilot-instructions.md');
}
function installMcpIntegration(config: McpInstallerConfig): () => Promise<number> {
return async (): Promise<number> => {
console.log(`\nInstalling Claude-Mem MCP integration for ${config.ideLabel}...\n`);
/**
* Install claude-mem MCP integration for Copilot CLI.
*
* - Writes MCP config to ~/.github/copilot/mcp.json
* - Injects context into .github/copilot-instructions.md in the workspace
*
* @returns 0 on success, 1 on failure
*/
export async function installCopilotCliMcpIntegration(): Promise<number> {
console.log('\nInstalling Claude-Mem MCP integration for Copilot CLI...\n');
const mcpServerPath = findMcpServerPath();
if (!mcpServerPath) {
console.error('Could not find MCP server script');
console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/mcp-server.cjs');
return 1;
}
const mcpServerPath = findMcpServerPath();
if (!mcpServerPath) {
console.error('Could not find MCP server script');
console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/mcp-server.cjs');
return 1;
}
try {
// Write MCP config
const configPath = config.configPath;
try {
// Write MCP config — Copilot CLI uses { "servers": { ... } } format
const configPath = getCopilotCliMcpConfigPath();
writeMcpJsonConfig(configPath, mcpServerPath, 'servers');
console.log(` MCP config written to: ${configPath}`);
// Warp special case: skip config write if ~/.warp/ doesn't exist
if (config.ideId === 'warp' && !existsSync(path.dirname(configPath))) {
console.log(` Note: ~/.warp/ not found. MCP may need to be configured via Warp Drive UI.`);
} else {
writeMcpJsonConfig(configPath, mcpServerPath, config.configKey);
console.log(` MCP config written to: ${configPath}`);
}
// Inject context into workspace instructions
const contextPath = getCopilotCliContextPath();
injectContextIntoMarkdownFile(contextPath, PLACEHOLDER_CONTEXT);
console.log(` Context placeholder written to: ${contextPath}`);
// Inject context if configured
let contextPath: string | undefined;
if (config.contextFile) {
contextPath = config.contextFile.path;
injectContextIntoMarkdownFile(contextPath, PLACEHOLDER_CONTEXT);
console.log(` Context placeholder written to: ${contextPath}`);
}
console.log(`
Installation complete!
// Print summary
const summaryLines = [`\nInstallation complete!\n`];
summaryLines.push(`MCP config: ${configPath}`);
if (contextPath) {
summaryLines.push(`Context: ${contextPath}`);
}
summaryLines.push('');
summaryLines.push(`Note: This is an MCP-only integration providing search tools and context.`);
summaryLines.push(`Transcript capture is not available for ${config.ideLabel}.`);
if (config.ideId === 'warp') {
summaryLines.push('If MCP config via file is not supported, configure MCP through Warp Drive UI.');
}
summaryLines.push('');
summaryLines.push('Next steps:');
summaryLines.push(' 1. Start claude-mem worker: npx claude-mem start');
summaryLines.push(` 2. Restart ${config.ideLabel} to pick up the MCP server`);
summaryLines.push('');
console.log(summaryLines.join('\n'));
MCP config: ${configPath}
Context: ${contextPath}
Note: This is an MCP-only integration providing search tools and context.
Transcript capture is not available for Copilot CLI.
Next steps:
1. Start claude-mem worker: npx claude-mem start
2. Restart Copilot CLI to pick up the MCP server
`);
return 0;
} catch (error) {
console.error(`\nInstallation failed: ${(error as Error).message}`);
return 1;
}
return 0;
} catch (error) {
console.error(`\nInstallation failed: ${(error as Error).message}`);
return 1;
}
};
}
// ============================================================================
// Antigravity
// Factory Configs for JSON-based IDEs
// ============================================================================
/**
* Get the Antigravity MCP config path.
* Antigravity stores MCP config at ~/.gemini/antigravity/mcp_config.json.
*/
function getAntigravityMcpConfigPath(): string {
return path.join(homedir(), '.gemini', 'antigravity', 'mcp_config.json');
}
const COPILOT_CLI_CONFIG: McpInstallerConfig = {
ideId: 'copilot-cli',
ideLabel: 'Copilot CLI',
configPath: path.join(homedir(), '.github', 'copilot', 'mcp.json'),
configKey: 'servers',
contextFile: {
path: path.join(process.cwd(), '.github', 'copilot-instructions.md'),
isWorkspaceRelative: true,
},
};
/**
* Get the Antigravity context injection path for the current workspace.
* Antigravity reads agent rules from .agent/rules/ in the workspace.
*/
function getAntigravityContextPath(): string {
return path.join(process.cwd(), '.agent', 'rules', 'claude-mem-context.md');
}
const ANTIGRAVITY_CONFIG: McpInstallerConfig = {
ideId: 'antigravity',
ideLabel: 'Antigravity',
configPath: path.join(homedir(), '.gemini', 'antigravity', 'mcp_config.json'),
configKey: 'mcpServers',
contextFile: {
path: path.join(process.cwd(), '.agent', 'rules', 'claude-mem-context.md'),
isWorkspaceRelative: true,
},
};
/**
* Install claude-mem MCP integration for Antigravity.
*
* - Writes MCP config to ~/.gemini/antigravity/mcp_config.json
* - Injects context into .agent/rules/claude-mem-context.md in the workspace
*
* @returns 0 on success, 1 on failure
*/
export async function installAntigravityMcpIntegration(): Promise<number> {
console.log('\nInstalling Claude-Mem MCP integration for Antigravity...\n');
const CRUSH_CONFIG: McpInstallerConfig = {
ideId: 'crush',
ideLabel: 'Crush',
configPath: path.join(homedir(), '.config', 'crush', 'mcp.json'),
configKey: 'mcpServers',
};
const mcpServerPath = findMcpServerPath();
if (!mcpServerPath) {
console.error('Could not find MCP server script');
console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/mcp-server.cjs');
return 1;
}
const ROO_CODE_CONFIG: McpInstallerConfig = {
ideId: 'roo-code',
ideLabel: 'Roo Code',
configPath: path.join(process.cwd(), '.roo', 'mcp.json'),
configKey: 'mcpServers',
contextFile: {
path: path.join(process.cwd(), '.roo', 'rules', 'claude-mem-context.md'),
isWorkspaceRelative: true,
},
};
try {
// Write MCP config
const configPath = getAntigravityMcpConfigPath();
writeMcpJsonConfig(configPath, mcpServerPath);
console.log(` MCP config written to: ${configPath}`);
// Inject context into workspace rules
const contextPath = getAntigravityContextPath();
injectContextIntoMarkdownFile(contextPath, PLACEHOLDER_CONTEXT);
console.log(` Context placeholder written to: ${contextPath}`);
console.log(`
Installation complete!
MCP config: ${configPath}
Context: ${contextPath}
Note: This is an MCP-only integration providing search tools and context.
Transcript capture is not available for Antigravity.
Next steps:
1. Start claude-mem worker: npx claude-mem start
2. Restart Antigravity to pick up the MCP server
`);
return 0;
} catch (error) {
console.error(`\nInstallation failed: ${(error as Error).message}`);
return 1;
}
}
const WARP_CONFIG: McpInstallerConfig = {
ideId: 'warp',
ideLabel: 'Warp',
configPath: path.join(homedir(), '.warp', 'mcp.json'),
configKey: 'mcpServers',
contextFile: {
path: path.join(process.cwd(), 'WARP.md'),
isWorkspaceRelative: true,
},
};
// ============================================================================
// Goose
// Goose (YAML-based — separate handler)
// ============================================================================
/**
@@ -290,7 +238,7 @@ function buildGooseMcpYamlBlock(mcpServerPath: string): string {
return [
'mcpServers:',
' claude-mem:',
' command: node',
` command: ${process.execPath}`,
' args:',
` - ${mcpServerPath}`,
].join('\n');
@@ -302,7 +250,7 @@ function buildGooseMcpYamlBlock(mcpServerPath: string): string {
function buildGooseClaudeMemEntryYaml(mcpServerPath: string): string {
return [
' claude-mem:',
' command: node',
` command: ${process.execPath}`,
' args:',
` - ${mcpServerPath}`,
].join('\n');
@@ -392,206 +340,6 @@ Next steps:
}
}
// ============================================================================
// Crush
// ============================================================================
/**
* Get the Crush MCP config path.
* Crush stores MCP config at ~/.config/crush/mcp.json.
*/
function getCrushMcpConfigPath(): string {
return path.join(homedir(), '.config', 'crush', 'mcp.json');
}
/**
* Install claude-mem MCP integration for Crush.
*
* - Writes MCP config to ~/.config/crush/mcp.json
*
* @returns 0 on success, 1 on failure
*/
export async function installCrushMcpIntegration(): Promise<number> {
console.log('\nInstalling Claude-Mem MCP integration for Crush...\n');
const mcpServerPath = findMcpServerPath();
if (!mcpServerPath) {
console.error('Could not find MCP server script');
console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/mcp-server.cjs');
return 1;
}
try {
// Write MCP config
const configPath = getCrushMcpConfigPath();
writeMcpJsonConfig(configPath, mcpServerPath);
console.log(` MCP config written to: ${configPath}`);
console.log(`
Installation complete!
MCP config: ${configPath}
Note: This is an MCP-only integration providing search tools and context.
Transcript capture is not available for Crush.
Next steps:
1. Start claude-mem worker: npx claude-mem start
2. Restart Crush to pick up the MCP server
`);
return 0;
} catch (error) {
console.error(`\nInstallation failed: ${(error as Error).message}`);
return 1;
}
}
// ============================================================================
// Roo Code
// ============================================================================
/**
* Get the Roo Code MCP config path for the current workspace.
* Roo Code reads MCP config from .roo/mcp.json in the workspace.
*/
function getRooCodeMcpConfigPath(): string {
return path.join(process.cwd(), '.roo', 'mcp.json');
}
/**
* Get the Roo Code context injection path for the current workspace.
* Roo Code reads rules from .roo/rules/ in the workspace.
*/
function getRooCodeContextPath(): string {
return path.join(process.cwd(), '.roo', 'rules', 'claude-mem-context.md');
}
/**
* Install claude-mem MCP integration for Roo Code.
*
* - Writes MCP config to .roo/mcp.json in the workspace
* - Injects context into .roo/rules/claude-mem-context.md in the workspace
*
* @returns 0 on success, 1 on failure
*/
export async function installRooCodeMcpIntegration(): Promise<number> {
console.log('\nInstalling Claude-Mem MCP integration for Roo Code...\n');
const mcpServerPath = findMcpServerPath();
if (!mcpServerPath) {
console.error('Could not find MCP server script');
console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/mcp-server.cjs');
return 1;
}
try {
// Write MCP config to workspace
const configPath = getRooCodeMcpConfigPath();
writeMcpJsonConfig(configPath, mcpServerPath);
console.log(` MCP config written to: ${configPath}`);
// Inject context into workspace rules
const contextPath = getRooCodeContextPath();
injectContextIntoMarkdownFile(contextPath, PLACEHOLDER_CONTEXT);
console.log(` Context placeholder written to: ${contextPath}`);
console.log(`
Installation complete!
MCP config: ${configPath}
Context: ${contextPath}
Note: This is an MCP-only integration providing search tools and context.
Transcript capture is not available for Roo Code.
Next steps:
1. Start claude-mem worker: npx claude-mem start
2. Restart Roo Code to pick up the MCP server
`);
return 0;
} catch (error) {
console.error(`\nInstallation failed: ${(error as Error).message}`);
return 1;
}
}
// ============================================================================
// Warp
// ============================================================================
/**
* Get the Warp context injection path for the current workspace.
* Warp reads project-level instructions from WARP.md in the project root.
*/
function getWarpContextPath(): string {
return path.join(process.cwd(), 'WARP.md');
}
/**
* Get the Warp MCP config path.
* Warp stores MCP config at ~/.warp/mcp.json when supported.
*/
function getWarpMcpConfigPath(): string {
return path.join(homedir(), '.warp', 'mcp.json');
}
/**
* Install claude-mem MCP integration for Warp.
*
* - Writes MCP config to ~/.warp/mcp.json
* - Injects context into WARP.md in the project root
*
* @returns 0 on success, 1 on failure
*/
export async function installWarpMcpIntegration(): Promise<number> {
console.log('\nInstalling Claude-Mem MCP integration for Warp...\n');
const mcpServerPath = findMcpServerPath();
if (!mcpServerPath) {
console.error('Could not find MCP server script');
console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/mcp-server.cjs');
return 1;
}
try {
// Write MCP config — Warp may also support configuring MCP via Warp Drive UI
const configPath = getWarpMcpConfigPath();
if (existsSync(path.dirname(configPath))) {
writeMcpJsonConfig(configPath, mcpServerPath);
console.log(` MCP config written to: ${configPath}`);
} else {
console.log(` Note: ~/.warp/ not found. MCP may need to be configured via Warp Drive UI.`);
}
// Inject context into project-level WARP.md
const contextPath = getWarpContextPath();
injectContextIntoMarkdownFile(contextPath, PLACEHOLDER_CONTEXT);
console.log(` Context placeholder written to: ${contextPath}`);
console.log(`
Installation complete!
MCP config: ${configPath}
Context: ${contextPath}
Note: This is an MCP-only integration providing search tools and context.
Transcript capture is not available for Warp.
If MCP config via file is not supported, configure MCP through Warp Drive UI.
Next steps:
1. Start claude-mem worker: npx claude-mem start
2. Restart Warp to pick up the MCP server
`);
return 0;
} catch (error) {
console.error(`\nInstallation failed: ${(error as Error).message}`);
return 1;
}
}
// ============================================================================
// Unified Installer (used by npx install command)
// ============================================================================
@@ -601,10 +349,10 @@ Next steps:
* Used by the install command to dispatch to the correct integration.
*/
export const MCP_IDE_INSTALLERS: Record<string, () => Promise<number>> = {
'copilot-cli': installCopilotCliMcpIntegration,
'antigravity': installAntigravityMcpIntegration,
'copilot-cli': installMcpIntegration(COPILOT_CLI_CONFIG),
'antigravity': installMcpIntegration(ANTIGRAVITY_CONFIG),
'goose': installGooseMcpIntegration,
'crush': installCrushMcpIntegration,
'roo-code': installRooCodeMcpIntegration,
'warp': installWarpMcpIntegration,
'crush': installMcpIntegration(CRUSH_CONFIG),
'roo-code': installMcpIntegration(ROO_CODE_CONFIG),
'warp': installMcpIntegration(WARP_CONFIG),
};
+2 -32
View File
@@ -18,6 +18,7 @@ import path from 'path';
import { homedir } from 'os';
import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, unlinkSync } from 'fs';
import { logger } from '../../utils/logger.js';
import { CONTEXT_TAG_OPEN, CONTEXT_TAG_CLOSE, injectContextIntoMarkdownFile } from '../../utils/context-injection.js';
// ============================================================================
// Path Resolution
@@ -125,9 +126,6 @@ export function installOpenCodePlugin(): number {
// Context Injection (AGENTS.md)
// ============================================================================
const CONTEXT_TAG_OPEN = '<claude-mem-context>';
const CONTEXT_TAG_CLOSE = '</claude-mem-context>';
/**
* Inject or update claude-mem context in OpenCode's AGENTS.md file.
*
@@ -140,37 +138,9 @@ const CONTEXT_TAG_CLOSE = '</claude-mem-context>';
*/
export function injectContextIntoAgentsMd(contextContent: string): number {
const agentsMdPath = getOpenCodeAgentsMdPath();
const wrappedContent = `${CONTEXT_TAG_OPEN}\n${contextContent}\n${CONTEXT_TAG_CLOSE}`;
try {
const configDirectory = getOpenCodeConfigDirectory();
mkdirSync(configDirectory, { recursive: true });
if (existsSync(agentsMdPath)) {
let existingContent = readFileSync(agentsMdPath, 'utf-8');
// Check if context tags already exist
const tagStartIndex = existingContent.indexOf(CONTEXT_TAG_OPEN);
const tagEndIndex = existingContent.indexOf(CONTEXT_TAG_CLOSE);
if (tagStartIndex !== -1 && tagEndIndex !== -1) {
// Replace existing section
existingContent =
existingContent.slice(0, tagStartIndex) +
wrappedContent +
existingContent.slice(tagEndIndex + CONTEXT_TAG_CLOSE.length);
} else {
// Append section
existingContent = existingContent.trimEnd() + '\n\n' + wrappedContent + '\n';
}
writeFileSync(agentsMdPath, existingContent, 'utf-8');
} else {
// Create new AGENTS.md with context
const newContent = `# Claude-Mem Memory Context\n\n${wrappedContent}\n`;
writeFileSync(agentsMdPath, newContent, 'utf-8');
}
injectContextIntoMarkdownFile(agentsMdPath, contextContent, '# Claude-Mem Memory Context');
logger.info('OPENCODE', 'Context injected into AGENTS.md', { path: agentsMdPath });
return 0;
} catch (error) {
+42 -2
View File
@@ -101,6 +101,9 @@ import {
updateCursorContextForProject,
handleCursorCommand
} from './integrations/CursorHooksInstaller.js';
import {
handleGeminiCliCommand
} from './integrations/GeminiCliHooksInstaller.js';
// Service layer imports
import { DatabaseManager } from './worker/DatabaseManager.js';
@@ -128,6 +131,10 @@ import { MemoryRoutes } from './worker/http/routes/MemoryRoutes.js';
// Process management for zombie cleanup (Issue #737)
import { startOrphanReaper, reapOrphanedProcesses, getProcessBySession, ensureProcessExit } from './worker/ProcessRegistry.js';
// Transcript watcher for external CLI session monitoring
import { TranscriptWatcher } from './transcripts/watcher.js';
import { loadTranscriptWatchConfig, expandHomePath, DEFAULT_CONFIG_PATH as TRANSCRIPT_CONFIG_PATH } from './transcripts/config.js';
/**
* Build JSON status output for hook framework communication.
* This is a pure function extracted for testability.
@@ -189,6 +196,9 @@ export class WorkerService {
// Stale session reaper interval (Issue #1168)
private staleSessionReaperInterval: ReturnType<typeof setInterval> | null = null;
// Transcript watcher for external CLI sessions (e.g. Codex, Gemini)
private transcriptWatcher: TranscriptWatcher | null = null;
// AI interaction tracking for health endpoint
private lastAiInteraction: {
timestamp: number;
@@ -421,6 +431,22 @@ export class WorkerService {
this.resolveInitialization();
logger.info('SYSTEM', 'Core initialization complete (DB + search ready)');
// Auto-start transcript watchers if configured
if (existsSync(TRANSCRIPT_CONFIG_PATH)) {
try {
const transcriptConfig = loadTranscriptWatchConfig(TRANSCRIPT_CONFIG_PATH);
if (transcriptConfig.watches.length > 0) {
const transcriptStatePath = expandHomePath(transcriptConfig.stateFile ?? '~/.claude-mem/transcript-watch-state.json');
this.transcriptWatcher = new TranscriptWatcher(transcriptConfig, transcriptStatePath);
await this.transcriptWatcher.start();
logger.info('SYSTEM', `Transcript watcher started with ${transcriptConfig.watches.length} watch target(s)`);
}
} catch (transcriptError) {
logger.warn('SYSTEM', 'Failed to start transcript watcher (non-fatal)', {}, transcriptError as Error);
// Non-fatal — worker continues without transcript watching
}
}
// Auto-backfill Chroma for all projects if out of sync with SQLite (fire-and-forget)
if (this.chromaMcpManager) {
ChromaSync.backfillAllProjects().then(() => {
@@ -922,6 +948,13 @@ export class WorkerService {
this.staleSessionReaperInterval = null;
}
// Stop transcript watcher
if (this.transcriptWatcher) {
this.transcriptWatcher.stop();
this.transcriptWatcher = null;
logger.info('SYSTEM', 'Transcript watcher stopped');
}
await performGracefulShutdown({
server: this.server.getHttpServer(),
sessionManager: this.sessionManager,
@@ -1174,14 +1207,21 @@ async function main() {
break;
}
case 'gemini-cli': {
const geminiSubcommand = process.argv[3];
const geminiResult = await handleGeminiCliCommand(geminiSubcommand, process.argv.slice(4));
process.exit(geminiResult);
break;
}
case 'hook': {
// Validate CLI args first (before any I/O)
const platform = process.argv[3];
const event = process.argv[4];
if (!platform || !event) {
console.error('Usage: claude-mem hook <platform> <event>');
console.error('Platforms: claude-code, cursor, raw');
console.error('Events: context, session-init, observation, summarize, session-complete');
console.error('Platforms: claude-code, cursor, gemini-cli, raw');
console.error('Events: context, session-init, observation, summarize, session-complete, user-message');
process.exit(1);
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Shared context injection utilities for claude-mem.
*
* Provides tag constants and a function to inject or update a
* <claude-mem-context> section in any markdown file. Used by
* MCP integrations and OpenCode installer.
*/
import path from 'path';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
// ============================================================================
// Tag Constants
// ============================================================================
export const CONTEXT_TAG_OPEN = '<claude-mem-context>';
export const CONTEXT_TAG_CLOSE = '</claude-mem-context>';
// ============================================================================
// Context Injection
// ============================================================================
/**
* Inject or update a <claude-mem-context> section in a markdown file.
* Creates the file if it doesn't exist. Preserves content outside the tags.
*
* @param filePath - Absolute path to the target markdown file.
* @param contextContent - The content to place between the context tags.
* @param headerLine - Optional first line written when creating a new file
* (e.g. `"# Claude-Mem Memory Context"` for AGENTS.md).
*/
export function injectContextIntoMarkdownFile(
filePath: string,
contextContent: string,
headerLine?: string,
): void {
const parentDirectory = path.dirname(filePath);
mkdirSync(parentDirectory, { recursive: true });
const wrappedContent = `${CONTEXT_TAG_OPEN}\n${contextContent}\n${CONTEXT_TAG_CLOSE}`;
if (existsSync(filePath)) {
let existingContent = readFileSync(filePath, 'utf-8');
const tagStartIndex = existingContent.indexOf(CONTEXT_TAG_OPEN);
const tagEndIndex = existingContent.indexOf(CONTEXT_TAG_CLOSE);
if (tagStartIndex !== -1 && tagEndIndex !== -1) {
// Replace existing section
existingContent =
existingContent.slice(0, tagStartIndex) +
wrappedContent +
existingContent.slice(tagEndIndex + CONTEXT_TAG_CLOSE.length);
} else {
// Append section
existingContent = existingContent.trimEnd() + '\n\n' + wrappedContent + '\n';
}
writeFileSync(filePath, existingContent, 'utf-8');
} else {
// Create new file
if (headerLine) {
writeFileSync(filePath, `${headerLine}\n\n${wrappedContent}\n`, 'utf-8');
} else {
writeFileSync(filePath, wrappedContent + '\n', 'utf-8');
}
}
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Shared JSON file utilities for claude-mem.
*
* Provides safe read/write helpers used across the CLI and services.
*/
import { existsSync, readFileSync } from 'fs';
import { logger } from './logger.js';
/**
* Read a JSON file safely, returning a default value if the file
* does not exist or contains corrupt JSON.
*
* @param filePath - Absolute path to the JSON file.
* @param defaultValue - Value returned when the file is missing or unreadable.
* @returns The parsed JSON content, or `defaultValue` on failure.
*/
export function readJsonSafe<T>(filePath: string, defaultValue: T): T {
if (!existsSync(filePath)) return defaultValue;
try {
return JSON.parse(readFileSync(filePath, 'utf-8'));
} catch (error) {
logger.error('JSON', `Corrupt JSON file, using default`, { path: filePath }, error as Error);
return defaultValue;
}
}