feat: add Gemini CLI, OpenCode, and Windsurf IDE integrations
Gemini CLI: platform adapter mapping 6 of 11 hooks, settings.json deep-merge installer, GEMINI.md context injection. OpenCode: plugin with tool.execute.after interceptor, bus events for session lifecycle, claude_mem_search custom tool, AGENTS.md context. Windsurf: platform adapter for tool_info envelope format, hooks.json installer for 5 post-action hooks, .windsurf/rules context injection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* GeminiCliHooksInstaller - 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 }]
|
||||
* }]
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Events registered:
|
||||
* SessionStart — session init
|
||||
* BeforeAgent — capture user prompt
|
||||
* AfterAgent — capture full response
|
||||
* AfterTool — capture all tool results (matcher: "*")
|
||||
* PreCompress — trigger summary
|
||||
* SessionEnd — finalize session
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* The Gemini CLI events we register hooks for, mapped to our internal event names.
|
||||
*/
|
||||
const GEMINI_EVENT_TO_CLAUDE_MEM_EVENT: Record<string, string> = {
|
||||
'SessionStart': 'session-init',
|
||||
'BeforeAgent': 'user-message',
|
||||
'AfterAgent': 'observation',
|
||||
'AfterTool': 'observation',
|
||||
'PreCompress': 'summarize',
|
||||
'SessionEnd': 'session-complete',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deep Merge for Hook Arrays
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Merge claude-mem hooks into an existing event's hook matcher array.
|
||||
* If a matcher with the same `matcher` value already has a hook named "claude-mem",
|
||||
* it is replaced. Otherwise, the hook is appended.
|
||||
*/
|
||||
function mergeHookMatchers(
|
||||
existingMatchers: GeminiHookMatcher[],
|
||||
newMatcher: GeminiHookMatcher,
|
||||
): GeminiHookMatcher[] {
|
||||
const result = [...existingMatchers];
|
||||
|
||||
const existingMatcherIndex = result.findIndex(
|
||||
(m) => m.matcher === newMatcher.matcher,
|
||||
);
|
||||
|
||||
if (existingMatcherIndex !== -1) {
|
||||
// Matcher exists — replace or add our hook within it
|
||||
const existing = result[existingMatcherIndex];
|
||||
const hookIndex = existing.hooks.findIndex((h) => h.name === HOOK_NAME);
|
||||
if (hookIndex !== -1) {
|
||||
existing.hooks[hookIndex] = newMatcher.hooks[0];
|
||||
} else {
|
||||
existing.hooks.push(newMatcher.hooks[0]);
|
||||
}
|
||||
} else {
|
||||
// No matching matcher — add the whole entry
|
||||
result.push(newMatcher);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook Installation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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, claudeMemEvent] of Object.entries(GEMINI_EVENT_TO_CLAUDE_MEM_EVENT)) {
|
||||
const command = buildHookCommand(bunPath, workerServicePath, claudeMemEvent);
|
||||
|
||||
// AfterTool uses matcher: "*" to capture all tool results
|
||||
const matcherValue = geminiEvent === 'AfterTool' ? '*' : '*';
|
||||
|
||||
const newMatcher: GeminiHookMatcher = {
|
||||
matcher: matcherValue,
|
||||
hooks: [{
|
||||
name: HOOK_NAME,
|
||||
type: 'command',
|
||||
command,
|
||||
timeout: HOOK_TIMEOUT_MS,
|
||||
}],
|
||||
};
|
||||
|
||||
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_EVENT_TO_CLAUDE_MEM_EVENT).join(', ')}`);
|
||||
|
||||
// Inject context into GEMINI.md
|
||||
injectGeminiMdContext();
|
||||
|
||||
console.log(`
|
||||
Installation complete!
|
||||
|
||||
Hooks installed to: ${GEMINI_SETTINGS_PATH}
|
||||
Using unified CLI: bun worker-service.cjs hook gemini-cli <event>
|
||||
|
||||
Next steps:
|
||||
1. Start claude-mem worker: claude-mem start
|
||||
2. Restart Gemini CLI to load the hooks
|
||||
3. Memory capture is now automatic!
|
||||
|
||||
Context Injection:
|
||||
Context from past sessions is injected via ${GEMINI_MD_PATH}
|
||||
and automatically included in every Gemini CLI session.
|
||||
`);
|
||||
|
||||
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_EVENT_TO_CLAUDE_MEM_EVENT);
|
||||
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,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
Reference in New Issue
Block a user