Merge remote-tracking branch 'origin/thedotmack/npx-gemini-cli' into thedotmack/npx-gemini-cli

Resolve merge conflicts in adapter index, gemini-cli adapter, and rebuilt CJS artifacts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-04-03 13:47:49 -07:00
53 changed files with 5248 additions and 5626 deletions
@@ -0,0 +1,373 @@
/**
* CodexCliInstaller - Codex CLI integration for claude-mem
*
* Uses transcript-only watching (no notify hook). The watcher infrastructure
* already exists in src/services/transcripts/. This installer:
*
* 1. Writes/merges transcript-watch config to ~/.claude-mem/transcript-watch.json
* 2. Sets up watch for ~/.codex/sessions/**\/*.jsonl using existing watcher
* 3. Injects context via ~/.codex/AGENTS.md (Codex reads this natively)
*
* Anti-patterns:
* - Does NOT add notify hooks -- transcript watching is sufficient
* - Does NOT modify existing transcript watcher infrastructure
* - Does NOT overwrite existing transcript-watch.json -- merges only
*/
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 {
DEFAULT_CONFIG_PATH,
DEFAULT_STATE_PATH,
SAMPLE_CONFIG,
} from '../transcripts/config.js';
import type { TranscriptWatchConfig, WatchTarget } from '../transcripts/types.js';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CODEX_DIR = path.join(homedir(), '.codex');
const CODEX_AGENTS_MD_PATH = path.join(CODEX_DIR, 'AGENTS.md');
const CLAUDE_MEM_DIR = path.join(homedir(), '.claude-mem');
/**
* The watch name used to identify the Codex CLI entry in transcript-watch.json.
* Must match the name in SAMPLE_CONFIG for merging to work correctly.
*/
const CODEX_WATCH_NAME = 'codex';
// ---------------------------------------------------------------------------
// Transcript Watch Config Merging
// ---------------------------------------------------------------------------
/**
* Load existing transcript-watch.json, or return an empty config scaffold.
* Never throws -- returns a valid empty config on any parse error.
*/
function loadExistingTranscriptWatchConfig(): TranscriptWatchConfig {
const configPath = DEFAULT_CONFIG_PATH;
if (!existsSync(configPath)) {
return { version: 1, schemas: {}, watches: [], stateFile: DEFAULT_STATE_PATH };
}
try {
const raw = readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(raw) as TranscriptWatchConfig;
// Ensure required fields exist
if (!parsed.version) parsed.version = 1;
if (!parsed.watches) parsed.watches = [];
if (!parsed.schemas) parsed.schemas = {};
if (!parsed.stateFile) parsed.stateFile = DEFAULT_STATE_PATH;
return parsed;
} catch (parseError) {
logger.error('CODEX', 'Corrupt transcript-watch.json, creating backup', { path: configPath }, parseError as Error);
// Back up corrupt file
const backupPath = `${configPath}.backup.${Date.now()}`;
writeFileSync(backupPath, readFileSync(configPath));
console.warn(` Backed up corrupt transcript-watch.json to ${backupPath}`);
return { version: 1, schemas: {}, watches: [], stateFile: DEFAULT_STATE_PATH };
}
}
/**
* Merge Codex watch configuration into existing transcript-watch.json.
*
* - If a watch with name 'codex' already exists, it is replaced in-place.
* - If the 'codex' schema already exists, it is replaced in-place.
* - All other watches and schemas are preserved untouched.
*/
function mergeCodexWatchConfig(existingConfig: TranscriptWatchConfig): TranscriptWatchConfig {
const merged = { ...existingConfig };
// Merge schemas: add/replace the codex schema
merged.schemas = { ...merged.schemas };
const codexSchema = SAMPLE_CONFIG.schemas?.[CODEX_WATCH_NAME];
if (codexSchema) {
merged.schemas[CODEX_WATCH_NAME] = codexSchema;
}
// Merge watches: add/replace the codex watch entry
const codexWatchFromSample = SAMPLE_CONFIG.watches.find(
(w: WatchTarget) => w.name === CODEX_WATCH_NAME,
);
if (codexWatchFromSample) {
const existingWatchIndex = merged.watches.findIndex(
(w: WatchTarget) => w.name === CODEX_WATCH_NAME,
);
if (existingWatchIndex !== -1) {
// Replace existing codex watch in-place
merged.watches[existingWatchIndex] = codexWatchFromSample;
} else {
// Append new codex watch
merged.watches.push(codexWatchFromSample);
}
}
return merged;
}
/**
* Write the merged transcript-watch.json config atomically.
*/
function writeTranscriptWatchConfig(config: TranscriptWatchConfig): void {
mkdirSync(CLAUDE_MEM_DIR, { recursive: true });
writeFileSync(DEFAULT_CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
}
// ---------------------------------------------------------------------------
// Context Injection (AGENTS.md)
// ---------------------------------------------------------------------------
/**
* Inject claude-mem context section into ~/.codex/AGENTS.md.
* Uses the same <claude-mem-context> tag pattern as CLAUDE.md and GEMINI.md.
* Preserves any existing user content outside the tags.
*/
function injectCodexAgentsMdContext(): void {
try {
mkdirSync(CODEX_DIR, { recursive: true });
let existingContent = '';
if (existsSync(CODEX_AGENTS_MD_PATH)) {
existingContent = readFileSync(CODEX_AGENTS_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(CODEX_AGENTS_MD_PATH, finalContent);
console.log(` Injected context placeholder into ${CODEX_AGENTS_MD_PATH}`);
} catch (error) {
// Non-fatal -- transcript watching still works without context injection
logger.warn('CODEX', 'Failed to inject AGENTS.md context', { error: (error as Error).message });
console.warn(` Warning: Could not inject context into AGENTS.md: ${(error as Error).message}`);
}
}
/**
* Remove claude-mem context section from AGENTS.md.
* Preserves user content outside the <claude-mem-context> tags.
*/
function removeCodexAgentsMdContext(): void {
try {
if (!existsSync(CODEX_AGENTS_MD_PATH)) return;
const content = readFileSync(CODEX_AGENTS_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(CODEX_AGENTS_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(CODEX_AGENTS_MD_PATH, '');
}
console.log(` Removed context section from ${CODEX_AGENTS_MD_PATH}`);
} catch (error) {
logger.warn('CODEX', 'Failed to clean AGENTS.md context', { error: (error as Error).message });
}
}
// ---------------------------------------------------------------------------
// Public API: Install
// ---------------------------------------------------------------------------
/**
* Install Codex CLI integration for claude-mem.
*
* 1. Merges Codex transcript-watch config into ~/.claude-mem/transcript-watch.json
* 2. Injects context placeholder into ~/.codex/AGENTS.md
*
* @returns 0 on success, 1 on failure
*/
export async function installCodexCli(): Promise<number> {
console.log('\nInstalling Claude-Mem for Codex CLI (transcript watching)...\n');
try {
// Step 1: Merge transcript-watch config
const existingConfig = loadExistingTranscriptWatchConfig();
const mergedConfig = mergeCodexWatchConfig(existingConfig);
writeTranscriptWatchConfig(mergedConfig);
console.log(` Updated ${DEFAULT_CONFIG_PATH}`);
console.log(` Watch path: ~/.codex/sessions/**/*.jsonl`);
console.log(` Schema: codex (v${SAMPLE_CONFIG.schemas?.codex?.version ?? '?'})`);
// Step 2: Inject context into AGENTS.md
injectCodexAgentsMdContext();
console.log(`
Installation complete!
Transcript watch config: ${DEFAULT_CONFIG_PATH}
Context file: ${CODEX_AGENTS_MD_PATH}
How it works:
- claude-mem watches Codex session JSONL files for new activity
- No hooks needed -- transcript watching is fully automatic
- Context from past sessions is injected via ${CODEX_AGENTS_MD_PATH}
Next steps:
1. Start claude-mem worker: npx claude-mem start
2. Use Codex CLI as usual -- memory capture is automatic!
`);
return 0;
} catch (error) {
console.error(`\nInstallation failed: ${(error as Error).message}`);
return 1;
}
}
// ---------------------------------------------------------------------------
// Public API: Uninstall
// ---------------------------------------------------------------------------
/**
* Remove Codex CLI integration from claude-mem.
*
* 1. Removes the codex watch and schema from transcript-watch.json (preserves others)
* 2. Removes context section from AGENTS.md (preserves user content)
*
* @returns 0 on success, 1 on failure
*/
export function uninstallCodexCli(): number {
console.log('\nUninstalling Claude-Mem Codex CLI integration...\n');
try {
// Step 1: Remove codex watch from transcript-watch.json
if (existsSync(DEFAULT_CONFIG_PATH)) {
const config = loadExistingTranscriptWatchConfig();
// Remove codex watch
config.watches = config.watches.filter(
(w: WatchTarget) => w.name !== CODEX_WATCH_NAME,
);
// Remove codex schema
if (config.schemas) {
delete config.schemas[CODEX_WATCH_NAME];
}
writeTranscriptWatchConfig(config);
console.log(` Removed codex watch from ${DEFAULT_CONFIG_PATH}`);
} else {
console.log(' No transcript-watch.json found -- nothing to remove.');
}
// Step 2: Remove context section from AGENTS.md
removeCodexAgentsMdContext();
console.log('\nUninstallation complete!');
console.log('Restart claude-mem worker to apply changes.\n');
return 0;
} catch (error) {
console.error(`\nUninstallation failed: ${(error as Error).message}`);
return 1;
}
}
// ---------------------------------------------------------------------------
// Public API: Status Check
// ---------------------------------------------------------------------------
/**
* Check Codex CLI integration status.
*
* @returns 0 always (informational)
*/
export function checkCodexCliStatus(): number {
console.log('\nClaude-Mem Codex CLI Integration Status\n');
// Check transcript-watch.json
if (!existsSync(DEFAULT_CONFIG_PATH)) {
console.log('Status: Not installed');
console.log(` No transcript watch config at ${DEFAULT_CONFIG_PATH}`);
console.log('\nRun: npx claude-mem install --ide codex-cli\n');
return 0;
}
try {
const config = loadExistingTranscriptWatchConfig();
const codexWatch = config.watches.find(
(w: WatchTarget) => w.name === CODEX_WATCH_NAME,
);
const codexSchema = config.schemas?.[CODEX_WATCH_NAME];
if (!codexWatch) {
console.log('Status: Not installed');
console.log(' transcript-watch.json exists but no codex watch configured.');
console.log('\nRun: npx claude-mem install --ide codex-cli\n');
return 0;
}
console.log('Status: Installed');
console.log(` Config: ${DEFAULT_CONFIG_PATH}`);
console.log(` Watch path: ${codexWatch.path}`);
console.log(` Schema: ${codexSchema ? `codex (v${codexSchema.version ?? '?'})` : 'missing'}`);
console.log(` Start at end: ${codexWatch.startAtEnd ?? false}`);
// Check context config
if (codexWatch.context) {
console.log(` Context mode: ${codexWatch.context.mode}`);
console.log(` Context path: ${codexWatch.context.path ?? 'default'}`);
console.log(` Context updates on: ${codexWatch.context.updateOn?.join(', ') ?? 'none'}`);
}
// Check AGENTS.md
if (existsSync(CODEX_AGENTS_MD_PATH)) {
const mdContent = readFileSync(CODEX_AGENTS_MD_PATH, 'utf-8');
if (mdContent.includes('<claude-mem-context>')) {
console.log(` Context: Active (${CODEX_AGENTS_MD_PATH})`);
} else {
console.log(` Context: AGENTS.md exists but no context tags`);
}
} else {
console.log(` Context: No AGENTS.md file`);
}
// Check if ~/.codex/sessions exists (indicates Codex has been used)
const sessionsDir = path.join(CODEX_DIR, 'sessions');
if (existsSync(sessionsDir)) {
console.log(` Sessions directory: exists`);
} else {
console.log(` Sessions directory: not yet created (use Codex CLI to generate sessions)`);
}
} catch {
console.log('Status: Unknown');
console.log(' Could not parse transcript-watch.json.');
}
console.log('');
return 0;
}
@@ -133,9 +133,7 @@ export function findMcpServerPath(): string | null {
const possiblePaths = [
// Marketplace install location
path.join(MARKETPLACE_ROOT, 'plugin', 'scripts', 'mcp-server.cjs'),
// Development/source location (relative to built worker-service.cjs in plugin/scripts/)
path.join(path.dirname(__filename), 'mcp-server.cjs'),
// Alternative dev location
// Development/source location
path.join(process.cwd(), 'plugin', 'scripts', 'mcp-server.cjs'),
];
@@ -155,9 +153,7 @@ export function findWorkerServicePath(): string | null {
const possiblePaths = [
// Marketplace install location
path.join(MARKETPLACE_ROOT, 'plugin', 'scripts', 'worker-service.cjs'),
// Development/source location (relative to built worker-service.cjs in plugin/scripts/)
path.join(path.dirname(__filename), 'worker-service.cjs'),
// Alternative dev location
// Development/source location
path.join(process.cwd(), 'plugin', 'scripts', 'worker-service.cjs'),
];
@@ -0,0 +1,470 @@
/**
* GeminiCliHooksInstaller - First-class Gemini CLI integration for claude-mem
*
* Installs claude-mem hooks into ~/.gemini/settings.json using deep merge
* to preserve any existing user configuration.
*
* Gemini CLI hook config format:
* {
* "hooks": {
* "AfterTool": [{
* "matcher": "*",
* "hooks": [{ "name": "claude-mem", "type": "command", "command": "...", "timeout": 5000, "description": "..." }]
* }]
* }
* }
*
* 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';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface GeminiHookEntry {
name: string;
type: 'command';
command: string;
timeout: number;
description?: string;
}
interface GeminiHookMatcher {
matcher: string;
hooks: GeminiHookEntry[];
}
interface GeminiSettingsJson {
hooks?: Record<string, GeminiHookMatcher[]>;
[otherKeys: string]: unknown;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
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;
/**
* Gemini CLI events → claude-mem internal events.
*
* We register 8 of 11 hooks. Skipped: BeforeModel, AfterModel, BeforeToolSelection
* (model-level events fire per-LLM-call — too chatty for observation capture).
*/
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' },
};
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
/**
* Build the hook command string for a given Gemini CLI event.
*
* Invokes: <bun-path> <worker-service.cjs> hook gemini-cli <event>
*/
function buildHookCommand(bunPath: string, workerServicePath: string, claudeMemEvent: string): string {
const escapedBunPath = bunPath.replace(/\\/g, '\\\\');
const escapedWorkerPath = workerServicePath.replace(/\\/g, '\\\\');
return `"${escapedBunPath}" "${escapedWorkerPath}" hook gemini-cli ${claudeMemEvent}`;
}
/**
* Install claude-mem hooks into Gemini CLI's settings.json.
* Deep-merges with existing configuration — never overwrites.
*
* @returns 0 on success, 1 on failure
*/
export async function installGeminiCliHooks(): Promise<number> {
console.log('\nInstalling Claude-Mem Gemini CLI hooks...\n');
// Find required paths
const workerServicePath = findWorkerServicePath();
if (!workerServicePath) {
console.error('Could not find worker-service.cjs');
console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/worker-service.cjs');
return 1;
}
const bunPath = findBunPath();
console.log(` Using Bun runtime: ${bunPath}`);
console.log(` Worker service: ${workerServicePath}`);
try {
// 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 = {};
}
}
// Initialize hooks object if missing
if (!settings.hooks) {
settings.hooks = {};
}
// 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)
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
2. Restart Gemini CLI to load the hooks
3. Memory capture is now automatic!
Context Injection:
Memory from past sessions is injected via hookSpecificOutput.additionalContext
on SessionStart, and persisted in ${GEMINI_MD_PATH} for static context.
`);
return 0;
} catch (error) {
console.error(`\nInstallation failed: ${(error as Error).message}`);
return 1;
}
}
// ---------------------------------------------------------------------------
// 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.
*
* @returns 0 on success, 1 on failure
*/
export function uninstallGeminiCliHooks(): number {
console.log('\nUninstalling Claude-Mem Gemini CLI hooks...\n');
try {
if (!existsSync(GEMINI_SETTINGS_PATH)) {
console.log(' No settings.json 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;
}
if (!settings.hooks) {
console.log(' No hooks configured — 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,
);
// Clean up empty event arrays
if (settings.hooks[eventName].length === 0) {
delete settings.hooks[eventName];
}
}
// Clean up empty hooks object
if (Object.keys(settings.hooks).length === 0) {
delete settings.hooks;
}
writeFileSync(GEMINI_SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
console.log(` Removed ${removedCount} claude-mem hook(s) from settings.json`);
// Remove context section from GEMINI.md
removeGeminiMdContext();
console.log('\nUninstallation complete!');
console.log('Restart Gemini CLI to apply changes.\n');
return 0;
} catch (error) {
console.error(`\nUninstallation failed: ${(error as Error).message}`);
return 1;
}
}
/**
* 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.
*
* @returns 0 always (informational)
*/
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');
return 0;
}
try {
const settings: GeminiSettingsJson = JSON.parse(readFileSync(GEMINI_SETTINGS_PATH, 'utf-8'));
if (!settings.hooks) {
console.log('Status: Not installed');
console.log(' settings.json exists but has no hooks section.');
return 0;
}
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('Status: Not installed');
console.log(' settings.json exists but no claude-mem hooks found.');
} 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.');
}
}
} catch {
console.log('Status: Unknown');
console.log(' Could not parse settings.json.');
}
console.log('');
return 0;
}
@@ -0,0 +1,610 @@
/**
* McpIntegrations - MCP-based IDE integrations for claude-mem
*
* Handles MCP config writing and context injection for IDEs that support
* the Model Context Protocol. These are "MCP-only" integrations: they provide
* search tools and context injection but do NOT capture transcripts.
*
* Supported IDEs:
* - Copilot CLI
* - Antigravity (Gemini)
* - Goose
* - Crush
* - Roo Code
* - Warp
*
* All IDEs point to the same MCP server: plugin/scripts/mcp-server.cjs
*/
import path from 'path';
import { homedir } from 'os';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { logger } from '../../utils/logger.js';
import { findMcpServerPath } from './CursorHooksInstaller.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.*
Use claude-mem's MCP search tools for manual memory queries.`;
// ============================================================================
// Shared Utilities
// ============================================================================
/**
* Build the standard MCP server entry that all IDEs use.
* Points to the same mcp-server.cjs script.
*/
function buildMcpServerEntry(mcpServerPath: string): { command: string; args: string[] } {
return {
command: 'node',
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.
*/
function writeMcpJsonConfig(
configFilePath: string,
mcpServerPath: string,
serversKeyName: string = 'mcpServers',
): void {
const parentDirectory = path.dirname(configFilePath);
mkdirSync(parentDirectory, { recursive: true });
const existingConfig = readJsonSafe<Record<string, any>>(configFilePath, {});
if (!existingConfig[serversKeyName]) {
existingConfig[serversKeyName] = {};
}
existingConfig[serversKeyName]['claude-mem'] = buildMcpServerEntry(mcpServerPath);
writeFileSync(configFilePath, JSON.stringify(existingConfig, null, 2) + '\n');
}
// ============================================================================
// Copilot CLI
// ============================================================================
/**
* Get the Copilot CLI MCP config path.
* Copilot CLI uses ~/.github/copilot/mcp.json for user-level MCP config.
*/
function getCopilotCliMcpConfigPath(): string {
return path.join(homedir(), '.github', 'copilot', 'mcp.json');
}
/**
* Get the Copilot CLI context injection path for the current workspace.
* Copilot reads instructions from .github/copilot-instructions.md in the workspace.
*/
function getCopilotCliContextPath(): string {
return path.join(process.cwd(), '.github', 'copilot-instructions.md');
}
/**
* 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;
}
try {
// Write MCP config — Copilot CLI uses { "servers": { ... } } format
const configPath = getCopilotCliMcpConfigPath();
writeMcpJsonConfig(configPath, mcpServerPath, 'servers');
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}`);
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 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
// ============================================================================
/**
* 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');
}
/**
* 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');
}
/**
* 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 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 = 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;
}
}
// ============================================================================
// Goose
// ============================================================================
/**
* Get the Goose config path.
* Goose stores its config at ~/.config/goose/config.yaml.
*/
function getGooseConfigPath(): string {
return path.join(homedir(), '.config', 'goose', 'config.yaml');
}
/**
* Check if a YAML string already has a claude-mem entry under mcpServers.
* Uses string matching to avoid needing a YAML parser.
*/
function gooseConfigHasClaudeMemEntry(yamlContent: string): boolean {
// Look for "claude-mem:" indented under mcpServers
return yamlContent.includes('claude-mem:') &&
yamlContent.includes('mcpServers:');
}
/**
* Build the Goose YAML MCP server block as a string.
* Produces properly indented YAML without needing a parser.
*/
function buildGooseMcpYamlBlock(mcpServerPath: string): string {
// Goose expects the mcpServers section at the top level
return [
'mcpServers:',
' claude-mem:',
' command: node',
' args:',
` - ${mcpServerPath}`,
].join('\n');
}
/**
* Build just the claude-mem server entry (for appending under existing mcpServers).
*/
function buildGooseClaudeMemEntryYaml(mcpServerPath: string): string {
return [
' claude-mem:',
' command: node',
' args:',
` - ${mcpServerPath}`,
].join('\n');
}
/**
* Install claude-mem MCP integration for Goose.
*
* - Writes/merges MCP config into ~/.config/goose/config.yaml
* - Uses string manipulation for YAML (no parser dependency)
*
* @returns 0 on success, 1 on failure
*/
export async function installGooseMcpIntegration(): Promise<number> {
console.log('\nInstalling Claude-Mem MCP integration for Goose...\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 {
const configPath = getGooseConfigPath();
const configDirectory = path.dirname(configPath);
mkdirSync(configDirectory, { recursive: true });
if (existsSync(configPath)) {
let yamlContent = readFileSync(configPath, 'utf-8');
if (gooseConfigHasClaudeMemEntry(yamlContent)) {
// Already configured — replace the claude-mem block
// Find the claude-mem entry and replace it
const claudeMemPattern = /( {2}claude-mem:\n(?:.*\n)*?(?= {2}\S|\n\n|$))/;
const newEntry = buildGooseClaudeMemEntryYaml(mcpServerPath) + '\n';
if (claudeMemPattern.test(yamlContent)) {
yamlContent = yamlContent.replace(claudeMemPattern, newEntry);
}
writeFileSync(configPath, yamlContent);
console.log(` Updated existing claude-mem entry in: ${configPath}`);
} else if (yamlContent.includes('mcpServers:')) {
// mcpServers section exists but no claude-mem entry — append under it
const mcpServersIndex = yamlContent.indexOf('mcpServers:');
const insertionPoint = mcpServersIndex + 'mcpServers:'.length;
const newEntry = '\n' + buildGooseClaudeMemEntryYaml(mcpServerPath);
yamlContent =
yamlContent.slice(0, insertionPoint) +
newEntry +
yamlContent.slice(insertionPoint);
writeFileSync(configPath, yamlContent);
console.log(` Added claude-mem to existing mcpServers in: ${configPath}`);
} else {
// No mcpServers section — append the entire block
const mcpBlock = '\n' + buildGooseMcpYamlBlock(mcpServerPath) + '\n';
yamlContent = yamlContent.trimEnd() + '\n' + mcpBlock;
writeFileSync(configPath, yamlContent);
console.log(` Appended mcpServers section to: ${configPath}`);
}
} else {
// File doesn't exist — create from template
const templateContent = buildGooseMcpYamlBlock(mcpServerPath) + '\n';
writeFileSync(configPath, templateContent);
console.log(` Created config with MCP server: ${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 Goose.
Next steps:
1. Start claude-mem worker: npx claude-mem start
2. Restart Goose to pick up the MCP server
`);
return 0;
} catch (error) {
console.error(`\nInstallation failed: ${(error as Error).message}`);
return 1;
}
}
// ============================================================================
// 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)
// ============================================================================
/**
* Map of IDE identifiers to their install functions.
* 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,
'goose': installGooseMcpIntegration,
'crush': installCrushMcpIntegration,
'roo-code': installRooCodeMcpIntegration,
'warp': installWarpMcpIntegration,
};
@@ -0,0 +1,430 @@
/**
* OpenClawInstaller - OpenClaw gateway integration installer for claude-mem
*
* Installs the pre-built claude-mem plugin into OpenClaw's extension directory
* and registers it in ~/.openclaw/openclaw.json.
*
* Install strategy: File-based
* - Copies the pre-built plugin from the npm package's openclaw/dist/ directory
* to ~/.openclaw/extensions/claude-mem/dist/
* - Registers the plugin in openclaw.json under plugins.entries.claude-mem
* - Sets the memory slot to claude-mem
*
* Important: The OpenClaw plugin ships pre-built from the npm package.
* It must NOT be rebuilt at install time.
*/
import path from 'path';
import { homedir } from 'os';
import {
existsSync,
readFileSync,
writeFileSync,
mkdirSync,
cpSync,
rmSync,
unlinkSync,
} from 'fs';
import { logger } from '../../utils/logger.js';
// ============================================================================
// Path Resolution
// ============================================================================
/**
* Resolve the OpenClaw config directory (~/.openclaw).
*/
export function getOpenClawConfigDirectory(): string {
return path.join(homedir(), '.openclaw');
}
/**
* Resolve the OpenClaw extensions directory where plugins are installed.
*/
export function getOpenClawExtensionsDirectory(): string {
return path.join(getOpenClawConfigDirectory(), 'extensions');
}
/**
* Resolve the claude-mem extension install directory.
*/
export function getOpenClawClaudeMemExtensionDirectory(): string {
return path.join(getOpenClawExtensionsDirectory(), 'claude-mem');
}
/**
* Resolve the path to openclaw.json config file.
*/
export function getOpenClawConfigFilePath(): string {
return path.join(getOpenClawConfigDirectory(), 'openclaw.json');
}
// ============================================================================
// Pre-built Plugin Location
// ============================================================================
/**
* Find the pre-built OpenClaw plugin bundle in the npm package.
* Searches in: openclaw/dist/index.js relative to package root,
* then the marketplace install location.
*/
export function findPreBuiltPluginDirectory(): string | null {
const possibleRoots = [
// Marketplace install location (production — after `npx claude-mem install`)
path.join(
process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), '.claude'),
'plugins', 'marketplaces', 'thedotmack',
),
// Development location (relative to project root)
process.cwd(),
];
for (const root of possibleRoots) {
const openclawDistDirectory = path.join(root, 'openclaw', 'dist');
const pluginEntryPoint = path.join(openclawDistDirectory, 'index.js');
if (existsSync(pluginEntryPoint)) {
return openclawDistDirectory;
}
}
return null;
}
/**
* Find the openclaw.plugin.json file for copying alongside the plugin.
*/
export function findPluginManifestPath(): string | null {
const possibleRoots = [
path.join(
process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), '.claude'),
'plugins', 'marketplaces', 'thedotmack',
),
process.cwd(),
];
for (const root of possibleRoots) {
const manifestPath = path.join(root, 'openclaw', 'openclaw.plugin.json');
if (existsSync(manifestPath)) {
return manifestPath;
}
}
return null;
}
/**
* Find the openclaw skills directory for copying alongside the plugin.
*/
export function findPluginSkillsDirectory(): string | null {
const possibleRoots = [
path.join(
process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), '.claude'),
'plugins', 'marketplaces', 'thedotmack',
),
process.cwd(),
];
for (const root of possibleRoots) {
const skillsDirectory = path.join(root, 'openclaw', 'skills');
if (existsSync(skillsDirectory)) {
return skillsDirectory;
}
}
return null;
}
// ============================================================================
// OpenClaw Config (openclaw.json) Management
// ============================================================================
/**
* Read openclaw.json safely, returning an empty object if missing or invalid.
*/
function readOpenClawConfig(): Record<string, any> {
const configFilePath = getOpenClawConfigFilePath();
if (!existsSync(configFilePath)) return {};
try {
return JSON.parse(readFileSync(configFilePath, 'utf-8'));
} catch {
return {};
}
}
/**
* Write openclaw.json atomically, creating the directory if needed.
*/
function writeOpenClawConfig(config: Record<string, any>): void {
const configDirectory = getOpenClawConfigDirectory();
mkdirSync(configDirectory, { recursive: true });
writeFileSync(getOpenClawConfigFilePath(), JSON.stringify(config, null, 2) + '\n', 'utf-8');
}
/**
* Register claude-mem in openclaw.json by merging into the existing config.
* Does NOT overwrite the entire file -- only touches the claude-mem entry
* and the memory slot.
*/
function registerPluginInOpenClawConfig(
workerPort: number = 37777,
project: string = 'openclaw',
syncMemoryFile: boolean = true,
): void {
const config = readOpenClawConfig();
// Ensure the plugins structure exists
if (!config.plugins) config.plugins = {};
if (!config.plugins.slots) config.plugins.slots = {};
if (!config.plugins.entries) config.plugins.entries = {};
// Set the memory slot to claude-mem
config.plugins.slots.memory = 'claude-mem';
// Create or update the claude-mem plugin entry
if (!config.plugins.entries['claude-mem']) {
config.plugins.entries['claude-mem'] = {
enabled: true,
config: {
workerPort,
project,
syncMemoryFile,
},
};
} else {
// Merge: enable and update config without losing existing user settings
config.plugins.entries['claude-mem'].enabled = true;
if (!config.plugins.entries['claude-mem'].config) {
config.plugins.entries['claude-mem'].config = {};
}
const existingPluginConfig = config.plugins.entries['claude-mem'].config;
// Only set defaults if not already configured
if (existingPluginConfig.workerPort === undefined) existingPluginConfig.workerPort = workerPort;
if (existingPluginConfig.project === undefined) existingPluginConfig.project = project;
if (existingPluginConfig.syncMemoryFile === undefined) existingPluginConfig.syncMemoryFile = syncMemoryFile;
}
writeOpenClawConfig(config);
}
/**
* Remove claude-mem from openclaw.json without deleting other config.
*/
function unregisterPluginFromOpenClawConfig(): void {
const configFilePath = getOpenClawConfigFilePath();
if (!existsSync(configFilePath)) return;
const config = readOpenClawConfig();
// Remove claude-mem entry
if (config.plugins?.entries?.['claude-mem']) {
delete config.plugins.entries['claude-mem'];
}
// Clear memory slot if it points to claude-mem
if (config.plugins?.slots?.memory === 'claude-mem') {
delete config.plugins.slots.memory;
}
writeOpenClawConfig(config);
}
// ============================================================================
// Plugin Installation
// ============================================================================
/**
* Install the claude-mem plugin into OpenClaw's extensions directory.
* Copies the pre-built plugin bundle and registers it in openclaw.json.
*
* @returns 0 on success, 1 on failure
*/
export function installOpenClawPlugin(): number {
const preBuiltDistDirectory = findPreBuiltPluginDirectory();
if (!preBuiltDistDirectory) {
console.error('Could not find pre-built OpenClaw plugin bundle.');
console.error(' Expected at: openclaw/dist/index.js');
console.error(' Ensure the npm package includes the openclaw directory.');
return 1;
}
const extensionDirectory = getOpenClawClaudeMemExtensionDirectory();
const destinationDistDirectory = path.join(extensionDirectory, 'dist');
try {
// Create the extension directory structure
mkdirSync(destinationDistDirectory, { recursive: true });
// Copy pre-built dist files
cpSync(preBuiltDistDirectory, destinationDistDirectory, { recursive: true, force: true });
console.log(` Plugin dist copied to: ${destinationDistDirectory}`);
// Copy openclaw.plugin.json if available
const manifestPath = findPluginManifestPath();
if (manifestPath) {
const destinationManifest = path.join(extensionDirectory, 'openclaw.plugin.json');
cpSync(manifestPath, destinationManifest, { force: true });
console.log(` Plugin manifest copied to: ${destinationManifest}`);
}
// Copy skills directory if available
const skillsDirectory = findPluginSkillsDirectory();
if (skillsDirectory) {
const destinationSkills = path.join(extensionDirectory, 'skills');
cpSync(skillsDirectory, destinationSkills, { recursive: true, force: true });
console.log(` Skills copied to: ${destinationSkills}`);
}
// Create a minimal package.json for the extension (OpenClaw expects this)
const extensionPackageJson = {
name: 'claude-mem',
version: '1.0.0',
type: 'module',
main: 'dist/index.js',
openclaw: { extensions: ['./dist/index.js'] },
};
writeFileSync(
path.join(extensionDirectory, 'package.json'),
JSON.stringify(extensionPackageJson, null, 2) + '\n',
'utf-8',
);
// Register in openclaw.json (merge, not overwrite)
registerPluginInOpenClawConfig();
console.log(` Registered in openclaw.json`);
logger.info('OPENCLAW', 'Plugin installed', { destination: extensionDirectory });
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Failed to install OpenClaw plugin: ${message}`);
return 1;
}
}
// ============================================================================
// Uninstallation
// ============================================================================
/**
* Remove the claude-mem plugin from OpenClaw.
* Removes extension files and unregisters from openclaw.json.
*
* @returns 0 on success, 1 on failure
*/
export function uninstallOpenClawPlugin(): number {
let hasErrors = false;
// Remove extension directory
const extensionDirectory = getOpenClawClaudeMemExtensionDirectory();
if (existsSync(extensionDirectory)) {
try {
rmSync(extensionDirectory, { recursive: true, force: true });
console.log(` Removed extension: ${extensionDirectory}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(` Failed to remove extension directory: ${message}`);
hasErrors = true;
}
}
// Unregister from openclaw.json
try {
unregisterPluginFromOpenClawConfig();
console.log(` Unregistered from openclaw.json`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(` Failed to update openclaw.json: ${message}`);
hasErrors = true;
}
return hasErrors ? 1 : 0;
}
// ============================================================================
// Status Check
// ============================================================================
/**
* Check OpenClaw integration status.
*
* @returns 0 always (informational only)
*/
export function checkOpenClawStatus(): number {
console.log('\nClaude-Mem OpenClaw Integration Status\n');
const configDirectory = getOpenClawConfigDirectory();
const extensionDirectory = getOpenClawClaudeMemExtensionDirectory();
const configFilePath = getOpenClawConfigFilePath();
const pluginEntryPoint = path.join(extensionDirectory, 'dist', 'index.js');
console.log(`Config directory: ${configDirectory}`);
console.log(` Exists: ${existsSync(configDirectory) ? 'yes' : 'no'}`);
console.log('');
console.log(`Extension directory: ${extensionDirectory}`);
console.log(` Exists: ${existsSync(extensionDirectory) ? 'yes' : 'no'}`);
console.log(` Plugin entry: ${existsSync(pluginEntryPoint) ? 'yes' : 'no'}`);
console.log('');
console.log(`Config (openclaw.json): ${configFilePath}`);
if (existsSync(configFilePath)) {
const config = readOpenClawConfig();
const isRegistered = config.plugins?.entries?.['claude-mem'] !== undefined;
const isEnabled = config.plugins?.entries?.['claude-mem']?.enabled === true;
const isMemorySlot = config.plugins?.slots?.memory === 'claude-mem';
console.log(` Exists: yes`);
console.log(` Registered: ${isRegistered ? 'yes' : 'no'}`);
console.log(` Enabled: ${isEnabled ? 'yes' : 'no'}`);
console.log(` Memory slot: ${isMemorySlot ? 'yes' : 'no'}`);
if (isRegistered) {
const pluginConfig = config.plugins.entries['claude-mem'].config;
if (pluginConfig) {
console.log(` Worker port: ${pluginConfig.workerPort ?? 'default'}`);
console.log(` Project: ${pluginConfig.project ?? 'default'}`);
console.log(` Sync MEMORY.md: ${pluginConfig.syncMemoryFile ?? 'default'}`);
}
}
} else {
console.log(` Exists: no`);
}
console.log('');
return 0;
}
// ============================================================================
// Full Install Flow (used by npx install command)
// ============================================================================
/**
* Run the full OpenClaw installation: copy plugin + register in config.
*
* @returns 0 on success, 1 on failure
*/
export async function installOpenClawIntegration(): Promise<number> {
console.log('\nInstalling Claude-Mem for OpenClaw...\n');
// Step 1: Install plugin files and register in config
const pluginResult = installOpenClawPlugin();
if (pluginResult !== 0) {
return pluginResult;
}
const extensionDirectory = getOpenClawClaudeMemExtensionDirectory();
console.log(`
Installation complete!
Plugin installed to: ${extensionDirectory}
Config updated: ${getOpenClawConfigFilePath()}
Next steps:
1. Start claude-mem worker: npx claude-mem start
2. Restart OpenClaw to load the plugin
3. Memory capture is automatic from then on
`);
return 0;
}
@@ -0,0 +1,373 @@
/**
* OpenCodeInstaller - OpenCode IDE integration installer for claude-mem
*
* Installs the claude-mem plugin into OpenCode's plugin directory and
* sets up context injection via AGENTS.md.
*
* Install strategy: File-based (Option A)
* - Copies the built plugin to the OpenCode plugins directory
* - Plugins in that directory are auto-loaded at startup
*
* Context injection:
* - Appends/updates <claude-mem-context> section in AGENTS.md
*
* Respects OPENCODE_CONFIG_DIR env var for config directory resolution.
*/
import path from 'path';
import { homedir } from 'os';
import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, unlinkSync } from 'fs';
import { logger } from '../../utils/logger.js';
// ============================================================================
// Path Resolution
// ============================================================================
/**
* Resolve the OpenCode config directory.
* Respects OPENCODE_CONFIG_DIR env var, falls back to ~/.config/opencode.
*/
export function getOpenCodeConfigDirectory(): string {
if (process.env.OPENCODE_CONFIG_DIR) {
return process.env.OPENCODE_CONFIG_DIR;
}
return path.join(homedir(), '.config', 'opencode');
}
/**
* Resolve the OpenCode plugins directory.
*/
export function getOpenCodePluginsDirectory(): string {
return path.join(getOpenCodeConfigDirectory(), 'plugins');
}
/**
* Resolve the AGENTS.md path for context injection.
*/
export function getOpenCodeAgentsMdPath(): string {
return path.join(getOpenCodeConfigDirectory(), 'AGENTS.md');
}
/**
* Resolve the path to the installed plugin file.
*/
export function getInstalledPluginPath(): string {
return path.join(getOpenCodePluginsDirectory(), 'claude-mem.js');
}
// ============================================================================
// Plugin Installation
// ============================================================================
/**
* Find the built OpenCode plugin bundle.
* Searches in: dist/opencode-plugin/index.js (built output),
* then marketplace location.
*/
export function findBuiltPluginPath(): string | null {
const possiblePaths = [
// Marketplace install location (production)
path.join(
process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), '.claude'),
'plugins', 'marketplaces', 'thedotmack',
'dist', 'opencode-plugin', 'index.js',
),
// Development location (relative to project root)
path.join(process.cwd(), 'dist', 'opencode-plugin', 'index.js'),
];
for (const candidatePath of possiblePaths) {
if (existsSync(candidatePath)) {
return candidatePath;
}
}
return null;
}
/**
* Install the claude-mem plugin into OpenCode's plugins directory.
* Copies the built plugin bundle to ~/.config/opencode/plugins/claude-mem.js
*
* @returns 0 on success, 1 on failure
*/
export function installOpenCodePlugin(): number {
const builtPluginPath = findBuiltPluginPath();
if (!builtPluginPath) {
console.error('Could not find built OpenCode plugin bundle.');
console.error(' Expected at: dist/opencode-plugin/index.js');
console.error(' Run the build first: npm run build');
return 1;
}
const pluginsDirectory = getOpenCodePluginsDirectory();
const destinationPath = getInstalledPluginPath();
try {
// Create plugins directory if needed
mkdirSync(pluginsDirectory, { recursive: true });
// Copy plugin bundle
copyFileSync(builtPluginPath, destinationPath);
console.log(` Plugin installed to: ${destinationPath}`);
logger.info('OPENCODE', 'Plugin installed', { destination: destinationPath });
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Failed to install OpenCode plugin: ${message}`);
return 1;
}
}
// ============================================================================
// 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.
*
* If the file doesn't exist, creates it with the context section.
* If the file exists, replaces the existing <claude-mem-context> section
* or appends one at the end.
*
* @param contextContent - The context content to inject (without tags)
* @returns 0 on success, 1 on failure
*/
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');
}
logger.info('OPENCODE', 'Context injected into AGENTS.md', { path: agentsMdPath });
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Failed to inject context into AGENTS.md: ${message}`);
return 1;
}
}
/**
* Sync context from the worker into OpenCode's AGENTS.md.
* Fetches context from the worker API and writes it to AGENTS.md.
*
* @param port - Worker port number
* @param project - Project name for context filtering
*/
export async function syncContextToAgentsMd(
port: number,
project: string,
): Promise<void> {
try {
const response = await fetch(
`http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(project)}`,
);
if (!response.ok) return;
const contextText = await response.text();
if (contextText && contextText.trim()) {
injectContextIntoAgentsMd(contextText);
}
} catch {
// Worker not available — non-critical
}
}
// ============================================================================
// Uninstallation
// ============================================================================
/**
* Remove the claude-mem plugin from OpenCode.
* Removes the plugin file and cleans up the AGENTS.md context section.
*
* @returns 0 on success, 1 on failure
*/
export function uninstallOpenCodePlugin(): number {
let hasErrors = false;
// Remove plugin file
const pluginPath = getInstalledPluginPath();
if (existsSync(pluginPath)) {
try {
unlinkSync(pluginPath);
console.log(` Removed plugin: ${pluginPath}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(` Failed to remove plugin: ${message}`);
hasErrors = true;
}
}
// Remove context section from AGENTS.md
const agentsMdPath = getOpenCodeAgentsMdPath();
if (existsSync(agentsMdPath)) {
try {
let content = readFileSync(agentsMdPath, 'utf-8');
const tagStartIndex = content.indexOf(CONTEXT_TAG_OPEN);
const tagEndIndex = content.indexOf(CONTEXT_TAG_CLOSE);
if (tagStartIndex !== -1 && tagEndIndex !== -1) {
content =
content.slice(0, tagStartIndex).trimEnd() +
'\n' +
content.slice(tagEndIndex + CONTEXT_TAG_CLOSE.length).trimStart();
// If the file is now essentially empty, don't bother keeping it
if (content.trim().length === 0) {
unlinkSync(agentsMdPath);
console.log(` Removed empty AGENTS.md`);
} else {
writeFileSync(agentsMdPath, content.trimEnd() + '\n', 'utf-8');
console.log(` Cleaned context from AGENTS.md`);
}
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(` Failed to clean AGENTS.md: ${message}`);
hasErrors = true;
}
}
return hasErrors ? 1 : 0;
}
// ============================================================================
// Status Check
// ============================================================================
/**
* Check OpenCode integration status.
*
* @returns 0 always (informational only)
*/
export function checkOpenCodeStatus(): number {
console.log('\nClaude-Mem OpenCode Integration Status\n');
const configDirectory = getOpenCodeConfigDirectory();
const pluginPath = getInstalledPluginPath();
const agentsMdPath = getOpenCodeAgentsMdPath();
console.log(`Config directory: ${configDirectory}`);
console.log(` Exists: ${existsSync(configDirectory) ? 'yes' : 'no'}`);
console.log('');
console.log(`Plugin: ${pluginPath}`);
console.log(` Installed: ${existsSync(pluginPath) ? 'yes' : 'no'}`);
console.log('');
console.log(`Context (AGENTS.md): ${agentsMdPath}`);
if (existsSync(agentsMdPath)) {
const content = readFileSync(agentsMdPath, 'utf-8');
const hasContextTags = content.includes(CONTEXT_TAG_OPEN);
console.log(` Exists: yes`);
console.log(` Has claude-mem context: ${hasContextTags ? 'yes' : 'no'}`);
} else {
console.log(` Exists: no`);
}
console.log('');
return 0;
}
// ============================================================================
// Full Install Flow (used by npx install command)
// ============================================================================
/**
* Run the full OpenCode installation: plugin + context injection.
*
* @returns 0 on success, 1 on failure
*/
export async function installOpenCodeIntegration(): Promise<number> {
console.log('\nInstalling Claude-Mem for OpenCode...\n');
// Step 1: Install plugin
const pluginResult = installOpenCodePlugin();
if (pluginResult !== 0) {
return pluginResult;
}
// Step 2: Create initial context in AGENTS.md
const placeholderContext = `# Memory Context from Past Sessions
*No context yet. Complete your first session and context will appear here.*
Use claude-mem search tools for manual memory queries.`;
// Try to fetch real context from worker first
try {
const healthResponse = await fetch('http://127.0.0.1:37777/api/readiness');
if (healthResponse.ok) {
const contextResponse = await fetch(
`http://127.0.0.1:37777/api/context/inject?project=opencode`,
);
if (contextResponse.ok) {
const realContext = await contextResponse.text();
if (realContext && realContext.trim()) {
injectContextIntoAgentsMd(realContext);
console.log(' Context injected from existing memory');
} else {
injectContextIntoAgentsMd(placeholderContext);
console.log(' Placeholder context created (will populate after first session)');
}
} else {
injectContextIntoAgentsMd(placeholderContext);
}
} else {
injectContextIntoAgentsMd(placeholderContext);
console.log(' Placeholder context created (worker not running)');
}
} catch {
injectContextIntoAgentsMd(placeholderContext);
console.log(' Placeholder context created (worker not running)');
}
console.log(`
Installation complete!
Plugin installed to: ${getInstalledPluginPath()}
Context file: ${getOpenCodeAgentsMdPath()}
Next steps:
1. Start claude-mem worker: npx claude-mem start
2. Restart OpenCode to load the plugin
3. Memory capture is automatic from then on
`);
return 0;
}
@@ -0,0 +1,520 @@
/**
* WindsurfHooksInstaller - Windsurf IDE integration for claude-mem
*
* Handles:
* - Windsurf hooks installation/uninstallation to ~/.codeium/windsurf/hooks.json
* - Context file generation (.windsurf/rules/claude-mem-context.md)
* - Project registry management for auto-context updates
*
* Windsurf hooks.json format:
* {
* "hooks": {
* "<event_name>": [{ "command": "...", "show_output": false, "working_directory": "..." }]
* }
* }
*
* Events registered (all post-action, non-blocking):
* - pre_user_prompt — session init + context injection
* - post_write_code — code generation observation
* - post_run_command — command execution observation
* - post_mcp_tool_use — MCP tool results
* - post_cascade_response — full AI response
*/
import path from 'path';
import { homedir } from 'os';
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, renameSync } from 'fs';
import { logger } from '../../utils/logger.js';
import { getWorkerPort } from '../../shared/worker-utils.js';
import { DATA_DIR } from '../../shared/paths.js';
import { findBunPath, findWorkerServicePath } from './CursorHooksInstaller.js';
// ============================================================================
// Types
// ============================================================================
interface WindsurfHookEntry {
command: string;
show_output: boolean;
working_directory: string;
}
interface WindsurfHooksJson {
hooks: {
[eventName: string]: WindsurfHookEntry[];
};
}
interface WindsurfProjectRegistry {
[projectName: string]: {
workspacePath: string;
installedAt: string;
};
}
// ============================================================================
// Constants
// ============================================================================
/** User-level hooks config — global coverage across all Windsurf workspaces */
const WINDSURF_HOOKS_DIR = path.join(homedir(), '.codeium', 'windsurf');
const WINDSURF_HOOKS_JSON_PATH = path.join(WINDSURF_HOOKS_DIR, 'hooks.json');
/** Windsurf context rule limit: 6,000 chars per file */
const WINDSURF_CONTEXT_CHAR_LIMIT = 6000;
/** Registry file for tracking projects with Windsurf hooks */
const WINDSURF_REGISTRY_FILE = path.join(DATA_DIR, 'windsurf-projects.json');
/** Hook events we register */
const WINDSURF_HOOK_EVENTS = [
'pre_user_prompt',
'post_write_code',
'post_run_command',
'post_mcp_tool_use',
'post_cascade_response',
] as const;
// ============================================================================
// Project Registry
// ============================================================================
/**
* Read the Windsurf project registry
*/
export function readWindsurfRegistry(): WindsurfProjectRegistry {
try {
if (!existsSync(WINDSURF_REGISTRY_FILE)) return {};
return JSON.parse(readFileSync(WINDSURF_REGISTRY_FILE, 'utf-8'));
} catch (error) {
logger.error('WINDSURF', 'Failed to read registry, using empty', {
file: WINDSURF_REGISTRY_FILE,
}, error as Error);
return {};
}
}
/**
* Write the Windsurf project registry
*/
export function writeWindsurfRegistry(registry: WindsurfProjectRegistry): void {
const dir = path.dirname(WINDSURF_REGISTRY_FILE);
mkdirSync(dir, { recursive: true });
writeFileSync(WINDSURF_REGISTRY_FILE, JSON.stringify(registry, null, 2));
}
/**
* Register a project for auto-context updates
*/
export function registerWindsurfProject(projectName: string, workspacePath: string): void {
const registry = readWindsurfRegistry();
registry[projectName] = {
workspacePath,
installedAt: new Date().toISOString(),
};
writeWindsurfRegistry(registry);
logger.info('WINDSURF', 'Registered project for auto-context updates', { projectName, workspacePath });
}
/**
* Unregister a project from auto-context updates
*/
export function unregisterWindsurfProject(projectName: string): void {
const registry = readWindsurfRegistry();
if (registry[projectName]) {
delete registry[projectName];
writeWindsurfRegistry(registry);
logger.info('WINDSURF', 'Unregistered project', { projectName });
}
}
/**
* Update Windsurf context files for all registered projects matching this project name.
* Called by SDK agents after saving a summary.
*/
export async function updateWindsurfContextForProject(projectName: string, port: number): Promise<void> {
const registry = readWindsurfRegistry();
const entry = registry[projectName];
if (!entry) return; // Project doesn't have Windsurf hooks installed
try {
const response = await fetch(
`http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(projectName)}`
);
if (!response.ok) return;
const context = await response.text();
if (!context || !context.trim()) return;
writeWindsurfContextFile(entry.workspacePath, context);
logger.debug('WINDSURF', 'Updated context file', { projectName, workspacePath: entry.workspacePath });
} catch (error) {
// Background context update — failure is non-critical
logger.error('WINDSURF', 'Failed to update context file', { projectName }, error as Error);
}
}
// ============================================================================
// Context File
// ============================================================================
/**
* Write context to the workspace-level Windsurf rules directory.
* Windsurf rules are workspace-scoped: .windsurf/rules/claude-mem-context.md
* Rule file limit: 6,000 chars per file.
*/
export function writeWindsurfContextFile(workspacePath: string, context: string): void {
const rulesDir = path.join(workspacePath, '.windsurf', 'rules');
const rulesFile = path.join(rulesDir, 'claude-mem-context.md');
const tempFile = `${rulesFile}.tmp`;
mkdirSync(rulesDir, { recursive: true });
let content = `# Memory Context from Past Sessions
The following context is from claude-mem, a persistent memory system that tracks your coding sessions.
${context}
---
*Auto-updated by claude-mem after each session. Use MCP search tools for detailed queries.*
`;
// Enforce Windsurf's 6K char limit
if (content.length > WINDSURF_CONTEXT_CHAR_LIMIT) {
content = content.slice(0, WINDSURF_CONTEXT_CHAR_LIMIT - 50) +
'\n\n*[Truncated — use MCP search for full history]*\n';
}
// Atomic write: temp file + rename
writeFileSync(tempFile, content);
renameSync(tempFile, rulesFile);
}
// ============================================================================
// Hook Installation
// ============================================================================
/**
* Build the hook command string for a given event.
* Uses bun to run worker-service.cjs with the windsurf platform adapter.
*/
function buildHookCommand(bunPath: string, workerServicePath: string, eventName: string): string {
// Map Windsurf event names to unified CLI hook commands
const eventToCommand: Record<string, string> = {
'pre_user_prompt': 'session-init',
'post_write_code': 'file-edit',
'post_run_command': 'observation',
'post_mcp_tool_use': 'observation',
'post_cascade_response': 'observation',
};
const hookCommand = eventToCommand[eventName] ?? 'observation';
// Escape backslashes for JSON on Windows
const escapedBunPath = bunPath.replace(/\\/g, '\\\\');
const escapedWorkerPath = workerServicePath.replace(/\\/g, '\\\\');
return `"${escapedBunPath}" "${escapedWorkerPath}" hook windsurf ${hookCommand}`;
}
/**
* Read existing hooks.json, merge our hooks, and write back.
* Preserves any existing hooks from other tools.
*/
function mergeAndWriteHooksJson(
bunPath: string,
workerServicePath: string,
workingDirectory: string,
): void {
mkdirSync(WINDSURF_HOOKS_DIR, { recursive: true });
// Read existing hooks.json if present
let existingConfig: WindsurfHooksJson = { hooks: {} };
if (existsSync(WINDSURF_HOOKS_JSON_PATH)) {
try {
existingConfig = JSON.parse(readFileSync(WINDSURF_HOOKS_JSON_PATH, 'utf-8'));
if (!existingConfig.hooks) {
existingConfig.hooks = {};
}
} catch (error) {
logger.error('WINDSURF', 'Corrupt hooks.json, starting fresh', {
path: WINDSURF_HOOKS_JSON_PATH,
}, error as Error);
existingConfig = { hooks: {} };
}
}
// For each event, add our hook entry (remove any previous claude-mem entries first)
for (const eventName of WINDSURF_HOOK_EVENTS) {
const command = buildHookCommand(bunPath, workerServicePath, eventName);
const hookEntry: WindsurfHookEntry = {
command,
show_output: false,
working_directory: workingDirectory,
};
// Get existing hooks for this event, filtering out old claude-mem ones
const existingHooks = (existingConfig.hooks[eventName] ?? []).filter(
(hook) => !hook.command.includes('worker-service') || !hook.command.includes('windsurf')
);
existingConfig.hooks[eventName] = [...existingHooks, hookEntry];
}
writeFileSync(WINDSURF_HOOKS_JSON_PATH, JSON.stringify(existingConfig, null, 2));
}
/**
* Install Windsurf hooks to ~/.codeium/windsurf/hooks.json (user-level).
* Merges with existing hooks.json to preserve other integrations.
*/
export async function installWindsurfHooks(): Promise<number> {
console.log('\nInstalling Claude-Mem Windsurf hooks (user level)...\n');
// Find the worker-service.cjs path
const workerServicePath = findWorkerServicePath();
if (!workerServicePath) {
console.error('Could not find worker-service.cjs');
console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/worker-service.cjs');
return 1;
}
// Find bun executable — required because worker-service.cjs uses bun:sqlite
const bunPath = findBunPath();
// IMPORTANT: Tilde expansion is NOT supported in working_directory — use absolute paths
const workingDirectory = path.dirname(workerServicePath);
try {
console.log(` Using Bun runtime: ${bunPath}`);
console.log(` Worker service: ${workerServicePath}`);
// Merge our hooks into the existing hooks.json
mergeAndWriteHooksJson(bunPath, workerServicePath, workingDirectory);
console.log(` Created/merged hooks.json`);
// Set up initial context for the current workspace
const workspaceRoot = process.cwd();
await setupWindsurfProjectContext(workspaceRoot);
console.log(`
Installation complete!
Hooks installed to: ${WINDSURF_HOOKS_JSON_PATH}
Using unified CLI: bun worker-service.cjs hook windsurf <command>
Events registered:
- pre_user_prompt (session init + context injection)
- post_write_code (code generation observation)
- post_run_command (command execution observation)
- post_mcp_tool_use (MCP tool results)
- post_cascade_response (full AI response)
Next steps:
1. Start claude-mem worker: claude-mem start
2. Restart Windsurf to load the hooks
3. Context is injected via .windsurf/rules/claude-mem-context.md (workspace-level)
`);
return 0;
} catch (error) {
console.error(`\nInstallation failed: ${(error as Error).message}`);
return 1;
}
}
/**
* Setup initial context file for a Windsurf workspace
*/
async function setupWindsurfProjectContext(workspaceRoot: string): Promise<void> {
const port = getWorkerPort();
const projectName = path.basename(workspaceRoot);
let contextGenerated = false;
console.log(` Generating initial context...`);
try {
const healthResponse = await fetch(`http://127.0.0.1:${port}/api/readiness`);
if (healthResponse.ok) {
const contextResponse = await fetch(
`http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(projectName)}`
);
if (contextResponse.ok) {
const context = await contextResponse.text();
if (context && context.trim()) {
writeWindsurfContextFile(workspaceRoot, context);
contextGenerated = true;
console.log(` Generated initial context from existing memory`);
}
}
}
} catch (error) {
// Worker not running during install — non-critical
logger.debug('WINDSURF', 'Worker not running during install', {}, error as Error);
}
if (!contextGenerated) {
// Create placeholder context file
const rulesDir = path.join(workspaceRoot, '.windsurf', 'rules');
mkdirSync(rulesDir, { recursive: true });
const rulesFile = path.join(rulesDir, 'claude-mem-context.md');
const placeholderContent = `# Memory Context from Past Sessions
*No context yet. Complete your first session and context will appear here.*
Use claude-mem's MCP search tools for manual memory queries.
`;
writeFileSync(rulesFile, placeholderContent);
console.log(` Created placeholder context file (will populate after first session)`);
}
// Register project for automatic context updates after summaries
registerWindsurfProject(projectName, workspaceRoot);
console.log(` Registered for auto-context updates`);
}
/**
* Uninstall Windsurf hooks — removes claude-mem entries from hooks.json
*/
export function uninstallWindsurfHooks(): number {
console.log('\nUninstalling Claude-Mem Windsurf hooks...\n');
try {
// Remove our entries from hooks.json (preserve other integrations)
if (existsSync(WINDSURF_HOOKS_JSON_PATH)) {
try {
const config: WindsurfHooksJson = JSON.parse(readFileSync(WINDSURF_HOOKS_JSON_PATH, 'utf-8'));
for (const eventName of WINDSURF_HOOK_EVENTS) {
if (config.hooks[eventName]) {
config.hooks[eventName] = config.hooks[eventName].filter(
(hook) => !hook.command.includes('worker-service') || !hook.command.includes('windsurf')
);
// Remove empty arrays
if (config.hooks[eventName].length === 0) {
delete config.hooks[eventName];
}
}
}
// If no hooks remain, remove the file entirely
if (Object.keys(config.hooks).length === 0) {
unlinkSync(WINDSURF_HOOKS_JSON_PATH);
console.log(` Removed hooks.json (no hooks remaining)`);
} else {
writeFileSync(WINDSURF_HOOKS_JSON_PATH, JSON.stringify(config, null, 2));
console.log(` Removed claude-mem entries from hooks.json (other hooks preserved)`);
}
} catch (error) {
// Corrupt file — just remove it
unlinkSync(WINDSURF_HOOKS_JSON_PATH);
console.log(` Removed corrupt hooks.json`);
}
} else {
console.log(` No hooks.json found`);
}
// Remove context file from the current workspace
const workspaceRoot = process.cwd();
const contextFile = path.join(workspaceRoot, '.windsurf', 'rules', 'claude-mem-context.md');
if (existsSync(contextFile)) {
unlinkSync(contextFile);
console.log(` Removed context file`);
}
// Unregister project
const projectName = path.basename(workspaceRoot);
unregisterWindsurfProject(projectName);
console.log(` Unregistered from auto-context updates`);
console.log(`\nUninstallation complete!\n`);
console.log('Restart Windsurf to apply changes.');
return 0;
} catch (error) {
console.error(`\nUninstallation failed: ${(error as Error).message}`);
return 1;
}
}
/**
* Check Windsurf hooks installation status
*/
export function checkWindsurfHooksStatus(): number {
console.log('\nClaude-Mem Windsurf Hooks Status\n');
if (existsSync(WINDSURF_HOOKS_JSON_PATH)) {
console.log(`User-level: Installed`);
console.log(` Config: ${WINDSURF_HOOKS_JSON_PATH}`);
try {
const config: WindsurfHooksJson = JSON.parse(readFileSync(WINDSURF_HOOKS_JSON_PATH, 'utf-8'));
const registeredEvents = WINDSURF_HOOK_EVENTS.filter(
(event) => config.hooks[event]?.some(
(hook) => hook.command.includes('worker-service') && hook.command.includes('windsurf')
)
);
console.log(` Events: ${registeredEvents.length}/${WINDSURF_HOOK_EVENTS.length} registered`);
for (const event of registeredEvents) {
console.log(` - ${event}`);
}
} catch {
console.log(` Mode: Unable to parse hooks.json`);
}
// Check for context file in current workspace
const contextFile = path.join(process.cwd(), '.windsurf', 'rules', 'claude-mem-context.md');
if (existsSync(contextFile)) {
console.log(` Context: Active (current workspace)`);
} else {
console.log(` Context: Not yet generated for this workspace`);
}
} else {
console.log(`User-level: Not installed`);
console.log(`\nNo hooks installed. Run: claude-mem windsurf install\n`);
}
console.log('');
return 0;
}
/**
* Handle windsurf subcommand for hooks installation
*/
export async function handleWindsurfCommand(subcommand: string, _args: string[]): Promise<number> {
switch (subcommand) {
case 'install':
return installWindsurfHooks();
case 'uninstall':
return uninstallWindsurfHooks();
case 'status':
return checkWindsurfHooksStatus();
default: {
console.log(`
Claude-Mem Windsurf Integration
Usage: claude-mem windsurf <command>
Commands:
install Install Windsurf hooks (user-level, ~/.codeium/windsurf/hooks.json)
uninstall Remove Windsurf hooks
status Check installation status
Examples:
claude-mem windsurf install # Install hooks globally
claude-mem windsurf uninstall # Remove hooks
claude-mem windsurf status # Check if hooks are installed
For more info: https://docs.claude-mem.ai/windsurf
`);
return 0;
}
}
}
+7 -1
View File
@@ -1,6 +1,12 @@
/**
* Integrations module - IDE integrations (Cursor, etc.)
* Integrations module - IDE integrations (Cursor, Gemini CLI, OpenCode, Windsurf, etc.)
*/
export * from './types.js';
export * from './CursorHooksInstaller.js';
export * from './GeminiCliHooksInstaller.js';
export * from './OpenCodeInstaller.js';
export * from './WindsurfHooksInstaller.js';
export * from './OpenClawInstaller.js';
export * from './CodexCliInstaller.js';
export * from './McpIntegrations.js';
+6 -4
View File
@@ -8,7 +8,7 @@ export const DEFAULT_STATE_PATH = join(homedir(), '.claude-mem', 'transcript-wat
const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
name: 'codex',
version: '0.2',
version: '0.3',
description: 'Schema for Codex session JSONL files under ~/.codex/sessions.',
events: [
{
@@ -46,13 +46,14 @@ const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
},
{
name: 'tool-use',
match: { path: 'payload.type', in: ['function_call', 'custom_tool_call', 'web_search_call'] },
match: { path: 'payload.type', in: ['function_call', 'custom_tool_call', 'web_search_call', 'exec_command'] },
action: 'tool_use',
fields: {
toolId: 'payload.call_id',
toolName: {
coalesce: [
'payload.name',
'payload.type',
{ value: 'web_search' }
]
},
@@ -60,6 +61,7 @@ const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
coalesce: [
'payload.arguments',
'payload.input',
'payload.command',
'payload.action'
]
}
@@ -67,7 +69,7 @@ const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
},
{
name: 'tool-result',
match: { path: 'payload.type', in: ['function_call_output', 'custom_tool_call_output'] },
match: { path: 'payload.type', in: ['function_call_output', 'custom_tool_call_output', 'exec_command_output'] },
action: 'tool_result',
fields: {
toolId: 'payload.call_id',
@@ -76,7 +78,7 @@ const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
},
{
name: 'session-end',
match: { path: 'payload.type', equals: 'turn_aborted' },
match: { path: 'payload.type', in: ['turn_aborted', 'turn_completed'] },
action: 'session_end'
}
]