claudeup 4.10.2 → 4.11.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.
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Plugin version mismatch detection and fix.
3
+ *
4
+ * Claude Code's plugin loader (pluginLoader.ts:2051) takes the FIRST entry
5
+ * from installed_plugins.json for each plugin ID:
6
+ *
7
+ * const installEntry = installedPluginsData.plugins[pluginId]?.[0]
8
+ *
9
+ * This means the first project to install a plugin determines which version
10
+ * loads for ALL projects. When different projects install different versions,
11
+ * only the one at index [0] takes effect.
12
+ *
13
+ * Bug report: https://github.com/anthropics/claude-code/issues/45997
14
+ */
15
+
16
+ import type { InstalledPluginEntry } from "../types/index.js";
17
+ import {
18
+ getEnabledPlugins,
19
+ getGlobalEnabledPlugins,
20
+ getLocalEnabledPlugins,
21
+ readInstalledPluginsRegistry,
22
+ writeInstalledPluginsRegistry,
23
+ } from "./claude-settings.js";
24
+
25
+ const BUG_REPORT_URL = "https://github.com/anthropics/claude-code/issues/45997";
26
+
27
+ // ── Types ────────────────────────────────────────────────────────────────────
28
+
29
+ export interface VersionMismatchInfo {
30
+ /** Plugin ID, e.g. "terminal@magus" */
31
+ pluginId: string;
32
+ /** Version at entries[0] — the one Claude Code actually loads */
33
+ firstEntryVersion: string;
34
+ /** Project path of the entries[0] entry */
35
+ firstEntryProject: string | undefined;
36
+ /** Version this project has installed */
37
+ currentProjectVersion: string;
38
+ /** All entries for this plugin */
39
+ allEntries: InstalledPluginEntry[];
40
+ }
41
+
42
+ export interface FixResult {
43
+ /** Number of entries updated */
44
+ updated: number;
45
+ /** Project paths that were updated */
46
+ projects: string[];
47
+ }
48
+
49
+ // ── Detection ────────────────────────────────────────────────────────────────
50
+
51
+ /**
52
+ * Check for plugin version mismatches caused by the [0] index bug.
53
+ *
54
+ * For each enabled plugin in the current project:
55
+ * 1. Reads the plugin's entry array from installed_plugins.json
56
+ * 2. Finds the entry matching the current project
57
+ * 3. Checks if entries[0] has a DIFFERENT version than the current project's entry
58
+ * 4. If yes: this project will load the wrong version
59
+ *
60
+ * @param projectPath - The current project path to check
61
+ * @returns Array of mismatch info for each affected plugin
62
+ */
63
+ export async function checkPluginVersionMismatches(
64
+ projectPath: string,
65
+ ): Promise<VersionMismatchInfo[]> {
66
+ const registry = await readInstalledPluginsRegistry();
67
+ const enabledPluginIds = await collectEnabledPluginIds(projectPath);
68
+ const mismatches: VersionMismatchInfo[] = [];
69
+
70
+ for (const pluginId of enabledPluginIds) {
71
+ const entries = registry.plugins[pluginId];
72
+ if (!entries || entries.length < 2) continue;
73
+
74
+ // Find the entry for the current project
75
+ const currentEntry = entries.find(
76
+ (e) =>
77
+ e.scope === "project" &&
78
+ normalizePath(e.projectPath) === normalizePath(projectPath),
79
+ );
80
+ if (!currentEntry) continue;
81
+
82
+ const firstEntry = entries[0];
83
+
84
+ // Compare the version at [0] with the current project's version
85
+ if (firstEntry.version !== currentEntry.version) {
86
+ mismatches.push({
87
+ pluginId,
88
+ firstEntryVersion: firstEntry.version,
89
+ firstEntryProject: firstEntry.projectPath,
90
+ currentProjectVersion: currentEntry.version,
91
+ allEntries: entries,
92
+ });
93
+ }
94
+ }
95
+
96
+ return mismatches;
97
+ }
98
+
99
+ /**
100
+ * Check a single plugin for version mismatch (used after plugin update).
101
+ *
102
+ * @param pluginId - The plugin ID to check
103
+ * @param projectPath - The current project path
104
+ * @returns Mismatch info or null if no mismatch
105
+ */
106
+ export async function checkSinglePluginMismatch(
107
+ pluginId: string,
108
+ projectPath: string,
109
+ ): Promise<VersionMismatchInfo | null> {
110
+ const registry = await readInstalledPluginsRegistry();
111
+ const entries = registry.plugins[pluginId];
112
+ if (!entries || entries.length < 2) return null;
113
+
114
+ const currentEntry = entries.find(
115
+ (e) =>
116
+ e.scope === "project" &&
117
+ normalizePath(e.projectPath) === normalizePath(projectPath),
118
+ );
119
+ if (!currentEntry) return null;
120
+
121
+ const firstEntry = entries[0];
122
+ if (firstEntry.version === currentEntry.version) return null;
123
+
124
+ return {
125
+ pluginId,
126
+ firstEntryVersion: firstEntry.version,
127
+ firstEntryProject: firstEntry.projectPath,
128
+ currentProjectVersion: currentEntry.version,
129
+ allEntries: entries,
130
+ };
131
+ }
132
+
133
+ // ── Fix ──────────────────────────────────────────────────────────────────────
134
+
135
+ /**
136
+ * Fix a version mismatch by updating ALL entries for a plugin to use
137
+ * the target version. This is a JSON-only operation that rewrites
138
+ * installPath and version in each entry.
139
+ *
140
+ * IMPORTANT: This modifies installed_plugins.json which is Claude Code-owned.
141
+ * Only call after explicit user consent.
142
+ *
143
+ * @param pluginId - The plugin ID to fix
144
+ * @param targetVersion - The version to normalize all entries to
145
+ * @returns Number of updated entries and which projects were affected
146
+ */
147
+ export async function fixPluginVersionMismatch(
148
+ pluginId: string,
149
+ targetVersion: string,
150
+ ): Promise<FixResult> {
151
+ const registry = await readInstalledPluginsRegistry();
152
+ const entries = registry.plugins[pluginId];
153
+ if (!entries || entries.length === 0) {
154
+ return { updated: 0, projects: [] };
155
+ }
156
+
157
+ // Find an existing entry with the target version to get the correct installPath
158
+ const templateEntry = entries.find((e) => e.version === targetVersion);
159
+ if (!templateEntry) {
160
+ return { updated: 0, projects: [] };
161
+ }
162
+
163
+ const targetInstallPath = templateEntry.installPath;
164
+ const updated: string[] = [];
165
+
166
+ for (const entry of entries) {
167
+ if (entry.version !== targetVersion) {
168
+ entry.version = targetVersion;
169
+ entry.installPath = targetInstallPath;
170
+ entry.lastUpdated = new Date().toISOString();
171
+ updated.push(entry.projectPath || "(global)");
172
+ }
173
+ }
174
+
175
+ if (updated.length > 0) {
176
+ await writeInstalledPluginsRegistry(registry);
177
+ }
178
+
179
+ return { updated: updated.length, projects: updated };
180
+ }
181
+
182
+ /**
183
+ * Fix all version mismatches by normalizing each plugin to its latest version.
184
+ *
185
+ * @param mismatches - Array of mismatches from checkPluginVersionMismatches
186
+ * @returns Map of pluginId to FixResult
187
+ */
188
+ export async function fixAllPluginVersionMismatches(
189
+ mismatches: VersionMismatchInfo[],
190
+ ): Promise<Map<string, FixResult>> {
191
+ const results = new Map<string, FixResult>();
192
+
193
+ // Read registry once, apply all fixes, write once
194
+ const registry = await readInstalledPluginsRegistry();
195
+
196
+ for (const mismatch of mismatches) {
197
+ const entries = registry.plugins[mismatch.pluginId];
198
+ if (!entries || entries.length === 0) {
199
+ results.set(mismatch.pluginId, { updated: 0, projects: [] });
200
+ continue;
201
+ }
202
+
203
+ // Use the current project's version as the target (the version the user expects)
204
+ const targetVersion = mismatch.currentProjectVersion;
205
+ const templateEntry = entries.find((e) => e.version === targetVersion);
206
+ if (!templateEntry) {
207
+ results.set(mismatch.pluginId, { updated: 0, projects: [] });
208
+ continue;
209
+ }
210
+
211
+ const targetInstallPath = templateEntry.installPath;
212
+ const updated: string[] = [];
213
+
214
+ for (const entry of entries) {
215
+ if (entry.version !== targetVersion) {
216
+ entry.version = targetVersion;
217
+ entry.installPath = targetInstallPath;
218
+ entry.lastUpdated = new Date().toISOString();
219
+ updated.push(entry.projectPath || "(global)");
220
+ }
221
+ }
222
+
223
+ results.set(mismatch.pluginId, {
224
+ updated: updated.length,
225
+ projects: updated,
226
+ });
227
+ }
228
+
229
+ await writeInstalledPluginsRegistry(registry);
230
+ return results;
231
+ }
232
+
233
+ // ── Formatting ───────────────────────────────────────────────────────────────
234
+
235
+ /**
236
+ * Format mismatch info for console output (prerunner).
237
+ */
238
+ export function formatMismatchWarning(
239
+ mismatches: VersionMismatchInfo[],
240
+ ): string {
241
+ const lines: string[] = [];
242
+ lines.push(
243
+ "WARNING: Plugin version mismatch detected (Claude Code bug #45997)",
244
+ );
245
+ lines.push(
246
+ "Claude Code loads the first entry from installed_plugins.json for ALL projects.",
247
+ );
248
+ lines.push(
249
+ "The following plugins will load a different version than what this project has installed:\n",
250
+ );
251
+
252
+ for (const m of mismatches) {
253
+ const firstProject = m.firstEntryProject
254
+ ? shortenPath(m.firstEntryProject)
255
+ : "(global)";
256
+ lines.push(` ${m.pluginId}:`);
257
+ lines.push(` Loading v${m.firstEntryVersion} (from ${firstProject})`);
258
+ lines.push(` Expected v${m.currentProjectVersion} (this project)`);
259
+ }
260
+
261
+ lines.push("");
262
+ lines.push(`Bug report: ${BUG_REPORT_URL}`);
263
+ lines.push(
264
+ "Run claudeup to fix — it can align all projects to the same version.",
265
+ );
266
+
267
+ return lines.join("\n");
268
+ }
269
+
270
+ /**
271
+ * Format mismatch info for TUI modal display.
272
+ * Returns a multi-line string suitable for a modal message.
273
+ */
274
+ export function formatMismatchModal(
275
+ mismatches: VersionMismatchInfo[],
276
+ ): string {
277
+ const lines: string[] = [];
278
+ lines.push(
279
+ "Claude Code has a bug where it loads the wrong plugin version.",
280
+ );
281
+ lines.push(
282
+ "It always uses the first entry in the registry, ignoring which",
283
+ );
284
+ lines.push("project you're in. These plugins are affected:\n");
285
+
286
+ for (const m of mismatches) {
287
+ const name = m.pluginId.split("@")[0];
288
+ lines.push(
289
+ ` ${name}: will load v${m.firstEntryVersion} instead of v${m.currentProjectVersion}`,
290
+ );
291
+ }
292
+
293
+ lines.push(`\nBug: github.com/anthropics/claude-code/issues/45997`);
294
+
295
+ return lines.join("\n");
296
+ }
297
+
298
+ // ── Helpers ──────────────────────────────────────────────────────────────────
299
+
300
+ /**
301
+ * Collect all enabled plugin IDs across all scopes for a project.
302
+ */
303
+ async function collectEnabledPluginIds(
304
+ projectPath: string,
305
+ ): Promise<Set<string>> {
306
+ const pluginIds = new Set<string>();
307
+
308
+ try {
309
+ const global = await getGlobalEnabledPlugins();
310
+ for (const [id, enabled] of Object.entries(global)) {
311
+ if (enabled) pluginIds.add(id);
312
+ }
313
+ } catch {
314
+ /* skip unreadable */
315
+ }
316
+
317
+ try {
318
+ const project = await getEnabledPlugins(projectPath);
319
+ for (const [id, enabled] of Object.entries(project)) {
320
+ if (enabled) pluginIds.add(id);
321
+ }
322
+ } catch {
323
+ /* skip unreadable */
324
+ }
325
+
326
+ try {
327
+ const local = await getLocalEnabledPlugins(projectPath);
328
+ for (const [id, enabled] of Object.entries(local)) {
329
+ if (enabled) pluginIds.add(id);
330
+ }
331
+ } catch {
332
+ /* skip unreadable */
333
+ }
334
+
335
+ return pluginIds;
336
+ }
337
+
338
+ /**
339
+ * Normalize a path for comparison (resolve and trailing-slash normalize).
340
+ */
341
+ function normalizePath(p: string | undefined): string {
342
+ if (!p) return "";
343
+ return p.replace(/\/+$/, "");
344
+ }
345
+
346
+ /**
347
+ * Shorten a path for display by replacing homedir with ~.
348
+ */
349
+ function shortenPath(p: string): string {
350
+ const home = process.env.HOME || process.env.USERPROFILE || "";
351
+ if (home && p.startsWith(home)) {
352
+ return `~${p.slice(home.length)}`;
353
+ }
354
+ return p;
355
+ }
package/src/ui/App.js CHANGED
@@ -8,6 +8,7 @@ import { ModalContainer } from "./components/modals/index.js";
8
8
  import { PluginsScreen, McpScreen, McpRegistryScreen, SettingsScreen, CliToolsScreen, ModelSelectorScreen, ProfilesScreen, SkillsScreen, } from "./screens/index.js";
