magus-cli 7.5.1 → 7.5.3

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.
@@ -13,7 +13,7 @@ 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
15
  import { route } from "../cli/router.js";
16
- import { installPlugin, uninstallPlugin } from "../services/claude-cli.js";
16
+ import { runUpdateCommand } from "../cli/update.js";
17
17
  import {
18
18
  readSettings,
19
19
  writeMcpConfig,
@@ -25,6 +25,7 @@ import type { ModelsConfig } from "../services/models-core.js";
25
25
  import { reconcileActiveProfile } from "../services/profile-absorb.js";
26
26
  import {
27
27
  activateProfile,
28
+ activationNotes,
28
29
  regenerateLiveFiles,
29
30
  trackedGeneratedFiles,
30
31
  } from "../services/profile-activate.js";
@@ -44,10 +45,6 @@ import {
44
45
  switchToProfile,
45
46
  } from "../services/profile-state.js";
46
47
  import { applyUserProfile } from "../services/profiles.js";
47
- import {
48
- GitCheckError,
49
- TrackedLiveFileError,
50
- } from "../services/tracked-live-files.js";
51
48
  import type { ProfileManifest } from "../types/index.js";
52
49
 
53
50
  const MANIFEST: ProfileManifest = {
@@ -686,7 +683,7 @@ function git(cwd: string, ...args: string[]) {
686
683
  );
687
684
  }
688
685
 
689
- describe("generated files git still tracks", () => {
686
+ describe("generated files git still tracks: written, and warned about", () => {
690
687
  /** A repo that committed profiles.json AND settings.json, like magus-src. */
691
688
  async function commitTrackedSettings(): Promise<void> {
692
689
  expect(git(project, "init", "-q").status).toBe(0);
@@ -698,61 +695,63 @@ describe("generated files git still tracks", () => {
698
695
  expect(git(project, "commit", "-q", "-m", "init").status).toBe(0);
699
696
  }
700
697
 
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
- }
698
+ /** magus never runs git: nothing is staged, whatever it wrote. */
699
+ const nothingStaged = () =>
700
+ expect(git(project, "diff", "--cached", "--name-only").stdout).toBe("");
701
+
702
+ const WARNING =
703
+ ".claude/settings.json is generated from .claude/profiles.json but tracked by git — run `git rm --cached .claude/settings.json`, then commit";
710
704
 
711
- test("B1: activating over a tracked settings.json writes nothing and names the fix", async () => {
705
+ test("B7: activating over a tracked settings.json writes it and names the fix", async () => {
712
706
  await commitTrackedSettings();
713
707
 
714
- const error = await activateProfile(MANIFEST, "team", project, {
708
+ const result = await activateProfile(MANIFEST, "team", project, {
715
709
  onDisplace: () => {},
716
- }).then(
717
- () => null,
718
- (e: unknown) => e,
719
- );
710
+ });
720
711
 
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();
712
+ expect(result.tracked).toEqual([".claude/settings.json"]);
713
+ expect(activationNotes(result)).toContain(WARNING);
714
+ expect((await fs.readJson(settingsPath())).enabledPlugins).toEqual({
715
+ "dev@magus": true,
716
+ "terminal@magus": false,
717
+ });
718
+ expect((await readActiveMarker(project))?.profile).toBe("team");
719
+ expect(git(project, "status", "--porcelain").stdout).toContain(
720
+ " M .claude/settings.json",
721
+ );
722
+ nothingStaged();
727
723
  });
728
724
 
729
- test("B1: the gate, the switch path and a startup choice all refuse, writing nothing", async () => {
725
+ test("B7: the gate, the switch path and a startup choice all write", async () => {
730
726
  await commitTrackedSettings();
731
727
 
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);
736
-
737
- const switched = await switchToProfile(project, "other", {
738
- onDisplace: () => {},
739
- });
740
- expect(switched.activation).toBeNull();
741
- expect(switched.problems[0]).toContain(".claude/settings.json");
742
-
743
728
  for (const choice of [
744
729
  { kind: "activate", id: "team" },
745
730
  { kind: "adopt", id: "default-2" },
746
731
  ] as const) {
747
- await expect(
748
- applyProfileChoice(choice, MANIFEST, project, { onDisplace: () => {} }),
749
- ).rejects.toBeInstanceOf(TrackedLiveFileError);
732
+ const result = await applyProfileChoice(choice, MANIFEST, project, {
733
+ onDisplace: () => {},
734
+ });
735
+ expect(result.profileId).toBe(choice.id);
750
736
  }
751
- await untouched();
737
+ const prep = await prepareProfileState(project);
738
+ expect(prep.problems).toEqual([]);
739
+ expect(await profileGate(project)).toBe(true);
740
+
741
+ const switched = await switchToProfile(project, "other", {
742
+ onDisplace: () => {},
743
+ });
744
+ expect(switched.problems).toEqual([]);
745
+ expect(switched.activation?.profileId).toBe("other");
746
+ expect(
747
+ (await fs.readJson(settingsPath())).enabledPlugins["terminal@magus"],
748
+ ).toBe(true);
749
+ nothingStaged();
752
750
  });
753
751
 
754
- test("B1: the CLI router exits 1 for `magus profile switch`, and writes nothing", async () => {
752
+ test("B7: `magus profile switch` exits 0 and warns once, running no git", async () => {
755
753
  await commitTrackedSettings();
754
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
756
755
  const cwd = process.cwd();
757
756
  process.chdir(project);
758
757
  let outcome: Awaited<ReturnType<typeof route>>;
@@ -761,63 +760,47 @@ describe("generated files git still tracks", () => {
761
760
  } finally {
762
761
  process.chdir(cwd);
763
762
  }
764
- expect(outcome.exitCode).toBe(1);
765
- expect(printed()).toContain("git rm --cached .claude/settings.json");
766
- await untouched();
763
+ expect(outcome.exitCode).toBe(0);
764
+ expect(printed().split(WARNING).length - 1).toBe(1);
765
+ expect((await readActiveMarker(project))?.profile).toBe("other");
766
+ nothingStaged();
767
+ });
768
+
769
+ test("B7: `magus update` warns once about a tracked generated file, running no git", async () => {
770
+ await commitTrackedSettings();
771
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
772
+ // A named profile that does not exist: the command stops right after the
773
+ // warning, before anything reaches the network.
774
+ expect(await runUpdateCommand(["no-such-profile"], project)).toBe(1);
775
+ expect(printed().split(WARNING).length - 1).toBe(1);
776
+ nothingStaged();
767
777
  });
768
778
 
769
- test("B1: once settings.json is tracked, a pulled change regenerates nothing", async () => {
779
+ test("B7: once settings.json is tracked, a pulled change is still regenerated", async () => {
770
780
  expect(git(project, "init", "-q").status).toBe(0);
771
781
  await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
772
782
  // Somebody commits the generated file after all.
773
783
  git(project, "add", "-f", ".claude/settings.json");
774
784
  git(project, "add", ".claude/profiles.json");
775
785
  expect(git(project, "commit", "-q", "-m", "oops").status).toBe(0);
776
- const before = await fs.readFile(settingsPath(), "utf8");
777
786
  // A teammate's pull changes the profile.
778
787
  const pulled = structuredClone(MANIFEST);
779
788
  (pulled.profiles.team as { plugins: Record<string, string> }).plugins[
780
789
  "mnemex@magus"
781
790
  ] = "latest";
782
791
  await writeManifest(pulled, project);
783
- const manifestBefore = await fs.readFile(
784
- join(project, ".claude", "profiles.json"),
785
- "utf8",
786
- );
787
792
 
788
793
  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",
792
- );
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
794
 
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");
795
+ expect(prep.problems).toEqual([]);
796
+ expect(prep.lines.join("\n")).toContain("regenerated");
815
797
  expect(
816
- await fs.readFile(join(project, ".claude", "profiles.json"), "utf8"),
817
- ).toBe(manifestBytes);
798
+ (await fs.readJson(settingsPath())).enabledPlugins["mnemex@magus"],
799
+ ).toBe(true);
800
+ nothingStaged();
818
801
  });
819
802
 
820
- test("B1: adopting a repo with no profiles.json and a tracked settings.json writes nothing", async () => {
803
+ test("B7: adopting a repo with no profiles.json and a tracked settings.json writes and warns", async () => {
821
804
  await fs.remove(join(project, ".claude", "profiles.json"));
822
805
  expect(git(project, "init", "-q").status).toBe(0);
823
806
  await fs.outputJson(settingsPath(), {
@@ -829,112 +812,112 @@ describe("generated files git still tracks", () => {
829
812
  const savedHome = process.env.HOME;
830
813
  process.env.HOME = home;
831
814
  try {
832
- await expect(
833
- ensureManifest(project, { allowAdopt: true }),
834
- ).rejects.toBeInstanceOf(TrackedLiveFileError);
815
+ expect(
816
+ await ensureManifest(project, { allowAdopt: true }),
817
+ ).not.toBeNull();
835
818
  } finally {
836
819
  process.env.HOME = savedHome;
837
820
  await rm(home, { recursive: true, force: true });
838
821
  }
839
- expect(await fs.pathExists(join(project, ".claude", "profiles.json"))).toBe(
840
- false,
822
+ const manifest = await readManifest(project);
823
+ expect(Object.keys(manifest.profiles)).toHaveLength(1);
824
+ expect(await activeProfile(project)).toBe(
825
+ Object.keys(manifest.profiles)[0],
841
826
  );
842
- await untouched();
827
+ expect(printed()).toContain(WARNING);
828
+ nothingStaged();
843
829
  });
844
830
 
845
- test("B1: doctor counts a tracked live file as a problem", async () => {
831
+ test("B7: doctor notes a tracked live file with the fix, and does not count it", async () => {
846
832
  await commitTrackedSettings();
833
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
847
834
  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();
835
+ expect(counts.unfixable).toBe(0);
836
+ expect(printed()).toContain(`• ${WARNING}`);
851
837
  });
852
838
 
853
- test("review M1: writeSettings and writeMcpConfig refuse a tracked live file and leave it as it is", async () => {
839
+ test("B7: writeSettings and writeMcpConfig write a tracked live file", async () => {
854
840
  await commitTrackedSettings();
855
841
  await fs.outputJson(join(project, ".mcp.json"), { mcpServers: {} });
856
842
  git(project, "add", ".mcp.json");
857
843
  expect(git(project, "commit", "-q", "-m", "mcp").status).toBe(0);
858
- const before = await fs.readFile(settingsPath(), "utf8");
859
844
 
860
845
  // What the TUI Profiles tab's "apply predefined" does.
861
846
  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
847
  (settings.enabledPlugins as Record<string, boolean>)["frontend@magus"] =
905
848
  true;
906
849
  await writeSettings(settings, project);
850
+ await writeMcpConfig({ mcpServers: { x: { command: "x" } } }, project);
851
+
907
852
  expect(
908
853
  (await fs.readJson(settingsPath())).enabledPlugins["frontend@magus"],
909
854
  ).toBe(true);
855
+ expect((await fs.readJson(join(project, ".mcp.json"))).mcpServers).toEqual({
856
+ x: { command: "x" },
857
+ });
858
+ nothingStaged();
910
859
  });
911
860
 
912
- test("review 3a: inside a work tree git cannot read, activation refuses with git's own message", async () => {
861
+ test("B7: inside a work tree git cannot read, activation writes and says git could not be asked", async () => {
913
862
  // A .git pointing at a repository that is not there: git exits 128.
914
863
  await fs.outputFile(
915
864
  join(project, ".git"),
916
865
  `gitdir: ${join(project, "no-such-repository")}\n`,
917
866
  );
918
867
 
919
- const error = await activateProfile(MANIFEST, "team", project, {
868
+ const result = await activateProfile(MANIFEST, "team", project, {
920
869
  onDisplace: () => {},
921
- }).then(
922
- () => null,
923
- (e: unknown) => e,
870
+ });
871
+
872
+ expect(await fs.pathExists(settingsPath())).toBe(true);
873
+ expect(typeof result.tracked).toBe("string");
874
+ expect(activationNotes(result).join("\n")).toContain(
875
+ "magus could not check which files git tracks in this project",
924
876
  );
877
+ expect((await prepareProfileState(project)).problems).toEqual([]);
878
+ });
925
879
 
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,
880
+ test("B7 review L3: converting an older layout where git cannot answer exits 1 with git's message, no stack trace", async () => {
881
+ // 7.4.1 layout, a listed skill linked: the conversion must ask git which
882
+ // skill folders the team committed. `--dry-run` skips the entry gate, so
883
+ // the error reaches the router's own handler.
884
+ const withSkill = structuredClone(MANIFEST);
885
+ (withSkill.profiles.team as { skills: unknown[] }).skills = [
886
+ { name: "alpha", repo: "a/b", path: "skills/alpha" },
887
+ ];
888
+ await writeManifest(withSkill, project);
889
+ const old = join(project, ".claude", "_profiles", "team");
890
+ await fs.outputFile(join(old, "skills", "alpha", "SKILL.md"), "# alpha\n");
891
+ await fs.outputJson(join(old, "settings.json"), {
892
+ enabledPlugins: { "dev@magus": true },
893
+ });
894
+ await fs.symlink("_profiles/team/settings.json", settingsPath());
895
+ await fs.symlink(
896
+ "_profiles/team/skills",
897
+ join(project, ".claude", "skills"),
898
+ );
899
+ await fs.outputFile(
900
+ join(project, ".git"),
901
+ `gitdir: ${join(project, "no-such-repository")}\n`,
902
+ );
903
+ const cwd = process.cwd();
904
+ process.chdir(project);
905
+ let outcome: Awaited<ReturnType<typeof route>>;
906
+ try {
907
+ outcome = await route(
908
+ ["profile", "switch", "team", "--dry-run"],
909
+ "0.0.0-test",
910
+ );
911
+ } finally {
912
+ process.chdir(cwd);
913
+ }
914
+ expect(outcome.exitCode).toBe(1);
915
+ expect(printed()).toContain(
916
+ "magus could not check which files git tracks in this project. git said:",
932
917
  );
933
- const prep = await prepareProfileState(project);
934
- expect(prep.refusal).toBeInstanceOf(GitCheckError);
935
918
  });
936
919
 
937
- test("review 3a: git missing is a refusal inside a work tree, and no check outside one", async () => {
920
+ test("git missing is an error inside a work tree, and no check outside one", async () => {
938
921
  expect(git(project, "init", "-q").status).toBe(0);
939
922
  const outside = await mkdtemp(join(tmpdir(), "no-git-"));
940
923
  const pathBefore = process.env.PATH;
@@ -1180,3 +1180,163 @@ describe("B4: an update brings the loader forward in the same run", () => {
1180
1180
  expect(await backups()).toEqual([]);
1181
1181
  });
1182
1182
  });
1183
+
1184
+ describe("7.5.2: Claude Code 2.1.281 updates another project's row of the repository", () => {
1185
+ /**
1186
+ * `claude plugin update --scope project` as 2.1.281 does it: the FIRST row
1187
+ * relevant to the repository moves to `version` — here, the main tree's —
1188
+ * and this worktree's own row stays where it was.
1189
+ */
1190
+ async function cli281(version: string): Promise<void> {
1191
+ const registry = await readRegistry();
1192
+ const rows = registry.plugins[PLUGIN];
1193
+ const first = pickLoadedEntry(rows, here) as InstalledPluginEntry;
1194
+ first.version = version;
1195
+ first.installPath = cacheFor(version);
1196
+ await fs.outputJson(registryFile, registry, { spaces: 2 });
1197
+ }
1198
+
1199
+ /** The real registry, rewrite and loader question, against this root. */
1200
+ function deps(version: string): PluginApplyDeps {
1201
+ return {
1202
+ install: async () => {},
1203
+ update: async () => cli281(version),
1204
+ repair: async () => {},
1205
+ readInstalled: async () =>
1206
+ (await readRegistry()).plugins[PLUGIN].find(
1207
+ (e) => e.projectPath === here,
1208
+ )?.version ?? null,
1209
+ saveInstalled: async () => {},
1210
+ registryRows: async () => (await readRegistry()).plugins[PLUGIN],
1211
+ repairStaleLoad: (id, target, projectPath) =>
1212
+ repairStaleLoad(id, target, projectPath, root),
1213
+ loadedVersion: (id, projectPath) =>
1214
+ loadedVersionHere(id, projectPath, root),
1215
+ };
1216
+ }
1217
+
1218
+ const item = (target: string) => ({
1219
+ pluginId: PLUGIN,
1220
+ pinned: "latest",
1221
+ installed: "0.4.0",
1222
+ available: target,
1223
+ target,
1224
+ action: "update" as const,
1225
+ reason: "behind" as const,
1226
+ scopes: ["project" as const],
1227
+ beforeVersions: { project: "0.4.0" },
1228
+ });
1229
+
1230
+ function collect() {
1231
+ const details: string[] = [];
1232
+ return {
1233
+ details,
1234
+ reporter: {
1235
+ begin: () => {},
1236
+ finish: (_ok: boolean, _name: string, detail: string) =>
1237
+ details.push(detail),
1238
+ detach: <T>(work: () => T) => work(),
1239
+ },
1240
+ };
1241
+ }
1242
+
1243
+ test("7.5.2: this worktree's row follows to the version the CLI fetched, and the update says where it wrote", async () => {
1244
+ await writeRegistry([
1245
+ row({ version: "0.4.0", projectPath: mainTree }),
1246
+ row({ version: "0.4.0", projectPath: here }),
1247
+ ]);
1248
+ const { details, reporter } = collect();
1249
+
1250
+ const result = await applyPlugins(
1251
+ [item("0.5.0")],
1252
+ here,
1253
+ deps("0.5.0"),
1254
+ reporter,
1255
+ );
1256
+
1257
+ expect(result.ok).toBe(1);
1258
+ expect(result.unchanged).toEqual([]);
1259
+ expect(
1260
+ (await readRegistry()).plugins[PLUGIN].map((e) => [
1261
+ e.projectPath,
1262
+ e.version,
1263
+ ]),
1264
+ ).toEqual([
1265
+ [mainTree, "0.5.0"],
1266
+ [here, "0.5.0"],
1267
+ ]);
1268
+ expect(details).toEqual([
1269
+ `project → 0.5.0 · Claude Code updated ${mainTree} instead of this project — moved this project's record to v0.5.0 too`,
1270
+ ]);
1271
+ expect(await loadedVersionHere(PLUGIN, here, root)).toBe("0.5.0");
1272
+ });
1273
+
1274
+ test("7.5.2 review: a row of an UNRELATED repository moving during the update is not followed", async () => {
1275
+ const other = path.join(base, "unrelated-752");
1276
+ await fs.ensureDir(other);
1277
+ git(other, "init", "-q");
1278
+ await writeRegistry([
1279
+ row({ version: "0.4.0", projectPath: other }),
1280
+ row({ version: "0.4.0", projectPath: here }),
1281
+ ]);
1282
+ const { details, reporter } = collect();
1283
+ const unrelated: PluginApplyDeps = {
1284
+ ...deps("0.5.0"),
1285
+ // Another repository's session updates its own row meanwhile.
1286
+ update: async () => {
1287
+ const registry = await readRegistry();
1288
+ const moved = registry.plugins[PLUGIN].find(
1289
+ (e) => e.projectPath === other,
1290
+ ) as InstalledPluginEntry;
1291
+ moved.version = "0.5.0";
1292
+ moved.installPath = cacheFor("0.5.0");
1293
+ await fs.outputJson(registryFile, registry, { spaces: 2 });
1294
+ },
1295
+ };
1296
+
1297
+ const result = await applyPlugins(
1298
+ [item("0.5.0")],
1299
+ here,
1300
+ unrelated,
1301
+ reporter,
1302
+ );
1303
+
1304
+ expect(result.unchanged).toEqual([PLUGIN]);
1305
+ expect(
1306
+ (await readRegistry()).plugins[PLUGIN].map((e) => [
1307
+ e.projectPath,
1308
+ e.version,
1309
+ ]),
1310
+ ).toEqual([
1311
+ [other, "0.5.0"],
1312
+ [here, "0.4.0"],
1313
+ ]);
1314
+ expect(details).toEqual([
1315
+ `no change — the installer exited 0 but ${PLUGIN} is still 0.4.0`,
1316
+ ]);
1317
+ });
1318
+
1319
+ test("7.5.2: a version with no cache directory is not followed — no change, and why", async () => {
1320
+ await writeRegistry([
1321
+ row({ version: "0.4.0", projectPath: mainTree }),
1322
+ row({ version: "0.4.0", projectPath: here }),
1323
+ ]);
1324
+ const { details, reporter } = collect();
1325
+
1326
+ const result = await applyPlugins(
1327
+ [item("0.6.0")],
1328
+ here,
1329
+ deps("0.6.0"),
1330
+ reporter,
1331
+ );
1332
+
1333
+ expect(result.unchanged).toEqual([PLUGIN]);
1334
+ expect(
1335
+ (await readRegistry()).plugins[PLUGIN].find((e) => e.projectPath === here)
1336
+ ?.version,
1337
+ ).toBe("0.4.0");
1338
+ expect(details[0]).toContain(
1339
+ `no change — the installer exited 0 but ${PLUGIN} is still 0.4.0 (Claude Code updated ${mainTree} instead of this project, and this project's record could not follow: no cache directory for v0.6.0`,
1340
+ );
1341
+ });
1342
+ });