claudeup 6.5.0 → 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 (82) 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__/catalog-notice.test.ts +3 -3
  9. package/src/__tests__/cli-apply-seams.test.ts +2 -0
  10. package/src/__tests__/cli-tool-commands.test.ts +1 -1
  11. package/src/__tests__/cli-tools-adapter.test.ts +294 -0
  12. package/src/__tests__/cli-update-view.test.ts +142 -2
  13. package/src/__tests__/content-drift.test.ts +60 -0
  14. package/src/__tests__/enabled-not-installed.test.ts +1 -2
  15. package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
  16. package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
  17. package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
  18. package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
  19. package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
  20. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
  21. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
  22. package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
  23. package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
  24. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
  25. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
  26. package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
  27. package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
  28. package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
  29. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
  30. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
  31. package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
  32. package/src/__tests__/{gitignore-prerun.test.ts → gitignore-check.test.ts} +8 -6
  33. package/src/__tests__/marketplace-badge.test.ts +1 -1
  34. package/src/__tests__/marketplaces.test.ts +1 -0
  35. package/src/__tests__/plugin-cli-argv.test.ts +1 -1
  36. package/src/__tests__/plugin-row-text.test.ts +336 -0
  37. package/src/__tests__/scope-action.test.ts +69 -19
  38. package/src/__tests__/shell-script-callers.test.ts +8 -8
  39. package/src/__tests__/tool-probe.test.ts +1400 -0
  40. package/src/__tests__/update-apply.test.ts +283 -1
  41. package/src/__tests__/update-plan.test.ts +773 -31
  42. package/src/__tests__/version-unknown-state.test.ts +388 -0
  43. package/src/cli/router.ts +0 -5
  44. package/src/cli/update-view.ts +92 -12
  45. package/src/cli/update.ts +114 -38
  46. package/src/data/marketplaces.ts +13 -0
  47. package/src/services/active-closure.ts +117 -0
  48. package/src/services/catalog-cache-store.ts +6 -2
  49. package/src/services/claude-settings.ts +1 -173
  50. package/src/services/{gitignore-prerun.ts → gitignore-check.ts} +14 -10
  51. package/src/services/gitignore-fixer.ts +1 -1
  52. package/src/services/marketplace-refresh.ts +30 -0
  53. package/src/services/plugin-manager.ts +200 -283
  54. package/src/services/resolver.ts +7 -2
  55. package/src/services/tool-probe.ts +1637 -0
  56. package/src/services/toolchain.ts +97 -0
  57. package/src/services/update-engine.ts +220 -32
  58. package/src/services/update-plan.ts +640 -160
  59. package/src/services/version-snapshot.ts +1 -1
  60. package/src/types/bun.d.ts +6 -0
  61. package/src/ui/App.tsx +2 -27
  62. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  63. package/src/ui/adapters/pluginsAdapter.ts +58 -1
  64. package/src/ui/components/modals/ModalContainer.tsx +0 -26
  65. package/src/ui/hooks/index.ts +0 -1
  66. package/src/ui/hooks/useGitignoreModal.ts +2 -3
  67. package/src/ui/renderers/cliToolRenderers.tsx +108 -67
  68. package/src/ui/renderers/pluginRenderers.tsx +157 -98
  69. package/src/ui/renderers/pluginRowText.ts +70 -0
  70. package/src/ui/renderers/pluginStatusText.ts +56 -0
  71. package/src/ui/screens/CliToolsScreen.tsx +154 -280
  72. package/src/ui/screens/PluginsScreen.tsx +156 -140
  73. package/src/ui/state/types.ts +0 -7
  74. package/src/__tests__/dual-write-prevention.test.ts +0 -380
  75. package/src/__tests__/gap-fill-versions.test.ts +0 -389
  76. package/src/__tests__/plugin-version-check.test.ts +0 -762
  77. package/src/cli/claude.ts +0 -21
  78. package/src/prerunner/index.ts +0 -500
  79. package/src/services/plugin-version-check.ts +0 -360
  80. package/src/services/update-cache.ts +0 -78
  81. package/src/ui/components/modals/VersionMismatchModal.tsx +0 -138
  82. package/src/ui/hooks/useMismatchModal.ts +0 -89
@@ -1,13 +1,28 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import type { BinCheckResult } from "../services/doctor-bins.js";
3
2
  import type { PluginInfo } from "../services/plugin-manager.js";
3
+ import { type ToolFacts, classifyTool } from "../services/tool-probe.js";
4
4
  import {
5
+ applyBins,
6
+ binCommandFor,
7
+ planWorkItems,
8
+ } from "../services/update-engine.js";
9
+ import {
10
+ type BinUpdateAction,
11
+ type BinUpdateItem,
12
+ type PluginUpdateItem,
5
13
  type UpdatePlan,
14
+ binNeedsWork,
15
+ classifyPlugin,
16
+ classifyPluginRows,
17
+ countPluginStates,
18
+ indexCatalog,
19
+ pinForRow,
6
20
  planBinUpdates,
7
21
  planHasWork,
8
22
  planIsBehind,
9
23
  planPluginUpdates,
10
24
  planSkillUpdates,
25
+ scopeIsBehind,
11
26
  scopesNeeding,
12
27
  summarizePlan,
13
28
  } from "../services/update-plan.js";
@@ -30,6 +45,21 @@ function catalog(...infos: PluginInfo[]): Map<string, PluginInfo> {
30
45
  return new Map(infos.map((i) => [i.id, i]));
31
46
  }
32
47
 
48
+ /** Probe facts for a present, managed tool; override what the case is about. */
49
+ function toolFacts(name: string, over: Partial<ToolFacts> = {}): ToolFacts {
50
+ return {
51
+ name,
52
+ present: true,
53
+ resolvedPath: `/bin/${name}`,
54
+ installed: "1.0.0",
55
+ latest: null,
56
+ behind: null,
57
+ managed: true,
58
+ sources: [],
59
+ ...over,
60
+ };
61
+ }
62
+
33
63
  function plan(overrides: Partial<UpdatePlan> = {}): UpdatePlan {
34
64
  return {
35
65
  profileId: "test",
@@ -53,12 +83,14 @@ describe("planPluginUpdates — the profile declares it, the machine lacks it",
53
83
  target: "4.7.0",
54
84
  scopes: [],
55
85
  });
86
+ expect(item.reason).toBe("absent");
56
87
  });
