magus-cli 7.5.0 → 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 (33) hide show
  1. package/package.json +5 -4
  2. package/src/__tests__/profile-absorb.test.ts +72 -0
  3. package/src/__tests__/profile-dependencies.test.ts +442 -0
  4. package/src/__tests__/profile-manifest-write.test.ts +231 -0
  5. package/src/__tests__/profile-skills.test.ts +99 -0
  6. package/src/__tests__/profile-state.test.ts +328 -13
  7. package/src/__tests__/stale-load-repair.test.ts +105 -0
  8. package/src/__tests__/support/preload.ts +14 -0
  9. package/src/__tests__/update-apply.test.ts +145 -0
  10. package/src/cli/bootstrap.ts +4 -0
  11. package/src/cli/doctor.ts +39 -6
  12. package/src/cli/install.ts +68 -4
  13. package/src/cli/router.ts +7 -0
  14. package/src/cli/update.ts +3 -0
  15. package/src/services/claude-cli.ts +24 -0
  16. package/src/services/claude-settings.ts +27 -0
  17. package/src/services/git-worktree.ts +92 -9
  18. package/src/services/manifest.ts +220 -3
  19. package/src/services/plugin-dependencies.ts +48 -0
  20. package/src/services/plugin-requires.ts +137 -14
  21. package/src/services/profile-absorb.ts +69 -15
  22. package/src/services/profile-activate.ts +179 -33
  23. package/src/services/profile-live.ts +66 -7
  24. package/src/services/profile-render.ts +73 -6
  25. package/src/services/profile-skills.ts +32 -7
  26. package/src/services/profile-state.ts +89 -5
  27. package/src/services/profiles.ts +6 -5
  28. package/src/services/resolver.ts +96 -3
  29. package/src/services/tracked-live-files.ts +125 -0
  30. package/src/services/update-engine.ts +87 -2
  31. package/src/types/index.ts +19 -0
  32. package/src/ui/screens/PluginsScreen.tsx +11 -2
  33. package/src/ui/screens/ProfilesScreen.tsx +10 -3
@@ -9,14 +9,23 @@ import { lstat, mkdtemp, readdir, rm } from "node:fs/promises";
9
9
  import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import fs from "fs-extra";
12
- import { profileGate } from "../cli/bootstrap.js";
12
+ import { ensureManifest, profileGate } from "../cli/bootstrap.js";
13
13
  import { reportProfileState, runDoctorCommand } from "../cli/doctor.js";
14
14
  import { runModelsCommand } from "../cli/models.js";
15
+ import { route } from "../cli/router.js";
16
+ import { installPlugin, uninstallPlugin } from "../services/claude-cli.js";
17
+ import {
18
+ readSettings,
19
+ writeMcpConfig,
20
+ writeSettings,
21
+ } from "../services/claude-settings.js";
22
+ import { trackedByGit } from "../services/git-worktree.js";
15
23
  import { readManifest, writeManifest } from "../services/manifest.js";
16
24
  import type { ModelsConfig } from "../services/models-core.js";
25
+ import { reconcileActiveProfile } from "../services/profile-absorb.js";
17
26
  import {
18
27
  activateProfile,
19
- activationNotes,
28
+ regenerateLiveFiles,
20
29
  trackedGeneratedFiles,
21
30
  } from "../services/profile-activate.js";
