magus-cli 7.3.1 → 7.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
  3. package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
  4. package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
  5. package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
  6. package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
  7. package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
  8. package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
  9. package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
  10. package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
  11. package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
  12. package/src/__tests__/blind/c19-paste-routing.test.ts +252 -0
  13. package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
  14. package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
  15. package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
  16. package/src/__tests__/blind/c5-keymap.test.ts +461 -0
  17. package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
  18. package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
  19. package/src/__tests__/blind/c8-text-width.test.ts +137 -0
  20. package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
  21. package/src/__tests__/blind/child-run.ts +114 -0
  22. package/src/__tests__/cli-tools-adapter.test.ts +5 -2
  23. package/src/__tests__/component-badge.test.ts +20 -36
  24. package/src/__tests__/footer-hints.test.ts +1 -1
  25. package/src/__tests__/manifest.test.ts +25 -17
  26. package/src/__tests__/marketplace-badge.test.ts +4 -4
  27. package/src/__tests__/models-screen-state.test.ts +36 -13
  28. package/src/__tests__/scope-squares.test.tsx +28 -114
  29. package/src/__tests__/shutdown.test.ts +578 -0
  30. package/src/__tests__/stale-load-repair.test.ts +5 -4
  31. package/src/__tests__/styles-manager.test.ts +4 -4
  32. package/src/__tests__/styles-screen-state.test.ts +26 -12
  33. package/src/__tests__/tabbar-layout.test.ts +44 -32
  34. package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
  35. package/src/__tests__/tool-probe.test.ts +47 -22
  36. package/src/__tests__/uppercase-keybindings.test.ts +25 -12
  37. package/src/cli/ansi.ts +2 -2
  38. package/src/cli/bootstrap.ts +33 -2
  39. package/src/cli/doctor.ts +120 -15
  40. package/src/cli/install.ts +35 -4
  41. package/src/cli/profile.ts +18 -1
  42. package/src/cli/router.ts +31 -18
  43. package/src/cli/update-view.ts +1 -1
  44. package/src/data/cli-tools.ts +9 -1
  45. package/src/services/claude-settings.ts +434 -198
  46. package/src/services/cli-tool-commands.ts +45 -5
  47. package/src/services/cli-tool-version.ts +101 -0
  48. package/src/services/gitignore-service.ts +39 -22
  49. package/src/services/hook-registration.ts +23 -6
  50. package/src/services/manifest.ts +117 -52
  51. package/src/services/plugin-manager.ts +19 -5
  52. package/src/services/profile-adopt.ts +7 -2
  53. package/src/services/profile-startup.ts +87 -7
  54. package/src/services/profiles.ts +36 -23
  55. package/src/services/settings-file.ts +164 -4
  56. package/src/services/settings-manager.ts +19 -5
  57. package/src/services/skills-manager.ts +46 -0
  58. package/src/services/tool-probe.ts +316 -44
  59. package/src/services/toolchain.ts +39 -0
  60. package/src/services/update-engine.ts +5 -0
  61. package/src/services/update-plan.ts +9 -0
  62. package/src/tui.tsx +48 -21
  63. package/src/ui/App.tsx +58 -197
  64. package/src/ui/adapters/cliToolsAdapter.ts +141 -12
  65. package/src/ui/adapters/pluginsAdapter.ts +10 -7
  66. package/src/ui/adapters/settingsAdapter.ts +0 -21
  67. package/src/ui/adapters/skillsAdapter.ts +27 -8
  68. package/src/ui/adapters/stylesAdapter.ts +15 -11
  69. package/src/ui/components/CategoryHeader.tsx +7 -1
  70. package/src/ui/components/EmptyFilterState.tsx +2 -2
  71. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  72. package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
  73. package/src/ui/components/TabBar.tsx +93 -21
  74. package/src/ui/components/layout/FooterHints.tsx +6 -27
  75. package/src/ui/components/layout/ScreenLayout.tsx +68 -42
  76. package/src/ui/components/layout/index.ts +1 -5
  77. package/src/ui/components/modals/ConfirmModal.tsx +2 -19
  78. package/src/ui/components/modals/HelpOverlay.tsx +186 -0
  79. package/src/ui/components/modals/MessageModal.tsx +3 -15
  80. package/src/ui/components/modals/ModalContainer.tsx +107 -60
  81. package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
  82. package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
  83. package/src/ui/components/primitives/MetaText.tsx +1 -1
  84. package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
  85. package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
  86. package/src/ui/components/primitives/SelectableRow.tsx +24 -1
  87. package/src/ui/components/primitives/index.ts +4 -3
  88. package/src/ui/hooks/useProfileStartupModal.ts +15 -3
  89. package/src/ui/input/KeyRouter.tsx +304 -0
  90. package/src/ui/input/key-dispatch.ts +197 -0
  91. package/src/ui/keymap.ts +905 -0
  92. package/src/ui/renderers/cliToolRenderers.tsx +192 -92
  93. package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
  94. package/src/ui/renderers/markdownRenderer.tsx +2 -2
  95. package/src/ui/renderers/mcpRenderers.tsx +28 -14
  96. package/src/ui/renderers/modelRenderers.tsx +12 -13
  97. package/src/ui/renderers/modelVisuals.tsx +0 -2
  98. package/src/ui/renderers/pluginRenderers.tsx +47 -54
  99. package/src/ui/renderers/profileRenderers.tsx +32 -37
  100. package/src/ui/renderers/settingsRenderers.tsx +84 -55
  101. package/src/ui/renderers/skillRenderers.tsx +237 -190
  102. package/src/ui/renderers/styleRenderers.tsx +13 -2
  103. package/src/ui/runtime/shutdown.ts +268 -0
  104. package/src/ui/screens/AliasScreen.tsx +118 -100
  105. package/src/ui/screens/CliToolsScreen.tsx +173 -101
  106. package/src/ui/screens/EnvVarsScreen.tsx +44 -42
  107. package/src/ui/screens/GitignoreScreen.tsx +50 -55
  108. package/src/ui/screens/McpRegistryScreen.tsx +52 -86
  109. package/src/ui/screens/McpScreen.tsx +47 -33
  110. package/src/ui/screens/ModelsScreen.tsx +59 -104
  111. package/src/ui/screens/PluginsScreen.tsx +129 -129
  112. package/src/ui/screens/ProfilesScreen.tsx +49 -46
  113. package/src/ui/screens/SkillsScreen.tsx +184 -204
  114. package/src/ui/screens/StylesScreen.tsx +104 -157
  115. package/src/ui/state/AppContext.tsx +6 -0
  116. package/src/ui/state/DimensionsContext.tsx +1 -10
  117. package/src/ui/state/reducer.ts +5 -8
  118. package/src/ui/state/types.ts +11 -9
  119. package/src/ui/text-width.ts +341 -0
  120. package/src/ui/theme-mode.ts +34 -71
  121. package/src/ui/theme.ts +50 -28
  122. package/src/utils/request-gate.ts +37 -0
  123. package/src/ui/components/ScopeIndicator.tsx +0 -63
  124. package/src/ui/components/SearchInput.tsx +0 -57
  125. package/src/ui/hooks/index.ts +0 -2
  126. package/src/ui/hooks/useKeyboard.ts +0 -24
  127. package/src/ui/hooks/useKeyboardHandler.ts +0 -63
