import * as p from '@clack/prompts'; import pc from 'picocolors'; import { execSync } from 'child_process'; import { spawnHidden } from '../../shared/spawn.js'; import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { homedir } from 'os'; import { dirname, join } from 'path'; import { SettingsDefaultsManager, type SettingsDefaults } from '../../shared/SettingsDefaultsManager.js'; import { USER_SETTINGS_PATH } from '../../shared/paths.js'; import { loadClaudeMemEnv, saveClaudeMemEnv } from '../../shared/EnvManager.js'; import { ensureWorkerStarted, type WorkerStartResult } from '../../services/worker-spawner.js'; import { ensureBun, ensureUv, installPluginDependencies, writeInstallMarker, isInstallCurrent, } from '../install/setup-runtime.js'; import { playBanner } from '../banner.js'; function getSetting(key: K): SettingsDefaults[K] { return SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH)[key]; } const isInteractive = process.stdin.isTTY === true; interface TaskDescriptor { title: string; task: (message: (msg: string) => void) => Promise; } async function runTasks(tasks: TaskDescriptor[]): Promise { if (isInteractive) { await p.tasks(tasks); } else { for (const t of tasks) { const result = await t.task((msg: string) => console.log(` ${msg}`)); console.log(` ${result}`); } } } async function bufferConsole(fn: () => Promise): Promise<{ result: T; output: string }> { if (!isInteractive) { const result = await fn(); return { result, output: '' }; } let buffer = ''; const append = (...args: unknown[]) => { buffer += args.map((a) => (typeof a === 'string' ? a : String(a))).join(' ') + '\n'; }; const orig = { log: console.log, error: console.error, warn: console.warn }; console.log = append; console.error = append; console.warn = append; try { const result = await fn(); return { result, output: buffer }; } finally { console.log = orig.log; console.error = orig.error; console.warn = orig.warn; } } const log = { info: (msg: string) => isInteractive ? p.log.info(msg) : console.log(` ${msg}`), success: (msg: string) => isInteractive ? p.log.success(msg) : console.log(` ${msg}`), warn: (msg: string) => isInteractive ? p.log.warn(msg) : console.warn(` ${msg}`), error: (msg: string) => isInteractive ? p.log.error(msg) : console.error(` ${msg}`), }; import { claudeSettingsPath, ensureDirectoryExists, installedPluginsPath, IS_WINDOWS, knownMarketplacesPath, marketplaceDirectory, npmPackagePluginDirectory, npmPackageRootDirectory, pluginCacheDirectory, pluginsDirectory, readPluginVersion, writeJsonFileAtomic, } from '../utils/paths.js'; import { readJsonSafe } from '../../utils/json-utils.js'; import { shutdownWorkerAndWait } from '../../services/install/shutdown-helper.js'; import { detectInstalledIDEs } from './ide-detection.js'; function registerMarketplace(): void { const knownMarketplaces = readJsonSafe>(knownMarketplacesPath(), {}); knownMarketplaces['thedotmack'] = { source: { source: 'github', repo: 'thedotmack/claude-mem', }, installLocation: marketplaceDirectory(), lastUpdated: new Date().toISOString(), autoUpdate: true, }; ensureDirectoryExists(pluginsDirectory()); writeJsonFileAtomic(knownMarketplacesPath(), knownMarketplaces); } function registerPlugin(version: string): void { const installedPlugins = readJsonSafe>(installedPluginsPath(), {}); if (!installedPlugins.version) installedPlugins.version = 2; if (!installedPlugins.plugins) installedPlugins.plugins = {}; const cachePath = pluginCacheDirectory(version); const now = new Date().toISOString(); installedPlugins.plugins['claude-mem@thedotmack'] = [ { scope: 'user', installPath: cachePath, version, installedAt: now, lastUpdated: now, }, ]; writeJsonFileAtomic(installedPluginsPath(), installedPlugins); } function enablePluginInClaudeSettings(): void { const settings = readJsonSafe>(claudeSettingsPath(), {}); if (!settings.enabledPlugins) settings.enabledPlugins = {}; settings.enabledPlugins['claude-mem@thedotmack'] = true; writeJsonFileAtomic(claudeSettingsPath(), settings); } /** * Disable Claude Code's built-in auto-memory by setting CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 * in ~/.claude/settings.json `env` block. claude-mem provides its own persistent memory * via plugin hooks; the built-in MEMORY.md system creates shadow state outside the user's * control and competes with claude-mem for context window tokens. * * Per anthropics/claude-code#23544, the env var is the only supported toggle. * * Idempotent: only writes when not already set, preserves existing env vars and other * settings keys, and merges atomically. Returns true when a write happened (for the * caller to surface in the install summary). */ export function disableClaudeAutoMemory(): boolean { const settings = readJsonSafe>(claudeSettingsPath(), {}); const env = (settings.env && typeof settings.env === 'object') ? settings.env : {}; if (env.CLAUDE_CODE_DISABLE_AUTO_MEMORY === '1') { return false; } settings.env = { ...env, CLAUDE_CODE_DISABLE_AUTO_MEMORY: '1' }; writeJsonFileAtomic(claudeSettingsPath(), settings); return true; } function makeIDETask(ideId: string, failedIDEs: string[], pendingErrors: string[]): TaskDescriptor | null { const recordFailure = (label: string, output: string) => { failedIDEs.push(ideId); if (output && output.trim().length > 0) { pendingErrors.push(`${label}\n${output.trim()}`); } }; switch (ideId) { case 'claude-code': { return { title: 'Claude Code: registering plugin', task: async () => `Claude Code: plugin registered ${pc.green('OK')}`, }; } case 'cursor': { return { title: 'Cursor: installing hooks + MCP', task: async (message) => { message('Loading Cursor installer…'); const { installCursorHooks, configureCursorMcp } = await import('../../services/integrations/CursorHooksInstaller.js'); message('Installing Cursor hooks…'); const { result: cursorResult, output: hooksOutput } = await bufferConsole(() => installCursorHooks('user')); if (cursorResult !== 0) { recordFailure('Cursor: hook installation failed', hooksOutput); return `Cursor: hook installation failed ${pc.red('FAIL')}`; } message('Configuring Cursor MCP…'); const { result: mcpResult } = await bufferConsole(async () => configureCursorMcp('user')); if (mcpResult === 0) { return `Cursor: hooks + MCP installed ${pc.green('OK')}`; } return `Cursor: hooks installed; MCP setup failed — run \`npx claude-mem cursor mcp\` ${pc.yellow('!')}`; }, }; } case 'gemini-cli': { return { title: 'Gemini CLI: installing hooks', task: async (message) => { message('Loading Gemini CLI installer…'); const { installGeminiCliHooks } = await import('../../services/integrations/GeminiCliHooksInstaller.js'); message('Installing Gemini CLI hooks…'); const { result, output } = await bufferConsole(() => installGeminiCliHooks()); if (result !== 0) { recordFailure('Gemini CLI: hook installation failed', output); return `Gemini CLI: hook installation failed ${pc.red('FAIL')}`; } return `Gemini CLI: hooks installed ${pc.green('OK')}`; }, }; } case 'opencode': { return { title: 'OpenCode: installing plugin', task: async (message) => { message('Loading OpenCode installer…'); const { installOpenCodeIntegration } = await import('../../services/integrations/OpenCodeInstaller.js'); message('Installing OpenCode plugin…'); const { result, output } = await bufferConsole(() => installOpenCodeIntegration()); if (result !== 0) { recordFailure('OpenCode: plugin installation failed', output); return `OpenCode: plugin installation failed ${pc.red('FAIL')}`; } return `OpenCode: plugin installed ${pc.green('OK')}`; }, }; } case 'windsurf': { return { title: 'Windsurf: installing hooks', task: async (message) => { message('Loading Windsurf installer…'); const { installWindsurfHooks } = await import('../../services/integrations/WindsurfHooksInstaller.js'); message('Installing Windsurf hooks…'); const { result, output } = await bufferConsole(() => installWindsurfHooks()); if (result !== 0) { recordFailure('Windsurf: hook installation failed', output); return `Windsurf: hook installation failed ${pc.red('FAIL')}`; } return `Windsurf: hooks installed ${pc.green('OK')}`; }, }; } case 'openclaw': { return { title: 'OpenClaw: installing plugin', task: async (message) => { message('Loading OpenClaw installer…'); const { installOpenClawIntegration } = await import('../../services/integrations/OpenClawInstaller.js'); message('Copying plugin files…'); const { result, output } = await bufferConsole(() => installOpenClawIntegration()); if (result !== 0) { recordFailure('OpenClaw: plugin installation failed', output); return `OpenClaw: plugin installation failed ${pc.red('FAIL')}`; } return `OpenClaw: plugin installed ${pc.green('OK')}`; }, }; } case 'codex-cli': { return { title: 'Codex CLI: registering hooks marketplace', task: async (message) => { message('Loading Codex CLI installer…'); const { installCodexCli } = await import('../../services/integrations/CodexCliInstaller.js'); message('Registering native Codex hooks…'); const { result, output } = await bufferConsole(() => installCodexCli(marketplaceDirectory())); if (result !== 0) { recordFailure('Codex CLI: integration setup failed', output); return `Codex CLI: integration setup failed ${pc.red('FAIL')}`; } return `Codex CLI: hooks marketplace registered ${pc.green('OK')}`; }, }; } case 'copilot-cli': case 'antigravity': case 'goose': case 'roo-code': case 'warp': { const allIDEs = detectInstalledIDEs(); const ideInfo = allIDEs.find((i) => i.id === ideId); const ideLabel = ideInfo?.label ?? ideId; return { title: `${ideLabel}: installing MCP integration`, task: async (message) => { message('Loading MCP installer…'); const { MCP_IDE_INSTALLERS } = await import('../../services/integrations/McpIntegrations.js'); const mcpInstaller = MCP_IDE_INSTALLERS[ideId]; if (!mcpInstaller) { return `${ideLabel}: MCP installer not found ${pc.yellow('!')}`; } message(`Configuring ${ideLabel} MCP…`); const { result, output } = await bufferConsole(() => mcpInstaller()); if (result !== 0) { recordFailure(`${ideLabel}: MCP integration failed`, output); return `${ideLabel}: MCP integration failed ${pc.red('FAIL')}`; } return `${ideLabel}: MCP integration installed ${pc.green('OK')}`; }, }; } default: { const allIDEs = detectInstalledIDEs(); const ide = allIDEs.find((i) => i.id === ideId); if (ide && !ide.supported) { return { title: `${ide.label}: skipping`, task: async () => `${ide.label}: support coming soon ${pc.yellow('!')}`, }; } return null; } } } async function setupIDEs(selectedIDEs: string[]): Promise { const failedIDEs: string[] = []; const pendingErrors: string[] = []; const tasks: TaskDescriptor[] = []; for (const ideId of selectedIDEs) { const taskDescriptor = makeIDETask(ideId, failedIDEs, pendingErrors); if (taskDescriptor) tasks.push(taskDescriptor); } if (tasks.length > 0) { await runTasks(tasks); } for (const errorBlock of pendingErrors) { log.warn(errorBlock); } return failedIDEs; } function detectShellConfigFile(): { path: string; shell: 'zsh' | 'bash' | 'fish' } { const home = homedir(); const shellEnv = process.env.SHELL ?? ''; if (shellEnv.includes('fish')) { return { path: join(home, '.config', 'fish', 'config.fish'), shell: 'fish' }; } if (shellEnv.includes('zsh')) { return { path: join(home, '.zshrc'), shell: 'zsh' }; } if (process.platform === 'darwin') { const bashProfile = join(home, '.bash_profile'); if (existsSync(bashProfile)) return { path: bashProfile, shell: 'bash' }; } return { path: join(home, '.bashrc'), shell: 'bash' }; } function applyClaudeCodePathSetupIfNeeded(): void { const home = homedir(); const claudeBinDir = join(home, '.local', 'bin'); const claudeBinary = join(claudeBinDir, 'claude'); if (!existsSync(claudeBinary)) return; const currentPath = process.env.PATH ?? ''; const pathEntries = currentPath.split(':'); if (pathEntries.includes(claudeBinDir)) return; const { path: configFile, shell } = detectShellConfigFile(); const binPathLiteral = '$HOME/.local/bin'; const exportLine = shell === 'fish' ? `set -gx PATH ${claudeBinDir} $PATH` : `export PATH="${binPathLiteral}:$PATH"`; let existing = ''; if (existsSync(configFile)) { try { existing = readFileSync(configFile, 'utf-8'); } catch (error: unknown) { log.warn(`Could not read ${configFile}: ${error instanceof Error ? error.message : String(error)}`); } } else { try { mkdirSync(dirname(configFile), { recursive: true }); } catch { // Best-effort directory creation. } } if (existing.includes(claudeBinDir) || existing.includes(binPathLiteral)) { log.info(`Claude Code PATH already configured in ${configFile}`); } else { try { const trailing = existing.length === 0 || existing.endsWith('\n') ? '' : '\n'; const block = `${trailing}\n# Added by claude-mem installer for Claude Code\n${exportLine}\n`; writeFileSync(configFile, existing + block, 'utf-8'); log.success(`Added Claude Code to PATH in ${configFile}`); } catch (error: unknown) { log.warn(`Could not update ${configFile}: ${error instanceof Error ? error.message : String(error)}`); log.info(`Run manually: echo '${exportLine}' >> ${configFile}`); return; } } process.env.PATH = `${claudeBinDir}:${currentPath}`; } async function installClaudeCode(): Promise { const command = IS_WINDOWS ? 'powershell -ExecutionPolicy ByPass -c "irm https://claude.ai/install.ps1 | iex"' : 'curl -fsSL https://claude.ai/install.sh | bash'; const spinner = isInteractive ? p.spinner() : null; spinner?.start('Installing Claude Code (this can take a few minutes — downloading the native build)…'); return new Promise((resolve) => { let captured = ''; const child = spawnHidden(command, [], { shell: IS_WINDOWS ? (process.env.ComSpec ?? 'cmd.exe') : '/bin/bash', stdio: spinner ? ['inherit', 'pipe', 'pipe'] : 'inherit', }); child.stdout?.on('data', (chunk: Buffer) => { captured += chunk.toString(); }); child.stderr?.on('data', (chunk: Buffer) => { captured += chunk.toString(); }); child.on('error', (error: Error) => { spinner?.stop('Claude Code install failed', 1); if (captured) process.stderr.write(captured); log.error(`Claude Code install failed: ${error.message}`); log.info('You can install it manually later: https://claude.ai/install.sh'); resolve(false); }); child.on('exit', (code) => { if (code !== 0) { spinner?.stop('Claude Code install failed', 1); if (captured) process.stderr.write(captured); log.error(`Claude Code install failed (exit ${code ?? 'unknown'})`); log.info('You can install it manually later: https://claude.ai/install.sh'); resolve(false); return; } spinner?.stop('Claude Code installed'); if (!IS_WINDOWS) { try { applyClaudeCodePathSetupIfNeeded(); } catch (error: unknown) { log.warn(`Could not auto-apply PATH setup: ${error instanceof Error ? error.message : String(error)}`); } } resolve(true); }); }); } async function promptForIDESelection(): Promise { let detectedIDEs = detectInstalledIDEs(); const claudeCodeInfo = detectedIDEs.find((ide) => ide.id === 'claude-code'); if (claudeCodeInfo && !claudeCodeInfo.detected) { log.warn('Claude Code is not installed. Claude-mem works best in Claude Code, but also works with the IDEs below.'); const choice = await p.select<'install' | 'skip' | 'cancel'>({ message: 'Install Claude Code now?', options: [ { value: 'install', label: 'Yes — install Claude Code (recommended)' }, { value: 'skip', label: 'No — pick another IDE below' }, { value: 'cancel', label: 'Cancel installation' }, ], initialValue: 'install', }); if (p.isCancel(choice) || choice === 'cancel') { p.cancel('Installation cancelled.'); process.exit(0); } if (choice === 'install') { if (await installClaudeCode()) { detectedIDEs = detectInstalledIDEs(); } } } const detected = detectedIDEs.filter((ide) => ide.detected); if (detected.length === 0) { log.warn('No supported IDEs detected — pick the one(s) you plan to use.'); } const options = detectedIDEs.map((ide) => { const detectedTag = ide.detected ? ' [detected]' : ''; const hint = ide.supported ? `${ide.hint}${detectedTag}` : `coming soon${detectedTag}`; return { value: ide.id, label: ide.label, hint, }; }); const result = await p.multiselect({ message: 'Which IDEs do you use?', options, initialValues: [], required: true, }); if (p.isCancel(result)) { p.cancel('Installation cancelled.'); process.exit(0); } return result as string[]; } function copyPluginToMarketplace(): void { const marketplaceDir = marketplaceDirectory(); const packageRoot = npmPackageRootDirectory(); ensureDirectoryExists(marketplaceDir); const allowedTopLevelEntries = [ '.agents', '.codex-plugin', 'plugin', 'package.json', 'package-lock.json', 'openclaw', 'dist', 'LICENSE', 'README.md', 'CHANGELOG.md', ]; for (const entry of allowedTopLevelEntries) { const sourcePath = join(packageRoot, entry); const destPath = join(marketplaceDir, entry); if (!existsSync(sourcePath)) continue; if (existsSync(destPath)) { rmSync(destPath, { recursive: true, force: true }); } cpSync(sourcePath, destPath, { recursive: true, force: true, }); } } function copyPluginToCache(version: string): void { const sourcePluginDirectory = npmPackagePluginDirectory(); const cachePath = pluginCacheDirectory(version); rmSync(cachePath, { recursive: true, force: true }); ensureDirectoryExists(cachePath); cpSync(sourcePluginDirectory, cachePath, { recursive: true, force: true }); } function runNpmInstallInMarketplace(): void { const marketplaceDir = marketplaceDirectory(); const packageJsonPath = join(marketplaceDir, 'package.json'); if (!existsSync(packageJsonPath)) return; // --legacy-peer-deps suppresses a known false-positive ERESOLVE between // tree-sitter@0.21 and @tree-sitter-grammars/* peer ranges. The native // bindings path is unused (we load .wasm), so the conflict is benign. // Revisit if real peer constraints are added to the marketplace deps. execSync('npm install --omit=dev --legacy-peer-deps', { cwd: marketplaceDir, stdio: 'pipe', encoding: 'utf8', ...(IS_WINDOWS ? { shell: process.env.ComSpec ?? 'cmd.exe' } : {}), }); } function mergeSettings(updates: Record): boolean { const path = USER_SETTINGS_PATH; try { let current: Record = {}; if (existsSync(path)) { try { const raw = readFileSync(path, 'utf-8'); const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object' && parsed.env && typeof parsed.env === 'object') { current = { ...parsed.env }; } else if (parsed && typeof parsed === 'object') { current = { ...parsed }; } } catch (parseError: unknown) { console.warn('[install] Failed to parse existing settings.json, starting from empty:', parseError instanceof Error ? parseError.message : String(parseError)); current = {}; } } else { const dir = dirname(path); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } } for (const [key, value] of Object.entries(updates)) { current[key] = value; } writeFileSync(path, JSON.stringify(current, null, 2), 'utf-8'); return true; } catch (error: unknown) { log.error(`Failed to write settings to ${path}: ${error instanceof Error ? error.message : String(error)}`); return false; } } type ProviderId = 'claude' | 'gemini' | 'openrouter'; type ClaudeAccessMode = 'subscription' | 'api-key'; type ClaudeApiMode = 'direct' | 'gateway'; type RuntimeId = 'worker' | 'server-beta'; function readRawStoredAuthMethod(): 'subscription' | 'api-key' | 'gateway' | undefined { try { if (!existsSync(USER_SETTINGS_PATH)) return undefined; const raw = JSON.parse(readFileSync(USER_SETTINGS_PATH, 'utf-8')) as Record; const flat = (raw.env && typeof raw.env === 'object' ? raw.env : raw) as Record; const value = flat.CLAUDE_MEM_CLAUDE_AUTH_METHOD; if (value === 'subscription' || value === 'api-key' || value === 'gateway') return value; return undefined; } catch { return undefined; } } function resolveClaudeAuthMethod(): 'subscription' | 'api-key' | 'gateway' { const stored = readRawStoredAuthMethod(); if (stored) return stored; const env = loadClaudeMemEnv(); if (env.ANTHROPIC_BASE_URL?.trim()) return 'gateway'; if (env.ANTHROPIC_API_KEY?.trim()) return 'api-key'; return 'subscription'; } async function promptRuntime(): Promise { if (!isInteractive) { mergeSettings({ CLAUDE_MEM_RUNTIME: 'worker' }); return 'worker'; } const selected = await p.select({ message: 'Which runtime should claude-mem start after install?', options: [ { value: 'worker', label: 'Worker', hint: 'stable compatibility path' }, { value: 'server-beta', label: 'Server (beta)', hint: 'REST V1, API keys, team-ready storage' }, ], initialValue: 'worker', }); if (p.isCancel(selected)) { p.cancel('Installation cancelled.'); process.exit(0); } mergeSettings({ CLAUDE_MEM_RUNTIME: selected, }); if (selected === 'server-beta') { await maybeBootstrapServerBetaApiKey(); } return selected; } async function maybeBootstrapServerBetaApiKey(): Promise { // Only attempt if Postgres is configured. Without DATABASE_URL we cannot // reach the api_keys table — the operator must configure the server first // and rerun `claude-mem server keys rotate`. if (!process.env.CLAUDE_MEM_SERVER_DATABASE_URL) { log.warn( 'Skipping local hook API key bootstrap: CLAUDE_MEM_SERVER_DATABASE_URL is not set. ' + 'Run `npx claude-mem server keys rotate` after configuring Postgres to provision a key.', ); return; } try { const { bootstrapServerBetaApiKey, persistServerBetaSettings } = await import( '../../services/hooks/server-beta-bootstrap.js' ); const result = await bootstrapServerBetaApiKey(); persistServerBetaSettings(USER_SETTINGS_PATH, { apiKey: result.rawKey, projectId: result.projectId, }); log.info( `Provisioned local hook API key (project=${result.projectId.slice(0, 8)}…). ` + 'Settings saved with mode 0600.', ); } catch (error: unknown) { log.warn( `Failed to bootstrap server-beta API key: ${error instanceof Error ? error.message : String(error)}. ` + 'Hooks will fall back to the worker until you run `npx claude-mem server keys rotate`.', ); } } async function promptProvider(options: InstallOptions): Promise { const initialProvider = (getSetting('CLAUDE_MEM_PROVIDER') as ProviderId) || 'claude'; const persistClaudeProvider = (authMethod?: 'subscription' | 'api-key' | 'gateway') => { const resolvedAuthMethod = authMethod ?? resolveClaudeAuthMethod(); const wrote = mergeSettings({ CLAUDE_MEM_PROVIDER: 'claude', CLAUDE_MEM_CLAUDE_AUTH_METHOD: resolvedAuthMethod, }); if (wrote) log.info('Saved Claude Agent SDK configuration to ~/.claude-mem/settings.json'); }; const useSubscriptionAuth = () => { persistClaudeProvider('subscription'); saveClaudeMemEnv({ ANTHROPIC_API_KEY: '', ANTHROPIC_BASE_URL: '', ANTHROPIC_AUTH_TOKEN: '', }); log.info('Configured claude-mem to use your logged-in Claude SDK account.'); }; const configureDirectApiKey = async (): Promise => { const existing = loadClaudeMemEnv().ANTHROPIC_API_KEY || ''; if (existing.trim().length > 0) { const choice = await p.select<'keep' | 'replace'>({ message: 'An Anthropic API key is already configured. Keep it or enter a new one?', options: [ { value: 'keep', label: 'Keep existing key' }, { value: 'replace', label: 'Enter a new key (rotate)' }, ], initialValue: 'keep', }); if (p.isCancel(choice)) { log.warn('API key prompt cancelled — leaving existing configuration untouched.'); return; } if (choice === 'keep') { saveClaudeMemEnv({ ANTHROPIC_API_KEY: existing.trim(), ANTHROPIC_BASE_URL: '', ANTHROPIC_AUTH_TOKEN: '', }); persistClaudeProvider('api-key'); return; } } const apiKeyResult = await p.password({ message: 'Paste your Anthropic API key:', mask: '*', validate: (v?: string) => (!v || v.trim().length === 0) ? 'API key required' : undefined, }); if (p.isCancel(apiKeyResult)) { log.warn('API key prompt cancelled — leaving existing configuration untouched.'); return; } saveClaudeMemEnv({ ANTHROPIC_API_KEY: String(apiKeyResult).trim(), ANTHROPIC_BASE_URL: '', ANTHROPIC_AUTH_TOKEN: '', }); persistClaudeProvider('api-key'); log.info('Saved Anthropic API key for the Claude Agent SDK path.'); }; const configureGateway = async (): Promise => { const existing = loadClaudeMemEnv(); const baseUrlResult = await p.text({ message: 'Gateway URL:', placeholder: existing.ANTHROPIC_BASE_URL || 'http://localhost:4000', defaultValue: existing.ANTHROPIC_BASE_URL || '', validate: (v?: string) => { const value = v?.trim() ?? ''; if (!value) return 'Gateway URL required'; try { new URL(value); return undefined; } catch { return 'Enter a valid URL, for example http://localhost:4000'; } }, }); if (p.isCancel(baseUrlResult)) { log.warn('Gateway setup cancelled — leaving existing configuration untouched.'); return; } const tokenResult = await p.password({ message: 'Gateway key/token (leave blank to keep current token, or type a new one):', mask: '*', }); const tokenCancelled = p.isCancel(tokenResult); const tokenInput = tokenCancelled ? '' : String(tokenResult).trim(); const env: Record = { ANTHROPIC_API_KEY: '', ANTHROPIC_BASE_URL: String(baseUrlResult).trim(), }; if (!tokenCancelled && tokenInput.length > 0) { env.ANTHROPIC_AUTH_TOKEN = tokenInput; } saveClaudeMemEnv(env); persistClaudeProvider('gateway'); if (tokenCancelled || tokenInput.length === 0) { log.info('Gateway URL saved; existing gateway token preserved.'); } else { log.info('Configured Claude Agent SDK gateway in ~/.claude-mem/.env.'); } }; if (!isInteractive) { if (options.provider) { if (options.provider === 'claude') { persistClaudeProvider(); return 'claude'; } const wrote = mergeSettings({ CLAUDE_MEM_PROVIDER: options.provider }); if (wrote) log.info(`Saved provider=${options.provider} to ~/.claude-mem/settings.json`); log.warn(`Provider=${options.provider} requested non-interactively. API key prompt skipped — set CLAUDE_MEM_${options.provider.toUpperCase()}_API_KEY and CLAUDE_MEM_PROVIDER in settings.json or env manually if not already set.`); return options.provider; } return initialProvider; } const runClaudeAuthFlow = async (): Promise => { const resolvedAuthMethod = resolveClaudeAuthMethod(); const initialAccessMode: ClaudeAccessMode = resolvedAuthMethod === 'subscription' ? 'subscription' : 'api-key'; const result = await p.select({ message: 'Do you use a subscription plan or an API key/gateway for the memory agent?', options: [ { value: 'subscription', label: 'Subscription plan (recommended — uses your logged-in Claude SDK account)' }, { value: 'api-key', label: 'API key or gateway (Anthropic, LiteLLM, or compatible proxy)' }, ], initialValue: initialAccessMode, }); if (p.isCancel(result)) { p.cancel('Installation cancelled.'); process.exit(0); } if (result === 'subscription') { useSubscriptionAuth(); return; } const apiModeResult = await p.select({ message: 'How should claude-mem connect?', options: [ { value: 'direct', label: 'Anthropic API key' }, { value: 'gateway', label: 'LiteLLM or custom gateway' }, ], initialValue: resolvedAuthMethod === 'gateway' || loadClaudeMemEnv().ANTHROPIC_BASE_URL ? 'gateway' : 'direct', }); if (p.isCancel(apiModeResult)) { p.cancel('Installation cancelled.'); process.exit(0); } if (apiModeResult === 'gateway') { await configureGateway(); } else { await configureDirectApiKey(); } }; let selectedProvider: ProviderId; if (options.provider) { selectedProvider = options.provider; } else { const providerResult = await p.select({ message: 'Which memory provider do you want to use?', options: [ { value: 'claude', label: 'Claude Agent SDK (recommended)' }, { value: 'gemini', label: 'Gemini' }, { value: 'openrouter', label: 'OpenRouter' }, ], initialValue: initialProvider, }); if (p.isCancel(providerResult)) { p.cancel('Installation cancelled.'); process.exit(0); } selectedProvider = providerResult; } if (selectedProvider === 'claude') { await runClaudeAuthFlow(); return 'claude'; } const providerLabel = selectedProvider === 'gemini' ? 'Gemini' : 'OpenRouter'; const keyEnvName = selectedProvider === 'gemini' ? 'CLAUDE_MEM_GEMINI_API_KEY' : 'CLAUDE_MEM_OPENROUTER_API_KEY'; const existingKey = getSetting(keyEnvName as keyof SettingsDefaults) as string | undefined; if (existingKey && existingKey.trim().length > 0) { const wrote = mergeSettings({ CLAUDE_MEM_PROVIDER: selectedProvider }); if (wrote) log.info(`Saved provider=${selectedProvider} to ~/.claude-mem/settings.json`); return selectedProvider; } const apiKeyResult = await p.password({ message: `Paste your ${providerLabel} API key:`, mask: '*', validate: (v?: string) => (!v || v.trim().length === 0) ? 'API key required' : undefined, }); if (p.isCancel(apiKeyResult)) { log.warn(`API key prompt cancelled — falling back to Claude provider.`); persistClaudeProvider(); return 'claude'; } const apiKey = String(apiKeyResult).trim(); const wrote = mergeSettings({ CLAUDE_MEM_PROVIDER: selectedProvider, [keyEnvName]: apiKey, }); if (wrote) { log.info(`Saved provider=${selectedProvider} to ~/.claude-mem/settings.json`); } return selectedProvider; } async function promptClaudeModel(options: InstallOptions): Promise { const allowed = new Set([ 'claude-haiku-4-5-20251001', 'claude-sonnet-4-6', 'claude-opus-4-7', ]); const allowCustomModel = resolveClaudeAuthMethod() === 'gateway'; if (options.model && !allowCustomModel) { if (!allowed.has(options.model)) { throw new Error( `Unknown Claude model: ${options.model}. Allowed: ${[...allowed].join(', ')}`, ); } const wrote = mergeSettings({ CLAUDE_MEM_MODEL: options.model }); if (wrote) { log.info(`Saved Claude model=${options.model} to ~/.claude-mem/settings.json`); } return; } if (options.model && allowCustomModel) { const wrote = mergeSettings({ CLAUDE_MEM_MODEL: options.model }); if (wrote) { log.info(`Saved gateway model=${options.model} to ~/.claude-mem/settings.json`); } return; } if (!isInteractive) return; const initialModel = getSetting('CLAUDE_MEM_MODEL'); if (allowCustomModel) { const result = await p.text({ message: 'Which model should the gateway use?', placeholder: 'claude-haiku-4-5-20251001', defaultValue: initialModel || 'claude-haiku-4-5-20251001', validate: (v?: string) => (!v || v.trim().length === 0) ? 'Model required' : undefined, }); if (p.isCancel(result)) { p.cancel('Installation cancelled.'); process.exit(0); } const selectedModel = String(result).trim(); const wrote = mergeSettings({ CLAUDE_MEM_MODEL: selectedModel }); if (wrote) { log.info(`Saved gateway model=${selectedModel} to ~/.claude-mem/settings.json`); } return; } const initialValue = allowed.has(initialModel) ? initialModel : 'claude-haiku-4-5-20251001'; const result = await p.select({ message: 'Which Claude model should claude-mem use to compress observations?\nThis runs whenever you and Claude touch a file — keep it cheap and fast.', options: [ { value: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5 (recommended — fast, cheap, great for compression)' }, { value: 'claude-sonnet-4-6', label: 'Sonnet 4.6 (balanced quality and cost)' }, { value: 'claude-opus-4-7', label: 'Opus 4.7 (highest quality, most expensive)' }, ], initialValue, }); if (p.isCancel(result)) { p.cancel('Installation cancelled.'); process.exit(0); } const selectedModel = result as string; const wrote = mergeSettings({ CLAUDE_MEM_MODEL: selectedModel }); if (wrote) { log.info(`Saved Claude model=${selectedModel} to ~/.claude-mem/settings.json`); } } export interface InstallOptions { ide?: string; provider?: 'claude' | 'gemini' | 'openrouter'; model?: string; noAutoStart?: boolean; } export async function runInstallCommand(options: InstallOptions = {}): Promise { const version = readPluginVersion(); if (isInteractive) { await playBanner(); p.intro(pc.bgCyan(pc.black(' claude-mem install '))); } else { console.log('claude-mem install'); } const marketplaceDir = marketplaceDirectory(); const alreadyInstalled = existsSync(join(marketplaceDir, 'plugin', '.claude-plugin', 'plugin.json')); let existingVersion: string | undefined; if (alreadyInstalled) { try { const existingPluginJson = JSON.parse( readFileSync(join(marketplaceDir, 'plugin', '.claude-plugin', 'plugin.json'), 'utf-8'), ); existingVersion = existingPluginJson.version ?? undefined; } catch (error: unknown) { console.warn('[install] Failed to read existing plugin version:', error instanceof Error ? error.message : String(error)); } } const dot = pc.dim('·'); const segments = [`${pc.bold('claude-mem')} ${pc.cyan(`v${version}`)}`]; if (existingVersion && existingVersion !== version) { segments.push(`installed ${pc.yellow(`v${existingVersion}`)}`); } else if (existingVersion) { segments.push(pc.dim('reinstall')); } log.info(segments.join(` ${dot} `)); if (alreadyInstalled) { if (process.stdin.isTTY) { const shouldContinue = await p.confirm({ message: 'Overwrite existing installation?', initialValue: true, }); if (p.isCancel(shouldContinue) || !shouldContinue) { p.cancel('Installation cancelled.'); process.exit(0); } } } let selectedIDEs: string[]; if (options.ide) { selectedIDEs = [options.ide]; const allIDEs = detectInstalledIDEs(); const match = allIDEs.find((i) => i.id === options.ide); if (match && !match.supported) { log.error(`Support for ${match.label} coming soon.`); process.exit(1); } if (!match) { log.error(`Unknown IDE: ${options.ide}`); log.info(`Available IDEs: ${allIDEs.map((i) => i.id).join(', ')}`); process.exit(1); } } else if (process.stdin.isTTY) { selectedIDEs = await promptForIDESelection(); } else { selectedIDEs = ['claude-code']; } const selectedRuntime = await promptRuntime(); const selectedProvider = await promptProvider(options); if (selectedProvider === 'claude') { await promptClaudeModel(options); } let workerStartResult: WorkerStartResult = 'dead'; // Claude Code consumes the marketplace plugin system directly, so any selection // (claude-code or otherwise) needs the marketplace + plugin registration steps. // The only time we'd skip is a hypothetical no-IDE install, which the prompt above // doesn't allow today. const needsMarketplace = selectedIDEs.length > 0; { if (needsMarketplace) { const installPort = getSetting('CLAUDE_MEM_WORKER_PORT'); const shutdownSpinner = isInteractive ? p.spinner() : null; shutdownSpinner?.start('Stopping running worker (so we can overwrite cleanly)…'); try { const result = await shutdownWorkerAndWait(installPort, 10000); if (shutdownSpinner) { shutdownSpinner.stop( result.workerWasRunning ? 'Stopped running worker before overwrite.' : 'No worker running — proceeding.', ); } else if (result.workerWasRunning) { log.info('Stopped running worker before overwrite.'); } } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); if (shutdownSpinner) { shutdownSpinner.stop(`Pre-overwrite worker shutdown failed: ${message}`, 1); } else { console.warn('[install] Pre-overwrite worker shutdown failed:', message); } } } const tasks: TaskDescriptor[] = [ { title: 'Caching plugin version', task: async (message) => { message(`Caching v${version}...`); copyPluginToCache(version); return `Plugin cached (v${version}) ${pc.green('OK')}`; }, }, { title: 'Registering marketplace', task: async () => { registerMarketplace(); return `Marketplace registered ${pc.green('OK')}`; }, }, { title: 'Registering plugin', task: async () => { registerPlugin(version); return `Plugin registered ${pc.green('OK')}`; }, }, { title: 'Enabling plugin in Claude settings', task: async () => { enablePluginInClaudeSettings(); return `Plugin enabled ${pc.green('OK')}`; }, }, { title: 'Setting up runtime (first install can take ~30s)', task: async (message) => { message('Checking Bun…'); const { version: bunVersion } = await ensureBun(); message('Checking uv…'); const { version: uvVersion } = await ensureUv(); const cacheDir = pluginCacheDirectory(version); if (!isInstallCurrent(cacheDir, version)) { message('Installing plugin dependencies…'); const { bunPath } = await ensureBun(); await installPluginDependencies(cacheDir, bunPath); writeInstallMarker(cacheDir, version, bunVersion, uvVersion); } return `Runtime ready (Bun ${bunVersion}, uv ${uvVersion}) ${pc.green('OK')}`; }, }, ]; if (needsMarketplace) { tasks.unshift({ title: 'Copying plugin files to marketplace', task: async (message) => { message('Copying to marketplace directory...'); copyPluginToMarketplace(); return `Plugin files copied ${pc.green('OK')}`; }, }); tasks.push({ title: 'Installing marketplace dependencies', task: async (message) => { message('Running npm install...'); try { runNpmInstallInMarketplace(); return `Dependencies installed ${pc.green('OK')}`; } catch (error: unknown) { console.warn('[install] npm install error:', error instanceof Error ? error.message : String(error)); return `Dependencies may need manual install ${pc.yellow('!')}`; } }, }); } await runTasks(tasks); } const failedIDEs = await setupIDEs(selectedIDEs); // Disable Claude Code's built-in auto-memory (CLAUDE_CODE_DISABLE_AUTO_MEMORY=1) // for any install that targets claude-code. claude-mem's hook-based memory is the // intended source of cross-session context; the built-in MEMORY.md system creates // shadow state and competes for context-window tokens. // Tri-state so the summary can distinguish "wrote", "already set", and "failed". // A boolean would conflate the error path with "already set", which is misleading // when a write fails mid-install (the warn would say one thing, the summary another). let autoMemoryStatus: 'disabled' | 'already-disabled' | 'failed' | null = null; if (selectedIDEs.includes('claude-code')) { try { const wrote = disableClaudeAutoMemory(); autoMemoryStatus = wrote ? 'disabled' : 'already-disabled'; if (wrote) { log.success('Claude Code: auto-memory disabled (CLAUDE_CODE_DISABLE_AUTO_MEMORY=1).'); } else { log.info('Claude Code: auto-memory already disabled, leaving settings.json untouched.'); } } catch (error: unknown) { // Don't fail the install over this — surface the warning and continue. autoMemoryStatus = 'failed'; log.warn(`Could not disable Claude Code auto-memory: ${error instanceof Error ? error.message : String(error)}`); } } const autoStartSkipped = !isInteractive || options.noAutoStart; await runTasks([ { title: selectedRuntime === 'server-beta' ? 'Starting server beta daemon' : 'Starting worker daemon', task: async (message) => { if (autoStartSkipped) { return isInteractive ? `Skipped (--no-auto-start)` : `Skipped (non-TTY)`; } const port = Number(getSetting('CLAUDE_MEM_WORKER_PORT')); const marketplaceScriptPath = join(marketplaceDirectory(), 'plugin', 'scripts', 'worker-service.cjs'); const cacheScriptPath = join(pluginCacheDirectory(version), 'scripts', 'worker-service.cjs'); const scriptPath = existsSync(marketplaceScriptPath) ? marketplaceScriptPath : cacheScriptPath; message(`Spawning ${selectedRuntime === 'server-beta' ? 'server beta' : 'worker'} on port ${port}...`); workerStartResult = await ensureWorkerStarted(port, scriptPath); switch (workerStartResult) { case 'ready': return `${selectedRuntime === 'server-beta' ? 'Server beta' : 'Worker'} ready at http://localhost:${port} ${pc.green('OK')}`; case 'warming': return `${selectedRuntime === 'server-beta' ? 'Server beta' : 'Worker'} starting on port ${port} — finishing in background ${pc.yellow('⏳')}`; case 'dead': return `${selectedRuntime === 'server-beta' ? 'Server beta' : 'Worker'} did not start — try \`${selectedRuntime === 'server-beta' ? 'npx claude-mem server start' : 'npx claude-mem start'}\` manually ${pc.yellow('!')}`; } }, }, ]); const installStatus = failedIDEs.length > 0 ? 'Installation Partial' : 'Installation Complete'; const summaryLines = [ `Version: ${pc.cyan(version)}`, `Plugin dir: ${pc.cyan(marketplaceDir)}`, `IDEs: ${pc.cyan(selectedIDEs.join(', '))}`, ]; if (autoMemoryStatus === 'disabled') { summaryLines.push(`Auto-memory: ${pc.cyan('disabled')} (CLAUDE_CODE_DISABLE_AUTO_MEMORY=1)`); } else if (autoMemoryStatus === 'already-disabled') { summaryLines.push(`Auto-memory: ${pc.cyan('already disabled')} (CLAUDE_CODE_DISABLE_AUTO_MEMORY=1)`); } else if (autoMemoryStatus === 'failed') { summaryLines.push(`Auto-memory: ${pc.red('write failed')} (see warning above)`); } if (failedIDEs.length > 0) { summaryLines.push(`Failed: ${pc.red(failedIDEs.join(', '))}`); } if (isInteractive) { p.note(summaryLines.join('\n'), installStatus); } else { console.log(`\n ${installStatus}`); summaryLines.forEach(l => console.log(` ${l}`)); } const workerPort = getSetting('CLAUDE_MEM_WORKER_PORT'); let actualPort: number | string = workerPort; let workerReady = false; // Don't poll the worker or imply it's "still starting" when autostart was // intentionally skipped (--no-auto-start, or non-interactive default). The // user knows they have to start it themselves; lying about a starting worker // is misleading. if (!autoStartSkipped) { const healthSpinner = isInteractive ? p.spinner() : null; healthSpinner?.start(`Verifying worker on port ${workerPort}…`); try { const healthResponse = await fetch(`http://127.0.0.1:${workerPort}/api/health`, { signal: AbortSignal.timeout(3000), }); if (healthResponse.ok) { workerReady = true; try { const body = await healthResponse.json() as { port?: number | string }; if (body && (typeof body.port === 'number' || typeof body.port === 'string')) { actualPort = body.port; } } catch { // Health endpoint returned non-JSON — keep using the requested port. } } healthSpinner?.stop( workerReady ? `Worker ready at http://localhost:${actualPort}` : `Worker reachable but not ready on port ${workerPort}`, ); } catch { healthSpinner?.stop(`Worker not yet responding on port ${workerPort} (still starting)`); } } const finalWorkerState = workerStartResult as WorkerStartResult; const workerAlive = finalWorkerState !== 'dead' || workerReady; const runtimeLabel = selectedRuntime === 'server-beta' ? 'Server beta' : 'Worker'; const runtimeStartCommand = selectedRuntime === 'server-beta' ? 'npx claude-mem server start' : 'npx claude-mem start'; const workerHeadline = autoStartSkipped ? `${pc.yellow('!')} ${runtimeLabel} autostart skipped — start it manually with ${pc.bold(runtimeStartCommand)}` : workerReady || finalWorkerState === 'ready' ? `${pc.green('✓')} ${runtimeLabel} running at ${pc.underline(`http://localhost:${actualPort}`)}` : `${pc.yellow('⏳')} ${runtimeLabel} starting at ${pc.underline(`http://localhost:${actualPort}`)} — give it ~30s, then refresh`; const nextSteps = autoStartSkipped ? [ workerHeadline, ``, `${pc.bold('First success:')} once the worker is running, keep ${pc.underline(`http://localhost:${workerPort}`)} open in a browser, then open Claude Code in any project. Observations stream in as Claude reads, edits, and runs commands.`, ``, `${pc.bold('Two paths from here:')}`, ` ${pc.cyan('A.')} Just start working. Memory builds passively from your first prompt. (Recommended.)`, ` ${pc.cyan('B.')} Front-load it: open Claude Code and run ${pc.bold('/learn-codebase')} to ingest the whole repo (~5 min, optional).`, ``, `Memory injection starts on your second session in a project.`, `Everything stays in ${pc.cyan('~/.claude-mem')} on this machine.`, ``, `${pc.dim('How it works: /how-it-works · Disable first-session hint: CLAUDE_MEM_WELCOME_HINT_ENABLED=false')}`, `${pc.dim('Note: close all Claude Code sessions before uninstalling, or ~/.claude-mem will be recreated by active hooks.')}`, ] : workerAlive ? [ workerHeadline, ``, `${pc.bold('First success:')} keep that URL open in a browser, then open Claude Code in any project. Observations stream in as Claude reads, edits, and runs commands.`, ``, `${pc.bold('Two paths from here:')}`, ` ${pc.cyan('A.')} Just start working. Memory builds passively from your first prompt. (Recommended.)`, ` ${pc.cyan('B.')} Front-load it: open Claude Code and run ${pc.bold('/learn-codebase')} to ingest the whole repo (~5 min, optional).`, ``, `Memory injection starts on your second session in a project.`, `Everything stays in ${pc.cyan('~/.claude-mem')} on this machine.`, ``, `${pc.dim('How it works: /how-it-works · Disable first-session hint: CLAUDE_MEM_WELCOME_HINT_ENABLED=false')}`, `${pc.dim('Note: close all Claude Code sessions before uninstalling, or ~/.claude-mem will be recreated by active hooks.')}`, ] : [ `${pc.yellow('!')} Worker not yet ready on port ${pc.cyan(String(workerPort))} -- still starting up; check ${pc.bold('claude-mem status')} later, or start manually: ${pc.bold('npx claude-mem start')}`, ``, `${pc.bold('First success:')} keep ${pc.underline(`http://localhost:${workerPort}`)} open in a browser, then open Claude Code in any project. Observations stream in as Claude reads, edits, and runs commands.`, ``, `${pc.bold('Two paths from here:')}`, ` ${pc.cyan('A.')} Just start working. Memory builds passively from your first prompt. (Recommended.)`, ` ${pc.cyan('B.')} Front-load it: open Claude Code and run ${pc.bold('/learn-codebase')} to ingest the whole repo (~5 min, optional).`, ``, `Memory injection starts on your second session in a project.`, `Everything stays in ${pc.cyan('~/.claude-mem')} on this machine.`, ``, `${pc.dim('How it works: /how-it-works · Disable first-session hint: CLAUDE_MEM_WELCOME_HINT_ENABLED=false')}`, `${pc.dim('Note: close all Claude Code sessions before uninstalling, or ~/.claude-mem will be recreated by active hooks.')}`, ]; if (isInteractive) { p.note(nextSteps.join('\n'), 'Next Steps'); if (failedIDEs.length > 0) { p.outro(pc.yellow('claude-mem installed with some IDE setup failures.')); } else { p.outro(pc.green('claude-mem installed successfully!')); } } else { console.log('\n Next Steps'); nextSteps.forEach(l => console.log(` ${l}`)); if (failedIDEs.length > 0) { console.log('\nclaude-mem installed with some IDE setup failures.'); process.exitCode = 1; } else { console.log('\nclaude-mem installed successfully!'); } } } export async function runRepairCommand(): Promise { const version = readPluginVersion(); const cacheDir = pluginCacheDirectory(version); if (isInteractive) { p.intro(pc.bgCyan(pc.black(' claude-mem repair '))); } else { console.log('claude-mem repair'); } log.info(`Version: ${pc.cyan(version)}`); await runTasks([ { title: 'Setting up runtime', task: async (message) => { message('Checking Bun…'); const { version: bunVersion } = await ensureBun(); message('Checking uv…'); const { version: uvVersion } = await ensureUv(); // Repair must regenerate the cache if it was wiped (e.g. user ran // `rm -rf ~/.claude/plugins/cache`). Without this, bun install would // fail immediately with no package.json to install against. if (!existsSync(join(cacheDir, 'package.json'))) { message('Cache missing — repopulating from npm package…'); copyPluginToCache(version); } message('Reinstalling plugin dependencies…'); const { bunPath } = await ensureBun(); await installPluginDependencies(cacheDir, bunPath); writeInstallMarker(cacheDir, version, bunVersion, uvVersion); return `Runtime ready (Bun ${bunVersion}, uv ${uvVersion}) ${pc.green('OK')}`; }, }, ]); if (isInteractive) { p.outro(pc.green('claude-mem repair complete.')); } else { console.log('claude-mem repair complete.'); } }