57
88
 
58
89
  test("a plugin missing from the catalog entirely is still an install", () => {
59
90
  // The marketplace may not be registered yet — install registers it.
60
91
  const [item] = planPluginUpdates({ "new@magus": "latest" }, catalog());
61
92
  expect(item).toMatchObject({ action: "install", available: null });
93
+ expect(item.reason).toBe("absent");
62
94
  });
63
95
 
64
96
  test("an exact pin that is not installed installs that exact version", () => {
@@ -67,6 +99,7 @@ describe("planPluginUpdates — the profile declares it, the machine lacks it",
67
99
  catalog(info({ id: "dev@magus", version: "4.7.0" })),
68
100
  );
69
101
  expect(item.target).toBe("4.6.1");
102
+ expect(item.reason).toBe("absent");
70
103
  });
71
104
  });
72
105
 
@@ -88,6 +121,7 @@ describe("planPluginUpdates — advancing a 'latest' pin", () => {
88
121
  target: "4.7.0",
89
122
  scopes: ["project"],
90
123
  });
124
+ expect(item.reason).toBe("behind");
91
125
  });
92
126
 
93
127
  test("the same version is current, with no scopes to touch", () => {
@@ -102,6 +136,7 @@ describe("planPluginUpdates — advancing a 'latest' pin", () => {
102
136
  ),
103
137
  );
104
138
  expect(item).toMatchObject({ action: "current", scopes: [] });
139
+ expect(item.reason).toBe("up-to-date");
105
140
  });
106
141
 
107
142
  test("an older catalog version does not downgrade a 'latest' pin", () => {
@@ -116,6 +151,7 @@ describe("planPluginUpdates — advancing a 'latest' pin", () => {
116
151
  ),
117
152
  );
118
153
  expect(item.action).toBe("current");
154
+ expect(item.reason).toBe("up-to-date");
119
155
  });
120
156
  });
121
157
 
@@ -142,9 +178,13 @@ describe("planPluginUpdates — a failed check is never 'up to date'", () => {
142
178
  expect(item.action).toBe("unknown");
143
179
  expect(item.note).toBe("GitHub rate limit");
144
180
  expect(item.target).toBeNull();
181
+ expect(item.reason).toBe("catalog-unverified");
145
182
  });
146
183
 
147
- test("an unknown plugin keeps --check failing", () => {
184
+ test("an unknown plugin is still work to look at, but does not fail --check", () => {
185
+ // The two predicates split here on purpose: the run has something to
186
+ // report (planHasWork), yet nothing is KNOWN to be behind, so the CI gate
187
+ // passes. Nothing is applied for an unknown either way.
148
188
  const p = plan({
149
189
  plugins: planPluginUpdates(
150
190
  { "dev@magus": "latest" },
@@ -159,6 +199,7 @@ describe("planPluginUpdates — a failed check is never 'up to date'", () => {
159
199
  ),
160
200
  });
161
201
  expect(planHasWork(p)).toBe(true);
202
+ expect(planIsBehind(p)).toBe(false);
162
203
  });
163
204
  });
164
205
 
@@ -178,6 +219,7 @@ describe("planPluginUpdates — content drift", () => {
178
219
  );
179
220
  expect(item.action).toBe("repair");
180
221
  expect(item.scopes).toEqual(["user", "project"]);
222
+ expect(item.reason).toBe("content-stale");
181
223
  });
182
224
  });
183
225
 
@@ -199,6 +241,7 @@ describe("planPluginUpdates — an exact pin plans against what is deliverable",
199
241
  ),
200
242
  );
201
243
  expect(item).toMatchObject({ action: "update", target: "9.9.9" });
244
+ expect(item.reason).toBe("pin-undeliverable");
202
245
  });
203
246
 
204
247
  test("an unreachable pin is NOTED, never silently abandoned", () => {
@@ -215,6 +258,7 @@ describe("planPluginUpdates — an exact pin plans against what is deliverable",
215
258
  expect(item.note).toBe(
216
259
  "manifest pins 4.6.1; the marketplace offers only 9.9.9",
217
260
  );
261
+ expect(item.reason).toBe("pin-undeliverable");
218
262
  });
219
263
 
220
264
  test("already on the only installable version is CURRENT — no churn", () => {
@@ -233,6 +277,7 @@ describe("planPluginUpdates — an exact pin plans against what is deliverable",
233
277
  );
234
278
  expect(item.action).toBe("current");
235
279
  expect(item.scopes).toEqual([]);
280
+ expect(item.reason).toBe("pin-undeliverable");
236
281
  });
237
282
 
238
283
  test("an unsatisfiable pin does not fail --check forever", () => {
@@ -264,6 +309,7 @@ describe("planPluginUpdates — an exact pin plans against what is deliverable",
264
309
  );
265
310
  expect(item).toMatchObject({ action: "update", target: "4.6.1" });
266
311
  expect(item.note).toBeUndefined();
312
+ expect(item.reason).toBe("behind");
267
313
  });
268
314
 
269
315
  test("falls back to the pin when the catalog offers nothing", () => {
@@ -278,6 +324,7 @@ describe("planPluginUpdates — an exact pin plans against what is deliverable",
278
324
  ),
279
325
  );
280
326
  expect(item.target).toBe("4.6.1");
327
+ expect(item.reason).toBe("behind");
281
328
  });
282
329
  });
283
330
 
@@ -299,6 +346,7 @@ describe("per-scope decisions — a stale scope is never masked", () => {
299
346
  );
300
347
  expect(item.action).toBe("update");
301
348
  expect(item.scopes).toEqual(["project"]);
349
+ expect(item.reason).toBe("behind");
302
350
  });
303
351
 
