magus-cli 7.4.1 → 7.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +9 -8
  2. package/package.json +5 -4
  3. package/src/__tests__/install-command.test.ts +4 -3
  4. package/src/__tests__/models-cli.test.ts +4 -7
  5. package/src/__tests__/models-manager.test.ts +52 -40
  6. package/src/__tests__/profile-absorb.test.ts +1210 -0
  7. package/src/__tests__/profile-command.test.ts +90 -32
  8. package/src/__tests__/profile-dependencies.test.ts +442 -0
  9. package/src/__tests__/profile-manifest-write.test.ts +231 -0
  10. package/src/__tests__/profile-migration-offer.test.ts +18 -15
  11. package/src/__tests__/profile-migration.test.ts +246 -117
  12. package/src/__tests__/profile-render.test.ts +141 -0
  13. package/src/__tests__/profile-skills.test.ts +524 -0
  14. package/src/__tests__/profile-startup.test.ts +44 -14
  15. package/src/__tests__/profile-state.test.ts +1110 -0
  16. package/src/__tests__/settings-file.test.ts +5 -7
  17. package/src/__tests__/stale-load-repair.test.ts +105 -0
  18. package/src/__tests__/styles-manager.test.ts +47 -74
  19. package/src/__tests__/styles-screen-state.test.ts +11 -10
  20. package/src/__tests__/support/preload.ts +14 -0
  21. package/src/__tests__/update-apply.test.ts +145 -0
  22. package/src/cli/bootstrap.ts +91 -27
  23. package/src/cli/doctor.ts +165 -38
  24. package/src/cli/install.ts +126 -122
  25. package/src/cli/models.ts +21 -1
  26. package/src/cli/profile.ts +48 -77
  27. package/src/cli/router.ts +39 -6
  28. package/src/cli/update.ts +46 -6
  29. package/src/data/gitignore-defaults.ts +3 -3
  30. package/src/services/active-closure.ts +3 -3
  31. package/src/services/claude-cli.ts +24 -0
  32. package/src/services/claude-settings.ts +27 -0
  33. package/src/services/conventions-manager.ts +88 -0
  34. package/src/services/git-worktree.ts +113 -9
  35. package/src/services/manifest.ts +224 -8
  36. package/src/services/models-manager.ts +36 -73
  37. package/src/services/plugin-dependencies.ts +48 -0
  38. package/src/services/plugin-requires.ts +137 -14
  39. package/src/services/profile-absorb.ts +1586 -0
  40. package/src/services/profile-activate.ts +492 -94
  41. package/src/services/profile-live.ts +884 -0
  42. package/src/services/profile-render.ts +182 -0
  43. package/src/services/profile-skills.ts +487 -0
  44. package/src/services/profile-startup.ts +88 -16
  45. package/src/services/profile-state.ts +706 -0
  46. package/src/services/profiles.ts +26 -37
  47. package/src/services/resolver.ts +100 -2
  48. package/src/services/settings-file.ts +5 -5
  49. package/src/services/skills-manager.ts +14 -8
  50. package/src/services/styles-manager.ts +45 -28
  51. package/src/services/tracked-live-files.ts +125 -0
  52. package/src/services/update-engine.ts +87 -2
  53. package/src/types/index.ts +34 -9
  54. package/src/ui/App.tsx +25 -2
  55. package/src/ui/hooks/useProfileStartupModal.ts +20 -15
  56. package/src/ui/screens/PluginsScreen.tsx +11 -2
  57. package/src/ui/screens/ProfilesScreen.tsx +84 -29
  58. package/src/ui/screens/StylesScreen.tsx +2 -1
  59. package/src/ui/state/types.ts +1 -1
  60. package/src/__tests__/profile-materializer.test.ts +0 -238
  61. package/src/__tests__/profile-sync.test.ts +0 -154
  62. package/src/__tests__/symlink-manager.test.ts +0 -180
  63. package/src/services/profile-materializer.ts +0 -133
  64. package/src/services/profile-sync.ts +0 -158
  65. package/src/services/symlink-manager.ts +0 -343
