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
File diff suppressed because one or more lines are too long
+78 -36
View File
@@ -12,6 +12,34 @@ import pc from 'picocolors';
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import { cpSync, existsSync, readFileSync } from 'fs'; import { cpSync, existsSync, readFileSync } from 'fs';
import { join } from 'path'; 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 { import {
claudeSettingsPath, claudeSettingsPath,
ensureDirectoryExists, ensureDirectoryExists,
@@ -23,10 +51,10 @@ import {
npmPackageRootDirectory, npmPackageRootDirectory,
pluginCacheDirectory, pluginCacheDirectory,
pluginsDirectory, pluginsDirectory,
readJsonFileSafe,
readPluginVersion, readPluginVersion,
writeJsonFileAtomic, writeJsonFileAtomic,
} from '../utils/paths.js'; } from '../utils/paths.js';
import { readJsonSafe } from '../../utils/json-utils.js';
import { detectInstalledIDEs } from './ide-detection.js'; import { detectInstalledIDEs } from './ide-detection.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -34,7 +62,7 @@ import { detectInstalledIDEs } from './ide-detection.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function registerMarketplace(): void { function registerMarketplace(): void {
const knownMarketplaces = readJsonFileSafe(knownMarketplacesPath()); const knownMarketplaces = readJsonSafe<Record<string, any>>(knownMarketplacesPath(), {});
knownMarketplaces['thedotmack'] = { knownMarketplaces['thedotmack'] = {
source: { source: {
@@ -51,7 +79,7 @@ function registerMarketplace(): void {
} }
function registerPlugin(version: string): 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.version) installedPlugins.version = 2;
if (!installedPlugins.plugins) installedPlugins.plugins = {}; if (!installedPlugins.plugins) installedPlugins.plugins = {};
@@ -73,7 +101,7 @@ function registerPlugin(version: string): void {
} }
function enablePluginInClaudeSettings(): void { function enablePluginInClaudeSettings(): void {
const settings = readJsonFileSafe(claudeSettingsPath()); const settings = readJsonSafe<Record<string, any>>(claudeSettingsPath(), {});
if (!settings.enabledPlugins) settings.enabledPlugins = {}; if (!settings.enabledPlugins) settings.enabledPlugins = {};
settings.enabledPlugins['claude-mem@thedotmack'] = true; settings.enabledPlugins['claude-mem@thedotmack'] = true;
@@ -91,21 +119,21 @@ async function setupIDEs(selectedIDEs: string[]): Promise<void> {
case 'claude-code': case 'claude-code':
// Claude Code picks up the plugin via marketplace registration — nothing // Claude Code picks up the plugin via marketplace registration — nothing
// else to do beyond what registerMarketplace / registerPlugin already did. // 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; break;
case 'cursor': case 'cursor':
p.log.info('Cursor: hook configuration available after first launch.'); log.info('Cursor: hook configuration available after first launch.');
p.log.info(` Run: npx claude-mem cursor-setup (coming soon)`); log.info(` Run: npx claude-mem cursor-setup (coming soon)`);
break; break;
case 'gemini-cli': { case 'gemini-cli': {
const { installGeminiCliHooks } = await import('../../services/integrations/GeminiCliHooksInstaller.js'); const { installGeminiCliHooks } = await import('../../services/integrations/GeminiCliHooksInstaller.js');
const geminiResult = await installGeminiCliHooks(); const geminiResult = await installGeminiCliHooks();
if (geminiResult === 0) { if (geminiResult === 0) {
p.log.success('Gemini CLI: hooks installed.'); log.success('Gemini CLI: hooks installed.');
} else { } else {
p.log.error('Gemini CLI: hook installation failed.'); log.error('Gemini CLI: hook installation failed.');
} }
break; break;
} }
@@ -114,9 +142,9 @@ async function setupIDEs(selectedIDEs: string[]): Promise<void> {
const { installOpenCodeIntegration } = await import('../../services/integrations/OpenCodeInstaller.js'); const { installOpenCodeIntegration } = await import('../../services/integrations/OpenCodeInstaller.js');
const openCodeResult = await installOpenCodeIntegration(); const openCodeResult = await installOpenCodeIntegration();
if (openCodeResult === 0) { if (openCodeResult === 0) {
p.log.success('OpenCode: plugin installed.'); log.success('OpenCode: plugin installed.');
} else { } else {
p.log.error('OpenCode: plugin installation failed.'); log.error('OpenCode: plugin installation failed.');
} }
break; break;
} }
@@ -125,9 +153,9 @@ async function setupIDEs(selectedIDEs: string[]): Promise<void> {
const { installWindsurfHooks } = await import('../../services/integrations/WindsurfHooksInstaller.js'); const { installWindsurfHooks } = await import('../../services/integrations/WindsurfHooksInstaller.js');
const windsurfResult = await installWindsurfHooks(); const windsurfResult = await installWindsurfHooks();
if (windsurfResult === 0) { if (windsurfResult === 0) {
p.log.success('Windsurf: hooks installed.'); log.success('Windsurf: hooks installed.');
} else { } else {
p.log.error('Windsurf: hook installation failed.'); log.error('Windsurf: hook installation failed.');
} }
break; break;
} }
@@ -136,9 +164,9 @@ async function setupIDEs(selectedIDEs: string[]): Promise<void> {
const { installOpenClawIntegration } = await import('../../services/integrations/OpenClawInstaller.js'); const { installOpenClawIntegration } = await import('../../services/integrations/OpenClawInstaller.js');
const openClawResult = await installOpenClawIntegration(); const openClawResult = await installOpenClawIntegration();
if (openClawResult === 0) { if (openClawResult === 0) {
p.log.success('OpenClaw: plugin installed.'); log.success('OpenClaw: plugin installed.');
} else { } else {
p.log.error('OpenClaw: plugin installation failed.'); log.error('OpenClaw: plugin installation failed.');
} }
break; break;
} }
@@ -147,9 +175,9 @@ async function setupIDEs(selectedIDEs: string[]): Promise<void> {
const { installCodexCli } = await import('../../services/integrations/CodexCliInstaller.js'); const { installCodexCli } = await import('../../services/integrations/CodexCliInstaller.js');
const codexResult = await installCodexCli(); const codexResult = await installCodexCli();
if (codexResult === 0) { if (codexResult === 0) {
p.log.success('Codex CLI: transcript watching configured.'); log.success('Codex CLI: transcript watching configured.');
} else { } else {
p.log.error('Codex CLI: integration setup failed.'); log.error('Codex CLI: integration setup failed.');
} }
break; break;
} }
@@ -168,9 +196,9 @@ async function setupIDEs(selectedIDEs: string[]): Promise<void> {
const ideInfo = allIDEs.find((i) => i.id === ideId); const ideInfo = allIDEs.find((i) => i.id === ideId);
const ideLabel = ideInfo?.label ?? ideId; const ideLabel = ideInfo?.label ?? ideId;
if (mcpResult === 0) { if (mcpResult === 0) {
p.log.success(`${ideLabel}: MCP integration installed.`); log.success(`${ideLabel}: MCP integration installed.`);
} else { } else {
p.log.error(`${ideLabel}: MCP integration failed.`); log.error(`${ideLabel}: MCP integration failed.`);
} }
} }
break; break;
@@ -180,7 +208,7 @@ async function setupIDEs(selectedIDEs: string[]): Promise<void> {
const allIDEs = detectInstalledIDEs(); const allIDEs = detectInstalledIDEs();
const ide = allIDEs.find((i) => i.id === ideId); const ide = allIDEs.find((i) => i.id === ideId);
if (ide && !ide.supported) { if (ide && !ide.supported) {
p.log.warn(`Support for ${ide.label} coming soon.`); log.warn(`Support for ${ide.label} coming soon.`);
} }
break; break;
} }
@@ -197,7 +225,7 @@ async function promptForIDESelection(): Promise<string[]> {
const detected = detectedIDEs.filter((ide) => ide.detected); const detected = detectedIDEs.filter((ide) => ide.detected);
if (detected.length === 0) { 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']; return ['claude-code'];
} }
@@ -295,7 +323,7 @@ function runSmartInstall(): void {
const smartInstallPath = join(marketplaceDirectory(), 'plugin', 'scripts', 'smart-install.js'); const smartInstallPath = join(marketplaceDirectory(), 'plugin', 'scripts', 'smart-install.js');
if (!existsSync(smartInstallPath)) { 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; return;
} }
@@ -305,7 +333,7 @@ function runSmartInstall(): void {
...(IS_WINDOWS ? { shell: true as const } : {}), ...(IS_WINDOWS ? { shell: true as const } : {}),
}); });
} catch { } 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> { export async function runInstallCommand(options: InstallOptions = {}): Promise<void> {
const version = readPluginVersion(); const version = readPluginVersion();
p.intro(pc.bgCyan(pc.black(' claude-mem install '))); if (isInteractive) {
p.log.info(`Version: ${pc.cyan(version)}`); p.intro(pc.bgCyan(pc.black(' claude-mem install ')));
p.log.info(`Platform: ${process.platform} (${process.arch})`); } else {
console.log('claude-mem install');
}
log.info(`Version: ${pc.cyan(version)}`);
log.info(`Platform: ${process.platform} (${process.arch})`);
// Check for existing installation // Check for existing installation
const marketplaceDir = marketplaceDirectory(); const marketplaceDir = marketplaceDirectory();
@@ -335,9 +367,9 @@ export async function runInstallCommand(options: InstallOptions = {}): Promise<v
const existingPluginJson = JSON.parse( const existingPluginJson = JSON.parse(
readFileSync(join(marketplaceDir, 'plugin', '.claude-plugin', 'plugin.json'), 'utf-8'), 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 { } catch {
p.log.warn('Existing installation detected.'); log.warn('Existing installation detected.');
} }
if (process.stdin.isTTY) { if (process.stdin.isTTY) {
@@ -360,12 +392,12 @@ export async function runInstallCommand(options: InstallOptions = {}): Promise<v
const allIDEs = detectInstalledIDEs(); const allIDEs = detectInstalledIDEs();
const match = allIDEs.find((i) => i.id === options.ide); const match = allIDEs.find((i) => i.id === options.ide);
if (match && !match.supported) { if (match && !match.supported) {
p.log.error(`Support for ${match.label} coming soon.`); log.error(`Support for ${match.label} coming soon.`);
process.exit(1); process.exit(1);
} }
if (!match) { if (!match) {
p.log.error(`Unknown IDE: ${options.ide}`); log.error(`Unknown IDE: ${options.ide}`);
p.log.info(`Available IDEs: ${allIDEs.map((i) => i.id).join(', ')}`); log.info(`Available IDEs: ${allIDEs.map((i) => i.id).join(', ')}`);
process.exit(1); process.exit(1);
} }
} else if (process.stdin.isTTY) { } else if (process.stdin.isTTY) {
@@ -376,7 +408,7 @@ export async function runInstallCommand(options: InstallOptions = {}): Promise<v
} }
// Run tasks // Run tasks
await p.tasks([ await runTasks([
{ {
title: 'Copying plugin files', title: 'Copying plugin files',
task: async (message) => { task: async (message) => {
@@ -450,7 +482,12 @@ export async function runInstallCommand(options: InstallOptions = {}): Promise<v
`IDEs: ${pc.cyan(selectedIDEs.join(', '))}`, `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 = [ const nextSteps = [
'Open Claude Code and start a conversation -- memory is automatic!', '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')}`, `Start worker: ${pc.bold('npx claude-mem start')}`,
]; ];
p.note(nextSteps.join('\n'), 'Next Steps'); if (isInteractive) {
p.note(nextSteps.join('\n'), 'Next Steps');
p.outro(pc.green('claude-mem installed successfully!')); 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, knownMarketplacesPath,
marketplaceDirectory, marketplaceDirectory,
pluginsDirectory, pluginsDirectory,
readJsonFileSafe,
writeJsonFileAtomic, writeJsonFileAtomic,
} from '../utils/paths.js'; } from '../utils/paths.js';
import { readJsonSafe } from '../../utils/json-utils.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Cleanup helpers // Cleanup helpers
@@ -45,7 +45,7 @@ function removeCacheDirectory(): boolean {
} }
function removeFromKnownMarketplaces(): void { function removeFromKnownMarketplaces(): void {
const knownMarketplaces = readJsonFileSafe(knownMarketplacesPath()); const knownMarketplaces = readJsonSafe<Record<string, any>>(knownMarketplacesPath(), {});
if (knownMarketplaces['thedotmack']) { if (knownMarketplaces['thedotmack']) {
delete knownMarketplaces['thedotmack']; delete knownMarketplaces['thedotmack'];
writeJsonFileAtomic(knownMarketplacesPath(), knownMarketplaces); writeJsonFileAtomic(knownMarketplacesPath(), knownMarketplaces);
@@ -53,7 +53,7 @@ function removeFromKnownMarketplaces(): void {
} }
function removeFromInstalledPlugins(): void { function removeFromInstalledPlugins(): void {
const installedPlugins = readJsonFileSafe(installedPluginsPath()); const installedPlugins = readJsonSafe<Record<string, any>>(installedPluginsPath(), {});
if (installedPlugins.plugins?.['claude-mem@thedotmack']) { if (installedPlugins.plugins?.['claude-mem@thedotmack']) {
delete installedPlugins.plugins['claude-mem@thedotmack']; delete installedPlugins.plugins['claude-mem@thedotmack'];
writeJsonFileAtomic(installedPluginsPath(), installedPlugins); writeJsonFileAtomic(installedPluginsPath(), installedPlugins);
@@ -61,7 +61,7 @@ function removeFromInstalledPlugins(): void {
} }
function removeFromClaudeSettings(): void { function removeFromClaudeSettings(): void {
const settings = readJsonFileSafe(claudeSettingsPath()); const settings = readJsonSafe<Record<string, any>>(claudeSettingsPath(), {});
if (settings.enabledPlugins?.['claude-mem@thedotmack'] !== undefined) { if (settings.enabledPlugins?.['claude-mem@thedotmack'] !== undefined) {
delete settings.enabledPlugins['claude-mem@thedotmack']; delete settings.enabledPlugins['claude-mem@thedotmack'];
writeJsonFileAtomic(claudeSettingsPath(), settings); 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 {}; * @deprecated Use `readJsonSafe` from `../../utils/json-utils.js` instead.
try { * Kept as re-export for backward compatibility.
return JSON.parse(readFileSync(filepath, 'utf-8')); */
} catch { export { readJsonSafe } from '../../utils/json-utils.js';
return {};
}
}
export function writeJsonFileAtomic(filepath: string, data: any): void { export function writeJsonFileAtomic(filepath: string, data: any): void {
ensureDirectoryExists(dirname(filepath)); 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 * Installs hooks into ~/.gemini/settings.json using the unified CLI:
* to preserve any existing user configuration. * bun worker-service.cjs hook gemini-cli <event>
* *
* Gemini CLI hook config format: * This routes through the hook-command.ts framework:
* { * readJsonFromStdin() → gemini-cli adapter → event handler → POST to worker
* "hooks": { *
* "AfterTool": [{ * Gemini CLI supports 11 lifecycle hooks; we register 8 that map to
* "matcher": "*", * useful memory events. See src/cli/adapters/gemini-cli.ts for the
* "hooks": [{ "name": "claude-mem", "type": "command", "command": "...", "timeout": 5000, "description": "..." }] * 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 path from 'path';
import { homedir } from 'os'; import { homedir } from 'os';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { logger } from '../../utils/logger.js'; import { logger } from '../../utils/logger.js';
import { replaceTaggedContent } from '../../utils/claude-md-utils.js'; import { findWorkerServicePath, findBunPath } from './CursorHooksInstaller.js';
import { findBunPath, findWorkerServicePath } from './CursorHooksInstaller.js';
// --------------------------------------------------------------------------- // ============================================================================
// Types // Types
// --------------------------------------------------------------------------- // ============================================================================
/** A single hook entry in a Gemini CLI hook group */
interface GeminiHookEntry { interface GeminiHookEntry {
name: string; name: string;
type: 'command'; type: 'command';
command: string; command: string;
timeout: number; timeout: number;
description?: string;
} }
interface GeminiHookMatcher { /** A hook group — matcher selects which tools/events this applies to */
interface GeminiHookGroup {
matcher: string; matcher: string;
hooks: GeminiHookEntry[]; hooks: GeminiHookEntry[];
} }
interface GeminiSettingsJson { /** The hooks section in ~/.gemini/settings.json */
hooks?: Record<string, GeminiHookMatcher[]>; interface GeminiHooksConfig {
[otherKeys: string]: unknown; [eventName: string]: GeminiHookGroup[];
} }
// --------------------------------------------------------------------------- /** Full ~/.gemini/settings.json structure (partial — we only care about hooks) */
// Constants 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_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 * These events are processed by hookCommand() in src/cli/hook-command.ts,
* (model-level events fire per-LLM-call — too chatty for observation capture). * 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 { const GEMINI_EVENT_TO_INTERNAL_EVENT: Record<string, string> = {
claudeMemEvent: string; 'SessionStart': 'context',
description: string; 'BeforeAgent': 'user-message',
} 'AfterAgent': 'observation',
'BeforeTool': 'observation',
const GEMINI_EVENTS: Record<string, GeminiEventConfig> = { 'AfterTool': 'observation',
'SessionStart': { claudeMemEvent: 'context', description: 'Inject memory context from past sessions' }, 'PreCompress': 'summarize',
'BeforeAgent': { claudeMemEvent: 'session-init', description: 'Initialize session and capture user prompt' }, 'Notification': 'observation',
'AfterAgent': { claudeMemEvent: 'observation', description: 'Capture full agent response' }, 'SessionEnd': 'session-complete',
'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' },
}; };
// --------------------------------------------------------------------------- // ============================================================================
// Deep Merge for Hook Arrays // Hook Command Builder
// --------------------------------------------------------------------------- // ============================================================================
/**
* 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
// ---------------------------------------------------------------------------
/** /**
* Build the hook command string for a given Gemini CLI event. * 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 escapedBunPath = bunPath.replace(/\\/g, '\\\\');
const escapedWorkerPath = workerServicePath.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. * Create a hook group entry for a Gemini CLI event.
* Deep-merges with existing configuration — never overwrites. * 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 * @returns 0 on success, 1 on failure
*/ */
@@ -162,80 +284,47 @@ export async function installGeminiCliHooks(): Promise<number> {
console.log(` Worker service: ${workerServicePath}`); console.log(` Worker service: ${workerServicePath}`);
try { try {
// Ensure ~/.gemini exists // Build hook commands for all mapped events
mkdirSync(GEMINI_DIR, { recursive: true }); const hooksConfig: GeminiHooksConfig = {};
for (const geminiEvent of Object.keys(GEMINI_EVENT_TO_INTERNAL_EVENT)) {
// Read existing settings (deep merge, never overwrite) const command = buildHookCommand(bunPath, workerServicePath, geminiEvent);
let settings: GeminiSettingsJson = {}; hooksConfig[geminiEvent] = [createHookGroup(command)];
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 = {};
}
} }
// Initialize hooks object if missing // Read existing settings and merge
if (!settings.hooks) { const existingSettings = readGeminiSettings();
settings.hooks = {}; 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(` console.log(`
Installation complete! (8 hooks registered) Installation complete!
Hooks installed to: ${GEMINI_SETTINGS_PATH} Hooks installed to: ${GEMINI_SETTINGS_PATH}
Using unified CLI: bun worker-service.cjs hook gemini-cli <event> 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: 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 2. Restart Gemini CLI to load the hooks
3. Memory capture is now automatic! 3. Memory will be captured automatically during sessions
Context Injection: Context Injection:
Memory from past sessions is injected via hookSpecificOutput.additionalContext Context from past sessions is injected via ~/.gemini/GEMINI.md
on SessionStart, and persisted in ${GEMINI_MD_PATH} for static context. and automatically included in Gemini CLI conversations.
`); `);
return 0; return 0;
@@ -245,48 +334,10 @@ Context Injection:
} }
} }
// ---------------------------------------------------------------------------
// Context Injection (GEMINI.md)
// ---------------------------------------------------------------------------
/** /**
* Inject claude-mem context section into ~/.gemini/GEMINI.md. * Uninstall claude-mem hooks from ~/.gemini/settings.json.
* Uses the same <claude-mem-context> tag pattern as CLAUDE.md. *
* Preserves any existing user content outside the tags. * Removes only claude-mem hooks — other hooks and settings are preserved.
*/
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.
* *
* @returns 0 on success, 1 on failure * @returns 0 on success, 1 on failure
*/ */
@@ -295,44 +346,31 @@ export function uninstallGeminiCliHooks(): number {
try { try {
if (!existsSync(GEMINI_SETTINGS_PATH)) { 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; return 0;
} }
let settings: GeminiSettingsJson; const settings = readGeminiSettings();
try {
settings = JSON.parse(readFileSync(GEMINI_SETTINGS_PATH, 'utf-8'));
} catch {
console.error(' Could not parse settings.json');
return 1;
}
if (!settings.hooks) { 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; return 0;
} }
let removedCount = 0; let removedCount = 0;
// Remove claude-mem hooks from each event // Remove claude-mem hooks from each event
for (const eventName of Object.keys(settings.hooks)) { for (const [eventName, groups] of Object.entries(settings.hooks)) {
const matchers = settings.hooks[eventName]; const filteredGroups = groups.filter(group =>
if (!Array.isArray(matchers)) continue; !group.hooks.some(hook => hook.name === HOOK_NAME)
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,
); );
// Clean up empty event arrays if (filteredGroups.length < groups.length) {
if (settings.hooks[eventName].length === 0) { removedCount += groups.length - filteredGroups.length;
}
if (filteredGroups.length > 0) {
settings.hooks[eventName] = filteredGroups;
} else {
delete settings.hooks[eventName]; delete settings.hooks[eventName];
} }
} }
@@ -342,15 +380,22 @@ export function uninstallGeminiCliHooks(): number {
delete settings.hooks; delete settings.hooks;
} }
writeFileSync(GEMINI_SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n'); writeGeminiSettings(settings);
console.log(` Removed ${removedCount} claude-mem hook(s) from settings.json`); console.log(` Removed ${removedCount} claude-mem hook group(s) from ${GEMINI_SETTINGS_PATH}`);
// Remove context section from GEMINI.md // Remove claude-mem context section from GEMINI.md
removeGeminiMdContext(); if (existsSync(GEMINI_MD_PATH)) {
let mdContent = readFileSync(GEMINI_MD_PATH, 'utf-8');
console.log('\nUninstallation complete!'); const contextRegex = /\n?<claude-mem-context>[\s\S]*?<\/claude-mem-context>\n?/;
console.log('Restart Gemini CLI to apply changes.\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; return 0;
} catch (error) { } catch (error) {
console.error(`\nUninstallation failed: ${(error as Error).message}`); 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. * Check Gemini CLI hooks installation status.
* *
@@ -407,64 +412,93 @@ export function checkGeminiCliHooksStatus(): number {
console.log('\nClaude-Mem Gemini CLI Hooks Status\n'); console.log('\nClaude-Mem Gemini CLI Hooks Status\n');
if (!existsSync(GEMINI_SETTINGS_PATH)) { if (!existsSync(GEMINI_SETTINGS_PATH)) {
console.log('Status: Not installed'); console.log('Gemini CLI settings: Not found');
console.log(` No settings file at ${GEMINI_SETTINGS_PATH}`); console.log(` Expected at: ${GEMINI_SETTINGS_PATH}\n`);
console.log('\nRun: npx claude-mem install --ide gemini-cli\n'); console.log('No hooks installed. Run: claude-mem install --ide gemini-cli\n');
return 0; return 0;
} }
try { const settings = readGeminiSettings();
const settings: GeminiSettingsJson = JSON.parse(readFileSync(GEMINI_SETTINGS_PATH, 'utf-8'));
if (!settings.hooks) { if (!settings.hooks) {
console.log('Status: Not installed'); console.log('Gemini CLI settings: Found, but no hooks configured\n');
console.log(' settings.json exists but has no hooks section.'); console.log('No hooks installed. Run: claude-mem install --ide gemini-cli\n');
return 0; 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[] = []; if (installedEvents.length === 0) {
for (const [eventName, matchers] of Object.entries(settings.hooks)) { console.log('Gemini CLI settings: Found, but no claude-mem hooks\n');
if (!Array.isArray(matchers)) continue; console.log('Run: claude-mem install --ide gemini-cli\n');
for (const matcher of matchers) { return 0;
if (matcher.hooks?.some((h: GeminiHookEntry) => h.name === HOOK_NAME)) { }
installedEvents.push(eventName);
}
}
}
if (installedEvents.length === 0) { console.log(`Settings: ${GEMINI_SETTINGS_PATH}`);
console.log('Status: Not installed'); console.log(`Mode: Unified CLI (bun worker-service.cjs hook gemini-cli)`);
console.log(' settings.json exists but no claude-mem hooks found.'); 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 { } else {
console.log('Status: Installed'); console.log('Context: GEMINI.md exists but missing claude-mem section');
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.');
}
} }
} catch { } else {
console.log('Status: Unknown'); console.log('Context: No GEMINI.md found');
console.log(' Could not parse settings.json.');
} }
console.log(''); console.log('');
return 0; 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 { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { logger } from '../../utils/logger.js'; import { logger } from '../../utils/logger.js';
import { findMcpServerPath } from './CursorHooksInstaller.js'; import { findMcpServerPath } from './CursorHooksInstaller.js';
import { readJsonSafe } from '../../utils/json-utils.js';
import { injectContextIntoMarkdownFile } from '../../utils/context-injection.js';
// ============================================================================ // ============================================================================
// Shared Constants // Shared Constants
// ============================================================================ // ============================================================================
const CONTEXT_TAG_OPEN = '<claude-mem-context>';
const CONTEXT_TAG_CLOSE = '</claude-mem-context>';
const PLACEHOLDER_CONTEXT = `# claude-mem: Cross-Session Memory const PLACEHOLDER_CONTEXT = `# claude-mem: Cross-Session Memory
*No context yet. Complete your first session and context will appear here.* *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[] } { function buildMcpServerEntry(mcpServerPath: string): { command: string; args: string[] } {
return { return {
command: 'node', command: process.execPath,
args: [mcpServerPath], 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. * Write a standard MCP JSON config file, merging with existing config.
* Supports both { "mcpServers": { ... } } and { "servers": { ... } } formats. * 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. * Configuration for a JSON-based MCP IDE integration.
* Copilot CLI uses ~/.github/copilot/mcp.json for user-level MCP config.
*/ */
function getCopilotCliMcpConfigPath(): string { interface McpInstallerConfig {
return path.join(homedir(), '.github', 'copilot', 'mcp.json'); 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. * Factory function that creates an MCP installer for any JSON-config-based IDE.
* Copilot reads instructions from .github/copilot-instructions.md in the workspace. * Handles MCP config writing and optional context injection.
*/ */
function getCopilotCliContextPath(): string { function installMcpIntegration(config: McpInstallerConfig): () => Promise<number> {
return path.join(process.cwd(), '.github', 'copilot-instructions.md'); return async (): Promise<number> => {
} console.log(`\nInstalling Claude-Mem MCP integration for ${config.ideLabel}...\n`);
/** const mcpServerPath = findMcpServerPath();
* Install claude-mem MCP integration for Copilot CLI. if (!mcpServerPath) {
* console.error('Could not find MCP server script');
* - Writes MCP config to ~/.github/copilot/mcp.json console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/mcp-server.cjs');
* - Injects context into .github/copilot-instructions.md in the workspace return 1;
* }
* @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(); try {
if (!mcpServerPath) { // Write MCP config
console.error('Could not find MCP server script'); const configPath = config.configPath;
console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/mcp-server.cjs');
return 1;
}
try { // Warp special case: skip config write if ~/.warp/ doesn't exist
// Write MCP config — Copilot CLI uses { "servers": { ... } } format if (config.ideId === 'warp' && !existsSync(path.dirname(configPath))) {
const configPath = getCopilotCliMcpConfigPath(); console.log(` Note: ~/.warp/ not found. MCP may need to be configured via Warp Drive UI.`);
writeMcpJsonConfig(configPath, mcpServerPath, 'servers'); } else {
console.log(` MCP config written to: ${configPath}`); writeMcpJsonConfig(configPath, mcpServerPath, config.configKey);
console.log(` MCP config written to: ${configPath}`);
}
// Inject context into workspace instructions // Inject context if configured
const contextPath = getCopilotCliContextPath(); let contextPath: string | undefined;
injectContextIntoMarkdownFile(contextPath, PLACEHOLDER_CONTEXT); if (config.contextFile) {
console.log(` Context placeholder written to: ${contextPath}`); contextPath = config.contextFile.path;
injectContextIntoMarkdownFile(contextPath, PLACEHOLDER_CONTEXT);
console.log(` Context placeholder written to: ${contextPath}`);
}
console.log(` // Print summary
Installation complete! 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} return 0;
Context: ${contextPath} } catch (error) {
console.error(`\nInstallation failed: ${(error as Error).message}`);
Note: This is an MCP-only integration providing search tools and context. return 1;
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;
}
} }
// ============================================================================ // ============================================================================
// Antigravity // Factory Configs for JSON-based IDEs
// ============================================================================ // ============================================================================
/** const COPILOT_CLI_CONFIG: McpInstallerConfig = {
* Get the Antigravity MCP config path. ideId: 'copilot-cli',
* Antigravity stores MCP config at ~/.gemini/antigravity/mcp_config.json. ideLabel: 'Copilot CLI',
*/ configPath: path.join(homedir(), '.github', 'copilot', 'mcp.json'),
function getAntigravityMcpConfigPath(): string { configKey: 'servers',
return path.join(homedir(), '.gemini', 'antigravity', 'mcp_config.json'); contextFile: {
} path: path.join(process.cwd(), '.github', 'copilot-instructions.md'),
isWorkspaceRelative: true,
},
};
/** const ANTIGRAVITY_CONFIG: McpInstallerConfig = {
* Get the Antigravity context injection path for the current workspace. ideId: 'antigravity',
* Antigravity reads agent rules from .agent/rules/ in the workspace. ideLabel: 'Antigravity',
*/ configPath: path.join(homedir(), '.gemini', 'antigravity', 'mcp_config.json'),
function getAntigravityContextPath(): string { configKey: 'mcpServers',
return path.join(process.cwd(), '.agent', 'rules', 'claude-mem-context.md'); contextFile: {
} path: path.join(process.cwd(), '.agent', 'rules', 'claude-mem-context.md'),
isWorkspaceRelative: true,
},
};
/** const CRUSH_CONFIG: McpInstallerConfig = {
* Install claude-mem MCP integration for Antigravity. ideId: 'crush',
* ideLabel: 'Crush',
* - Writes MCP config to ~/.gemini/antigravity/mcp_config.json configPath: path.join(homedir(), '.config', 'crush', 'mcp.json'),
* - Injects context into .agent/rules/claude-mem-context.md in the workspace configKey: 'mcpServers',
* };
* @returns 0 on success, 1 on failure
*/
export async function installAntigravityMcpIntegration(): Promise<number> {
console.log('\nInstalling Claude-Mem MCP integration for Antigravity...\n');
const mcpServerPath = findMcpServerPath(); const ROO_CODE_CONFIG: McpInstallerConfig = {
if (!mcpServerPath) { ideId: 'roo-code',
console.error('Could not find MCP server script'); ideLabel: 'Roo Code',
console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/mcp-server.cjs'); configPath: path.join(process.cwd(), '.roo', 'mcp.json'),
return 1; configKey: 'mcpServers',
} contextFile: {
path: path.join(process.cwd(), '.roo', 'rules', 'claude-mem-context.md'),
isWorkspaceRelative: true,
},
};
try { const WARP_CONFIG: McpInstallerConfig = {
// Write MCP config ideId: 'warp',
const configPath = getAntigravityMcpConfigPath(); ideLabel: 'Warp',
writeMcpJsonConfig(configPath, mcpServerPath); configPath: path.join(homedir(), '.warp', 'mcp.json'),
console.log(` MCP config written to: ${configPath}`); configKey: 'mcpServers',
contextFile: {
// Inject context into workspace rules path: path.join(process.cwd(), 'WARP.md'),
const contextPath = getAntigravityContextPath(); isWorkspaceRelative: true,
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;
}
}
// ============================================================================ // ============================================================================
// Goose // Goose (YAML-based — separate handler)
// ============================================================================ // ============================================================================
/** /**
@@ -290,7 +238,7 @@ function buildGooseMcpYamlBlock(mcpServerPath: string): string {
return [ return [
'mcpServers:', 'mcpServers:',
' claude-mem:', ' claude-mem:',
' command: node', ` command: ${process.execPath}`,
' args:', ' args:',
` - ${mcpServerPath}`, ` - ${mcpServerPath}`,
].join('\n'); ].join('\n');
@@ -302,7 +250,7 @@ function buildGooseMcpYamlBlock(mcpServerPath: string): string {
function buildGooseClaudeMemEntryYaml(mcpServerPath: string): string { function buildGooseClaudeMemEntryYaml(mcpServerPath: string): string {
return [ return [
' claude-mem:', ' claude-mem:',
' command: node', ` command: ${process.execPath}`,
' args:', ' args:',
` - ${mcpServerPath}`, ` - ${mcpServerPath}`,
].join('\n'); ].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) // Unified Installer (used by npx install command)
// ============================================================================ // ============================================================================
@@ -601,10 +349,10 @@ Next steps:
* Used by the install command to dispatch to the correct integration. * Used by the install command to dispatch to the correct integration.
*/ */
export const MCP_IDE_INSTALLERS: Record<string, () => Promise<number>> = { export const MCP_IDE_INSTALLERS: Record<string, () => Promise<number>> = {
'copilot-cli': installCopilotCliMcpIntegration, 'copilot-cli': installMcpIntegration(COPILOT_CLI_CONFIG),
'antigravity': installAntigravityMcpIntegration, 'antigravity': installMcpIntegration(ANTIGRAVITY_CONFIG),
'goose': installGooseMcpIntegration, 'goose': installGooseMcpIntegration,
'crush': installCrushMcpIntegration, 'crush': installMcpIntegration(CRUSH_CONFIG),
'roo-code': installRooCodeMcpIntegration, 'roo-code': installMcpIntegration(ROO_CODE_CONFIG),
'warp': installWarpMcpIntegration, 'warp': installMcpIntegration(WARP_CONFIG),
}; };
+2 -32
View File
@@ -18,6 +18,7 @@ import path from 'path';
import { homedir } from 'os'; import { homedir } from 'os';
import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, unlinkSync } from 'fs'; import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, unlinkSync } from 'fs';
import { logger } from '../../utils/logger.js'; import { logger } from '../../utils/logger.js';
import { CONTEXT_TAG_OPEN, CONTEXT_TAG_CLOSE, injectContextIntoMarkdownFile } from '../../utils/context-injection.js';
// ============================================================================ // ============================================================================
// Path Resolution // Path Resolution
@@ -125,9 +126,6 @@ export function installOpenCodePlugin(): number {
// Context Injection (AGENTS.md) // 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. * 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 { export function injectContextIntoAgentsMd(contextContent: string): number {
const agentsMdPath = getOpenCodeAgentsMdPath(); const agentsMdPath = getOpenCodeAgentsMdPath();
const wrappedContent = `${CONTEXT_TAG_OPEN}\n${contextContent}\n${CONTEXT_TAG_CLOSE}`;
try { try {
const configDirectory = getOpenCodeConfigDirectory(); injectContextIntoMarkdownFile(agentsMdPath, contextContent, '# Claude-Mem Memory Context');
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');
}
logger.info('OPENCODE', 'Context injected into AGENTS.md', { path: agentsMdPath }); logger.info('OPENCODE', 'Context injected into AGENTS.md', { path: agentsMdPath });
return 0; return 0;
} catch (error) { } catch (error) {
+42 -2
View File
@@ -101,6 +101,9 @@ import {
updateCursorContextForProject, updateCursorContextForProject,
handleCursorCommand handleCursorCommand
} from './integrations/CursorHooksInstaller.js'; } from './integrations/CursorHooksInstaller.js';
import {
handleGeminiCliCommand
} from './integrations/GeminiCliHooksInstaller.js';
// Service layer imports // Service layer imports
import { DatabaseManager } from './worker/DatabaseManager.js'; 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) // Process management for zombie cleanup (Issue #737)
import { startOrphanReaper, reapOrphanedProcesses, getProcessBySession, ensureProcessExit } from './worker/ProcessRegistry.js'; 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. * Build JSON status output for hook framework communication.
* This is a pure function extracted for testability. * This is a pure function extracted for testability.
@@ -189,6 +196,9 @@ export class WorkerService {
// Stale session reaper interval (Issue #1168) // Stale session reaper interval (Issue #1168)
private staleSessionReaperInterval: ReturnType<typeof setInterval> | null = null; 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 // AI interaction tracking for health endpoint
private lastAiInteraction: { private lastAiInteraction: {
timestamp: number; timestamp: number;
@@ -421,6 +431,22 @@ export class WorkerService {
this.resolveInitialization(); this.resolveInitialization();
logger.info('SYSTEM', 'Core initialization complete (DB + search ready)'); 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) // Auto-backfill Chroma for all projects if out of sync with SQLite (fire-and-forget)
if (this.chromaMcpManager) { if (this.chromaMcpManager) {
ChromaSync.backfillAllProjects().then(() => { ChromaSync.backfillAllProjects().then(() => {
@@ -922,6 +948,13 @@ export class WorkerService {
this.staleSessionReaperInterval = null; this.staleSessionReaperInterval = null;
} }
// Stop transcript watcher
if (this.transcriptWatcher) {
this.transcriptWatcher.stop();
this.transcriptWatcher = null;
logger.info('SYSTEM', 'Transcript watcher stopped');
}
await performGracefulShutdown({ await performGracefulShutdown({
server: this.server.getHttpServer(), server: this.server.getHttpServer(),
sessionManager: this.sessionManager, sessionManager: this.sessionManager,
@@ -1174,14 +1207,21 @@ async function main() {
break; break;
} }
case 'gemini-cli': {
const geminiSubcommand = process.argv[3];
const geminiResult = await handleGeminiCliCommand(geminiSubcommand, process.argv.slice(4));
process.exit(geminiResult);
break;
}
case 'hook': { case 'hook': {
// Validate CLI args first (before any I/O) // Validate CLI args first (before any I/O)
const platform = process.argv[3]; const platform = process.argv[3];
const event = process.argv[4]; const event = process.argv[4];
if (!platform || !event) { if (!platform || !event) {
console.error('Usage: claude-mem hook <platform> <event>'); console.error('Usage: claude-mem hook <platform> <event>');
console.error('Platforms: claude-code, cursor, raw'); console.error('Platforms: claude-code, cursor, gemini-cli, raw');
console.error('Events: context, session-init, observation, summarize, session-complete'); console.error('Events: context, session-init, observation, summarize, session-complete, user-message');
process.exit(1); 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;
}
}