@@ -28,6 +28,22 @@ import {
28
28
  pickLoadedEntry,
29
29
  repoRootForPath,
30
30
  } from "./plugin-resolution.js";
31
+ import {
32
+ SettingsParseError,
33
+ SettingsShapeError,
34
+ describeJsonKind,
35
+ isJsonObject,
36
+ readSettingsFile,
37
+ writableList,
38
+ writableMap,
39
+ } from "./settings-file.js";
40
+
41
+ /**
42
+ * What every reader below throws for a file that exists but does not parse.
43
+ * Defined beside `readSettingsFile`, which all four readers delegate to, and
44
+ * re-exported here because this is the module callers read settings through.
45
+ */
46
+ export { SettingsParseError } from "./settings-file.js";
31
47
 
32
48
  const CLAUDE_DIR = ".claude";
33
49
  const SETTINGS_FILE = "settings.json";
@@ -44,8 +60,17 @@ export function getClaudeDir(projectPath?: string): string {
44
60
  return path.join(base, CLAUDE_DIR);
45
61
  }
46
62
 
63
+ /**
64
+ * `~/.claude`, resolved per call.
65
+ *
66
+ * `HOME` first, then `os.homedir()` — Node's own rule for `os.homedir()`, done
67
+ * by hand because Bun does not follow it: under Bun 1.4 `os.homedir()` answers
68
+ * the home the process started with and ignores a later `process.env.HOME`
69
+ * (measured; Node follows the change). Reading `HOME` at call time is what lets
70
+ * a caller — a test above all — point the global settings somewhere else.
71
+ */
47
72
  export function getGlobalClaudeDir(): string {
48
- return path.join(os.homedir(), CLAUDE_DIR);
73
+ return path.join(process.env.HOME || os.homedir(), CLAUDE_DIR);
49
74
  }
50
75
 
