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