magus-cli 7.5.2 → 7.5.4

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 (68) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-convergence.test.ts +1 -2
  3. package/src/__tests__/blackbox-parity.test.ts +16 -14
  4. package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
  5. package/src/__tests__/cli-apply-seams.test.ts +3 -3
  6. package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
  7. package/src/__tests__/content-drift.test.ts +0 -1
  8. package/src/__tests__/files-missing-scope.test.ts +235 -0
  9. package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
  10. package/src/__tests__/format-update-outcome.test.ts +367 -0
  11. package/src/__tests__/gitignore-check.test.ts +11 -29
  12. package/src/__tests__/gitignore-profile.test.ts +248 -0
  13. package/src/__tests__/installed-version-scope.test.tsx +425 -0
  14. package/src/__tests__/mate-availability.test.ts +11 -13
  15. package/src/__tests__/models-cli.test.ts +11 -0
  16. package/src/__tests__/plugin-contents.test.ts +0 -1
  17. package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
  18. package/src/__tests__/plugin-row-text.test.ts +18 -21
  19. package/src/__tests__/profile-command.test.ts +2 -2
  20. package/src/__tests__/profile-migration-offer.test.ts +5 -4
  21. package/src/__tests__/profile-skills.test.ts +17 -22
  22. package/src/__tests__/profile-startup-nested.test.ts +329 -0
  23. package/src/__tests__/profile-startup-old-format.test.ts +390 -0
  24. package/src/__tests__/profile-state.test.ts +152 -164
  25. package/src/__tests__/scope-action.test.ts +35 -38
  26. package/src/__tests__/scope-row-lookup.test.ts +126 -0
  27. package/src/__tests__/select-restore.test.ts +209 -0
  28. package/src/__tests__/stale-load-repair.test.ts +1 -2
  29. package/src/__tests__/update-apply.test.ts +18 -2
  30. package/src/__tests__/update-null-readback.test.ts +300 -0
  31. package/src/__tests__/update-plan-parity.test.ts +249 -0
  32. package/src/__tests__/update-plan.test.ts +66 -67
  33. package/src/__tests__/version-unknown-state.test.ts +38 -39
  34. package/src/cli/bootstrap.ts +239 -70
  35. package/src/cli/doctor.ts +7 -20
  36. package/src/cli/install.ts +83 -27
  37. package/src/cli/prompt.ts +7 -3
  38. package/src/cli/router.ts +6 -4
  39. package/src/cli/select.ts +168 -0
  40. package/src/cli/update-view.ts +118 -6
  41. package/src/cli/update.ts +10 -1
  42. package/src/services/claude-cli.ts +0 -24
  43. package/src/services/claude-settings.ts +28 -7
  44. package/src/services/gitignore-check.ts +37 -49
  45. package/src/services/gitignore-resolver.ts +10 -1
  46. package/src/services/gitignore-service.ts +67 -0
  47. package/src/services/manifest.ts +31 -0
  48. package/src/services/mate-availability.ts +23 -29
  49. package/src/services/plugin-manager.ts +247 -105
  50. package/src/services/profile-absorb.ts +41 -24
  51. package/src/services/profile-activate.ts +102 -127
  52. package/src/services/profile-startup.ts +160 -4
  53. package/src/services/profile-state.ts +7 -68
  54. package/src/services/tracked-live-files.ts +109 -74
  55. package/src/services/update-engine.ts +28 -10
  56. package/src/services/update-plan.ts +78 -94
  57. package/src/ui/App.tsx +41 -35
  58. package/src/ui/adapters/pluginsAdapter.ts +1 -1
  59. package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
  60. package/src/ui/hooks/useGitignoreModal.ts +63 -17
  61. package/src/ui/hooks/useProfileStartupModal.ts +94 -9
  62. package/src/ui/keymap.ts +1 -1
  63. package/src/ui/renderers/pluginRenderers.tsx +39 -45
  64. package/src/ui/renderers/pluginRowText.ts +6 -2
  65. package/src/ui/renderers/pluginStatusText.ts +4 -3
  66. package/src/ui/screens/PluginsScreen.tsx +30 -52
  67. package/src/ui/screens/ProfilesScreen.tsx +3 -10
  68. package/src/cli/tui-prompt.tsx +0 -143
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { defaultMarketplaces } from "../data/marketplaces.js";
3
3
  import type {
4
+ InstalledPluginEntry,
4
5
  InstalledPluginsRegistry,
5
6
  PluginRelease,
6
7
  } from "../types/index.js";
