From fa6b27f159fd8dd8782f98ae415b572c5cde4eee Mon Sep 17 00:00:00 2001 From: Nelson Melo Date: Fri, 31 Jul 2026 13:40:23 -0400 Subject: [PATCH] fix(shared-manager): adopt diverged plugin registry files in linkInstancePlugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: :'. Move adoptDivergedFileContent to fs-helpers (avoids a circular import) and apply it to file-type plugin entries before re-linking. --- src/management/shared-manager/fs-helpers.ts | 36 +++++++++++++++++++ .../shared-manager/plugin-layout-internals.ts | 11 +++++- .../shared-manager/shared-dir-linker.ts | 35 +----------------- tests/unit/shared-manager.test.ts | 23 ++++++++++++ 4 files changed, 70 insertions(+), 35 deletions(-) diff --git a/src/management/shared-manager/fs-helpers.ts b/src/management/shared-manager/fs-helpers.ts index ab8d82bb..b8ffbc27 100644 --- a/src/management/shared-manager/fs-helpers.ts +++ b/src/management/shared-manager/fs-helpers.ts @@ -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). diff --git a/src/management/shared-manager/plugin-layout-internals.ts b/src/management/shared-manager/plugin-layout-internals.ts index 5e4566d8..0480005e 100644 --- a/src/management/shared-manager/plugin-layout-internals.ts +++ b/src/management/shared-manager/plugin-layout-internals.ts @@ -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 { diff --git a/src/management/shared-manager/shared-dir-linker.ts b/src/management/shared-manager/shared-dir-linker.ts index c74fc964..f46850ba 100644 --- a/src/management/shared-manager/shared-dir-linker.ts +++ b/src/management/shared-manager/shared-dir-linker.ts @@ -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. diff --git a/tests/unit/shared-manager.test.ts b/tests/unit/shared-manager.test.ts index e4c98dee..a7eec44a 100644 --- a/tests/unit/shared-manager.test.ts +++ b/tests/unit/shared-manager.test.ts @@ -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 }; + 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');