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
@@ -8,13 +8,23 @@
8
8
 
9
9
  import { beforeEach, describe, expect, mock, test } from "bun:test";
10
10
  import type { PluginScope } from "../services/claude-cli.js";
11
+ import type { ToolFacts } from "../services/tool-probe.js";
11
12
  import {
13
+ type ApplyReporter,
12
14
  type PluginApplyDeps,
15
+ applyBins,
16
+ applyPlan,
13
17
  applyPlugins,
14
18
  installPluginInScope,
15
19
  updatePluginInScope,
16
20
  } from "../services/update-engine.js";
17
- import type { PluginUpdateItem } from "../services/update-plan.js";
21
+ import type {
22
+ BinUpdateItem,
23
+ PluginUpdateItem,
24
+ UpdatePlan,
25
+ } from "../services/update-plan.js";
26
+ import type { ResolvedBin } from "../types/index.js";
27
+ import { noMoveReasons } from "../ui/adapters/pluginsAdapter.js";
18
28
 
19
29
  const PROJECT = "/tmp/project";
20
30
 
@@ -59,7 +69,9 @@ function item(over: Partial<PluginUpdateItem>): PluginUpdateItem {
59
69
  available: "2.0.0",
60
70
  target: "2.0.0",
61
71
  action: "update",
72
+ reason: "behind",
62
73
  scopes: ["project"],
74
+ beforeVersions: { project: "1.0.0" },
63
75
  ...over,
64
76
  };
65
77
  }
@@ -353,3 +365,273 @@ describe("installPluginInScope — a fresh install records what landed", () => {
353
365
  expect(updatePluginInScope.length).toBe(3);
354
366
  });
355
367
  });
