fix(shared-manager): adopt diverged settings.json content before re-linking

Claude Code saves settings.json atomically (temp file + rename), which
replaces the managed shared symlink with a regular file holding the
user's latest changes (see #57). The launch-time relink then deleted
that file without reading it, silently reverting plugin enables and any
other in-session settings change on every profile relaunch.

Adopt the diverged file's content into the canonical ~/.claude file
(with a .bak-ccs-adopt backup) before restoring the symlink, at both
the shared-level and instance-level reconciliation points.

Fixes #1681
This commit is contained in:
Nelson Melo committed 2026-07-31 12:14:29 -04:00
1 parent f532f46c9f
commit 481f013ec2
2 files changed
+101

No files matched your search

@@ -88,6 +88,40 @@ export function detectCircularSymlink(target: string, sharedDir: string): boolea
return false;
}
/**
* Claude Code saves settings.json with an atomic write (temp file + rename),
* which replaces a managed symlink with a regular file holding the user's
* latest changes (e.g. enabledPlugins toggles from /plugins) — see #57.
* When reconciliation finds such a diverged regular file, adopt its content
* into the canonical ~/.claude file before re-creating the symlink, instead
* of discarding the user's changes. The previous canonical content is kept
* in a `.bak-ccs-adopt` backup alongside it.
*/
function adoptDivergedFileContent(divergedPath: string, canonicalPath: string): void {
try {
const stats = fs.lstatSync(divergedPath);
if (!stats.isFile()) {
return;
}
const diverged = fs.readFileSync(divergedPath);
const current = fs.existsSync(canonicalPath) ? fs.readFileSync(canonicalPath) : null;
if (current && diverged.equals(current)) {
return;
}
if (current) {
fs.copyFileSync(canonicalPath, `${canonicalPath}.bak-ccs-adopt`);
}
fs.writeFileSync(canonicalPath, diverged);
console.log(
info(`Adopted diverged ${path.basename(divergedPath)} content into ${canonicalPath}`)
);
} catch (_err) {
// Best effort: fall through to standard re-link behavior.
}
}
/**
* Ensure shared directories exist as symlinks to ~/.claude/ and that the
* plugin layout default directories and registry files are present.
@@ -138,6 +172,10 @@ export function ensureSharedDirectories(roots: LinkerRoots): void {
// Continue to recreate
}
if (item.type === 'file') {
adoptDivergedFileContent(sharedPath, claudePath);
}
if (item.type === 'directory') {
fs.rmSync(sharedPath, { recursive: true, force: true });
} else {
@@ -182,6 +220,10 @@ export function linkSharedDirectories(roots: LinkerRoots, instancePath: string):
const linkPath = path.join(instancePath, item.name);
const targetPath = path.join(sharedDir, item.name);
if (item.type === 'file') {
adoptDivergedFileContent(linkPath, path.join(roots.claudeDir, item.name));
}
removeExistingPath(linkPath, item.type);
try {
+59
View File
@@ -306,6 +306,65 @@ describe('SharedManager', () => {
});
});
describe('diverged settings adoption', () => {
it('adopts a diverged shared settings.json into ~/.claude before re-linking', () => {
const manager = new SharedManager();
const claudeSettingsPath = path.join(claudeDir(), 'settings.json');
const sharedSettingsPath = path.join(ccsDir(), 'shared', 'settings.json');
fs.mkdirSync(claudeDir(), { recursive: true });
fs.mkdirSync(path.join(ccsDir(), 'shared'), { recursive: true });
writeJson(claudeSettingsPath, { enabledPlugins: { 'demo@market': false } });
// Simulate Claude Code's atomic save (temp file + rename) replacing the
// managed symlink with a regular file carrying the user's latest change.
writeJson(sharedSettingsPath, { enabledPlugins: { 'demo@market': true } });
manager.ensureSharedDirectories();
expect(fs.lstatSync(sharedSettingsPath).isSymbolicLink()).toBe(true);
expect(readJson(claudeSettingsPath)).toEqual({
enabledPlugins: { 'demo@market': true },
});
expect(readJson(`${claudeSettingsPath}.bak-ccs-adopt`)).toEqual({
enabledPlugins: { 'demo@market': false },
});
});
it('adopts a diverged instance settings.json during instance linking', () => {
const manager = new SharedManager();
const instancePath = instanceDir('work');
const claudeSettingsPath = path.join(claudeDir(), 'settings.json');
const instanceSettingsPath = path.join(instancePath, 'settings.json');
fs.mkdirSync(claudeDir(), { recursive: true });
fs.mkdirSync(instancePath, { recursive: true });
writeJson(claudeSettingsPath, { theme: 'light' });
writeJson(instanceSettingsPath, { theme: 'dark' });
manager.linkSharedDirectories(instancePath);
expect(fs.lstatSync(instanceSettingsPath).isSymbolicLink()).toBe(true);
expect(readJson(claudeSettingsPath)).toEqual({ theme: 'dark' });
});
it('does not create a backup when the diverged copy matches the canonical file', () => {
const manager = new SharedManager();
const claudeSettingsPath = path.join(claudeDir(), 'settings.json');
const sharedSettingsPath = path.join(ccsDir(), 'shared', 'settings.json');
fs.mkdirSync(claudeDir(), { recursive: true });
fs.mkdirSync(path.join(ccsDir(), 'shared'), { recursive: true });
writeJson(claudeSettingsPath, { theme: 'dark' });
fs.copyFileSync(claudeSettingsPath, sharedSettingsPath);
manager.ensureSharedDirectories();
expect(fs.lstatSync(sharedSettingsPath).isSymbolicLink()).toBe(true);
expect(readJson(claudeSettingsPath)).toEqual({ theme: 'dark' });
expect(fs.existsSync(`${claudeSettingsPath}.bak-ccs-adopt`)).toBe(false);
});
});
describe('marketplace registry ownership', () => {
it('skips unstatable shared plugin entries during instance linking', () => {
const manager = new SharedManager();