/** * 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": { * "": [{ "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 { [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. * Keys by full workspacePath to avoid collisions between directories with the same basename. */ export function registerWindsurfProject(workspacePath: string): void { const registry = readWindsurfRegistry(); registry[workspacePath] = { installedAt: new Date().toISOString(), }; writeWindsurfRegistry(registry); logger.info('WINDSURF', 'Registered project for auto-context updates', { workspacePath }); } /** * Unregister a project from auto-context updates */ export function unregisterWindsurfProject(workspacePath: string): void { const registry = readWindsurfRegistry(); if (registry[workspacePath]) { delete registry[workspacePath]; writeWindsurfRegistry(registry); logger.info('WINDSURF', 'Unregistered project', { workspacePath }); } } /** * Update Windsurf context files for a registered project. * Called by SDK agents after saving a summary. */ export async function updateWindsurfContextForProject(projectName: string, workspacePath: string, port: number): Promise { const registry = readWindsurfRegistry(); const entry = registry[workspacePath]; 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(workspacePath, context); logger.debug('WINDSURF', 'Updated context file', { projectName, workspacePath }); } catch (error) { // Background context update — failure is non-critical logger.error('WINDSURF', 'Failed to update context file', { projectName, workspacePath }, 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 = { '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'; return `"${bunPath}" "${workerServicePath}" 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) { throw new Error(`Corrupt hooks.json at ${WINDSURF_HOOKS_JSON_PATH}, refusing to overwrite`); } } // 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 { 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(); if (!bunPath) { console.error('Could not find Bun runtime'); console.error(' Install Bun: curl -fsSL https://bun.sh/install | bash'); return 1; } // 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 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 { 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(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) { console.log(` Warning: could not parse hooks.json — leaving file intact to preserve other hooks`); } } 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 unregisterWindsurfProject(workspaceRoot); 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 { 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 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; } } }