304
352
  test("the same masking under an EXACT pin is also caught", () => {
@@ -319,6 +367,7 @@ describe("per-scope decisions — a stale scope is never masked", () => {
319
367
  );
320
368
  expect(item.action).toBe("update");
321
369
  expect(item.scopes).toEqual(["project"]);
370
+ expect(item.reason).toBe("behind");
322
371
  });
323
372
 
324
373
  test("an unreachable pin moves EVERY scope to the installable version", () => {
@@ -335,6 +384,7 @@ describe("per-scope decisions — a stale scope is never masked", () => {
335
384
  );
336
385
  expect(item.target).toBe("9.9.9");
337
386
  expect(item.scopes).toEqual(["user", "project"]);
387
+ expect(item.reason).toBe("pin-undeliverable");
338
388
  });
339
389
 
340
390
  test("all scopes at the target is genuinely current", () => {
@@ -350,9 +400,14 @@ describe("per-scope decisions — a stale scope is never masked", () => {
350
400
  ),
351
401
  );
352
402
  expect(item.action).toBe("current");
403
+ expect(item.reason).toBe("up-to-date");
353
404
  });
354
405
 
355
- test("an installed plugin no marketplace offers is 'unknown', not 'current'", () => {
406
+ test("an installed plugin whose catalog declares no version is 'unknown'", () => {
407
+ // This fixture puts dev@magus IN the catalog with a null version, so it is
408
+ // the declares-no-version case, not the withdrawn one — the assertion used
409
+ // to say "not offered", which is what conflated them. 278 of the 292
410
+ // plugins in claude-plugins-official are in exactly this state.
356
411
  const [item] = planPluginUpdates(
357
412
  { "dev@magus": "latest" },
358
413
  catalog(
@@ -364,7 +419,20 @@ describe("per-scope decisions — a stale scope is never masked", () => {
364
419
  ),
365
420
  );
366
421
  expect(item.action).toBe("unknown");
367
- expect(item.note).toContain("not offered");
422
+ expect(item.note).toContain("publishes no version");
423
+ expect(item.reason).toBe("no-published-version");
424
+ });
425
+
426
+ test("an installed plugin ABSENT from every catalog is 'not offered'", () => {
427
+ // The genuinely withdrawn case: no catalog entry at all, the installed
428
+ // version known only from the registry.
429
+ const [item] = planPluginUpdates(
430
+ { "withdrawn@magus": "latest" },
431
+ catalog(info({ id: "dev@magus", version: "4.6.1" })),
432
+ );
433
+ expect(item.action).toBe("unknown");
434
+ expect(item.note).toContain("not offered by any configured marketplace");
435
+ expect(item.reason).toBe("not-offered");
368
436
  });
369
437
  });
370
438
 
@@ -391,16 +459,40 @@ describe("planIsBehind — the --check gate", () => {
391
459
  expect(planIsBehind(p)).toBe(true);
392
460
  });
393
461
 
394
- test("a present unpinned binary does NOT fail the gate", () => {
395
- // There is no version probe for a binary, only present/absent. "Upgrade"
396
- // is best-effort advancement, not evidence anything is behind.
462
+ test("a present tool its installer calls current does NOT fail the gate, and is no work", () => {
397
463
  const p = plan({
398
464
  bins: planBinUpdates(
399
- [{ name: "tmux-mcp", via: "bun", sources: [] }],
400
- [{ name: "tmux-mcp", present: true, installCommand: "", sources: [] }],
465
+ [{ name: "tmux", via: "brew", sources: [] }],
466
+ [toolFacts("tmux", { behind: false })],
401
467
  ),
402
468
  });
403
- expect(planHasWork(p)).toBe(true);
469
+ expect(planHasWork(p)).toBe(false);
470
+ expect(planIsBehind(p)).toBe(false);
471
+ });
472
+
473
+ test("a tool its installer says is BEHIND does fail the gate", () => {
474
+ // Behaviour change, deliberate: "upgrade" used to mean "re-run the
475
+ // installer on a guess" and was excluded; it now means proven behind.
476
+ const p = plan({
477
+ bins: planBinUpdates(
478
+ [{ name: "claude-swap", via: "pip", sources: [] }],
479
+ [toolFacts("claude-swap", { behind: true, latest: "0.26.0" })],
480
+ ),
481
+ });
482
+ expect(planIsBehind(p)).toBe(true);
483
+ });
484
+
485
+ test("an UNKNOWN tool does not fail the gate, and is no work", () => {
486
+ // Tool unknowns are often structural and permanent (another npm owns the
487
+ // file, go is absent) — counting them makes a gate that never passes.
488
+ const p = plan({
489
+ bins: planBinUpdates(
490
+ [{ name: "crush", via: "npm", sources: [] }],
491
+ [toolFacts("crush", { managed: false })],
492
+ ),
493
+ });
494
+ expect(p.bins[0]!.action).toBe("unknown");
495
+ expect(planHasWork(p)).toBe(false);
404
496
  expect(planIsBehind(p)).toBe(false);
405
497
  });
406
498
 
@@ -408,13 +500,17 @@ describe("planIsBehind — the --check gate", () => {
408
500
  const p = plan({
409
501
  bins: planBinUpdates(
410
502
  [{ name: "tmux-mcp", via: "bun", sources: [] }],
411
- [{ name: "tmux-mcp", present: false, installCommand: "", sources: [] }],
503
+ [toolFacts("tmux-mcp", { present: false, installed: null })],
412
504
  ),
413
505
  });
414
506
  expect(planIsBehind(p)).toBe(true);
415
507
  });
416
508
 
417
- test("an unverifiable plugin still fails the gate", () => {
509
+ test("an unverifiable plugin does NOT fail the gate, and is not applied", () => {
510
+ // Same rule as an unknown tool. The user's own profile declares 5 plugins
511
+ // whose marketplace publishes no version, so the old behaviour exited 1 on
512
+ // every run for ever — a gate that can never pass protects nothing. The
513
+ // count of unchecked items is printed beside the verdict instead.
418
514
  const p = plan({
419
515
  plugins: planPluginUpdates(
420
516
  { "dev@magus": "latest" },
@@ -428,7 +524,69 @@ describe("planIsBehind — the --check gate", () => {
428
524
  ),
429
525
  ),
430
526
  });
431
- expect(planIsBehind(p)).toBe(true);
527
+ expect(p.plugins[0]?.action).toBe("unknown");
528
+ expect(planIsBehind(p)).toBe(false);
529
+ expect(planWorkItems(p)).toEqual([]);
530
+ });
531
+
532
+ test("a plugin whose marketplace publishes no version does NOT fail the gate", () => {
533
+ // The real shape behind dry-run-1: listed, installed, no catalog version.
534
+ const p = plan({
535
+ plugins: planPluginUpdates(
536
+ { "code-review@claude-plugins-official": "latest" },
537
+ catalog(
538
+ info({
539
+ id: "code-review@claude-plugins-official",
540
+ version: null,
541
+ projectScope: { enabled: true, version: "3ea32df27be7" },
542
+ }),
543
+ ),
544
+ ),
545
+ });
546
+ expect(p.plugins[0]?.reason).toBe("no-published-version");
547
+ expect(planIsBehind(p)).toBe(false);
548
+ });
549
+
550
+ test("a plugin that is behind, absent or content-stale still fails the gate", () => {
551
+ const behind = plan({
552
+ plugins: planPluginUpdates(
553
+ { "dev@magus": "latest" },
554
+ catalog(
555
+ info({
556
+ id: "dev@magus",
557
+ version: "4.7.0",
558
+ projectScope: { enabled: true, version: "4.6.1" },
559
+ }),
560
+ ),
561
+ ),
562
+ });
563
+ expect(behind.plugins[0]?.action).toBe("update");
564
+ expect(planIsBehind(behind)).toBe(true);
565
+
566
+ const absent = plan({
567
+ plugins: planPluginUpdates(
568
+ { "dev@magus": "latest" },
569
+ catalog(info({ id: "dev@magus", version: "4.7.0" })),
570
+ ),
571
+ });
572
+ expect(absent.plugins[0]?.action).toBe("install");
573
+ expect(planIsBehind(absent)).toBe(true);
574
+
575
+ const stale = plan({
576
+ plugins: planPluginUpdates(
577
+ { "dev@magus": "latest" },
578
+ catalog(
579
+ info({
580
+ id: "dev@magus",
581
+ version: "4.6.1",
582
+ projectScope: { enabled: true, version: "4.6.1" },
583
+ contentStale: true,
584
+ }),
585
+ ),
586
+ ),
587
+ });
588
+ expect(stale.plugins[0]?.action).toBe("repair");
589
+ expect(planIsBehind(stale)).toBe(true);
432
590
  });
433
591
 
434
592
  test("a fully current profile with skills and bins passes the gate", () => {
@@ -445,7 +603,7 @@ describe("planIsBehind — the --check gate", () => {
445
603
  ),
446
604
  bins: planBinUpdates(
447
605
  [{ name: "tmux-mcp", via: "bun", version: "1.0.0", sources: [] }],
448
- [{ name: "tmux-mcp", present: true, installCommand: "", sources: [] }],
606
+ [toolFacts("tmux-mcp", { installed: "1.0.0" })],
449
607
  ),
450
608
  skills: planSkillUpdates([skillRef], new Set(["release"])),
451
609
  });
@@ -472,6 +630,7 @@ describe("nothing ever plans a downgrade", () => {
472
630
  );
473
631
  expect(item.action).toBe("current");
474
632
  expect(item.scopes).toEqual([]);
633
+ expect(item.reason).toBe("pin-undeliverable");
475
634
  });
476
635
 
477
636
  test("an older pin with no catalog entry does not drag an install backwards", () => {
@@ -489,6 +648,7 @@ describe("nothing ever plans a downgrade", () => {
489
648
  );
490
649
  expect(item.action).toBe("current");
491
650
  expect(item.scopes).toEqual([]);
651
+ expect(item.reason).toBe("up-to-date");
492
652
  });
493
653
 
494
654
  test("only the behind scope moves when one is ahead and one behind", () => {
@@ -504,6 +664,7 @@ describe("nothing ever plans a downgrade", () => {
504
664
  ),
505
665
  );
506
666
  expect(item.scopes).toEqual(["project"]);
667
+ expect(item.reason).toBe("behind");
507
668
  });
508
669
 
509
670
  test("scopesNeeding never returns a scope ahead of the target", () => {
@@ -519,8 +680,8 @@ describe("nothing ever plans a downgrade", () => {
519
680
 
520
681
  describe("scopesNeeding", () => {
521
682
  test("returns every outdated scope, not just user", () => {
522
- // The prerunner's original bug: it fixed 'user' and left the project row
523
- // behind, so the same drift reappeared on every run.
683
+ // Fixing only 'user' leaves the project row behind, so the same drift
684
+ // reappears on every run.
524
685
  const i = info({
525
686
  id: "dev@magus",
526
687
  userScope: { enabled: true, version: "4.6.1" },
@@ -546,29 +707,155 @@ describe("planBinUpdates", () => {
546
707
  sources: ["profile:test.cliTools"],
547
708
  ...over,
548
709
  });
549
- const check = (name: string, present: boolean): BinCheckResult => ({
550
- name,
551
- present,
552
- installCommand: "",
553
- sources: [],
554
- });
555
710
 
556
711
  test("a missing binary is an install", () => {
557
- const [item] = planBinUpdates([bin({})], [check("tmux-mcp", false)]);
558
- expect(item.action).toBe("install");
712
+ const [item] = planBinUpdates(
713
+ [bin({})],
714
+ [toolFacts("tmux-mcp", { present: false, installed: null })],
715
+ );
716
+ expect(item).toMatchObject({ action: "install", reason: "absent" });
559
717
  });
560
718
 
561
- test("present and unpinned is an upgrade that is what 'update' means", () => {
562
- const [item] = planBinUpdates([bin({})], [check("tmux-mcp", true)]);
563
- expect(item.action).toBe("upgrade");
719
+ test("present, unpinned and CURRENT is currentthe upgrade loop is gone", () => {
720
+ // This test used to read "present and unpinned is an upgrade — that is
721
+ // what 'update' means", and that was the defect: tmux, browser-use and
722
+ // mcp were current on the reporter's machine (brew / uv said so) and were
723
+ // "upgraded" on every run, forever.
724
+ const [item] = planBinUpdates(
725
+ [bin({ name: "tmux", via: "brew" })],
726
+ [toolFacts("tmux", { installed: "3.7c", behind: false })],
727
+ );
728
+ expect(item).toMatchObject({
729
+ action: "current",
730
+ reason: "up-to-date",
731
+ installed: "3.7c",
732
+ target: null,
733
+ });
734
+ expect(item.version).toBeUndefined();
564
735
  });
565
736
 
566
- test("present and pinned is left alone there is nothing to advance to", () => {
737
+ test("present and unpinned is an upgrade ONLY when its installer says behind", () => {
738
+ const [item] = planBinUpdates(
739
+ [bin({ name: "claude-swap", via: "pip" })],
740
+ [toolFacts("claude-swap", { behind: true, latest: "0.26.0" })],
741
+ );
742
+ expect(item).toMatchObject({
743
+ action: "upgrade",
744
+ reason: "behind",
745
+ target: "0.26.0",
746
+ });
747
+ });
748
+
749
+ test("present and at its pin is current, keeping the pin", () => {
567
750
  const [item] = planBinUpdates(
568
751
  [bin({ version: "1.2.3" })],
569
- [check("tmux-mcp", true)],
752
+ [toolFacts("tmux-mcp", { installed: "1.2.3" })],
753
+ );
754
+ expect(item).toMatchObject({
755
+ action: "current",
756
+ reason: "at-pin",
757
+ version: "1.2.3",
758
+ });
759
+ });
760
+
761
+ test("a bin with no facts was never probed: unknown, with a note", () => {
762
+ const [item] = planBinUpdates([bin({})], []);
763
+ expect(item).toMatchObject({
764
+ action: "unknown",
765
+ reason: "not-probed",
766
+ note: "not probed",
767
+ });
768
+ });
769
+
770
+ test("the note is classifyTool's note, verbatim (the TUI shows the same string)", () => {
771
+ const f = toolFacts("crush", {
772
+ managed: false,
773
+ resolvedPath: "/n/crush.js",
774
+ owner: "npm (root /l)",
775
+ });
776
+ const [item] = planBinUpdates([bin({ name: "crush", via: "npm" })], [f]);
777
+ expect(item.note).toBe(
778
+ classifyTool(bin({ name: "crush", via: "npm" }), f).note,
570
779
  );
571
- expect(item).toMatchObject({ action: "current", version: "1.2.3" });
780
+ });
781
+ });
782
+
783
+ describe("binNeedsWork — one predicate for apply, the meter and 'has work'", () => {
784
+ const actions: BinUpdateAction[] = [
785
+ "install",
786
+ "upgrade",
787
+ "current",
788
+ "unknown",
789
+ ];
790
+ const itemFor = (action: BinUpdateAction): BinUpdateItem => ({
791
+ name: `t-${action}`,
792
+ action,
793
+ reason: "not-probed",
794
+ installed: null,
795
+ latest: null,
796
+ target: null,
797
+ sources: [],
798
+ });
799
+
800
+ test("install and upgrade are work; current and unknown are not", () => {
801
+ expect(actions.map((a) => binNeedsWork(itemFor(a)))).toEqual([
802
+ true,
803
+ true,
804
+ false,
805
+ false,
806
+ ]);
807
+ });
808
+
809
+ test("applyBins, planWorkItems and planHasWork agree for every action", async () => {
810
+ for (const action of actions) {
811
+ const item = itemFor(action);
812
+ const p = plan({
813
+ bins: [item],
814
+ binSpecs: new Map([
815
+ [item.name, { name: item.name, via: "bun", sources: [] }],
816
+ ]),
817
+ });
818
+ const ran: string[] = [];
819
+ await applyBins(
820
+ p,
821
+ undefined,
822
+ async (c) => {
823
+ ran.push(c.cmd);
824
+ return true;
825
+ },
826
+ // Read-back seam: never the real PATH in a unit test.
827
+ async (s) => toolFacts(s.name, { installed: "2.0.0" }),
828
+ );
829
+ const expected = binNeedsWork(item);
830
+ expect(ran.length > 0).toBe(expected);
831
+ expect(planWorkItems(p).includes(item.name)).toBe(expected);
832
+ expect(planHasWork(p)).toBe(expected);
833
+ }
834
+ });
835
+
836
+ test("an upgrade TO the pin runs the pin command, not the upgrade command", () => {
837
+ const spec: ResolvedBin = {
838
+ name: "mnemex",
839
+ via: "bun",
840
+ version: "1.0.0",
841
+ sources: [],
842
+ };
843
+ expect(
844
+ binCommandFor(
845
+ { ...itemFor("upgrade"), name: "mnemex", target: "1.0.0" },
846
+ spec,
847
+ ),
848
+ ).toEqual({ cmd: "bun", args: ["install", "-g", "mnemex@1.0.0"] });
849
+ expect(
850
+ binCommandFor(
851
+ { ...itemFor("upgrade"), target: "2.0.0" },
852
+ {
853
+ ...spec,
854
+ version: undefined,
855
+ },
856
+ ),
857
+ ).toEqual({ cmd: "bun", args: ["install", "-g", "mnemex@latest"] });
858
+ expect(binCommandFor(itemFor("unknown"), spec)).toBeNull();
572
859
  });
573
860
  });
574
861
 
@@ -623,9 +910,464 @@ describe("planHasWork / summarizePlan", () => {
623
910
  ),
624
911
  bins: planBinUpdates(
625
912
  [{ name: "tmux-mcp", via: "bun", sources: [] }],
626
- [{ name: "tmux-mcp", present: true, installCommand: "", sources: [] }],
913
+ [toolFacts("tmux-mcp", { installed: "1.0.0", latest: "1.1.0" })],
627
914
  ),
628
915
  });
629
916
  expect(summarizePlan(p)).toMatchObject({ installed: 1, updated: 2 });
630
917
  });
918
+
919
+ test("unknown splits into plugins and tools, and the total is their sum", () => {
920
+ const p = plan({
921
+ plugins: planPluginUpdates(
922
+ { "a@magus": "latest" },
923
+ catalog(
924
+ info({
925
+ id: "a@magus",
926
+ version: null,
927
+ projectScope: { enabled: true, version: "abc123" },
928
+ }),
929
+ ),
930
+ ),
931
+ bins: planBinUpdates(
932
+ [
933
+ { name: "crush", via: "npm", sources: [] },
934
+ { name: "claude", via: "npm", sources: [] },
935
+ { name: "tmux", via: "brew", sources: [] },
936
+ ],
937
+ [
938
+ toolFacts("crush", { managed: false }),
939
+ toolFacts("claude", { managed: false }),
940
+ toolFacts("tmux", { behind: false }),
941
+ ],
942
+ ),
943
+ });
944
+ expect(summarizePlan(p)).toMatchObject({
945
+ unknownPlugins: 1,
946
+ unknownTools: 2,
947
+ unknown: 3,
948
+ current: 1,
949
+ });
950
+ });
951
+ });
952
+
953
+ // ── The classifier: rules the TUI and CLI now share ─────────────────────────
954
+
955
+ /**
956
+ * An orphan as `getAvailablePlugins` builds one: installed, published by no
957
+ * marketplace we read, and — the trap — `version` filled in from its own
958
+ * installed version, so a naive compare finds them equal.
959
+ */
960
+ function orphan(
961
+ id: string,
962
+ installedVersion: string,
963
+ over: Partial<PluginInfo> = {},
964
+ ): PluginInfo {
965
+ return info({
966
+ id,
967
+ version: installedVersion,
968
+ installedVersion,
969
+ isOrphaned: true,
970
+ projectScope: { enabled: true, version: installedVersion },
971
+ ...over,
972
+ });
973
+ }
974
+
975
+ describe("classifyPlugin — an orphan is never 'current'", () => {
976
+ test("REGRESSION: an orphan used to plan current against its own version", () => {
977
+ // Before: `catalog.has(id)` was true for an orphan, its `version` equalled
978
+ // its installed version, and the plan said `current` while the TUI drew
979
+ // the same row red as "deprecated".
980
+ const [item] = planPluginUpdates(
981
+ { "seo@magus": "latest" },
982
+ catalog(
983
+ orphan("seo@magus", "1.8.0"),
984
+ info({ id: "dev@magus", version: "4.7.0" }),
985
+ ),
986
+ );
987
+ expect(item.action).toBe("unknown");
988
+ expect(item.action).not.toBe("current");
989
+ expect(item.available).toBeNull();
990
+ expect(item.target).toBeNull();
991
+ expect(item.scopes).toEqual([]);
992
+ });
993
+
994
+ test("an orphan another marketplace publishes is 'moved', naming it", () => {
995
+ const [item] = planPluginUpdates(
996
+ { "seo@magus": "latest" },
997
+ catalog(
998
+ orphan("seo@magus", "1.8.0", { movedTo: "magus-marketing" }),
999
+ info({ id: "dev@magus", version: "4.7.0" }),
1000
+ ),
1001
+ );
1002
+ expect(item).toMatchObject({
1003
+ action: "unknown",
1004
+ reason: "moved",
1005
+ note: "moved to magus-marketing",
1006
+ });
1007
+ });
1008
+
1009
+ test("an orphan whose marketplace we DID read is 'not offered'", () => {
1010
+ const [item] = planPluginUpdates(
1011
+ { "seo@magus": "latest" },
1012
+ catalog(
1013
+ orphan("seo@magus", "1.8.0"),
1014
+ info({ id: "dev@magus", version: "4.7.0" }),
1015
+ ),
1016
+ );
1017
+ expect(item).toMatchObject({
1018
+ action: "unknown",
1019
+ reason: "not-offered",
1020
+ note: "not offered by any configured marketplace",
1021
+ });
1022
+ });
1023
+
1024
+ test("an orphan whose marketplace contributed nothing is 'marketplace-unread'", () => {
1025
+ // No listed id from @gone — we never read that catalog, so its absence is
1026
+ // not evidence it was dropped. Claiming "not offered" would be a guess.
1027
+ const [item] = planPluginUpdates(
1028
+ { "thing@gone": "latest" },
1029
+ catalog(
1030
+ orphan("thing@gone", "2.0.0"),
1031
+ info({ id: "dev@magus", version: "4.7.0" }),
1032
+ ),
1033
+ );
1034
+ expect(item).toMatchObject({
1035
+ action: "unknown",
1036
+ reason: "marketplace-unread",
1037
+ note: "its marketplace gone was not read, so whether it still offers this plugin is unknown",
1038
+ });
1039
+ });
1040
+
1041
+ test("another ORPHAN from the same marketplace is not proof we read it", () => {
1042
+ // Two orphans under @gone used to make `marketplaceIsKnown` true for each
1043
+ // other. Neither is listed, so neither proves anything.
1044
+ const [item] = planPluginUpdates(
1045
+ { "thing@gone": "latest" },
1046
+ catalog(orphan("thing@gone", "2.0.0"), orphan("other@gone", "1.0.0")),
1047
+ );
1048
+ expect(item.reason).toBe("marketplace-unread");
1049
+ });
1050
+
1051
+ test("an orphan is unknown under an exact pin too", () => {
1052
+ const [item] = planPluginUpdates(
1053
+ { "seo@magus": "1.8.0" },
1054
+ catalog(
1055
+ orphan("seo@magus", "1.8.0"),
1056
+ info({ id: "dev@magus", version: "4.7.0" }),
1057
+ ),
1058
+ );
1059
+ expect(item.action).toBe("unknown");
1060
+ });
1061
+ });
1062
+
1063
+ describe("classifyPlugin — an exact pin that cannot be ordered is unknown (F3)", () => {
1064
+ test("REGRESSION: a pinned plugin at an unorderable version used to be current", () => {
1065
+ // The "latest" path already refused this; the exact-pin path did not.
1066
+ const [item] = planPluginUpdates(
1067
+ { "playground@official": "7.3.0" },
1068
+ catalog(
1069
+ info({
1070
+ id: "playground@official",
1071
+ version: "7.3.0",
1072
+ projectScope: { enabled: true, version: "unknown" },
1073
+ }),
1074
+ ),
1075
+ );
1076
+ expect(item).toMatchObject({
1077
+ action: "unknown",
1078
+ reason: "unorderable",
1079
+ target: null,
1080
+ scopes: [],
1081
+ note: "installed version cannot be compared with the published 7.3.0",
1082
+ });
1083
+ });
1084
+
1085
+ test("with no published version the note names the pin instead", () => {
1086
+ const [item] = planPluginUpdates(
1087
+ { "playground@official": "7.3.0" },
1088
+ catalog(
1089
+ info({
1090
+ id: "playground@official",
1091
+ version: null,
1092
+ projectScope: { enabled: true, version: "3ea32df27be7" },
1093
+ }),
1094
+ ),
1095
+ );
1096
+ expect(item).toMatchObject({
1097
+ action: "unknown",
1098
+ reason: "unorderable",
1099
+ note: "installed version cannot be compared with the pin 7.3.0",
1100
+ });
1101
+ });
1102
+
1103
+ test("a scope that IS behind still wins: it is planned, not hidden", () => {
1104
+ const [item] = planPluginUpdates(
1105
+ { "dev@magus": "4.7.0" },
1106
+ catalog(
1107
+ info({
1108
+ id: "dev@magus",
1109
+ version: "4.7.0",
1110
+ userScope: { enabled: true, version: "unknown" },
1111
+ projectScope: { enabled: true, version: "4.6.1" },
1112
+ }),
1113
+ ),
1114
+ );
1115
+ expect(item).toMatchObject({ action: "update", scopes: ["project"] });
1116
+ });
1117
+ });
1118
+
1119
+ describe("classifyPlugin — install reasons", () => {
1120
+ test("enabled in settings with no files anywhere says so", () => {
1121
+ const [item] = planPluginUpdates(
1122
+ { "dev@magus": "latest" },
1123
+ catalog(
1124
+ info({
1125
+ id: "dev@magus",
1126
+ version: "4.7.0",
1127
+ projectScope: { enabled: true },
1128
+ }),
1129
+ ),
1130
+ );
1131
+ expect(item).toMatchObject({
1132
+ action: "install",
1133
+ reason: "enabled-not-installed",
1134
+ note: "enabled in settings, but no files on disk",
1135
+ });
1136
+ });
1137
+
1138
+ test("a plain absence carries no note", () => {
1139
+ const [item] = planPluginUpdates(
1140
+ { "dev@magus": "latest" },
1141
+ catalog(info({ id: "dev@magus", version: "4.7.0" })),
1142
+ );
1143
+ expect(item.reason).toBe("absent");
1144
+ expect(item.note).toBeUndefined();
1145
+ });
1146
+ });
1147
+
1148
+ describe("classifyPlugin — beforeVersions records every installed scope", () => {
1149
+ test("each installed scope's version, and nothing for an absent one", () => {
1150
+ const [item] = planPluginUpdates(
1151
+ { "dev@magus": "latest" },
1152
+ catalog(
1153
+ info({
1154
+ id: "dev@magus",
1155
+ version: "4.7.0",
1156
+ userScope: { enabled: true, version: "4.6.0" },
1157
+ projectScope: { enabled: true, version: "4.6.1" },
1158
+ localScope: { enabled: true },
1159
+ }),
1160
+ ),
1161
+ );
1162
+ expect(item.beforeVersions).toEqual({ user: "4.6.0", project: "4.6.1" });
1163
+ });
1164
+
1165
+ test("an uninstalled plugin has none", () => {
1166
+ const [item] = planPluginUpdates(
1167
+ { "dev@magus": "latest" },
1168
+ catalog(info({ id: "dev@magus", version: "4.7.0" })),
1169
+ );
1170
+ expect(item.beforeVersions).toEqual({});
1171
+ });
1172
+ });
1173
+
1174
+ describe("indexCatalog", () => {
1175
+ const idx = indexCatalog([
1176
+ info({ id: "dev@magus", version: "4.7.0" }),
1177
+ orphan("seo@magus", "1.8.0"),
1178
+ orphan("thing@gone", "2.0.0"),
1179
+ ]);
1180
+
1181
+ test("isListed excludes orphans", () => {
1182
+ expect(idx.isListed("dev@magus")).toBe(true);
1183
+ expect(idx.isListed("seo@magus")).toBe(false);
1184
+ expect(idx.isListed("absent@magus")).toBe(false);
1185
+ // Still reachable by id — the classifier needs its scopes.
1186
+ expect(idx.get("seo@magus")?.isOrphaned).toBe(true);
1187
+ });
1188
+
1189
+ test("marketplaceIsKnown counts OTHER listed ids only", () => {
1190
+ expect(idx.marketplaceIsKnown("seo@magus")).toBe(true); // dev@magus is listed
1191
+ expect(idx.marketplaceIsKnown("absent@magus")).toBe(true);
1192
+ // dev@magus is the only listed @magus id — itself does not count.
1193
+ expect(idx.marketplaceIsKnown("dev@magus")).toBe(false);
1194
+ // Only an orphan there.
1195
+ expect(idx.marketplaceIsKnown("other@gone")).toBe(false);
1196
+ expect(idx.marketplaceIsKnown("no-marketplace")).toBe(false);
1197
+ expect(idx.marketplaceIsKnown("trailing@")).toBe(false);
1198
+ });
1199
+
1200
+ test("a Map and an array index identically", () => {
1201
+ const list = [
1202
+ info({ id: "a@m", version: "1.0.0" }),
1203
+ info({ id: "b@m", version: "1.0.0" }),
1204
+ ];
1205
+ const fromMap = indexCatalog(catalog(...list));
1206
+ const fromArray = indexCatalog(list);
1207
+ for (const id of ["a@m", "b@m", "c@m"]) {
1208
+ expect(fromMap.isListed(id)).toBe(fromArray.isListed(id));
1209
+ expect(fromMap.marketplaceIsKnown(id)).toBe(
1210
+ fromArray.marketplaceIsKnown(id),
1211
+ );
1212
+ }
1213
+ });
1214
+ });
1215
+
1216
+ describe("classifyPlugin and planPluginUpdates are the same function", () => {
1217
+ test("planPluginUpdates is classifyPlugin over each pin, sorted", () => {
1218
+ const cat = catalog(
1219
+ info({
1220
+ id: "b@magus",
1221
+ version: "2.0.0",
1222
+ projectScope: { enabled: true, version: "1.0.0" },
1223
+ }),
1224
+ info({ id: "a@magus", version: "1.0.0" }),
1225
+ );
1226
+ const pins = { "b@magus": "latest", "a@magus": "1.0.0" };
1227
+ const idx = indexCatalog(cat);
1228
+ expect(planPluginUpdates(pins, cat)).toEqual([
1229
+ classifyPlugin("a@magus", "1.0.0", idx),
1230
+ classifyPlugin("b@magus", "latest", idx),
1231
+ ]);
1232
+ });
1233
+ });
1234
+
1235
+ describe("pinForRow — which pin a TUI row is judged against", () => {
1236
+ test("the profile's pin wins, exact or latest", () => {
1237
+ const i = info({
1238
+ id: "dev@magus",
1239
+ projectScope: { enabled: true, version: "1.0.0" },
1240
+ });
1241
+ expect(pinForRow(i, { "dev@magus": "4.6.1" })).toBe("4.6.1");
1242
+ expect(pinForRow(i, { "dev@magus": "latest" })).toBe("latest");
1243
+ });
1244
+
1245
+ test("a declared plugin gets its pin even when it is installed nowhere", () => {
1246
+ expect(
1247
+ pinForRow(info({ id: "dev@magus" }), { "dev@magus": "latest" }),
1248
+ ).toBe("latest");
1249
+ });
1250
+
1251
+ test("undeclared but installed in any scope is judged against latest", () => {
1252
+ for (const scope of ["userScope", "projectScope", "localScope"] as const) {
1253
+ const i = info({
1254
+ id: "dev@magus",
1255
+ [scope]: { enabled: false, version: "1.0.0" },
1256
+ });
1257
+ expect(pinForRow(i, {})).toBe("latest");
1258
+ }
1259
+ });
1260
+
1261
+ test("undeclared, enabled but not installed is judged against latest", () => {
1262
+ expect(
1263
+ pinForRow(info({ id: "dev@magus", localScope: { enabled: true } }), {}),
1264
+ ).toBe("latest");
1265
+ });
1266
+
1267
+ test("undeclared and neither installed nor enabled gets NO status", () => {
1268
+ // A catalog entry the user is only browsing. `install` would claim the
1269
+ // profile wants it, which nothing says.
1270
+ expect(
1271
+ pinForRow(info({ id: "dev@magus", version: "1.0.0" }), {}),
1272
+ ).toBeNull();
1273
+ });
1274
+
1275
+ test("an inherited key from Object.prototype is not a pin", () => {
1276
+ expect(
1277
+ pinForRow(info({ id: "toString", version: "1.0.0" }), {}),
1278
+ ).toBeNull();
1279
+ });
1280
+ });
1281
+
1282
+ describe("classifyPluginRows", () => {
1283
+ test("one status per row with a pin; browse-only rows are absent", () => {
1284
+ const rows = [
1285
+ info({ id: "declared@magus", version: "1.0.0" }),
1286
+ info({
1287
+ id: "installed@magus",
1288
+ version: "2.0.0",
1289
+ projectScope: { enabled: true, version: "1.0.0" },
1290
+ }),
1291
+ info({ id: "browse@magus", version: "3.0.0" }),
1292
+ ];
1293
+ const statuses = classifyPluginRows(rows, { "declared@magus": "latest" });
1294
+ expect([...statuses.keys()].sort()).toEqual([
1295
+ "declared@magus",
1296
+ "installed@magus",
1297
+ ]);
1298
+ expect(statuses.get("declared@magus")).toMatchObject({
1299
+ action: "install",
1300
+ reason: "absent",
1301
+ });
1302
+ expect(statuses.get("installed@magus")).toMatchObject({
1303
+ action: "update",
1304
+ reason: "behind",
1305
+ });
1306
+ expect(statuses.has("browse@magus")).toBe(false);
1307
+ });
1308
+
1309
+ test("a declared row gets exactly what the CLI plans for it", () => {
1310
+ const rows = [
1311
+ info({
1312
+ id: "dev@magus",
1313
+ version: "4.7.0",
1314
+ projectScope: { enabled: true, version: "4.6.1" },
1315
+ }),
1316
+ info({ id: "terminal@magus", version: "5.0.1" }),
1317
+ ];
1318
+ const pins = { "dev@magus": "4.6.1", "terminal@magus": "latest" };
1319
+ const cli = planPluginUpdates(pins, catalog(...rows));
1320
+ const tui = classifyPluginRows(rows, pins);
1321
+ for (const item of cli) expect(tui.get(item.pluginId)).toEqual(item);
1322
+ });
1323
+ });
1324
+
1325
+ describe("countPluginStates", () => {
1326
+ test("counts every action, including zeros", () => {
1327
+ const items = [
1328
+ { action: "install" },
1329
+ { action: "update" },
1330
+ { action: "update" },
1331
+ { action: "repair" },
1332
+ { action: "unknown" },
1333
+ ] as PluginUpdateItem[];
1334
+ expect(countPluginStates(items)).toEqual({
1335
+ install: 1,
1336
+ update: 2,
1337
+ repair: 1,
1338
+ current: 0,
1339
+ unknown: 1,
1340
+ });
1341
+ });
1342
+
1343
+ test("accepts a Map's values directly", () => {
1344
+ const m = new Map([["a", { action: "current" } as PluginUpdateItem]]);
1345
+ expect(countPluginStates(m.values()).current).toBe(1);
1346
+ });
1347
+ });
1348
+
1349
+ describe("scopeIsBehind — the scope picker's one question", () => {
1350
+ const status = (over: Partial<PluginUpdateItem>) =>
1351
+ ({ action: "update", scopes: ["project"], ...over }) as PluginUpdateItem;
1352
+
1353
+ test("an update touching the scope says yes", () => {
1354
+ expect(scopeIsBehind(status({}), "project")).toBe(true);
1355
+ });
1356
+
1357
+ test("an update touching OTHER scopes says no for this one", () => {
1358
+ expect(scopeIsBehind(status({}), "user")).toBe(false);
1359
+ });
1360
+
1361
+ test("repair, unknown, current and install all say no", () => {
1362
+ // `claude plugin install` on the same version is a no-op, so a repair
1363
+ // scope must not be offered "update".
1364
+ for (const action of ["repair", "unknown", "current", "install"] as const) {
1365
+ expect(scopeIsBehind(status({ action }), "project")).toBe(false);
1366
+ }
1367
+ });
1368
+
1369
+ test("no status says no", () => {
1370
+ expect(scopeIsBehind(null, "project")).toBe(false);
1371
+ expect(scopeIsBehind(undefined, "project")).toBe(false);
1372
+ });
631
1373
  });