claudeup 6.5.1 → 6.6.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.
Files changed (78) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/active-closure.test.ts +184 -0
  3. package/src/__tests__/blackbox-active-closure.test.ts +119 -0
  4. package/src/__tests__/blackbox-convergence.test.ts +334 -0
  5. package/src/__tests__/blackbox-parity.test.ts +684 -0
  6. package/src/__tests__/blackbox-plugin-state.test.ts +523 -0
  7. package/src/__tests__/blackbox-tool-state.test.ts +669 -0
  8. package/src/__tests__/cli-apply-seams.test.ts +2 -0
  9. package/src/__tests__/cli-tool-commands.test.ts +1 -1
  10. package/src/__tests__/cli-tools-adapter.test.ts +294 -0
  11. package/src/__tests__/cli-update-view.test.ts +142 -2
  12. package/src/__tests__/content-drift.test.ts +60 -0
  13. package/src/__tests__/enabled-not-installed.test.ts +1 -2
  14. package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
  15. package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
  16. package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
  17. package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
  18. package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
  19. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
  20. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
  21. package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
  22. package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
  23. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
  24. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
  25. package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
  26. package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
  27. package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
  28. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
  29. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
  30. package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
  31. package/src/__tests__/{gitignore-prerun.test.ts → gitignore-check.test.ts} +8 -6
  32. package/src/__tests__/plugin-cli-argv.test.ts +1 -1
  33. package/src/__tests__/plugin-row-text.test.ts +336 -0
  34. package/src/__tests__/scope-action.test.ts +69 -19
  35. package/src/__tests__/shell-script-callers.test.ts +8 -8
  36. package/src/__tests__/tool-probe.test.ts +1400 -0
  37. package/src/__tests__/update-apply.test.ts +283 -1
  38. package/src/__tests__/update-plan.test.ts +773 -31
  39. package/src/__tests__/version-unknown-state.test.ts +388 -0
  40. package/src/cli/router.ts +0 -5
  41. package/src/cli/update-view.ts +92 -12
  42. package/src/cli/update.ts +114 -38
  43. package/src/services/active-closure.ts +117 -0
  44. package/src/services/catalog-cache-store.ts +6 -2
  45. package/src/services/claude-settings.ts +1 -173
  46. package/src/services/{gitignore-prerun.ts → gitignore-check.ts} +14 -10
  47. package/src/services/gitignore-fixer.ts +1 -1
  48. package/src/services/marketplace-refresh.ts +30 -0
  49. package/src/services/plugin-manager.ts +200 -283
  50. package/src/services/resolver.ts +7 -2
  51. package/src/services/tool-probe.ts +1637 -0
  52. package/src/services/toolchain.ts +97 -0
  53. package/src/services/update-engine.ts +220 -32
  54. package/src/services/update-plan.ts +640 -160
  55. package/src/services/version-snapshot.ts +1 -1
  56. package/src/types/bun.d.ts +6 -0
  57. package/src/ui/App.tsx +2 -27
  58. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  59. package/src/ui/adapters/pluginsAdapter.ts +57 -0
  60. package/src/ui/components/modals/ModalContainer.tsx +0 -26
  61. package/src/ui/hooks/index.ts +0 -1
  62. package/src/ui/hooks/useGitignoreModal.ts +2 -3
  63. package/src/ui/renderers/cliToolRenderers.tsx +108 -67
  64. package/src/ui/renderers/pluginRenderers.tsx +157 -98
  65. package/src/ui/renderers/pluginRowText.ts +70 -0
  66. package/src/ui/renderers/pluginStatusText.ts +56 -0
  67. package/src/ui/screens/CliToolsScreen.tsx +154 -280
  68. package/src/ui/screens/PluginsScreen.tsx +156 -140
  69. package/src/ui/state/types.ts +0 -7
  70. package/src/__tests__/dual-write-prevention.test.ts +0 -380
  71. package/src/__tests__/gap-fill-versions.test.ts +0 -389
  72. package/src/__tests__/plugin-version-check.test.ts +0 -762
  73. package/src/cli/claude.ts +0 -21
  74. package/src/prerunner/index.ts +0 -500
  75. package/src/services/plugin-version-check.ts +0 -360
  76. package/src/services/update-cache.ts +0 -78
  77. package/src/ui/components/modals/VersionMismatchModal.tsx +0 -138
  78. package/src/ui/hooks/useMismatchModal.ts +0 -89
