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.
@@ -1,7 +1,12 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
3
  import fs from "fs-extra";
4
- import { isClaudeAvailable, updatePlugin } from "../services/claude-cli.js";
4
+ import {
5
+ isClaudeAvailable,
6
+ type PluginScope,
7
+ repairPlugin,
8
+ updatePlugin,
9
+ } from "../services/claude-cli.js";
5
10
  import { runClaude } from "../services/claude-runner.js";
6
11
  import {
7
12
  cleanupExtraKnownMarketplaces,
@@ -10,6 +15,7 @@ import {
10
15
  readGlobalSettings,
11
16
  recoverMarketplaceSettings,
12
17
  saveGlobalInstalledPluginVersion,
18
+ saveLocalInstalledPluginVersion,
13
19
  writeGlobalSettings,
14
20
  } from "../services/claude-settings.js";
15
21
  import {
@@ -18,9 +24,12 @@ import {
18
24
  } from "../services/gitignore-prerun.js";
19
25
  import { refreshRegisteredMarketplaces } from "../services/marketplace-refresh.js";
20
26
  import { autoAddMissingMarketplaces } from "../services/marketplace-sync.js";
27
+ import { clearContentDriftCache } from "../services/content-drift.js";
21
28
  import {
22
29
  clearMarketplaceCache,
30
+ compareVersions,
23
31
  getAvailablePlugins,
32
+ saveInstalledPluginVersion,
24
33
  } from "../services/plugin-manager.js";
25
34
  import {
26
35
  checkPluginVersionMismatches,
@@ -32,6 +41,27 @@ export interface PrerunOptions {
32
41
  force?: boolean; // Bypass cache and force update check
33
42
  }
34
43
 
44
+ /**
45
+ * Record an installed version in the settings file that owns the given scope.
46
+ *
47
+ * Claude Code's CLI does not maintain `installedPluginVersions`, so claudeup
48
+ * keeps its own copy. Writing it at the WRONG scope is worse than not writing
49
+ * it: it makes a scope claim a version it does not have installed.
50
+ */
51
+ async function saveInstalledPluginVersionForScope(
52
+ pluginId: string,
53
+ version: string,
54
+ scope: PluginScope,
55
+ ): Promise<void> {
56
+ if (scope === "user") {
57
+ await saveGlobalInstalledPluginVersion(pluginId, version);
58
+ } else if (scope === "local") {
59
+ await saveLocalInstalledPluginVersion(pluginId, version, process.cwd());
60
+ } else {
61
+ await saveInstalledPluginVersion(pluginId, version, process.cwd());
62
+ }
63
+ }
64
+
35
65
  const CONTINUITY_PLUGIN_SENTINEL = "tmux-claude-continuity";
36
66
  const CONTINUITY_PLUGIN_SCRIPT = path.join(
37
67
  os.homedir(),
@@ -290,12 +320,28 @@ export async function prerunClaude(
290
320
  `✓ Refreshed marketplaces: ${mpRefresh.refreshed.join(", ")}`,
291
321
  );
292
322
  }
323
+ if (mpRefresh.autoUpdateDisabled.length > 0) {
324
+ // Silence here is what let a clone sit 11 days behind: the catalog
325
+ // stops moving, so every plugin from that marketplace reads as up
326
+ // to date and no update is ever offered.
327
+ for (const name of mpRefresh.autoUpdateDisabled) {
328
+ console.log(
329
+ `⚠ ${name}: auto-update disabled — its plugin catalog will not refresh, so updates stay hidden.`,
330
+ );
331
+ }
332
+ console.log(
333
+ ` Re-enable with: claude plugin marketplace update ${mpRefresh.autoUpdateDisabled[0]} (or set autoUpdate:true in ~/.claude/plugins/known_marketplaces.json)`,
334
+ );
335
+ }
293
336
  for (const name of mpRefresh.failed) {
294
337
  console.warn(`⚠ Failed to refresh marketplace: ${name}`);
295
338
  }
296
339
 
297
340
  // STEP 2: Clear cache to force fresh plugin info
298
341
  clearMarketplaceCache();
342
+ // The marketplace refresh above may have moved each clone's HEAD, and
343
+ // drift answers are relative to HEAD.
344
+ clearContentDriftCache();
299
345
 
300
346
  // STEP 3: Get updated plugin info (to detect versions)
301
347
  const plugins = await getAvailablePlugins();
@@ -305,6 +351,13 @@ export async function prerunClaude(
305
351
  pluginId: string;
306
352
  oldVersion: string;
307
353
  newVersion: string;
354
+ /** Scopes actually updated — never assume "user". */
355
+ scopes: ReadonlyArray<PluginScope>;
356
+ }> = [];
357
+ /** Plugins whose files drifted under an unchanged version. */
358
+ const repairedPlugins: Array<{
359
+ pluginId: string;
360
+ scope: PluginScope;
308
361
  }> = [];
309
362
 
310
363
  const cliAvailable = await isClaudeAvailable();
@@ -314,6 +367,43 @@ export async function prerunClaude(
314
367
  // 1. Plugin is enabled
315
368
  // 2. Plugin has an update available
316
369
  // 3. Plugin has both installedVersion and version (newVersion)
370
+ // A content-stale plugin has NO version bump to detect — its files
371
+ // changed under an unchanged version — so it must be repaired
372
+ // (uninstall+install) rather than updated. Auto-fixing broken state
373
+ // without human interaction is claudeup's job; leaving a plugin
374
+ // silently running deleted skills is exactly the state it exists
375
+ // to prevent.
376
+ if (
377
+ plugin.enabled &&
378
+ !plugin.hasUpdate &&
379
+ plugin.contentStale &&
380
+ plugin.installedVersion
381
+ ) {
382
+ if (!cliAvailable) continue;
383
+ const staleScopes = (
384
+ [
385
+ ["user", plugin.userScope],
386
+ ["project", plugin.projectScope],
387
+ ["local", plugin.localScope],
388
+ ] as const
389
+ )
390
+ .filter(([, status]) => !!status?.version)
391
+ .map(([scope]) => scope);
392
+
393
+ for (const scope of staleScopes) {
394
+ try {
395
+ await repairPlugin(plugin.id, scope, process.cwd());
396
+ repairedPlugins.push({ pluginId: plugin.id, scope });
397
+ } catch (error) {
398
+ console.warn(
399
+ `⚠ Failed to repair ${plugin.id} (${scope}):`,
400
+ error instanceof Error ? error.message : "Unknown error",
401
+ );
402
+ }
403
+ }
404
+ continue;
405
+ }
406
+
317
407
  if (
318
408
  plugin.enabled &&
319
409
  plugin.hasUpdate &&
@@ -325,14 +415,51 @@ export async function prerunClaude(
325
415
  // CLI unavailable — skip update entirely, don't write phantom state
326
416
  continue;
327
417
  }
328
- await updatePlugin(plugin.id, "user");
329
- // CLI does NOT update installedPluginVersions save it ourselves
330
- await saveGlobalInstalledPluginVersion(plugin.id, plugin.version);
418
+
419
+ // Update every scope that actually holds an outdated install.
420
+ //
421
+ // This used to be a hardcoded `updatePlugin(plugin.id, "user")`,
422
+ // which was wrong for almost every real install: `hasUpdate` is
423
+ // computed from the CURRENT PROJECT's resolved version, while
424
+ // installs live per project in installed_plugins.json. On this
425
+ // machine 495 of 496 magus installs are project-scoped, so the
426
+ // prerunner detected project drift, "fixed" it at user scope,
427
+ // reported success — and left the project row untouched. Next run
428
+ // it detected the same drift again. The update never converged,
429
+ // which is what "auto-update ran but an update is still available"
430
+ // actually was.
431
+ const outdatedScopes = (
432
+ [
433
+ ["user", plugin.userScope],
434
+ ["project", plugin.projectScope],
435
+ ["local", plugin.localScope],
436
+ ] as const
437
+ )
438
+ .filter(
439
+ ([, status]) =>
440
+ !!status?.version &&
441
+ compareVersions(plugin.version, status.version) > 0,
442
+ )
443
+ .map(([scope]) => scope);
444
+
445
+ if (outdatedScopes.length === 0) continue;
446
+
447
+ for (const scope of outdatedScopes) {
448
+ await updatePlugin(plugin.id, scope);
449
+ // The CLI does not maintain installedPluginVersions, so keep
450
+ // claudeup's own copy in step for the scope we just touched.
451
+ await saveInstalledPluginVersionForScope(
452
+ plugin.id,
453
+ plugin.version,
454
+ scope,
455
+ );
456
+ }
331
457
 
332
458
  autoUpdatedPlugins.push({
333
459
  pluginId: plugin.id,
334
460
  oldVersion: plugin.installedVersion,
335
461
  newVersion: plugin.version,
462
+ scopes: outdatedScopes,
336
463
  });
337
464
  } catch (error) {
338
465
  // Non-fatal: Log warning and continue
@@ -357,8 +484,27 @@ export async function prerunClaude(
357
484
  // STEP 6: Display auto-update summary
358
485
  if (autoUpdatedPlugins.length > 0) {
359
486
  console.log(`✓ Auto-updated ${autoUpdatedPlugins.length} plugin(s):`);
360
- for (const { pluginId, oldVersion, newVersion } of autoUpdatedPlugins) {
361
- console.log(` - ${pluginId}: ${oldVersion} → ${newVersion}`);
487
+ for (const {
488
+ pluginId,
489
+ oldVersion,
490
+ newVersion,
491
+ scopes,
492
+ } of autoUpdatedPlugins) {
493
+ // Name the scope. "Auto-updated" that silently touched only user
494
+ // scope, while the project stayed behind, is exactly how this became
495
+ // untrustworthy.
496
+ console.log(
497
+ ` - ${pluginId}: ${oldVersion} → ${newVersion} (${scopes.join(", ")})`,
498
+ );
499
+ }
500
+ }
501
+
502
+ if (repairedPlugins.length > 0) {
503
+ console.log(
504
+ `✓ Repaired ${repairedPlugins.length} plugin(s) whose files changed without a version bump:`,
505
+ );
506
+ for (const { pluginId, scope } of repairedPlugins) {
507
+ console.log(` - ${pluginId} (${scope})`);
362
508
  }
363
509
  }
364
510
  }
@@ -160,6 +160,37 @@ export async function uninstallPlugin(
160
160
  }
161
161
  }
162
162
 
163
+ /**
164
+ * Repair a plugin whose files drifted from the marketplace under an unchanged
165
+ * version — uninstall, then install again at the same scope.
166
+ *
167
+ * `claude plugin install` cannot do this alone. Measured against the real
168
+ * `MadAppGang/magus` marketplace (autotest/plugin-system/probe-05): with the
169
+ * version unchanged it reports *"Plugin is already installed"* and copies
170
+ * nothing — cache content and the recorded `gitCommitSha` both stay put.
171
+ * Uninstall-then-install refreshes both. That is why the only cure users ever
172
+ * found was removing the plugin and adding it back.
173
+ *
174
+ * The window between the two calls is real: if the install fails, the plugin is
175
+ * left uninstalled. The caller must surface that rather than swallow it, which
176
+ * is why the install error is rethrown with the state spelled out.
177
+ */
178
+ export async function repairPlugin(
179
+ pluginId: string,
180
+ scope: PluginScope = "user",
181
+ projectPath?: string,
182
+ ): Promise<void> {
183
+ await uninstallPlugin(pluginId, scope, projectPath);
184
+ try {
185
+ await execClaude(["plugin", "install", pluginId, "--scope", scope], 60000);
186
+ } catch (error) {
187
+ const msg = error instanceof Error ? error.message : String(error);
188
+ throw new Error(
189
+ `${pluginId} was uninstalled from ${scope} scope but could not be reinstalled: ${msg}. Reinstall it manually with: claude plugin install ${pluginId} --scope ${scope}`,
190
+ );
191
+ }
192
+ }
193
+
163
194
  /**
164
195
  * Enable a previously disabled plugin
165
196
  */
@@ -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
@@ -112,6 +135,48 @@ export function isEnabledButNotInstalled(plugin: PluginInfo): boolean {
112
135
  return enabledSomewhere && !plugin.installedVersion;
113
136
  }
114
137
 
138
+ /**
139
+ * True when a scope genuinely has the plugin installed.
140
+ *
141
+ * `enabled` on its own is not proof. It is the `enabledPlugins` flag out of a
142
+ * settings file, and only claudeup and the user maintain it; `version` comes
143
+ * from `installed_plugins.json` via `overlayRegistryVersions`, which is what
144
+ * Claude Code actually loaded. When the two disagree — flag set, no registry
145
+ * entry — the plugin is in the broken enabled-but-not-installed state that the
146
+ * list already renders as "not installed".
147
+ *
148
+ * Every action that touches the filesystem must use this, not the flag alone.
149
+ * Deciding on the flag is what made the scope keys uninstall a plugin the row
150
+ * had just labelled "not installed".
151
+ *
152
+ * A version of "0.0.0" still counts as installed — Anthropic's official plugins
153
+ * record exactly that — which is why this tests presence, not `isKnownVersion`.
154
+ */
155
+ export function isInstalledInScope(scope: ScopeStatus | undefined): boolean {
156
+ return !!scope?.enabled && !!scope.version;
157
+ }
158
+
159
+ /**
160
+ * What a scope toggle (`u` / `p` / `l`, or the Enter scope picker) should do.
161
+ *
162
+ * `install` doubles as the repair for enabled-but-not-installed. Measured
163
+ * against Claude Code 2.1.223 in exactly that state — `dev@magus` flagged in
164
+ * `.claude/settings.json` with no registry entry for the project path —
165
+ * `claude plugin install dev@magus --scope project` installed it normally and
166
+ * wrote the registry entry. It does not report "already installed", so this
167
+ * needs no uninstall-first dance (unlike content drift, see repairPlugin).
168
+ */
169
+ export function resolveScopeAction(
170
+ scope: ScopeStatus | undefined,
171
+ latestVersion: string,
172
+ ): "install" | "update" | "uninstall" {
173
+ if (!isInstalledInScope(scope)) return "install";
174
+ const installed = scope?.version;
175
+ const hasUpdate =
176
+ !!installed && latestVersion !== "0.0.0" && installed !== latestVersion;
177
+ return hasUpdate ? "update" : "uninstall";
178
+ }
179
+
115
180
 
116
181
  export async function getAvailablePlugins(
117
182
  projectPath?: string,
@@ -282,6 +347,9 @@ export async function getAvailablePlugins(
282
347
  // Try to get plugin info from local marketplace cache (fallback)
283
348
  const localMp = localMarketplaces.get(mpName);
284
349
  const localPlugin = localMp?.plugins.find((p) => p.name === pluginName);
350
+ const movedTo = localPlugin
351
+ ? undefined
352
+ : findPluginInOtherMarketplace(pluginName, mpName, localMarketplaces);
285
353
 
286
354
  const latestVersion = localPlugin?.version || installedVersion || "unknown";
287
355
  const description = localPlugin?.description || "Installed plugin";
@@ -301,10 +369,12 @@ export async function getAvailablePlugins(
301
369
  installedVersion: installedVersion,
302
370
  hasUpdate,
303
371
  isOrphaned: true,
372
+ movedTo,
304
373
  ...scopeStatus,
305
374
  });
306
375
  }
307
376
 
377
+ await annotateContentDrift(plugins, "project", projectPath);
308
378
  return plugins;
309
379
  }
310
380
 
@@ -474,6 +544,9 @@ export async function getGlobalAvailablePlugins(): Promise<PluginInfo[]> {
474
544
  // Try to get plugin info from local marketplace cache (fallback)
475
545
  const localMp = localMarketplaces.get(mpName);
476
546
  const localPlugin = localMp?.plugins.find((p) => p.name === pluginName);
547
+ const movedTo = localPlugin
548
+ ? undefined
549
+ : findPluginInOtherMarketplace(pluginName, mpName, localMarketplaces);
477
550
 
478
551
  const latestVersion = localPlugin?.version || installedVersion || "unknown";
479
552
  const description = localPlugin?.description || "Installed plugin";
@@ -494,14 +567,83 @@ export async function getGlobalAvailablePlugins(): Promise<PluginInfo[]> {
494
567
  installedVersion: installedVersion,
495
568
  hasUpdate,
496
569
  isOrphaned: true,
570
+ movedTo,
497
571
  });
498
572
  }
499
573
 
574
+ await annotateContentDrift(plugins, "user");
500
575
  return plugins;
501
576
  }
502
577
 
578
+ /**
579
+ * Find another configured marketplace that still publishes this plugin name.
580
+ *
581
+ * Answers "did it disappear, or did it move?" for an orphaned plugin, so the
582
+ * UI can offer migration rather than only deletion.
583
+ */
584
+ function findPluginInOtherMarketplace(
585
+ pluginName: string,
586
+ currentMarketplace: string,
587
+ localMarketplaces: Map<string, LocalMarketplace>,
588
+ ): string | undefined {
589
+ for (const [name, mp] of localMarketplaces) {
590
+ if (name === currentMarketplace) continue;
591
+ if (mp.plugins.some((p) => p.name === pluginName)) return name;
592
+ }
593
+ return undefined;
594
+ }
595
+
596
+ /**
597
+ * Flag plugins whose files moved without their version moving.
598
+ *
599
+ * Runs after the plugin list is assembled so every code path that builds a
600
+ * PluginInfo gets the same treatment. Only plugins that already look "up to
601
+ * date" are worth checking — anything with a pending version bump will be
602
+ * reinstalled by that update anyway.
603
+ *
604
+ * Best-effort throughout: a plugin whose drift cannot be determined is left
605
+ * exactly as it is today.
606
+ */
607
+ async function annotateContentDrift(
608
+ plugins: PluginInfo[],
609
+ scope: "user" | "project" | "local",
610
+ projectPath?: string,
611
+ ): Promise<void> {
612
+ let registry: InstalledPluginsRegistry;
613
+ try {
614
+ registry = await readInstalledPluginsRegistry();
615
+ } catch {
616
+ return;
617
+ }
618
+
619
+ await Promise.all(
620
+ plugins.map(async (plugin) => {
621
+ if (plugin.hasUpdate || plugin.isOrphaned) return;
622
+ if (!plugin.installedVersion || !plugin.version) return;
623
+ if (compareVersions(plugin.version, plugin.installedVersion) !== 0) return;
624
+
625
+ const entry = pickRegistryEntry(
626
+ registry.plugins[plugin.id],
627
+ scope,
628
+ projectPath,
629
+ );
630
+ if (!entry?.gitCommitSha) return;
631
+
632
+ try {
633
+ plugin.contentStale = await hasContentDrift({
634
+ marketplace: plugin.marketplace,
635
+ pluginName: plugin.name,
636
+ installedSha: entry.gitCommitSha,
637
+ });
638
+ } catch {
639
+ /* leave unflagged */
640
+ }
641
+ }),
642
+ );
643
+ }
644
+
503
645
  // Simple version comparison (returns 1 if a > b, -1 if a < b, 0 if equal)
504
- function compareVersions(
646
+ export function compareVersions(
505
647
  a: string | null | undefined,
506
648
  b: string | null | undefined,
507
649
  ): number {