fix(install): disable Claude Code auto-memory on every claude-code install
Disable Claude Code auto-memory during claude-code installs and harden atomic settings writes, including symlink and dangling-symlink destinations.
This commit is contained in:
@@ -135,6 +135,31 @@ function enablePluginInClaudeSettings(): void {
|
||||
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);
|
||||
@@ -1116,6 +1141,30 @@ export async function runInstallCommand(options: InstallOptions = {}): Promise<v
|
||||
|
||||
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([
|
||||
@@ -1151,6 +1200,13 @@ export async function runInstallCommand(options: InstallOptions = {}): Promise<v
|
||||
`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(', '))}`);
|
||||
}
|
||||
|
||||
+117
-4
@@ -1,7 +1,22 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
fsyncSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeSync,
|
||||
} from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { dirname, join } from 'path';
|
||||
import { basename, dirname, join, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
export const IS_WINDOWS = process.platform === 'win32';
|
||||
|
||||
@@ -86,7 +101,105 @@ export function ensureDirectoryExists(directoryPath: string): void {
|
||||
|
||||
export { readJsonSafe } from '../../utils/json-utils.js';
|
||||
|
||||
/**
|
||||
* Write JSON to disk with crash-safe atomic-rename semantics.
|
||||
*
|
||||
* Sequence: resolve symlinks at the destination, write payload to a uniquely
|
||||
* named temp file in the same directory as the resolved target, loop writeSync
|
||||
* until the full payload is on disk, fsync the fd, close, rename over the
|
||||
* resolved target, then fsync the parent directory for crash durability. The
|
||||
* rename is atomic on POSIX and on Windows Vista+ (Node uses
|
||||
* MoveFileExW/MOVEFILE_REPLACE_EXISTING under the hood). A crash mid-write
|
||||
* leaves either the old contents or the new contents — never a truncated file.
|
||||
*
|
||||
* Symlink-safe: POSIX rename(2) replaces the symlink itself rather than the
|
||||
* target file, so a naive rename over a symlinked destination would break the
|
||||
* link. We lstat/realpath up front so the temp file lives next to the real
|
||||
* target and the rename writes through the link.
|
||||
*
|
||||
* Preserves the destination file's mode bits when the file already exists so
|
||||
* we don't accidentally widen permissions on user-owned configs like
|
||||
* ~/.claude/settings.json.
|
||||
*/
|
||||
export function writeJsonFileAtomic(filepath: string, data: any): void {
|
||||
ensureDirectoryExists(dirname(filepath));
|
||||
writeFileSync(filepath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
||||
// POSIX rename(2) operates on the symlink itself, so an atomic rename over
|
||||
// a symlinked destination would replace the link rather than writing through
|
||||
// it. Resolve up front so temp + rename both live on the real target's fs.
|
||||
let resolved = filepath;
|
||||
try {
|
||||
if (lstatSync(filepath).isSymbolicLink()) {
|
||||
try {
|
||||
resolved = realpathSync(filepath);
|
||||
} catch {
|
||||
const linkTarget = readlinkSync(filepath);
|
||||
resolved = resolve(dirname(filepath), linkTarget);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
|
||||
throw err;
|
||||
}
|
||||
// Destination doesn't exist yet - write directly to the literal path.
|
||||
}
|
||||
|
||||
ensureDirectoryExists(dirname(resolved));
|
||||
|
||||
const dir = dirname(resolved);
|
||||
const base = basename(resolved);
|
||||
const tmpPath = join(dir, `.${base}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
|
||||
const payload = Buffer.from(JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
||||
|
||||
// Preserve existing mode if the destination already exists; otherwise let
|
||||
// the OS apply the standard new-file default (0o666 minus umask via openSync).
|
||||
let mode: number | undefined;
|
||||
try {
|
||||
mode = statSync(resolved).mode & 0o777;
|
||||
} catch {
|
||||
// File doesn't exist yet — fall through to default mode.
|
||||
}
|
||||
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
fd = mode !== undefined ? openSync(tmpPath, 'w', mode) : openSync(tmpPath, 'w');
|
||||
|
||||
// writeSync wraps POSIX write(2), which may short-write — loop until the
|
||||
// full payload is committed before fsync.
|
||||
let written = 0;
|
||||
while (written < payload.length) {
|
||||
const n = writeSync(fd, payload, written, payload.length - written);
|
||||
if (n === 0) {
|
||||
throw new Error(`writeSync stalled at ${written}/${payload.length} bytes`);
|
||||
}
|
||||
written += n;
|
||||
}
|
||||
|
||||
fsyncSync(fd);
|
||||
closeSync(fd);
|
||||
fd = undefined;
|
||||
renameSync(tmpPath, resolved);
|
||||
|
||||
// fsync the parent directory so the rename's directory-entry change
|
||||
// survives a crash. Best-effort: Windows can't fsync a directory and
|
||||
// some filesystems disallow it — skip silently in those cases.
|
||||
if (!IS_WINDOWS) {
|
||||
let dirFd: number | undefined;
|
||||
try {
|
||||
dirFd = openSync(dir, 'r');
|
||||
fsyncSync(dirFd);
|
||||
} catch {
|
||||
// Best-effort durability.
|
||||
} finally {
|
||||
if (dirFd !== undefined) {
|
||||
try { closeSync(dirFd); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (fd !== undefined) {
|
||||
try { closeSync(fd); } catch { /* ignore close-after-error */ }
|
||||
}
|
||||
try { unlinkSync(tmpPath); } catch { /* tempfile may not exist */ }
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user