#!/usr/bin/env node /** * AgentKits Memory Setup CLI * * Sets up memory hooks, MCP server, and downloads embedding model. * Supports multiple AI tools: Claude Code, Cursor, Windsurf, Cline, OpenCode. * * Usage: * npx @aitytech/agentkits-memory [setup] [options] * * Options: * --project-dir=X Project directory (default: cwd) * --platform=X Target platform(s): claude-code, cursor, windsurf, cline, opencode, all * Default: auto-detect, fallback to claude-code * --force Overwrite existing configuration * --skip-model Skip embedding model download * --skip-mcp Skip MCP server configuration * --show-hooks Show full hooks JSON for manual configuration * --json Output result as JSON * * @module @agentkits/memory/cli/setup */ import * as fs from 'node:fs'; import * as path from 'node:path'; import { LocalEmbeddingsService } from '../embeddings/local-embeddings.js'; import { type PlatformDefinition, type PlatformId, PLATFORMS, resolvePlatforms } from './platforms.js'; import { installRulesFile } from './rules-generator.js'; const args = process.argv.slice(2); interface HookEntry { matcher: string; hooks: Array<{ type: string; command: string; timeout?: number }>; } interface HooksConfig { SessionStart?: HookEntry[]; UserPromptSubmit?: HookEntry[]; PostToolUse?: HookEntry[]; Stop?: HookEntry[]; PreCompact?: HookEntry[]; [key: string]: HookEntry[] | undefined; } interface ClaudeSettings { hooks?: HooksConfig; mcpServers?: Record; [key: string]: unknown; } interface McpServerConfig { command: string; args?: string[]; env?: Record; } interface McpConfig { mcpServers: Record; } const MEMORY_MCP_SERVER: McpServerConfig = { command: 'npx', args: ['-y', '@aitytech/agentkits-memory', 'server'], }; /** * Memory hooks for Claude Code lifecycle events * * Hook Events: * - SessionStart: Load memory context at session start * - UserPromptSubmit: Capture user intent when prompt submitted * - PostToolUse: Capture observations after tool execution (Edit, Write, Bash, Task) * - Stop: Generate summary when session ends */ const MEMORY_HOOKS: HooksConfig = { // Load memory context at session start SessionStart: [ { matcher: '', hooks: [ { type: 'command', command: 'npx --yes @aitytech/agentkits-memory hook context', timeout: 15, }, ], }, ], // Capture user intent when prompt submitted UserPromptSubmit: [ { matcher: '', hooks: [ { type: 'command', command: 'npx --yes @aitytech/agentkits-memory hook session-init', timeout: 10, }, ], }, ], // Capture observations after tool execution // Only track meaningful tools: Edit, Write, Bash, Task PostToolUse: [ { matcher: 'Edit|Write|Bash|Task', hooks: [ { type: 'command', command: 'npx --yes @aitytech/agentkits-memory hook observation', timeout: 15, }, ], }, ], // Generate summary when session ends Stop: [ { matcher: '', hooks: [ { type: 'command', command: 'npx --yes @aitytech/agentkits-memory hook summarize', timeout: 15, }, ], }, ], }; function parseArgs(): Record { const parsed: Record = {}; for (const arg of args) { if (arg.startsWith('--')) { const eqIndex = arg.indexOf('='); if (eqIndex > 0) { const key = arg.slice(2, eqIndex); const value = arg.slice(eqIndex + 1); parsed[key] = value; } else { parsed[arg.slice(2)] = true; } } } return parsed; } interface MergeResult { merged: HooksConfig; added: string[]; skipped: string[]; manualRequired: string[]; } /** * Merge memory hooks with existing hooks configuration * Always preserves existing hooks, never overwrites */ function mergeHooks( existing: HooksConfig | undefined, newHooks: HooksConfig, force: boolean ): MergeResult { const result: MergeResult = { merged: { ...existing }, added: [], skipped: [], manualRequired: [], }; // If no existing hooks, add all memory hooks if (!existing) { result.merged = { ...newHooks }; result.added = Object.keys(newHooks); return result; } for (const [event, memoryHooks] of Object.entries(newHooks)) { if (!memoryHooks) continue; const existingHooks = result.merged[event]; // Case 1: No existing hooks for this event → add ours if (!existingHooks) { result.merged[event] = memoryHooks; result.added.push(event); continue; } // Case 2: Check if our memory hook already exists const hasMemoryHook = existingHooks.some((h: HookEntry) => h.hooks.some((hook) => hook.command.includes('agentkits-memory')) ); if (hasMemoryHook) { if (force) { // Remove old memory hooks, add new ones const filtered = existingHooks.filter( (h: HookEntry) => !h.hooks.some((hook) => hook.command.includes('agentkits-memory')) ); result.merged[event] = [...filtered, ...memoryHooks]; result.added.push(`${event} (updated)`); } else { result.skipped.push(`${event} (already configured)`); } continue; } // Case 3: PostToolUse with different matcher - needs attention if (event === 'PostToolUse') { const ourMatcher = memoryHooks[0]?.matcher || ''; const existingMatchers = existingHooks.map((h: HookEntry) => h.matcher || ''); // Check for potential conflicts (different matchers that might overlap) const hasConflict = existingMatchers.some( (m) => m && m !== ourMatcher && (m === '*' || ourMatcher.split('|').some((t) => m.includes(t))) ); if (hasConflict) { // Add anyway but warn user result.merged[event] = [...existingHooks, ...memoryHooks]; result.added.push(event); result.manualRequired.push( `PostToolUse: Added with matcher "${ourMatcher}", existing has different matchers - verify no conflicts` ); continue; } } // Case 4: Append our hooks to existing result.merged[event] = [...existingHooks, ...memoryHooks]; result.added.push(event); } return result; } /** * Configure MCP server for a specific platform. * Handles two formats: * - 'embedded': mcpServers key inside an existing settings file (Claude Code) * - 'standalone': dedicated mcp.json file with { mcpServers: { ... } } */ function configureMcpForPlatform( projectDir: string, platform: PlatformDefinition, force: boolean, asJson: boolean, claudeSettings?: ClaudeSettings, ): { configured: boolean; path: string } { const mcpPath = path.join(projectDir, platform.mcpConfigPath); if (platform.mcpConfigFormat === 'embedded' && claudeSettings) { // Claude Code: mcpServers key inside settings.json if (!claudeSettings.mcpServers) { claudeSettings.mcpServers = {}; } if (!claudeSettings.mcpServers.memory || force) { claudeSettings.mcpServers.memory = MEMORY_MCP_SERVER; return { configured: true, path: mcpPath }; } return { configured: false, path: mcpPath }; } // Standalone mcp.json try { let existing: McpConfig = { mcpServers: {} }; if (fs.existsSync(mcpPath)) { try { existing = JSON.parse(fs.readFileSync(mcpPath, 'utf-8')) as McpConfig; existing.mcpServers = existing.mcpServers || {}; } catch { if (!asJson) { console.warn(` ⚠ ${platform.mcpConfigPath} parse error, creating new config`); } existing = { mcpServers: {} }; } } if (!existing.mcpServers.memory || force) { // Ensure parent directory exists const mcpDir = path.dirname(mcpPath); if (!fs.existsSync(mcpDir)) { fs.mkdirSync(mcpDir, { recursive: true }); } existing.mcpServers.memory = MEMORY_MCP_SERVER; fs.writeFileSync(mcpPath, JSON.stringify(existing, null, 2)); return { configured: true, path: mcpPath }; } return { configured: false, path: mcpPath }; } catch { return { configured: false, path: mcpPath }; } } /** * Install memory skills to a platform's skills directory. * Copies SKILL.md files from package to project's skills directory. */ function installSkills( projectDir: string, platform: PlatformDefinition, force: boolean, asJson: boolean ): { installed: string[]; skipped: string[] } { const installed: string[] = []; const skipped: string[] = []; if (!platform.skillsDir) return { installed, skipped }; // Resolve package root: setup.ts is at dist/cli/setup.js → package root is ../../ const packageRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..'); const sourceSkillsDir = path.join(packageRoot, 'skills'); if (!fs.existsSync(sourceSkillsDir)) { return { installed, skipped }; } let skillDirs: fs.Dirent[]; try { skillDirs = fs.readdirSync(sourceSkillsDir, { withFileTypes: true }) .filter(d => d.isDirectory()); } catch { return { installed, skipped }; } for (const skillDir of skillDirs) { const sourcePath = path.join(sourceSkillsDir, skillDir.name, 'SKILL.md'); const targetDir = path.join(projectDir, platform.skillsDir, skillDir.name); const targetPath = path.join(targetDir, 'SKILL.md'); if (!fs.existsSync(sourcePath)) continue; if (fs.existsSync(targetPath) && !force) { skipped.push(skillDir.name); continue; } if (!fs.existsSync(targetDir)) { fs.mkdirSync(targetDir, { recursive: true }); } fs.copyFileSync(sourcePath, targetPath); installed.push(skillDir.name); } if (!asJson && installed.length > 0) { console.log('\nšŸŽÆ Skills installed:'); for (const skill of installed) { console.log(` āœ“ ${skill} (${platform.skillsDir}/${skill}/SKILL.md)`); } } return { installed, skipped }; } /** * Create default memory settings file if not exists */ function createDefaultSettings(memoryDir: string, force: boolean): boolean { const settingsPath = path.join(memoryDir, 'settings.json'); if (fs.existsSync(settingsPath) && !force) return false; const defaultSettings = { context: { showSummaries: true, showPrompts: true, showObservations: true, showToolGuidance: true, maxSummaries: 3, maxPrompts: 10, maxObservations: 10, }, }; fs.writeFileSync(settingsPath, JSON.stringify(defaultSettings, null, 2)); return true; } async function downloadModel(cacheDir: string, asJson: boolean): Promise { if (!asJson) { console.log('\nšŸ“„ Downloading embedding model...'); console.log(' Model: multilingual-e5-small (~470MB)'); console.log(' This enables semantic search in 100+ languages.\n'); } try { const embeddingsService = new LocalEmbeddingsService({ showProgress: !asJson, cacheDir: path.join(cacheDir, 'embeddings-cache'), }); await embeddingsService.initialize(); // Verify model works with a test embedding const testResult = await embeddingsService.embed('Test embedding'); if (testResult.embedding.length !== 384) { throw new Error(`Unexpected embedding dimension: ${testResult.embedding.length}`); } if (!asJson) { console.log(' āœ“ Model downloaded and verified\n'); } return true; } catch (error) { if (!asJson) { console.error(' ⚠ Model download failed:', error instanceof Error ? error.message : error); console.log(' Model will be downloaded on first use.\n'); } return false; } } /** * Print full hooks configuration for manual setup */ function printHooksConfig(): void { console.log('\n' + '━'.repeat(60)); console.log('šŸ“‹ MEMORY HOOKS CONFIGURATION\n'); console.log('Copy and paste this JSON into your settings file.\n'); console.log('For Claude Code: .claude/settings.json'); console.log('─'.repeat(40)); console.log(JSON.stringify({ hooks: MEMORY_HOOKS }, null, 2)); console.log('\nFor Cursor/Windsurf: .cursor/mcp.json or settings'); console.log('─'.repeat(40)); console.log('Add hooks section with the same configuration above.\n'); console.log('Hook Events:'); console.log(' • SessionStart - Load memory context when session begins'); console.log(' • UserPromptSubmit - Capture user intent on each prompt'); console.log(' • PostToolUse - Record actions (Edit, Write, Bash, Task)'); console.log(' • Stop - Generate summary when session ends'); console.log('━'.repeat(60) + '\n'); } async function main() { const options = parseArgs(); const projectDir = (options['project-dir'] as string) || process.env.CLAUDE_PROJECT_DIR || process.cwd(); const force = !!options.force; const asJson = !!options.json; const skipModel = !!options['skip-model']; const skipMcp = !!options['skip-mcp']; const showHooks = !!options['show-hooks']; const platformArg = options.platform as string | undefined; // Just show hooks config and exit if (showHooks) { printHooksConfig(); return; } // Resolve target platforms const targetPlatforms = resolvePlatforms(platformArg, projectDir); // Memory data always stored under .claude/memory (single source of truth) const claudeDir = path.join(projectDir, '.claude'); const settingsPath = path.join(claudeDir, 'settings.json'); const memoryDir = path.join(claudeDir, 'memory'); try { if (!asJson) { const platformNames = targetPlatforms.map(id => PLATFORMS[id].name).join(', '); console.log('\n🧠 AgentKits Memory Setup\n'); console.log(` Platforms: ${platformNames}`); } // Always create memory directory (single source of truth) if (!fs.existsSync(claudeDir)) { fs.mkdirSync(claudeDir, { recursive: true }); } if (!fs.existsSync(memoryDir)) { fs.mkdirSync(memoryDir, { recursive: true }); } // Track results across all platforms const mcpConfigured: string[] = []; const mcpSkipped: string[] = []; const rulesInstalled: string[] = []; const rulesSkipped: string[] = []; let hooksResult: MergeResult = { merged: {}, added: [], skipped: [], manualRequired: [] }; let skillsResult = { installed: [] as string[], skipped: [] as string[] }; // Load Claude settings (needed for embedded MCP + hooks) let claudeSettings: ClaudeSettings = {}; if (fs.existsSync(settingsPath)) { const content = fs.readFileSync(settingsPath, 'utf-8'); claudeSettings = JSON.parse(content); } // Process each platform for (const platformId of targetPlatforms) { const platform = PLATFORMS[platformId]; // 1. Configure MCP if (!skipMcp) { const mcpResult = configureMcpForPlatform( projectDir, platform, force, asJson, platformId === 'claude-code' ? claudeSettings : undefined, ); if (mcpResult.configured) { mcpConfigured.push(`${platform.name} (${platform.mcpConfigPath})`); } else { mcpSkipped.push(`${platform.name} (already configured)`); } } // 2. Install hooks (Claude Code only for now; OpenCode in Phase B) if (platformId === 'claude-code') { hooksResult = mergeHooks(claudeSettings.hooks, MEMORY_HOOKS, force); claudeSettings.hooks = hooksResult.merged; } // 3. Install skills (platforms that support them) if (platform.skillsDir) { const result = installSkills(projectDir, platform, force, asJson); skillsResult.installed.push(...result.installed); skillsResult.skipped.push(...result.skipped); } // 4. Install rules file (platforms that support them) if (platform.rulesFile) { const result = installRulesFile(projectDir, platform.rulesFile, force, asJson); if (result.installed) { rulesInstalled.push(`${platform.rulesFile} (${result.action})`); } else { rulesSkipped.push(`${platform.rulesFile} (already configured)`); } } } // Write Claude settings (hooks + embedded MCP) if (targetPlatforms.includes('claude-code')) { fs.writeFileSync(settingsPath, JSON.stringify(claudeSettings, null, 2)); } // Create default memory settings const settingsCreated = createDefaultSettings(memoryDir, force); if (!asJson && settingsCreated) { console.log('\nāš™ļø Default memory settings created'); } // Download embedding model let modelDownloaded = false; if (!skipModel) { modelDownloaded = await downloadModel(memoryDir, asJson); } const result = { success: true, platforms: targetPlatforms, settingsPath, memoryDir, hooksAdded: hooksResult.added, hooksSkipped: hooksResult.skipped, hooksManualRequired: hooksResult.manualRequired, skillsInstalled: skillsResult.installed, mcpConfigured, rulesInstalled, modelDownloaded, message: 'Memory setup complete', }; if (asJson) { console.log(JSON.stringify(result, null, 2)); } else { console.log('\nāœ… Setup Complete\n'); console.log(`šŸ“ Memory: ${memoryDir}`); // Show MCP status if (mcpConfigured.length > 0) { console.log('\nšŸ”Œ MCP Server configured for:'); for (const entry of mcpConfigured) { console.log(` āœ“ ${entry}`); } } // Show hooks status (Claude Code only) if (hooksResult.added.length > 0) { console.log(`\nšŸ“‹ Hooks added: ${hooksResult.added.join(', ')}`); } if (hooksResult.skipped.length > 0) { console.log(` Skipped: ${hooksResult.skipped.join(', ')}`); } // Show skills status if (skillsResult.installed.length > 0) { console.log(`\nšŸŽÆ Skills: ${skillsResult.installed.join(', ')}`); } // Show rules files status if (rulesInstalled.length > 0) { console.log('\nšŸ“ Rules files:'); for (const entry of rulesInstalled) { console.log(` āœ“ ${entry}`); } } // Show manual action required if (hooksResult.manualRequired.length > 0) { console.log('\nāš ļø Manual review recommended:'); for (const msg of hooksResult.manualRequired) { console.log(` • ${msg}`); } } // Model status if (modelDownloaded) { console.log('\nšŸ“¦ Model: Downloaded and ready'); } else if (skipModel) { console.log('\nšŸ“¦ Model: Skipped (will download on first use)'); } console.log('\nšŸ‘‰ Restart your AI tool to activate.'); console.log('šŸ’” Open web viewer: npx @aitytech/agentkits-memory web'); console.log('šŸ“‹ Show hooks config: npx @aitytech/agentkits-memory setup --show-hooks\n'); // Show manual hook instructions if some hooks couldn't be added if (targetPlatforms.includes('claude-code')) { const allHookEvents = Object.keys(MEMORY_HOOKS); const addedEvents = hooksResult.added.map((h) => h.replace(/ \(.*\)$/, '')); const missingEvents = allHookEvents.filter( (e) => !addedEvents.includes(e) && !hooksResult.skipped.some((s) => s.startsWith(e)) ); if (missingEvents.length > 0) { console.log('━'.repeat(60)); console.log('šŸ“ MANUAL SETUP REQUIRED\n'); console.log(`Some hooks could not be auto-configured.`); console.log(`Missing: ${missingEvents.join(', ')}\n`); console.log(`To add manually:`); console.log(`1. Open: ${settingsPath}`); console.log(`2. Add/merge the following into the "hooks" section:\n`); // Generate copy-paste JSON for missing hooks only const missingHooksJson: Record = {}; for (const event of missingEvents) { const hookConfig = MEMORY_HOOKS[event]; if (hookConfig) { missingHooksJson[event] = hookConfig; } } console.log(JSON.stringify(missingHooksJson, null, 2)); console.log('\n━'.repeat(60)); } } } } catch (error) { const result = { success: false, error: error instanceof Error ? error.message : String(error), }; if (asJson) { console.log(JSON.stringify(result, null, 2)); } else { console.error('āŒ Setup failed:', result.error); } process.exit(1); } } main();