fix(shared-manager): adopt diverged plugin registry files in linkInstancePlugins

The same atomic-rename divergence occurs in the plugins subtree: a
plugin install inside a session rewrites plugins/installed_plugins.json,
replacing the instance-level symlink with a regular file. The per-launch
relink then discarded it, so the plugin was effectively uninstalled on
every relaunch while settings.json still marked it enabled — sessions
then fail with 'Unknown skill: <plugin>:<skill>'.

Move adoptDivergedFileContent to fs-helpers (avoids a circular import)
and apply it to file-type plugin entries before re-linking.
This commit is contained in:
Nelson Melo committed 2026-07-31 13:40:23 -04:00
1 parent 481f013ec2
commit fa6b27f159
4 files changed
+70 -35

No files matched your search

@@ -11,8 +11,44 @@
import * as fs from 'fs';
import * as path from 'path';
import { info } from '../../utils/ui';
import type { SharedItem } from './types';
/**
* Claude Code saves managed files (settings.json, plugins/installed_plugins.json)
* with an atomic write (temp file + rename), which replaces a managed symlink
* with a regular file holding the user's latest changes — see #57 and #1681.
* 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.
*/
export 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.
}
}
/**
* Return canonical realpath for a path. Falls back to the lexical resolve
* when realpath fails (e.g. path does not exist).
@@ -14,7 +14,12 @@ import * as fs from 'fs';
import * as path from 'path';
import { warn } from '../../utils/ui';
import { copyDirectoryFallback, removeExistingPath, symlinkPointsTo } from './fs-helpers';
import {
adoptDivergedFileContent,
copyDirectoryFallback,
removeExistingPath,
symlinkPointsTo,
} from './fs-helpers';
import type { PluginMetadataRoots } from './plugin-metadata-normalizer';
import { reconcileLocalMarketplaceRegistry } from './plugin-metadata-normalizer';
import {
@@ -87,6 +92,10 @@ export function linkInstancePlugins(roots: PluginLayoutRoots, instancePath: stri
const targetEntryPath = path.join(targetPath, item.name);
const linkEntryPath = path.join(linkPath, item.name);
if (item.type === 'file') {
adoptDivergedFileContent(linkEntryPath, path.join(roots.claudeDir, 'plugins', item.name));
}
removeExistingPath(linkEntryPath, item.type);
try {
@@ -19,6 +19,7 @@ import * as path from 'path';
import { info, warn } from '../../utils/ui';
import {
adoptDivergedFileContent,
copyDirectoryFallback,
getLstatSync,
isPathWithinDirectory,
@@ -88,40 +89,6 @@ 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.
+23
View File
@@ -347,6 +347,29 @@ describe('SharedManager', () => {
expect(readJson(claudeSettingsPath)).toEqual({ theme: 'dark' });
});
it('adopts a diverged instance plugin registry during instance linking', () => {
const manager = new SharedManager();
const instancePath = instanceDir('work');
const claudeRegistryPath = path.join(claudeDir(), 'plugins', 'installed_plugins.json');
const instanceRegistryPath = path.join(instancePath, 'plugins', 'installed_plugins.json');
fs.mkdirSync(claudeDir(), { recursive: true });
fs.mkdirSync(path.join(instancePath, 'plugins'), { recursive: true });
writeJson(claudeRegistryPath, { version: 2, plugins: {} });
// Simulate Claude Code's atomic save replacing the managed symlink with
// a regular file that records a plugin installed inside a session.
writeJson(instanceRegistryPath, {
version: 2,
plugins: { 'demo@demo-market': [{ scope: 'user', version: '1.0.0' }] },
});
manager.linkSharedDirectories(instancePath);
expect(fs.lstatSync(instanceRegistryPath).isSymbolicLink()).toBe(true);
const adopted = readJson(claudeRegistryPath) as { plugins: Record<string, unknown> };
expect(Object.keys(adopted.plugins)).toContain('demo@demo-market');
});
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');