claudeup 4.31.0 → 4.32.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.
@@ -42,22 +42,50 @@ export interface PluginChange {
42
42
  }
43
43
 
44
44
  interface SnapshotFile {
45
- /** pluginId -> installedVersion as of the last time claudeup rendered it. */
46
- seen: Record<string, string>;
45
+ /**
46
+ * Legacy flat baseline: pluginId -> version, with no record of which project
47
+ * it was observed from. Kept only so an existing file can be migrated.
48
+ */
49
+ seen?: Record<string, string>;
50
+ /**
51
+ * projectPath -> (pluginId -> installedVersion) as of the last time claudeup
52
+ * rendered that project.
53
+ *
54
+ * Versions are per-project (installed_plugins.json keys installs by
55
+ * projectPath), but this file is global. A single flat map therefore let two
56
+ * projects legitimately on different versions overwrite each other's
57
+ * baseline, and every differing plugin was badged "updated" on the next
58
+ * render — a change that never happened. Keying by project removes the
59
+ * cross-talk entirely.
60
+ */
61
+ byProject?: Record<string, Record<string, string>>;
47
62
  /** Set on the run that first created the baseline. */
48
63
  seededAt: string;
49
64
  updatedAt: string;
50
65
  }
51
66
 
67
+ /**
68
+ * Honour CLAUDE_CONFIG_DIR — the same override Claude Code itself uses.
69
+ *
70
+ * `os.homedir()` reads the passwd database on macOS and ignores $HOME, so a
71
+ * test that only overrides HOME writes to the operator's real snapshot file.
72
+ * That is not hypothetical: it happened while building the tests for this fix.
73
+ */
52
74
  function snapshotPath(): string {
53
- return path.join(os.homedir(), ".claude", "claudeup-version-snapshot.json");
75
+ const configDir =
76
+ process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
77
+ return path.join(configDir, "claudeup-version-snapshot.json");
54
78
  }
55
79
 
