diff --git a/scripts/build-hooks.js b/scripts/build-hooks.js index 4fc704b7..9d978355 100644 --- a/scripts/build-hooks.js +++ b/scripts/build-hooks.js @@ -238,6 +238,32 @@ async function buildHooks() { console.log(`āœ“ openclaw plugin built (${(openclawStats.size / 1024).toFixed(2)} KB)`); } + // Build OpenCode plugin (self-contained, runs in Bun) + if (fs.existsSync('src/integrations/opencode-plugin/index.ts')) { + console.log(`\nšŸ”§ Building OpenCode plugin...`); + const opencodeOutDir = 'dist/opencode-plugin'; + if (!fs.existsSync(opencodeOutDir)) { + fs.mkdirSync(opencodeOutDir, { recursive: true }); + } + await build({ + entryPoints: ['src/integrations/opencode-plugin/index.ts'], + bundle: true, + platform: 'node', + target: 'node18', + format: 'esm', + outfile: `${opencodeOutDir}/index.js`, + minify: true, + logLevel: 'error', + external: [ + 'fs', 'fs/promises', 'path', 'os', 'child_process', 'url', + 'crypto', 'http', 'https', 'net', 'stream', 'util', 'events', + ], + }); + + const opencodeStats = fs.statSync(`${opencodeOutDir}/index.js`); + console.log(`āœ“ opencode plugin built (${(opencodeStats.size / 1024).toFixed(2)} KB)`); + } + // Verify critical distribution files exist (skills are source files, not build outputs) console.log('\nšŸ“‹ Verifying distribution files...'); const requiredDistributionFiles = [ @@ -264,6 +290,10 @@ async function buildHooks() { console.log(` Output: openclaw/dist/`); console.log(` - OpenClaw Plugin: index.js`); } + if (fs.existsSync('dist/opencode-plugin/index.js')) { + console.log(` Output: dist/opencode-plugin/`); + console.log(` - OpenCode Plugin: index.js`); + } } catch (error) { console.error('\nāŒ Build failed:', error.message); diff --git a/src/cli/adapters/gemini-cli.ts b/src/cli/adapters/gemini-cli.ts new file mode 100644 index 00000000..db03daed --- /dev/null +++ b/src/cli/adapters/gemini-cli.ts @@ -0,0 +1,85 @@ +import type { PlatformAdapter, NormalizedHookInput, HookResult } from '../types.js'; + +/** + * Gemini CLI Platform Adapter + * + * Normalizes Gemini CLI's hook JSON to NormalizedHookInput. + * Gemini CLI has 11 lifecycle hooks; we map 6 of them: + * SessionStart → session-init + * BeforeAgent → user-message (captures prompt) + * AfterAgent → observation (full response) + * AfterTool → observation (tool result) + * PreCompress → summarize + * SessionEnd → session-complete + * + * Base fields (all events): session_id, transcript_path, cwd, hook_event_name, timestamp + * + * Output format: { continue, stopReason, suppressOutput, systemMessage, decision, reason } + * Advisory hooks (SessionStart, SessionEnd, PreCompress) ignore `continue` and `decision`. + */ +export const geminiCliAdapter: PlatformAdapter = { + normalizeInput(raw) { + const r = (raw ?? {}) as any; + + // Use GEMINI_CWD, GEMINI_PROJECT_DIR, or the JSON cwd field + const cwd = r.cwd + ?? process.env.GEMINI_CWD + ?? process.env.GEMINI_PROJECT_DIR + ?? process.env.CLAUDE_PROJECT_DIR + ?? process.cwd(); + + const sessionId = r.session_id + ?? process.env.GEMINI_SESSION_ID + ?? undefined; + + // Map event-specific fields into normalized shape + // AfterTool provides tool_name, tool_input, tool_response + // BeforeAgent/AfterAgent provide prompt (and prompt_response for AfterAgent) + const hookEventName: string | undefined = r.hook_event_name; + + // For AfterAgent, treat the full response as an observation by packing it + // into toolResponse so the observation handler can process it + let toolName: string | undefined = r.tool_name; + let toolInput: unknown = r.tool_input; + let toolResponse: unknown = r.tool_response; + + if (hookEventName === 'AfterAgent' && r.prompt_response) { + toolName = toolName ?? 'GeminiAgent'; + toolInput = toolInput ?? { prompt: r.prompt }; + toolResponse = toolResponse ?? { response: r.prompt_response }; + } + + return { + sessionId, + cwd, + prompt: r.prompt, + toolName, + toolInput, + toolResponse, + transcriptPath: r.transcript_path, + }; + }, + + formatOutput(result) { + // Gemini CLI expects: { continue, stopReason, suppressOutput, systemMessage, decision, reason } + const output: Record = {}; + + // Always include continue — controls whether the agent proceeds + output.continue = result.continue ?? true; + + if (result.suppressOutput !== undefined) { + output.suppressOutput = result.suppressOutput; + } + + if (result.systemMessage) { + output.systemMessage = result.systemMessage; + } + + // hookSpecificOutput carries context injection data + if (result.hookSpecificOutput) { + output.systemMessage = result.hookSpecificOutput.additionalContext || output.systemMessage; + } + + return output; + } +}; diff --git a/src/cli/adapters/index.ts b/src/cli/adapters/index.ts index 8c19039e..c6b5c3a7 100644 --- a/src/cli/adapters/index.ts +++ b/src/cli/adapters/index.ts @@ -1,16 +1,20 @@ import type { PlatformAdapter } from '../types.js'; import { claudeCodeAdapter } from './claude-code.js'; import { cursorAdapter } from './cursor.js'; +import { geminiCliAdapter } from './gemini-cli.js'; import { rawAdapter } from './raw.js'; +import { windsurfAdapter } from './windsurf.js'; export function getPlatformAdapter(platform: string): PlatformAdapter { switch (platform) { case 'claude-code': return claudeCodeAdapter; case 'cursor': return cursorAdapter; + case 'gemini-cli': return geminiCliAdapter; + case 'windsurf': return windsurfAdapter; case 'raw': return rawAdapter; // Codex CLI and other compatible platforms use the raw adapter (accepts both camelCase and snake_case fields) default: return rawAdapter; } } -export { claudeCodeAdapter, cursorAdapter, rawAdapter }; +export { claudeCodeAdapter, cursorAdapter, geminiCliAdapter, rawAdapter, windsurfAdapter }; diff --git a/src/cli/adapters/windsurf.ts b/src/cli/adapters/windsurf.ts new file mode 100644 index 00000000..7f065ab1 --- /dev/null +++ b/src/cli/adapters/windsurf.ts @@ -0,0 +1,79 @@ +import type { PlatformAdapter, NormalizedHookInput, HookResult } from '../types.js'; + +// Maps Windsurf stdin format — JSON envelope with agent_action_name + tool_info payload +// +// Common envelope (all hooks): +// { agent_action_name, trajectory_id, execution_id, timestamp, tool_info: { ... } } +// +// Event-specific tool_info payloads: +// pre_user_prompt: { user_prompt: string } +// post_write_code: { file_path, edits: [{ old_string, new_string }] } +// post_run_command: { command_line, cwd } +// post_mcp_tool_use: { mcp_server_name, mcp_tool_name, mcp_tool_arguments, mcp_result } +// post_cascade_response: { response } +export const windsurfAdapter: PlatformAdapter = { + normalizeInput(raw) { + const r = (raw ?? {}) as any; + const toolInfo = r.tool_info ?? {}; + const actionName: string = r.agent_action_name ?? ''; + + const base: NormalizedHookInput = { + sessionId: r.trajectory_id ?? r.execution_id, + cwd: toolInfo.cwd ?? process.cwd(), + platform: 'windsurf', + }; + + switch (actionName) { + case 'pre_user_prompt': + return { + ...base, + prompt: toolInfo.user_prompt, + }; + + case 'post_write_code': + return { + ...base, + toolName: 'Write', + filePath: toolInfo.file_path, + edits: toolInfo.edits, + toolInput: { + file_path: toolInfo.file_path, + edits: toolInfo.edits, + }, + }; + + case 'post_run_command': + return { + ...base, + cwd: toolInfo.cwd ?? base.cwd, + toolName: 'Bash', + toolInput: { command: toolInfo.command_line }, + }; + + case 'post_mcp_tool_use': + return { + ...base, + toolName: toolInfo.mcp_tool_name ?? 'mcp_tool', + toolInput: toolInfo.mcp_tool_arguments, + toolResponse: toolInfo.mcp_result, + }; + + case 'post_cascade_response': + return { + ...base, + toolName: 'cascade_response', + toolResponse: toolInfo.response, + }; + + default: + // Unknown action — pass through what we can + return base; + } + }, + + formatOutput(result) { + // Windsurf exit codes: 0 = success, 2 = block (pre-hooks only) + // The CLI layer handles exit codes; here we just return a simple continue flag + return { continue: result.continue ?? true }; + }, +}; diff --git a/src/integrations/opencode-plugin/index.ts b/src/integrations/opencode-plugin/index.ts new file mode 100644 index 00000000..2133a22a --- /dev/null +++ b/src/integrations/opencode-plugin/index.ts @@ -0,0 +1,355 @@ +/** + * OpenCode Plugin for claude-mem + * + * Integrates claude-mem persistent memory with OpenCode (110k+ stars). + * Runs inside OpenCode's Bun-based plugin runtime. + * + * Plugin hooks: + * - tool.execute.after: Captures tool execution observations + * - Bus events: session.created, message.updated, session.compacted, + * file.edited, session.deleted + * + * Custom tool: + * - claude_mem_search: Search memory database from within OpenCode + */ + +// ============================================================================ +// Minimal type declarations for OpenCode Plugin SDK +// These match the runtime API provided by @opencode-ai/plugin +// ============================================================================ + +interface OpenCodeProject { + name?: string; + path?: string; +} + +interface OpenCodePluginContext { + client: unknown; + project: OpenCodeProject; + directory: string; + worktree: string; + serverUrl: URL; + $: unknown; // BunShell +} + +interface ToolExecuteAfterInput { + tool: string; + sessionID: string; + callID: string; + args: Record; +} + +interface ToolExecuteAfterOutput { + title: string; + output: string; + metadata: Record; +} + +interface ToolDefinition { + description: string; + args: Record; + execute: (args: Record, context: unknown) => Promise; +} + +// Bus event payloads +interface SessionCreatedEvent { + event: { + sessionID: string; + directory?: string; + project?: string; + }; +} + +interface MessageUpdatedEvent { + event: { + sessionID: string; + role: string; + content: string; + }; +} + +interface SessionCompactedEvent { + event: { + sessionID: string; + summary?: string; + messageCount?: number; + }; +} + +interface FileEditedEvent { + event: { + sessionID: string; + path: string; + diff?: string; + }; +} + +interface SessionDeletedEvent { + event: { + sessionID: string; + }; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const WORKER_BASE_URL = "http://127.0.0.1:37777"; +const MAX_TOOL_RESPONSE_LENGTH = 1000; + +// ============================================================================ +// Worker HTTP Client +// ============================================================================ + +async function workerPost( + path: string, + body: Record, +): Promise | null> { + try { + const response = await fetch(`${WORKER_BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) { + console.warn(`[claude-mem] Worker POST ${path} returned ${response.status}`); + return null; + } + return (await response.json()) as Record; + } catch (error: unknown) { + // Gracefully handle ECONNREFUSED — worker may not be running + const message = error instanceof Error ? error.message : String(error); + if (!message.includes("ECONNREFUSED")) { + console.warn(`[claude-mem] Worker POST ${path} failed: ${message}`); + } + return null; + } +} + +function workerPostFireAndForget( + path: string, + body: Record, +): void { + fetch(`${WORKER_BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes("ECONNREFUSED")) { + console.warn(`[claude-mem] Worker POST ${path} failed: ${message}`); + } + }); +} + +async function workerGetText(path: string): Promise { + try { + const response = await fetch(`${WORKER_BASE_URL}${path}`); + if (!response.ok) { + console.warn(`[claude-mem] Worker GET ${path} returned ${response.status}`); + return null; + } + return await response.text(); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes("ECONNREFUSED")) { + console.warn(`[claude-mem] Worker GET ${path} failed: ${message}`); + } + return null; + } +} + +// ============================================================================ +// Session tracking +// ============================================================================ + +const contentSessionIdsByOpenCodeSessionId = new Map(); + +function getOrCreateContentSessionId(openCodeSessionId: string): string { + if (!contentSessionIdsByOpenCodeSessionId.has(openCodeSessionId)) { + contentSessionIdsByOpenCodeSessionId.set( + openCodeSessionId, + `opencode-${openCodeSessionId}-${Date.now()}`, + ); + } + return contentSessionIdsByOpenCodeSessionId.get(openCodeSessionId)!; +} + +// ============================================================================ +// Plugin Entry Point +// ============================================================================ + +export const ClaudeMemPlugin = async (ctx: OpenCodePluginContext) => { + const projectName = ctx.project?.name || "opencode"; + + console.log(`[claude-mem] OpenCode plugin loading (project: ${projectName})`); + + return { + // ------------------------------------------------------------------ + // Direct interceptor hooks + // ------------------------------------------------------------------ + hooks: { + tool: { + execute: { + after: ( + input: ToolExecuteAfterInput, + output: ToolExecuteAfterOutput, + ) => { + const contentSessionId = getOrCreateContentSessionId(input.sessionID); + + // Truncate long tool output + let toolResponseText = output.output || ""; + if (toolResponseText.length > MAX_TOOL_RESPONSE_LENGTH) { + toolResponseText = toolResponseText.slice(0, MAX_TOOL_RESPONSE_LENGTH); + } + + workerPostFireAndForget("/api/sessions/observations", { + contentSessionId, + tool_name: input.tool, + tool_input: input.args || {}, + tool_response: toolResponseText, + cwd: ctx.directory, + }); + }, + }, + }, + }, + + // ------------------------------------------------------------------ + // Bus event handlers + // ------------------------------------------------------------------ + event: (eventName: string, payload: unknown) => { + switch (eventName) { + case "session.created": { + const { event } = payload as SessionCreatedEvent; + const contentSessionId = getOrCreateContentSessionId(event.sessionID); + + workerPostFireAndForget("/api/sessions/init", { + contentSessionId, + project: projectName, + prompt: "", + }); + break; + } + + case "message.updated": { + const { event } = payload as MessageUpdatedEvent; + + // Only capture assistant messages as observations + if (event.role !== "assistant") break; + + const contentSessionId = getOrCreateContentSessionId(event.sessionID); + + let messageText = event.content || ""; + if (messageText.length > MAX_TOOL_RESPONSE_LENGTH) { + messageText = messageText.slice(0, MAX_TOOL_RESPONSE_LENGTH); + } + + workerPostFireAndForget("/api/sessions/observations", { + contentSessionId, + tool_name: "assistant_message", + tool_input: {}, + tool_response: messageText, + cwd: ctx.directory, + }); + break; + } + + case "session.compacted": { + const { event } = payload as SessionCompactedEvent; + const contentSessionId = getOrCreateContentSessionId(event.sessionID); + + workerPostFireAndForget("/api/sessions/summarize", { + contentSessionId, + last_assistant_message: event.summary || "", + }); + break; + } + + case "file.edited": { + const { event } = payload as FileEditedEvent; + const contentSessionId = getOrCreateContentSessionId(event.sessionID); + + workerPostFireAndForget("/api/sessions/observations", { + contentSessionId, + tool_name: "file_edit", + tool_input: { path: event.path }, + tool_response: event.diff + ? event.diff.slice(0, MAX_TOOL_RESPONSE_LENGTH) + : `File edited: ${event.path}`, + cwd: ctx.directory, + }); + break; + } + + case "session.deleted": { + const { event } = payload as SessionDeletedEvent; + const contentSessionId = contentSessionIdsByOpenCodeSessionId.get( + event.sessionID, + ); + + if (contentSessionId) { + workerPostFireAndForget("/api/sessions/complete", { + contentSessionId, + }); + contentSessionIdsByOpenCodeSessionId.delete(event.sessionID); + } + break; + } + } + }, + + // ------------------------------------------------------------------ + // Custom tools + // ------------------------------------------------------------------ + tool: { + claude_mem_search: { + description: + "Search claude-mem memory database for past observations, sessions, and context", + args: { + query: { + type: "string", + description: "Search query for memory observations", + }, + }, + async execute( + args: Record, + ): Promise { + const query = String(args.query || ""); + if (!query) { + return "Please provide a search query."; + } + + const text = await workerGetText( + `/api/search/observations?query=${encodeURIComponent(query)}&limit=10`, + ); + + if (!text) { + return "claude-mem worker is not running. Start it with: npx claude-mem start"; + } + + try { + const data = JSON.parse(text); + const items = Array.isArray(data.items) ? data.items : []; + if (items.length === 0) { + return `No results found for "${query}".`; + } + + return items + .slice(0, 10) + .map((item: Record, index: number) => { + const title = String(item.title || item.subtitle || "Untitled"); + const project = item.project ? ` [${String(item.project)}]` : ""; + return `${index + 1}. ${title}${project}`; + }) + .join("\n"); + } catch { + return "Failed to parse search results."; + } + }, + } satisfies ToolDefinition, + }, + }; +}; + +export default ClaudeMemPlugin; diff --git a/src/npx-cli/commands/ide-detection.ts b/src/npx-cli/commands/ide-detection.ts index 2d419bf3..ef49b684 100644 --- a/src/npx-cli/commands/ide-detection.ts +++ b/src/npx-cli/commands/ide-detection.ts @@ -82,16 +82,15 @@ export function detectInstalledIDEs(): IDEInfo[] { id: 'gemini-cli', label: 'Gemini CLI', detected: existsSync(join(home, '.gemini')), - supported: false, - hint: 'coming soon', + supported: true, }, { id: 'opencode', label: 'OpenCode', detected: existsSync(join(home, '.config', 'opencode')) || isCommandInPath('opencode'), - supported: false, - hint: 'coming soon', + supported: true, + hint: 'plugin-based integration', }, { id: 'openclaw', @@ -104,8 +103,7 @@ export function detectInstalledIDEs(): IDEInfo[] { id: 'windsurf', label: 'Windsurf', detected: existsSync(join(home, '.codeium', 'windsurf')), - supported: false, - hint: 'coming soon', + supported: true, }, { id: 'codex-cli', diff --git a/src/npx-cli/commands/install.ts b/src/npx-cli/commands/install.ts index 30b060f8..d95e5f3d 100644 --- a/src/npx-cli/commands/install.ts +++ b/src/npx-cli/commands/install.ts @@ -85,7 +85,7 @@ function enablePluginInClaudeSettings(): void { // IDE setup dispatcher // --------------------------------------------------------------------------- -function setupIDEs(selectedIDEs: string[]): void { +async function setupIDEs(selectedIDEs: string[]): Promise { for (const ideId of selectedIDEs) { switch (ideId) { case 'claude-code': @@ -99,6 +99,39 @@ function setupIDEs(selectedIDEs: string[]): void { p.log.info(` Run: npx claude-mem cursor-setup (coming soon)`); break; + case 'gemini-cli': { + const { installGeminiCliHooks } = await import('../../services/integrations/GeminiCliHooksInstaller.js'); + const geminiResult = await installGeminiCliHooks(); + if (geminiResult === 0) { + p.log.success('Gemini CLI: hooks installed.'); + } else { + p.log.error('Gemini CLI: hook installation failed.'); + } + break; + } + + case 'opencode': { + const { installOpenCodeIntegration } = await import('../../services/integrations/OpenCodeInstaller.js'); + const openCodeResult = await installOpenCodeIntegration(); + if (openCodeResult === 0) { + p.log.success('OpenCode: plugin installed.'); + } else { + p.log.error('OpenCode: plugin installation failed.'); + } + break; + } + + case 'windsurf': { + const { installWindsurfHooks } = await import('../../services/integrations/WindsurfHooksInstaller.js'); + const windsurfResult = await installWindsurfHooks(); + if (windsurfResult === 0) { + p.log.success('Windsurf: hooks installed.'); + } else { + p.log.error('Windsurf: hook installation failed.'); + } + break; + } + default: { const allIDEs = detectInstalledIDEs(); const ide = allIDEs.find((i) => i.id === ideId); @@ -350,7 +383,7 @@ export async function runInstallCommand(options: InstallOptions = {}): Promise; + [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 = { + '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: hook gemini-cli + */ +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 { + 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 + +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 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', + '', + '', + '', + '*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 tags. + */ +function removeGeminiMdContext(): void { + try { + if (!existsSync(GEMINI_MD_PATH)) return; + + const content = readFileSync(GEMINI_MD_PATH, 'utf-8'); + const startTag = ''; + const endTag = ''; + + 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('')) { + 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; +} diff --git a/src/services/integrations/OpenCodeInstaller.ts b/src/services/integrations/OpenCodeInstaller.ts new file mode 100644 index 00000000..1ac34fbb --- /dev/null +++ b/src/services/integrations/OpenCodeInstaller.ts @@ -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 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 = ''; +const CONTEXT_TAG_CLOSE = ''; + +/** + * 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 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 { + 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 { + 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; +} diff --git a/src/services/integrations/WindsurfHooksInstaller.ts b/src/services/integrations/WindsurfHooksInstaller.ts new file mode 100644 index 00000000..2cfd05c6 --- /dev/null +++ b/src/services/integrations/WindsurfHooksInstaller.ts @@ -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": { + * "": [{ "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 { + 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 = { + '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 { + 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 + +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(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 { + 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; + } + } +} diff --git a/src/services/integrations/index.ts b/src/services/integrations/index.ts index db0abfa1..67676836 100644 --- a/src/services/integrations/index.ts +++ b/src/services/integrations/index.ts @@ -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';