claudeup 4.10.1 → 4.11.0

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,10 @@ import {
28
28
  migrateMarketplaceRename,
29
29
  recoverMarketplaceSettings,
30
30
  } from "../services/claude-settings.js";
31
+ import {
32
+ checkPluginVersionMismatches,
33
+ formatMismatchBanner,
34
+ } from "../services/plugin-version-check.js";
31
35
  import {
32
36
  checkForUpdates,
33
37
  getCurrentVersion,
@@ -216,7 +220,7 @@ function UpdateBanner({ result }: { result: VersionCheckResult }) {
216
220
  v{result.currentVersion} → v{result.latestVersion}
217
221
  </text>
218
222
  <text fg="gray"> Run: </text>
219
- <text fg="cyan">npm i -g claudeup</text>
223
+ <text fg="cyan">claudeup update</text>
220
224
  </box>
221
225
  );
222
226
  }
@@ -250,6 +254,8 @@ interface AppContentInnerProps {
250
254
  onDebugToggle: () => void;
251
255
  updateInfo: VersionCheckResult | null;
252
256
  onExit: () => void;
257
+ recoveryReport: string | null;
258
+ mismatchWarning: string | null;
253
259
  }
254
260
 
255
261
  function AppContentInner({
@@ -257,81 +263,24 @@ function AppContentInner({
257
263
  onDebugToggle,
258
264
  updateInfo,
259
265
  onExit,
266
+ recoveryReport,
267
+ mismatchWarning,
260
268
  }: AppContentInnerProps) {
261
- const { state, dispatch } = useApp();
269
+ const { state } = useApp();
262
270
  const { progress } = state;
263
271
  const dimensions = useDimensions();
264
- const [recoveryReport, setRecoveryReport] = useState<string | null>(null);
265
-
266
- // Auto-refresh marketplaces on startup
267
- useEffect(() => {
268
- const noRefresh = process.argv.includes("--no-refresh");
269
- if (noRefresh) return;
270
-
271
- dispatch({
272
- type: "SHOW_PROGRESS",
273
- state: { message: "Scanning marketplaces..." },
274
- });
275
-
276
- // Migrate old marketplace names → magus (idempotent), then repair plugin.json files
277
- migrateMarketplaceRename().catch(() => {}); // non-blocking, best-effort
278
-
279
- // Recover stale marketplace registry entries (e.g. "directory" → "github")
280
- recoverMarketplaceSettings()
281
- .then(async (recovery) => {
282
- const msgs: string[] = [];
283
- if (recovery.reregistered.length > 0) {
284
- msgs.push(
285
- `Re-registered as GitHub: ${recovery.reregistered.join(", ")}`,
286
- );
287
- // Update the marketplace clone now that the source is fixed
288
- const { updateMarketplace } = await import(
289
- "../services/claude-cli.js"
290
- );
291
- for (const mp of recovery.reregistered) {
292
- try {
293
- await updateMarketplace(mp);
294
- msgs.push(`Updated marketplace: ${mp}`);
295
- } catch {
296
- msgs.push(`Failed to update: ${mp}`);
297
- }
298
- }
299
- }
300
- if (recovery.enabledAutoUpdate.length > 0) {
301
- msgs.push(
302
- `Enabled auto-update: ${recovery.enabledAutoUpdate.join(", ")}`,
303
- );
304
- }
305
- if (recovery.removed.length > 0) {
306
- msgs.push(
307
- `Removed stale: ${recovery.removed.join(", ")}`,
308
- );
309
- }
310
- if (msgs.length > 0) {
311
- setRecoveryReport(msgs.join("\n"));
312
- }
313
- })
314
- .catch(() => {}); // non-fatal
315
-
316
- repairAllMarketplaces()
317
- .then(async () => {
318
- dispatch({ type: "HIDE_PROGRESS" });
319
- dispatch({ type: "DATA_REFRESH_COMPLETE" });
320
- })
321
- .catch(() => {
322
- dispatch({ type: "HIDE_PROGRESS" });
323
- });
324
- }, [dispatch]);
325
272
 
326
273
  return (
327
274
  <box flexDirection="column" height={dimensions.terminalHeight}>
328
275
  {updateInfo?.updateAvailable && <UpdateBanner result={updateInfo} />}
329
276
  {recoveryReport && (
330
277
  <box paddingLeft={1} paddingRight={1}>
331
- <text bg="green" fg="black">
332
- <strong> RECOVERED </strong>
333
- </text>
334
- <text fg="green"> {recoveryReport.split("\n").join(" | ")}</text>
278
+ <text fg="green">✓ Fixed: {recoveryReport}</text>
279
+ </box>
280
+ )}
281
+ {mismatchWarning && (
282
+ <box paddingLeft={1} paddingRight={1}>
283
+ <text fg="yellow">⚠ {mismatchWarning} (bug #45997)</text>
335
284
  </box>
336
285
  )}
337
286
  {showDebug && (
@@ -367,10 +316,12 @@ interface AppContentProps {
367
316
  }
368
317
 
369
318
  function AppContent({ onExit }: AppContentProps) {
370
- const { state } = useApp();
319
+ const { state, dispatch } = useApp();
371
320
  const { progress } = state;
372
321
  const [showDebug, setShowDebug] = useState(false);
373
322
  const [updateInfo, setUpdateInfo] = useState<VersionCheckResult | null>(null);
323
+ const [recoveryReport, setRecoveryReport] = useState<string | null>(null);
324
+ const [mismatchWarning, setMismatchWarning] = useState<string | null>(null);
374
325
 
375
326
  // Check for updates on startup (non-blocking)
376
327
  useEffect(() => {
@@ -379,17 +330,100 @@ function AppContent({ onExit }: AppContentProps) {
379
330
  .catch(() => {});
380
331
  }, []);
381
332
 
333
+ // Auto-dismiss recovery banner after 5 seconds
334
+ useEffect(() => {
335
+ if (!recoveryReport) return;
336
+ const timer = setTimeout(() => setRecoveryReport(null), 5000);
337
+ return () => clearTimeout(timer);
338
+ }, [recoveryReport]);
339
+
340
+ // Auto-dismiss mismatch warning after 8 seconds (longer — more important)
341
+ useEffect(() => {
342
+ if (!mismatchWarning) return;
343
+ const timer = setTimeout(() => setMismatchWarning(null), 8000);
344
+ return () => clearTimeout(timer);
345
+ }, [mismatchWarning]);
346
+
347
+ // Auto-refresh marketplaces on startup
348
+ useEffect(() => {
349
+ const noRefresh = process.argv.includes("--no-refresh");
350
+ if (noRefresh) return;
351
+
352
+ dispatch({
353
+ type: "SHOW_PROGRESS",
354
+ state: { message: "Scanning marketplaces..." },
355
+ });
356
+
357
+ // Migrate old marketplace names -> magus (idempotent), then repair plugin.json files
358
+ migrateMarketplaceRename().catch(() => {}); // non-blocking, best-effort
359
+
360
+ // Recover stale marketplace registry entries (e.g. "directory" -> "github")
361
+ recoverMarketplaceSettings()
362
+ .then(async (recovery) => {
363
+ const parts: string[] = [];
364
+ if (recovery.reregistered.length > 0) {
365
+ // Update the marketplace clone now that the source is fixed
366
+ const { updateMarketplace } = await import(
367
+ "../services/claude-cli.js"
368
+ );
369
+ for (const mp of recovery.reregistered) {
370
+ try {
371
+ await updateMarketplace(mp);
372
+ parts.push(`${mp} refreshed`);
373
+ } catch {
374
+ parts.push(`${mp} (update failed)`);
375
+ }
376
+ }
377
+ }
378
+ if (recovery.enabledAutoUpdate.length > 0) {
379
+ parts.push(`auto-update: ${recovery.enabledAutoUpdate.join(", ")}`);
380
+ }
381
+ if (recovery.removed.length > 0) {
382
+ parts.push(`removed: ${recovery.removed.join(", ")}`);
383
+ }
384
+ if (parts.length > 0) {
385
+ setRecoveryReport(parts.join(" | "));
386
+ }
387
+ })
388
+ .catch(() => {}); // non-fatal
389
+
390
+ // Check for plugin version mismatches (the [0] index bug)
391
+ checkPluginVersionMismatches(process.cwd())
392
+ .then((mismatches) => {
393
+ if (mismatches.length > 0) {
394
+ setMismatchWarning(formatMismatchBanner(mismatches));
395
+ }
396
+ })
397
+ .catch(() => {}); // non-fatal
398
+
399
+ repairAllMarketplaces()
400
+ .then(async () => {
401
+ dispatch({ type: "HIDE_PROGRESS" });
402
+ dispatch({ type: "DATA_REFRESH_COMPLETE" });
403
+ })
404
+ .catch(() => {
405
+ dispatch({ type: "HIDE_PROGRESS" });
406
+ });
407
+ }, [dispatch]);
408
+
409
+ // Count transient banners for dimension calculation
410
+ const transientBannerCount =
411
+ (recoveryReport ? 1 : 0) + (mismatchWarning ? 1 : 0);
412
+
382
413
  return (
383
414
  <DimensionsProvider
384
415
  showProgress={!!progress}
385
416
  showDebug={showDebug}
386
417
  showUpdateBanner={!!updateInfo?.updateAvailable}
418
+ transientBannerCount={transientBannerCount}
387
419
  >
388
420
  <AppContentInner
389
421
  showDebug={showDebug}
390
422
  onDebugToggle={() => setShowDebug((s) => !s)}
391
423
  updateInfo={updateInfo}
392
424
  onExit={onExit}
425
+ recoveryReport={recoveryReport}
426
+ mismatchWarning={mismatchWarning}
393
427
  />
394
428
  </DimensionsProvider>
395
429
  );
@@ -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: {