import * as p from '@clack/prompts'; import { styleText } from 'node:util'; import { randomUUID } from 'crypto'; import { spawnSync } from 'child_process'; import { loadTelemetryConfig, saveTelemetryConfig } from '../../services/telemetry/consent.js'; import { captureCliEvent } from '../../services/telemetry/cli-telemetry.js'; import { spawnHidden } from '../../shared/spawn.js'; import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { readProjectAttribution, replaceOwnedFiles } from '../../shared/owned-file-install.js'; import { homedir, hostname } from 'os'; import { dirname, join } from 'path'; import { SettingsDefaultsManager, type SettingsDefaults } from '../../shared/SettingsDefaultsManager.js'; import { resolveDbPath, USER_SETTINGS_PATH } from '../../shared/paths.js'; import { updateSettingsDocument } from '../../shared/settings-document.js'; import { loadClaudeMemEnv, saveClaudeMemEnv } from '../../shared/EnvManager.js'; import { ensureWorkerStarted, getLastWorkerBootFailure, type WorkerStartResult } from '../../services/worker-spawner.js'; import { formatHostForUrl } from '../../shared/worker-utils.js'; import { ensureBun, ensureUv, installPluginDependencies, provisionTreeSitterCli, writeInstallMarker, isInstallCurrent, getBunPath, } from '../install/setup-runtime.js'; import { formatUsageSummaryLines, readUsageSummary, type UsageSummary } from '../install/usage-summary.js'; import { playBanner } from '../banner.js'; import { normalizeRuntimeFlag } from './server-runtime-setup.js'; import { ErrorSeverity } from '../install/error-taxonomy.js'; import { clearLastInstallError, createInstallSummary, flushSummary, installerError, InstallAbortError, type InstallSummary, } from '../install/error-reporter.js'; import { extractEresolveBlock, isEresolve, npmErrorCode, runNpmStrict } from '../install/npm-install-helper.js'; import { buildProviderLabels, CMEM_INSTALLER_OAUTH_POLL_URL, CMEM_INSTALLER_OAUTH_START_URL, CMEM_PRO_BASE_URL, CMEM_PRO_MODEL, PROVIDER_PROMPT_MESSAGE, } from '../cmem-pro-costs.js'; import { clearProFallback, isCmemGatewayUrl } from '../../shared/cmem-gateway.js'; import { PRO_TRIAL_LABEL, PRO_TRIAL_PITCH, proTrialUrl } from '../../shared/pro-promo.js'; import { buildAnthropicMaxLocalSettings, buildCmemActivationSettings, buildHostObserverSettings, buildNonInteractiveOpenRouterSettings, buildPersonalOpenRouterSettings, resolveCmemMemoryCredentials, type DeliveredCmemMemoryCredentials, } from '../cmem-memory-credentials.js'; import { parseTeamBrainTeamRef, type TeamBrainTeamRef } from '../../shared/teambrain.js'; import { relayedLine } from '../../shared/relayed-text.js'; import { endSetupSession, INITIAL_SETUP_STATE, readSetupState, startSetupSession, writeSetupState } from '../../shared/setup-state.js'; import { intentCredentialHeaders, readIntentCredentials } from '../../shared/intent-credentials.js'; import { runBrowserSetup, settingsChangesNeedingRestart, shouldUseBrowserSetup, waitForBackgroundWorkToFinish, type BrowserSetupOutcome } from '../install/browser-setup.js'; function getSetting(key: K): SettingsDefaults[K] { return SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH)[key]; } const isInteractive = process.stdin.isTTY === true; /** How long `install` waits for the tree-sitter CLI download before warning and moving on to sign-in. */ const TREE_SITTER_INSTALL_BUDGET_MS = 2 * 60 * 1000; /** * Which package manager launched this CLI (npx / bunx / pnpm / yarn), parsed * from npm_config_user_agent ("npm/10.8.2 node/v22.14.0 darwin arm64 ..."). * Bounded enum for telemetry — never raw user-agent content. */ function detectInstallMethod(): string { const agent = process.env.npm_config_user_agent ?? ''; const name = agent.split('/')[0]?.trim().toLowerCase(); if (name === 'npm' || name === 'bun' || name === 'pnpm' || name === 'yarn') return name; if (process.versions.bun) return 'bun'; return 'unknown'; } 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}`); } } } /** * Tick a task's spinner message with elapsed seconds. The multi-minute * dependency installs used to sit on one static message (and previously a * blocked event loop), which read as a stalled install. Returns a stop * function for a finally block. Non-interactive runs get the label once — * a per-second console.log line would spam CI logs. */ function startHeartbeat(message: (msg: string) => void, label: string): () => void { message(label); if (!isInteractive) return () => {}; const started = Date.now(); const timer = setInterval(() => { const elapsed = Math.round((Date.now() - started) / 1000); message(`${label} ${styleText('dim', `(${elapsed}s — still working)`)}`); }, 1000); return () => clearInterval(timer); } 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; } } export 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 { prunePluginCacheSafely } from '../utils/prune-cache.js'; import { readJsonSafe } from '../../utils/json-utils.js'; import { readFlatSettings } from '../utils/settings.js'; import { shutdownWorkerAndWait, type ShutdownBlocker } from '../../services/install/shutdown-helper.js'; import { holdSpawnLock, SPAWN_LOCK_STALE_MS } from '../../shared/worker-spawn-gate.js'; import { readOwnedWorkerPidInfo, type PidInfo } from '../../supervisor/process-registry.js'; import { isWorkerAutostartDisabled } from '../../shared/worker-autostart.js'; import { detectInstalledIDEs } from './ide-detection.js'; import { initialIDESelection } from './ide-detection.js'; import { canonicalIntegrationId } from '../../shared/integration-id.js'; import { checkWindowsGitBash } from '../utils/windows-git-bash-preflight.js'; import { claudeCodeVersionTooOldWarning, detectClaudeCodeVersion } from '../install/claude-code-version.js'; import { ensureLauncherOnPath, ensureLocalBinOnShellPath } from '../../launcher/install-launcher.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; } type ClaudeAutoMemoryChoice = 'disable' | 'leave-enabled' | 'not-applicable'; async function resolveClaudeAutoMemoryChoice( selectedIDEs: string[], options: InstallOptions, ): Promise { if (!selectedIDEs.includes('claude-code')) { return 'not-applicable'; } if (options.disableAutoMemory) { return 'disable'; } if (!isInteractive) { return 'leave-enabled'; } const choice = await p.select<'leave-enabled' | 'disable'>({ message: 'Disable Claude Code auto-memory?', options: [ { value: 'leave-enabled', label: 'Leave enabled', hint: 'Recommended; keeps Claude Code native memory visible on startup.', }, { value: 'disable', label: 'Disable auto-memory', hint: 'Only if you explicitly want claude-mem to replace native startup memory.', }, ], initialValue: 'leave-enabled', }); // @clack/prompts 1.8: isCancel narrows to unique CANCEL_SYMBOL, not generic symbol. if (p.isCancel(choice) || typeof choice === 'symbol') { p.cancel('Installation cancelled.'); process.exit(0); } return choice; } export function makeIDETask(ideId: string, summary: InstallSummary, dshProfile?: string): TaskDescriptor | null { const recordFailure = (label: string, output: string) => { // Route every per-IDE failure through the central decision point. A single // IDE failure is FAIL_LOUD_PER_IDE (partial install); the summary headline // and exit code reflect it. The stderr is preserved verbatim in `details`. installerError(ErrorSeverity.FAIL_LOUD_PER_IDE, { component: label, ide: ideId, phase: 'ide-install', cause: new Error(label), details: output && output.trim().length > 0 ? output.trim().slice(0, 4000) : undefined, }, summary); }; switch (ideId) { case 'claude-code': { return { title: 'Claude Code: registering plugin', task: async () => `Claude Code: plugin registered ${styleText('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 ${styleText('red', 'FAIL')}`; } message('Configuring Cursor MCP…'); const { result: mcpResult } = await bufferConsole(async () => configureCursorMcp('user')); if (mcpResult === 0) { return `Cursor: hooks + MCP installed ${styleText('green', 'OK')}`; } return `Cursor: hooks installed; MCP setup failed — run \`npx claude-mem mcp\` ${styleText('yellow', '!')}`; }, }; } case 'grok-bot': { return { title: 'Grok Bot: installing transcript watch', task: async (message) => { message('Configuring Grok Bot transcript watch…'); const { installGrokBotIntegration } = await import('../../services/integrations/GrokBotInstaller.js'); const { result, output } = await bufferConsole(async () => installGrokBotIntegration()); if (result !== 0) { recordFailure('Grok Bot: transcript watch installation failed', output); return `Grok Bot: transcript watch installation failed ${styleText('red', 'FAIL')}`; } return `Grok Bot: transcript watch installed ${styleText('green', 'OK')}`; }, }; } case 'kimi': { return { title: 'Kimi Code: installing hooks + MCP', task: async (message) => { message('Loading Kimi installer…'); const { installKimiHooks, configureKimiMcp } = await import('../../services/integrations/KimiHooksInstaller.js'); message('Installing Kimi hooks…'); const { result: hooksResult, output: hooksOutput } = await bufferConsole(async () => installKimiHooks()); if (hooksResult !== 0) { recordFailure('Kimi Code: hook installation failed', hooksOutput); return `Kimi Code: hook installation failed ${styleText('red', 'FAIL')}`; } message('Configuring Kimi MCP…'); const { result: mcpResult } = await bufferConsole(async () => configureKimiMcp()); if (mcpResult === 0) { return `Kimi Code: hooks + MCP installed ${styleText('green', 'OK')}`; } return `Kimi Code: hooks installed; MCP setup failed — run \`npx claude-mem kimi install\` ${styleText('yellow', '!')}`; }, }; } case 'opencode': { return { title: 'OpenCode: installing plugin', task: async (message) => { message('Loading OpenCode installer…'); const { installOpenCodeIntegration, getOpenCodeAgentsMdPath, OPENCODE_MCP_REGISTRATION_INCOMPLETE, OPENCODE_OLD_CONTEXT_BLOCK_LEFT, } = await import('../../services/integrations/OpenCodeInstaller.js'); message('Installing OpenCode plugin…'); const { result, output } = await bufferConsole(() => installOpenCodeIntegration()); if (result === OPENCODE_MCP_REGISTRATION_INCOMPLETE) { // The plugin is installed; only the MCP entry is missing. That is a // partial install, not a failed IDE: record a WARN_CONTINUE warning // (exit 0) and keep the integration's captured output so its // remediation reaches the user after the spinners. installerError(ErrorSeverity.WARN_CONTINUE, { component: 'opencode', phase: 'ide-install', cause: new Error('OpenCode plugin installed, but MCP registration is incomplete (mcp-server.cjs not found).'), remediation: 'Restore the plugin build, then re-run `npx claude-mem install --ide=opencode` to register the MCP server.', details: output, }, summary); return `OpenCode: plugin installed; MCP registration incomplete ${styleText('yellow', '!')}`; } if (result === OPENCODE_OLD_CONTEXT_BLOCK_LEFT) { // Installed, but the stale memory block an older install wrote into // the global AGENTS.md is still there: a warning with the remedy. installerError(ErrorSeverity.WARN_CONTINUE, { component: 'opencode', phase: 'ide-install', cause: new Error(`OpenCode plugin installed, but the old claude-mem memory block in ${getOpenCodeAgentsMdPath()} could not be removed; OpenCode shows it in every project.`), remediation: 'Delete the block from that file (or fix its permissions and re-run `npx claude-mem install --ide=opencode`).', details: output, }, summary); return `OpenCode: plugin installed; old AGENTS.md memory block left in place ${styleText('yellow', '!')}`; } if (result !== 0) { recordFailure('OpenCode: plugin installation failed', output); return `OpenCode: plugin installation failed ${styleText('red', 'FAIL')}`; } return `OpenCode: plugin installed ${styleText('green', 'OK')}`; }, }; } case 'hermes': { return { title: 'Hermes Agent: installing memory provider', task: async (message) => { message('Loading Hermes installer…'); const { installHermesIntegration } = await import('../../services/integrations/HermesInstaller.js'); message('Installing the memory provider plugin…'); const { result, output } = await bufferConsole(async () => installHermesIntegration()); if (result !== 0) { recordFailure('Hermes Agent: memory provider installation failed', output); return `Hermes Agent: memory provider installation failed ${styleText('red', 'FAIL')}`; } return `Hermes Agent: memory provider installed ${styleText('green', 'OK')}`; }, }; } case 'pi': { return { title: 'Pi: installing memory extension', task: async () => { const { installPiExtension } = await import('../../services/integrations/PiInstaller.js'); const { result, output } = await bufferConsole(async () => installPiExtension()); if (result !== 0) { recordFailure('Pi: memory extension installation failed', output); return 'Pi: extension installation failed'; } return 'Pi: extension files installed; automatic capture is unverified. Pi 0.79.6 provides manual recall only. ' + 'Check your Pi version and update Pi if needed: https://github.com/thedotmack/claude-mem/blob/main/docs/pi-native-capture.md'; }, }; } case 'dsh': { return { title: 'DeepSeek Harness: installing native memory', task: async () => { const { installDeepSeekHarness } = await import('../../services/integrations/DeepSeekHarnessInstaller.js'); const { result, output } = await bufferConsole(() => installDeepSeekHarness(dshProfile)); if (result === 2) { installerError(ErrorSeverity.WARN_CONTINUE, { component: 'dsh', phase: 'ide-install', cause: new Error('DeepSeek Harness plugin installed, but transcript capture is incomplete.'), remediation: 'Check the transcript config and CLAUDE_MEM_TRANSCRIPTS_ENABLED, then re-run npx claude-mem install --ide dsh --dsh-profile ' + (dshProfile ?? 'web') + '.', details: output, }, summary); return 'DeepSeek Harness: plugin installed; transcript setup incomplete'; } if (result !== 0) { recordFailure('DeepSeek Harness: native memory installation failed', output); return 'DeepSeek Harness: installation failed'; } return 'DeepSeek Harness: native memory installed'; }, }; } case 'omp': { return { title: 'OMP: installing hooks', task: async (message) => { message('Loading OMP installer…'); const { installOmpHooks } = await import('../../services/integrations/OmpHooksInstaller.js'); message('Installing OMP hooks…'); const { result, output } = await bufferConsole(() => installOmpHooks()); if (result !== 0) { recordFailure('OMP: hook installation failed', output); return `OMP: hook installation failed ${styleText('red', 'FAIL')}`; } return `OMP: hooks installed ${styleText('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 ${styleText('red', 'FAIL')}`; } return `Windsurf: hooks installed ${styleText('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 ${styleText('red', 'FAIL')}`; } return `OpenClaw: plugin installed ${styleText('green', 'OK')}`; }, }; } case 't3code': { return { title: 'T3 Code: registering native provider plugins', task: async (message) => { message('Installing hooks and MCP for T3 Code providers…'); const { installT3Code } = await import('../../services/integrations/T3CodeInstaller.js'); const { result, output } = await bufferConsole(() => installT3Code(marketplaceDirectory())); if (result !== 0) { recordFailure('T3 Code: integration setup failed', output); return `T3 Code: integration setup failed ${styleText('red', 'FAIL')}`; } return `T3 Code: native provider plugins registered ${styleText('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 ${styleText('red', 'FAIL')}`; } return `Codex CLI: hooks marketplace registered ${styleText('green', 'OK')}`; }, }; } case 'antigravity': { return { title: 'Antigravity: installing hooks + MCP', task: async (message) => { message('Loading Antigravity CLI installer…'); const { installAntigravityCliHooks } = await import('../../services/integrations/AntigravityCliHooksInstaller.js'); message('Installing Antigravity hooks + MCP…'); const { result, output } = await bufferConsole(() => installAntigravityCliHooks()); if (result !== 0) { recordFailure('Antigravity: hooks + MCP installation failed', output); return `Antigravity: hooks + MCP installation failed ${styleText('red', 'FAIL')}`; } return `Antigravity: hooks + MCP installed ${styleText('green', 'OK')}`; }, }; } case 'copilot-cli': 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 ${styleText('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 ${styleText('red', 'FAIL')}`; } return `${ideLabel}: MCP integration installed ${styleText('green', 'OK')}`; }, }; } default: { return null; } } } async function setupIDEs(selectedIDEs: string[], summary: InstallSummary, dshProfile?: string): Promise { const tasks: TaskDescriptor[] = []; for (const ideId of selectedIDEs) { const taskDescriptor = makeIDETask(ideId, summary, dshProfile); if (taskDescriptor) tasks.push(taskDescriptor); } if (tasks.length > 0) { await runTasks(tasks); } // FAIL_LOUD_PER_IDE failures were recorded on the summary; if EVERY selected // IDE failed, escalate to an ABORT (all-ides-failed) — a fully failed install // must not print "Installation Complete". if (selectedIDEs.length > 0 && summary.failedIDEs.length === selectedIDEs.length) { installerError(ErrorSeverity.ABORT, { component: 'all-ides', phase: 'ide-install', cause: new Error(`All ${selectedIDEs.length} selected IDE integrations failed.`), }, summary); } return summary.failedIDEs; } function applyClaudeCodePathSetupIfNeeded(): void { // Claude Code's native installer drops `claude` in ~/.local/bin; only then // does this caller need ~/.local/bin on PATH. The appender itself is shared // with the claude-mem launcher setup (src/launcher/install-launcher.ts). if (!existsSync(join(homedir(), '.local', 'bin', 'claude'))) return; ensureLocalBinOnShellPath({ purposeLabel: 'Claude Code', logger: log }); } export function claudeCodeInstallCommand(windows = IS_WINDOWS): string { return windows ? 'powershell -ExecutionPolicy ByPass -c "irm https://claude.ai/install.ps1 | iex"' : 'set -o pipefail; curl -fsSL https://claude.ai/install.sh | bash'; } async function installClaudeCode(): Promise { const command = claudeCodeInstallCommand(); const installShell = IS_WINDOWS ? (process.env.ComSpec ?? 'cmd.exe') : '/bin/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: installShell, 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?.error('Claude Code install failed'); 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?.error('Claude Code install failed'); 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) { // [ANTI-PATTERN IGNORED]: the failure is already surfaced to the user via the interactive-aware log.warn wrapper below (p.log.warn in a TTY, console.warn otherwise); PATH setup is best-effort after a successful install. log.warn(`Could not auto-apply PATH setup: ${error instanceof Error ? error.message : String(error)}`); } } resolve(true); }); }); } async function promptForIDESelection(): Promise { const 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]' : ''; return { value: ide.id, label: ide.label, hint: `${ide.hint}${detectedTag}`, }; }); // Prefer the agents already installed; Claude Code is the fallback when none are detected. const preselected = initialIDESelection(detectedIDEs); const result = await p.multiselect({ message: 'Which agents do you use?', options, initialValues: preselected, required: true, }); if (p.isCancel(result)) { p.cancel('Installation cancelled.'); process.exit(0); } const selected = result as string[]; if (selected.includes('claude-code') && !detectedIDEs.find(ide => ide.id === 'claude-code')?.detected) { const installHost = await p.confirm({ message: 'Install Claude Code now?', initialValue: true }); if (p.isCancel(installHost)) { p.cancel('Installation cancelled.'); process.exit(0); } if (installHost && !await installClaudeCode()) { log.warn('Claude Code installation failed. Choose an agent again, or decline the host installation to set it up later.'); return promptForIDESelection(); } } return selected; } function copyPluginToMarketplace(): void { const marketplaceDir = marketplaceDirectory(); const packageRoot = npmPackageRootDirectory(); const attribution = readProjectAttribution(packageRoot); ensureDirectoryExists(marketplaceDir); const allowedTopLevelEntries = [ '.agents', '.claude-plugin', '.codex-plugin', '.cursor-plugin', 'claude-mem-cursor', 'claude-mem-grok-bot', 'plugin', 'package-lock.json', 'openclaw', 'omp', 'hermes', 'pi', 'dsh', 'dist', '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, }); } replaceOwnedFiles(marketplaceDir, attribution); writeTrimmedMarketplacePackageJson(packageRoot, marketplaceDir); writeTrimmedMarketplaceManifest(marketplaceDir); } /** * Keep only the marketplace entries whose local `source` shipped. * * The repo's root .claude-plugin/marketplace.json also lists claude-mem-cowork * (`./cowork`), which the npm package does not ship. Copied verbatim, the * manifest would advertise a plugin whose source is missing from the * marketplace directory, so drop every entry whose relative source path does * not exist there. Non-path sources (a GitHub repo object) are kept as-is. */ export function writeTrimmedMarketplaceManifest(marketplaceDir: string): void { const manifestPath = join(marketplaceDir, '.claude-plugin', 'marketplace.json'); if (!existsSync(manifestPath)) return; const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as { plugins?: unknown }; if (!Array.isArray(manifest.plugins)) return; const shipped = manifest.plugins.filter((entry) => { const source = (entry as { source?: unknown } | null)?.source; return typeof source !== 'string' || !source.startsWith('.') || existsSync(join(marketplaceDir, source)); }); if (shipped.length === manifest.plugins.length) return; writeFileSync(manifestPath, `${JSON.stringify({ ...manifest, plugins: shipped }, null, 2)}\n`); } /** * Write a runtime-only package.json into the marketplace directory. * * The root package.json declares ~40 dev-only tree-sitter grammars whose peer * ranges conflict (@derekstride/tree-sitter-sql wants tree-sitter@^0.21.0 while * @tree-sitter-grammars/tree-sitter-lua wants tree-sitter@^0.22.4). Copying it * verbatim makes `npm install --omit=dev` still resolve those dev edges and * abort with ERESOLVE (#3636). A consumer install needs only the two live * runtime deps, so we strip devDependencies and trustedDependencies before npm * ever sees the graph. */ export function writeTrimmedMarketplacePackageJson(packageRoot: string, marketplaceDir: string): void { const sourcePath = join(packageRoot, 'package.json'); if (!existsSync(sourcePath)) return; const pkg = JSON.parse(readFileSync(sourcePath, 'utf-8')) as Record; delete pkg.devDependencies; delete pkg.trustedDependencies; writeFileSync(join(marketplaceDir, 'package.json'), `${JSON.stringify(pkg, null, 2)}\n`); } async function copyPluginToCache(version: string): Promise { const sourcePluginDirectory = npmPackagePluginDirectory(); const cachePath = pluginCacheDirectory(version); rmSync(cachePath, { recursive: true, force: true }); ensureDirectoryExists(cachePath); cpSync(sourcePluginDirectory, cachePath, { recursive: true, force: true }); // Prune superseded versions now that the new one has landed. Without this the // cache grew one directory per release forever, and every retained directory // stayed a runnable old-version worker source (#4105). The safe prune keeps // the just-written version plus N-1 and protects any live worker's version — // the repair path reaches here without stopping the worker, so a running // older worker must never lose its source directory. It also keeps the // newest version whose dependencies are complete: the one just copied has // none until "Setting up runtime" installs them, and that step can fail. // Pruning is housekeeping: it runs inside runTasks, before the sign-in/trial // step, so any failure (a corrupt installed_plugins.json, say) only warns. try { const pruned = await prunePluginCacheSafely({ additionalProtectedVersions: [version] }); if (pruned.retainedForLiveWorker) { log.info('Skipped cache prune: a worker is running but its version could not be read; retaining all versions.'); } else if (pruned.removed.length > 0) { log.info(`Pruned ${pruned.removed.length} stale plugin cache version(s): ${pruned.removed.join(', ')}`); } for (const failure of pruned.failed) { log.warn(`Could not prune cache version ${failure.version}: ${failure.reason}`); } } catch (error: unknown) { log.warn(`Skipped cache prune: ${error instanceof Error ? error.message : String(error)}`); } } function writeMarketplaceInstallMarkers( marketplaceDir: string, version: string, bunVersion: string, uvVersion: string, ): void { writeInstallMarker(marketplaceDir, version, bunVersion, uvVersion); // Hooks execute from marketplace/plugin, and Codex caches only that nested // directory. Keep the runtime marker beside the package.json the hook reads // so SessionStart does not report a completed install as missing. writeInstallMarker(join(marketplaceDir, 'plugin'), version, bunVersion, uvVersion); } /** * Install marketplace dependencies, strict-first. * * Phase 4 of plans/04-installer-transparency.md: the old code ALWAYS passed * `--legacy-peer-deps`, papering over any real peer conflict unconditionally. * Now we run strict first and only fall back to `--legacy-peer-deps` on a * confirmed ERESOLVE token, announced loudly. `--ignore-scripts` is the default * (v12.6.2 lesson: a transitive postinstall can hang the install). */ async function runNpmInstallInMarketplace(summary: InstallSummary): Promise { const marketplaceDir = marketplaceDirectory(); const packageJsonPath = join(marketplaceDir, 'package.json'); if (!existsSync(packageJsonPath)) return; const baseFlags = ['install', '--omit=dev', '--ignore-scripts']; const strictResult = await runNpmStrict(marketplaceDir, baseFlags); if (strictResult.code === 0) return; if (strictResult.timedOut) { installerError(ErrorSeverity.ABORT, { component: 'marketplace-npm-install', phase: 'marketplace-deps', cause: new Error('npm install timed out'), details: strictResult.stderr.slice(0, 4000), }, summary); } if (!isEresolve(strictResult.stderr)) { // A strict failure with no ERESOLVE is a real bug — never retry, ABORT. // npm's own error code goes in the cause so the taxonomy can name the fix. const npmCode = npmErrorCode(strictResult.stderr); installerError(ErrorSeverity.ABORT, { component: 'marketplace-npm-install', phase: 'marketplace-deps', cause: new Error(`npm install failed (exit ${strictResult.code})${npmCode ? `: ${npmCode}` : ''}`), details: strictResult.stderr.slice(0, 4000), }, summary); } // Confirmed ERESOLVE — log loudly, attempt one fallback with --legacy-peer-deps. log.warn('npm reported an ERESOLVE peer-dependency conflict in marketplace deps; retrying once with --legacy-peer-deps.'); log.warn(extractEresolveBlock(strictResult.stderr)); const legacyResult = await runNpmStrict(marketplaceDir, [...baseFlags, '--legacy-peer-deps']); if (legacyResult.code === 0) { summary.warnings.push({ component: 'marketplace-npm-install', message: 'tree-sitter peer-dep ERESOLVE was resolved with the --legacy-peer-deps fallback. Benign for the marketplace install; re-evaluate when tree-sitter peer ranges change.', remediation: 'No action required.', }); return; } installerError(ErrorSeverity.ABORT, { component: 'marketplace-npm-install', phase: 'marketplace-deps', cause: new Error(`npm install --legacy-peer-deps still failed (exit ${legacyResult.code}): ERESOLVE`), details: legacyResult.stderr.slice(0, 4000), }, summary); } /** * Merge installer settings into settings.json through the shared settings * document boundary. The whole document is kept (a Claude-Code-style `env` * wrapper and its root peers survive). An unreadable file is no longer reset * to `{}`: it is moved aside to `settings.json.corrupt-` (the user's * bytes are kept) and a fresh document is written, so a corrupt file can never * stop setup or drop the sign-in's memory key. Returns false only when the * write itself fails; the worker and hooks refuse-and-report instead. The * boundary writes settings.json owner-only from the first byte (it can carry * the CMEM Pro setup token and provider API keys). */ export function mergeSettings( updates: Record, settingsPath: string = USER_SETTINGS_PATH, ): boolean { const result = updateSettingsDocument(settingsPath, updates, {}, undefined, { quarantineCorrupt: true }); if (result.status === 'refused') { const reason = result.error instanceof Error ? result.error.message : String(result.error); // A quarantined file is moved back when the fresh write fails; quarantinedTo // survives only if even that move failed, so say where the bytes are. log.error(result.quarantinedTo ? `Failed to write settings to ${settingsPath}: ${reason}. Its unreadable previous contents are in ${result.quarantinedTo}.` : `Failed to write settings to ${settingsPath}: ${reason}`); return false; } if (result.quarantinedTo) { log.warn(`${settingsPath} could not be read, so it was moved to ${result.quarantinedTo} and a fresh settings file was started. Copy back any settings you still need from it.`); } return true; } type ProviderId = 'claude' | 'codex' | 'gemini' | 'openrouter' | 'openai-compatible' | 'host'; /** * What the installer prompt may offer. `cmem` is a prompt-only sentinel: picking * it configures the generic OpenAI-compatible path (base URL + model + key) and * persists CLAUDE_MEM_PROVIDER='openrouter'. The worker only understands 'claude' | * 'codex' | 'gemini' | 'openrouter' | 'openai-compatible', so 'cmem' must never * reach settings.json. */ type ProviderChoice = ProviderId | 'cmem'; // Phase 1d: Persisted DB literals (`server_beta_schema_migrations`, job_type // enums, `server-beta-worker` lockedBy marker) are intentionally preserved in // the source code; runtime-selector dual-accepts both `'server'` and // `'server-beta'` settings values, but the installer writes the new canonical // form `'server'` going forward (settings keys: CLAUDE_MEM_SERVER_{URL, // API_KEY,PROJECT_ID}). type RuntimeId = 'worker' | 'server'; /** Read only persisted values: environment secrets must never be copied to disk. */ function readPersistedInstallerSettings(): Record { try { return readFlatSettings(USER_SETTINGS_PATH) ?? {}; } catch { // settings.json is optional and may be hand-edited; provider prompts retain // their normal recovery paths when it cannot be read. return {}; } } function readRawStoredAuthMethod(): 'subscription' | 'api-key' | 'gateway' | undefined { try { const value = readFlatSettings(USER_SETTINGS_PATH)?.CLAUDE_MEM_CLAUDE_AUTH_METHOD; if (value === 'subscription' || value === 'api-key' || value === 'gateway') return value; return undefined; } catch { // [ANTI-PATTERN IGNORED]: settings.json is optional and may be absent or hand-edited into invalid JSON; falling back to env-based auth detection in resolveClaudeAuthMethod is the designed recovery. 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'; } const DEFAULT_SERVER_RUNTIME_BASE_URL = 'http://127.0.0.1:37877'; function requireSupportedRuntime(selectedIDEs: string[], runtime: RuntimeId): void { if (runtime === 'server' && selectedIDEs.some(id => id === 'pi' || id === 'dsh')) { log.error('Pi and DeepSeek Harness currently require --runtime worker.'); process.exit(1); } } async function promptRuntime(options: InstallOptions, selectedIDEs: string[]): Promise { // #2543 — non-interactive runtime selection via `--runtime`. When the flag is // present we never prompt and never fall back to the worker path: we resolve // the requested runtime deterministically and, for the server runtime, plan + // execute the server-specific setup (Docker stack, key gen, IDE MCP config). if (options.runtime !== undefined) { const requested = normalizeRuntimeFlag(options.runtime); if (requested === null) { log.error(`Unknown --runtime: ${options.runtime}. Allowed: worker, server`); process.exit(1); } requireSupportedRuntime(selectedIDEs, requested); if (requested === 'server') { await setupServerRuntimeNonInteractive(options); return 'server'; } mergeSettings({ CLAUDE_MEM_RUNTIME: 'worker' }); return 'worker'; } 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', label: 'Server (beta)', hint: 'REST V1, API keys, team-ready storage' }, ], initialValue: 'worker', }); // @clack/prompts 1.8: isCancel narrows to unique CANCEL_SYMBOL, not generic symbol. if (p.isCancel(selected) || typeof selected === 'symbol') { p.cancel('Installation cancelled.'); process.exit(0); } requireSupportedRuntime(selectedIDEs, selected); mergeSettings({ CLAUDE_MEM_RUNTIME: selected, }); if (selected === 'server') { await maybeBootstrapServerApiKey(); } return selected; } // #2543 — set up the server runtime non-interactively. Docker stack bring-up // is config-only here (we log the command an operator must run / a CI // provisioner executes); key generation reuses the same bootstrap path as the // interactive flow (createServerApiKey via server-bootstrap), and the IDE MCP // config target is recorded in settings so hooks resolve the server runtime. async function setupServerRuntimeNonInteractive(options: InstallOptions): Promise { const serverBaseUrl = (options.serverUrl ?? '').trim() || DEFAULT_SERVER_RUNTIME_BASE_URL; mergeSettings({ CLAUDE_MEM_RUNTIME: 'server', CLAUDE_MEM_SERVER_URL: serverBaseUrl }); log.info( 'Server runtime selected. Bring up the bundled stack with ' + '`docker compose up -d postgres valkey claude-mem-server claude-mem-worker` ' + `(pg + redis/valkey). The server listens at ${serverBaseUrl}.`, ); // The server mounts its MCP endpoint at `/mcp` over HTTP (vs. the // worker's stdio transport); trailing slashes are trimmed so we never emit // `http://host//mcp`. log.info( `IDE MCP config target for the server runtime: http ${serverBaseUrl.replace(/\/+$/, '')}/mcp`, ); await maybeBootstrapServerApiKey(); } async function maybeBootstrapServerApiKey(): 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 { await bootstrapAndPersistServerApiKey(); } catch (error: unknown) { // [ANTI-PATTERN IGNORED]: the failure is already surfaced to the user via the interactive-aware log.warn wrapper below (p.log.warn in a TTY, console.warn otherwise), including the manual remediation command. log.warn( `Failed to bootstrap server 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 bootstrapAndPersistServerApiKey(): Promise { const { bootstrapServerApiKey, revokeServerApiKey, persistServerSettings } = await import( '../../services/hooks/server-bootstrap.js' ); const result = await bootstrapServerApiKey(); const persisted = persistServerSettings(USER_SETTINGS_PATH, { apiKey: result.rawKey, projectId: result.projectId, }); if (persisted) { log.info( `Provisioned local hook API key (project=${result.projectId.slice(0, 8)}…). ` + 'Settings saved with mode 0600.', ); return; } // The key exists server-side but no hook can use it: revoke it rather than // leave an orphaned live credential, and say how to recover. await revokeServerApiKey(result.apiKeyId).catch((error: unknown) => { log.warn(`Could not revoke the unpersisted server API key: ${error instanceof Error ? error.message : String(error)}`); }); log.warn('Server API key was provisioned but settings.json could not be updated. Repair or restore ~/.claude-mem/settings.json and rerun the installer.'); } /** * Best-effort "open this URL in the user's browser". Every failure mode is * non-fatal — the caller has already printed the URL, so the worst case is the * user clicks it themselves. */ export function openBrowser(url: string): void { try { if (process.platform === 'darwin') { spawnSync('open', [url], { stdio: 'ignore' }); } else if (process.platform === 'win32') { spawnSync('cmd', ['/c', 'start', '', url], { stdio: 'ignore' }); } else { spawnSync('xdg-open', [url], { stdio: 'ignore' }); } } catch { // [ANTI-PATTERN IGNORED]: opening a browser is a convenience, not a step of the // install; the recovery is the URL already printed above this call, which the // user can open by hand. A headless box legitimately has no opener at all. } } /** Exported for tests: the provider step of `install` and `update`. */ export async function promptProvider( options: InstallOptions, /** * Null only when login was skipped for an explicit local provider. * That path cannot reach the CMEM branch below, which * re-checks rather than assuming. */ pairing: InstallerOAuthPairing | null, version: string, ): Promise { const initialProvider = (getSetting('CLAUDE_MEM_PROVIDER') as ProviderId) || 'claude'; const persistedSettings = readPersistedInstallerSettings(); 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 = () => { const wrote = mergeSettings(buildAnthropicMaxLocalSettings(persistedSettings)); if (!wrote) { p.cancel('Could not save the local Anthropic Max configuration.'); process.exit(1); } saveClaudeMemEnv({ ANTHROPIC_API_KEY: '', ANTHROPIC_BASE_URL: '', ANTHROPIC_AUTH_TOKEN: '', }); log.info('Disabled cloud sync and saved local Anthropic Max configuration.'); log.info('Configured claude-mem to use your logged-in Claude SDK account.'); }; // A provider reused from settings on a non-interactive run is already fully // configured. Re-running the claude branch would call useSubscriptionAuth(), // which blanks cloud-sync and the Pro memory key — a persisted Pro config // must survive a non-TTY `npx claude-mem update` untouched. if (options.providerSource === 'persisted' && options.provider) { return options.provider; } let selectedProvider: ProviderChoice; if (options.provider) { selectedProvider = options.provider; } else { if (!isInteractive) { throw new Error('Non-interactive provider validation did not run.'); } options.providerSource = 'prompt'; const labels = buildProviderLabels(); // Multiselect gives both choices square controls. Exactly one provider is // still required; selecting both re-opens the prompt instead of guessing. while (true) { const providerResult = await p.multiselect({ message: PROVIDER_PROMPT_MESSAGE, options: [ { value: 'cmem', label: labels.cmem, hint: labels.cmemHint }, { value: 'claude', label: labels.claude, hint: labels.claudeHint }, ], // CMEM Pro pre-selected: it is the recommended path and the one the // funnel is built around. Selecting it no longer means "pay now" — // it opens the offer page to read first. initialValues: ['cmem'], required: true, }); // @clack/prompts 1.8: isCancel narrows to unique CANCEL_SYMBOL, not generic symbol. if (p.isCancel(providerResult) || !Array.isArray(providerResult)) { p.cancel('Installation cancelled.'); process.exit(1); } if (providerResult.length === 1) { selectedProvider = providerResult[0]; break; } log.warn('Select exactly one provider.'); } } // CMEM Pro: no new provider code. The worker's OpenRouter client is a generic // OpenAI-compatible client whose endpoint and model both come from settings, // so "use the CMEM observer model" is four settings writes and nothing else. if (selectedProvider === 'cmem') { if (!pairing) { // Unreachable via the flag that skips login (it forces 'claude'), but a // future caller passing null here would otherwise enroll against nothing. throw new Error('CMEM Pro requires a signed-in claude-mem account.'); } // The billing disclosure lives on the checkout page, not here. It is a term // of the charge, so it belongs on the screen that takes the payment method, // where it can be shown next to the price and the card field. Re-asking for // it in the terminal made the user consent twice to the same thing, before // ever seeing what they were agreeing to. const enrollment = await completeCmemTrialPairing(pairing, version); if (!enrollment) { p.cancel('CMEM Pro setup was not completed. Run npx claude-mem install to try again.'); process.exit(1); } // A sync-only account (a TeamBrain seat) gets cloud sync and no CMEM Pro // observer. Its sync connection is already saved; the observer and the // provider settings stay exactly as they are, and no staged or earlier // gateway key is promoted in their place. if (!enrollment.memory) { log.warn(`This account syncs through a TeamBrain seat, which does not include CMEM Pro's observer. Memory keeps its current provider (${initialProvider}). CMEM Pro: ${proTrialUrl('installer')}`); return initialProvider; } const cmemCredentials = resolveCmemMemoryCredentials(enrollment.memory, persistedSettings); if (!cmemCredentials) { p.cancel('CMEM Pro did not return memory credentials. Run npx claude-mem install to try again.'); process.exit(1); } const wrote = mergeSettings(buildCmemActivationSettings(cmemCredentials)); if (!wrote) { p.cancel('Could not save the CMEM Pro configuration.'); process.exit(1); } if (cmemCredentials.clearFallback) clearProFallback(); log.info('CMEM Pro configured with your signed-in memory key.'); return 'openrouter'; } if (selectedProvider === 'claude') { useSubscriptionAuth(); return 'claude'; } if (selectedProvider === 'codex') { const model = options.model?.trim(); if (options.model !== undefined && !model) { throw new Error('Codex model must not be empty. Omit --model to keep the current Codex model setting.'); } const wrote = mergeSettings({ CLAUDE_MEM_PROVIDER: 'codex', ...(model ? { CLAUDE_MEM_CODEX_MODEL: model } : {}), }); if (!wrote) { p.cancel('Could not save the Codex provider configuration.'); process.exit(1); } log.info('Configured Codex subscription provider. Run `codex login` before starting the worker.'); return 'codex'; } if (selectedProvider === 'host') { const observerModel = options.ide === 'grok-bot' ? 'grok-bot' : 'cursor'; const wrote = mergeSettings(buildHostObserverSettings(observerModel, persistedSettings)); if (!wrote) { p.cancel('Could not save the host observer configuration.'); process.exit(1); } log.info(`Configured host observer for ${observerModel}.`); return 'openrouter'; } if (selectedProvider === 'openai-compatible') { // Only a persisted configuration carries this provider, and it returned // above. The installer has no setup flow for it: the endpoint, model and // key are set in ~/.claude-mem/settings.json. throw new Error('The openai-compatible provider is configured in ~/.claude-mem/settings.json, not by the installer.'); } 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; const existingOpenRouterBaseUrl = selectedProvider === 'openrouter' ? String(getSetting('CLAUDE_MEM_OPENROUTER_BASE_URL') ?? '') : ''; const existingKeyIsCmem = selectedProvider === 'openrouter' && isCmemGatewayUrl(existingOpenRouterBaseUrl); if (existingKey && existingKey.trim().length > 0 && !existingKeyIsCmem) { const wrote = mergeSettings({ CLAUDE_MEM_PROVIDER: selectedProvider }); if (wrote) log.info(`Saved provider=${selectedProvider} to ~/.claude-mem/settings.json`); return selectedProvider; } if (!isInteractive) { throw new Error(`Non-interactive ${providerLabel} configuration is missing a personal API key.`); } 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 updates = selectedProvider === 'openrouter' ? buildPersonalOpenRouterSettings( apiKey, readPersistedInstallerSettings(), SettingsDefaultsManager.getAllDefaults().CLAUDE_MEM_OPENROUTER_MODEL, ) : { CLAUDE_MEM_PROVIDER: selectedProvider, [keyEnvName]: apiKey, }; const wrote = mergeSettings(updates); if (wrote) { log.info(`Saved provider=${selectedProvider} to ~/.claude-mem/settings.json`); } return selectedProvider; } /** `interactive: false` keeps the defaults (or `--model`) without asking: the browser funnel's At Home branch. */ async function promptClaudeModel(options: InstallOptions, interactive: boolean = isInteractive): Promise { // Model policy (CLAUDE.md): offer Claude Code's aliases, never dated ids, so every // install follows each new and cheaper release. Pinned ids stay accepted for --model. const allowed = new Set([ 'haiku', 'sonnet', 'opus', 'claude-haiku-4-5-20251001', 'claude-sonnet-5', 'claude-opus-4-8', ]); 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 (!interactive) return; const initialModel = getSetting('CLAUDE_MEM_MODEL'); if (allowCustomModel) { const result = await p.text({ message: 'Which model should the gateway use?', placeholder: 'haiku', defaultValue: initialModel || 'haiku', 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 : 'haiku'; const choices = [ { value: 'haiku', label: 'Haiku, newest version (recommended — fast, cheap, great for compression)' }, { value: 'sonnet', label: 'Sonnet, newest version (balanced quality and cost)' }, { value: 'opus', label: 'Opus, newest version (highest quality, slowest)' }, ]; // A pinned id saved by an older install stays selectable, so rerunning the installer can keep it. if (!choices.some((o) => o.value === initialValue)) { choices.push({ value: initialValue, label: `${initialValue} (your saved pin; never updates)` }); } 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: choices, 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`); } } // --- claude-mem OAuth pairing ---------------------------------------------- // Every installer authenticates through the same GitHub/Google OAuth screen as // cmem.ai. Login only proves account ownership; provider selection happens // afterward, and only the CMEM Pro choice continues into trial enrollment. function nonEmptyTrimmedString(value: unknown): string | null { return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; } const OAUTH_START_TIMEOUT_MS = 10_000; // The optional end-of-install sign-in offer must not hold a finished install // hostage to a stalled cmem.ai: give up well before the blocking login would. const OAUTH_DEFERRED_START_TIMEOUT_MS = 3_000; const OAUTH_POLL_TIMEOUT_MS = 10_000; // Fallback budget when the server does not report `expires_in`. The server // pairing lives 30 minutes; polling past that only yields `gone`. const OAUTH_POLL_BUDGET_MS = 240_000; const OAUTH_POLL_BUDGET_MAX_MS = 30 * 60 * 1000; const OAUTH_DEFAULT_POLL_INTERVAL_S = 3; type InstallerPollStage = 'awaiting_login' | 'awaiting_checkout' | 'awaiting_approval'; /** `teambrain_seat`: a TeamBrain seat, which grants cloud sync and no CMEM Pro observer. */ type TrialPlan = 'trial' | 'pro' | 'none' | 'teambrain_seat'; /** * What a poll loop waits for. `login` returns once the browser signs in; * `enrollment` (CMEM Pro) and `teambrain_join` only once credentials are ready. */ export type InstallerPairingPhase = 'login' | 'enrollment' | 'teambrain_join'; export interface InstallerOAuthPairing { pairingId: string; secret: string; userCode: string; authorizationUrl: string; checkoutUrl: string; pollIntervalMs: number; /** * Absolute epoch ms after which polling stops, derived from the server's * `expires_in` at parse time and clamped to OAUTH_POLL_BUDGET_MAX_MS. * Undefined when the server did not report one (fallback budget applies). */ expiresAt?: number; /** Defensive compatibility for a server that returns ready during login. */ delivered?: TrialReadyResult; } function parseBrowserUrl(value: unknown, expectedPath: string): URL | null { const candidate = nonEmptyTrimmedString(value); if (!candidate) return null; try { const parsed = new URL(candidate); const expectedOrigin = new URL(CMEM_INSTALLER_OAUTH_START_URL).origin; if ( parsed.origin !== expectedOrigin || parsed.pathname !== expectedPath || parsed.username || parsed.password || parsed.hash ) return null; return parsed; } catch { return null; } } function hasExactSearchParams(url: URL, expected: Record): boolean { const entries = [...url.searchParams.entries()]; const expectedEntries = Object.entries(expected); return entries.length === expectedEntries.length && expectedEntries.every(([key, value]) => url.searchParams.get(key) === value); } /** * The checkout URL must carry exactly `pairing` and `trial`, and nothing else — * but the trial LENGTH is not the installer's business to pin. * * This previously required `trial: '7'` exactly. That made the server unable to * change the offer without breaking every installer already published: a * `trial=30` URL was rejected outright, surfacing as "Could not start OAuth * login" with no hint that the length was the reason. The shape stays strict * (both params present, pairing must match, no extras); only the number is now * the server's to choose. */ function hasPairingAndTrialParams(url: URL, pairingId: string): boolean { const entries = [...url.searchParams.entries()]; if (entries.length !== 2) return false; if (url.searchParams.get('pairing') !== pairingId) return false; const trial = url.searchParams.get('trial'); if (trial === null || !/^[0-9]{1,3}$/.test(trial)) return false; const days = Number(trial); return days >= 1 && days <= 365; } /** Pure parser used by the mock-server contract tests. */ export function parseInstallerOAuthStartBody(body: unknown): InstallerOAuthPairing | null { if (!body || typeof body !== 'object') return null; const b = body as { pairing_id?: unknown; secret?: unknown; user_code?: unknown; authorization_url?: unknown; checkout_url?: unknown; poll_interval?: unknown; expires_in?: unknown; }; const pairingId = nonEmptyTrimmedString(b.pairing_id); const secret = nonEmptyTrimmedString(b.secret); const userCode = nonEmptyTrimmedString(b.user_code); const authorizationUrl = parseBrowserUrl(b.authorization_url, '/login'); const checkoutUrl = parseBrowserUrl(b.checkout_url, '/api/pro/trial/claim'); if ( !pairingId || !/^[0-9a-f]{32}$/.test(pairingId) || !secret || !/^[0-9a-f]{48}$/.test(secret) || !userCode || !/^[A-HJ-KM-NP-TV-Z2-9]{4}-[A-HJ-KM-NP-TV-Z2-9]{4}$/.test(userCode) || !authorizationUrl || !checkoutUrl ) return null; const expectedOrigin = new URL(CMEM_INSTALLER_OAUTH_START_URL).origin; const loginNext = authorizationUrl.searchParams.get('next'); if ( !loginNext || !loginNext.startsWith('/') || loginNext.startsWith('//') || !hasExactSearchParams(authorizationUrl, { next: loginNext }) ) return null; const loginClaim = parseBrowserUrl(`${expectedOrigin}${loginNext}`, '/api/pro/trial/claim'); if ( !loginClaim || !hasExactSearchParams(loginClaim, { pairing: pairingId, login_only: '1' }) || !hasPairingAndTrialParams(checkoutUrl, pairingId) ) return null; const pollIntervalS = typeof b.poll_interval === 'number' && Number.isFinite(b.poll_interval) && b.poll_interval > 0 ? Math.min(Math.max(b.poll_interval, 1), 30) : OAUTH_DEFAULT_POLL_INTERVAL_S; const expiresAt = typeof b.expires_in === 'number' && Number.isFinite(b.expires_in) && b.expires_in > 0 ? Date.now() + Math.min(b.expires_in * 1000, OAUTH_POLL_BUDGET_MAX_MS) : undefined; return { pairingId, secret, userCode, authorizationUrl: authorizationUrl.toString(), checkoutUrl: checkoutUrl.toString(), pollIntervalMs: pollIntervalS * 1000, ...(expiresAt !== undefined ? { expiresAt } : {}), }; } export type OAuthStartFailure = 'http_error' | 'network' | 'timeout' | 'bad_body' | 'credentials'; // Why the most recent startInstallerOAuthPairing() returned null. A closed enum // for the installer_oauth_start_failed telemetry event — never a URL, status // text, or error message. let lastStartFailure: OAuthStartFailure | null = null; let lastStartErrorMessage: string | null = null; export function lastOAuthStartFailure(): OAuthStartFailure | null { return lastStartFailure; } /** User-visible server/configuration details stay out of the telemetry failure enum. */ export function lastOAuthStartErrorMessage(): string | null { return lastStartErrorMessage; } export type InstallState = 'fresh' | 'update'; /** * What this install tells cmem.ai alongside the sign-in start request: whether * it ran over an existing install, and the numbers-only local usage summary * (see install/usage-summary.ts). Set once by the install command before any * pairing starts; both are optional on the wire and omitted when unknown. */ let signupContext: { installState?: InstallState; usageSummary?: UsageSummary | null } = {}; export function setInstallerSignupContext(ctx: { installState?: InstallState; usageSummary?: UsageSummary | null }): void { signupContext = { ...ctx }; } /** * Pure: the JSON body for POST /api/installer/oauth/start. `teamInvite` is * the TeamBrain invite token a `teambrain_join` pairing carries; it travels * only in this POST body, never in a URL. */ export function buildInstallerOAuthStartBody( source: string, deviceName: string, ctx: { installState?: InstallState; usageSummary?: UsageSummary | null } = {}, teamInvite?: string, ): Record { return { source, device_name: deviceName, ...(ctx.installState ? { install_state: ctx.installState } : {}), ...(ctx.usageSummary ? { usage_summary: ctx.usageSummary } : {}), ...(teamInvite ? { team_invite: teamInvite } : {}), }; } /** Starts an OAuth pairing. No email address or identity is accepted from the CLI. */ export async function startInstallerOAuthPairing( opts: { source?: string; timeoutMs?: number; teamInvite?: string } = {}, ): Promise { const source = opts.source ?? 'npx-installer'; lastStartFailure = null; lastStartErrorMessage = null; let installHeaders: Record; try { // Bind the already-issued installation at sign-in. An invalid explicit // identity must stop here, before any request can create another pairing. installHeaders = intentCredentialHeaders(readIntentCredentials()); } catch (error: unknown) { lastStartFailure = 'credentials'; lastStartErrorMessage = error instanceof Error ? error.message : String(error); return null; } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? OAUTH_START_TIMEOUT_MS); try { const response = await fetch(CMEM_INSTALLER_OAUTH_START_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', ...installHeaders }, body: JSON.stringify(buildInstallerOAuthStartBody(source, hostname(), signupContext, opts.teamInvite)), signal: controller.signal, }); if (!response.ok) { lastStartFailure = 'http_error'; try { const body: unknown = await response.json(); if (body && typeof body === 'object') { lastStartErrorMessage = nonEmptyTrimmedString((body as { message?: unknown }).message); } } catch (error: unknown) { if (error instanceof Error && error.name === 'AbortError') throw error; } return null; } // A 2xx with unparseable JSON is a server-response problem, not a // connection failure; keep it out of the `network` bucket. let body: unknown; try { body = await response.json(); } catch (error: unknown) { // The request timer can fire while the body is still streaming; that is // the same timeout as a stalled connect, not a malformed response. lastStartFailure = error instanceof Error && error.name === 'AbortError' ? 'timeout' : 'bad_body'; return null; } const pairing = parseInstallerOAuthStartBody(body); if (!pairing) lastStartFailure = 'bad_body'; return pairing; } catch (error: unknown) { lastStartFailure = error instanceof Error && error.name === 'AbortError' ? 'timeout' : 'network'; return null; } finally { clearTimeout(timer); } } /** * Everything a ready poll delivers. The memory credentials are staged before * provider activation so one-shot delivery cannot be lost after enrollment. */ export interface TrialReadyResult { userId: string; setupToken: string; hubUrl: string; /** * The CMEM Pro observer credentials, or null when the server sent none: a * sync-only account (a TeamBrain seat), which keeps its own observer. */ memory: DeliveredCmemMemoryCredentials | null; plan: TrialPlan; trialEndsAt: string | null; /** The TeamBrain team a `teambrain_join` pairing joined, when the server names it. */ team: TeamBrainTeamRef | null; /** Where that team's TeamBrain API lives, when the server says. */ teambrainUrl: string | null; } /** * The cloud sync connection every ready poll delivers, seat or Pro: the keys * the worker's CloudSync reads, and nothing for the observer. A new pairing * starts a new device (CloudSync mints the id on its next start). */ export function buildSyncConnectionSettings( result: Pick, deviceName: string = hostname(), ): Record { return { CLAUDE_MEM_CLOUD_SYNC_TOKEN: result.setupToken, CLAUDE_MEM_CLOUD_SYNC_USER_ID: result.userId, CLAUDE_MEM_CLOUD_SYNC_HUB_URL: result.hubUrl, CLAUDE_MEM_CLOUD_SYNC_DEVICE_ID: '', CLAUDE_MEM_CLOUD_SYNC_DEVICE_NAME: deviceName, }; } export function buildTrialReadySettings( result: TrialReadyResult, deviceName: string = hostname(), ): Record { const syncConnection = buildSyncConnectionSettings(result, deviceName); // Without memory credentials there is no CMEM Pro observer to stage, so only // the sync connection is written. The provider, a key staged by an earlier // pairing and the fallback marker all stay as they are. if (!result.memory) return syncConnection; return { ...syncConnection, CLAUDE_MEM_PRO_TRIAL_STATE: 'active', CLAUDE_MEM_PRO_TRIAL_ENDS_AT: result.trialEndsAt ?? '', CLAUDE_MEM_PRO_PLAN: result.plan, CLAUDE_MEM_PRO_MEMORY_KEY: result.memory.memoryKey, CLAUDE_MEM_PRO_MEMORY_BASE_URL: result.memory.memoryBaseUrl, CLAUDE_MEM_PRO_MEMORY_MODEL: result.memory.memoryModel, CLAUDE_MEM_PRO_FALLBACK_AT: '', }; } export function parseTrialReadyBody(body: unknown): TrialReadyResult | null { if (!body || typeof body !== 'object') return null; const b = body as { status?: unknown; user_id?: unknown; setup_token?: unknown; hub_url?: unknown; memory_key?: unknown; memory_base_url?: unknown; memory_model?: unknown; plan?: unknown; trial?: { ends_at?: unknown }; team?: unknown; teambrain_url?: unknown; }; const userId = nonEmptyTrimmedString(b.user_id); const setupToken = nonEmptyTrimmedString(b.setup_token); const hubUrl = nonEmptyTrimmedString(b.hub_url); if (b.status !== 'ready' || !userId || !setupToken || !hubUrl) return null; // Observer credentials come from the server or not at all. A body without a // memory key is a sync-only account: nothing may stand in for the key, since // the setup token is no gateway key and the gateway refuses an account that // is not Pro. Filling it in pointed a seat member's observer at a gateway // that turns them away. const memoryKey = nonEmptyTrimmedString(b.memory_key); return { userId, setupToken, hubUrl, memory: memoryKey ? { memoryKey, memoryBaseUrl: nonEmptyTrimmedString(b.memory_base_url) ?? CMEM_PRO_BASE_URL, memoryModel: nonEmptyTrimmedString(b.memory_model) ?? CMEM_PRO_MODEL, } : null, plan: b.plan === 'trial' || b.plan === 'pro' || b.plan === 'none' || b.plan === 'teambrain_seat' ? b.plan : 'trial', trialEndsAt: nonEmptyTrimmedString(b.trial?.ends_at), team: parseTeamBrainTeamRef(b.team), teambrainUrl: nonEmptyTrimmedString(b.teambrain_url), }; } export type InstallerPollOutcome = | { kind: 'authenticated'; userId: string } | { kind: 'pending'; stage: InstallerPollStage } | ({ kind: 'ready' } & TrialReadyResult) /** The server refused a TeamBrain join (team full, invite no longer valid, ...): final. */ | { kind: 'join_failed'; reason: string; message: string | null } | { kind: 'gone' } | { kind: 'unreachable' }; /** What to say for a refused join when the server sent no words of its own. */ const JOIN_FAILED_FALLBACKS: Record = { team_full: 'the team has no free seat. Ask its owner to add one.', invite_invalid: 'the invite is no longer valid. Ask the team owner for a new invite link.', team_inactive: 'the team is not active.', not_a_member: 'this account is not a member of the team.', }; /** One line for a refused join: the server's own words when it sent them, and its reason code. */ export function joinFailedMessage(outcome: { reason: string; message: string | null }): string { const said = relayedLine(outcome.message) || JOIN_FAILED_FALLBACKS[outcome.reason] || 'cmem.ai refused the join.'; const reason = relayedLine(outcome.reason); return `Could not join the team: ${said}${reason && reason !== 'unknown' ? ` (${reason})` : ''}`; } async function pollInstallerPairingOnce(pairing: InstallerOAuthPairing): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), OAUTH_POLL_TIMEOUT_MS); try { const response = await fetch(CMEM_INSTALLER_OAUTH_POLL_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pairing_id: pairing.pairingId, secret: pairing.secret }), signal: controller.signal, }); const body = await response.json().catch(() => ({})) as { status?: unknown; stage?: unknown; user_id?: unknown; reason?: unknown; error?: unknown; }; // A refused TeamBrain join also answers 410, so it is told apart from an // expired pairing by its body: at sign-in or at device approval, the // server says why (team_full, invite_invalid, team_inactive, not_a_member). if (body.status === 'join_failed') { return { kind: 'join_failed', reason: nonEmptyTrimmedString(body.reason) ?? 'unknown', message: nonEmptyTrimmedString(body.error), }; } if (response.status === 404 || response.status === 410) return { kind: 'gone' }; if (response.status === 202) { const stage: InstallerPollStage = body.stage === 'awaiting_checkout' || body.stage === 'awaiting_approval' ? body.stage : 'awaiting_login'; return { kind: 'pending', stage }; } if (response.ok && body.status === 'authenticated') { const userId = nonEmptyTrimmedString(body.user_id); return userId ? { kind: 'authenticated', userId } : { kind: 'unreachable' }; } if (response.ok) { const ready = parseTrialReadyBody(body); return ready ? { kind: 'ready', ...ready } : { kind: 'unreachable' }; } return { kind: 'unreachable' }; } catch { return { kind: 'unreachable' }; } finally { clearTimeout(timer); } } function sleepUnlessCancelled(ms: number, isCancelled: () => boolean): Promise { return new Promise((resolve) => { const startedAt = Date.now(); const timer = setInterval(() => { if (isCancelled() || Date.now() - startedAt >= ms) { clearInterval(timer); resolve(); } }, 250); }); } export async function waitForInstallerPairing( pairing: InstallerOAuthPairing, phase: InstallerPairingPhase, version: string, ): Promise { const startedAt = Date.now(); const rerun = phase === 'teambrain_join' ? 'Run npx claude-mem teambrain join again' : 'Run the installer again'; // The login phase must never name CMEM Pro. Logging in is required of every // user and happens BEFORE the provider choice, so naming a paid plan here // reads as an upsell attached to a mandatory step and muddies the funnel. // The poll loop prints whatever stage the server reports, so a server-side // 'awaiting_checkout' during login would otherwise leak the Pro wording. // A TeamBrain join is neither: the server is adding this account to a team. const stageMessages: Record = { awaiting_login: phase === 'teambrain_join' ? 'Waiting for you to sign in in the browser…' : 'Waiting for OAuth login in the browser…', awaiting_checkout: phase === 'login' ? 'Waiting for the browser to finish signing you in…' : phase === 'teambrain_join' ? 'Waiting for the browser to finish joining the team…' : 'Waiting for CMEM Pro setup in the browser…', // A join's invite is redeemed at device approval, on a page that names the team. awaiting_approval: phase === 'teambrain_join' ? `Approve this device on the page (code ${pairing.userCode}) to join the team…` : `Enter code ${pairing.userCode} in the browser to approve this device…`, }; let stage: InstallerPollStage = phase === 'enrollment' ? 'awaiting_checkout' : 'awaiting_login'; log.info(stageMessages[stage]); let cancelled = false; const onStdinData = (chunk: Buffer | string): void => { if (typeof chunk === 'string' ? chunk.includes('\x03') : chunk.includes(0x03)) cancelled = true; }; const onSigint = (): void => { cancelled = true; }; const useRawInput = process.stdin.isTTY === true; const stdinWasRaw = process.stdin.isRaw === true; let changedRawMode = false; if (useRawInput) { process.stdin.on('data', onStdinData); if (!stdinWasRaw) { process.stdin.setRawMode(true); changedRawMode = true; } process.stdin.resume(); } process.on('SIGINT', onSigint); try { let consecutiveFailures = 0; // The deadline belongs to the pairing, not the phase: login and enrollment // share one pairing, so both poll until the server's own expiry. const deadline = pairing.expiresAt ?? (startedAt + OAUTH_POLL_BUDGET_MS); while (Date.now() < deadline) { if (cancelled) return null; const result = await pollInstallerPairingOnce(pairing); if (cancelled) return null; if (result.kind === 'authenticated' && phase === 'login') return result; if (result.kind === 'ready') return result; if (result.kind === 'join_failed') { log.error(joinFailedMessage(result)); return null; } if (result.kind === 'gone') { log.error(`This browser authorization expired. ${rerun}.`); return null; } if (result.kind === 'unreachable') { consecutiveFailures += 1; if (consecutiveFailures >= 3) { log.error(`cmem.ai is not responding. ${rerun} when it is reachable.`); return null; } } else { consecutiveFailures = 0; if (result.kind === 'pending' && result.stage !== stage) { stage = result.stage; log.info(stageMessages[stage]); } } await sleepUnlessCancelled(pairing.pollIntervalMs, () => cancelled); } // The telemetry `phase` enum is the installer's (login / enrollment / // deferred); a TeamBrain join is not reported. if (phase !== 'teambrain_join') { await captureCliEvent('installer_oauth_timeout', { version, phase, duration_ms: Date.now() - startedAt, }); } log.error(`Browser authorization timed out. ${rerun}.`); return null; } finally { process.off('SIGINT', onSigint); if (useRawInput) { process.stdin.off('data', onStdinData); if (changedRawMode) process.stdin.setRawMode(false); process.stdin.pause(); } } } /** * Required account step. OAuth completes before provider choice and never * chooses or enrolls a paid provider on its own. */ export async function completeInstallerOAuthLogin( pairing: InstallerOAuthPairing, version: string, ): Promise { const result = await waitForInstallerPairing(pairing, 'login', version); if (!result) return false; if (result.kind === 'ready') { if (!mergeSettings(buildTrialReadySettings(result))) return false; pairing.delivered = result; } log.success('OAuth login complete.'); await captureCliEvent('installer_oauth_completed', { version }); return result.kind === 'authenticated' || result.kind === 'ready'; } /** * Holds until the user presses Return, then the caller opens the browser. The * URL is printed BEFORE this, so a user who cannot use an opener (headless box, * SSH) is never blocked — they can open it by hand and the wait still clears on * Return. Ctrl-C falls through to the normal SIGINT handling. * * Raw mode mirrors waitForInstallerPairing: only toggled when this call turned * it on, and always restored. */ export async function waitForReturnToOpenBrowser(message: string): Promise { if (!isInteractive || process.stdin.isTTY !== true) return; log.info(message); await new Promise((resolve) => { const wasRaw = process.stdin.isRaw === true; let changedRawMode = false; const finish = (): void => { process.stdin.off('data', onData); if (changedRawMode) process.stdin.setRawMode(false); process.stdin.pause(); resolve(); }; const onData = (chunk: Buffer | string): void => { const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8'); if (text.includes('\r') || text.includes('\n') || text.includes('\x03')) finish(); }; if (!wasRaw) { process.stdin.setRawMode(true); changedRawMode = true; } process.stdin.resume(); process.stdin.on('data', onData); }); } async function requireInstallerOAuthLogin(version: string): Promise { const spinner = isInteractive ? p.spinner() : null; spinner?.start('Starting secure OAuth login…'); const pairing = await startInstallerOAuthPairing(); if (!pairing) { await captureCliEvent('installer_oauth_start_failed', { version, outcome: lastOAuthStartFailure() ?? 'network', interactive: isInteractive, phase: 'login', }); spinner?.stop(styleText('red', 'Could not start OAuth login.')); log.error(lastOAuthStartErrorMessage() ?? 'OAuth login is required. Run npx claude-mem install again when cmem.ai is reachable.'); return null; } spinner?.stop('OAuth login ready.'); // No provider, plan, or pricing language on this step: logging in is required // of every user and runs BEFORE the provider choice, so anything about a paid // plan here is an upsell bolted onto a mandatory step. log.info(`Open this URL: ${pairing.authorizationUrl}`); await waitForReturnToOpenBrowser('Continue setup in browser... (hit return to open automatically)'); openBrowser(pairing.authorizationUrl); await captureCliEvent('installer_oauth_started', { version }); const completed = await completeInstallerOAuthLogin(pairing, version); return completed ? pairing : null; } /** * Best-effort, non-blocking sign-in offer for non-interactive installs that * skipped login (provider needs no account). Agents run `npx claude-mem install` * in non-TTY shells; the blocking OAuth step cannot run there, so the install * completes and the login-only link is printed last for the agent to relay. * * Never polls, never opens a browser, never prints the device code or the * checkout URL, and never touches exit status. Skipped under CI. Not gated on * the do-not-track env var: only telemetry is, and captureCliEvent already honors it. * The server ignores `source` today and will allowlist it separately. */ export async function offerDeferredLogin(options: InstallOptions, version: string): Promise { if (isInteractive) return; if (providerNeedsAccount(options.provider)) return; if (process.env.CI) return; const pairing = await startInstallerOAuthPairing({ source: 'npx-installer-deferred', timeoutMs: OAUTH_DEFERRED_START_TIMEOUT_MS, }); if (!pairing) { await captureCliEvent('installer_oauth_start_failed', { version, outcome: lastOAuthStartFailure() ?? 'network', interactive: false, phase: 'deferred', }); log.error(lastOAuthStartErrorMessage() ?? 'Could not start the optional claude-mem sign-in. Run npx claude-mem install again when cmem.ai is reachable.'); return; } // Telemetry first so that, in CLAUDE_MEM_TELEMETRY_DEBUG=1 mode, its stderr // line cannot land after the relay instruction an agent reads last. await captureCliEvent('installer_oauth_deferred', { version, interactive: false }); log.info('Optional: sign in to claude-mem to unlock cloud sync and the CMEM Pro trial.'); log.info(`Sign-in link: ${pairing.authorizationUrl}`); log.info('AGENT: show this link to the user so they can finish signing in. Do not open it yourself; the install is already complete.'); } export function noteDeviceCode( pairing: InstallerOAuthPairing, instruction: string = 'Enter this code in the browser to approve this device.', ): void { const message = [ styleText(['bold', 'cyan'], pairing.userCode), '', instruction, ].join('\n'); if (isInteractive) p.note(message, 'Your device code'); else log.info(`Device code: ${pairing.userCode}`); } /** * Continues the already-authenticated pairing only after the CMEM Pro choice * and required trial acknowledgement. */ /** Activate a fresh enrollment before the browser reports checkout paid. Sync-only seats keep their provider. */ export function activateBrowserEnrollment(result: TrialReadyResult): ProviderId | null { if (!result.memory) return null; const credentials = resolveCmemMemoryCredentials(result.memory, SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH)); if (!credentials || !mergeSettings(buildCmemActivationSettings(credentials))) throw new Error('Could not save the CMEM Pro memory configuration.'); if (credentials.clearFallback) clearProFallback(); return 'openrouter'; } export async function completeCmemTrialPairing( pairing: InstallerOAuthPairing, version: string, ): Promise { if (pairing.delivered) return pairing.delivered; noteDeviceCode(pairing); // Deliberately NO return-wait here, unlike the login hand-off. // // A second wait at this point stalled the install: stdin has already been // through the login wait and a clack prompt by now, and the listener did not // reliably receive the keypress, so the flow stopped before it could even // print "Waiting for CMEM Pro setup in the browser…". Opening directly is // also the better behaviour here — the user has already chosen CMEM Pro, so // there is nothing left to confirm before the browser takes over. log.info(`Continue CMEM Pro setup: ${pairing.checkoutUrl}`); openBrowser(pairing.checkoutUrl); const result = await waitForInstallerPairing(pairing, 'enrollment', version); if (!result || result.kind !== 'ready') return null; const wrote = mergeSettings(buildTrialReadySettings(result)); if (!wrote) { log.error('Could not save the one-time CMEM Pro credentials.'); return null; } pairing.delivered = result; // A sync-only account (a TeamBrain seat) has no CMEM Pro to announce, and // no gateway key whose fallback a fresh delivery would clear. if (result.memory) { clearProFallback(); log.success(`CMEM Pro ${PRO_TRIAL_LABEL} active.`); await captureCliEvent('trial_activated', { version }); } return result; } /** * Final step of the install flow: tell the user telemetry is on by default * (opt-out) and let them decide. Asked ONCE — a telemetry.json with a recorded * enabled decision means the user already chose, and we never re-nag. An * installId-only config (written by the worker's ID bootstrap) does NOT count * as a decision. Respects DO_NOT_TRACK (skip entirely: they already answered), * CI, and non-TTY. See docs/public/telemetry.mdx for what is/isn't collected. */ async function promptTelemetryOptIn(): Promise { if (!isInteractive) return; if (process.env.CI) return; const dnt = process.env.DO_NOT_TRACK; if (dnt !== undefined && dnt !== '' && dnt !== '0' && dnt !== 'false') return; const existing = loadTelemetryConfig(); if (existing?.enabled !== undefined) return; p.log.message(styleText('dim', 'Anonymous install ID only — no prompts, file paths, code, or project names, ever.\n' + 'Details: https://docs.claude-mem.ai/telemetry · Change anytime: claude-mem telemetry disable', )); const consent = await p.confirm({ message: 'Share anonymized usage data with CMEM? It is on by default and helps us make the product better.', initialValue: true, }); if (p.isCancel(consent)) return; saveTelemetryConfig({ enabled: consent === true, installId: existing?.installId || randomUUID(), decidedAt: new Date().toISOString(), }); log.success(consent ? 'Thanks! Anonymized usage sharing is on.' : 'No problem — telemetry is off.'); } /** * Whether an install still has an account question to answer. * * `--provider claude` and `--provider host` are exempt: they use the user's * existing local credentials and need no claude-mem account. `gemini`, * `openrouter` and `codex` are NOT exempt — openrouter is the transport for * the cmem gateway, so an explicit `openrouter` install may still be reaching * cmem.ai, and gemini and codex are bring-your-own providers that sign in * like any other. With no flag at all the provider screen can still offer * CMEM Pro, so login must happen first. */ export function providerNeedsAccount(provider: InstallOptions['provider']): boolean { return provider !== 'claude' && provider !== 'host'; } export interface InstallOptions { ide?: string; dshProfile?: string; /** * `openai-compatible` is never a flag value: it is only ever kept from * settings.json by a non-interactive run (`providerSource: 'persisted'`). */ provider?: 'claude' | 'codex' | 'gemini' | 'openrouter' | 'openai-compatible' | 'host'; /** * How `provider` was decided. `flag` for an explicit `--provider` (and the * grok-bot implicit cmem default set in index.ts), `default` when a fresh * non-interactive run fell back to claude, `persisted` when a non-interactive * run reused the provider already in settings, `prompt` for the interactive * multiselect, `browser` for the install funnel's At Home choice. Reported on * install_completed as `provider_source`. */ providerSource?: 'flag' | 'default' | 'persisted' | 'prompt' | 'browser'; model?: string; noAutoStart?: boolean; disableAutoMemory?: boolean; // #2543 — non-interactive runtime selection. `server` is the operator-facing // alias for the canonical `server-beta` runtime id. runtime?: 'worker' | 'server' | 'server-beta'; // Base URL the server runtime (and the injected IDE MCP config) targets. serverUrl?: string; } /** * How the installer reacts when the worker port is not free. This runs before * the sign-in/trial step, which the install must still reach, so it aborts only * when a live claude-mem worker demonstrably holds the port: the verified owner * of the PID file, the worker the stop targeted, is still alive. Its in-memory * configuration would outlive the overwrite. A port some other process holds, * or one taken again after that worker exited, only warns. Exported for tests. */ export function workerShutdownFailure( blocker: ShutdownBlocker, port: number | string, ): { severity: ErrorSeverity; cause: string; remediation: string } { switch (blocker.kind) { case 'port-held-by-other-process': return { severity: ErrorSeverity.WARN_CONTINUE, cause: `Port ${port} is held by a process that is not a claude-mem worker (no live claude-mem worker owns it), so the worker cannot listen there.`, remediation: `Stop the process using port ${port}, or set CLAUDE_MEM_WORKER_PORT to a free port in ${USER_SETTINGS_PATH}, then run \`npx claude-mem start\`.`, }; case 'port-rebound': { const owner = blocker.ownerPid === null ? 'another process' : `another claude-mem worker (PID ${blocker.ownerPid})`; return { severity: ErrorSeverity.WARN_CONTINUE, cause: `The claude-mem worker stopped, but ${owner} took port ${port} again before the install finished: a hook, the MCP server or a process manager started it, or a leftover process still holds the socket.`, remediation: 'When the install finishes, run `npx claude-mem restart` (or restart the worker with whatever manages it) so it runs the new version.', }; } case 'worker-still-running': { if (blocker.pid === null) { // Identified only by its /api/health answers: never name that PID in // a kill command (it may belong to a container, or be reused). return { severity: ErrorSeverity.ABORT, cause: 'The running claude-mem worker accepted the shutdown request but was still serving after 10 seconds.', remediation: 'Run `npx claude-mem stop`, verify it exits, then run `npx claude-mem install` again.', }; } // The PID was verified as ours (PID file plus start token) at the // deadline, so naming it cannot point the user at an unrelated process. const killCommand = process.platform === 'win32' ? `taskkill /PID ${blocker.pid} /F` : `kill ${blocker.pid}`; return { severity: ErrorSeverity.ABORT, cause: `The claude-mem worker (PID ${blocker.pid}) did not stop within 10 seconds.`, remediation: `Run \`npx claude-mem stop\`, or end PID ${blocker.pid} (\`${killCommand}\`), then run \`npx claude-mem install\` again.`, }; } } } /** Evidence sources for the installer's worker stop, injectable so tests never touch a real worker. */ export interface WorkerStopDeps { stopWorker?: typeof shutdownWorkerAndWait; /** The live, verified PID-file owner, or null. */ readOwnedWorker?: () => PidInfo | null; /** How long to wait for the spawn lock; defaults to the lock's staleness window. */ spawnLockWaitMs?: number; } /** * How long the installer waits for the spawn lock: a launcher normally holds * it for seconds, and any lock not refreshed within the staleness window can * be broken, so waiting that long gets it from a stuck or crashed holder too. */ const INSTALL_SPAWN_LOCK_WAIT_MS = SPAWN_LOCK_STALE_MS + 5_000; /** * Stop the running worker, then overwrite the plugin files, holding the spawn * lock from before the stop until the overwrite is done. Without it a hook or * the MCP server could lazily start a worker from half-copied files the moment * the old one exited; the stop then found the port taken again. Exported for * tests. */ export async function overwriteWithWorkerStopped( port: number | string, summary: InstallSummary, overwrite: () => Promise, deps: WorkerStopDeps = {}, ): Promise { let releaseSpawnLock = await holdSpawnLock(0); if (!releaseSpawnLock) { log.info('Waiting for another claude-mem launcher to finish starting the worker…'); releaseSpawnLock = await holdSpawnLock(deps.spawnLockWaitMs ?? INSTALL_SPAWN_LOCK_WAIT_MS); } if (!releaseSpawnLock) { // Only a holder that keeps refreshing the lock (another install running // now) gets here. Aborting is not an option before sign-in, so overwrite // and say how to recover if a worker started from the half-copied files. installerError(ErrorSeverity.WARN_CONTINUE, { component: 'worker-shutdown', phase: 'pre-overwrite', cause: new Error('Another claude-mem process held the worker spawn lock, so the plugin files were overwritten without it.'), remediation: 'When the install finishes, run `npx claude-mem restart` so the worker runs the new files.', }, summary); } try { await requireWorkerStopped(port, 'pre-overwrite', summary, deps); await overwrite(); } finally { releaseSpawnLock?.(); } } /** The verified PID-file owner on `port`, or null; never throws. */ function readOwnedWorkerOnPort(port: number | string, readOwnedWorker: () => PidInfo | null): PidInfo | null { try { const owner = readOwnedWorker(); return owner && (!owner.port || Number(owner.port) === Number(port)) ? owner : null; } catch { return null; } } /** Exported for tests. */ export async function requireWorkerStopped( port: number | string, phase: 'pre-overwrite' | 'provider-cutover', summary: InstallSummary, deps: WorkerStopDeps = {}, ): Promise { const stopWorker = deps.stopWorker ?? shutdownWorkerAndWait; // CLAUDE_MEM_WORKER_AUTOSTART=false: the worker is managed externally // (#2828). claude-mem never stops, kills or recycles it, and its manager // would restart it the moment it exited, so leave it running and say once // how to load the new version. if (isWorkerAutostartDisabled({ CLAUDE_MEM_WORKER_AUTOSTART: getSetting('CLAUDE_MEM_WORKER_AUTOSTART') })) { if (phase === 'pre-overwrite') { installerError(ErrorSeverity.WARN_CONTINUE, { component: 'worker-shutdown', phase, cause: new Error('CLAUDE_MEM_WORKER_AUTOSTART=false: the worker is managed externally, so the installer did not stop it.'), remediation: 'When the install finishes, restart the worker with whatever manages it so it runs the new version.', }, summary); } return; } const spinner = isInteractive ? p.spinner() : null; const action = phase === 'pre-overwrite' ? 'Stopping running worker (so we can overwrite cleanly)…' : 'Confirming the old worker is stopped before provider cutover…'; spinner?.start(action); try { const result = await stopWorker(port, 10000); if (!result.stopped) { const failure = workerShutdownFailure(result.blocker, port); if (failure.severity === ErrorSeverity.ABORT) { spinner?.error('Running worker did not stop; refusing to overwrite its live configuration.'); } // ABORT throws; WARN_CONTINUE records the warning for the end-of-install summary. installerError(failure.severity, { component: 'worker-shutdown', phase, cause: new Error(failure.cause), remediation: failure.remediation, }, summary); } const stopMessage = result.stopped ? result.workerWasRunning ? 'Stopped running worker before configuration cutover.' : 'No worker running — proceeding.' : result.blocker.kind === 'port-rebound' ? `Stopped the running worker, but port ${port} was taken again — proceeding.` : `Port ${port} is held by another process, not a claude-mem worker — proceeding.`; if (spinner) spinner.stop(stopMessage); else if (result.workerWasRunning) log.info(stopMessage); } catch (error: unknown) { if (error instanceof InstallAbortError) throw error; const message = error instanceof Error ? error.message : String(error); if (spinner) spinner.error(`Worker shutdown failed: ${message}`); // The stop itself failed, so decide from the ownership record alone: a // live, verified worker on this port must not have its files overwritten; // with none, nothing shows a claude-mem worker holds the port, and the // install must still reach sign-in. const owner = readOwnedWorkerOnPort(port, deps.readOwnedWorker ?? readOwnedWorkerPidInfo); if (owner) { const failure = workerShutdownFailure({ kind: 'worker-still-running', pid: owner.pid }, port); // ABORT: throws. installerError(failure.severity, { component: 'worker-shutdown', phase, cause: error, remediation: failure.remediation }, summary); return; } installerError(ErrorSeverity.WARN_CONTINUE, { component: 'worker-shutdown', phase, cause: error, remediation: 'When the install finishes, run `npx claude-mem restart` so the worker runs the new version.', }, summary); } } function holdsKey(value: unknown): boolean { if (Array.isArray(value)) return value.some(holdsKey); return typeof value === 'string' && value.trim() !== ''; } /** * Whether a personal Gemini or OpenRouter setup holds a key the worker can use. * The worker reads the single key and the rotation pool (which may hold every * key, with the single key left empty) from settings.json, the environment and * ~/.claude-mem/.env, so each of those counts. Read for validation only: an * exported or .env key is never copied to disk. */ function hasPersonalProviderKey(provider: 'gemini' | 'openrouter', persisted: Record): boolean { const vendor = provider === 'gemini' ? 'GEMINI' : 'OPENROUTER'; const credentials = loadClaudeMemEnv() as Record; return [`${vendor}_API_KEY`, `${vendor}_API_KEYS`].some(name => holdsKey(persisted[`CLAUDE_MEM_${name}`]) || holdsKey(process.env[`CLAUDE_MEM_${name}`]) || holdsKey(credentials[name]), ); } /** Exported for the non-interactive contract tests; not part of the CLI surface. */ export function validateNonInteractiveProvider( options: InstallOptions, summary: InstallSummary, ): void { if (isInteractive) return; if (!options.provider) { // No `--provider` on a non-TTY run. Aborting here taught agents to re-run // with `--provider claude`, which reached the same place with more friction, // so the missing flag now resolves instead of failing. // // A persisted provider wins: `npx claude-mem update` calls this path with no // options, and defaulting an existing Pro (openrouter) config to claude would // run useSubscriptionAuth() and wipe cloud sync. A persisted personal // provider still needs a non-empty key, or the install would report success // with a worker that cannot talk to its provider. Unlike the explicit-flag // check below, a cmem gateway URL is accepted here: a persisted Pro config // keeps its memory key in CLAUDE_MEM_OPENROUTER_API_KEY by design. const persisted = readPersistedInstallerSettings(); const persistedProvider = persisted.CLAUDE_MEM_PROVIDER; if ( persistedProvider === 'claude' || persistedProvider === 'codex' || persistedProvider === 'gemini' || persistedProvider === 'openrouter' || persistedProvider === 'openai-compatible' ) { // Codex, like claude, authenticates through a local login rather than a // saved key, and openai-compatible may point at a local server that takes // no key at all, so there is nothing to validate before keeping them. if (persistedProvider === 'gemini' || persistedProvider === 'openrouter') { const persistedKeyName = persistedProvider === 'gemini' ? 'CLAUDE_MEM_GEMINI_API_KEY' : 'CLAUDE_MEM_OPENROUTER_API_KEY'; // A persisted cmem gateway tuple is locked by the worker to the saved // key: it ignores a key-only override and never pools, so only that // key counts. An exported base URL unlocks the tuple (the worker then // runs on the exported URL and key), so the lock is mirrored exactly: // gateway URL on disk AND no base-URL override in the environment. const persistedCmemGateway = persistedProvider === 'openrouter' && isCmemGatewayUrl(String(persisted.CLAUDE_MEM_OPENROUTER_BASE_URL ?? '')) && !Object.prototype.hasOwnProperty.call(process.env, 'CLAUDE_MEM_OPENROUTER_BASE_URL'); const hasKey = persistedCmemGateway ? holdsKey(persisted[persistedKeyName]) : hasPersonalProviderKey(persistedProvider, persisted); if (!hasKey) { installerError(ErrorSeverity.ABORT, { component: 'provider-credentials', phase: 'non-interactive-validation', cause: new Error(`The configured ${persistedProvider} provider has no API key saved${persistedCmemGateway ? '' : ' or exported'}, so a non-interactive run cannot keep it.`), remediation: persistedCmemGateway ? `Save ${persistedKeyName} in ~/.claude-mem/settings.json (the cmem gateway ignores an exported key), pass --provider claude to switch to your Anthropic plan, or run the installer in an interactive terminal.` : `Save ${persistedKeyName} in ~/.claude-mem/settings.json or export it in the environment, pass --provider claude to switch to your Anthropic plan, or run the installer in an interactive terminal.`, }, summary); } } options.provider = persistedProvider; options.providerSource = 'persisted'; log.info(`Non-interactive run: keeping the configured provider (${persistedProvider}).`); return; } options.provider = 'claude'; options.providerSource = 'default'; log.info('No --provider given on a non-interactive run: defaulting to your Anthropic plan (local memory).'); } if (options.provider === 'host') return; if (options.provider !== 'gemini' && options.provider !== 'openrouter') return; const keyName = options.provider === 'gemini' ? 'CLAUDE_MEM_GEMINI_API_KEY' : 'CLAUDE_MEM_OPENROUTER_API_KEY'; const key = String(getSetting(keyName as keyof SettingsDefaults) ?? '').trim(); const configuredCmemKey = options.provider === 'openrouter' && isCmemGatewayUrl(String(getSetting('CLAUDE_MEM_OPENROUTER_BASE_URL') ?? '')); if (!key || configuredCmemKey) { installerError(ErrorSeverity.ABORT, { component: 'provider-credentials', phase: 'non-interactive-validation', cause: new Error(`${options.provider} requires a preconfigured personal API key when stdin is not interactive.`), remediation: `Save ${keyName} first, or run the installer in an interactive terminal so it can ask securely.`, }, summary); } } /** * Compiles the version-stable hook launcher from the freshly cached plugin and * puts `claude-mem` on PATH (plan-17 #3605, Phase 3). Skips the compile when the * placed binary already prints the current LAUNCHER_PROTOCOL, but still checks * PATH. A failure warns * and continues: `doctor` reports the gap and the plugin's SessionStart * self-heal (scripts/ensure-launcher.cjs) retries on the next session. * Runs inside a spinner task, so lines are returned or queued on the summary, * never printed live. Returns the one-line status for the task result. */ function placeHookLauncherOnPath(pluginRoot: string, summary: InstallSummary): string { const warnHookLauncher = (cause: Error, remediation?: string) => installerError(ErrorSeverity.WARN_CONTINUE, { component: 'hook-launcher', phase: 'setup-runtime', cause, remediation: remediation ?? 'Re-run `npx claude-mem install`; `npx claude-mem doctor` shows the hook launcher row.', }, summary); const launcherStatusLines: string[] = []; try { const ensureResult = ensureLauncherOnPath({ pluginRoot, // ensureBun ran just before this step and aborts the install without Bun. resolveBunPath: getBunPath, invocationContext: 'npx-installer', logger: { info: (line) => launcherStatusLines.push(line), success: (line) => launcherStatusLines.push(line), warn: (line) => warnHookLauncher(new Error(line)), }, }); if (ensureResult.status === 'already-current') { launcherStatusLines.unshift(`claude-mem launcher already current at ${ensureResult.binaryPath}`); } else if (ensureResult.status === 'newer-protocol-kept') { launcherStatusLines.unshift(`claude-mem launcher at ${ensureResult.binaryPath} is newer than this installer; kept`); } else if (ensureResult.status === 'foreign-binary-kept') { return `claude-mem launcher not placed (${ensureResult.binaryPath} is another program; see warnings below)`; } else if (ensureResult.status === 'bun-not-found') { warnHookLauncher(new Error('Bun not found, the claude-mem launcher was not compiled')); return 'claude-mem launcher not placed (Bun not found)'; } return launcherStatusLines.join('\n '); } catch (error: unknown) { warnHookLauncher(error instanceof Error ? error : new Error(String(error))); return 'claude-mem launcher not placed (see warnings below)'; } } /** * The browser funnel's early worker start, before any login or provider * question. Same steps as the final "Starting worker daemon" task. */ async function startInstallWorker(version: string, summary: InstallSummary, message: (msg: string) => void): Promise { 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 worker on port ${port}...`); await requireWorkerStopped(port, 'provider-cutover', summary); return ensureWorkerStarted(port, scriptPath); } function describeWorkerStart(result: WorkerStartResult): string { const port = getSetting('CLAUDE_MEM_WORKER_PORT'); switch (result) { case 'ready': return `Worker ready at http://localhost:${port} ${styleText('green', 'OK')}`; case 'warming': return `Worker starting on port ${port} — finishing in background ${styleText('yellow', '⏳')}`; case 'dead': { const startFailure = getLastWorkerBootFailure()?.split('\n')[0]; return `Worker did not start${startFailure ? ` (${startFailure})` : ''} ${styleText('red', '✗')}`; } } } /** Finish Setup's local tool ids (setup-data.ts SETUP_TOOLS) and the installer IDE each one is. */ const FINISH_SETUP_LOCAL_TOOL_IDES: Record = { 'claude-code': 'claude-code', codex: 'codex-cli' }; function readSettingsSnapshot(): string { return existsSync(USER_SETTINGS_PATH) ? readFileSync(USER_SETTINGS_PATH, 'utf8') : ''; } /** * On The Go in the browser funnel: starts the sign-in pairing, carrying any * earlier hosted-intent installation identity. The checkout that completes it is the * funnel's next step (completeCmemTrialPairing, funnel Phase 8). */ async function startOnTheGoPairing(version: string): Promise { const spinner = isInteractive ? p.spinner() : null; spinner?.start('Starting CMEM Pro sign-in…'); const pairing = await startInstallerOAuthPairing(); if (!pairing) { await captureCliEvent('installer_oauth_start_failed', { version, outcome: lastOAuthStartFailure() ?? 'network', interactive: isInteractive, phase: 'enrollment', }); spinner?.stop(styleText('red', 'Could not start the CMEM Pro sign-in.')); throw new Error(lastOAuthStartErrorMessage() ?? 'Could not start the CMEM Pro sign-in. Run npx claude-mem install again when cmem.ai is reachable.'); } spinner?.stop('CMEM Pro sign-in ready; checkout continues in the browser.'); return pairing; } /** * Whether the installer leaves the worker for the user to start. A fresh * non-interactive install skips it, but an update or reinstall must not: the * overwrite already stopped the old worker, so skipping would leave none * running. Exported for tests. */ export function shouldSkipWorkerAutostart(input: { interactive: boolean; noAutoStart?: boolean; runtime: RuntimeId; alreadyInstalled: boolean; }): boolean { // The server runtime is brought up via its own stack (Docker pg+redis + // `claude-mem server start`), never the worker-service spawner (#2543). if (input.noAutoStart || input.runtime === 'server') return true; return !input.interactive && !input.alreadyInstalled; } export async function runInstallCommand(options: InstallOptions = {}): Promise { const summary = createInstallSummary(); try { await runInstallCommandInner(options, summary); clearLastInstallError(); } catch (error: unknown) { const err = error instanceof Error ? error : new Error(String(error)); if (err instanceof InstallAbortError) { // err.category.id is OUR taxonomy id (error-taxonomy.ts), never a message. await captureCliEvent('install_failed', { error_category: err.category.id, interactive: isInteractive, install_method: detectInstallMethod(), claude_code_version: detectClaudeCodeVersion(), }, { person: true }); // Flush whatever warnings accrued before the abort, then print the // remediation headline and exit non-zero. ABORT must never reach the // "Installation Complete" path. flushSummary(summary, (line) => (isInteractive ? p.log.message(line) : console.error(` ${line}`))); const headline = `Installation Aborted: ${err.category.id}`; if (isInteractive) { p.log.error(headline); p.log.error(err.remediation); p.outro(styleText('red', 'claude-mem installation aborted.')); } else { console.error(`\n ${headline}`); console.error(` ${err.remediation}`); console.error(` ${err.message}`); } process.exit(1); } throw error; } } async function runInstallCommandInner(options: InstallOptions, summary: InstallSummary): Promise { // Explicit host/runtime incompatibility must refuse before provider getters, // runtime settings persistence, or server API-key bootstrap. if (options.ide && options.runtime !== undefined) { const requested = normalizeRuntimeFlag(options.runtime); if (requested !== null) requireSupportedRuntime([canonicalIntegrationId(options.ide)], requested); } const installStartedAt = Date.now(); const version = readPluginVersion(); validateNonInteractiveProvider(options, summary); // Captured by the runtime-setup task below; reported on install_completed // so funnel dropoff can be sliced by toolchain versions. let installedBunVersion: string | undefined; let installedUvVersion: string | undefined; if (isInteractive) { await playBanner(); p.intro(styleText(['bgCyan', '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 = styleText('dim', '·'); const segments = [`${styleText('bold', 'claude-mem')} ${styleText('cyan', `v${version}`)}`]; if (existingVersion && existingVersion !== version) { segments.push(`installed ${styleText('yellow', `v${existingVersion}`)}`); } else if (existingVersion) { segments.push(styleText('dim', 'reinstall')); } log.info(segments.join(` ${dot} `)); // Numbers-only local usage summary for the sign-in start request (and the // block below). Read before the worker is restarted; any failure omits it. const usageSummary = await readUsageSummary({ dbPath: resolveDbPath(), bunPath: getBunPath() }) .catch(() => null); setInstallerSignupContext({ installState: alreadyInstalled ? 'update' : 'fresh', usageSummary }); const usageLines = formatUsageSummaryLines(usageSummary); if (usageLines.length > 0) log.info(usageLines.join('\n')); // Runtime hooks run the on-PATH claude-mem launcher in exec form, with no // shell. Only the Setup hook (version-check.js) still declares // `"shell": "bash"`, and Claude Code fires it only for `claude --init` / // `--maintenance`; on Windows it resolves bash through Git for Windows with // no WSL fallback. A warning, not a hard stop: memory capture works without // Git Bash, and Git for Windows can be installed later without a reinstall. if (IS_WINDOWS) { const gitBash = checkWindowsGitBash(); if (!gitBash.ok) { log.warn(gitBash.detail); } } // An older Claude Code ignores the hooks' `args` and runs bare `claude-mem`, // which captures nothing and tells no one, so say it here. const claudeCodeTooOldWarning = claudeCodeVersionTooOldWarning(detectClaudeCodeVersion()); if (claudeCodeTooOldWarning) log.warn(claudeCodeTooOldWarning); 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) { const ideId = canonicalIntegrationId(options.ide); selectedIDEs = [ideId]; const allIDEs = detectInstalledIDEs(); const match = allIDEs.find((i) => i.id === ideId); 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(options, selectedIDEs); 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; { const tasks: TaskDescriptor[] = [ { title: 'Caching plugin version', task: async (message) => { message(`Caching v${version}...`); await copyPluginToCache(version); return `Plugin cached (v${version}) ${styleText('green', 'OK')}`; }, }, { title: 'Registering marketplace', task: async () => { registerMarketplace(); return `Marketplace registered ${styleText('green', 'OK')}`; }, }, { title: 'Registering plugin', task: async () => { registerPlugin(version); return `Plugin registered ${styleText('green', 'OK')}`; }, }, { title: 'Enabling plugin in Claude settings', task: async () => { enablePluginInClaudeSettings(); return `Plugin enabled ${styleText('green', 'OK')}`; }, }, { title: 'Setting up runtime (first install can take ~30s)', task: async (message) => { message('Checking Bun…'); const { version: bunVersion } = await ensureBun(summary); message('Checking uv…'); const { version: uvVersion } = await ensureUv(summary); installedBunVersion = bunVersion; installedUvVersion = uvVersion; const cacheDir = pluginCacheDirectory(version); if (!isInstallCurrent(cacheDir, version)) { const { bunPath } = await ensureBun(); const stopHeartbeat = startHeartbeat(message, 'Installing plugin dependencies (bun install)…'); try { await installPluginDependencies(cacheDir, bunPath); } finally { stopHeartbeat(); } writeInstallMarker(cacheDir, version, bunVersion, uvVersion); } // Installs run with lifecycle scripts suppressed, so tree-sitter-cli's // own download never ran (#2910). This runs before the sign-in/trial // step, so a failure only warns and a stalled download is cut off at // TREE_SITTER_INSTALL_BUDGET_MS; `npx claude-mem repair` retries it // with the full dependency timeout. const stopTreeSitterHeartbeat = startHeartbeat(message, 'Checking the tree-sitter CLI…'); try { await provisionTreeSitterCli(cacheDir, ErrorSeverity.WARN_CONTINUE, summary, TREE_SITTER_INSTALL_BUDGET_MS); } finally { stopTreeSitterHeartbeat(); } writeInstallMarker(join(marketplaceDirectory(), 'plugin'), version, bunVersion, uvVersion); message('Placing the claude-mem hook launcher on PATH…'); const launcherStatus = placeHookLauncherOnPath(cacheDir, summary); return `Runtime ready (Bun ${bunVersion}, uv ${uvVersion}) ${styleText('green', 'OK')}\n ${launcherStatus}`; }, }, ]; if (needsMarketplace) { tasks.unshift({ title: 'Copying plugin files to marketplace', task: async (message) => { message('Copying to marketplace directory...'); copyPluginToMarketplace(); return `Plugin files copied ${styleText('green', 'OK')}`; }, }); tasks.push({ title: 'Installing marketplace dependencies', task: async (message) => { // runNpmInstallInMarketplace throws InstallAbortError on a real // failure (non-ERESOLVE, or ERESOLVE that --legacy-peer-deps could // not fix). We deliberately do NOT swallow it here — the top-level // handler turns it into "Installation Aborted" + exit 1. const stopHeartbeat = startHeartbeat(message, 'Running npm install…'); try { await runNpmInstallInMarketplace(summary); writeMarketplaceInstallMarkers( marketplaceDirectory(), version, installedBunVersion ?? 'unknown', installedUvVersion ?? 'unknown', ); } finally { stopHeartbeat(); } return `Dependencies installed ${styleText('green', 'OK')}`; }, }); } if (needsMarketplace) { await overwriteWithWorkerStopped(getSetting('CLAUDE_MEM_WORKER_PORT'), summary, () => runTasks(tasks)); } else { await runTasks(tasks); } } const failedIDEs = await setupIDEs(selectedIDEs, summary, options.dshProfile); // Optionally disable Claude Code's built-in auto-memory (CLAUDE_CODE_DISABLE_AUTO_MEMORY=1) // when the user explicitly opts in, either through the interactive prompt or // via --disable-auto-memory. claude-mem's hook-based memory is the intended // source of cross-session context, but we no longer mutate settings.json silently. // Four-state so the summary can distinguish "wrote", "already set", "left enabled", // and "failed". A boolean would conflate the error path with a deliberate no-op. let autoMemoryStatus: 'disabled' | 'already-disabled' | 'left-enabled' | 'failed' | null = null; const autoMemoryChoice = await resolveClaudeAutoMemoryChoice(selectedIDEs, options); if (autoMemoryChoice === 'disable') { 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 — WARN_CONTINUE via the central handler. autoMemoryStatus = 'failed'; const err = error instanceof Error ? error : new Error(String(error)); // [ANTI-PATTERN IGNORED]: recorded via installerError(WARN_CONTINUE) and flushed after the spinners; a direct console call would be clobbered by the clack UI. installerError(ErrorSeverity.WARN_CONTINUE, { component: 'auto-memory', phase: 'post-ide', cause: err, }, summary); } } else if (autoMemoryChoice === 'leave-enabled') { autoMemoryStatus = 'left-enabled'; log.info('Claude Code: leaving native auto-memory enabled unless you explicitly opt in to disabling it.'); } // Browser funnel (GUI mode): the worker starts before any login or provider // question, the setup page opens, and the terminal waits on the worker's // setup state with no time limit. Login happens only if the page picks On // The Go. Everything else keeps the terminal flow below unchanged. let oauthPairing: InstallerOAuthPairing | null = null; // On The Go's pairing, held for the checkout hand-off (funnel Phase 8): // completeCmemTrialPairing(onTheGoPairing) runs when the page asks for checkout. let onTheGoPairing: InstallerOAuthPairing | null = null; let browserProvider: ProviderId = (getSetting('CLAUDE_MEM_PROVIDER') as ProviderId) || 'claude'; let browserOutcome: BrowserSetupOutcome | null = null; let settingsBeforeBrowserSetup: string | null = null; if (shouldUseBrowserSetup({ interactive: isInteractive, provider: options.provider, noAutoStart: options.noAutoStart, runtime: selectedRuntime, setupComplete: readSetupState().status === 'complete', })) { startSetupSession(); try { // Mark this GUI setup before the worker's first refresh tick: its scan uses hosted extraction, // so existing prompts must not launch a local agent while the browser is still opening. writeSetupState({ ...INITIAL_SETUP_STATE }); settingsBeforeBrowserSetup = readSettingsSnapshot(); await runTasks([{ title: 'Starting worker daemon', task: async (message) => { workerStartResult = await startInstallWorker(version, summary, message); return describeWorkerStart(workerStartResult); }, }]); if ((workerStartResult as WorkerStartResult) === 'dead') { log.warn('The setup page needs the worker, which did not start. Continuing setup here in the terminal.'); } else { browserOutcome = await runBrowserSetup({ workerBaseUrl: `http://127.0.0.1:${getSetting('CLAUDE_MEM_WORKER_PORT')}`, openBrowser, log, applyChoice: async (choice, state) => { if (choice.branch === 'home') { // At Home: the existing local branch (logged-in Claude SDK account, no claude-mem login). options.provider = 'claude'; options.providerSource = 'browser'; browserProvider = await promptProvider(options, null, version); await promptClaudeModel(options, false); return 'home-ready'; } if (state.alreadyPro) { log.info('On The Go: keeping your existing CMEM Pro configuration.'); return 'go-kept-existing-pro'; } onTheGoPairing = await startOnTheGoPairing(version); return 'go-awaiting-checkout'; }, // On The Go checkout: the existing pairing → /api/pro/trial/claim (inline login, then Stripe) path, unchanged. checkout: { run: async () => { // A pairing whose checkout failed or was cancelled is not reused; the next ask starts a fresh one. if (!onTheGoPairing) onTheGoPairing = await startOnTheGoPairing(version); const pairing: InstallerOAuthPairing = onTheGoPairing; const delivered = await completeCmemTrialPairing(pairing, version); if (!delivered) { onTheGoPairing = null; return false; } browserProvider = activateBrowserEnrollment(delivered) ?? browserProvider; return true; }, reopen: () => { const pairing = onTheGoPairing as InstallerOAuthPairing | null; if (pairing) openBrowser(pairing.checkoutUrl); }, }, }); } } finally { // Preserve saved choices and errors, but release the local-work pause on every hand-off exit. endSetupSession(); } } // Finish Setup's picked local tools that this run did not install yet are set up now, the same way. const pickedTools = (browserOutcome as BrowserSetupOutcome | null)?.state.tools ?? []; const newlyPickedIDEs = pickedTools.map((tool) => FINISH_SETUP_LOCAL_TOOL_IDES[tool]).filter((ide): ide is string => Boolean(ide) && !selectedIDEs.includes(ide!)); if (newlyPickedIDEs.length) { // Failures land on summary.failedIDEs, the same list the summary reads. await setupIDEs(newlyPickedIDEs, summary, options.dshProfile); selectedIDEs = [...selectedIDEs, ...newlyPickedIDEs]; } // The terminal flow: non-TTY (agent-run) installs, --provider, --no-auto-start, // the server runtime, and machines that already finished the funnel. const terminalAccountAndProvider = async (): Promise => { // Login is account-first for every install EXCEPT one that has already named // a provider needing no claude-mem account. `--provider claude` (or the // non-interactive default to claude) runs memory on the user's own Anthropic // plan, so gating it on browser OAuth made an unrelated cmem.ai outage fail // an install that could have completed offline — and there is no account // question left to ask, because the provider already answered it. Such a // non-interactive install still prints an optional, non-blocking sign-in // link at the very end (offerDeferredLogin). // // Deliberately keyed on the resolved provider, not on reachability: a silent // fallback to a local install whenever cmem.ai is down would quietly change // what the user gets. This only skips a step the provider choice made moot. // // A provider reused from settings on a non-interactive run (`persisted`) is // exempt too, even when it is account-backed: the machine already holds its // account and credentials, and promptProvider returns early for it without // enrolling anything. Running the blocking browser login here would hang an // agent shell polling for up to the pairing TTL and then exit 1. if (options.providerSource === 'persisted') { log.info(`Skipping claude-mem login: keeping the existing account and ${options.provider} configuration.`); } else if (providerNeedsAccount(options.provider)) { oauthPairing = await requireInstallerOAuthLogin(version); if (!oauthPairing) { if (isInteractive) p.cancel('OAuth login is required to finish installation.'); else console.error('OAuth login is required to finish installation.'); process.exit(1); } } else { const skipReason = options.provider === 'host' ? 'host observer uses the logged-in host agent over a local OpenAI-compatible shim.' : options.providerSource === 'default' ? 'no --provider was given, so memory defaults to your own Anthropic plan.' : '--provider claude runs memory on your own Anthropic plan.'; log.info(`Skipping claude-mem login: ${skipReason}`); } const selectedProvider = await promptProvider(options, oauthPairing, version); if (selectedProvider === 'claude') { await promptClaudeModel(options); } return selectedProvider; }; const selectedProvider = browserOutcome ? browserProvider : await terminalAccountAndProvider(); const cloudSyncConfigured = [ getSetting('CLAUDE_MEM_CLOUD_SYNC_TOKEN'), getSetting('CLAUDE_MEM_CLOUD_SYNC_USER_ID'), getSetting('CLAUDE_MEM_CLOUD_SYNC_HUB_URL'), ].every((value) => typeof value === 'string' && value.trim().length > 0); const autoStartSkipped = shouldSkipWorkerAutostart({ interactive: isInteractive, noAutoStart: options.noAutoStart, runtime: selectedRuntime, alreadyInstalled, }); // CLAUDE_MEM_WORKER_AUTOSTART=false: its own manager runs the worker, so a // worker that is not up yet is that manager's to start, not an install failure. const workerManagedExternally = isWorkerAutostartDisabled({ CLAUDE_MEM_WORKER_AUTOSTART: getSetting('CLAUDE_MEM_WORKER_AUTOSTART'), }); await runTasks([ { title: selectedRuntime === 'server' ? 'Starting server daemon' : 'Starting worker daemon', task: async (message) => { if (selectedRuntime === 'server') { return `Server runtime selected — start it with ${styleText('bold', 'npx claude-mem server start')} ${styleText('dim', '(or via Docker compose)')}`; } if (autoStartSkipped) { return options.noAutoStart ? `Skipped (--no-auto-start)` : `Skipped (non-TTY)`; } // The browser funnel started the worker already. It reads the provider and model live, so it restarts only // for settings it reads at boot (cloud sync after On The Go's checkout), and never while it is extracting // goals, work and rules or importing history: the restart waits for that work, however long it takes. if (browserOutcome) { const needRestart = settingsChangesNeedingRestart(settingsBeforeBrowserSetup ?? '', readSettingsSnapshot()); if (!needRestart.length) return describeWorkerStart(workerStartResult); message('Waiting for the worker to finish its setup work before it restarts...'); await waitForBackgroundWorkToFinish({ workerBaseUrl: `http://127.0.0.1:${getSetting('CLAUDE_MEM_WORKER_PORT')}`, log: { info: message, warn: message } }); } 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; // selectedRuntime is narrowed to 'worker' here: the server case // returned above and never reaches the worker-service spawner. message(`Spawning worker on port ${port}...`); // Stop any worker that came up during install with the previous // provider so the RAM queue cannot mix old/new settings. Runtime // POST /api/settings still must not recycle a healthy worker. await requireWorkerStopped(port, 'provider-cutover', summary); workerStartResult = await ensureWorkerStarted(port, scriptPath); return describeWorkerStart(workerStartResult); }, }, ]); // "Installation Complete" only when no ABORT fired (we'd have thrown) AND no // IDE failed. Any failed IDE => "Installation Partial". Reads summary.failedIDEs // (which captures failures that happen AFTER bufferConsole returns), not a // stale local count. const hasFailures = summary.failedIDEs.length > 0; const installStatus = hasFailures ? 'Installation Partial' : 'Installation Complete'; // Keyed on whether a pairing actually ran, not on the provider class: a // persisted account-backed provider skips login and must not claim it. A // persisted claude/host provider holds no claude-mem account at all, so it // reports "not required" rather than a kept account. const browserAccountStatus = browserOutcome?.checkoutPaid ? `CMEM Pro ${PRO_TRIAL_LABEL} active` : browserOutcome?.stage === 'go-awaiting-checkout' ? 'CMEM Pro sign-in started (checkout not completed)' : browserOutcome?.stage === 'go-kept-existing-pro' ? 'Kept existing CMEM Pro account' : null; const accountStatus = oauthPairing ? 'OAuth login complete' : options.provider === 'host' ? 'Not required (host observer)' : !providerNeedsAccount(options.provider) ? 'Not required (local provider)' : options.providerSource === 'persisted' ? 'Kept existing account (no login this run)' : 'No login this run'; const summaryLines = [ `Version: ${styleText('cyan', version)}`, `Plugin dir: ${styleText('cyan', marketplaceDir)}`, `IDEs: ${styleText('cyan', selectedIDEs.join(', '))}`, ]; summaryLines.push(`Account: ${styleText('cyan', browserAccountStatus ?? accountStatus)}`); summaryLines.push(`Cloud sync: ${styleText('cyan', cloudSyncConfigured ? 'ON (CMEM Pro)' : 'OFF (local)')}`); if (autoMemoryStatus === 'disabled') { summaryLines.push(`Auto-memory: ${styleText('cyan', 'disabled')} (CLAUDE_CODE_DISABLE_AUTO_MEMORY=1)`); } else if (autoMemoryStatus === 'already-disabled') { summaryLines.push(`Auto-memory: ${styleText('cyan', 'already disabled')} (CLAUDE_CODE_DISABLE_AUTO_MEMORY=1)`); } else if (autoMemoryStatus === 'left-enabled') { summaryLines.push(`Auto-memory: ${styleText('cyan', 'left enabled')} (native Claude Code memory preserved)`); } else if (autoMemoryStatus === 'failed') { summaryLines.push(`Auto-memory: ${styleText('red', 'write failed')} (see warning above)`); } if (failedIDEs.length > 0) { summaryLines.push(`Failed: ${styleText('red', failedIDEs.join(', '))}`); } if (isInteractive) { p.note(summaryLines.join('\n'), installStatus); } else { console.log(`\n ${installStatus}`); summaryLines.forEach(l => console.log(` ${l}`)); } // Flush all WARN_CONTINUE / FAIL_LOUD_PER_IDE warnings + remediation AFTER the // spinners and summary note (a live print would be clobbered by clack). flushSummary(summary, (line) => (isInteractive ? p.log.message(line) : console.log(` ${line}`))); const workerHost = getSetting('CLAUDE_MEM_WORKER_HOST'); const workerUrlHost = formatHostForUrl(workerHost); 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://${workerUrlHost}:${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)`); } // A sign-in just wrote cloud-sync settings the worker booted with, so // one cheap read of /api/sync/status confirms sync is really configured. // Purely informational and fail-soft — a warming worker legitimately // can't answer yet, and the state is always visible via the cloud-sync skill. if (cloudSyncConfigured && workerReady) { try { const syncResponse = await fetch(`http://${workerUrlHost}:${actualPort}/api/sync/status`, { signal: AbortSignal.timeout(3000), }); if (syncResponse.ok) { const sync = await syncResponse.json() as { configured?: boolean }; if (sync && sync.configured === false) { log.warn('Cloud sync: worker has not picked up the sync settings yet — it will on its next restart.'); } else { log.success('Cloud sync: configured — the worker is reporting sync status.'); } } } catch { // [ANTI-PATTERN IGNORED]: this read-through is purely informational; a worker still warming up legitimately can't answer, and sync state stays visible via the cloud-sync skill. } } } const finalWorkerState = workerStartResult as WorkerStartResult; const workerAlive = finalWorkerState !== 'dead' || workerReady; const workerFailedToStart = !autoStartSkipped && !workerManagedExternally && !workerAlive; const workerBootFailure = workerFailedToStart ? getLastWorkerBootFailure() : undefined; const runtimeLabel = selectedRuntime === 'server' ? 'Server' : 'Worker'; const runtimeStartCommand = selectedRuntime === 'server' ? 'npx claude-mem server start' : 'npx claude-mem start'; const workerBaseUrl = `http://${workerUrlHost}:${actualPort}`; const configuredWorkerBaseUrl = `http://${workerUrlHost}:${workerPort}`; const workerHeadline = autoStartSkipped ? `${styleText('yellow', '!')} ${runtimeLabel} autostart skipped — start it manually with ${styleText('bold', runtimeStartCommand)}` : workerReady || finalWorkerState === 'ready' ? `${styleText('green', '✓')} ${runtimeLabel} running at ${styleText('underline', workerBaseUrl)}` : `${styleText('yellow', '⏳')} ${runtimeLabel} starting at ${styleText('underline', workerBaseUrl)} — give it ~30s, then refresh`; const nextStepsHeadline = workerFailedToStart ? `${styleText('red', '✗')} Worker did not start on port ${styleText('cyan', String(workerPort))}${workerBootFailure ? `: ${workerBootFailure}` : ''} — run ${styleText('bold', 'npx claude-mem doctor')}, then ${styleText('bold', 'npx claude-mem start')}` : autoStartSkipped || workerAlive ? workerHeadline : `${styleText('yellow', '!')} Worker not yet ready on port ${styleText('cyan', String(workerPort))} -- still starting up; check ${styleText('bold', 'claude-mem status')} later, or start manually: ${styleText('bold', 'npx claude-mem start')}`; const firstSuccessOpener = autoStartSkipped ? `once the worker is running, keep ${styleText('underline', configuredWorkerBaseUrl)} open in a browser` : workerAlive ? 'keep that URL open in a browser' : `keep ${styleText('underline', configuredWorkerBaseUrl)} open in a browser`; // Last screen of the funnel: it should read as an invitation to start, not a // manual. Everything here has to earn its line. // // Cut deliberately: the WELCOME_HINT_ENABLED env var (opting out of a hint // they have not seen yet), the uninstall warning (uninstall trivia on the // install screen), and the A/B "two paths" framing that dressed up "just // start working" as a decision the user has to make. const nextSteps = [ nextStepsHeadline, ``, `${styleText('bold', 'Start working.')} Memory builds passively from your first prompt — observations stream in as Claude reads, edits, and runs commands.`, `To watch them live, ${firstSuccessOpener}.`, ``, `Memory injection starts on your second session in a project.`, cloudSyncConfigured ? 'Memory syncs across your signed-in CMEM Pro agents and devices.' : `Everything stays in ${styleText('cyan', '~/.claude-mem')} on this machine. cmem.ai is contacted once, at signup, to create the sign-in link, with a numbers-only usage summary (observation counts and token totals, no prompts, paths, or project names); nothing else is sent to cmem.ai (telemetry is separate: npx claude-mem telemetry).`, ...(cloudSyncConfigured ? [] : [`${PRO_TRIAL_PITCH}: ${styleText('underline', proTrialUrl('installer'))}`]), ``, `${styleText('dim', `Optional: ${'/learn-codebase'} ingests a whole repo up front (~5 min) · How it works: /how-it-works`)}`, ]; if (isInteractive) { p.note(nextSteps.join('\n'), 'Next Steps'); // Deliberately the last interaction of the flow: consent is asked after // the product is installed and working, never as a gate in front of it. await promptTelemetryOptIn(); if (workerFailedToStart) { p.outro(styleText('red', 'claude-mem installed, but the worker did not start.')); process.exitCode = 1; } else if (failedIDEs.length > 0) { p.outro(styleText('yellow', 'claude-mem installed with some IDE setup failures.')); } else { p.outro(styleText('green', 'claude-mem installed successfully!')); } } else { console.log('\n Next Steps'); nextSteps.forEach(l => console.log(` ${l}`)); if (workerFailedToStart) { console.error('\nclaude-mem installed, but the worker did not start.'); process.exitCode = 1; } else if (failedIDEs.length > 0) { console.log('\nclaude-mem installed with some IDE setup failures.'); process.exitCode = 1; } else { console.log('\nclaude-mem installed successfully!'); } // Last line of a non-interactive install so an agent relaying the output // sees the link after the success line. Nothing here may change the exit // status or throw past the summary. try { await offerDeferredLogin(options, version); } catch { // best-effort only } } // After promptTelemetryOptIn so a just-made consent choice is honored. // ide/provider/runtime_mode/install_method are installer enums, the // *_version values are tool version strings — never user data. await captureCliEvent('install_completed', { ide: selectedIDEs.join(','), provider: selectedProvider, provider_source: options.providerSource ?? 'prompt', runtime_mode: selectedRuntime, is_update: alreadyInstalled, outcome: failedIDEs.length > 0 ? 'partial' : 'ok', duration_ms: Date.now() - installStartedAt, interactive: isInteractive, install_method: detectInstallMethod(), bun_version: installedBunVersion, uv_version: installedUvVersion, claude_code_version: detectClaudeCodeVersion(), }, { person: true }); } async function runRepairCommandInner(summary: InstallSummary): Promise { const version = readPluginVersion(); const cacheDir = pluginCacheDirectory(version); const marketplaceDir = marketplaceDirectory(); let bunVersion = 'unknown'; let uvVersion = 'unknown'; if (isInteractive) { p.intro(styleText(['bgCyan', 'black'], ' claude-mem repair ')); } else { console.log('claude-mem repair'); } log.info(`Version: ${styleText('cyan', version)}`); await runTasks([ { title: 'Setting up runtime', task: async (message) => { message('Checking Bun…'); const bun = await ensureBun(summary); bunVersion = bun.version; message('Checking uv…'); const uv = await ensureUv(summary); uvVersion = uv.version; // 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…'); await copyPluginToCache(version); } message('Reinstalling plugin dependencies…'); const { bunPath } = bun; await installPluginDependencies(cacheDir, bunPath); message('Provisioning the tree-sitter CLI…'); await provisionTreeSitterCli(cacheDir, ErrorSeverity.ABORT, summary); writeInstallMarker(cacheDir, version, bunVersion, uvVersion); message('Placing the claude-mem hook launcher on PATH…'); const launcherStatus = placeHookLauncherOnPath(cacheDir, summary); return `Runtime ready (Bun ${bunVersion}, uv ${uvVersion}) ${styleText('green', 'OK')}\n ${launcherStatus}`; }, }, { title: 'Repairing marketplace runtime', task: async (message) => { message('Repopulating marketplace root from npm package…'); copyPluginToMarketplace(); message('Reinstalling marketplace dependencies…'); const stopHeartbeat = startHeartbeat(message, 'Running npm install…'); try { await runNpmInstallInMarketplace(summary); writeMarketplaceInstallMarkers(marketplaceDir, version, bunVersion, uvVersion); } finally { stopHeartbeat(); } return `Marketplace runtime ready ${styleText('green', 'OK')}`; }, }, ]); flushSummary(summary, (line) => (isInteractive ? p.log.message(line) : console.log(` ${line}`))); if (isInteractive) { p.outro(styleText('green', 'claude-mem repair complete.')); } else { console.log('claude-mem repair complete.'); } } export async function runRepairCommand(): Promise { const summary = createInstallSummary(); try { await runRepairCommandInner(summary); clearLastInstallError(); } catch (error: unknown) { const err = error instanceof Error ? error : new Error(String(error)); if (err instanceof InstallAbortError) { flushSummary(summary, (line) => (isInteractive ? p.log.message(line) : console.error(` ${line}`))); const headline = `Repair Aborted: ${err.category.id}`; if (isInteractive) { p.log.error(headline); p.log.error(err.remediation); p.outro(styleText('red', 'claude-mem repair aborted.')); } else { console.error(`\n ${headline}`); console.error(` ${err.remediation}`); console.error(` ${err.message}`); } process.exit(1); } throw error; } }