51
76
  export async function ensureClaudeDir(projectPath?: string): Promise<string> {
@@ -54,24 +79,76 @@ export async function ensureClaudeDir(projectPath?: string): Promise<string> {
54
79
  return claudeDir;
55
80
  }
56
81
 
82
+ /*
83
+ * THE FOUR READERS: a missing file reads as `{}`; a file that exists but does
84
+ * not parse throws `SettingsParseError`, never `{}`.
85
+ *
86
+ * They used to swallow every error and answer `{}`. Each has a writer beside
87
+ * it, and every read-modify-write in magus is `read → change one key →
88
+ * write`, so a settings file a human was mid-edit in came back as `{}` plus
89
+ * one key — and was written over, taking the rest of the configuration with
90
+ * it. Throwing from the read is what makes every one of those callers refuse:
91
+ * nothing reaches the write, so the file stays byte-for-byte as it was.
92
+ */
93
+
94
+ /*
95
+ * Each file's path has one getter, used by its reader AND by every writer that
96
+ * checks a map before writing into it (`writableMap`), so a refusal names the
97
+ * file that was actually read.
98
+ */
99
+
100
+ /**
101
+ * THE FOUR WRITERS all start here: the file as it is NOW must still be one this
102
+ * module would read, or nothing is written.
103
+ *
104
+ * Every caller reads before it writes, but not necessarily just before —
105
+ * `magus install` reads the local settings, puts a prompt per missing variable
106
+ * on screen, and writes when the last one is answered. Claude Code writes that
107
+ * same file while those prompts are up (a permission rule, whenever someone
108
+ * answers "don't ask again"), and `writeJson` would replace it with a document
109
+ * built from the pre-prompt read.
110
+ *
111
+ * Checking at write time is what makes the refusal hold for every writer instead
112
+ * of only the ones that happened to re-read — the same rule `writeManifest`
113
+ * applies to `.claude/profiles.json`. The read is for the throw alone; the
114
+ * contents are about to be replaced.
115
+ *
116
+ * It closes the gap, it does not close the race: the check and the write are two
117
+ * syscalls. The window is microseconds rather than however long a human takes to
118
+ * answer a prompt.
119
+ */
120
+ async function refuseUnwritableFile(filePath: string): Promise<void> {
121
+ await readSettingsFile(filePath);
122
+ }
123
+
124
+ /** <project>/.claude/settings.json */
125
+ export function getSettingsPath(projectPath?: string): string {
126
+ return path.join(getClaudeDir(projectPath), SETTINGS_FILE);
127
+ }
128
+
129
+ /** <project>/.claude/settings.local.json */
130
+ export function getLocalSettingsPath(projectPath?: string): string {
131
+ return path.join(getClaudeDir(projectPath), LOCAL_SETTINGS_FILE);
132
+ }
133
+
134
+ /** ~/.claude/settings.json */
135
+ export function getGlobalSettingsPath(): string {
136
+ return path.join(getGlobalClaudeDir(), SETTINGS_FILE);
137
+ }
138
+
57
139
  export async function readSettings(
58
140
  projectPath?: string,
59
141
  ): Promise<ClaudeSettings> {
60
- const settingsPath = path.join(getClaudeDir(projectPath), SETTINGS_FILE);
61
- try {
62
- if (await fs.pathExists(settingsPath)) {
63
- return await fs.readJson(settingsPath);
64
- }
65
- } catch {
66
- // Return empty settings on error
67
- }
68
- return {};
142
+ return (await readSettingsFile(
143
+ getSettingsPath(projectPath),
144
+ )) as ClaudeSettings;
69
145
  }
70
146
 
71
147
  export async function writeSettings(
72
148
  settings: ClaudeSettings,
73
149
  projectPath?: string,
74
150
  ): Promise<void> {
151
+ await refuseUnwritableFile(getSettingsPath(projectPath));
75
152
  const claudeDir = await ensureClaudeDir(projectPath);
76
153
  const settingsPath = path.join(claudeDir, SETTINGS_FILE);
77
154
  await fs.writeJson(settingsPath, settings, { spaces: 2 });
@@ -80,21 +157,16 @@ export async function writeSettings(
80
157
  export async function readLocalSettings(
81
158
  projectPath?: string,
82
159
  ): Promise<ClaudeLocalSettings> {
83
- const localPath = path.join(getClaudeDir(projectPath), LOCAL_SETTINGS_FILE);
84
- try {
85
- if (await fs.pathExists(localPath)) {
86
- return await fs.readJson(localPath);
87
- }
88
- } catch {
89
- // Return empty settings on error
90
- }
91
- return {};
160
+ return (await readSettingsFile(
161
+ getLocalSettingsPath(projectPath),
162
+ )) as ClaudeLocalSettings;
92
163
  }
93
164
 
94
165
  export async function writeLocalSettings(
95
166
  settings: ClaudeLocalSettings,
96
167
  projectPath?: string,
97
168
  ): Promise<void> {
169
+ await refuseUnwritableFile(getLocalSettingsPath(projectPath));
98
170
  const claudeDir = await ensureClaudeDir(projectPath);
99
171
  const localPath = path.join(claudeDir, LOCAL_SETTINGS_FILE);
100
172
  await fs.writeJson(localPath, settings, { spaces: 2 });
@@ -109,15 +181,9 @@ export function getMcpConfigPath(projectPath?: string): string {
109
181
  export async function readMcpConfig(
110
182
  projectPath?: string,
111
183
  ): Promise<McpConfigFile> {
112
- const mcpPath = getMcpConfigPath(projectPath);
113
- try {
114
- if (await fs.pathExists(mcpPath)) {
115
- return await fs.readJson(mcpPath);
116
- }
117
- } catch {
118
- // Return empty config on error
119
- }
120
- return {};
184
+ return (await readSettingsFile(
185
+ getMcpConfigPath(projectPath),
186
+ )) as McpConfigFile;
121
187
  }
122
188
 
123
189
  export async function writeMcpConfig(
@@ -125,24 +191,18 @@ export async function writeMcpConfig(
125
191
  projectPath?: string,
126
192
  ): Promise<void> {
127
193
  const mcpPath = getMcpConfigPath(projectPath);
194
+ await refuseUnwritableFile(mcpPath);
128
195
  await fs.writeJson(mcpPath, config, { spaces: 2 });
129
196
  }
130
197
 
131
198
  export async function readGlobalSettings(): Promise<ClaudeSettings> {
132
- const settingsPath = path.join(getGlobalClaudeDir(), SETTINGS_FILE);
133
- try {
134
- if (await fs.pathExists(settingsPath)) {
135
- return await fs.readJson(settingsPath);
136
- }
137
- } catch {
138
- // Return empty settings on error
139
- }
140
- return {};
199
+ return (await readSettingsFile(getGlobalSettingsPath())) as ClaudeSettings;
141
200
  }
142
201
 
143
202
  export async function writeGlobalSettings(
144
203
  settings: ClaudeSettings,
145
204
  ): Promise<void> {
205
+ await refuseUnwritableFile(getGlobalSettingsPath());
146
206
  await fs.ensureDir(getGlobalClaudeDir());
147
207
  const settingsPath = path.join(getGlobalClaudeDir(), SETTINGS_FILE);
148
208
  await fs.writeJson(settingsPath, settings, { spaces: 2 });
@@ -160,15 +220,27 @@ export async function addMcpServer(
160
220
  // biome-ignore lint/performance/noDelete: this object is serialised into .mcp.json; the env key must be absent, not present as undefined
161
221
  delete configWithoutEnv.env;
162
222
 
163
- // Add to .mcp.json (without env vars)
223
+ // Read BOTH files, and check every map written into, before writing either:
224
+ // if one does not parse or has the wrong shape, the whole operation refuses,
225
+ // rather than leaving .mcp.json changed and the local settings not.
164
226
  const mcpConfig = await readMcpConfig(projectPath);
165
- mcpConfig.mcpServers = mcpConfig.mcpServers || {};
166
- mcpConfig.mcpServers[name] = configWithoutEnv;
227
+ const localSettings = await readLocalSettings(projectPath);
228
+ const localPath = getLocalSettingsPath(projectPath);
229
+ const mcpServers =
230
+ writableMap(mcpConfig, "mcpServers", getMcpConfigPath(projectPath)) ?? {};
231
+ const enabledServers =
232
+ writableList(localSettings, "enabledMcpjsonServers", localPath) ?? [];
233
+ const env =
234
+ Object.keys(envVars).length > 0
235
+ ? (writableMap(localSettings, "env", localPath) ?? {})
236
+ : undefined;
237
+
238
+ // Add to .mcp.json (without env vars)
239
+ mcpConfig.mcpServers = mcpServers;
240
+ mcpServers[name] = configWithoutEnv;
167
241
  await writeMcpConfig(mcpConfig, projectPath);
168
242
 
169
243
  // Enable in settings.local.json and add env vars
170
- const localSettings = await readLocalSettings(projectPath);
171
- const enabledServers = localSettings.enabledMcpjsonServers || [];
172
244
  if (!enabledServers.includes(name)) {
173
245
  enabledServers.push(name);
174
246
  }
@@ -176,12 +248,12 @@ export async function addMcpServer(
176
248
  localSettings.enableAllProjectMcpServers = true;
177
249
 
178
250
  // Add env vars to settings.local.json
179
- if (Object.keys(envVars).length > 0) {
180
- localSettings.env = localSettings.env || {};
251
+ if (env) {
252
+ localSettings.env = env;
181
253
  for (const [key, value] of Object.entries(envVars)) {
182
254
  // Only add non-reference values (references like ${VAR} don't need to be stored)
183
255
  if (!value.startsWith("${") || !value.endsWith("}")) {
184
- localSettings.env[key] = value;
256
+ env[key] = value;
185
257
  }
186
258
  }
187
259
  }
@@ -193,18 +265,32 @@ export async function removeMcpServer(
193
265
  name: string,
194
266
  projectPath?: string,
195
267
  ): Promise<void> {
196
- // Remove from .mcp.json
268
+ // Both reads and both shape checks first, for the same reason as in
269
+ // addMcpServer.
197
270
  const mcpConfig = await readMcpConfig(projectPath);
198
- if (mcpConfig.mcpServers) {
199
- delete mcpConfig.mcpServers[name];
271
+ const localSettings = await readLocalSettings(projectPath);
272
+ const mcpServers = writableMap(
273
+ mcpConfig,
274
+ "mcpServers",
275
+ getMcpConfigPath(projectPath),
276
+ );
277
+ const enabledServers = writableList(
278
+ localSettings,
279
+ "enabledMcpjsonServers",
280
+ getLocalSettingsPath(projectPath),
281
+ );
282
+
283
+ // Remove from .mcp.json
284
+ if (mcpServers) {
285
+ delete mcpServers[name];
200
286
  }
201
287
  await writeMcpConfig(mcpConfig, projectPath);
202
288
 
203
289
  // Remove from settings.local.json
204
- const localSettings = await readLocalSettings(projectPath);
205
- if (localSettings.enabledMcpjsonServers) {
206
- localSettings.enabledMcpjsonServers =
207
- localSettings.enabledMcpjsonServers.filter((s) => s !== name);
290
+ if (enabledServers) {
291
+ localSettings.enabledMcpjsonServers = enabledServers.filter(
292
+ (s) => s !== name,
293
+ );
208
294
  await writeLocalSettings(localSettings, projectPath);
209
295
  }
210
296
  }
@@ -242,12 +328,11 @@ export async function enablePlugin(
242
328
  projectPath?: string,
243
329
  ): Promise<void> {
244
330
  const settings = await readSettings(projectPath);
245
- settings.enabledPlugins = settings.enabledPlugins || {};
246
- if (enabled) {
247
- settings.enabledPlugins[pluginId] = true;
248
- } else {
249
- delete settings.enabledPlugins[pluginId];
250
- }
331
+ settings.enabledPlugins =
332
+ writableMap(settings, "enabledPlugins", getSettingsPath(projectPath)) ?? {};
333
+ // `false`, never a deleted key: Claude Code resolves enabledPlugins per id with
334
+ // fall-through, so a missing project entry lets a user-scope `true` win.
335
+ settings.enabledPlugins[pluginId] = enabled;
251
336
  await writeSettings(settings, projectPath);
252
337
  }
253
338
 
@@ -398,7 +483,12 @@ export async function enableLocalPlugin(
398
483
  projectPath?: string,
399
484
  ): Promise<void> {
400
485
  const settings = await readLocalSettings(projectPath);
401
- settings.enabledPlugins = settings.enabledPlugins || {};
486
+ settings.enabledPlugins =
487
+ writableMap(
488
+ settings,
489
+ "enabledPlugins",
490
+ getLocalSettingsPath(projectPath),
491
+ ) ?? {};
402
492
  if (enabled) {
403
493
  settings.enabledPlugins[pluginId] = true;
404
494
  } else {
@@ -413,7 +503,12 @@ export async function saveLocalInstalledPluginVersion(
413
503
  projectPath?: string,
414
504
  ): Promise<void> {
415
505
  const settings = await readLocalSettings(projectPath);
416
- settings.installedPluginVersions = settings.installedPluginVersions || {};
506
+ settings.installedPluginVersions =
507
+ writableMap(
508
+ settings,
509
+ "installedPluginVersions",
510
+ getLocalSettingsPath(projectPath),
511
+ ) ?? {};
417
512
  settings.installedPluginVersions[pluginId] = version;
418
513
  await writeLocalSettings(settings, projectPath);
419
514
 
@@ -431,11 +526,14 @@ export async function removeLocalInstalledPluginVersion(
431
526
  projectPath?: string,
432
527
  ): Promise<void> {
433
528
  const settings = await readLocalSettings(projectPath);
434
- if (settings.installedPluginVersions) {
435
- delete settings.installedPluginVersions[pluginId];
529
+ const localPath = getLocalSettingsPath(projectPath);
530
+ const versions = writableMap(settings, "installedPluginVersions", localPath);
531
+ const enabled = writableMap(settings, "enabledPlugins", localPath);
532
+ if (versions) {
533
+ delete versions[pluginId];
436
534
  }
437
- if (settings.enabledPlugins) {
438
- delete settings.enabledPlugins[pluginId];
535
+ if (enabled) {
536
+ delete enabled[pluginId];
439
537
  }
440
538
  await writeLocalSettings(settings, projectPath);
441
539
 
@@ -556,7 +654,8 @@ export async function setMcpEnvVar(
556
654
  projectPath?: string,
557
655
  ): Promise<void> {
558
656
  const localSettings = await readLocalSettings(projectPath);
559
- localSettings.env = localSettings.env || {};
657
+ localSettings.env =
658
+ writableMap(localSettings, "env", getLocalSettingsPath(projectPath)) ?? {};
560
659
  localSettings.env[name] = value;
561
660
  await writeLocalSettings(localSettings, projectPath);
562
661
  }
@@ -567,8 +666,13 @@ export async function removeMcpEnvVar(
567
666
  projectPath?: string,
568
667
  ): Promise<void> {
569
668
  const localSettings = await readLocalSettings(projectPath);
570
- if (localSettings.env) {
571
- delete localSettings.env[name];
669
+ const env = writableMap(
670
+ localSettings,
671
+ "env",
672
+ getLocalSettingsPath(projectPath),
673
+ );
674
+ if (env) {
675
+ delete env[name];
572
676
  await writeLocalSettings(localSettings, projectPath);
573
677
  }
574
678
  }
@@ -652,7 +756,8 @@ export async function enableGlobalPlugin(
652
756
  enabled: boolean,
653
757
  ): Promise<void> {
654
758
  const settings = await readGlobalSettings();
655
- settings.enabledPlugins = settings.enabledPlugins || {};
759
+ settings.enabledPlugins =
760
+ writableMap(settings, "enabledPlugins", getGlobalSettingsPath()) ?? {};
656
761
  if (enabled) {
657
762
  settings.enabledPlugins[pluginId] = true;
658
763
  } else {
@@ -683,7 +788,9 @@ export async function saveGlobalInstalledPluginVersion(
683
788
  version: string,
684
789
  ): Promise<void> {
685
790
  const settings = await readGlobalSettings();
686
- settings.installedPluginVersions = settings.installedPluginVersions || {};
791
+ settings.installedPluginVersions =
792
+ writableMap(settings, "installedPluginVersions", getGlobalSettingsPath()) ??
793
+ {};
687
794
  settings.installedPluginVersions[pluginId] = version;
688
795
  await writeGlobalSettings(settings);
689
796
 
@@ -695,11 +802,14 @@ export async function removeGlobalInstalledPluginVersion(
695
802
  pluginId: string,
696
803
  ): Promise<void> {
697
804
  const settings = await readGlobalSettings();
698
- if (settings.installedPluginVersions) {
699
- delete settings.installedPluginVersions[pluginId];
805
+ const globalPath = getGlobalSettingsPath();
806
+ const versions = writableMap(settings, "installedPluginVersions", globalPath);
807
+ const enabled = writableMap(settings, "enabledPlugins", globalPath);
808
+ if (versions) {
809
+ delete versions[pluginId];
700
810
  }
701
- if (settings.enabledPlugins) {
702
- delete settings.enabledPlugins[pluginId];
811
+ if (enabled) {
812
+ delete enabled[pluginId];
703
813
  }
704
814
  await writeGlobalSettings(settings);
705
815
 
@@ -1052,13 +1162,13 @@ export async function migrateMarketplaceRename(
1052
1162
  }
1053
1163
 
1054
1164
  // 4. known_marketplaces.json — rename old keys + physical directory cleanup
1055
- const pluginsDir = path.join(
1165
+ const marketplacesDir = path.join(
1056
1166
  os.homedir(),
1057
1167
  ".claude",
1058
1168
  "plugins",
1059
1169
  "marketplaces",
1060
1170
  );
1061
- const newDir = path.join(pluginsDir, NEW_MARKETPLACE_NAME);
1171
+ const newDir = path.join(marketplacesDir, NEW_MARKETPLACE_NAME);
1062
1172
 
1063
1173
  try {
1064
1174
  const known = await readKnownMarketplaces();
@@ -1100,7 +1210,7 @@ export async function migrateMarketplaceRename(
1100
1210
 
1101
1211
  // 4b. Rename/remove old physical directories (runs even if keys were already migrated)
1102
1212
  for (const oldName of OLD_MARKETPLACE_NAMES) {
1103
- const oldDir = path.join(pluginsDir, oldName);
1213
+ const oldDir = path.join(marketplacesDir, oldName);
1104
1214
  try {
1105
1215
  if (await fs.pathExists(oldDir)) {
1106
1216
  if (!(await fs.pathExists(newDir))) {
@@ -1139,29 +1249,32 @@ export async function migrateMarketplaceRename(
1139
1249
 
1140
1250
  // 5. installed_plugins.json — rename plugin ID keys
1141
1251
  try {
1142
- const registry = await readInstalledPluginsRegistry();
1143
1252
  let regCount = 0;
1144
- const newPlugins: typeof registry.plugins = {};
1145
- for (const [pluginId, entries] of Object.entries(registry.plugins)) {
1146
- const oldName = OLD_MARKETPLACE_NAMES.find((n) =>
1147
- pluginId.endsWith(`@${n}`),
1148
- );
1149
- if (oldName) {
1150
- const pluginName = pluginId.slice(0, pluginId.lastIndexOf("@"));
1151
- const newKey = `${pluginName}@${NEW_MARKETPLACE_NAME}`;
1152
- if (!newPlugins[newKey]) {
1153
- newPlugins[newKey] = entries;
1253
+ await updateRegistryFile((registry) => {
1254
+ const newPlugins: typeof registry.plugins = {};
1255
+ for (const [pluginId, entries] of Object.entries(registry.plugins)) {
1256
+ const oldName = OLD_MARKETPLACE_NAMES.find((n) =>
1257
+ pluginId.endsWith(`@${n}`),
1258
+ );
1259
+ if (oldName) {
1260
+ const pluginName = pluginId.slice(0, pluginId.lastIndexOf("@"));
1261
+ const newKey = `${pluginName}@${NEW_MARKETPLACE_NAME}`;
1262
+ if (!newPlugins[newKey]) {
1263
+ newPlugins[newKey] = entries;
1264
+ }
1265
+ regCount++;
1266
+ } else {
1267
+ newPlugins[pluginId] = entries;
1154
1268
  }
1155
- regCount++;
1156
- } else {
1157
- newPlugins[pluginId] = entries;
1158
1269
  }
1159
- }
1160
- if (regCount > 0) {
1270
+ if (regCount === 0) return false;
1161
1271
  registry.plugins = newPlugins;
1162
- await writeInstalledPluginsRegistry(registry);
1163
- result.registryMigrated = regCount;
1164
- }
1272
+ return true;
1273
+ });
1274
+ // After the write, never inside the mutation: a refused write (a corrupt
1275
+ // file, or one another session changed) must not report a migration that
1276
+ // did not land.
1277
+ result.registryMigrated = regCount;
1165
1278
  } catch {
1166
1279
  /* skip if unreadable */
1167
1280
  }
@@ -1416,7 +1529,17 @@ export async function resolveCacheProvenance(
1416
1529
  }
1417
1530
 
1418
1531
  /**
1419
- * Read installed_plugins.json registry
1532
+ * Read installed_plugins.json registry.
1533
+ *
1534
+ * Missing reads as an empty registry — nothing is lost by starting from one.
1535
+ * A file that EXISTS and cannot be used throws, exactly as the settings readers
1536
+ * do: this is the record of what every project on the machine has installed,
1537
+ * and "unreadable" is not the same answer as "nothing installed".
1538
+ *
1539
+ * It used to answer an empty registry for both, and the registry writers below
1540
+ * then wrote that empty copy back with one plugin in it — so a registry a
1541
+ * Claude Code write had truncated came back holding a single row, and every
1542
+ * other plugin on the machine read as uninstalled.
1420
1543
  *
1421
1544
  * @param root the plugins directory to read from — see {@link pluginsDir}
1422
1545
  */
@@ -1424,20 +1547,7 @@ export async function readInstalledPluginsRegistry(
1424
1547
  root: string = pluginsDir(),
1425
1548
  ): Promise<InstalledPluginsRegistry> {
1426
1549
  const file = installedPluginsFile(root);
1427
- const raw = await readRegistryBytes(file);
1428
- const parsed = parseRegistryBytes(raw);
1429
- if (parsed) return parsed;
1430
- if (raw !== null) {
1431
- // Corrupt rather than absent — keep a copy before any caller writes over it.
1432
- try {
1433
- const backup = await registryBackupPath(file, "corrupt");
1434
- await fs.copy(file, backup);
1435
- console.warn(`Corrupted registry backed up to: ${backup}`);
1436
- } catch {
1437
- // Ignore backup errors
1438
- }
1439
- }
1440
- return { version: 2, plugins: {} };
1550
+ return requireRegistry(file, await readRegistryBytes(file));
1441
1551
  }
1442
1552
 
1443
1553
  /**
@@ -1457,18 +1567,104 @@ async function readRegistryBytes(file: string): Promise<string | null> {
1457
1567
  }
1458
1568
  }
1459
1569
 
1460
- /** Parse + structure check. Null means "not a registry", including absent. */
1461
- function parseRegistryBytes(
1570
+ /** An empty, valid registry: what an absent file reads as. */
1571
+ function emptyRegistry(): InstalledPluginsRegistry {
1572
+ return { version: 2, plugins: {} };
1573
+ }
1574
+
1575
+ /**
1576
+ * Refuse a registry document that cannot be read AT ALL — it does not parse, or
1577
+ * its top level is not a JSON object — with the same refusal every settings file
1578
+ * gets, naming the file. Absent or whitespace-only passes (and answers `null`).
1579
+ *
1580
+ * The weaker of the two checks, and deliberately so: it is what a WRITER asks
1581
+ * before renaming over a file, where the question is only "is this a document
1582
+ * anything could have read", not "does it match the shape this version of magus
1583
+ * models". {@link requireRegistry} is the stronger one, asked by every reader.
1584
+ */
1585
+ function refuseUnreadableRegistry(
1586
+ file: string,
1462
1587
  raw: string | null,
1463
- ): InstalledPluginsRegistry | null {
1464
- if (raw === null) return null;
1588
+ ): Record<string, unknown> | null {
1589
+ if (raw === null || raw.trim() === "") return null;
1590
+
1591
+ let parsed: unknown;
1465
1592
  try {
1466
- const content = JSON.parse(raw);
1467
- if (!content?.version || !content?.plugins) return null;
1468
- return content as InstalledPluginsRegistry;
1469
- } catch {
1470
- return null;
1593
+ parsed = JSON.parse(raw);
1594
+ } catch (error) {
1595
+ throw new SettingsParseError(file, error);
1596
+ }
1597
+ if (!isJsonObject(parsed)) {
1598
+ throw new SettingsShapeError(
1599
+ file,
1600
+ "is not a JSON object",
1601
+ `its top level is ${describeJsonKind(parsed)}`,
1602
+ );
1471
1603
  }
1604
+ return parsed;
1605
+ }
1606
+
1607
+ /**
1608
+ * Parse + structure check, or throw.
1609
+ *
1610
+ * THE one place a registry document is judged for READING, so no two readers
1611
+ * can disagree about whether the file on disk is usable. Absent or
1612
+ * whitespace-only reads as an empty registry; anything else that is not
1613
+ * `{version, plugins: {…}}` throws a {@link SettingsParseError} — a
1614
+ * `SettingsShapeError` when it parsed but is the wrong shape — naming the file,
1615
+ * so every read-modify-write refuses at the same point and nothing reaches a
1616
+ * write.
1617
+ */
1618
+ function requireRegistry(
1619
+ file: string,
1620
+ raw: string | null,
1621
+ ): InstalledPluginsRegistry {
1622
+ const parsed = refuseUnreadableRegistry(file, raw);
1623
+ if (parsed === null) return emptyRegistry();
1624
+
1625
+ if (!parsed.version || !isJsonObject(parsed.plugins)) {
1626
+ throw new SettingsShapeError(
1627
+ file,
1628
+ "is not a plugin registry",
1629
+ `expected a "version" and a "plugins" object, got ${
1630
+ parsed.plugins === undefined
1631
+ ? 'no "plugins" key'
1632
+ : `"plugins" as ${describeJsonKind(parsed.plugins)}`
1633
+ }`,
1634
+ );
1635
+ }
1636
+ return parsed as unknown as InstalledPluginsRegistry;
1637
+ }
1638
+
1639
+ /**
1640
+ * Read the registry, change it, write it back — the ONE read-modify-write every
1641
+ * registry writer goes through.
1642
+ *
1643
+ * Both halves of the safety live here rather than in each writer: the read
1644
+ * refuses a document we cannot parse ({@link requireRegistry}), and the write
1645
+ * carries the exact bytes that were read as a precondition, so a Claude Code
1646
+ * install landing in between is refused instead of being silently dropped by our
1647
+ * snapshot (see {@link writeInstalledPluginsRegistry}).
1648
+ *
1649
+ * `mutate` answers whether there is anything to write: `false` writes nothing
1650
+ * and leaves the file untouched.
1651
+ *
1652
+ * {@link repairStaleLoad} is the one writer that does this by hand, because it
1653
+ * takes an undo backup between the read and the write and reports its refusals
1654
+ * as a result object instead of throwing. It reads and checks through the same
1655
+ * {@link requireRegistry}.
1656
+ */
1657
+ async function updateRegistryFile(
1658
+ mutate: (registry: InstalledPluginsRegistry) => Promise<boolean> | boolean,
1659
+ root: string = pluginsDir(),
1660
+ ): Promise<void> {
1661
+ const file = installedPluginsFile(root);
1662
+ // The BYTES first, and the snapshot parsed from those same bytes: they have
1663
+ // to describe one moment for the precondition to mean anything.
1664
+ const before = await readRegistryBytes(file);
1665
+ const registry = requireRegistry(file, before);
1666
+ if (!(await mutate(registry))) return;
1667
+ await writeInstalledPluginsRegistry(registry, root, before);
1472
1668
  }
1473
1669
 
1474
1670
  /** How many `installed_plugins.json.backup.*` files are kept. */
@@ -1547,10 +1743,23 @@ export class RegistryChangedError extends Error {
1547
1743
  *
1548
1744
  * ATOMIC — temp file in the same directory, then rename. This file is read by
1549
1745
  * every Claude Code session on the machine, so a write interrupted partway
1550
- * leaves a truncated JSON document that `readInstalledPluginsRegistry` treats as
1551
- * corrupt and replaces with an EMPTY registry: every plugin on the machine
1552
- * reads as not installed. `rename(2)` within one directory cannot be observed
1553
- * half-done, so a reader sees either the old file or the new one.
1746
+ * leaves a truncated JSON document nothing can read: every plugin on the machine
1747
+ * would read as not installed. `rename(2)` within one directory cannot be
1748
+ * observed half-done, so a reader sees either the old file or the new one.
1749
+ *
1750
+ * TWO preconditions, both checked as the last act before the rename, and the
1751
+ * second is not implied by the first:
1752
+ *
1753
+ * 1. the file is the one the caller read (`expectBytes`);
1754
+ * 2. the file on disk is still a document we can read at all — it parses, and
1755
+ * into an object.
1756
+ *
1757
+ * Byte-equality does not cover (2). A caller holding the raw bytes of a corrupt
1758
+ * file passes the compare exactly, and the rename then replaces a registry
1759
+ * nobody could read with one holding only the plugin being written — every other
1760
+ * project's installs gone. The strict read in {@link requireRegistry} is the
1761
+ * first line and stops every read-modify-write; this is the second, and it holds
1762
+ * for a direct caller too, including one that passes no `expectBytes`.
1554
1763
  *
1555
1764
  * ATOMICITY IS NOT ISOLATION, and conflating the two is how this silently lost
1556
1765
  * other sessions' work. A read-modify-write rewrites the WHOLE file from the
@@ -1586,10 +1795,12 @@ export async function writeInstalledPluginsRegistry(
1586
1795
  const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
1587
1796
  try {
1588
1797
  await fs.writeJson(tmp, registry, { spaces: 2 });
1589
- if (expectBytes !== undefined) {
1590
- const current = await readRegistryBytes(file);
1591
- if (current !== expectBytes) throw new RegistryChangedError(file);
1798
+ // One read, both preconditions — they have to judge the same moment.
1799
+ const current = await readRegistryBytes(file);
1800
+ if (expectBytes !== undefined && current !== expectBytes) {
1801
+ throw new RegistryChangedError(file);
1592
1802
  }
1803
+ refuseUnreadableRegistry(file, current);
1593
1804
  await fs.rename(tmp, file);
1594
1805
  } catch (error) {
1595
1806
  await fs.remove(tmp).catch(() => {});
@@ -1625,65 +1836,73 @@ export async function updateInstalledPluginsRegistry(
1625
1836
  projectPath?: string,
1626
1837
  ): Promise<void> {
1627
1838
  try {
1628
- const registry = await readInstalledPluginsRegistry();
1629
-
1630
- // Get marketplace from plugin ID
1631
- const parsed = parsePluginId(pluginId);
1632
- if (!parsed) {
1633
- console.warn(`Invalid plugin ID: ${pluginId}, skipping registry update`);
1634
- return;
1635
- }
1636
-
1637
- const { marketplace } = parsed;
1839
+ await updateRegistryFile(async (registry) => {
1840
+ // Get marketplace from plugin ID
1841
+ const parsed = parsePluginId(pluginId);
1842
+ if (!parsed) {
1843
+ console.warn(
1844
+ `Invalid plugin ID: ${pluginId}, skipping registry update`,
1845
+ );
1846
+ return false;
1847
+ }
1638
1848
 
1639
- // Copy plugin files from source to cache
1640
- // This ensures the cache has the latest plugin version
1641
- const copiedFrom = await copyPluginToCache(pluginId, version, marketplace);
1849
+ const { marketplace } = parsed;
1642
1850
 
1643
- const installPath = getPluginCachePath(pluginId, version, marketplace);
1644
- const now = new Date().toISOString();
1851
+ // Copy plugin files from source to cache
1852
+ // This ensures the cache has the latest plugin version
1853
+ const copiedFrom = await copyPluginToCache(
1854
+ pluginId,
1855
+ version,
1856
+ marketplace,
1857
+ );
1645
1858
 
1646
- // Initialize plugin array if it doesn't exist
1647
- if (!registry.plugins[pluginId]) {
1648
- registry.plugins[pluginId] = [];
1649
- }
1859
+ const installPath = getPluginCachePath(pluginId, version, marketplace);
1860
+ const now = new Date().toISOString();
1650
1861
 
1651
- // Find existing entry for this scope and project
1652
- const existingIndex = registry.plugins[pluginId].findIndex((entry) => {
1653
- if (entry.scope !== scope) return false;
1654
- if (scope === "user") return true;
1655
- return entry.projectPath === projectPath;
1656
- });
1862
+ // Initialize plugin array if it doesn't exist
1863
+ if (!registry.plugins[pluginId]) {
1864
+ registry.plugins[pluginId] = [];
1865
+ }
1657
1866
 
1658
- const entry: InstalledPluginEntry = {
1659
- scope,
1660
- projectPath,
1661
- installPath,
1662
- version,
1663
- installedAt:
1664
- existingIndex >= 0
1665
- ? registry.plugins[pluginId][existingIndex].installedAt
1666
- : now,
1667
- lastUpdated: now,
1668
- gitCommitSha: await resolveCacheProvenance(
1669
- copiedFrom,
1670
- existingIndex >= 0
1671
- ? registry.plugins[pluginId][existingIndex].gitCommitSha
1672
- : undefined,
1673
- ),
1674
- };
1867
+ // Find existing entry for this scope and project
1868
+ const existingIndex = registry.plugins[pluginId].findIndex((entry) => {
1869
+ if (entry.scope !== scope) return false;
1870
+ if (scope === "user") return true;
1871
+ return entry.projectPath === projectPath;
1872
+ });
1675
1873
 
1676
- if (existingIndex >= 0) {
1677
- // Update existing entry
1678
- registry.plugins[pluginId][existingIndex] = entry;
1679
- } else {
1680
- // Add new entry
1681
- registry.plugins[pluginId].push(entry);
1682
- }
1874
+ const entry: InstalledPluginEntry = {
1875
+ scope,
1876
+ projectPath,
1877
+ installPath,
1878
+ version,
1879
+ installedAt:
1880
+ existingIndex >= 0
1881
+ ? registry.plugins[pluginId][existingIndex].installedAt
1882
+ : now,
1883
+ lastUpdated: now,
1884
+ gitCommitSha: await resolveCacheProvenance(
1885
+ copiedFrom,
1886
+ existingIndex >= 0
1887
+ ? registry.plugins[pluginId][existingIndex].gitCommitSha
1888
+ : undefined,
1889
+ ),
1890
+ };
1683
1891
 
1684
- await writeInstalledPluginsRegistry(registry);
1892
+ if (existingIndex >= 0) {
1893
+ // Update existing entry
1894
+ registry.plugins[pluginId][existingIndex] = entry;
1895
+ } else {
1896
+ // Add new entry
1897
+ registry.plugins[pluginId].push(entry);
1898
+ }
1899
+ return true;
1900
+ });
1685
1901
  } catch (error) {
1686
- // Log warning but don't block plugin operation
1902
+ // Say what was refused and why, then let the plugin operation carry on:
1903
+ // the install itself succeeded, and the registry is Claude Code's file to
1904
+ // repair. What must not happen is writing over it, which is why the read
1905
+ // throws rather than answering an empty registry.
1687
1906
  console.warn(
1688
1907
  `Failed to update registry for ${pluginId}:`,
1689
1908
  error instanceof Error ? error.message : "Unknown error",
@@ -1700,23 +1919,24 @@ export async function removeFromInstalledPluginsRegistry(
1700
1919
  projectPath?: string,
1701
1920
  ): Promise<void> {
1702
1921
  try {
1703
- const registry = await readInstalledPluginsRegistry();
1704
-
1705
- if (!registry.plugins[pluginId]) return;
1922
+ await updateRegistryFile((registry) => {
1923
+ if (!registry.plugins[pluginId]) return false;
1924
+
1925
+ // Remove entry matching scope and projectPath
1926
+ registry.plugins[pluginId] = registry.plugins[pluginId].filter(
1927
+ (entry) => {
1928
+ if (entry.scope !== scope) return true;
1929
+ if (scope === "user") return false;
1930
+ return entry.projectPath !== projectPath;
1931
+ },
1932
+ );
1706
1933
 
1707
- // Remove entry matching scope and projectPath
1708
- registry.plugins[pluginId] = registry.plugins[pluginId].filter((entry) => {
1709
- if (entry.scope !== scope) return true;
1710
- if (scope === "user") return false;
1711
- return entry.projectPath !== projectPath;
1934
+ // Remove plugin key if no entries remain
1935
+ if (registry.plugins[pluginId].length === 0) {
1936
+ delete registry.plugins[pluginId];
1937
+ }
1938
+ return true;
1712
1939
  });
1713
-
1714
- // Remove plugin key if no entries remain
1715
- if (registry.plugins[pluginId].length === 0) {
1716
- delete registry.plugins[pluginId];
1717
- }
1718
-
1719
- await writeInstalledPluginsRegistry(registry);
1720
1940
  } catch (error) {
1721
1941
  // Log warning but don't block plugin operation
1722
1942
  console.warn(
@@ -2050,12 +2270,17 @@ export async function repairStaleLoad(
2050
2270
  // precondition the write is checked against, so they have to describe one
2051
2271
  // moment — see `writeInstalledPluginsRegistry`.
2052
2272
  const before = await readRegistryBytes(file);
2053
- const registry = parseRegistryBytes(before);
2054
- if (!registry) {
2273
+ if (before === null) return fail(`${file} does not exist`);
2274
+ let registry: InstalledPluginsRegistry;
2275
+ try {
2276
+ registry = requireRegistry(file, before);
2277
+ } catch (error) {
2278
+ // The same check every other reader makes; a repair reports it as a result
2279
+ // rather than throwing, because it is one item in a plan of many.
2055
2280
  return fail(
2056
- before === null
2057
- ? `${file} does not exist`
2058
- : `${file} is not a readable registry — refusing to write over it`,
2281
+ `${file} is not a readable registry — refusing to write over it: ${
2282
+ error instanceof Error ? error.message : String(error)
2283
+ }`,
2059
2284
  );
2060
2285
  }
2061
2286
  const rows = registry.plugins[pluginId];
@@ -2148,7 +2373,18 @@ export async function repairStaleLoad(
2148
2373
 
2149
2374
  // Re-read from DISK, not from the object just mutated: the assertion has to
2150
2375
  // judge the file the loader will read, which also proves the write landed.
2151
- const after = await readInstalledPluginsRegistry(root);
2376
+ // A refusal here is reported like every other outcome — this function answers
2377
+ // with a result, and a plan of repairs must not be ended by a throw from one.
2378
+ let after: InstalledPluginsRegistry;
2379
+ try {
2380
+ after = await readInstalledPluginsRegistry(root);
2381
+ } catch (error) {
2382
+ return fail(
2383
+ `could not re-read ${file} to confirm the repair: ${
2384
+ error instanceof Error ? error.message : String(error)
2385
+ }`,
2386
+ );
2387
+ }
2152
2388
  const loaded =
2153
2389
  pickLoadedEntry(after.plugins[pluginId], projectPath)?.version ?? null;
2154
2390
  if (loaded === target) {