56
80
  async function read(): Promise<SnapshotFile | null> {
57
81
  try {
58
82
  const raw = await fs.readFile(snapshotPath(), "utf-8");
59
83
  const data = JSON.parse(raw) as SnapshotFile;
60
- if (!data || typeof data.seen !== "object" || data.seen === null) return null;
84
+ if (!data) return null;
85
+ const hasFlat = typeof data.seen === "object" && data.seen !== null;
86
+ const hasByProject =
87
+ typeof data.byProject === "object" && data.byProject !== null;
88
+ if (!hasFlat && !hasByProject) return null;
61
89
  return data;
62
90
  } catch {
63
91
  return null; // absent or corrupt — treated as "no baseline yet"
@@ -109,10 +137,28 @@ export function diffVersions(
109
137
  return changes;
110
138
  }
111
139
 
112
- /** Load the previous snapshot's version map, or null when no baseline exists. */
113
- export async function loadSeenVersions(): Promise<Record<string, string> | null> {
140
+ /**
141
+ * Load the baseline for one project, or null when that project has none.
142
+ *
143
+ * A project seen for the first time returns null, so `diffVersions` stays
144
+ * silent — the same rule as a first ever run. That is deliberate: opening
145
+ * claudeup in a new project must not badge every plugin as "updated" just
146
+ * because this file has never seen that path before.
147
+ *
148
+ * A pre-migration flat file is adopted as the baseline for whichever project
149
+ * asks first, which is the closest thing to the truth it contains.
150
+ */
151
+ export async function loadSeenVersions(
152
+ projectPath: string,
153
+ ): Promise<Record<string, string> | null> {
114
154
  const data = await read();
115
- return data ? data.seen : null;
155
+ if (!data) return null;
156
+ const scoped = data.byProject?.[projectPath];
157
+ if (scoped) return scoped;
158
+ // Legacy flat baseline, project unknown — use it once, then it is replaced
159
+ // by per-project entries on save.
160
+ if (data.seen && !data.byProject) return data.seen;
161
+ return null;
116
162
  }
117
163
 
118
164
  /**
@@ -121,6 +167,7 @@ export async function loadSeenVersions(): Promise<Record<string, string> | null>
121
167
  * shows again next launch.
122
168
  */
123
169
  export async function saveSeenVersions(
170
+ projectPath: string,
124
171
  current: Record<string, string>,
125
172
  ): Promise<void> {
126
173
  try {
@@ -129,7 +176,9 @@ export async function saveSeenVersions(
129
176
  const existing = await read();
130
177
  const now = new Date().toISOString();
131
178
  const payload: SnapshotFile = {
132
- seen: current,
179
+ // Other projects' baselines are preserved — writing only this project's
180
+ // slot is the entire point of the fix.
181
+ byProject: { ...(existing?.byProject ?? {}), [projectPath]: current },
133
182
  seededAt: existing?.seededAt ?? now,
134
183
  updatedAt: now,
135
184
  };
@@ -16,9 +16,13 @@ import {
16
16
  import { theme } from "../theme.js";
17
17
  import { highlightMatches } from "../../utils/fuzzy-search.js";
18
18
  import { getMarketplaceVersion } from "../../services/marketplace-fetcher.js";
19
- import { isEnabledButNotInstalled } from "../../services/plugin-manager.js";
19
+ import {
20
+ isEnabledButNotInstalled,
21
+ isInstalledInScope,
22
+ } from "../../services/plugin-manager.js";
20
23
  import { isKnownVersion } from "../../services/version-snapshot.js";
21
24
  import type { PluginRelease } from "../../types/index.js";
25
+ import type { ScopeStatus } from "../../services/plugin-manager.js";
22
26
 
23
27
  // ─── Category renderers ───────────────────────────────────────────────────────
24
28
 
@@ -136,10 +140,20 @@ function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode
136
140
 
137
141
  let versionStr = "";
138
142
  if (plugin.isOrphaned) {
139
- versionStr = " deprecated";
143
+ // "deprecated" alone reads as "delete me" — but a plugin that merely moved
144
+ // marketplace is still published, and deleting it loses it for no reason.
145
+ versionStr = plugin.movedTo
146
+ ? ` moved → ${plugin.movedTo}`
147
+ : " deprecated";
140
148
  } else if (notInstalled) {
141
149
  versionStr = " not installed";
142
150
  } else if (hasAnyScope) {
151
+ // "what just changed" and "what is still available" are independent facts,
152
+ // and both can be true at once: the prerunner can move a plugin 2.9.0→3.0.1
153
+ // while the marketplace already offers 3.3.1. These used to share one
154
+ // if/else chain, so the "updated" badge silently swallowed the pending
155
+ // update — and the update only reappeared on the next refresh, which read
156
+ // as the state spontaneously changing. Render them as separate clauses.
143
157
  if (plugin.recentlyUpdatedFrom && shownVersion) {
144
158
  // Show what actually happened, not just where it landed — the update may
145
159
  // have come from Claude Code or the prerunner without ever telling us.
@@ -150,9 +164,15 @@ function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode
150
164
  versionStr = shownVersion ? ` v${shownVersion} new` : " new";
151
165
  } else if (shownVersion) {
152
166
  versionStr = ` v${shownVersion}`;
153
- if (plugin.hasUpdate && plugin.version) {
154
- versionStr += ` → v${plugin.version}`;
155
- }
167
+ }
168
+
169
+ // Pending update, regardless of which badge above applied.
170
+ if (plugin.hasUpdate && plugin.version && versionStr) {
171
+ versionStr += ` → v${plugin.version} available`;
172
+ } else if (plugin.contentStale && versionStr) {
173
+ // Same version, different files. The version compare says "up to date"
174
+ // and is wrong — only a reinstall delivers the current content.
175
+ versionStr += " stale — reinstall";
156
176
  }
157
177
  }
158
178
 
@@ -207,10 +227,14 @@ function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode
207
227
 
208
228
  function pluginDetail(item: PluginPluginItem): React.ReactNode {
209
229
  const { plugin } = item;
230
+ // "Installed" has to mean the same thing here as in the list row. Reading the
231
+ // `enabledPlugins` flag alone made this panel print "● Installed" for the
232
+ // exact plugin the row beside it was labelling "not installed".
210
233
  const isInstalled =
211
- plugin.userScope?.enabled ||
212
- plugin.projectScope?.enabled ||
213
- plugin.localScope?.enabled;
234
+ isInstalledInScope(plugin.userScope) ||
235
+ isInstalledInScope(plugin.projectScope) ||
236
+ isInstalledInScope(plugin.localScope);
237
+ const brokenInstall = isEnabledButNotInstalled(plugin);
214
238
 
215
239
  // Orphaned/deprecated plugin
216
240
  if (plugin.isOrphaned) {
@@ -268,10 +292,30 @@ function pluginDetail(item: PluginPluginItem): React.ReactNode {
268
292
 
269
293
  {/* Status line */}
270
294
  <box marginTop={1}>
271
- <text fg={isInstalled ? theme.colors.success : theme.colors.muted}>
272
- {isInstalled ? "● Installed" : "○ Not installed"}
295
+ <text
296
+ fg={
297
+ isInstalled
298
+ ? theme.colors.success
299
+ : brokenInstall
300
+ ? theme.colors.warning
301
+ : theme.colors.muted
302
+ }
303
+ >
304
+ {isInstalled
305
+ ? "● Installed"
306
+ : brokenInstall
307
+ ? "○ Not installed — enabled in settings, no files on disk"
308
+ : "○ Not installed"}
273
309
  </text>
274
310
  </box>
311
+ {brokenInstall ? (
312
+ <box>
313
+ <text fg={theme.colors.muted}>
314
+ Claude Code cannot load it in this state. Press the scope key to
315
+ repair.
316
+ </text>
317
+ </box>
318
+ ) : null}
275
319
 
276
320
  {/* Description */}
277
321
  <box marginTop={1} marginBottom={1}>
@@ -315,48 +359,27 @@ function pluginDetail(item: PluginPluginItem): React.ReactNode {
315
359
  <strong>Scopes:</strong>
316
360
  </text>
317
361
  <box marginTop={1} flexDirection="column">
318
- <text>
319
- <span bg={theme.scopes.user} fg="black">
320
- {" "}
321
- u{" "}
322
- </span>
323
- <span fg={plugin.userScope?.enabled ? theme.scopes.user : theme.colors.muted}>
324
- {plugin.userScope?.enabled ? " ● " : " ○ "}
325
- </span>
326
- <span fg={theme.scopes.user}>User</span>
327
- <span> global</span>
328
- {plugin.userScope?.version ? (
329
- <span fg={theme.scopes.user}> v{plugin.userScope.version}</span>
330
- ) : null}
331
- </text>
332
- <text>
333
- <span bg={theme.scopes.project} fg="black">
334
- {" "}
335
- p{" "}
336
- </span>
337
- <span fg={plugin.projectScope?.enabled ? theme.scopes.project : theme.colors.muted}>
338
- {plugin.projectScope?.enabled ? " ● " : " ○ "}
339
- </span>
340
- <span fg={theme.scopes.project}>Project</span>
341
- <span> team</span>
342
- {plugin.projectScope?.version ? (
343
- <span fg={theme.scopes.project}> v{plugin.projectScope.version}</span>
344
- ) : null}
345
- </text>
346
- <text>
347
- <span bg={theme.scopes.local} fg="black">
348
- {" "}
349
- l{" "}
350
- </span>
351
- <span fg={plugin.localScope?.enabled ? theme.scopes.local : theme.colors.muted}>
352
- {plugin.localScope?.enabled ? " ● " : " ○ "}
353
- </span>
354
- <span fg={theme.scopes.local}>Local</span>
355
- <span> private</span>
356
- {plugin.localScope?.version ? (
357
- <span fg={theme.scopes.local}> v{plugin.localScope.version}</span>
358
- ) : null}
359
- </text>
362
+ <ScopeLine
363
+ keyHint="u"
364
+ name="User"
365
+ qualifier="global"
366
+ color={theme.scopes.user}
367
+ scope={plugin.userScope}
368
+ />
369
+ <ScopeLine
370
+ keyHint="p"
371
+ name="Project"
372
+ qualifier="team"
373
+ color={theme.scopes.project}
374
+ scope={plugin.projectScope}
375
+ />
376
+ <ScopeLine
377
+ keyHint="l"
378
+ name="Local"
379
+ qualifier="private"
380
+ color={theme.scopes.local}
381
+ scope={plugin.localScope}
382
+ />
360
383
  </box>
361
384
  </DetailSection>
362
385
 
@@ -373,6 +396,47 @@ function pluginDetail(item: PluginPluginItem): React.ReactNode {
373
396
  );
374
397
  }
375
398
 
399
+ /**
400
+ * One line of the detail panel's scope breakdown.
401
+ *
402
+ * The filled dot means installed — registry-backed, same as everywhere else.
403
+ * A scope that is enabled with nothing installed is called out explicitly
404
+ * rather than shown as a plain empty dot, because those two states need
405
+ * different actions from the user and used to look identical.
406
+ */
407
+ function ScopeLine({
408
+ keyHint,
409
+ name,
410
+ qualifier,
411
+ color,
412
+ scope,
413
+ }: {
414
+ keyHint: string;
415
+ name: string;
416
+ qualifier: string;
417
+ color: string;
418
+ scope?: ScopeStatus;
419
+ }): React.ReactNode {
420
+ const installed = isInstalledInScope(scope);
421
+ return (
422
+ <text>
423
+ <span bg={color} fg="black">
424
+ {" "}
425
+ {keyHint}{" "}
426
+ </span>
427
+ <span fg={installed ? color : theme.colors.muted}>
428
+ {installed ? " ● " : " ○ "}
429
+ </span>
430
+ <span fg={color}>{name}</span>
431
+ <span> {qualifier}</span>
432
+ {scope?.version ? <span fg={color}> v{scope.version}</span> : null}
433
+ {!installed && scope?.enabled ? (
434
+ <span fg={theme.colors.warning}> enabled, not installed</span>
435
+ ) : null}
436
+ </text>
437
+ );
438
+ }
439
+
376
440
  /**
377
441
  * Recent release history, newest first.
378
442
  *
@@ -7,6 +7,7 @@ import { ScrollableList } from "../components/ScrollableList.js";
7
7
  import { EmptyFilterState } from "../components/EmptyFilterState.js";
8
8
  import { fuzzyFilter } from "../../utils/fuzzy-search.js";
9
9
  import { getAllMarketplaces } from "../../data/marketplaces.js";
10
+ import { clearContentDriftCache } from "../../services/content-drift.js";
10
11
  import {
11
12
  diffVersions,
12
13
  loadSeenVersions,
@@ -17,8 +18,11 @@ import {
17
18
  refreshAllMarketplaces,
18
19
  clearMarketplaceCache,
19
20
  getLocalMarketplacesInfo,
21
+ isInstalledInScope,
22
+ resolveScopeAction,
20
23
  saveInstalledPluginVersion,
21
24
  type PluginInfo,
25
+ type ScopeStatus,
22
26
  } from "../../services/plugin-manager.js";
23
27
  import {
24
28
  setMcpEnvVar,
@@ -30,6 +34,7 @@ import {
30
34
  import { saveProfile } from "../../services/profiles.js";
31
35
  import {
32
36
  installPlugin as cliInstallPlugin,
37
+ repairPlugin as cliRepairPlugin,
33
38
  uninstallPlugin as cliUninstallPlugin,
34
39
  updatePlugin as cliUpdatePlugin,
35
40
  type PluginScope,
@@ -87,7 +92,8 @@ export function PluginsScreen() {
87
92
  for (const p of pluginData) {
88
93
  if (p.installedVersion) current[p.id] = p.installedVersion;
89
94
  }
90
- const previous = await loadSeenVersions();
95
+ const projectPath = state.projectPath || process.cwd();
96
+ const previous = await loadSeenVersions(projectPath);
91
97
  for (const change of diffVersions(previous, current)) {
92
98
  const target = pluginData.find((p) => p.id === change.pluginId);
93
99
  if (!target) continue;
@@ -97,8 +103,10 @@ export function PluginsScreen() {
97
103
  target.recentlyInstalled = true;
98
104
  }
99
105
  }
100
- // Record now so the same change is not reported on the next launch.
101
- void saveSeenVersions(current);
106
+ // Await the write. Fire-and-forget raced the next fetchData() a
107
+ // refresh or a post-action refetch could load the baseline before the
108
+ // previous save landed and re-report the same change.
109
+ await saveSeenVersions(projectPath, current);
102
110
 
103
111
  dispatch({
104
112
  type: "PLUGINS_DATA_SUCCESS",
@@ -352,6 +360,9 @@ export function PluginsScreen() {
352
360
  progress.show(`${p.name}`, p.current, p.total);
353
361
  });
354
362
  clearMarketplaceCache();
363
+ // Drift is answered by diffing against the clone's HEAD, so a refresh
364
+ // that moves HEAD invalidates every cached answer.
365
+ clearContentDriftCache();
355
366
  progress.hide();
356
367
 
357
368
  let message =
@@ -597,10 +608,10 @@ export function PluginsScreen() {
597
608
 
598
609
  const buildScopeLabel = (
599
610
  name: string,
600
- scope: { enabled?: boolean; version?: string } | undefined,
611
+ scope: ScopeStatus | undefined,
601
612
  desc: string,
602
613
  ) => {
603
- const installed = scope?.enabled;
614
+ const installed = isInstalledInScope(scope);
604
615
  const ver = scope?.version;
605
616
  const hasUpdate =
606
617
  ver &&
@@ -611,6 +622,10 @@ export function PluginsScreen() {
611
622
  label += ` (${desc})`;
612
623
  if (ver) label += ` v${ver}`;
613
624
  if (hasUpdate) label += ` → v${latestVersion}`;
625
+ // Enabled in settings with nothing installed. Without saying so the
626
+ // row reads exactly like a scope that was never enabled, and the
627
+ // user cannot tell that picking it repairs rather than adds.
628
+ if (!installed && scope?.enabled) label += " — not installed, repairs";
614
629
  return label;
615
630
  };
616
631
 
@@ -642,8 +657,6 @@ export function PluginsScreen() {
642
657
  : scopeValue === "project"
643
658
  ? plugin.projectScope
644
659
  : plugin.localScope;
645
- const isInstalledInScope = selectedScope?.enabled;
646
- const installedVersion = selectedScope?.version;
647
660
  const scopeLabel =
648
661
  scopeValue === "user"
649
662
  ? "User"
@@ -651,20 +664,9 @@ export function PluginsScreen() {
651
664
  ? "Project"
652
665
  : "Local";
653
666
 
654
- const hasUpdateInScope =
655
- isInstalledInScope &&
656
- installedVersion &&
657
- latestVersion !== "0.0.0" &&
658
- installedVersion !== latestVersion;
659
-
660
- let action: "update" | "install" | "uninstall";
661
- if (isInstalledInScope && hasUpdateInScope) {
662
- action = "update";
663
- } else if (isInstalledInScope) {
664
- action = "uninstall";
665
- } else {
666
- action = "install";
667
- }
667
+ // Decided from the registry-backed version, not the `enabledPlugins`
668
+ // flag alone — see resolveScopeAction.
669
+ const action = resolveScopeAction(selectedScope, latestVersion);
668
670
 
669
671
  try {
670
672
  const scope = scopeValue as PluginScope;
@@ -705,17 +707,29 @@ export function PluginsScreen() {
705
707
 
706
708
  const handleUpdate = async () => {
707
709
  const item = selectableItems[pluginsState.selectedIndex];
708
- if (!item || item.kind !== "plugin" || !item.plugin.hasUpdate) return;
710
+ if (!item || item.kind !== "plugin") return;
711
+ if (!item.plugin.hasUpdate && !item.plugin.contentStale) return;
709
712
 
710
713
  const plugin = item.plugin;
711
714
  const scope: PluginScope =
712
715
  pluginsState.scope === "global" ? "user" : "project";
713
716
 
717
+ // No version bump to install over — the files changed underneath an
718
+ // unchanged version, and `plugin install` no-ops on "already installed".
719
+ // Only uninstall+install re-copies. See claude-cli.ts repairPlugin.
720
+ const repairing = !plugin.hasUpdate && plugin.contentStale;
721
+
714
722
  modal.loading(
715
- `Updating ${plugin.name}…\nclaude plugin install ${plugin.id} --scope ${scope}`,
723
+ repairing
724
+ ? `Repairing ${plugin.name}…\nclaude plugin uninstall ${plugin.id} --scope ${scope} && claude plugin install ${plugin.id} --scope ${scope}`
725
+ : `Updating ${plugin.name}…\nclaude plugin install ${plugin.id} --scope ${scope}`,
716
726
  );
717
727
  try {
718
- await cliUpdatePlugin(plugin.id, scope);
728
+ if (repairing) {
729
+ await cliRepairPlugin(plugin.id, scope, state.projectPath);
730
+ } else {
731
+ await cliUpdatePlugin(plugin.id, scope);
732
+ }
719
733
  if (plugin.version) {
720
734
  await saveVersionForScope(plugin.id, plugin.version, scope);
721
735
  }
@@ -731,7 +745,11 @@ export function PluginsScreen() {
731
745
  const handleUpdateAll = async () => {
732
746
  if (pluginsState.plugins.status !== "success") return;
733
747
 
734
- const updatable = pluginsState.plugins.data.filter((p) => p.hasUpdate);
748
+ // Content-stale plugins are included: they need repairing, and excluding
749
+ // them meant "update all" left the machine with silently outdated files.
750
+ const updatable = pluginsState.plugins.data.filter(
751
+ (p) => p.hasUpdate || p.contentStale,
752
+ );
735
753
  if (updatable.length === 0) return;
736
754
 
737
755
  const scope: PluginScope =
@@ -741,10 +759,15 @@ export function PluginsScreen() {
741
759
  try {
742
760
  for (let i = 0; i < updatable.length; i++) {
743
761
  const plugin = updatable[i];
762
+ const repairing = !plugin.hasUpdate && plugin.contentStale;
744
763
  modal.loading(
745
- `Updating ${plugin.name} (${i + 1}/${updatable.length})…\nclaude plugin install ${plugin.id} --scope ${scope}`,
764
+ `${repairing ? "Repairing" : "Updating"} ${plugin.name} (${i + 1}/${updatable.length})…\nclaude plugin install ${plugin.id} --scope ${scope}`,
746
765
  );
747
- await cliUpdatePlugin(plugin.id, scope);
766
+ if (repairing) {
767
+ await cliRepairPlugin(plugin.id, scope, state.projectPath);
768
+ } else {
769
+ await cliUpdatePlugin(plugin.id, scope);
770
+ }
748
771
  if (plugin.version) {
749
772
  await saveVersionForScope(plugin.id, plugin.version, scope);
750
773
  }
@@ -814,23 +837,12 @@ export function PluginsScreen() {
814
837
  : scope === "project"
815
838
  ? plugin.projectScope
816
839
  : plugin.localScope;
817
- const isInstalledInScope = scopeData?.enabled;
818
- const installedVersion = scopeData?.version;
819
-
820
- const hasUpdateInScope =
821
- isInstalledInScope &&
822
- installedVersion &&
823
- latestVersion !== "0.0.0" &&
824
- installedVersion !== latestVersion;
825
-
826
- let action: "update" | "install" | "uninstall";
827
- if (isInstalledInScope && hasUpdateInScope) {
828
- action = "update";
829
- } else if (isInstalledInScope) {
830
- action = "uninstall";
831
- } else {
832
- action = "install";
833
- }
840
+
841
+ // This used to key off `scopeData.enabled` alone, so a plugin the list
842
+ // had just labelled "not installed" answered its own scope key by
843
+ // uninstalling itself — the `enabledPlugins` flag was set, the registry
844
+ // entry was missing, and only the flag was consulted.
845
+ const action = resolveScopeAction(scopeData, latestVersion);
834
846
 
835
847
  try {
836
848
  if (action === "uninstall") {
@@ -872,19 +884,62 @@ export function PluginsScreen() {
872
884
  if (!item || item.kind !== "plugin" || !item.plugin.isOrphaned) return;
873
885
 
874
886
  const plugin = item.plugin;
887
+
888
+ // A plugin that moved marketplace is not deprecated — it is still
889
+ // published under a new namespace. Offering only "remove" silently drops
890
+ // something the user still wants (the magus → magus-marketing split left
891
+ // seo, instantly, video-editing and image-generate in this state).
892
+ let migrateTo: string | undefined;
893
+ if (plugin.movedTo) {
894
+ const choice = await modal.select(
895
+ "Plugin moved",
896
+ `${plugin.name} is no longer published by "${plugin.marketplace}", but "${plugin.movedTo}" still publishes it.`,
897
+ [
898
+ {
899
+ label: `Migrate to ${plugin.name}@${plugin.movedTo}`,
900
+ value: "migrate",
901
+ },
902
+ { label: "Remove it entirely", value: "remove" },
903
+ ],
904
+ );
905
+ if (choice === null) return;
906
+ if (choice === "migrate") migrateTo = plugin.movedTo;
907
+ }
908
+
875
909
  try {
876
910
  // Remove from all scopes — try all to clean up stale references
877
911
  const scopes: PluginScope[] = ["user", "project", "local"];
912
+ const removedFrom: PluginScope[] = [];
878
913
  for (const scope of scopes) {
879
914
  try {
880
915
  modal.loading(
881
916
  `Removing ${plugin.name} from ${scope}…\nclaude plugin uninstall ${plugin.id} --scope ${scope}`,
882
917
  );
918
+ const wasInstalled =
919
+ scope === "user"
920
+ ? plugin.userScope?.enabled
921
+ : scope === "project"
922
+ ? plugin.projectScope?.enabled
923
+ : plugin.localScope?.enabled;
883
924
  await cliUninstallPlugin(plugin.id, scope, state.projectPath);
925
+ if (wasInstalled) removedFrom.push(scope);
884
926
  } catch {
885
927
  // Ignore errors for scopes where it doesn't exist
886
928
  }
887
929
  }
930
+
931
+ if (migrateTo) {
932
+ // Reinstall under the new namespace in every scope it occupied, so
933
+ // the migration does not quietly change where the plugin is enabled.
934
+ const newId = `${plugin.name}@${migrateTo}`;
935
+ for (const scope of removedFrom.length > 0 ? removedFrom : ["project" as PluginScope]) {
936
+ modal.loading(
937
+ `Installing ${newId} (${scope})…\nclaude plugin install ${newId} --scope ${scope}`,
938
+ );
939
+ await cliInstallPlugin(newId, scope);
940
+ }
941
+ }
942
+
888
943
  modal.hideModal();
889
944
  fetchData();
890
945
  } catch (error) {
@@ -986,7 +1041,7 @@ export function PluginsScreen() {
986
1041
  const installedCount = plugins.filter((p) => p.enabled).length;
987
1042
  const updateCount = plugins.filter(
988
1043
  (p) =>
989
- p.hasUpdate &&
1044
+ (p.hasUpdate || p.contentStale) &&
990
1045
  (p.userScope?.enabled ||
991
1046
  p.projectScope?.enabled ||
992
1047
  p.localScope?.enabled),