22
31
  import {
@@ -29,11 +38,16 @@ import {
29
38
  } from "../services/profile-live.js";
30
39
  import { renderProfile } from "../services/profile-render.js";
31
40
  import {
41
+ applyProfileChoice,
32
42
  checkActiveProfile,
33
43
  prepareProfileState,
34
44
  switchToProfile,
35
45
  } from "../services/profile-state.js";
36
46
  import { applyUserProfile } from "../services/profiles.js";
47
+ import {
48
+ GitCheckError,
49
+ TrackedLiveFileError,
50
+ } from "../services/tracked-live-files.js";
37
51
  import type { ProfileManifest } from "../types/index.js";
38
52
 
39
53
  const MANIFEST: ProfileManifest = {
@@ -673,25 +687,268 @@ function git(cwd: string, ...args: string[]) {
673
687
  }
674
688
 
675
689
  describe("generated files git still tracks", () => {
676
- test("activation reports a committed settings.json with the git rm --cached fix", async () => {
690
+ /** A repo that committed profiles.json AND settings.json, like magus-src. */
691
+ async function commitTrackedSettings(): Promise<void> {
677
692
  expect(git(project, "init", "-q").status).toBe(0);
678
- await fs.outputJson(settingsPath(), { committed: true });
679
- git(project, "add", ".claude/settings.json");
693
+ await fs.outputJson(settingsPath(), {
694
+ enabledPlugins: { "dev@magus": true },
695
+ installedPluginVersions: { "dev@magus": "8.0.0" },
696
+ });
697
+ git(project, "add", ".claude/settings.json", ".claude/profiles.json");
680
698
  expect(git(project, "commit", "-q", "-m", "init").status).toBe(0);
699
+ }
700
+
701
+ /** Everything B1 says must not change when magus refuses. */
702
+ async function untouched(): Promise<void> {
703
+ expect(git(project, "status", "--porcelain").stdout).toBe("");
704
+ expect(await fs.pathExists(join(project, ".claude", "profiles"))).toBe(
705
+ false,
706
+ );
707
+ expect(await fs.pathExists(backupRoot(project))).toBe(false);
708
+ expect(await fs.pathExists(join(project, ".gitignore"))).toBe(false);
709
+ }
710
+
711
+ test("B1: activating over a tracked settings.json writes nothing and names the fix", async () => {
712
+ await commitTrackedSettings();
713
+
714
+ const error = await activateProfile(MANIFEST, "team", project, {
715
+ onDisplace: () => {},
716
+ }).then(
717
+ () => null,
718
+ (e: unknown) => e,
719
+ );
720
+
721
+ expect(error).toBeInstanceOf(TrackedLiveFileError);
722
+ const message = (error as Error).message;
723
+ expect(message).toContain(".claude/settings.json");
724
+ expect(message).toContain("git rm --cached .claude/settings.json");
725
+ expect(message).toContain("Nothing was changed.");
726
+ await untouched();
727
+ });
728
+
729
+ test("B1: the gate, the switch path and a startup choice all refuse, writing nothing", async () => {
730
+ await commitTrackedSettings();
731
+
732
+ const prep = await prepareProfileState(project);
733
+ expect(prep.refusal).toBeInstanceOf(TrackedLiveFileError);
734
+ expect(prep.problems[0]).toContain("git rm --cached .claude/settings.json");
735
+ expect(await profileGate(project)).toBe(false);
681
736
 
682
- const result = await activateProfile(MANIFEST, "team", project, {
737
+ const switched = await switchToProfile(project, "other", {
683
738
  onDisplace: () => {},
684
739
  });
740
+ expect(switched.activation).toBeNull();
741
+ expect(switched.problems[0]).toContain(".claude/settings.json");
742
+
743
+ for (const choice of [
744
+ { kind: "activate", id: "team" },
745
+ { kind: "adopt", id: "default-2" },
746
+ ] as const) {
747
+ await expect(
748
+ applyProfileChoice(choice, MANIFEST, project, { onDisplace: () => {} }),
749
+ ).rejects.toBeInstanceOf(TrackedLiveFileError);
750
+ }
751
+ await untouched();
752
+ });
753
+
754
+ test("B1: the CLI router exits 1 for `magus profile switch`, and writes nothing", async () => {
755
+ await commitTrackedSettings();
756
+ const cwd = process.cwd();
757
+ process.chdir(project);
758
+ let outcome: Awaited<ReturnType<typeof route>>;
759
+ try {
760
+ outcome = await route(["profile", "switch", "other"], "0.0.0-test");
761
+ } finally {
762
+ process.chdir(cwd);
763
+ }
764
+ expect(outcome.exitCode).toBe(1);
765
+ expect(printed()).toContain("git rm --cached .claude/settings.json");
766
+ await untouched();
767
+ });
685
768
 
686
- expect(result.tracked).toEqual([".claude/settings.json"]);
687
- expect(activationNotes(result)).toContain(
688
- ".claude/settings.json is generated but tracked by git — run `git rm --cached .claude/settings.json`, then commit",
769
+ test("B1: once settings.json is tracked, a pulled change regenerates nothing", async () => {
770
+ expect(git(project, "init", "-q").status).toBe(0);
771
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
772
+ // Somebody commits the generated file after all.
773
+ git(project, "add", "-f", ".claude/settings.json");
774
+ git(project, "add", ".claude/profiles.json");
775
+ expect(git(project, "commit", "-q", "-m", "oops").status).toBe(0);
776
+ const before = await fs.readFile(settingsPath(), "utf8");
777
+ // A teammate's pull changes the profile.
778
+ const pulled = structuredClone(MANIFEST);
779
+ (pulled.profiles.team as { plugins: Record<string, string> }).plugins[
780
+ "mnemex@magus"
781
+ ] = "latest";
782
+ await writeManifest(pulled, project);
783
+ const manifestBefore = await fs.readFile(
784
+ join(project, ".claude", "profiles.json"),
785
+ "utf8",
786
+ );
787
+
788
+ const prep = await prepareProfileState(project);
789
+ expect(prep.problems[0]).toContain("git rm --cached .claude/settings.json");
790
+ expect((await reconcileActiveProfile(project)).problems[0]).toContain(
791
+ ".claude/settings.json",
689
792
  );
690
- // Report only: nothing was untracked.
793
+ await expect(regenerateLiveFiles(project)).rejects.toBeInstanceOf(
794
+ TrackedLiveFileError,
795
+ );
796
+ expect(await fs.readFile(settingsPath(), "utf8")).toBe(before);
797
+ expect(
798
+ await fs.readFile(join(project, ".claude", "profiles.json"), "utf8"),
799
+ ).toBe(manifestBefore);
800
+ });
801
+
802
+ test("B1: after `git rm --cached` and a commit, activation proceeds and keeps the committed profiles.json", async () => {
803
+ await commitTrackedSettings();
804
+ git(project, "rm", "-q", "--cached", ".claude/settings.json");
805
+ expect(git(project, "commit", "-q", "-m", "untrack").status).toBe(0);
806
+ const manifestBytes = await fs.readFile(
807
+ join(project, ".claude", "profiles.json"),
808
+ "utf8",
809
+ );
810
+
811
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
812
+
813
+ expect((await lstat(settingsPath())).isFile()).toBe(true);
814
+ expect((await readActiveMarker(project))?.profile).toBe("team");
691
815
  expect(
692
- git(project, "ls-files", "--error-unmatch", ".claude/settings.json")
693
- .status,
694
- ).toBe(0);
816
+ await fs.readFile(join(project, ".claude", "profiles.json"), "utf8"),
817
+ ).toBe(manifestBytes);
818
+ });
819
+
820
+ test("B1: adopting a repo with no profiles.json and a tracked settings.json writes nothing", async () => {
821
+ await fs.remove(join(project, ".claude", "profiles.json"));
822
+ expect(git(project, "init", "-q").status).toBe(0);
823
+ await fs.outputJson(settingsPath(), {
824
+ enabledPlugins: { "dev@magus": true },
825
+ });
826
+ git(project, "add", ".claude/settings.json");
827
+ expect(git(project, "commit", "-q", "-m", "init").status).toBe(0);
828
+ const home = await mkdtemp(join(tmpdir(), "state-adopt-home-"));
829
+ const savedHome = process.env.HOME;
830
+ process.env.HOME = home;
831
+ try {
832
+ await expect(
833
+ ensureManifest(project, { allowAdopt: true }),
834
+ ).rejects.toBeInstanceOf(TrackedLiveFileError);
835
+ } finally {
836
+ process.env.HOME = savedHome;
837
+ await rm(home, { recursive: true, force: true });
838
+ }
839
+ expect(await fs.pathExists(join(project, ".claude", "profiles.json"))).toBe(
840
+ false,
841
+ );
842
+ await untouched();
843
+ });
844
+
845
+ test("B1: doctor counts a tracked live file as a problem", async () => {
846
+ await commitTrackedSettings();
847
+ const counts = await reportProfileState(project, false);
848
+ expect(counts.unfixable).toBeGreaterThan(0);
849
+ expect(printed()).toContain("git rm --cached .claude/settings.json");
850
+ await untouched();
851
+ });
852
+
853
+ test("review M1: writeSettings and writeMcpConfig refuse a tracked live file and leave it as it is", async () => {
854
+ await commitTrackedSettings();
855
+ await fs.outputJson(join(project, ".mcp.json"), { mcpServers: {} });
856
+ git(project, "add", ".mcp.json");
857
+ expect(git(project, "commit", "-q", "-m", "mcp").status).toBe(0);
858
+ const before = await fs.readFile(settingsPath(), "utf8");
859
+
860
+ // What the TUI Profiles tab's "apply predefined" does.
861
+ const settings = await readSettings(project);
862
+ (settings.enabledPlugins as Record<string, boolean>)["frontend@magus"] =
863
+ true;
864
+ await expect(writeSettings(settings, project)).rejects.toBeInstanceOf(
865
+ TrackedLiveFileError,
866
+ );
867
+ await expect(
868
+ writeMcpConfig({ mcpServers: { x: { command: "x" } } }, project),
869
+ ).rejects.toBeInstanceOf(TrackedLiveFileError);
870
+
871
+ expect(await fs.readFile(settingsPath(), "utf8")).toBe(before);
872
+ await untouched();
873
+ });
874
+
875
+ test("review M1: a project-scope Claude CLI install or uninstall is refused before the CLI runs", async () => {
876
+ await commitTrackedSettings();
877
+ const cwd = process.cwd();
878
+ const pathBefore = process.env.PATH;
879
+ // git stays reachable; `claude` does not — were the refusal missing, the
880
+ // CLI could not run here and touch this machine's plugins anyway.
881
+ const gitDir = spawnSync("sh", ["-c", "command -v git"], {
882
+ encoding: "utf8",
883
+ }).stdout.trim();
884
+ process.env.PATH = gitDir.slice(0, gitDir.lastIndexOf("/"));
885
+ process.chdir(project);
886
+ try {
887
+ await expect(
888
+ installPlugin("dev@magus", "project"),
889
+ ).rejects.toBeInstanceOf(TrackedLiveFileError);
890
+ await expect(
891
+ uninstallPlugin("dev@magus", "project", project),
892
+ ).rejects.toBeInstanceOf(TrackedLiveFileError);
893
+ } finally {
894
+ process.chdir(cwd);
895
+ process.env.PATH = pathBefore;
896
+ }
897
+ await untouched();
898
+ });
899
+
900
+ test("review M1: a project without profiles writes its own settings.json as before", async () => {
901
+ await commitTrackedSettings();
902
+ await writeManifest({ version: 2, profiles: {} }, project);
903
+ const settings = await readSettings(project);
904
+ (settings.enabledPlugins as Record<string, boolean>)["frontend@magus"] =
905
+ true;
906
+ await writeSettings(settings, project);
907
+ expect(
908
+ (await fs.readJson(settingsPath())).enabledPlugins["frontend@magus"],
909
+ ).toBe(true);
910
+ });
911
+
912
+ test("review 3a: inside a work tree git cannot read, activation refuses with git's own message", async () => {
913
+ // A .git pointing at a repository that is not there: git exits 128.
914
+ await fs.outputFile(
915
+ join(project, ".git"),
916
+ `gitdir: ${join(project, "no-such-repository")}\n`,
917
+ );
918
+
919
+ const error = await activateProfile(MANIFEST, "team", project, {
920
+ onDisplace: () => {},
921
+ }).then(
922
+ () => null,
923
+ (e: unknown) => e,
924
+ );
925
+
926
+ expect(error).toBeInstanceOf(GitCheckError);
927
+ expect((error as Error).message).toContain("not a git repository");
928
+ expect((error as Error).message).toContain("Nothing was changed.");
929
+ expect(await fs.pathExists(settingsPath())).toBe(false);
930
+ expect(await fs.pathExists(join(project, ".claude", "profiles"))).toBe(
931
+ false,
932
+ );
933
+ const prep = await prepareProfileState(project);
934
+ expect(prep.refusal).toBeInstanceOf(GitCheckError);
935
+ });
936
+
937
+ test("review 3a: git missing is a refusal inside a work tree, and no check outside one", async () => {
938
+ expect(git(project, "init", "-q").status).toBe(0);
939
+ const outside = await mkdtemp(join(tmpdir(), "no-git-"));
940
+ const pathBefore = process.env.PATH;
941
+ // Not empty: an empty PATH falls back to the system default, where git is.
942
+ process.env.PATH = join(outside, "no-bin");
943
+ try {
944
+ await expect(
945
+ trackedByGit(project, [".claude/settings.json"]),
946
+ ).rejects.toThrow("could not run git");
947
+ expect([...(await trackedByGit(outside, [".mcp.json"]))]).toEqual([]);
948
+ } finally {
949
+ process.env.PATH = pathBefore;
950
+ await rm(outside, { recursive: true, force: true });
951
+ }
695
952
  });
696
953
 
697
954
  test("committed custom skills are not reported", async () => {
@@ -712,6 +969,64 @@ describe("generated files git still tracks", () => {
712
969
  });
713
970
  });
714
971
 
972
+ describe("machine-local settings survive every render", () => {
973
+ /** What `magus update` records: eleven plugins, as on the reporting machine. */
974
+ const VERSIONS = Object.fromEntries(
975
+ Array.from({ length: 11 }, (_, i) => [`p${i}@magus`, `1.${i}.0`]),
976
+ );
977
+
978
+ test("B3: first adoption carries installedPluginVersions out of the settings.json it moves aside", async () => {
979
+ await fs.outputJson(settingsPath(), {
980
+ enabledPlugins: { "hand@written": true },
981
+ installedPluginVersions: VERSIONS,
982
+ });
983
+ const moved: string[] = [];
984
+
985
+ await activateProfile(MANIFEST, "team", project, {
986
+ onDisplace: (artifact) => moved.push(artifact.savedTo),
987
+ });
988
+
989
+ expect(moved).toHaveLength(1);
990
+ const live = await fs.readJson(settingsPath());
991
+ expect(live.installedPluginVersions).toEqual(VERSIONS);
992
+ expect(live.enabledPlugins["dev@magus"]).toBe(true);
993
+ expect(live.enabledPlugins["hand@written"]).toBeUndefined();
994
+ // The backup still holds the whole original.
995
+ expect(
996
+ (await fs.readJson(moved[0] as string)).installedPluginVersions,
997
+ ).toEqual(VERSIONS);
998
+ });
999
+
1000
+ test("B3: a switch, then a regeneration from a pulled change, keep what an update recorded", async () => {
1001
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
1002
+ const live = await fs.readJson(settingsPath());
1003
+ live.installedPluginVersions = VERSIONS;
1004
+ await fs.writeJson(settingsPath(), live);
1005
+
1006
+ const switched = await switchToProfile(project, "other");
1007
+ expect(switched.problems).toEqual([]);
1008
+ expect((await fs.readJson(settingsPath())).installedPluginVersions).toEqual(
1009
+ VERSIONS,
1010
+ );
1011
+ // Machine state, not a contract: nothing of it reached profiles.json.
1012
+ expect(JSON.stringify(await readManifest(project))).not.toContain(
1013
+ "installedPluginVersions",
1014
+ );
1015
+
1016
+ // A teammate's commit adds a plugin to the active profile.
1017
+ const manifest = await readManifest(project);
1018
+ (manifest.profiles.other?.plugins as Record<string, string>)[
1019
+ "designer@magus"
1020
+ ] = "latest";
1021
+ await writeManifest(manifest, project);
1022
+ expect(await regenerateLiveFiles(project)).toBe(true);
1023
+
1024
+ const after = await fs.readJson(settingsPath());
1025
+ expect(after.enabledPlugins["designer@magus"]).toBe(true);
1026
+ expect(after.installedPluginVersions).toEqual(VERSIONS);
1027
+ });
1028
+ });
1029
+
715
1030
  describe("the gate is idempotent", () => {
716
1031
  test("a second pass with nothing changed prints nothing", async () => {
717
1032
  await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
@@ -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
+ });
@@ -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
+ });
@@ -18,6 +18,7 @@ import {
18
18
  type ActivationResult,
19
19
  activateProfile,
20
20
  activationNotes,
21
+ assertNoTrackedWriteTargets,
21
22
  reportDisplaced,
22
23
  } from "../services/profile-activate.js";
23
24
  import {
@@ -83,6 +84,9 @@ async function writeAndActivate(
83
84
  // their hand-written files went.
84
85
  const rel = (p: string) => path.relative(projectPath, p);
85
86
 
87
+ // Before the manifest is written: adopting activates, and activating a
88
+ // project whose settings.json git tracks would take that file over.
89
+ await assertNoTrackedWriteTargets(projectPath);
86
90
  await writeManifest(result.manifest, projectPath);
87
91
  console.log(`✓ Wrote ${rel(getManifestPath(projectPath))}`);
88
92