368
+
369
+ // ── R6: an installer that exits 0 and changes nothing is never "changed" ────
370
+ //
371
+ // Planning (R4) refuses the upgrades it cannot justify, but it cannot see the
372
+ // no-ops that only appear after an installer runs: a stale brew tap, a PATH
373
+ // that still resolves an older copy, `~/.bun/bin` missing from PATH, a
374
+ // marketplace clone lagging its catalog. Each exits 0 and is replanned
375
+ // identically next run. Read-back — re-probing the one tool, or re-reading the
376
+ // registry row — is what tells them apart. No installer output is parsed.
377
+
378
+ const bin = (over: Partial<BinUpdateItem> = {}): BinUpdateItem => ({
379
+ name: "tmux",
380
+ action: "upgrade",
381
+ reason: "behind",
382
+ installed: "1.2.2",
383
+ latest: "1.2.3",
384
+ target: "1.2.3",
385
+ sources: [],
386
+ ...over,
387
+ });
388
+
389
+ const readBack = (over: Partial<ToolFacts> = {}): ToolFacts => ({
390
+ name: "tmux",
391
+ present: true,
392
+ resolvedPath: "/opt/homebrew/Cellar/tmux/1.2.3/bin/tmux",
393
+ installed: "1.2.3",
394
+ latest: null,
395
+ behind: false,
396
+ managed: true,
397
+ sources: [],
398
+ ...over,
399
+ });
400
+
401
+ function binPlan(item: BinUpdateItem): UpdatePlan {
402
+ const spec: ResolvedBin = { name: item.name, via: "brew", sources: [] };
403
+ return {
404
+ profileId: "test",
405
+ plugins: [],
406
+ bins: [item],
407
+ skills: [],
408
+ binSpecs: new Map([[item.name, spec]]),
409
+ };
410
+ }
411
+
412
+ function recordingReporter() {
413
+ const rows: Array<{ ok: boolean; name: string; detail: string }> = [];
414
+ const reporter: ApplyReporter = {
415
+ begin: () => {},
416
+ finish: (ok, name, detail) => rows.push({ ok, name, detail }),
417
+ detach: (work) => work(),
418
+ };
419
+ return { reporter, rows };
420
+ }
421
+
422
+ const RUN_OK = async () => true;
423
+
424
+ describe("applyBins — read-back", () => {
425
+ test("an upgrade that reads back the same version is UNCHANGED (v-prefix tolerant)", async () => {
426
+ const { reporter, rows } = recordingReporter();
427
+ const result = await applyBins(
428
+ binPlan(bin({ installed: "v1.2.3", target: "1.2.4" })),
429
+ reporter,
430
+ RUN_OK,
431
+ async () => readBack({ installed: "1.2.3" }),
432
+ );
433
+ expect(result).toEqual({ ok: 0, failed: [], unchanged: ["tmux"] });
434
+ // A finished row, not a failure, carrying the reason.
435
+ expect(rows).toEqual([
436
+ {
437
+ ok: true,
438
+ name: "tmux",
439
+ detail: "no change — the installer exited 0 but tmux is still 1.2.3",
440
+ },
441
+ ]);
442
+ });
443
+
444
+ test("a read-back where PATH still runs another copy is UNCHANGED", async () => {
445
+ const result = await applyBins(
446
+ binPlan(bin()),
447
+ undefined,
448
+ RUN_OK,
449
+ async () =>
450
+ readBack({
451
+ installed: "1.2.3",
452
+ managed: false,
453
+ resolvedPath: "/usr/local/bin/tmux",
454
+ }),
455
+ );
456
+ expect(result.unchanged).toEqual(["tmux"]);
457
+ expect(result.ok).toBe(0);
458
+ });
459
+
460
+ test("an install that is still not on PATH is UNCHANGED", async () => {
461
+ const { reporter, rows } = recordingReporter();
462
+ const result = await applyBins(
463
+ binPlan(bin({ action: "install", installed: null, reason: "absent" })),
464
+ reporter,
465
+ RUN_OK,
466
+ async () => readBack({ present: false, installed: null, managed: null }),
467
+ );
468
+ expect(result.unchanged).toEqual(["tmux"]);
469
+ expect(rows[0]!.detail).toContain("still not on PATH");
470
+ });
471
+
472
+ test("a read-back that cannot read a version counts as CHANGED", async () => {
473
+ // Failing to read is not evidence nothing moved — otherwise every
474
+ // go-less machine's upgrade would turn into a warning.
475
+ const result = await applyBins(
476
+ binPlan(bin()),
477
+ undefined,
478
+ RUN_OK,
479
+ async () => readBack({ installed: null }),
480
+ );
481
+ expect(result).toEqual({ ok: 1, failed: [], unchanged: [] });
482
+ });
483
+
484
+ test("a read-back that throws counts as CHANGED, never as a failure", async () => {
485
+ const result = await applyBins(
486
+ binPlan(bin()),
487
+ undefined,
488
+ RUN_OK,
489
+ async () => {
490
+ throw new Error("probe blew up");
491
+ },
492
+ );
493
+ expect(result).toEqual({ ok: 1, failed: [], unchanged: [] });
494
+ });
495
+
496
+ test("a real move is CHANGED", async () => {
497
+ const result = await applyBins(
498
+ binPlan(bin()),
499
+ undefined,
500
+ RUN_OK,
501
+ async () => readBack({ installed: "1.2.3" }),
502
+ );
503
+ expect(result).toEqual({ ok: 1, failed: [], unchanged: [] });
504
+ });
505
+
506
+ test("an installer that fails is FAILED and is not read back", async () => {
507
+ const probe = mock(async () => readBack());
508
+ const result = await applyBins(
509
+ binPlan(bin()),
510
+ undefined,
511
+ async () => false,
512
+ probe,
513
+ );
514
+ expect(result).toEqual({ ok: 0, failed: ["tmux"], unchanged: [] });
515
+ expect(probe).not.toHaveBeenCalled();
516
+ });
517
+
518
+ test("applyPlan: changed excludes the unchanged, which it lists", async () => {
519
+ const outcome = await applyPlan(
520
+ binPlan(bin({ installed: "1.2.2" })),
521
+ PROJECT,
522
+ { run: RUN_OK, probe: async () => readBack({ installed: "1.2.2" }) },
523
+ );
524
+ expect(outcome.changed).toBe(0);
525
+ expect(outcome.unchanged).toEqual(["tmux"]);
526
+ expect(outcome.failed).toEqual([]);
527
+ });
528
+ });
529
+
530
+ describe("applyPlugins — read-back against beforeVersions", () => {
531
+ test("an update whose every scope reads back its before-version is UNCHANGED", async () => {
532
+ // `claude plugin update` answering "already at the latest version" while
533
+ // the marketplace clone lags the catalog: exit 0, nothing moved.
534
+ const { deps } = stubDeps("1.0.0");
535
+ const { reporter, rows } = recordingReporter();
536
+ const result = await applyPlugins(
537
+ [item({ beforeVersions: { project: "1.0.0" } })],
538
+ PROJECT,
539
+ deps,
540
+ reporter,
541
+ );
542
+ expect(result.ok).toBe(0);
543
+ expect(result.unchanged).toEqual(["dev@magus"]);
544
+ expect(result.failed).toEqual([]);
545
+ expect(rows[0]).toMatchObject({ ok: true });
546
+ expect(rows[0]!.detail).toContain("still 1.0.0");
547
+ });
548
+
549
+ test("an update where ONE scope moved is CHANGED", async () => {
550
+ const { deps } = stubDeps((scope) =>
551
+ scope === "user" ? "2.0.0" : "1.0.0",
552
+ );
553
+ const result = await applyPlugins(
554
+ [
555
+ item({
556
+ scopes: ["user", "project"],
557
+ beforeVersions: { user: "1.0.0", project: "1.0.0" },
558
+ }),
559
+ ],
560
+ PROJECT,
561
+ deps,
562
+ );
563
+ expect(result.ok).toBe(1);
564
+ expect(result.unchanged).toEqual([]);
565
+ });
566
+
567
+ test("an install that reads back nothing in any scope is UNCHANGED", async () => {
568
+ const { deps } = stubDeps(null);
569
+ const result = await applyPlugins(
570
+ [
571
+ item({
572
+ action: "install",
573
+ installed: null,
574
+ scopes: [],
575
+ beforeVersions: {},
576
+ }),
577
+ ],
578
+ PROJECT,
579
+ deps,
580
+ );
581
+ expect(result.unchanged).toEqual(["dev@magus"]);
582
+ expect(result.ok).toBe(0);
583
+ });
584
+
585
+ test("an install that lands a version is CHANGED", async () => {
586
+ const { deps } = stubDeps("2.0.0");
587
+ const result = await applyPlugins(
588
+ [
589
+ item({
590
+ action: "install",
591
+ installed: null,
592
+ scopes: [],
593
+ beforeVersions: {},
594
+ }),
595
+ ],
596
+ PROJECT,
597
+ deps,
598
+ );
599
+ expect(result.ok).toBe(1);
600
+ });
601
+
602
+ test("a repair is always CHANGED — same version is its definition", async () => {
603
+ const { deps } = stubDeps("1.0.0");
604
+ const result = await applyPlugins(
605
+ [
606
+ item({
607
+ action: "repair",
608
+ target: "1.0.0",
609
+ beforeVersions: { project: "1.0.0" },
610
+ }),
611
+ ],
612
+ PROJECT,
613
+ deps,
614
+ );
615
+ expect(result.ok).toBe(1);
616
+ expect(result.unchanged).toEqual([]);
617
+ });
618
+
619
+ test("the TUI's collector names the same cause the CLI prints", async () => {
620
+ // The screen used to branch on `failed.length` alone: a no-op update
621
+ // closed with a success and a row that had not moved.
622
+ const { deps } = stubDeps("1.0.0");
623
+ const reasons = noMoveReasons();
624
+ const result = await applyPlugins(
625
+ [item({ beforeVersions: { project: "1.0.0" } })],
626
+ PROJECT,
627
+ deps,
628
+ reasons.reporter,
629
+ );
630
+ expect(result.unchanged).toEqual(["dev@magus"]);
631
+ expect(reasons.lines(result.unchanged)).toBe(
632
+ "dev@magus: the installer exited 0 but dev@magus is still 1.0.0",
633
+ );
634
+ // An item with no recorded cause still gets a line.
635
+ expect(reasons.lines(["other@magus"])).toBe("other@magus");
636
+ });
637
+ });