@@ -173,9 +173,27 @@ function tap(fn: (() => void) | undefined): void {
173
173
  * one unreachable marketplace must not block launching Claude. Pulls run
174
174
  * concurrently, so a slow one doesn't serialize the rest onto the launch path.
175
175
  */
176
+ /**
177
+ * Select and report, writing nothing.
178
+ *
179
+ * Everything above the pull loop is a read — the marketplace registry and each
180
+ * entry's `autoUpdate` flag — so a read-only caller can have the full selection
181
+ * verdict for free. `--dry-run` and `--check` promise to write nothing and
182
+ * therefore cannot pull, but they must still be able to say "your magus clone
183
+ * has autoUpdate off, so its catalog will never advance". Gating the whole call
184
+ * off silently dropped that warning, which is the same "no signal reads as no
185
+ * problem" failure the flag exists to prevent.
186
+ */
187
+ export async function reportRegisteredMarketplaces(
188
+ skip: Iterable<string> = [],
189
+ ): Promise<RefreshResult> {
190
+ return refreshRegisteredMarketplaces(skip, {}, { reportOnly: true });
191
+ }
192
+
176
193
  export async function refreshRegisteredMarketplaces(
177
194
  skip: Iterable<string> = [],
178
195
  progress: RefreshProgress = {},
196
+ options: { reportOnly?: boolean } = {},
179
197
  ): Promise<RefreshResult> {
180
198
  const refreshed: string[] = [];
181
199
  const failed: string[] = [];
@@ -200,6 +218,18 @@ export async function refreshRegisteredMarketplaces(
200
218
  eligible.push(name);
201
219
  }
202
220
 
221
+ // A read-only caller stops here, with the selection verdict intact and not
222
+ // one clone touched. `eligible` becomes `skipped`: nothing was refreshed, so
223
+ // claiming otherwise would be the false "all current" this module guards.
224
+ if (options.reportOnly) {
225
+ return {
226
+ refreshed: [],
227
+ failed: [],
228
+ skipped: [...skipped, ...eligible],
229
+ autoUpdateDisabled,
230
+ };
231
+ }
232
+
203
233
  // Fast-forward every eligible clone in parallel so a slow one doesn't
204
234
  // serialize the rest onto the launch path. fastForwardCloneIfPresent never
205
235
  // throws, so the batch always resolves.
@@ -72,6 +72,19 @@ export interface PluginInfo {
72
72
  marketplaceDisplay: string;
73
73
  enabled: boolean;
74
74
  installedVersion?: string;
75
+ /**
76
+ * Provably behind the catalog, on ONE version pair.
77
+ *
78
+ * @deprecated Derive plugin state from `classifyPlugin` (update-plan.ts),
79
+ * which answers per scope and distinguishes "cannot tell" from "current".
80
+ * Nothing in `src/` reads this any more — the content-drift gate was its
81
+ * last reader, and it now asks the version pair directly. It is still
82
+ * computed because `blackbox-parity.test.ts` reproduces the pre-classifier
83
+ * rule from it, which is what proves the fixture discriminates.
84
+ *
85
+ * Its sibling `updateUnknown` was written in six places and read in none,
86
+ * and is gone.
87
+ */
75
88
  hasUpdate?: boolean;
76
89
  // Per-scope installation status
77
90
  userScope?: ScopeStatus;
@@ -120,7 +133,7 @@ export interface PluginInfo {
120
133
  movedTo?: string;
121
134
  /**
122
135
  * Set when this plugin's installed version changed since claudeup last
123
- * rendered it — including updates made by Claude Code or the prerunner
136
+ * rendered it — including updates made by Claude Code or a direct CLI call
124
137
  * rather than by claudeup itself. Drives the "updated" badge.
125
138
  */
126
139
  recentlyUpdatedFrom?: string;
@@ -160,7 +173,7 @@ export interface PluginInfo {
160
173
  *
161
174
  * This is a broken state, not a healthy one: Claude Code will not load the
162
175
  * plugin, and because the update check is `installedVersion && compare(...)`,
163
- * it is also invisible to both the update list and the prerunner's auto-update.
176
+ * it is also invisible to every update path.
164
177
  * It previously rendered identically to a working install.
165
178
  *
166
179
  * `isOrphaned` is a different failure — installed, but no longer offered by any
@@ -196,7 +209,38 @@ export function isEnabledButNotInstalled(plugin: PluginInfo): boolean {
196
209
  * record exactly that — which is why this tests presence, not `isKnownVersion`.
197
210
  */
198
211
  export function isInstalledInScope(scope: ScopeStatus | undefined): boolean {
199
- return !!scope?.enabled && !!scope.version;
212
+ // Presence of files, NOT permission to load them. `enabled` was in this
213
+ // test until it was measured: a plugin with a real registry row that the
214
+ // user had simply switched off reported "not installed", so the row offered
215
+ // to install what was already on disk. Installation is a registry fact;
216
+ // enablement is a separate one, and `isEnabledButNotInstalled` is where the
217
+ // broken combination of the two is named.
218
+ return !!scope?.version;
219
+ }
220
+
221
+ /**
222
+ * One scope's status, from the two facts that define it.
223
+ *
224
+ * Exported and taken as arguments so this is testable and has exactly one
225
+ * implementation. It previously lived inline in `getAvailablePlugins` and its
226
+ * global twin — two copies, and both keyed existence off the settings flag:
227
+ *
228
+ * enabledPlugins[id] !== undefined ? {...} : undefined
229
+ *
230
+ * so a plugin with a registry row but no settings key produced NO scope at all
231
+ * and rendered exactly like one that was never installed. Existence is now
232
+ * "either fact is present", and `enabled` defaults to false rather than
233
+ * deciding whether the scope exists.
234
+ */
235
+ export function buildScopeStatus(
236
+ enabledPlugins: Record<string, boolean>,
237
+ installedVersions: Record<string, string>,
238
+ pluginId: string,
239
+ ): ScopeStatus | undefined {
240
+ const enabled = enabledPlugins[pluginId];
241
+ const version = installedVersions[pluginId];
242
+ if (enabled === undefined && version === undefined) return undefined;
243
+ return { enabled: enabled === true, version };
200
244
  }
201
245
 
202
246
  /**
@@ -208,16 +252,31 @@ export function isInstalledInScope(scope: ScopeStatus | undefined): boolean {
208
252
  * `claude plugin install dev@magus --scope project` installed it normally and
209
253
  * wrote the registry entry. It does not report "already installed", so this
210
254
  * needs no uninstall-first dance (unlike content drift, see repairPlugin).
255
+ *
256
+ * `behind` is the answer to "is this scope provably behind?", and it comes from
257
+ * the plugin classifier (`scopeIsBehind` in update-plan.ts), never from a
258
+ * comparison made here. This used to take the catalog version and compare raw
259
+ * strings — a fourth "is it behind?" rule, which offered `update` for a scope
260
+ * AHEAD of the catalog or one whose version cannot be ordered at all, while the
261
+ * row beside it said something else.
211
262
  */
212
263
  export function resolveScopeAction(
213
264
  scope: ScopeStatus | undefined,
214
- latestVersion: string,
265
+ behind: boolean,
215
266
  ): "install" | "update" | "uninstall" {
216
- if (!isInstalledInScope(scope)) return "install";
217
- const installed = scope?.version;
218
- const hasUpdate =
219
- !!installed && latestVersion !== "0.0.0" && installed !== latestVersion;
220
- return hasUpdate ? "update" : "uninstall";
267
+ // `enabled` is deliberately part of THIS decision even though it is no longer
268
+ // part of `isInstalledInScope`. The two ask different questions:
269
+ // "is it on disk" (installation) versus "what should this key do next".
270
+ //
271
+ // Installed-but-disabled must NOT resolve to uninstall. The scope key runs
272
+ // `cliUninstallPlugin` with no confirmation, so that combination would delete
273
+ // a plugin the user had merely switched off — with the row beside it showing
274
+ // no scope square, which reads as "not installed" and invites the press.
275
+ // `install` re-installs and re-enables, which is what the key meant before
276
+ // installation and enablement were separated, and is the only non-destructive
277
+ // answer here.
278
+ if (!isInstalledInScope(scope) || !scope?.enabled) return "install";
279
+ return behind ? "update" : "uninstall";
221
280
  }
222
281
 
223
282
  export async function getAvailablePlugins(
@@ -241,28 +300,25 @@ export async function getAvailablePlugins(
241
300
  const seenPluginIds = new Set<string>();
242
301
 
243
302
  // Helper to build scope status
244
- const buildScopeStatus = (pluginId: string) => ({
245
- userScope:
246
- userEnabledPlugins[pluginId] !== undefined
247
- ? {
248
- enabled: userEnabledPlugins[pluginId],
249
- version: userInstalledVersions[pluginId],
250
- }
251
- : undefined,
252
- projectScope:
253
- projectEnabledPlugins[pluginId] !== undefined
254
- ? {
255
- enabled: projectEnabledPlugins[pluginId],
256
- version: projectInstalledVersions[pluginId],
257
- }
258
- : undefined,
259
- localScope:
260
- localEnabledPlugins[pluginId] !== undefined
261
- ? {
262
- enabled: localEnabledPlugins[pluginId],
263
- version: localInstalledVersions[pluginId],
264
- }
265
- : undefined,
303
+ // One implementation, shared with the global view and unit-tested. Both
304
+ // copies used to key scope existence off the settings flag, so an installed
305
+ // plugin with no settings key produced no scope at all.
306
+ const scopeStatusFor = (pluginId: string) => ({
307
+ userScope: buildScopeStatus(
308
+ userEnabledPlugins,
309
+ userInstalledVersions,
310
+ pluginId,
311
+ ),
312
+ projectScope: buildScopeStatus(
313
+ projectEnabledPlugins,
314
+ projectInstalledVersions,
315
+ pluginId,
316
+ ),
317
+ localScope: buildScopeStatus(
318
+ localEnabledPlugins,
319
+ localInstalledVersions,
320
+ pluginId,
321
+ ),
266
322
  });
267
323
 
268
324
  // Get all marketplace names (configured + official/featured defaults)
@@ -325,7 +381,7 @@ export async function getAvailablePlugins(
325
381
  const pluginId = `${plugin.name}@${mpName}`;
326
382
  const installedVersion = installedVersions[pluginId];
327
383
  const isEnabled = enabledPlugins[pluginId] === true;
328
- const scopeStatus = buildScopeStatus(pluginId);
384
+ const scopeStatus = scopeStatusFor(pluginId);
329
385
 
330
386
  seenPluginIds.add(pluginId);
331
387
  plugins.push({
@@ -337,10 +393,7 @@ export async function getAvailablePlugins(
337
393
  marketplaceDisplay: marketplace.displayName,
338
394
  enabled: isEnabled,
339
395
  installedVersion: installedVersion,
340
- hasUpdate:
341
- installedVersion && plugin.version
342
- ? compareVersions(plugin.version, installedVersion) > 0
343
- : false,
396
+ hasUpdate: isBehind(plugin.version, installedVersion) === true,
344
397
  catalogSource: resolution.source,
345
398
  updateCheckFailed: unverified,
346
399
  updateCheckFailure: resolution.failure,
@@ -367,7 +420,7 @@ export async function getAvailablePlugins(
367
420
 
368
421
  const installedVersion = installedVersions[pluginId];
369
422
  const isEnabled = enabledPlugins[pluginId] === true;
370
- const scopeStatus = buildScopeStatus(pluginId);
423
+ const scopeStatus = scopeStatusFor(pluginId);
371
424
 
372
425
  seenPluginIds.add(pluginId);
373
426
  plugins.push({
@@ -379,9 +432,7 @@ export async function getAvailablePlugins(
379
432
  marketplaceDisplay: localMp.name || formatMarketplaceName(mpName),
380
433
  enabled: isEnabled,
381
434
  installedVersion: installedVersion,
382
- hasUpdate: installedVersion
383
- ? compareVersions(localPlugin.version, installedVersion) > 0
384
- : false,
435
+ hasUpdate: isBehind(localPlugin.version, installedVersion) === true,
385
436
  ...scopeStatus,
386
437
  category: localPlugin.category,
387
438
  author: localPlugin.author,
@@ -411,7 +462,7 @@ export async function getAvailablePlugins(
411
462
  const { pluginName, marketplace: mpName } = parsed;
412
463
  const installedVersion = installedVersions[pluginId];
413
464
  const isEnabled = enabledPlugins[pluginId] === true;
414
- const scopeStatus = buildScopeStatus(pluginId);
465
+ const scopeStatus = scopeStatusFor(pluginId);
415
466
 
416
467
  // Try to get plugin info from local marketplace cache (fallback)
417
468
  const localMp = localMarketplaces.get(mpName);
@@ -422,10 +473,7 @@ export async function getAvailablePlugins(
422
473
 
423
474
  const latestVersion = localPlugin?.version || installedVersion || "unknown";
424
475
  const description = localPlugin?.description || "Installed plugin";
425
- const hasUpdate =
426
- installedVersion && localPlugin?.version
427
- ? compareVersions(localPlugin.version, installedVersion) > 0
428
- : false;
476
+ const hasUpdate = isBehind(localPlugin?.version, installedVersion) === true;
429
477
 
430
478
  plugins.push({
431
479
  id: pluginId,
@@ -447,230 +495,6 @@ export async function getAvailablePlugins(
447
495
  return plugins;
448
496
  }
449
497
 
450
- export async function getGlobalAvailablePlugins(): Promise<PluginInfo[]> {
451
- const configuredMarketplaces = await getGlobalConfiguredMarketplaces();
452
- const enabledPlugins = await getGlobalEnabledPlugins();
453
- const installedVersions = await getGlobalInstalledPluginVersions();
454
-
455
- // Fetch all scopes for per-scope status display
456
- const userEnabledPlugins = await getGlobalEnabledPlugins();
457
- const userInstalledVersions = await getGlobalInstalledPluginVersions();
458
- // Also fetch project and local scope for complete status display
459
- const projectEnabledPlugins = await getEnabledPlugins();
460
- const projectInstalledVersions = await getInstalledPluginVersions();
461
- const localEnabledPlugins = await getLocalEnabledPlugins();
462
- const localInstalledVersions = await getLocalInstalledPluginVersions();
463
-
464
- const plugins: PluginInfo[] = [];
465
- const seenPluginIds = new Set<string>();
466
-
467
- // Helper to build scope status (show all scopes)
468
- const buildScopeStatus = (pluginId: string) => ({
469
- userScope:
470
- userEnabledPlugins[pluginId] !== undefined
471
- ? {
472
- enabled: userEnabledPlugins[pluginId],
473
- version: userInstalledVersions[pluginId],
474
- }
475
- : undefined,
476
- projectScope:
477
- projectEnabledPlugins[pluginId] !== undefined
478
- ? {
479
- enabled: projectEnabledPlugins[pluginId],
480
- version: projectInstalledVersions[pluginId],
481
- }
482
- : undefined,
483
- localScope:
484
- localEnabledPlugins[pluginId] !== undefined
485
- ? {
486
- enabled: localEnabledPlugins[pluginId],
487
- version: localInstalledVersions[pluginId],
488
- }
489
- : undefined,
490
- });
491
-
492
- // Get all marketplace names (configured + official/featured defaults)
493
- // Always include official and featured marketplaces so users can browse them
494
- const marketplaceNames = new Set<string>();
495
- for (const mp of defaultMarketplaces) {
496
- if (
497
- configuredMarketplaces[mp.name] ||
498
- mp.official ||
499
- mp.featured ||
500
- mp.owned
501
- ) {
502
- marketplaceNames.add(mp.name);
503
- }
504
- }
505
-
506
- // Fetch local marketplace caches up front so we can detect stale caches
507
- let localMarketplaces = await getLocalMarketplaces();
508
-
509
- // Resolve every marketplace concurrently. These are independent HTTP calls
510
- // with a 10s timeout each; awaited in sequence, one unreachable marketplace
511
- // added its full timeout to the "Loading..." the user stares at. Measured on
512
- // six marketplaces with four unreachable: 44.9s sequential.
513
- const resolutions = await Promise.all(
514
- [...marketplaceNames].map(async (mpName) => {
515
- const marketplace = defaultMarketplaces.find((m) => m.name === mpName);
516
- if (!marketplace) return null;
517
- return {
518
- mpName,
519
- marketplace,
520
- resolution: await resolveMarketplacePlugins(
521
- mpName,
522
- marketplace.source.repo,
523
- localMarketplaces,
524
- ),
525
- };
526
- }),
527
- );
528
-
529
- for (const entry of resolutions) {
530
- if (!entry) continue;
531
- const { mpName, marketplace, resolution } = entry;
532
- const marketplacePlugins = resolution.plugins;
533
-
534
- // Auto-sync local cache if remote has plugins the local cache doesn't
535
- localMarketplaces = await autoSyncIfStale(
536
- mpName,
537
- marketplacePlugins.map((p) => p.name),
538
- localMarketplaces,
539
- );
540
-
541
- // The catalog that answered was not the authoritative one, so every
542
- // version compare below is a guess, not a check.
543
- // `remote-git` counts as verified: it is upstream's own catalog file, read
544
- // through a git remote ref rather than over HTTP. Only the pinned working
545
- // tree (`local-clone`) is untrustworthy for versions.
546
- const unverified = !isAuthoritative(resolution.source);
547
-
548
- for (const plugin of marketplacePlugins) {
549
- const pluginId = `${plugin.name}@${mpName}`;
550
- const installedVersion = installedVersions[pluginId];
551
- const isEnabled = enabledPlugins[pluginId] === true;
552
- const scopeStatus = buildScopeStatus(pluginId);
553
-
554
- seenPluginIds.add(pluginId);
555
- plugins.push({
556
- id: pluginId,
557
- name: plugin.name,
558
- version: plugin.version,
559
- description: plugin.description,
560
- marketplace: mpName,
561
- marketplaceDisplay: marketplace.displayName,
562
- enabled: isEnabled,
563
- installedVersion: installedVersion,
564
- hasUpdate:
565
- installedVersion && plugin.version
566
- ? compareVersions(plugin.version, installedVersion) > 0
567
- : false,
568
- catalogSource: resolution.source,
569
- updateCheckFailed: unverified,
570
- updateCheckFailure: resolution.failure,
571
- ...scopeStatus,
572
- category: plugin.category,
573
- author: plugin.author,
574
- homepage: plugin.homepage,
575
- tags: plugin.tags,
576
- releases: plugin.releases,
577
- source: plugin.source,
578
- sourceRepo: plugin.sourceRepo,
579
- });
580
- }
581
- }
582
-
583
- for (const [mpName, localMp] of localMarketplaces) {
584
- // Skip if already fetched from defaults
585
- if (marketplaceNames.has(mpName)) continue;
586
-
587
- // Add ALL plugins from this local marketplace cache
588
- for (const localPlugin of localMp.plugins) {
589
- const pluginId = `${localPlugin.name}@${mpName}`;
590
- if (seenPluginIds.has(pluginId)) continue;
591
-
592
- const installedVersion = installedVersions[pluginId];
593
- const isEnabled = enabledPlugins[pluginId] === true;
594
- const scopeStatus = buildScopeStatus(pluginId);
595
-
596
- seenPluginIds.add(pluginId);
597
- plugins.push({
598
- id: pluginId,
599
- name: localPlugin.name,
600
- version: localPlugin.version,
601
- description: localPlugin.description || "",
602
- marketplace: mpName,
603
- marketplaceDisplay: localMp.name || formatMarketplaceName(mpName),
604
- enabled: isEnabled,
605
- installedVersion: installedVersion,
606
- hasUpdate: installedVersion
607
- ? compareVersions(localPlugin.version, installedVersion) > 0
608
- : false,
609
- ...scopeStatus,
610
- category: localPlugin.category,
611
- author: localPlugin.author,
612
- agents: localPlugin.agents,
613
- commands: localPlugin.commands,
614
- skills: localPlugin.skills,
615
- source: localPlugin.source,
616
- mcpServers: localPlugin.mcpServers,
617
- lspServers: localPlugin.lspServers,
618
- releases: localPlugin.releases,
619
- });
620
- }
621
- }
622
-
623
- // Add orphaned plugins (enabled/installed but not in any cache)
624
- const allPluginIds = new Set([
625
- ...Object.keys(enabledPlugins),
626
- ...Object.keys(installedVersions),
627
- ]);
628
-
629
- for (const pluginId of allPluginIds) {
630
- if (seenPluginIds.has(pluginId)) continue;
631
-
632
- const parsed = parsePluginId(pluginId);
633
- if (!parsed) continue;
634
-
635
- const { pluginName, marketplace: mpName } = parsed;
636
- const installedVersion = installedVersions[pluginId];
637
- const isEnabled = enabledPlugins[pluginId] === true;
638
- const scopeStatus = buildScopeStatus(pluginId);
639
-
640
- // Try to get plugin info from local marketplace cache (fallback)
641
- const localMp = localMarketplaces.get(mpName);
642
- const localPlugin = localMp?.plugins.find((p) => p.name === pluginName);
643
- const movedTo = localPlugin
644
- ? undefined
645
- : findPluginInOtherMarketplace(pluginName, mpName, localMarketplaces);
646
-
647
- const latestVersion = localPlugin?.version || installedVersion || "unknown";
648
- const description = localPlugin?.description || "Installed plugin";
649
- const hasUpdate =
650
- installedVersion && localPlugin?.version
651
- ? compareVersions(localPlugin.version, installedVersion) > 0
652
- : false;
653
-
654
- plugins.push({
655
- id: pluginId,
656
- name: pluginName,
657
- version: latestVersion,
658
- description,
659
- marketplace: mpName,
660
- marketplaceDisplay: localMp?.name || formatMarketplaceName(mpName),
661
- enabled: isEnabled,
662
- ...scopeStatus,
663
- installedVersion: installedVersion,
664
- hasUpdate,
665
- isOrphaned: true,
666
- movedTo,
667
- });
668
- }
669
-
670
- await annotateContentDrift(plugins, "user");
671
- return plugins;
672
- }
673
-
674
498
  /**
675
499
  * Find another configured marketplace that still publishes this plugin name.
676
500
  *
@@ -689,13 +513,39 @@ function findPluginInOtherMarketplace(
689
513
  return undefined;
690
514
  }
691
515
 
516
+ /**
517
+ * Is this plugin one whose files could have moved while its version did not?
518
+ *
519
+ * Drift is defined as "same version, different files", so it is only a
520
+ * question at all when the catalog version and the installed version are
521
+ * PROVABLY the same — `compareVersions` returning exactly 0, never null, which
522
+ * would be "cannot tell". A pending version bump is not drift: `hasContentDrift`
523
+ * compares the installed commit's subtree against the marketplace clone's HEAD,
524
+ * and for a plugin that is simply behind, that subtree differs BECAUSE of the
525
+ * newer version. Flagging it would turn an `update` into a `repair`, and a
526
+ * repair reinstalls whatever the clone holds — past a pin.
527
+ *
528
+ * This used to open with `if (plugin.hasUpdate || …)`, the pre-classifier
529
+ * boolean. It decided nothing the version compare below does not (`isBehind`
530
+ * cannot be true for a pair that compares 0), and gating the input the shared
531
+ * classifier reads on the rule it replaced is how the two drift apart again.
532
+ * An orphan is still excluded: it has no marketplace entry to diff against.
533
+ *
534
+ * Exported to be tested directly; `annotateContentDrift` itself needs the real
535
+ * registry.
536
+ */
537
+ export function contentDriftCandidate(plugin: PluginInfo): boolean {
538
+ if (plugin.isOrphaned) return false;
539
+ if (!plugin.installedVersion || !plugin.version) return false;
540
+ return compareVersions(plugin.version, plugin.installedVersion) === 0;
541
+ }
542
+
692
543
  /**
693
544
  * Flag plugins whose files moved without their version moving.
694
545
  *
695
546
  * Runs after the plugin list is assembled so every code path that builds a
696
- * PluginInfo gets the same treatment. Only plugins that already look "up to
697
- * date" are worth checking — anything with a pending version bump will be
698
- * reinstalled by that update anyway.
547
+ * PluginInfo gets the same treatment. Which plugins are worth asking about is
548
+ * {@link contentDriftCandidate}.
699
549
  *
700
550
  * Best-effort throughout: a plugin whose drift cannot be determined is left
701
551
  * exactly as it is today.
@@ -721,10 +571,7 @@ async function annotateContentDrift(
721
571
 
722
572
  await Promise.all(
723
573
  plugins.map(async (plugin) => {
724
- if (plugin.hasUpdate || plugin.isOrphaned) return;
725
- if (!plugin.installedVersion || !plugin.version) return;
726
- if (compareVersions(plugin.version, plugin.installedVersion) !== 0)
727
- return;
574
+ if (!contentDriftCandidate(plugin)) return;
728
575
 
729
576
  const entry = pickRegistryEntry(
730
577
  registry.plugins[plugin.id],
@@ -748,24 +595,94 @@ async function annotateContentDrift(
748
595
  );
749
596
  }
750
597
 
751
- // Simple version comparison (returns 1 if a > b, -1 if a < b, 0 if equal)
598
+ /** A dotted run of digits, optionally `v`-prefixed. Anything else is opaque. */
599
+ const SEMVER_ISH = /^\d+(\.\d+)*$/;
600
+
601
+ const normalize = (v: string) => v.replace(/^v/, "");
602
+
603
+ /**
604
+ * Version comparison: 1 if a > b, -1 if a < b, 0 if equal, **null if the pair
605
+ * carries no ordering at all**.
606
+ *
607
+ * `null` is the whole point. This used to return 0 for every unorderable pair,
608
+ * and 0 means "equal" to every caller — so "I cannot tell" was indistinguishable
609
+ * from "they match". Measured against real registry values before the fix:
610
+ *
611
+ * compareVersions("517b2fcd1b60", "b819188d2eea") = 0 ← two DIFFERENT commits
612
+ * compareVersions("7.2.0", "517b2fcd1b60") = 1 ← a fabricated ordering
613
+ *
614
+ * Anthropic's official plugins record a commit sha as their version, so the
615
+ * first line made every one of them permanently un-updatable, and the second
616
+ * invented an update whenever a sha met a semver.
617
+ *
618
+ * Three cases, and only the first has an answer:
619
+ * - both dotted-numeric → compare numerically, shorter side zero-padded
620
+ * - both opaque → equal strings are 0; different strings are `null`,
621
+ * because two commit ids carry no order between them
622
+ * - one of each → `null`; there is no shared scale
623
+ *
624
+ * A missing side is `null` too. It used to be 0, which claimed an absent
625
+ * version matched a present one.
626
+ *
627
+ * Callers asking "should this be updated?" want {@link isBehind}, not this.
628
+ */
752
629
  export function compareVersions(
753
630
  a: string | null | undefined,
754
631
  b: string | null | undefined,
755
- ): number {
756
- if (!a || !b) return 0;
757
- const partsA = a.replace(/^v/, "").split(".").map(Number);
758
- const partsB = b.replace(/^v/, "").split(".").map(Number);
632
+ ): number | null {
633
+ if (!a || !b) return null;
634
+ const A = normalize(a);
635
+ const B = normalize(b);
759
636
 
637
+ const aNumeric = SEMVER_ISH.test(A);
638
+ const bNumeric = SEMVER_ISH.test(B);
639
+
640
+ if (aNumeric !== bNumeric) return null; // no shared scale
641
+ if (!aNumeric) return A === B ? 0 : null; // two opaque ids: equal, or unordered
642
+
643
+ const partsA = A.split(".").map(Number);
644
+ const partsB = B.split(".").map(Number);
760
645
  for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
761
- const numA = partsA[i] || 0;
762
- const numB = partsB[i] || 0;
646
+ const numA = partsA[i] ?? 0;
647
+ const numB = partsB[i] ?? 0;
763
648
  if (numA > numB) return 1;
764
649
  if (numA < numB) return -1;
765
650
  }
766
651
  return 0;
767
652
  }
768
653
 
654
+ /**
655
+ * Is `installed` out of date against what the catalog publishes as `latest`?
656
+ *
657
+ * `true` / `false` / `null`, where **null means unknown** — never a silent
658
+ * `false`. This is the question every update path actually asks, and keeping it
659
+ * separate from {@link compareVersions} is what lets the two opaque-id cases
660
+ * differ:
661
+ *
662
+ * - Ordering two commit shas is impossible, so `compareVersions` says `null`.
663
+ * - But the catalog is authoritative for what is published *now*, so an
664
+ * installed sha that differs from it is **not current** — `isBehind` says
665
+ * `true`. That is what makes a sha-versioned plugin updatable at all.
666
+ *
667
+ * Not installed is `null`, not `false`. The old inline check was
668
+ * `installedVersion && compare(...) > 0`, so a plugin with no install at all
669
+ * evaluated to `false` and was reported — and counted — as up to date.
670
+ */
671
+ export function isBehind(
672
+ latest: string | null | undefined,
673
+ installed: string | null | undefined,
674
+ ): boolean | null {
675
+ if (!latest || !installed) return null;
676
+ const cmp = compareVersions(latest, installed);
677
+ if (cmp !== null) return cmp > 0;
678
+
679
+ // Unorderable. Two opaque ids that differ still mean "not what is published".
680
+ const bothOpaque =
681
+ !SEMVER_ISH.test(normalize(latest)) &&
682
+ !SEMVER_ISH.test(normalize(installed));
683
+ return bothOpaque ? true : null;
684
+ }
685
+
769
686
  // Get installed plugin versions from settings.json (shared, not secret)
770
687
  async function getInstalledPluginVersions(
771
688
  projectPath?: string,
@@ -79,8 +79,13 @@ export function resolveExtends(
79
79
  };
80
80
  }
81
81
 
82
- /** Map a manifest cliTools entry (name -> version) to a BinRequirement. */
83
- function cliToolToBin(name: string, version: string): BinRequirement {
82
+ /**
83
+ * Map a manifest cliTools entry (name -> version) to a BinRequirement.
84
+ *
85
+ * Exported so the TUI's CLI Tools screen maps a catalogue tool the same way the
86
+ * resolver does, rather than keeping a second copy of the mapping.
87
+ */
88
+ export function cliToolToBin(name: string, version: string): BinRequirement {
84
89
  const tool = cliTools.find((t) => t.name === name);
85
90
  const pinned = version && version !== "latest" ? version : undefined;
86
91
  if (tool) {