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.
package/src/ui/App.tsx CHANGED
@@ -28,6 +28,12 @@ import {
28
28
  migrateMarketplaceRename,
29
29
  recoverMarketplaceSettings,
30
30
  } from "../services/claude-settings.js";
31
+ import {
32
+ checkPluginVersionMismatches,
33
+ fixAllPluginVersionMismatches,
34
+ formatMismatchModal,
35
+ type VersionMismatchInfo,
36
+ } from "../services/plugin-version-check.js";
31
37
  import {
32
38
  checkForUpdates,
33
39
  getCurrentVersion,
@@ -250,6 +256,7 @@ interface AppContentInnerProps {
250
256
  onDebugToggle: () => void;
251
257
  updateInfo: VersionCheckResult | null;
252
258
  onExit: () => void;
259
+ recoveryReport: string | null;
253
260
  }
254
261
 
255
262
  function AppContentInner({
@@ -257,11 +264,66 @@ function AppContentInner({
257
264
  onDebugToggle,
258
265
  updateInfo,
259
266
  onExit,
267
+ recoveryReport,
260
268
  }: AppContentInnerProps) {
261
- const { state, dispatch } = useApp();
269
+ const { state } = useApp();
262
270
  const { progress } = state;
263
271
  const dimensions = useDimensions();
272
+
273
+ return (
274
+ <box flexDirection="column" height={dimensions.terminalHeight}>
275
+ {updateInfo?.updateAvailable && <UpdateBanner result={updateInfo} />}
276
+ {recoveryReport && (
277
+ <box paddingLeft={1} paddingRight={1}>
278
+ <text fg="green">✓ Fixed: {recoveryReport}</text>
279
+ </box>
280
+ )}
281
+ {showDebug && (
282
+ <box paddingLeft={1} paddingRight={1}>
283
+ <text fg="#888888">
284
+ DEBUG: {dimensions.terminalWidth}x{dimensions.terminalHeight} |
285
+ content={dimensions.contentHeight} | screen=
286
+ {state.currentRoute.screen}
287
+ </text>
288
+ </box>
289
+ )}
290
+ {progress && <ProgressIndicator {...progress} />}
291
+ <box
292
+ flexDirection="column"
293
+ height={dimensions.contentHeight}
294
+ paddingLeft={1}
295
+ paddingRight={1}
296
+ >
297
+ <Router />
298
+ </box>
299
+ <GlobalKeyHandler onDebugToggle={onDebugToggle} onExit={onExit} />
300
+ <ModalContainer />
301
+ </box>
302
+ );
303
+ }
304
+
305
+ /**
306
+ * AppContent Component
307
+ * Wraps app with DimensionsProvider and manages state for debug/updates
308
+ */
309
+ interface AppContentProps {
310
+ onExit: () => void;
311
+ }
312
+
313
+ function AppContent({ onExit }: AppContentProps) {
314
+ const { state, dispatch } = useApp();
315
+ const { progress } = state;
316
+ const [showDebug, setShowDebug] = useState(false);
317
+ const [updateInfo, setUpdateInfo] = useState<VersionCheckResult | null>(null);
264
318
  const [recoveryReport, setRecoveryReport] = useState<string | null>(null);
319
+ const [mismatchData, setMismatchData] = useState<VersionMismatchInfo[] | null>(null);
320
+
321
+ // Check for updates on startup (non-blocking)
322
+ useEffect(() => {
323
+ checkForUpdates()
324
+ .then(setUpdateInfo)
325
+ .catch(() => {});
326
+ }, []);
265
327
 
266
328
  // Auto-dismiss recovery banner after 5 seconds
267
329
  useEffect(() => {
@@ -270,6 +332,72 @@ function AppContentInner({
270
332
  return () => clearTimeout(timer);
271
333
  }, [recoveryReport]);
272
334
 
335
+ // Show mismatch modal when data arrives
336
+ useEffect(() => {
337
+ if (!mismatchData || mismatchData.length === 0) return;
338
+ dispatch({
339
+ type: "SHOW_MODAL",
340
+ modal: {
341
+ type: "select",
342
+ title: "⚠ Plugin Version Mismatch",
343
+ message: formatMismatchModal(mismatchData),
344
+ options: [
345
+ {
346
+ label: "Fix all projects",
347
+ value: "fix",
348
+ description: "Align all projects to this project's versions",
349
+ },
350
+ {
351
+ label: "Dismiss",
352
+ value: "dismiss",
353
+ description: "Ignore for now",
354
+ },
355
+ ],
356
+ onSelect: async (value: string) => {
357
+ dispatch({ type: "HIDE_MODAL" });
358
+ if (value === "fix") {
359
+ dispatch({
360
+ type: "SHOW_PROGRESS",
361
+ state: { message: "Fixing plugin versions..." },
362
+ });
363
+ try {
364
+ await fixAllPluginVersionMismatches(mismatchData);
365
+ dispatch({ type: "HIDE_PROGRESS" });
366
+ dispatch({
367
+ type: "SHOW_MODAL",
368
+ modal: {
369
+ type: "message",
370
+ title: "Fixed",
371
+ message:
372
+ "All plugin versions aligned. Restart Claude Code for changes to take effect.",
373
+ variant: "success",
374
+ onDismiss: () => dispatch({ type: "HIDE_MODAL" }),
375
+ },
376
+ });
377
+ } catch {
378
+ dispatch({ type: "HIDE_PROGRESS" });
379
+ dispatch({
380
+ type: "SHOW_MODAL",
381
+ modal: {
382
+ type: "message",
383
+ title: "Error",
384
+ message: "Failed to fix plugin versions. Try running claudeup again.",
385
+ variant: "error",
386
+ onDismiss: () => dispatch({ type: "HIDE_MODAL" }),
387
+ },
388
+ });
389
+ }
390
+ }
391
+ setMismatchData(null);
392
+ },
393
+ onCancel: () => {
394
+ dispatch({ type: "HIDE_MODAL" });
395
+ setMismatchData(null);
396
+ },
397
+ },
398
+ });
399
+ }, [mismatchData, dispatch]);
400
+
273
401
  // Auto-refresh marketplaces on startup
274
402
  useEffect(() => {
275
403
  const noRefresh = process.argv.includes("--no-refresh");
@@ -280,10 +408,10 @@ function AppContentInner({
280
408
  state: { message: "Scanning marketplaces..." },
281
409
  });
282
410
 
283
- // Migrate old marketplace names magus (idempotent), then repair plugin.json files
411
+ // Migrate old marketplace names -> magus (idempotent), then repair plugin.json files
284
412
  migrateMarketplaceRename().catch(() => {}); // non-blocking, best-effort
285
413
 
286
- // Recover stale marketplace registry entries (e.g. "directory" "github")
414
+ // Recover stale marketplace registry entries (e.g. "directory" -> "github")
287
415
  recoverMarketplaceSettings()
288
416
  .then(async (recovery) => {
289
417
  const parts: string[] = [];
@@ -302,14 +430,10 @@ function AppContentInner({
302
430
  }
303
431
  }
304
432
  if (recovery.enabledAutoUpdate.length > 0) {
305
- parts.push(
306
- `auto-update: ${recovery.enabledAutoUpdate.join(", ")}`,
307
- );
433
+ parts.push(`auto-update: ${recovery.enabledAutoUpdate.join(", ")}`);
308
434
  }
309
435
  if (recovery.removed.length > 0) {
310
- parts.push(
311
- `removed: ${recovery.removed.join(", ")}`,
312
- );
436
+ parts.push(`removed: ${recovery.removed.join(", ")}`);
313
437
  }
314
438
  if (parts.length > 0) {
315
439
  setRecoveryReport(parts.join(" | "));
@@ -317,6 +441,15 @@ function AppContentInner({
317
441
  })
318
442
  .catch(() => {}); // non-fatal
319
443
 
444
+ // Check for plugin version mismatches (the [0] index bug)
445
+ checkPluginVersionMismatches(process.cwd())
446
+ .then((mismatches) => {
447
+ if (mismatches.length > 0) {
448
+ setMismatchData(mismatches);
449
+ }
450
+ })
451
+ .catch(() => {}); // non-fatal
452
+
320
453
  repairAllMarketplaces()
321
454
  .then(async () => {
322
455
  dispatch({ type: "HIDE_PROGRESS" });
@@ -327,71 +460,24 @@ function AppContentInner({
327
460
  });
328
461
  }, [dispatch]);
329
462
 
330
- return (
331
- <box flexDirection="column" height={dimensions.terminalHeight}>
332
- {updateInfo?.updateAvailable && <UpdateBanner result={updateInfo} />}
333
- {recoveryReport && (
334
- <box paddingLeft={1} paddingRight={1}>
335
- <text fg="green">✓ Fixed: {recoveryReport}</text>
336
- </box>
337
- )}
338
- {showDebug && (
339
- <box paddingLeft={1} paddingRight={1}>
340
- <text fg="#888888">
341
- DEBUG: {dimensions.terminalWidth}x{dimensions.terminalHeight} |
342
- content={dimensions.contentHeight} | screen=
343
- {state.currentRoute.screen}
344
- </text>
345
- </box>
346
- )}
347
- {progress && <ProgressIndicator {...progress} />}
348
- <box
349
- flexDirection="column"
350
- height={dimensions.contentHeight}
351
- paddingLeft={1}
352
- paddingRight={1}
353
- >
354
- <Router />
355
- </box>
356
- <GlobalKeyHandler onDebugToggle={onDebugToggle} onExit={onExit} />
357
- <ModalContainer />
358
- </box>
359
- );
360
- }
361
-
362
- /**
363
- * AppContent Component
364
- * Wraps app with DimensionsProvider and manages state for debug/updates
365
- */
366
- interface AppContentProps {
367
- onExit: () => void;
368
- }
369
-
370
- function AppContent({ onExit }: AppContentProps) {
371
- const { state } = useApp();
372
- const { progress } = state;
373
- const [showDebug, setShowDebug] = useState(false);
374
- const [updateInfo, setUpdateInfo] = useState<VersionCheckResult | null>(null);
375
-
376
- // Check for updates on startup (non-blocking)
377
- useEffect(() => {
378
- checkForUpdates()
379
- .then(setUpdateInfo)
380
- .catch(() => {});
381
- }, []);
463
+ // Count transient banners for dimension calculation
464
+ const transientBannerCount =
465
+ recoveryReport ? 1 : 0;
382
466
 
383
467
  return (
384
468
  <DimensionsProvider
385
469
  showProgress={!!progress}
386
470
  showDebug={showDebug}
387
471
  showUpdateBanner={!!updateInfo?.updateAvailable}
472
+ transientBannerCount={transientBannerCount}
388
473
  >
389
474
  <AppContentInner
390
475
  showDebug={showDebug}
391
476
  onDebugToggle={() => setShowDebug((s) => !s)}
392
477
  updateInfo={updateInfo}
393
478
  onExit={onExit}
394
- />
479
+ recoveryReport={recoveryReport}
480
+ />
395
481
  </DimensionsProvider>
396
482
  );
397
483
  }
@@ -14,8 +14,9 @@ import { saveProfile } from "../../services/profiles.js";
14
14
  import { installPlugin as cliInstallPlugin, uninstallPlugin as cliUninstallPlugin, updatePlugin as cliUpdatePlugin, } from "../../services/claude-cli.js";
15
15
  import { getPluginEnvRequirements, getPluginSourcePath, } from "../../services/plugin-mcp-config.js";
16
16
  import { getPluginSetupFromSource, checkMissingDeps, installPluginDeps, } from "../../services/plugin-setup.js";
17
+ import { checkSinglePluginMismatch } from "../../services/plugin-version-check.js";
17
18
  import { buildPluginBrowserItems, } from "../adapters/pluginsAdapter.js";
18
- import { renderPluginRow, renderPluginDetail } from "../renderers/pluginRenderers.js";
19
+ import { renderPluginRow, renderPluginDetail, } from "../renderers/pluginRenderers.js";
19
20
  export function PluginsScreen() {
20
21
  const { state, dispatch } = useApp();
21
22
  const { plugins: pluginsState } = state;
@@ -140,7 +141,10 @@ export function PluginsScreen() {
140
141
  if (event.name === "up" || event.name === "k") {
141
142
  if (isSearchActive)
142
143
  dispatch({ type: "SET_SEARCHING", isSearching: false });
143
- dispatch({ type: "PLUGINS_SELECT", index: Math.max(0, pluginsState.selectedIndex - 1) });
144
+ dispatch({
145
+ type: "PLUGINS_SELECT",
146
+ index: Math.max(0, pluginsState.selectedIndex - 1),
147
+ });
144
148
  return;
145
149
  }
146
150
  if (event.name === "down" || event.name === "j") {
@@ -167,12 +171,18 @@ export function PluginsScreen() {
167
171
  selectableItems[pluginsState.selectedIndex]?.kind === "category") {
168
172
  const item = selectableItems[pluginsState.selectedIndex];
169
173
  if (item?.kind === "category") {
170
- dispatch({ type: "PLUGINS_TOGGLE_MARKETPLACE", name: item.marketplace.name });
174
+ dispatch({
175
+ type: "PLUGINS_TOGGLE_MARKETPLACE",
176
+ name: item.marketplace.name,
177
+ });
171
178
  }
172
179
  return;
173
180
  }
174
181
  if (isSearchActive) {
175
- if (event.name.length === 1 && !event.ctrl && !event.meta && !/[0-9]/.test(event.name)) {
182
+ if (event.name.length === 1 &&
183
+ !event.ctrl &&
184
+ !event.meta &&
185
+ !/[0-9]/.test(event.name)) {
176
186
  dispatch({
177
187
  type: "PLUGINS_SET_SEARCH",
178
188
  query: pluginsState.searchQuery + event.name,
@@ -223,6 +233,29 @@ export function PluginsScreen() {
223
233
  await saveInstalledPluginVersion(pluginId, version, state.projectPath);
224
234
  }
225
235
  };
236
+ /**
237
+ * Check for version mismatch after a plugin update and warn the user.
238
+ * The [0] index bug in Claude Code means the update may not actually
239
+ * take effect if another project's entry is at index 0.
240
+ */
241
+ const warnIfVersionMismatch = async (pluginId) => {
242
+ try {
243
+ const projectPath = state.projectPath || process.cwd();
244
+ const mismatch = await checkSinglePluginMismatch(pluginId, projectPath);
245
+ if (mismatch) {
246
+ await modal.message("Version Mismatch Warning", `${pluginId} was updated to v${mismatch.currentProjectVersion} for this project, ` +
247
+ `but Claude Code will load v${mismatch.firstEntryVersion} due to a known bug.\n\n` +
248
+ `The plugin loader always uses the first entry in installed_plugins.json, ` +
249
+ `which belongs to another project.\n\n` +
250
+ `Bug: https://github.com/anthropics/claude-code/issues/45997\n\n` +
251
+ `To fix: open claudeup and use the version alignment tool, ` +
252
+ `or update the plugin in all projects to the same version.`, "error");
253
+ }
254
+ }
255
+ catch {
256
+ // Non-fatal: mismatch check is best-effort
257
+ }
258
+ };
226
259
  // ── Action handlers ────────────────────────────────────────────────────────
227
260
  const handleRefresh = async () => {
228
261
  progress.show("Refreshing cache...");
@@ -360,7 +393,9 @@ export function PluginsScreen() {
360
393
  const result = await installPluginDeps(missing);
361
394
  modal.hideModal();
362
395
  if (result.failed.length > 0) {
363
- const failMsg = result.failed.map((f) => `${f.pkg}: ${f.error}`).join("\n");
396
+ const failMsg = result.failed
397
+ .map((f) => `${f.pkg}: ${f.error}`)
398
+ .join("\n");
364
399
  await modal.message("Partial Install", `Installed: ${result.installed.length}\nFailed:\n${failMsg}`, "error");
365
400
  }
366
401
  else if (result.installed.length > 0) {
@@ -404,7 +439,10 @@ export function PluginsScreen() {
404
439
  const buildScopeLabel = (name, scope, desc) => {
405
440
  const installed = scope?.enabled;
406
441
  const ver = scope?.version;
407
- const hasUpdate = ver && latestVersion && ver !== latestVersion && latestVersion !== "0.0.0";
442
+ const hasUpdate = ver &&
443
+ latestVersion &&
444
+ ver !== latestVersion &&
445
+ latestVersion !== "0.0.0";
408
446
  let label = installed ? `● ${name}` : `○ ${name}`;
409
447
  label += ` (${desc})`;
410
448
  if (ver)
@@ -414,9 +452,18 @@ export function PluginsScreen() {
414
452
  return label;
415
453
  };
416
454
  const scopeOptions = [
417
- { label: buildScopeLabel("User", plugin.userScope, "global"), value: "user" },
418
- { label: buildScopeLabel("Project", plugin.projectScope, "team"), value: "project" },
419
- { label: buildScopeLabel("Local", plugin.localScope, "private"), value: "local" },
455
+ {
456
+ label: buildScopeLabel("User", plugin.userScope, "global"),
457
+ value: "user",
458
+ },
459
+ {
460
+ label: buildScopeLabel("Project", plugin.projectScope, "team"),
461
+ value: "project",
462
+ },
463
+ {
464
+ label: buildScopeLabel("Local", plugin.localScope, "private"),
465
+ value: "local",
466
+ },
420
467
  ];
421
468
  const scopeValue = await modal.select(plugin.name, `Select scope to toggle:`, scopeOptions);
422
469
  if (scopeValue === null)
@@ -428,7 +475,11 @@ export function PluginsScreen() {
428
475
  : plugin.localScope;
429
476
  const isInstalledInScope = selectedScope?.enabled;
430
477
  const installedVersion = selectedScope?.version;
431
- const scopeLabel = scopeValue === "user" ? "User" : scopeValue === "project" ? "Project" : "Local";
478
+ const scopeLabel = scopeValue === "user"
479
+ ? "User"
480
+ : scopeValue === "project"
481
+ ? "Project"
482
+ : "Local";
432
483
  const hasUpdateInScope = isInstalledInScope &&
433
484
  installedVersion &&
434
485
  latestVersion !== "0.0.0" &&
@@ -465,6 +516,9 @@ export function PluginsScreen() {
465
516
  if (action !== "install") {
466
517
  modal.hideModal();
467
518
  }
519
+ if (action === "update") {
520
+ await warnIfVersionMismatch(plugin.id);
521
+ }
468
522
  fetchData();
469
523
  }
470
524
  catch (error) {
@@ -486,6 +540,7 @@ export function PluginsScreen() {
486
540
  await saveVersionForScope(plugin.id, plugin.version, scope);
487
541
  }
488
542
  modal.hideModal();
543
+ await warnIfVersionMismatch(plugin.id);
489
544
  fetchData();
490
545
  }
491
546
  catch (error) {
@@ -500,6 +555,7 @@ export function PluginsScreen() {
500
555
  if (updatable.length === 0)
501
556
  return;
502
557
  const scope = pluginsState.scope === "global" ? "user" : "project";
558
+ const updatedPluginIds = [];
503
559
  try {
504
560
  for (let i = 0; i < updatable.length; i++) {
505
561
  const plugin = updatable[i];
@@ -508,8 +564,13 @@ export function PluginsScreen() {
508
564
  if (plugin.version) {
509
565
  await saveVersionForScope(plugin.id, plugin.version, scope);
510
566
  }
567
+ updatedPluginIds.push(plugin.id);
511
568
  }
512
569
  modal.hideModal();
570
+ // Check for mismatches on all updated plugins
571
+ for (const pluginId of updatedPluginIds) {
572
+ await warnIfVersionMismatch(pluginId);
573
+ }
513
574
  fetchData();
514
575
  }
515
576
  catch (error) {
@@ -566,6 +627,9 @@ export function PluginsScreen() {
566
627
  if (action !== "install") {
567
628
  modal.hideModal();
568
629
  }
630
+ if (action === "update") {
631
+ await warnIfVersionMismatch(plugin.id);
632
+ }
569
633
  fetchData();
570
634
  }
571
635
  catch (error) {
@@ -605,8 +669,14 @@ export function PluginsScreen() {
605
669
  if (name === null || !name.trim())
606
670
  return;
607
671
  const scopeChoice = await modal.select("Save to scope", "Where should this profile be saved?", [
608
- { label: "User — ~/.claude/profiles.json (available everywhere)", value: "user" },
609
- { label: "Project.claude/profiles.json (shared with team via git)", value: "project" },
672
+ {
673
+ label: "User~/.claude/profiles.json (available everywhere)",
674
+ value: "user",
675
+ },
676
+ {
677
+ label: "Project — .claude/profiles.json (shared with team via git)",
678
+ value: "project",
679
+ },
610
680
  ]);
611
681
  if (scopeChoice === null)
612
682
  return;
@@ -638,7 +708,10 @@ export function PluginsScreen() {
638
708
  const scopeLabel = pluginsState.scope === "global" ? "Global" : "Project";
639
709
  const plugins = pluginsState.plugins.status === "success" ? pluginsState.plugins.data : [];
640
710
  const installedCount = plugins.filter((p) => p.enabled).length;
641
- const updateCount = plugins.filter((p) => p.hasUpdate && (p.userScope?.enabled || p.projectScope?.enabled || p.localScope?.enabled)).length;
711
+ const updateCount = plugins.filter((p) => p.hasUpdate &&
712
+ (p.userScope?.enabled ||
713
+ p.projectScope?.enabled ||
714
+ p.localScope?.enabled)).length;
642
715
  const subtitle = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} updates` : ""}`;
643
716
  const searchPlaceholder = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} ⬆` : ""} │ / to search`;
644
717
  return (_jsx(ScreenLayout, { title: "claudeup Plugins", subtitle: subtitle, currentScreen: "plugins", search: {