9
9
  import { repairAllMarketplaces } from "../services/local-marketplace.js";
10
10
  import { migrateMarketplaceRename, recoverMarketplaceSettings, } from "../services/claude-settings.js";
11
+ import { checkPluginVersionMismatches, fixAllPluginVersionMismatches, formatMismatchModal, } from "../services/plugin-version-check.js";
11
12
  import { checkForUpdates, getCurrentVersion, } from "../services/version-check.js";
12
13
  import { useKeyboardHandler } from "./hooks/useKeyboardHandler.js";
13
14
  import { ProgressBar } from "./components/layout/ProgressBar.js";
@@ -175,11 +176,25 @@ function UpdateBanner({ result }) {
175
176
  function ProgressIndicator({ message, current, total, }) {
176
177
  return (_jsx("box", { paddingLeft: 1, paddingRight: 1, children: _jsx(ProgressBar, { message: message, current: current, total: total }) }));
177
178
  }
178
- function AppContentInner({ showDebug, onDebugToggle, updateInfo, onExit, }) {
179
- const { state, dispatch } = useApp();
179
+ function AppContentInner({ showDebug, onDebugToggle, updateInfo, onExit, recoveryReport, }) {
180
+ const { state } = useApp();
180
181
  const { progress } = state;
181
182
  const dimensions = useDimensions();
183
+ return (_jsxs("box", { flexDirection: "column", height: dimensions.terminalHeight, children: [updateInfo?.updateAvailable && _jsx(UpdateBanner, { result: updateInfo }), recoveryReport && (_jsx("box", { paddingLeft: 1, paddingRight: 1, children: _jsxs("text", { fg: "green", children: ["\u2713 Fixed: ", recoveryReport] }) })), showDebug && (_jsx("box", { paddingLeft: 1, paddingRight: 1, children: _jsxs("text", { fg: "#888888", children: ["DEBUG: ", dimensions.terminalWidth, "x", dimensions.terminalHeight, " | content=", dimensions.contentHeight, " | screen=", state.currentRoute.screen] }) })), progress && _jsx(ProgressIndicator, { ...progress }), _jsx("box", { flexDirection: "column", height: dimensions.contentHeight, paddingLeft: 1, paddingRight: 1, children: _jsx(Router, {}) }), _jsx(GlobalKeyHandler, { onDebugToggle: onDebugToggle, onExit: onExit }), _jsx(ModalContainer, {})] }));
184
+ }
185
+ function AppContent({ onExit }) {
186
+ const { state, dispatch } = useApp();
187
+ const { progress } = state;
188
+ const [showDebug, setShowDebug] = useState(false);
189
+ const [updateInfo, setUpdateInfo] = useState(null);
182
190
  const [recoveryReport, setRecoveryReport] = useState(null);
191
+ const [mismatchData, setMismatchData] = useState(null);
192
+ // Check for updates on startup (non-blocking)
193
+ useEffect(() => {
194
+ checkForUpdates()
195
+ .then(setUpdateInfo)
196
+ .catch(() => { });
197
+ }, []);
183
198
  // Auto-dismiss recovery banner after 5 seconds
184
199
  useEffect(() => {
185
200
  if (!recoveryReport)
@@ -187,6 +202,72 @@ function AppContentInner({ showDebug, onDebugToggle, updateInfo, onExit, }) {
187
202
  const timer = setTimeout(() => setRecoveryReport(null), 5000);
188
203
  return () => clearTimeout(timer);
189
204
  }, [recoveryReport]);
205
+ // Show mismatch modal when data arrives
206
+ useEffect(() => {
207
+ if (!mismatchData || mismatchData.length === 0)
208
+ return;
209
+ dispatch({
210
+ type: "SHOW_MODAL",
211
+ modal: {
212
+ type: "select",
213
+ title: "⚠ Plugin Version Mismatch",
214
+ message: formatMismatchModal(mismatchData),
215
+ options: [
216
+ {
217
+ label: "Fix all projects",
218
+ value: "fix",
219
+ description: "Align all projects to this project's versions",
220
+ },
221
+ {
222
+ label: "Dismiss",
223
+ value: "dismiss",
224
+ description: "Ignore for now",
225
+ },
226
+ ],
227
+ onSelect: async (value) => {
228
+ dispatch({ type: "HIDE_MODAL" });
229
+ if (value === "fix") {
230
+ dispatch({
231
+ type: "SHOW_PROGRESS",
232
+ state: { message: "Fixing plugin versions..." },
233
+ });
234
+ try {
235
+ await fixAllPluginVersionMismatches(mismatchData);
236
+ dispatch({ type: "HIDE_PROGRESS" });
237
+ dispatch({
238
+ type: "SHOW_MODAL",
239
+ modal: {
240
+ type: "message",
241
+ title: "Fixed",
242
+ message: "All plugin versions aligned. Restart Claude Code for changes to take effect.",
243
+ variant: "success",
244
+ onDismiss: () => dispatch({ type: "HIDE_MODAL" }),
245
+ },
246
+ });
247
+ }
248
+ catch {
249
+ dispatch({ type: "HIDE_PROGRESS" });
250
+ dispatch({
251
+ type: "SHOW_MODAL",
252
+ modal: {
253
+ type: "message",
254
+ title: "Error",
255
+ message: "Failed to fix plugin versions. Try running claudeup again.",
256
+ variant: "error",
257
+ onDismiss: () => dispatch({ type: "HIDE_MODAL" }),
258
+ },
259
+ });
260
+ }
261
+ }
262
+ setMismatchData(null);
263
+ },
264
+ onCancel: () => {
265
+ dispatch({ type: "HIDE_MODAL" });
266
+ setMismatchData(null);
267
+ },
268
+ },
269
+ });
270
+ }, [mismatchData, dispatch]);
190
271
  // Auto-refresh marketplaces on startup
191
272
  useEffect(() => {
192
273
  const noRefresh = process.argv.includes("--no-refresh");
@@ -196,9 +277,9 @@ function AppContentInner({ showDebug, onDebugToggle, updateInfo, onExit, }) {
196
277
  type: "SHOW_PROGRESS",
197
278
  state: { message: "Scanning marketplaces..." },
198
279
  });
199
- // Migrate old marketplace names magus (idempotent), then repair plugin.json files
280
+ // Migrate old marketplace names -> magus (idempotent), then repair plugin.json files
200
281
  migrateMarketplaceRename().catch(() => { }); // non-blocking, best-effort
201
- // Recover stale marketplace registry entries (e.g. "directory" "github")
282
+ // Recover stale marketplace registry entries (e.g. "directory" -> "github")
202
283
  recoverMarketplaceSettings()
203
284
  .then(async (recovery) => {
204
285
  const parts = [];
@@ -226,6 +307,14 @@ function AppContentInner({ showDebug, onDebugToggle, updateInfo, onExit, }) {
226
307
  }
227
308
  })
228
309
  .catch(() => { }); // non-fatal
310
+ // Check for plugin version mismatches (the [0] index bug)
311
+ checkPluginVersionMismatches(process.cwd())
312
+ .then((mismatches) => {
313
+ if (mismatches.length > 0) {
314
+ setMismatchData(mismatches);
315
+ }
316
+ })
317
+ .catch(() => { }); // non-fatal
229
318
  repairAllMarketplaces()
230
319
  .then(async () => {
231
320
  dispatch({ type: "HIDE_PROGRESS" });
@@ -235,20 +324,9 @@ function AppContentInner({ showDebug, onDebugToggle, updateInfo, onExit, }) {
235
324
  dispatch({ type: "HIDE_PROGRESS" });
236
325
  });
237
326
  }, [dispatch]);
238
- return (_jsxs("box", { flexDirection: "column", height: dimensions.terminalHeight, children: [updateInfo?.updateAvailable && _jsx(UpdateBanner, { result: updateInfo }), recoveryReport && (_jsx("box", { paddingLeft: 1, paddingRight: 1, children: _jsxs("text", { fg: "green", children: ["\u2713 Fixed: ", recoveryReport] }) })), showDebug && (_jsx("box", { paddingLeft: 1, paddingRight: 1, children: _jsxs("text", { fg: "#888888", children: ["DEBUG: ", dimensions.terminalWidth, "x", dimensions.terminalHeight, " | content=", dimensions.contentHeight, " | screen=", state.currentRoute.screen] }) })), progress && _jsx(ProgressIndicator, { ...progress }), _jsx("box", { flexDirection: "column", height: dimensions.contentHeight, paddingLeft: 1, paddingRight: 1, children: _jsx(Router, {}) }), _jsx(GlobalKeyHandler, { onDebugToggle: onDebugToggle, onExit: onExit }), _jsx(ModalContainer, {})] }));
239
- }
240
- function AppContent({ onExit }) {
241
- const { state } = useApp();
242
- const { progress } = state;
243
- const [showDebug, setShowDebug] = useState(false);
244
- const [updateInfo, setUpdateInfo] = useState(null);
245
- // Check for updates on startup (non-blocking)
246
- useEffect(() => {
247
- checkForUpdates()
248
- .then(setUpdateInfo)
249
- .catch(() => { });
250
- }, []);
251
- return (_jsx(DimensionsProvider, { showProgress: !!progress, showDebug: showDebug, showUpdateBanner: !!updateInfo?.updateAvailable, children: _jsx(AppContentInner, { showDebug: showDebug, onDebugToggle: () => setShowDebug((s) => !s), updateInfo: updateInfo, onExit: onExit }) }));
327
+ // Count transient banners for dimension calculation
328
+ const transientBannerCount = recoveryReport ? 1 : 0;
329
+ return (_jsx(DimensionsProvider, { showProgress: !!progress, showDebug: showDebug, showUpdateBanner: !!updateInfo?.updateAvailable, transientBannerCount: transientBannerCount, children: _jsx(AppContentInner, { showDebug: showDebug, onDebugToggle: () => setShowDebug((s) => !s), updateInfo: updateInfo, onExit: onExit, recoveryReport: recoveryReport }) }));
252
330
  }
253
331
  export function App({ onExit }) {
254
332
  return (_jsx(AppProvider, { children: _jsx(AppContent, { onExit: onExit }) }));