c7c68e81f4
- README: add language specifier to fenced code block - paths.ts: guard npmPackageRootDirectory() against bundle structure drift - OpenCodeInstaller: resolve bundle from import.meta.url, not process.cwd() - OpenCodeInstaller: log warnings on AGENTS.md injection failures - WindsurfHooksInstaller: key registry by full workspace path, not basename - uninstall.ts: poll health endpoint to wait for worker exit before file deletion - uninstall.ts: call IDE-specific uninstallers (Gemini, Windsurf, OpenCode, OpenClaw, Codex) - opencode-plugin: cap session tracking Map at 1000 entries with LRU eviction - GeminiCliHooksInstaller: document intentional JSON double-escaping Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
515 lines
17 KiB
TypeScript
515 lines
17 KiB
TypeScript
/**
|
|
* 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 {
|
|
[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<void> {
|
|
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<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';
|
|
|
|
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<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();
|
|
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 <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(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<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;
|
|
}
|
|
}
|
|
}
|