@@ -17,6 +18,7 @@ import {
17
18
  getLocalInstalledPluginVersions,
18
19
  getProjectInstalledPluginVersions,
19
20
  getSettingsPath,
21
+ ownRegistryEntry,
20
22
  pickRegistryEntry,
21
23
  readInstalledPluginsRegistry,
22
24
  readSettings,
@@ -61,8 +63,25 @@ export {
61
63
  let localMarketplacesPromise: Promise<Map<string, LocalMarketplace>> | null =
62
64
  null;
63
65
 
66
+ /**
67
+ * A plugin's state at one scope. There are exactly two: installed, or not.
68
+ *
69
+ * `installed` means the settings file for that scope turns the plugin on,
70
+ * `enabledPlugins[id] === true` — `~/.claude/settings.json` for user,
71
+ * `<dir>/.claude/settings.json` for project, `<dir>/.claude/settings.local.json`
72
+ * for local. That is the fact Claude Code loads by. A registry row alone never
73
+ * makes it load a plugin: measured on 2.1.281, started in a subdirectory, Claude
74
+ * Code reads only that directory's `settings.json`. Read it through
75
+ * {@link isInstalledInScope}, never directly.
76
+ *
77
+ * `version` is the registry version that loads at this scope, and it can be
78
+ * inherited from another checkout of the same repository. It means something
79
+ * only where the plugin is installed — {@link versionInScope} hides it
80
+ * everywhere else. Installed with no version is the one broken sub-state,
81
+ * {@link scopeFilesMissing}.
82
+ */
64
83
  export interface ScopeStatus {
65
- enabled: boolean;
84
+ installed: boolean;
66
85
  version?: string;
67
86
  }
68
87
 
@@ -73,7 +92,14 @@ export interface PluginInfo {
73
92
  description: string;
74
93
  marketplace: string;
75
94
  marketplaceDisplay: string;
76
- enabled: boolean;
95
+ /**
96
+ * The highest version at a scope where the plugin is INSTALLED —
97
+ * {@link highestInstalledVersion}, the classifier's `installed`. Undefined
98
+ * when no installed scope has one: installed nowhere, or installed with its
99
+ * files missing. A row inherited from another checkout, at a scope whose
100
+ * settings do not turn the plugin on, is not an install Claude Code loads here
101
+ * and never supplies this.
102
+ */
77
103
  installedVersion?: string;
78
104
  /**
79
105
  * The version Claude Code's loader will ACTUALLY load in this project, or
@@ -172,91 +198,181 @@ export interface PluginInfo {
172
198
  updateCheckFailure?: MarketplaceFetchFailure;
173
199
  }
174
200
 
201
+ /** The three scopes a plugin carries — all the installed-state helpers read. */
202
+ type PluginScopes = Pick<
203
+ PluginInfo,
204
+ "userScope" | "projectScope" | "localScope"
205
+ >;
206
+
175
207
  /**
176
- * True when a plugin is enabled in some scope but no installed version resolves
177
- * anywhere — it is listed in `enabledPlugins` but was never actually installed.
208
+ * THE test for "installed at this scope": the scope's settings file turns the
209
+ * plugin on. See {@link ScopeStatus}.
178
210
  *
179
- * This is a broken state, not a healthy one: Claude Code will not load the
180
- * plugin, and because the update check is `installedVersion && compare(...)`,
181
- * it is also invisible to every update path.
182
- * It previously rendered identically to a working install.
211
+ * Every consumer asks this and nothing else — the list row's squares and name
212
+ * colour, the detail pane's dots, the header count, the classifier, the scope
213
+ * keys, the mate slots — so no two of them can show a different state.
183
214
  *
184
- * `isOrphaned` is a different failure — installed, but no longer offered by any
185
- * marketplace — and is rendered as "deprecated" already.
215
+ * A version is not part of the test. Installed with no version is still
216
+ * installed, in the broken sub-state {@link scopeFilesMissing} names.
217
+ */
218
+ export function isInstalledInScope(scope: ScopeStatus | undefined): boolean {
219
+ return scope?.installed === true;
220
+ }
221
+
222
+ /** Installed at user, project or local scope. */
223
+ export function isInstalledAnywhere(plugin: PluginScopes | undefined): boolean {
224
+ return (
225
+ isInstalledInScope(plugin?.userScope) ||
226
+ isInstalledInScope(plugin?.projectScope) ||
227
+ isInstalledInScope(plugin?.localScope)
228
+ );
229
+ }
230
+
231
+ /**
232
+ * The version at this scope, or undefined where the plugin is not installed.
186
233
  *
187
- * "0.0.0" means installed-with-unknown-version (Anthropic's official plugins
188
- * record exactly that), so it must NOT count as missing.
234
+ * A scope that is not installed can still carry a registry version, inherited
235
+ * from another checkout of the repository. Shown, it claims an install Claude
236
+ * Code does not load in this directory.
189
237
  */
190
- export function isEnabledButNotInstalled(plugin: PluginInfo): boolean {
191
- if (plugin.isOrphaned) return false;
192
- const enabledSomewhere =
193
- !!plugin.userScope?.enabled ||
194
- !!plugin.projectScope?.enabled ||
195
- !!plugin.localScope?.enabled;
196
- return enabledSomewhere && !plugin.installedVersion;
238
+ export function versionInScope(
239
+ scope: ScopeStatus | undefined,
240
+ ): string | undefined {
241
+ return isInstalledInScope(scope) ? scope?.version : undefined;
197
242
  }
198
243
 
199
244
  /**
200
- * True when a scope genuinely has the plugin installed.
245
+ * Installed at this scope with no version: Claude Code wants to load the plugin
246
+ * and has nothing to load. The repair is `install`.
201
247
  *
202
- * `enabled` on its own is not proof. It is the `enabledPlugins` flag out of a
203
- * settings file, and only magus and the user maintain it; `version` comes
204
- * from `installed_plugins.json` via `overlayRegistryVersions`, which is what
205
- * Claude Code actually loaded. When the two disagree — flag set, no registry
206
- * entry — the plugin is in the broken enabled-but-not-installed state that the
207
- * list already renders as "not installed".
248
+ * "0.0.0" is a version — Anthropic's official plugins record exactly that — so
249
+ * it is not missing, which is why this tests presence, not `isKnownVersion`.
250
+ */
251
+ export function scopeFilesMissing(scope: ScopeStatus | undefined): boolean {
252
+ return isInstalledInScope(scope) && !scope?.version;
253
+ }
254
+
255
+ /** The three scopes, in the one order every per-scope list uses: user, project, local. */
256
+ function scopeStatuses(
257
+ plugin: PluginScopes | undefined,
258
+ ): Array<[PluginScope, ScopeStatus | undefined]> {
259
+ return [
260
+ ["user", plugin?.userScope],
261
+ ["project", plugin?.projectScope],
262
+ ["local", plugin?.localScope],
263
+ ];
264
+ }
265
+
266
+ /**
267
+ * Scope → version, for the scopes where the plugin is installed and a version
268
+ * resolves.
208
269
  *
209
- * Every action that touches the filesystem must use this, not the flag alone.
210
- * Deciding on the flag is what made the scope keys uninstall a plugin the row
211
- * had just labelled "not installed".
270
+ * Only installed scopes. A scope that is not can still carry a registry version
271
+ * inherited from another checkout, and counting it planned an update — and
272
+ * printed a version — for a plugin Claude Code does not load here.
273
+ */
274
+ export function installedScopeVersions(
275
+ plugin: PluginScopes | undefined,
276
+ ): Array<[PluginScope, string]> {
277
+ const out: Array<[PluginScope, string]> = [];
278
+ for (const [scope, status] of scopeStatuses(plugin)) {
279
+ const version = versionInScope(status);
280
+ if (version) out.push([scope, version]);
281
+ }
282
+ return out;
283
+ }
284
+
285
+ /** The scopes where the plugin is installed with its files missing. */
286
+ export function filesMissingScopes(
287
+ plugin: PluginScopes | undefined,
288
+ ): PluginScope[] {
289
+ return scopeStatuses(plugin)
290
+ .filter(([, status]) => scopeFilesMissing(status))
291
+ .map(([scope]) => scope);
292
+ }
293
+
294
+ /**
295
+ * The highest version installed across scopes, or null.
212
296
  *
213
- * A version of "0.0.0" still counts as installed — Anthropic's official plugins
214
- * record exactly that — which is why this tests presence, not `isKnownVersion`.
297
+ * THE installed version: the classifier's `installed` and
298
+ * `PluginInfo.installedVersion` are both this, so the Plugins header, the row
299
+ * and the status line cannot name different versions for one plugin.
300
+ *
301
+ * `compareVersions` answers null for an unorderable pair, and null is not
302
+ * `>= 0`, so the reduce keeps the right-hand side when two versions cannot be
303
+ * ranked. "Highest" is not meaningful across commit ids; picking one
304
+ * deterministically is, and every caller only needs *an* installed version.
215
305
  */
216
- export function isInstalledInScope(scope: ScopeStatus | undefined): boolean {
217
- // Presence of files, NOT permission to load them. `enabled` was in this
218
- // test until it was measured: a plugin with a real registry row that the
219
- // user had simply switched off reported "not installed", so the row offered
220
- // to install what was already on disk. Installation is a registry fact;
221
- // enablement is a separate one, and `isEnabledButNotInstalled` is where the
222
- // broken combination of the two is named.
223
- return !!scope?.version;
306
+ export function highestInstalledVersion(
307
+ plugin: PluginScopes | undefined,
308
+ ): string | null {
309
+ const versions = installedScopeVersions(plugin).map(([, v]) => v);
310
+ if (versions.length === 0) return null;
311
+ return versions.reduce((a, b) =>
312
+ (compareVersions(a, b) ?? -1) >= 0 ? a : b,
313
+ );
224
314
  }
225
315
 
226
316
  /**
227
- * One scope's status, from the two facts that define it.
317
+ * Files missing, at plugin level: installed somewhere, and no installed scope
318
+ * has a version — the classifier's own test for reason `files-missing`.
319
+ *
320
+ * Broken, not healthy: Claude Code will not load the plugin, and because the
321
+ * update check is `installedVersion && compare(...)`, it is also invisible to
322
+ * every update path.
228
323
  *
229
- * Exported and taken as arguments so this is testable and has exactly one
230
- * implementation. It previously lived inline in `getAvailablePlugins` and its
231
- * global twin — two copies, and both keyed existence off the settings flag:
324
+ * Read from the scopes, never from `installedVersion`: a version that field
325
+ * once took from a scope NOT installed here made the header say "● Installed"
326
+ * beside a status line saying files missing.
232
327
  *
233
- * enabledPlugins[id] !== undefined ? {...} : undefined
328
+ * `isOrphaned` is a different failure — no longer offered by any marketplace —
329
+ * and has its own rendering already.
330
+ *
331
+ * "0.0.0" means installed-with-unknown-version (Anthropic's official plugins
332
+ * record exactly that), so it must NOT count as missing.
333
+ */
334
+ export function hasMissingFiles(plugin: PluginInfo): boolean {
335
+ if (plugin.isOrphaned) return false;
336
+ return (
337
+ isInstalledAnywhere(plugin) && highestInstalledVersion(plugin) === null
338
+ );
339
+ }
340
+
341
+ /**
342
+ * One scope's status, from the scope's `enabledPlugins` map and the registry
343
+ * versions resolved for it.
234
344
  *
235
- * so a plugin with a registry row but no settings key produced NO scope at all
236
- * and rendered exactly like one that was never installed. Existence is now
237
- * "either fact is present", and `enabled` defaults to false rather than
238
- * deciding whether the scope exists.
345
+ * Exported and taken as arguments so all three scopes share one implementation
346
+ * and it is unit-tested. `installed` is the settings flag and nothing else. The
347
+ * version is carried whenever the registry resolves one; {@link versionInScope}
348
+ * is what keeps it off screen for a scope where the plugin is not installed.
349
+ * Neither fact present means no scope at all.
239
350
  */
240
351
  export function buildScopeStatus(
241
352
  enabledPlugins: Record<string, boolean>,
242
353
  installedVersions: Record<string, string>,
243
354
  pluginId: string,
244
355
  ): ScopeStatus | undefined {
245
- const enabled = enabledPlugins[pluginId];
356
+ const flag = enabledPlugins[pluginId];
246
357
  const version = installedVersions[pluginId];
247
- if (enabled === undefined && version === undefined) return undefined;
248
- return { enabled: enabled === true, version };
358
+ if (flag === undefined && version === undefined) return undefined;
359
+ return { installed: flag === true, version };
249
360
  }
250
361
 
251
362
  /**
252
363
  * What a scope toggle (`u` / `p` / `l`, or the Enter scope picker) should do.
253
364
  *
254
- * `install` doubles as the repair for enabled-but-not-installed. Measured
255
- * against Claude Code 2.1.223 in exactly that state — `dev@magus` flagged in
256
- * `.claude/settings.json` with no registry entry for the project path —
257
- * `claude plugin install dev@magus --scope project` installed it normally and
258
- * wrote the registry entry. It does not report "already installed", so this
259
- * needs no uninstall-first dance (unlike content drift, see repairPlugin).
365
+ * `install` for a scope that is not installed, and for one whose files are
366
+ * missing — there it is the repair. Measured against Claude Code 2.1.223 in
367
+ * exactly that state — `dev@magus` in `.claude/settings.json` with no registry
368
+ * entry for the project path — `claude plugin install dev@magus --scope project`
369
+ * installed it normally and wrote the registry entry. It does not report
370
+ * "already installed", so this needs no uninstall-first dance (unlike content
371
+ * drift, see repairPlugin).
372
+ *
373
+ * Files missing must never resolve to `uninstall`. The scope key runs
374
+ * `cliUninstallPlugin` with no confirmation, and that answer removed a plugin
375
+ * the row beside it was reporting as broken — the user asked for the repair.
260
376
  *
261
377
  * `behind` is the answer to "is this scope provably behind?", and it comes from
262
378
  * the plugin classifier (`scopeIsBehind` in update-plan.ts), never from a
@@ -269,18 +385,7 @@ export function resolveScopeAction(
269
385
  scope: ScopeStatus | undefined,
270
386
  behind: boolean,
271
387
  ): "install" | "update" | "uninstall" {
272
- // `enabled` is deliberately part of THIS decision even though it is no longer
273
- // part of `isInstalledInScope`. The two ask different questions:
274
- // "is it on disk" (installation) versus "what should this key do next".
275
- //
276
- // Installed-but-disabled must NOT resolve to uninstall. The scope key runs
277
- // `cliUninstallPlugin` with no confirmation, so that combination would delete
278
- // a plugin the user had merely switched off — with the row beside it showing
279
- // no scope square, which reads as "not installed" and invites the press.
280
- // `install` re-installs and re-enables, which is what the key meant before
281
- // installation and enablement were separated, and is the only non-destructive
282
- // answer here.
283
- if (!isInstalledInScope(scope) || !scope?.enabled) return "install";
388
+ if (!isInstalledInScope(scope) || scopeFilesMissing(scope)) return "install";
284
389
  return behind ? "update" : "uninstall";
285
390
  }
286
391
 
@@ -288,6 +393,7 @@ export async function getAvailablePlugins(
288
393
  projectPath?: string,
289
394
  ): Promise<PluginInfo[]> {
290
395
  const configuredMarketplaces = await getConfiguredMarketplaces(projectPath);
396
+ // The project scope's two facts. They also seed the orphan list below.
291
397
  const enabledPlugins = await getEnabledPlugins(projectPath);
292
398
  const installedVersions = await getInstalledPluginVersions(projectPath);
293
399
 
@@ -303,12 +409,9 @@ export async function getAvailablePlugins(
303
409
  const loadedVersionOf = (pluginId: string): string | null =>
304
410
  loadedVersions[pluginId] ?? null;
305
411
 
306
- // Fetch all scopes for per-scope status
412
+ // The other two scopes.
307
413
  const userEnabledPlugins = await getGlobalEnabledPlugins();
308
414
  const userInstalledVersions = await getGlobalInstalledPluginVersions();
309
- const projectEnabledPlugins = await getEnabledPlugins(projectPath);
310
- const projectInstalledVersions =
311
- await getInstalledPluginVersions(projectPath);
312
415
  const localEnabledPlugins = await getLocalEnabledPlugins(projectPath);
313
416
  const localInstalledVersions =
314
417
  await getLocalInstalledPluginVersions(projectPath);
@@ -316,21 +419,13 @@ export async function getAvailablePlugins(
316
419
  const plugins: PluginInfo[] = [];
317
420
  const seenPluginIds = new Set<string>();
318
421
 
319
- // Helper to build scope status
320
- // One implementation, shared with the global view and unit-tested. Both
321
- // copies used to key scope existence off the settings flag, so an installed
322
- // plugin with no settings key produced no scope at all.
323
- const scopeStatusFor = (pluginId: string) => ({
422
+ const scopeStatusFor = (pluginId: string): PluginScopes => ({
324
423
  userScope: buildScopeStatus(
325
424
  userEnabledPlugins,
326
425
  userInstalledVersions,
327
426
  pluginId,
328
427
  ),
329
- projectScope: buildScopeStatus(
330
- projectEnabledPlugins,
331
- projectInstalledVersions,
332
- pluginId,
333
- ),
428
+ projectScope: buildScopeStatus(enabledPlugins, installedVersions, pluginId),
334
429
  localScope: buildScopeStatus(
335
430
  localEnabledPlugins,
336
431
  localInstalledVersions,
@@ -338,6 +433,17 @@ export async function getAvailablePlugins(
338
433
  ),
339
434
  });
340
435
 
436
+ // A version is an install only at a scope where Claude Code loads the plugin.
437
+ // The registry resolves a row here for plugins no settings file in this
438
+ // directory turns on — inherited from another checkout of the repository.
439
+ // This used to take the PROJECT lookup whenever the plugin was installed at
440
+ // any scope, so a plugin installed at user scope at 0.9.0 read "(v0.8.1
441
+ // installed)" off an inherited project row beside a status saying current at
442
+ // 0.9.0 — and with its user files missing, the header said "● Installed".
443
+ // The classifier's rule, from the same per-scope versions, is the only rule.
444
+ const installedVersionFor = (scopes: PluginScopes): string | undefined =>
445
+ highestInstalledVersion(scopes) ?? undefined;
446
+
341
447
  // Get all marketplace names (configured + official/featured defaults)
342
448
  // Always include official and featured marketplaces so users can browse them
343
449
  const marketplaceNames = new Set<string>();
@@ -396,9 +502,8 @@ export async function getAvailablePlugins(
396
502
 
397
503
  for (const plugin of marketplacePlugins) {
398
504
  const pluginId = `${plugin.name}@${mpName}`;
399
- const installedVersion = installedVersions[pluginId];
400
- const isEnabled = enabledPlugins[pluginId] === true;
401
505
  const scopeStatus = scopeStatusFor(pluginId);
506
+ const installedVersion = installedVersionFor(scopeStatus);
402
507
 
403
508
  seenPluginIds.add(pluginId);
404
509
  plugins.push({
@@ -408,7 +513,6 @@ export async function getAvailablePlugins(
408
513
  description: plugin.description,
409
514
  marketplace: mpName,
410
515
  marketplaceDisplay: marketplace.displayName,
411
- enabled: isEnabled,
412
516
  installedVersion: installedVersion,
413
517
  loadedVersion: loadedVersionOf(pluginId),
414
518
  hasUpdate: isBehind(plugin.version, installedVersion) === true,
@@ -436,9 +540,8 @@ export async function getAvailablePlugins(
436
540
  const pluginId = `${localPlugin.name}@${mpName}`;
437
541
  if (seenPluginIds.has(pluginId)) continue;
438
542
 
439
- const installedVersion = installedVersions[pluginId];
440
- const isEnabled = enabledPlugins[pluginId] === true;
441
543
  const scopeStatus = scopeStatusFor(pluginId);
544
+ const installedVersion = installedVersionFor(scopeStatus);
442
545
 
443
546
  seenPluginIds.add(pluginId);
444
547
  plugins.push({
@@ -448,7 +551,6 @@ export async function getAvailablePlugins(
448
551
  description: localPlugin.description || "",
449
552
  marketplace: mpName,
450
553
  marketplaceDisplay: localMp.name || formatMarketplaceName(mpName),
451
- enabled: isEnabled,
452
554
  installedVersion: installedVersion,
453
555
  loadedVersion: loadedVersionOf(pluginId),
454
556
  hasUpdate: isBehind(localPlugin.version, installedVersion) === true,
@@ -466,7 +568,7 @@ export async function getAvailablePlugins(
466
568
  }
467
569
  }
468
570
 
469
- // Add orphaned plugins (enabled/installed but not in any cache)
571
+ // Add orphaned plugins (in this project's settings or registry, but not in any cache)
470
572
  const allPluginIds = new Set([
471
573
  ...Object.keys(enabledPlugins),
472
574
  ...Object.keys(installedVersions),
@@ -479,9 +581,8 @@ export async function getAvailablePlugins(
479
581
  if (!parsed) continue;
480
582
 
481
583
  const { pluginName, marketplace: mpName } = parsed;
482
- const installedVersion = installedVersions[pluginId];
483
- const isEnabled = enabledPlugins[pluginId] === true;
484
584
  const scopeStatus = scopeStatusFor(pluginId);
585
+ const installedVersion = installedVersionFor(scopeStatus);
485
586
 
486
587
  // Try to get plugin info from local marketplace cache (fallback)
487
588
  const localMp = localMarketplaces.get(mpName);
@@ -498,7 +599,6 @@ export async function getAvailablePlugins(
498
599
  description,
499
600
  marketplace: mpName,
500
601
  marketplaceDisplay: localMp?.name || formatMarketplaceName(mpName),
501
- enabled: isEnabled,
502
602
  installedVersion: installedVersion,
503
603
  loadedVersion: loadedVersionOf(pluginId),
504
604
  hasUpdate,
@@ -507,7 +607,7 @@ export async function getAvailablePlugins(
507
607
  });
508
608
  }
509
609
 
510
- await annotateContentDrift(plugins, "project", projectPath);
610
+ await annotateContentDrift(plugins, projectPath);
511
611
  return plugins;
512
612
  }
513
613
 
@@ -538,19 +638,58 @@ export function contentDriftCandidate(plugin: PluginInfo): boolean {
538
638
  return compareVersions(plugin.version, plugin.installedVersion) === 0;
539
639
  }
540
640
 
641
+ /**
642
+ * The registry row whose commit a drift check compares: the row behind an
643
+ * INSTALLED scope at `installedVersion`, picked the way that scope's version
644
+ * was resolved — so a worktree that inherits a version inherits its
645
+ * `gitCommitSha` with it, and drift detection does not switch off for exactly
646
+ * the plugins that just started reporting as installed.
647
+ *
648
+ * It used to be the project lookup for every plugin. For one installed only at
649
+ * user scope, beside a project row inherited from another checkout, that
650
+ * compared the inherited row's commit — another version's files — against the
651
+ * user install's version.
652
+ *
653
+ * Project, then local, then user: this directory's rows before the user row,
654
+ * the precedence the project lookup always had.
655
+ *
656
+ * Exported to be tested directly; `annotateContentDrift` needs the registry.
657
+ */
658
+ export function contentDriftEntry(
659
+ plugin: PluginInfo,
660
+ entries: InstalledPluginEntry[] | undefined,
661
+ projectPath: string,
662
+ fallbackPaths: string[],
663
+ ): InstalledPluginEntry | undefined {
664
+ const atShown = new Set(
665
+ installedScopeVersions(plugin)
666
+ .filter(([, version]) => version === plugin.installedVersion)
667
+ .map(([scope]) => scope),
668
+ );
669
+ for (const scope of ["project", "local", "user"] as const) {
670
+ if (!atShown.has(scope)) continue;
671
+ const entry =
672
+ scope === "user"
673
+ ? pickRegistryEntry(entries, "user")
674
+ : pickRegistryEntry(entries, scope, projectPath, fallbackPaths);
675
+ if (entry) return entry;
676
+ }
677
+ return undefined;
678
+ }
679
+
541
680
  /**
542
681
  * Flag plugins whose files moved without their version moving.
543
682
  *
544
683
  * Runs after the plugin list is assembled so every code path that builds a
545
684
  * PluginInfo gets the same treatment. Which plugins are worth asking about is
546
- * {@link contentDriftCandidate}.
685
+ * {@link contentDriftCandidate}, and which row to ask about is
686
+ * {@link contentDriftEntry}.
547
687
  *
548
688
  * Best-effort throughout: a plugin whose drift cannot be determined is left
549
689
  * exactly as it is today.
550
690
  */
551
691
  async function annotateContentDrift(
552
692
  plugins: PluginInfo[],
553
- scope: "user" | "project" | "local",
554
693
  projectPath?: string,
555
694
  ): Promise<void> {
556
695
  let registry: InstalledPluginsRegistry;
@@ -560,20 +699,16 @@ async function annotateContentDrift(
560
699
  return;
561
700
  }
562
701
 
563
- // Must match how the version itself was resolved. Without this a worktree
564
- // inherits an installed version but never its `gitCommitSha`, so drift
565
- // detection silently switches off for exactly the plugins that just started
566
- // reporting as installed.
567
- const target = scope === "user" ? undefined : (projectPath ?? process.cwd());
568
- const fallbackPaths = target ? await inheritablePaths(target) : [];
702
+ const target = projectPath ?? process.cwd();
703
+ const fallbackPaths = await inheritablePaths(target);
569
704
 
570
705
  await Promise.all(
571
706
  plugins.map(async (plugin) => {
572
707
  if (!contentDriftCandidate(plugin)) return;
573
708
 
574
- const entry = pickRegistryEntry(
709
+ const entry = contentDriftEntry(
710
+ plugin,
575
711
  registry.plugins[plugin.id],
576
- scope,
577
712
  target,
578
713
  fallbackPaths,
579
714
  );
@@ -762,6 +897,13 @@ export async function saveInstalledPluginVersion(
762
897
  * version we got are therefore different questions, and only this one can answer
763
898
  * the second. Callers must record what this returns, never what they intended:
764
899
  * writing the intent fabricates state that the drift gate then trusts.
900
+ *
901
+ * Only THIS project's own row at `scope` ({@link ownRegistryEntry}) — no
902
+ * inherited row and no user-row fallback. A fallback answered for a row the
903
+ * command never wrote: the apply engine took it as "this project holds a row"
904
+ * and ran `update` where only `install` is safe, and a read-back after a
905
+ * project install that wrote nothing returned the user row's version, to be
906
+ * recorded as the project's.
765
907
  */
766
908
  export async function readInstalledVersionForScope(
767
909
  pluginId: string,
@@ -769,7 +911,7 @@ export async function readInstalledVersionForScope(
769
911
  projectPath: string = process.cwd(),
770
912
  ): Promise<string | null> {
771
913
  const registry = await readInstalledPluginsRegistry();
772
- const entry = pickRegistryEntry(
914
+ const entry = ownRegistryEntry(
773
915
  registry.plugins?.[pluginId],
774
916
  scope,
775
917
  projectPath,