claudeup 4.30.0 → 4.32.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.
@@ -302,7 +302,23 @@ async function overlayRegistryVersions(
302
302
  const merged = { ...base };
303
303
  for (const [pluginId, entries] of Object.entries(registry.plugins ?? {})) {
304
304
  const pick = pickRegistryEntry(entries, scope, projectPath);
305
- if (pick?.version) merged[pluginId] = pick.version;
305
+ if (pick?.version) {
306
+ merged[pluginId] = pick.version;
307
+ continue;
308
+ }
309
+
310
+ // The registry knows this plugin but has no entry for this scope, so it
311
+ // is NOT installed here. Any leftover `installedPluginVersions` value is
312
+ // a claim about an install that does not exist.
313
+ //
314
+ // Claude Code never writes that field — it appears 0 times in the 2.1.223
315
+ // binary, and `claude plugin install` writes it at no scope — so only
316
+ // claudeup's own past writes can be in there, and they go stale silently.
317
+ // Measured: user settings claimed browser-use 1.1.2 / terminal 4.0.2 /
318
+ // code-analysis 5.1.0 while the registry said 1.4.0 / 4.1.4 / 5.3.1, and
319
+ // none of the claimed versions had a cache directory. Keeping the stale
320
+ // value made every one of them read as a permanent pending update.
321
+ if (entries && entries.length > 0) delete merged[pluginId];
306
322
  }
307
323
  return merged;
308
324
  }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * content-drift.ts — detect a plugin whose files changed without its version.