@@ -5,11 +5,9 @@
5
5
  * their configuration:
6
6
  *
7
7
  * - an unparseable file must be REFUSED, and left byte-for-byte alone;
8
- * - a symlinked settings.json must be written THROUGH the link, because that
9
- * is the shape an active profile leaves behind (`.claude/settings.json` →
10
- * `.claude/_profiles/<name>/settings.json`). Remove-then-create would
11
- * replace the link with a plain file and detach the project from its
12
- * profile, which nothing downstream would notice.
8
+ * - a symlinked settings.json must be written THROUGH the link: a symlink a
9
+ * person set up deliberately is theirs. Remove-then-create would replace
10
+ * the link with a plain file, which nothing downstream would notice.
13
11
  */
14
12
 
15
13
  import { afterEach, describe, expect, test } from "bun:test";
@@ -154,7 +152,7 @@ describe("updateSettingsFile", () => {
154
152
  expect(readFileSync(file, "utf8")).toBe(original);
155
153
  });
156
154
 
157
- test("S-6: a symlinked settings.json is written THROUGH the link — the profile case", async () => {
155
+ test("S-6: a symlinked settings.json is written THROUGH the link", async () => {
158
156
  const dir = tempDir();
159
157
  const target = path.join(dir, "profile-settings.json");
160
158
  const link = path.join(dir, "settings.json");
@@ -168,7 +166,7 @@ describe("updateSettingsFile", () => {
168
166
  settings.outputStyle = "composed";
169
167
  });
170
168
 
171
- // The profile's real file received the write …
169
+ // The link target received the write …
172
170
  expect(JSON.parse(readFileSync(target, "utf8"))).toEqual({
173
171
  enabledPlugins: { "dev@magus": true },
174
172
  outputStyle: "composed",
@@ -13,6 +13,7 @@ import path from "node:path";
13
13
  import fs from "fs-extra";
14
14
  import {
15
15
  findStaleLoads,
16
+ loadedVersionHere,
16
17
  repairAllStaleLoads,
17
18
  repairStaleLoad,
18
19
  writeInstalledPluginsRegistry,
@@ -24,6 +25,11 @@ import {
24
25
  repoRootForPath,
25
26
  resolveLoadedVersions,
26
27
  } from "../services/plugin-resolution.js";
28
+ import {
29
+ type PluginApplyDeps,
30
+ applyPlugins,
31
+ bringLoadForward,
32
+ } from "../services/update-engine.js";
27
33
  import {
28
34
  classifyPluginRows,
29
35
  countPluginStates,
@@ -1075,3 +1081,102 @@ describe("repairAllStaleLoads", () => {
1075
1081
  expect(await backups()).toEqual([]);
1076
1082
  });
1077
1083
  });
1084
+
1085
+ describe("B4: an update brings the loader forward in the same run", () => {
1086
+ /**
1087
+ * `claude plugin update --scope project`, as far as the registry sees it:
1088
+ * this project's row moves to `version`, every other row stays.
1089
+ */
1090
+ async function cliUpdateHere(version: string): Promise<void> {
1091
+ const registry = await readRegistry();
1092
+ registry.plugins[PLUGIN] = registry.plugins[PLUGIN].map((e) =>
1093
+ e.projectPath === here
1094
+ ? { ...e, version, installPath: cacheFor(version) }
1095
+ : e,
1096
+ );
1097
+ await fs.outputJson(registryFile, registry, { spaces: 2 });
1098
+ }
1099
+
1100
+ /** The real repair and loader question, against this case's plugins root. */
1101
+ function realDeps(): PluginApplyDeps {
1102
+ return {
1103
+ install: async () => {},
1104
+ update: async () => cliUpdateHere("0.5.0"),
1105
+ repair: async () => {},
1106
+ readInstalled: async () => {
1107
+ const rows = (await readRegistry()).plugins[PLUGIN];
1108
+ return rows.find((e) => e.projectPath === here)?.version ?? null;
1109
+ },
1110
+ saveInstalled: async () => {},
1111
+ repairStaleLoad: (id, target, projectPath) =>
1112
+ repairStaleLoad(id, target, projectPath, root),
1113
+ loadedVersion: (id, projectPath) =>
1114
+ loadedVersionHere(id, projectPath, root),
1115
+ };
1116
+ }
1117
+
1118
+ test("B4: the deleted worktree's older row stops winning, and the update says so", async () => {
1119
+ await writeRegistry([
1120
+ row({ version: "0.3.0", projectPath: gone }),
1121
+ row({ version: "0.4.0", projectPath: here }),
1122
+ ]);
1123
+ const details: string[] = [];
1124
+
1125
+ const result = await applyPlugins(
1126
+ [
1127
+ {
1128
+ pluginId: PLUGIN,
1129
+ pinned: "latest",
1130
+ installed: "0.4.0",
1131
+ available: "0.5.0",
1132
+ target: "0.5.0",
1133
+ action: "update",
1134
+ reason: "behind",
1135
+ scopes: ["project"],
1136
+ beforeVersions: { project: "0.4.0" },
1137
+ },
1138
+ ],
1139
+ here,
1140
+ realDeps(),
1141
+ {
1142
+ begin: () => {},
1143
+ finish: (_ok, _name, detail) => details.push(detail),
1144
+ detach: (work) => work(),
1145
+ },
1146
+ );
1147
+
1148
+ expect(result.ok).toBe(1);
1149
+ expect(result.failed).toEqual([]);
1150
+ expect(details).toEqual([
1151
+ "project → 0.5.0 · Claude Code now loads v0.5.0 here (1 older record moved forward)",
1152
+ ]);
1153
+ expect(await loadedVersionHere(PLUGIN, here, root)).toBe("0.5.0");
1154
+ // Moved forward, nothing removed.
1155
+ expect(
1156
+ (await readRegistry()).plugins[PLUGIN].map((e) => [
1157
+ e.projectPath,
1158
+ e.version,
1159
+ ]),
1160
+ ).toEqual([
1161
+ [gone, "0.5.0"],
1162
+ [here, "0.5.0"],
1163
+ ]);
1164
+ });
1165
+
1166
+ test("B4: without the repair step the same update leaves the old version loading (negative control)", async () => {
1167
+ await writeRegistry([
1168
+ row({ version: "0.3.0", projectPath: gone }),
1169
+ row({ version: "0.4.0", projectPath: here }),
1170
+ ]);
1171
+ await cliUpdateHere("0.5.0");
1172
+ expect(await loadedVersionHere(PLUGIN, here, root)).toBe("0.3.0");
1173
+ });
1174
+
1175
+ test("B4: a loader already on the target is left alone — no write, no backup", async () => {
1176
+ await writeRegistry([row({ version: "0.5.0", projectPath: here })]);
1177
+ expect(
1178
+ await bringLoadForward(PLUGIN, "0.5.0", here, realDeps()),
1179
+ ).toBeNull();
1180
+ expect(await backups()).toEqual([]);
1181
+ });
1182
+ });
@@ -4,6 +4,13 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import fs from "fs-extra";
6
6
  import { EMBEDDED_PRESETS } from "../data/styles/index.js";
7
+ import { writeManifest } from "../services/manifest.js";
8
+ import { activateProfile } from "../services/profile-activate.js";
9
+ import {
10
+ liveHash,
11
+ readActiveMarker,
12
+ writeActiveMarker,
13
+ } from "../services/profile-live.js";
7
14
  import {
8
15
  INTEGRITY_BLOCK,
9
16
  type ImportedStyle,
@@ -19,6 +26,7 @@ import {
19
26
  splitFrontmatter,
20
27
  validateSelection,
21
28
  } from "../services/styles-manager.js";
29
+ import type { ProfileManifest } from "../types/index.js";
22
30
  import {
23
31
  buildStyleBrowserItems,
24
32
  firstSelectableIndex,
@@ -405,20 +413,26 @@ describe("applyStyles", () => {
405
413
  ).rejects.toThrow(/Nothing selected/);
406
414
  });
407
415
 
408
- test("writes THROUGH an active profile's settings symlink without breaking it", async () => {
409
- // The regression this guards: removing-then-recreating settings.json
410
- // silently detaches the project from its profile, and every later profile
411
- // switch then writes to a file nothing reads.
412
- const profileSettings = join(
416
+ const DEV: ProfileManifest = {
417
+ version: 2,
418
+ profiles: { dev: { name: "dev", plugins: {} } },
419
+ };
420
+
421
+ test("an older settings symlink is converted to a real file, not written through", async () => {
422
+ // Claude Code refuses to write through a symlink, so the style must not
423
+ // be applied by writing through one either: the file is rendered from
424
+ // the manifest in place of the link.
425
+ const oldSettings = join(
413
426
  dir,
414
427
  ".claude",
415
428
  "_profiles",
416
429
  "dev",
417
430
  "settings.json",
418
431
  );
419
- await fs.outputJson(profileSettings, { enabledPlugins: {} });
432
+ await fs.outputJson(oldSettings, { enabledPlugins: {} });
420
433
  const link = join(dir, ".claude", "settings.json");
421
434
  await symlink("_profiles/dev/settings.json", link);
435
+ await writeManifest(DEV, dir);
422
436
 
423
437
  const result = await applyStyles({
424
438
  projectPath: dir,
@@ -428,33 +442,13 @@ describe("applyStyles", () => {
428
442
 
429
443
  expect(result.profile).toBe("dev");
430
444
  expect(result.styleName).toBe("composed-dev");
431
- expect((await fs.lstat(link)).isSymbolicLink()).toBe(true);
432
- // The write landed in the profile, which is what "apply to the current
433
- // profile" means.
434
- expect((await fs.readJson(profileSettings)).outputStyle).toBe(
435
- "composed-dev",
436
- );
445
+ expect((await fs.lstat(link)).isSymbolicLink()).toBe(false);
446
+ expect((await fs.readJson(link)).outputStyle).toBe("composed-dev");
437
447
  });
438
448
 
439
- test("records the style in profiles.json so re-materializing cannot drop it", async () => {
440
- // materializeProfile rewrites settings.json from the manifest, so a live
441
- // write alone regresses on the next `magus install`.
442
- const profileSettings = join(
443
- dir,
444
- ".claude",
445
- "_profiles",
446
- "dev",
447
- "settings.json",
448
- );
449
- await fs.outputJson(profileSettings, { enabledPlugins: {} });
450
- await symlink(
451
- "_profiles/dev/settings.json",
452
- join(dir, ".claude", "settings.json"),
453
- );
454
- await fs.outputJson(join(dir, ".claude", "profiles.json"), {
455
- version: 2,
456
- profiles: { dev: { name: "dev", plugins: {} } },
457
- });
449
+ test("with an activated profile, the live file stays real and is recorded", async () => {
450
+ await writeManifest(DEV, dir);
451
+ await activateProfile(DEV, "dev", dir, { onDisplace: () => {} });
458
452
 
459
453
  const result = await applyStyles({
460
454
  projectPath: dir,
@@ -462,67 +456,46 @@ describe("applyStyles", () => {
462
456
  imports: [],
463
457
  });
464
458
 
459
+ const live = join(dir, ".claude", "settings.json");
465
460
  expect(result.recordedInManifest).toBe(true);
466
- const manifest = await fs.readJson(join(dir, ".claude", "profiles.json"));
467
- expect(manifest.profiles.dev.settings.outputStyle).toBe("composed-dev");
461
+ expect((await fs.lstat(live)).isFile()).toBe(true);
462
+ expect((await fs.readJson(live)).outputStyle).toBe("composed-dev");
463
+ // Rendered from the manifest and recorded, so it reads as unchanged.
464
+ expect((await readActiveMarker(dir))?.files.settings).toBe(
465
+ (await liveHash("settings", dir)) ?? "",
466
+ );
468
467
  });
469
468
 
470
- test("reports honestly when there is a profile but no manifest entry", async () => {
471
- const profileSettings = join(
472
- dir,
473
- ".claude",
474
- "_profiles",
475
- "dev",
476
- "settings.json",
477
- );
478
- await fs.outputJson(profileSettings, {});
479
- await symlink(
480
- "_profiles/dev/settings.json",
481
- join(dir, ".claude", "settings.json"),
482
- );
469
+ test("records the style in profiles.json, where rendering reads it", async () => {
470
+ await writeManifest(DEV, dir);
471
+ await activateProfile(DEV, "dev", dir, { onDisplace: () => {} });
483
472
 
484
473
  const result = await applyStyles({
485
474
  projectPath: dir,
486
475
  presets: [preset({ name: "direct" })],
487
476
  imports: [],
488
477
  });
489
- expect(result.profile).toBe("dev");
490
- expect(result.recordedInManifest).toBe(false);
491
- });
492
- });
493
478
 
494
- describe("clearStyle", () => {
495
- test("removes the key rather than setting it undefined", async () => {
496
- await applyStyles({
497
- projectPath: dir,
498
- presets: [preset({ name: "direct" })],
499
- imports: [],
500
- });
501
- await clearStyle(dir);
479
+ expect(result.recordedInManifest).toBe(true);
480
+ const manifest = await fs.readJson(join(dir, ".claude", "profiles.json"));
481
+ expect(manifest.profiles.dev.settings.outputStyle).toBe("composed-dev");
482
+ });
502
483
 
503
- const raw = await fs.readFile(
504
- join(dir, ".claude", "settings.json"),
505
- "utf8",
484
+ test("a marker naming a profile profiles.json lacks is no active profile", async () => {
485
+ await writeManifest(DEV, dir);
486
+ await writeActiveMarker(
487
+ { version: 1, profile: "gone", files: {}, skills: {} },
488
+ dir,
506
489
  );
507
- expect(raw).not.toContain("outputStyle");
508
- expect(JSON.parse(raw)).toEqual({});
509
- });
490
+ await fs.outputJson(join(dir, ".claude", "settings.json"), {});
510
491
 
511
- test("leaves the generated file on disk so a re-apply is one keypress away", async () => {
512
492
  const result = await applyStyles({
513
493
  projectPath: dir,
514
494
  presets: [preset({ name: "direct" })],
515
495
  imports: [],
516
496
  });
517
- await clearStyle(dir);
518
- expect(await fs.pathExists(result.stylePath)).toBe(true);
519
- });
520
-
521
- test("is a no-op on a project that never had a style", async () => {
522
- await clearStyle(dir);
523
- expect(await fs.pathExists(join(dir, ".claude", "settings.json"))).toBe(
524
- false,
525
- );
497
+ expect(result.profile).toBeNull();
498
+ expect(result.recordedInManifest).toBe(false);
526
499
  });
527
500
  });
528
501
 
@@ -1,15 +1,18 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdtemp, rm, symlink } from "node:fs/promises";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { parseKeypress } from "@opentui/core";
6
6
  import fs from "fs-extra";
7
+ import { writeManifest } from "../services/manifest.js";
8
+ import { activateProfile } from "../services/profile-activate.js";
7
9
  import {
8
10
  applyStyles,
9
11
  clearStyle,
10
12
  communityCacheDirOrNull,
11
13
  loadStyles,
12
14
  } from "../services/styles-manager.js";
15
+ import type { ProfileManifest } from "../types/index.js";
13
16
  import type { KeyPress } from "../ui/input/key-dispatch.js";
14
17
  import { SCREEN_BINDINGS, matchBinding } from "../ui/keymap.js";
15
18
  import { appReducer, initialState } from "../ui/state/reducer.js";
@@ -291,16 +294,14 @@ describe("loadStyles reports what is actually in force", () => {
291
294
  });
292
295
 
293
296
  test("looks for the per-profile style when a profile is active", async () => {
294
- await fs.outputJson(
295
- join(dir, ".claude", "_profiles", "dev", "settings.json"),
296
- {
297
- outputStyle: "composed-dev",
297
+ const manifest: ProfileManifest = {
298
+ version: 2,
299
+ profiles: {
300
+ dev: { name: "dev", settings: { outputStyle: "composed-dev" } },
298
301
  },
299
- );
300
- await symlink(
301
- "_profiles/dev/settings.json",
302
- join(dir, ".claude", "settings.json"),
303
- );
302
+ };
303
+ await writeManifest(manifest, dir);
304
+ await activateProfile(manifest, "dev", dir, { onDisplace: () => {} });
304
305
  await fs.outputFile(
305
306
  join(dir, ".claude", "output-styles", "composed-dev.md"),
306
307
  "---\nname: composed-dev\nstyle-presets: terse\nstyle-imports: none\n---\n\nbody",
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Loaded before every test file (bunfig.toml `[test] preload`).
3
+ *
4
+ * Rendering a profile reads each plugin's plugin.json to find its
5
+ * dependencies, from `~/.claude/plugins` — different on every machine, and
6
+ * absent on CI. Every test starts from "no plugin declares a dependency"; a
7
+ * test about dependencies installs its own reader and restores this one.
8
+ */
9
+
10
+ import { setPluginDependencyReader } from "../../services/plugin-dependencies.js";
11
+
12
+ export const NO_DEPENDENCIES = async (): Promise<string[]> => [];
13
+
14
+ setPluginDependencyReader(NO_DEPENDENCIES);
@@ -780,3 +780,148 @@ describe("applyPlugins — read-back against beforeVersions", () => {
780
780
  expect(reasons.lines(["other@magus"])).toBe("other@magus");
781
781
  });
782
782
  });
783
+
784
+ // ── B4: installed must mean LOADED ──────────────────────────────────────────
785
+ //
786
+ // Claude Code's loader takes the FIRST registry row relevant to the repository,
787
+ // and rows from this repository's other checkouts (deleted worktrees included)
788
+ // often precede this project's own. `claude plugin update` writes only this
789
+ // project's row, so an update could show "updated" and load the old version at
790
+ // once. The same operation now runs the stale-load repair as its last step.
791
+
792
+ describe("B4: an install or update ends with the loader on the new version", () => {
793
+ /** `loaded` is what the loader resolves until the repair has run. */
794
+ function loaderDeps(loaded: string | null, repairOk = true) {
795
+ const stub = stubDeps("2.0.0", (pluginId, target) =>
796
+ repairOk
797
+ ? {
798
+ pluginId,
799
+ target,
800
+ rewritten: 3,
801
+ backup: "/tmp/installed_plugins.json.backup.1",
802
+ loaded: target,
803
+ ok: true,
804
+ }
805
+ : {
806
+ pluginId,
807
+ target,
808
+ rewritten: 0,
809
+ backup: null,
810
+ loaded,
811
+ ok: false,
812
+ error: `the loader still resolves v${loaded} here, not v${target}`,
813
+ },
814
+ );
815
+ stub.deps.loadedVersion = mock(async () => loaded);
816
+ return stub;
817
+ }
818
+
819
+ test("B4: an update the loader does not pick up moves the older records forward and says so", async () => {
820
+ const { deps, rowsRepaired } = loaderDeps("1.0.0");
821
+ const { reporter, rows } = recordingReporter();
822
+
823
+ const result = await applyPlugins([item({})], PROJECT, deps, reporter);
824
+
825
+ expect(rowsRepaired).toEqual([
826
+ { id: "dev@magus", target: "2.0.0", projectPath: PROJECT },
827
+ ]);
828
+ expect(result.ok).toBe(1);
829
+ expect(result.failed).toEqual([]);
830
+ expect(rows).toEqual([
831
+ {
832
+ ok: true,
833
+ name: "dev@magus",
834
+ detail:
835
+ "project → 2.0.0 · Claude Code now loads v2.0.0 here (3 older records moved forward)",
836
+ },
837
+ ]);
838
+ });
839
+
840
+ test("B4: a loader already on the landed version runs no repair", async () => {
841
+ const { deps, rowsRepaired } = loaderDeps("2.0.0");
842
+ const { reporter, rows } = recordingReporter();
843
+
844
+ const result = await applyPlugins([item({})], PROJECT, deps, reporter);
845
+
846
+ expect(rowsRepaired).toEqual([]);
847
+ expect(result.ok).toBe(1);
848
+ expect(rows[0]?.detail).toBe("project → 2.0.0");
849
+ });
850
+
851
+ test("B4: a repair that cannot move the loader makes the update a failure, never 'updated'", async () => {
852
+ const { deps } = loaderDeps("1.0.0", false);
853
+ const { reporter, rows } = recordingReporter();
854
+
855
+ const result = await applyPlugins([item({})], PROJECT, deps, reporter);
856
+
857
+ expect(result.ok).toBe(0);
858
+ expect(result.failed).toEqual(["dev@magus"]);
859
+ expect(rows).toEqual([
860
+ {
861
+ ok: false,
862
+ name: "dev@magus",
863
+ detail:
864
+ "installed v2.0.0, but Claude Code still loads v1.0.0 here — the loader still resolves v1.0.0 here, not v2.0.0",
865
+ },
866
+ ]);
867
+ });
868
+
869
+ test("B4: an install whose version did not move still brings the loader forward, and is not 'no change'", async () => {
870
+ // The project row already held 2.0.0; the older row ahead of it is what
871
+ // loaded. Reporting "no change" would leave the plugin loading 1.0.0.
872
+ const { deps, rowsRepaired } = loaderDeps("1.0.0");
873
+
874
+ const result = await applyPlugins(
875
+ [item({ beforeVersions: { project: "2.0.0" } })],
876
+ PROJECT,
877
+ deps,
878
+ );
879
+
880
+ expect(rowsRepaired).toHaveLength(1);
881
+ expect(result.unchanged).toEqual([]);
882
+ expect(result.ok).toBe(1);
883
+ });
884
+
885
+ test("B4: the TUI's per-scope install and update helpers repair at project scope", async () => {
886
+ const a = loaderDeps("1.0.0");
887
+ expect(
888
+ await updatePluginInScope("dev@magus", "project", PROJECT, a.deps),
889
+ ).toBe("2.0.0");
890
+ expect(a.rowsRepaired).toEqual([
891
+ { id: "dev@magus", target: "2.0.0", projectPath: PROJECT },
892
+ ]);
893
+
894
+ const b = loaderDeps("1.0.0");
895
+ await installPluginInScope("dev@magus", "project", PROJECT, b.deps);
896
+ expect(b.rowsRepaired).toHaveLength(1);
897
+ });
898
+
899
+ test("B4: a per-scope helper whose repair fails throws, so the TUI reports a failure", async () => {
900
+ const { deps } = loaderDeps("1.0.0", false);
901
+ await expect(
902
+ updatePluginInScope("dev@magus", "project", PROJECT, deps),
903
+ ).rejects.toThrow(
904
+ "installed v2.0.0, but Claude Code still loads v1.0.0 here",
905
+ );
906
+ });
907
+
908
+ test("B4: the per-scope helpers bring the loader forward at user and local scope too", async () => {
909
+ for (const scope of ["user", "local"] as const) {
910
+ const up = loaderDeps("1.0.0");
911
+ await updatePluginInScope("dev@magus", scope, PROJECT, up.deps);
912
+ expect(up.rowsRepaired).toEqual([
913
+ { id: "dev@magus", target: "2.0.0", projectPath: PROJECT },
914
+ ]);
915
+ const fresh = loaderDeps("1.0.0");
916
+ await installPluginInScope("dev@magus", scope, PROJECT, fresh.deps);
917
+ expect(fresh.rowsRepaired).toHaveLength(1);
918
+ }
919
+ });
920
+
921
+ test("B4: a machine-wide winner the repair refuses fails the user-scope update, naming why", async () => {
922
+ const { deps } = loaderDeps("1.0.0", false);
923
+ await expect(
924
+ updatePluginInScope("dev@magus", "user", PROJECT, deps),
925
+ ).rejects.toThrow("the loader still resolves v1.0.0 here, not v2.0.0");
926
+ });
927
+ });