UNPKG

@stackmemoryai/stackmemory

Version:

Project-scoped memory for AI coding tools. Durable context across sessions with MCP integration, frames, smart retrieval, Claude Code skills, and automatic hooks.

702 lines (689 loc) 24.6 kB
import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import { Command } from "commander"; import chalk from "chalk"; import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"; import { join } from "path"; import { homedir } from "os"; import { execSync } from "child_process"; const CLAUDE_DIR = join(homedir(), ".claude"); const CLAUDE_CONFIG_FILE = join(CLAUDE_DIR, "config.json"); const MCP_CONFIG_FILE = join(CLAUDE_DIR, "stackmemory-mcp.json"); const HOOKS_JSON = join(CLAUDE_DIR, "hooks.json"); function createSetupMCPCommand() { return new Command("setup-mcp").description("Auto-configure Claude Code MCP integration").option("--dry-run", "Show what would be configured without making changes").option("--reset", "Reset MCP configuration to defaults").action(async (options) => { console.log(chalk.cyan("\nStackMemory MCP Setup\n")); if (options.dryRun) { console.log(chalk.yellow("[DRY RUN] No changes will be made.\n")); } if (!existsSync(CLAUDE_DIR)) { if (options.dryRun) { console.log(chalk.gray(`Would create: ${CLAUDE_DIR}`)); } else { mkdirSync(CLAUDE_DIR, { recursive: true }); console.log(chalk.green("[OK]") + " Created ~/.claude directory"); } } const mcpConfig = { mcpServers: { stackmemory: { command: "stackmemory", args: ["mcp-server"], env: { NODE_ENV: "production" } } } }; if (options.dryRun) { console.log( chalk.gray(`Would write MCP config to: ${MCP_CONFIG_FILE}`) ); console.log(chalk.gray(JSON.stringify(mcpConfig, null, 2))); } else { writeFileSync(MCP_CONFIG_FILE, JSON.stringify(mcpConfig, null, 2)); console.log(chalk.green("[OK]") + " Created MCP server configuration"); } let claudeConfig = {}; if (existsSync(CLAUDE_CONFIG_FILE)) { try { claudeConfig = JSON.parse(readFileSync(CLAUDE_CONFIG_FILE, "utf8")); } catch { console.log( chalk.yellow("[WARN]") + " Could not parse existing config.json, creating new" ); } } if (!claudeConfig.mcp) { claudeConfig.mcp = {}; } const mcp = claudeConfig.mcp; if (!mcp.configFiles) { mcp.configFiles = []; } const configFiles = mcp.configFiles; if (!configFiles.includes(MCP_CONFIG_FILE)) { configFiles.push(MCP_CONFIG_FILE); } if (options.dryRun) { console.log(chalk.gray(`Would update: ${CLAUDE_CONFIG_FILE}`)); } else { writeFileSync( CLAUDE_CONFIG_FILE, JSON.stringify(claudeConfig, null, 2) ); console.log(chalk.green("[OK]") + " Updated Claude config.json"); } console.log(chalk.cyan("\nValidating configuration...")); try { execSync("stackmemory --version", { stdio: "pipe" }); console.log(chalk.green("[OK]") + " stackmemory CLI is installed"); } catch { console.log(chalk.yellow("[WARN]") + " stackmemory CLI not in PATH"); console.log(chalk.gray(" You may need to restart your terminal")); } try { execSync("claude --version", { stdio: "pipe" }); console.log(chalk.green("[OK]") + " Claude Code is installed"); } catch { console.log(chalk.yellow("[WARN]") + " Claude Code not found"); console.log(chalk.gray(" Install from: https://claude.ai/code")); } if (!options.dryRun) { console.log(chalk.green("\nMCP setup complete!")); console.log(chalk.cyan("\nNext steps:")); console.log(chalk.white(" 1. Restart Claude Code")); console.log( chalk.white( " 2. The StackMemory MCP tools will be available automatically" ) ); console.log( chalk.gray( '\nTo verify: Run "stackmemory doctor" to check all integrations' ) ); } }); } function createDoctorCommand() { return new Command("doctor").description("Diagnose StackMemory configuration and common issues").option("--fix", "Attempt to automatically fix issues").action(async (options) => { console.log(chalk.cyan("\nStackMemory Doctor\n")); console.log(chalk.gray("Checking configuration and dependencies...\n")); const results = []; const projectDir = join(process.cwd(), ".stackmemory"); const dbPath = join(projectDir, "context.db"); if (existsSync(dbPath)) { results.push({ name: "Project Initialization", status: "ok", message: "StackMemory is initialized in this project" }); } else if (existsSync(projectDir)) { results.push({ name: "Project Initialization", status: "warn", message: ".stackmemory directory exists but database not found", fix: "Run: stackmemory init" }); } else { results.push({ name: "Project Initialization", status: "error", message: "StackMemory not initialized in this project", fix: "Run: stackmemory init" }); } if (existsSync(dbPath)) { try { const Database = (await import("better-sqlite3")).default; const db = new Database(dbPath, { readonly: true }); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all(); db.close(); const hasFrames = tables.some((t) => t.name === "frames"); if (hasFrames) { results.push({ name: "Database Integrity", status: "ok", message: `Database has ${tables.length} tables` }); } else { results.push({ name: "Database Integrity", status: "warn", message: "Database exists but missing expected tables", fix: "Run: stackmemory init --interactive" }); } } catch (error) { results.push({ name: "Database Integrity", status: "error", message: `Database error: ${error.message}`, fix: "Remove .stackmemory/context.db and run: stackmemory init" }); } } if (existsSync(MCP_CONFIG_FILE)) { try { const config = JSON.parse(readFileSync(MCP_CONFIG_FILE, "utf8")); if (config.mcpServers?.stackmemory) { results.push({ name: "MCP Configuration", status: "ok", message: "MCP server configured" }); } else { results.push({ name: "MCP Configuration", status: "warn", message: "MCP config file exists but stackmemory server not configured", fix: "Run: stackmemory setup-mcp" }); } } catch { results.push({ name: "MCP Configuration", status: "error", message: "Invalid MCP configuration file", fix: "Run: stackmemory setup-mcp --reset" }); } } else { results.push({ name: "MCP Configuration", status: "warn", message: "MCP not configured for Claude Code", fix: "Run: stackmemory setup-mcp" }); } if (existsSync(HOOKS_JSON)) { try { const hooks = JSON.parse(readFileSync(HOOKS_JSON, "utf8")); const hasTraceHook = !!hooks["tool-use-approval"]; if (hasTraceHook) { results.push({ name: "Claude Hooks", status: "ok", message: "Tool tracing hook installed" }); } else { results.push({ name: "Claude Hooks", status: "warn", message: "Hooks file exists but tracing not configured", fix: "Run: stackmemory hooks install" }); } } catch { results.push({ name: "Claude Hooks", status: "warn", message: "Could not read hooks.json" }); } } else { results.push({ name: "Claude Hooks", status: "warn", message: "Claude hooks not installed (optional)", fix: "Run: stackmemory hooks install" }); } const envChecks = [ { key: "LINEAR_API_KEY", name: "Linear API Key", optional: true }, { key: "TWILIO_ACCOUNT_SID", name: "Twilio Account", optional: true } ]; for (const check of envChecks) { const value = process.env[check.key]; if (value) { results.push({ name: check.name, status: "ok", message: "Environment variable set" }); } else if (!check.optional) { results.push({ name: check.name, status: "error", message: "Required environment variable not set", fix: `Set ${check.key} in your .env file` }); } } const homeStackmemory = join(homedir(), ".stackmemory"); if (existsSync(homeStackmemory)) { try { const testFile = join(homeStackmemory, ".write-test"); writeFileSync(testFile, "test"); const { unlinkSync } = await import("fs"); unlinkSync(testFile); results.push({ name: "File Permissions", status: "ok", message: "~/.stackmemory is writable" }); } catch { results.push({ name: "File Permissions", status: "error", message: "~/.stackmemory is not writable", fix: "Run: chmod 700 ~/.stackmemory" }); } } let hasErrors = false; let hasWarnings = false; for (const result of results) { const icon = result.status === "ok" ? chalk.green("[OK]") : result.status === "warn" ? chalk.yellow("[WARN]") : chalk.red("[ERROR]"); console.log(`${icon} ${result.name}`); console.log(chalk.gray(` ${result.message}`)); if (result.fix) { console.log(chalk.cyan(` Fix: ${result.fix}`)); if (options.fix && result.status !== "ok") { if (result.fix.includes("stackmemory setup-mcp")) { console.log(chalk.gray(" Attempting auto-fix...")); try { execSync("stackmemory setup-mcp", { stdio: "inherit" }); } catch { console.log(chalk.red(" Auto-fix failed")); } } } } if (result.status === "error") hasErrors = true; if (result.status === "warn") hasWarnings = true; } console.log(""); if (hasErrors) { console.log( chalk.red("Some issues need attention. Run suggested fixes above.") ); process.exit(1); } else if (hasWarnings) { console.log( chalk.yellow( "StackMemory is working but some optional features are not configured." ) ); } else { console.log( chalk.green("All checks passed! StackMemory is properly configured.") ); } }); } function createSetupPluginsCommand() { const cmd = new Command("setup-plugins"); cmd.description("Install StackMemory plugins for Claude Code").option("--force", "Overwrite existing plugins").action(async (options) => { console.log( chalk.cyan("Installing StackMemory plugins for Claude Code...\n") ); const pluginsDir = join(CLAUDE_DIR, "plugins"); if (!existsSync(pluginsDir)) { mkdirSync(pluginsDir, { recursive: true }); console.log(chalk.gray(`Created: ${pluginsDir}`)); } const possiblePaths = [ join(process.cwd(), "plugins"), join(__dirname, "..", "..", "..", "plugins"), join(homedir(), ".stackmemory", "plugins") ]; try { const globalRoot = execSync("npm root -g", { encoding: "utf-8" }).trim(); possiblePaths.push( join(globalRoot, "@stackmemoryai", "stackmemory", "plugins") ); } catch { } let sourcePluginsDir; for (const p of possiblePaths) { if (existsSync(p) && existsSync(join(p, "stackmemory"))) { sourcePluginsDir = p; break; } } if (!sourcePluginsDir) { console.log(chalk.red("Could not find StackMemory plugins directory")); console.log(chalk.gray("Searched:")); possiblePaths.forEach((p) => console.log(chalk.gray(` - ${p}`))); process.exit(1); } console.log(chalk.gray(`Source: ${sourcePluginsDir} `)); const plugins = ["stackmemory", "ralph-wiggum"]; let installed = 0; for (const plugin of plugins) { const sourcePath = join(sourcePluginsDir, plugin); const targetPath = join(pluginsDir, plugin); if (!existsSync(sourcePath)) { console.log(chalk.yellow(` [SKIP] ${plugin} - not found in source`)); continue; } if (existsSync(targetPath)) { if (options.force) { try { execSync(`rm -rf "${targetPath}"`, { encoding: "utf-8" }); } catch { console.log( chalk.red(` [ERROR] ${plugin} - could not remove existing`) ); continue; } } else { console.log( chalk.gray(` [EXISTS] ${plugin} - use --force to overwrite`) ); continue; } } try { execSync(`ln -s "${sourcePath}" "${targetPath}"`, { encoding: "utf-8" }); console.log(chalk.green(` [OK] ${plugin}`)); installed++; } catch (err) { console.log( chalk.red(` [ERROR] ${plugin} - ${err.message}`) ); } } console.log(""); if (installed > 0) { console.log(chalk.green(`Installed ${installed} plugin(s)`)); console.log(chalk.gray("\nAvailable commands in Claude Code:")); console.log( chalk.white(" /sm-status ") + chalk.gray("Show StackMemory status") ); console.log( chalk.white(" /sm-capture ") + chalk.gray("Capture work for handoff") ); console.log( chalk.white(" /sm-restore ") + chalk.gray("Restore from last handoff") ); console.log( chalk.white(" /sm-decision ") + chalk.gray("Record a decision") ); console.log( chalk.white(" /sm-help ") + chalk.gray("Show all commands") ); console.log( chalk.white(" /ralph-loop ") + chalk.gray("Start Ralph iteration loop") ); } else { console.log(chalk.yellow("No plugins installed")); } }); return cmd; } function createSetupRemoteCommand() { const cmd = new Command("setup-remote"); cmd.description("Configure remote MCP server to auto-start on boot").option("--port <number>", "Port for remote server", "3847").option("--project <path>", "Project root directory").option("--uninstall", "Remove the auto-start service").option("--status", "Check service status").action(async (options) => { const home = homedir(); const platform = process.platform; const serviceName = platform === "darwin" ? "com.stackmemory.remote-mcp" : "stackmemory-remote-mcp"; const serviceDir = platform === "darwin" ? join(home, "Library", "LaunchAgents") : join(home, ".config", "systemd", "user"); const serviceFile = platform === "darwin" ? join(serviceDir, `${serviceName}.plist`) : join(serviceDir, `${serviceName}.service`); const logDir = join(home, ".stackmemory", "logs"); const pidFile = join(home, ".stackmemory", "remote-mcp.pid"); if (options.status) { console.log(chalk.cyan("\nRemote MCP Server Status\n")); if (platform === "darwin") { try { const result = execSync( `launchctl list | grep ${serviceName} || true`, { encoding: "utf-8" } ); if (result.includes(serviceName)) { console.log(chalk.green("[RUNNING]") + " Service is active"); try { const health = execSync( `curl -s http://localhost:${options.port}/health 2>/dev/null`, { encoding: "utf-8" } ); const data = JSON.parse(health); console.log(chalk.gray(` Project: ${data.projectId}`)); console.log( chalk.gray(` URL: http://localhost:${options.port}/sse`) ); } catch { console.log( chalk.yellow(" Server not responding to health check") ); } } else { console.log(chalk.yellow("[STOPPED]") + " Service not running"); } } catch { console.log(chalk.yellow("[UNKNOWN]") + " Could not check status"); } } else if (platform === "linux") { try { execSync(`systemctl --user is-active ${serviceName}`, { stdio: "pipe" }); console.log(chalk.green("[RUNNING]") + " Service is active"); } catch { console.log(chalk.yellow("[STOPPED]") + " Service not running"); } } console.log(chalk.gray(` Service file: ${serviceFile}`)); console.log(chalk.gray(`Logs: ${logDir}/remote-mcp.log`)); return; } if (options.uninstall) { console.log(chalk.cyan("\nUninstalling Remote MCP Server Service\n")); if (platform === "darwin") { try { execSync(`launchctl unload "${serviceFile}"`, { stdio: "pipe" }); console.log(chalk.green("[OK]") + " Service unloaded"); } catch { console.log(chalk.gray("[SKIP]") + " Service was not loaded"); } if (existsSync(serviceFile)) { const fs = await import("fs/promises"); await fs.unlink(serviceFile); console.log(chalk.green("[OK]") + " Service file removed"); } } else if (platform === "linux") { try { execSync(`systemctl --user stop ${serviceName}`, { stdio: "pipe" }); execSync(`systemctl --user disable ${serviceName}`, { stdio: "pipe" }); console.log(chalk.green("[OK]") + " Service stopped and disabled"); } catch { console.log(chalk.gray("[SKIP]") + " Service was not running"); } if (existsSync(serviceFile)) { const fs = await import("fs/promises"); await fs.unlink(serviceFile); execSync("systemctl --user daemon-reload", { stdio: "pipe" }); console.log(chalk.green("[OK]") + " Service file removed"); } } console.log(chalk.green("\nRemote MCP service uninstalled")); return; } console.log(chalk.cyan("\nSetting up Remote MCP Server Auto-Start\n")); if (platform !== "darwin" && platform !== "linux") { console.log( chalk.red("Auto-start is only supported on macOS and Linux") ); console.log(chalk.gray("\nManual start: stackmemory mcp-remote")); return; } if (!existsSync(serviceDir)) { mkdirSync(serviceDir, { recursive: true }); } if (!existsSync(logDir)) { mkdirSync(logDir, { recursive: true }); } let nodePath; try { nodePath = execSync("which node", { encoding: "utf-8" }).trim(); } catch { nodePath = "/usr/local/bin/node"; } let stackmemoryPath; try { stackmemoryPath = execSync("which stackmemory", { encoding: "utf-8" }).trim(); } catch { try { const npmRoot = execSync("npm root -g", { encoding: "utf-8" }).trim(); stackmemoryPath = join( npmRoot, "@stackmemoryai", "stackmemory", "dist", "cli", "index.js" ); } catch { stackmemoryPath = "npx stackmemory"; } } const projectPath = options.project || home; const port = options.port || "3847"; if (platform === "darwin") { const plist = `<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>Label</key> <string>${serviceName}</string> <key>ProgramArguments</key> <array> <string>${stackmemoryPath.includes("npx") ? "npx" : nodePath}</string> ${stackmemoryPath.includes("npx") ? "<string>stackmemory</string>" : `<string>${stackmemoryPath}</string>`} <string>mcp-remote</string> <string>--port</string> <string>${port}</string> <string>--project</string> <string>${projectPath}</string> </array> <key>RunAtLoad</key> <true/> <key>KeepAlive</key> <dict> <key>SuccessfulExit</key> <false/> </dict> <key>WorkingDirectory</key> <string>${projectPath}</string> <key>StandardOutPath</key> <string>${logDir}/remote-mcp.log</string> <key>StandardErrorPath</key> <string>${logDir}/remote-mcp.error.log</string> <key>EnvironmentVariables</key> <dict> <key>HOME</key> <string>${home}</string> <key>PATH</key> <string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string> <key>NODE_ENV</key> <string>production</string> </dict> <key>ThrottleInterval</key> <integer>10</integer> </dict> </plist>`; writeFileSync(serviceFile, plist); console.log(chalk.green("[OK]") + " Created launchd service file"); try { execSync(`launchctl unload "${serviceFile}" 2>/dev/null`, { stdio: "pipe" }); } catch { } try { execSync(`launchctl load -w "${serviceFile}"`, { stdio: "pipe" }); console.log(chalk.green("[OK]") + " Service loaded and started"); } catch (err) { console.log(chalk.red("[ERROR]") + ` Failed to load service: ${err}`); return; } } else if (platform === "linux") { const service = `[Unit] Description=StackMemory Remote MCP Server Documentation=https://github.com/stackmemoryai/stackmemory After=network.target [Service] Type=simple ExecStart=${stackmemoryPath.includes("npx") ? "npx stackmemory" : `${nodePath} ${stackmemoryPath}`} mcp-remote --port ${port} --project ${projectPath} Restart=on-failure RestartSec=10 WorkingDirectory=${projectPath} Environment=HOME=${home} Environment=PATH=/usr/local/bin:/usr/bin:/bin Environment=NODE_ENV=production StandardOutput=append:${logDir}/remote-mcp.log StandardError=append:${logDir}/remote-mcp.error.log [Install] WantedBy=default.target`; writeFileSync(serviceFile, service); console.log(chalk.green("[OK]") + " Created systemd service file"); try { execSync("systemctl --user daemon-reload", { stdio: "pipe" }); execSync(`systemctl --user enable ${serviceName}`, { stdio: "pipe" }); execSync(`systemctl --user start ${serviceName}`, { stdio: "pipe" }); console.log(chalk.green("[OK]") + " Service enabled and started"); } catch (err) { console.log( chalk.red("[ERROR]") + ` Failed to start service: ${err}` ); return; } } console.log(chalk.cyan("\nVerifying server...\n")); await new Promise((resolve) => setTimeout(resolve, 2e3)); try { const health = execSync( `curl -s http://localhost:${port}/health 2>/dev/null`, { encoding: "utf-8" } ); const data = JSON.parse(health); console.log(chalk.green("[OK]") + " Server is running"); console.log(chalk.gray(` Project: ${data.projectId}`)); } catch { console.log( chalk.yellow("[WARN]") + " Server not responding yet (may still be starting)" ); } console.log( chalk.green("\nRemote MCP Server configured for auto-start!") ); console.log(chalk.cyan("\nConnection info:")); console.log(chalk.white(` URL: http://localhost:${port}/sse`)); console.log(chalk.white(` Health: http://localhost:${port}/health`)); console.log(chalk.gray(` Logs: ${logDir}/remote-mcp.log`)); console.log(chalk.gray(`Service: ${serviceFile}`)); console.log(chalk.cyan("\nFor external access (ngrok):")); console.log(chalk.white(` ngrok http ${port}`)); console.log(chalk.gray(" Then use the ngrok URL + /sse in Claude.ai")); }); return cmd; } function registerSetupCommands(program) { program.addCommand(createSetupMCPCommand()); program.addCommand(createDoctorCommand()); program.addCommand(createSetupPluginsCommand()); program.addCommand(createSetupRemoteCommand()); } export { createDoctorCommand, createSetupMCPCommand, createSetupPluginsCommand, createSetupRemoteCommand, registerSetupCommands }; //# sourceMappingURL=setup.js.map