3
+ *
4
+ * The whole update system keys off one signal: the version string.
5
+ * `hasUpdate` is `compareVersions(catalogVersion, installedVersion) > 0`, so a
6
+ * marketplace that republishes different content under an unchanged version is
7
+ * invisible. No update is offered, and the only cure is a reinstall — which is
8
+ * exactly the "plugin says installed but its skills are missing" report.
9
+ *
10
+ * This is not a hypothetical. `publish-dist.sh` force-pushes a rebuilt tree, so
11
+ * same-version-different-content is a normal outcome of the release process.
12
+ * Measured on 2026-08-06: `cache/magus/dev/3.0.1/skills` held
13
+ * {audit, tui-progress} while `marketplaces/magus/plugins/dev/skills` held
14
+ * {security-audit} — both stamped 3.0.1.
15
+ *
16
+ * The fix uses a signal already present in `installed_plugins.json`: each entry
17
+ * records `gitCommitSha`, the marketplace commit it was installed from. Asking
18
+ * git whether THIS PLUGIN'S SUBTREE changed between that commit and the
19
+ * marketplace's current HEAD answers the question exactly.
20
+ *
21
+ * Comparing whole-marketplace SHAs would be wrong: any commit to any plugin
22
+ * would flag every plugin as drifted. The subtree scope is what makes this
23
+ * precise enough to act on.
24
+ */
25
+
26
+ import { spawn } from "node:child_process";
27
+ import os from "node:os";
28
+ import path from "node:path";
29
+
30
+ /**
31
+ * Resolved per call, honouring CLAUDE_CONFIG_DIR — the same override Claude
32
+ * Code uses. A module-level constant would bake in `os.homedir()` at import
33
+ * time, which on macOS ignores $HOME and makes the service impossible to test
34
+ * without touching the operator's real marketplaces.
35
+ */
36
+ function marketplacesDir(): string {
37
+ const configDir =
38
+ process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
39
+ return path.join(configDir, "plugins", "marketplaces");
40
+ }
41
+
42
+ /**
43
+ * Session cache. Keyed by marketplace + installed sha + plugin, because the
44
+ * answer cannot change while claudeup is open — the marketplace clone is only
45
+ * refreshed by Claude Code, out of process.
46
+ */
47
+ const cache = new Map<string, boolean>();
48
+
49
+ export function clearContentDriftCache(): void {
50
+ cache.clear();
51
+ }
52
+
53
+ function git(cwd: string, args: string[]): Promise<{ code: number; out: string }> {
54
+ return new Promise((resolve) => {
55
+ const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] });
56
+ let out = "";
57
+ child.stdout.on("data", (d) => {
58
+ out += String(d);
59
+ });
60
+ child.on("error", () => resolve({ code: -1, out: "" }));
61
+ child.on("close", (code) => resolve({ code: code ?? -1, out: out.trim() }));
62
+ // Never let a wedged git block the UI.
63
+ setTimeout(() => {
64
+ child.kill();
65
+ resolve({ code: -1, out: "" });
66
+ }, 5000).unref?.();
67
+ });
68
+ }
69
+
70
+ export interface DriftQuery {
71
+ marketplace: string;
72
+ /** Plugin name without the @marketplace suffix. */
73
+ pluginName: string;
74
+ /** `gitCommitSha` from the installed_plugins.json entry. */
75
+ installedSha: string | undefined;
76
+ /**
77
+ * Path of the plugin inside the marketplace repo, from the catalog `source`
78
+ * field (e.g. "./plugins/dev"). Defaults to `plugins/<name>`.
79
+ */
80
+ sourcePath?: string;
81
+ }
82
+
83
+ /**
84
+ * True when the plugin's files in the marketplace differ from the commit it was
85
+ * installed from — i.e. a reinstall would deliver different content.
86
+ *
87
+ * Returns false whenever the question cannot be answered honestly: no recorded
88
+ * sha, no clone, git unavailable, or the recorded commit is absent locally
89
+ * (force-push, shallow clone). A false negative leaves today's behaviour; a
90
+ * false positive would nag the user to reinstall for no reason.
91
+ */
92
+ export async function hasContentDrift(q: DriftQuery): Promise<boolean> {
93
+ if (!q.installedSha) return false;
94
+
95
+ const key = `${q.marketplace}\0${q.pluginName}\0${q.installedSha}`;
96
+ const hit = cache.get(key);
97
+ if (hit !== undefined) return hit;
98
+
99
+ const repo = path.join(marketplacesDir(), q.marketplace);
100
+ const rel = (q.sourcePath ?? `plugins/${q.pluginName}`).replace(/^\.\//, "");
101
+
102
+ // The recorded commit must exist locally, or the diff is meaningless.
103
+ const known = await git(repo, ["cat-file", "-e", `${q.installedSha}^{commit}`]);
104
+ if (known.code !== 0) {
105
+ cache.set(key, false);
106
+ return false;
107
+ }
108
+
109
+ // Exit 0 = no difference, 1 = differs, anything else = could not tell.
110
+ const diff = await git(repo, [
111
+ "diff",
112
+ "--quiet",
113
+ q.installedSha,
114
+ "HEAD",
115
+ "--",
116
+ rel,
117
+ ]);
118
+ const drifted = diff.code === 1;
119
+ cache.set(key, drifted);
120
+ return drifted;
121
+ }
@@ -34,8 +34,19 @@ export interface RefreshResult {
34
34
  refreshed: string[];
35
35
  /** Pull errored (network, diverged, timeout) — clone left intact and stale. */
36
36
  failed: string[];
37
- /** Nothing to do: no clone on disk, not a git repo, dirty tree, or opted out. */
37
+ /** Nothing to do: no clone on disk, not a git repo, or dirty tree. */
38
38
  skipped: string[];
39
+ /**
40
+ * Skipped specifically because `autoUpdate: false` in known_marketplaces.json.
41
+ *
42
+ * Called out separately from `skipped` because the consequence is invisible
43
+ * and open-ended: the clone is the catalog, so a marketplace opted out here
44
+ * never learns about new plugin versions, and every plugin from it reads as
45
+ * up to date forever. The reporter's `magus` clone sat 11 days behind this
46
+ * way — Claude Code would not refresh it, and this function skipped it too,
47
+ * with nothing printed either time.
48
+ */
49
+ autoUpdateDisabled: string[];
39
50
  }
40
51
 
41
52
  function cloneDir(name: string): string {
@@ -136,6 +147,7 @@ export async function refreshRegisteredMarketplaces(
136
147
  const refreshed: string[] = [];
137
148
  const failed: string[] = [];
138
149
  const skipped: string[] = [];
150
+ const autoUpdateDisabled: string[] = [];
139
151
 
140
152
  const configured = await getConfiguredMarketplaces();
141
153
  const skipSet = new Set(skip);
@@ -149,7 +161,7 @@ export async function refreshRegisteredMarketplaces(
149
161
  continue;
150
162
  }
151
163
  if ((await getMarketplaceAutoUpdate(name)) === false) {
152
- skipped.push(name);
164
+ autoUpdateDisabled.push(name);
153
165
  continue;
154
166
  }
155
167
  eligible.push(name);
@@ -169,5 +181,5 @@ export async function refreshRegisteredMarketplaces(
169
181
  else skipped.push(name); // absent | skipped
170
182
  });
171
183
 
172
- return { refreshed, failed, skipped };
184
+ return { refreshed, failed, skipped, autoUpdateDisabled };
173
185
  }
@@ -12,11 +12,17 @@ import {
12
12
  getLocalEnabledPlugins,
13
13
  getLocalInstalledPluginVersions,
14
14
  getProjectInstalledPluginVersions,
15
+ pickRegistryEntry,
16
+ readInstalledPluginsRegistry,
15
17
  updateInstalledPluginsRegistry,
16
18
  removeFromInstalledPluginsRegistry,
17
19
  } from "./claude-settings.js";
20
+ import { hasContentDrift } from "./content-drift.js";
18
21
  import { defaultMarketplaces } from "../data/marketplaces.js";
19
- import type { PluginRelease } from "../types/index.js";
22
+ import type {
23
+ InstalledPluginsRegistry,
24
+ PluginRelease,
25
+ } from "../types/index.js";
20
26
  import {
21
27
  scanLocalMarketplaces,
22
28
  repairAllMarketplaces,
@@ -75,6 +81,23 @@ export interface PluginInfo {
75
81
  mcpServers?: string[];
76
82
  lspServers?: Record<string, unknown>;
77
83
  isOrphaned?: boolean;
84
+ /**
85
+ * Set when the installed version equals the catalog version, but the
86
+ * plugin's files in the marketplace have changed since the commit it was
87
+ * installed from. The version compare says "up to date" and is wrong: only
88
+ * a reinstall delivers the current content. See content-drift.ts.
89
+ */
90
+ contentStale?: boolean;
91
+ /**
92
+ * For an orphaned plugin: another configured marketplace that publishes a
93
+ * plugin of the same name — i.e. it did not disappear, it moved.
94
+ *
95
+ * Splitting `magus` into `magus` + `magus-marketing` left seo, instantly,
96
+ * video-editing and image-generate installed under a namespace that no
97
+ * longer lists them. Rendered as bare "deprecated", the only offered action
98
+ * was deletion, which silently drops a plugin that is still published.
99
+ */
100
+ movedTo?: string;
78
101
  /**
79
102
  * Set when this plugin's installed version changed since claudeup last
80
103
  * rendered it — including updates made by Claude Code or the prerunner
@@ -282,6 +305,9 @@ export async function getAvailablePlugins(
282
305
  // Try to get plugin info from local marketplace cache (fallback)
283
306
  const localMp = localMarketplaces.get(mpName);
284
307
  const localPlugin = localMp?.plugins.find((p) => p.name === pluginName);
308
+ const movedTo = localPlugin
309
+ ? undefined
310
+ : findPluginInOtherMarketplace(pluginName, mpName, localMarketplaces);
285
311
 
286
312
  const latestVersion = localPlugin?.version || installedVersion || "unknown";
287
313
  const description = localPlugin?.description || "Installed plugin";
@@ -301,10 +327,12 @@ export async function getAvailablePlugins(
301
327
  installedVersion: installedVersion,
302
328
  hasUpdate,
303
329
  isOrphaned: true,
330
+ movedTo,
304
331
  ...scopeStatus,
305
332
  });
306
333
  }
307
334
 
335
+ await annotateContentDrift(plugins, "project", projectPath);
308
336
  return plugins;
309
337
  }
310
338
 
@@ -474,6 +502,9 @@ export async function getGlobalAvailablePlugins(): Promise<PluginInfo[]> {
474
502
  // Try to get plugin info from local marketplace cache (fallback)
475
503
  const localMp = localMarketplaces.get(mpName);
476
504
  const localPlugin = localMp?.plugins.find((p) => p.name === pluginName);
505
+ const movedTo = localPlugin
506
+ ? undefined
507
+ : findPluginInOtherMarketplace(pluginName, mpName, localMarketplaces);
477
508
 
478
509
  const latestVersion = localPlugin?.version || installedVersion || "unknown";
479
510
  const description = localPlugin?.description || "Installed plugin";
@@ -494,14 +525,83 @@ export async function getGlobalAvailablePlugins(): Promise<PluginInfo[]> {
494
525
  installedVersion: installedVersion,
495
526
  hasUpdate,
496
527
  isOrphaned: true,
528
+ movedTo,
497
529
  });
498
530
  }
499
531
 
532
+ await annotateContentDrift(plugins, "user");
500
533
  return plugins;
501
534
  }
502
535
 
536
+ /**
537
+ * Find another configured marketplace that still publishes this plugin name.
538
+ *
539
+ * Answers "did it disappear, or did it move?" for an orphaned plugin, so the
540
+ * UI can offer migration rather than only deletion.
541
+ */
542
+ function findPluginInOtherMarketplace(
543
+ pluginName: string,
544
+ currentMarketplace: string,
545
+ localMarketplaces: Map<string, LocalMarketplace>,
546
+ ): string | undefined {
547
+ for (const [name, mp] of localMarketplaces) {
548
+ if (name === currentMarketplace) continue;
549
+ if (mp.plugins.some((p) => p.name === pluginName)) return name;
550
+ }
551
+ return undefined;
552
+ }
553
+
554
+ /**
555
+ * Flag plugins whose files moved without their version moving.
556
+ *
557
+ * Runs after the plugin list is assembled so every code path that builds a
558
+ * PluginInfo gets the same treatment. Only plugins that already look "up to
559
+ * date" are worth checking — anything with a pending version bump will be
560
+ * reinstalled by that update anyway.
561
+ *
562
+ * Best-effort throughout: a plugin whose drift cannot be determined is left
563
+ * exactly as it is today.
564
+ */
565
+ async function annotateContentDrift(
566
+ plugins: PluginInfo[],
567
+ scope: "user" | "project" | "local",
568
+ projectPath?: string,
569
+ ): Promise<void> {
570
+ let registry: InstalledPluginsRegistry;
571
+ try {
572
+ registry = await readInstalledPluginsRegistry();
573
+ } catch {
574
+ return;
575
+ }
576
+
577
+ await Promise.all(
578
+ plugins.map(async (plugin) => {
579
+ if (plugin.hasUpdate || plugin.isOrphaned) return;
580
+ if (!plugin.installedVersion || !plugin.version) return;
581
+ if (compareVersions(plugin.version, plugin.installedVersion) !== 0) return;
582
+
583
+ const entry = pickRegistryEntry(
584
+ registry.plugins[plugin.id],
585
+ scope,
586
+ projectPath,
587
+ );
588
+ if (!entry?.gitCommitSha) return;
589
+
590
+ try {
591
+ plugin.contentStale = await hasContentDrift({
592
+ marketplace: plugin.marketplace,
593
+ pluginName: plugin.name,
594
+ installedSha: entry.gitCommitSha,
595
+ });
596
+ } catch {
597
+ /* leave unflagged */
598
+ }
599
+ }),
600
+ );
601
+ }
602
+
503
603
  // Simple version comparison (returns 1 if a > b, -1 if a < b, 0 if equal)
504
- function compareVersions(
604
+ export function compareVersions(
505
605
  a: string | null | undefined,
506
606
  b: string | null | undefined,
507
607
  ): number {
@@ -21,11 +21,26 @@ import { profileDir } from "./symlink-manager.js";
21
21
  * enabled, merged with the profile's declared settings. The profile's settings
22
22
  * win over the derived enabledPlugins only if it explicitly sets that key
23
23
  * (it normally won't).
24
+ *
25
+ * `allPluginIds` is the manifest-wide union of plugins across every profile.
26
+ * Plugins in the union that this profile does NOT want are written as an
27
+ * explicit `false`, which is what makes `profile switch` exclusive rather than
28
+ * additive. Claude Code resolves enabledPlugins **per plugin id**, walking from
29
+ * the highest-precedence scope down and taking the first scope that mentions
30
+ * the id — an id a scope omits falls through to the next one. Since
31
+ * `claudeup install` installs the union at user scope, every non-member would
32
+ * otherwise inherit that user-scope `true` and stay enabled while a profile
33
+ * that excludes it is active. Omitting the union (the default) preserves the
34
+ * old additive behavior for callers that have no cross-profile view.
24
35
  */
25
36
  export function buildProfileSettings(
26
37
  closure: ResolvedClosure,
38
+ allPluginIds: readonly string[] = [],
27
39
  ): Record<string, unknown> {
28
40
  const enabledPlugins: Record<string, boolean> = {};
41
+ for (const pluginId of allPluginIds) {
42
+ enabledPlugins[pluginId] = false;
43
+ }
29
44
  for (const pluginId of Object.keys(closure.plugins)) {
30
45
  enabledPlugins[pluginId] = true;
31
46
  }
@@ -39,6 +54,37 @@ export function buildProfileMcp(
39
54
  return { mcpServers: { ...closure.mcpServers } };
40
55
  }
41
56
 
57
+ /**
58
+ * Seed a profile's skills/ dir from the project's pre-existing `.claude/skills/`.
59
+ *
60
+ * Activating a profile replaces `.claude/skills` with a symlink into the profile
61
+ * dir, and replacing means `fs.remove` first. A project that committed its own
62
+ * skills before adopting profiles would lose them at that moment. So the first
63
+ * time a profile's skills/ dir is created, any real (non-symlink) project skills
64
+ * are copied in — every profile inherits what the project already had, and the
65
+ * link swap destroys nothing.
66
+ *
67
+ * Only runs when the profile's skills/ dir does not exist yet, so it never
68
+ * fights a later `skills-manager` install or re-adds a skill the user removed.
69
+ */
70
+ async function seedSkillsFromProject(
71
+ skillsDir: string,
72
+ projectPath?: string,
73
+ ): Promise<void> {
74
+ if (await fs.pathExists(skillsDir)) return;
75
+
76
+ const projectSkills = path.join(projectPath ?? process.cwd(), ".claude", "skills");
77
+ try {
78
+ // lstat, not pathExists: an existing profile symlink is not a source.
79
+ const stat = await fs.lstat(projectSkills);
80
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return;
81
+ if ((await fs.readdir(projectSkills)).length === 0) return;
82
+ await fs.copy(projectSkills, skillsDir, { dereference: true });
83
+ } catch {
84
+ // No project skills dir (or unreadable) — nothing to carry over.
85
+ }
86
+ }
87
+
42
88
  /**
43
89
  * Write `_profiles/<name>/` from a resolved closure. Idempotent: overwrites
44
90
  * settings.json / mcp.json and ensures skills/ exists. Returns the dir path.
@@ -47,15 +93,20 @@ export async function materializeProfile(
47
93
  name: string,
48
94
  closure: ResolvedClosure,
49
95
  projectPath?: string,
96
+ allPluginIds: readonly string[] = [],
50
97
  ): Promise<string> {
51
98
  const dir = profileDir(name, projectPath);
52
99
  await fs.ensureDir(dir);
53
- await fs.writeJson(path.join(dir, "settings.json"), buildProfileSettings(closure), {
54
- spaces: 2,
55
- });
100
+ await fs.writeJson(
101
+ path.join(dir, "settings.json"),
102
+ buildProfileSettings(closure, allPluginIds),
103
+ { spaces: 2 },
104
+ );
56
105
  await fs.writeJson(path.join(dir, "mcp.json"), buildProfileMcp(closure), {
57
106
  spaces: 2,
58
107
  });
59
- await fs.ensureDir(path.join(dir, "skills"));
108
+ const skillsDir = path.join(dir, "skills");
109
+ await seedSkillsFromProject(skillsDir, projectPath);
110
+ await fs.ensureDir(skillsDir);
60
111
  return dir;
61
112
  }
@@ -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
  };
@@ -136,10 +136,20 @@ function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode
136
136
 
137
137
  let versionStr = "";
138
138
  if (plugin.isOrphaned) {
139
- versionStr = " deprecated";
139
+ // "deprecated" alone reads as "delete me" — but a plugin that merely moved
140
+ // marketplace is still published, and deleting it loses it for no reason.
141
+ versionStr = plugin.movedTo
142
+ ? ` moved → ${plugin.movedTo}`
143
+ : " deprecated";
140
144
  } else if (notInstalled) {
141
145
  versionStr = " not installed";
142
146
  } else if (hasAnyScope) {
147
+ // "what just changed" and "what is still available" are independent facts,
148
+ // and both can be true at once: the prerunner can move a plugin 2.9.0→3.0.1
149
+ // while the marketplace already offers 3.3.1. These used to share one
150
+ // if/else chain, so the "updated" badge silently swallowed the pending
151
+ // update — and the update only reappeared on the next refresh, which read
152
+ // as the state spontaneously changing. Render them as separate clauses.
143
153
  if (plugin.recentlyUpdatedFrom && shownVersion) {
144
154
  // Show what actually happened, not just where it landed — the update may
145
155
  // have come from Claude Code or the prerunner without ever telling us.
@@ -150,9 +160,15 @@ function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode
150
160
  versionStr = shownVersion ? ` v${shownVersion} new` : " new";
151
161
  } else if (shownVersion) {
152
162
  versionStr = ` v${shownVersion}`;
153
- if (plugin.hasUpdate && plugin.version) {
154
- versionStr += ` → v${plugin.version}`;
155
- }
163
+ }
164
+
165
+ // Pending update, regardless of which badge above applied.
166
+ if (plugin.hasUpdate && plugin.version && versionStr) {
167
+ versionStr += ` → v${plugin.version} available`;
168
+ } else if (plugin.contentStale && versionStr) {
169
+ // Same version, different files. The version compare says "up to date"
170
+ // and is wrong — only a reinstall delivers the current content.
171
+ versionStr += " stale — reinstall";
156
172
  }
157
173
  }
158
174