magus-cli 7.5.2 → 7.5.4

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 (68) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-convergence.test.ts +1 -2
  3. package/src/__tests__/blackbox-parity.test.ts +16 -14
  4. package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
  5. package/src/__tests__/cli-apply-seams.test.ts +3 -3
  6. package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
  7. package/src/__tests__/content-drift.test.ts +0 -1
  8. package/src/__tests__/files-missing-scope.test.ts +235 -0
  9. package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
  10. package/src/__tests__/format-update-outcome.test.ts +367 -0
  11. package/src/__tests__/gitignore-check.test.ts +11 -29
  12. package/src/__tests__/gitignore-profile.test.ts +248 -0
  13. package/src/__tests__/installed-version-scope.test.tsx +425 -0
  14. package/src/__tests__/mate-availability.test.ts +11 -13
  15. package/src/__tests__/models-cli.test.ts +11 -0
  16. package/src/__tests__/plugin-contents.test.ts +0 -1
  17. package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
  18. package/src/__tests__/plugin-row-text.test.ts +18 -21
  19. package/src/__tests__/profile-command.test.ts +2 -2
  20. package/src/__tests__/profile-migration-offer.test.ts +5 -4
  21. package/src/__tests__/profile-skills.test.ts +17 -22
  22. package/src/__tests__/profile-startup-nested.test.ts +329 -0
  23. package/src/__tests__/profile-startup-old-format.test.ts +390 -0
  24. package/src/__tests__/profile-state.test.ts +152 -164
  25. package/src/__tests__/scope-action.test.ts +35 -38
  26. package/src/__tests__/scope-row-lookup.test.ts +126 -0
  27. package/src/__tests__/select-restore.test.ts +209 -0
  28. package/src/__tests__/stale-load-repair.test.ts +1 -2
  29. package/src/__tests__/update-apply.test.ts +18 -2
  30. package/src/__tests__/update-null-readback.test.ts +300 -0
  31. package/src/__tests__/update-plan-parity.test.ts +249 -0
  32. package/src/__tests__/update-plan.test.ts +66 -67
  33. package/src/__tests__/version-unknown-state.test.ts +38 -39
  34. package/src/cli/bootstrap.ts +239 -70
  35. package/src/cli/doctor.ts +7 -20
  36. package/src/cli/install.ts +83 -27
  37. package/src/cli/prompt.ts +7 -3
  38. package/src/cli/router.ts +6 -4
  39. package/src/cli/select.ts +168 -0
  40. package/src/cli/update-view.ts +118 -6
  41. package/src/cli/update.ts +10 -1
  42. package/src/services/claude-cli.ts +0 -24
  43. package/src/services/claude-settings.ts +28 -7
  44. package/src/services/gitignore-check.ts +37 -49
  45. package/src/services/gitignore-resolver.ts +10 -1
  46. package/src/services/gitignore-service.ts +67 -0
  47. package/src/services/manifest.ts +31 -0
  48. package/src/services/mate-availability.ts +23 -29
  49. package/src/services/plugin-manager.ts +247 -105
  50. package/src/services/profile-absorb.ts +41 -24
  51. package/src/services/profile-activate.ts +102 -127
  52. package/src/services/profile-startup.ts +160 -4
  53. package/src/services/profile-state.ts +7 -68
  54. package/src/services/tracked-live-files.ts +109 -74
  55. package/src/services/update-engine.ts +28 -10
  56. package/src/services/update-plan.ts +78 -94
  57. package/src/ui/App.tsx +41 -35
  58. package/src/ui/adapters/pluginsAdapter.ts +1 -1
  59. package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
  60. package/src/ui/hooks/useGitignoreModal.ts +63 -17
  61. package/src/ui/hooks/useProfileStartupModal.ts +94 -9
  62. package/src/ui/keymap.ts +1 -1
  63. package/src/ui/renderers/pluginRenderers.tsx +39 -45
  64. package/src/ui/renderers/pluginRowText.ts +6 -2
  65. package/src/ui/renderers/pluginStatusText.ts +4 -3
  66. package/src/ui/screens/PluginsScreen.tsx +30 -52
  67. package/src/ui/screens/ProfilesScreen.tsx +3 -10
  68. package/src/cli/tui-prompt.tsx +0 -143
@@ -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 = {
@@ -148,7 +145,7 @@ describe("strict active-profile check", () => {
148
145
  profile: "other",
149
146
  restored: false,
150
147
  });
151
- expect(prep.lines[0]).toContain('matches profile "other" exactly');
148
+ expect(prep.lines[0]).toContain('Using profile "other"');
152
149
  expect((await readActiveMarker(project))?.profile).toBe("other");
153
150
  expect(await activeProfile(project)).toBe("other");
154
151
  });
@@ -227,11 +224,11 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
227
224
 
228
225
  expect(prep.problems).toEqual([]);
229
226
  expect(prep.lines).toContain(
230
- 'Changes Claude Code made under the older layout were added to profile "team"',
227
+ 'Changes Claude Code made were added to profile "team"',
231
228
  );
232
229
  expect(prep.lines).toContain('Added code-search@magus to profile "team"');
233
230
  expect(prep.lines).toContain(
234
- 'Converted profile "team" from the older layout to real files',
231
+ 'Updated profile "team" to the current profile format',
235
232
  );
236
233
  // In profiles.json...
237
234
  const team = (await readManifest(project)).profiles.team;
@@ -268,9 +265,8 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
268
265
  enabledPlugins: { "dev@magus": true, "code-search@magus": true },
269
266
  permissions: { allow: ["Bash(bun test:*)"] },
270
267
  });
271
- expect(prep.lines.join("\n")).toContain(
272
- `was kept at .claude/.magus-backups/${kept[0]}/`,
273
- );
268
+ // Said in the user's terms; where the backup went is checked on disk above.
269
+ expect(prep.lines).toContain("A backup of the old profile format was kept");
274
270
  expect(await readdir(join(project, ".claude", "profiles"))).toEqual([
275
271
  "active.json",
276
272
  ]);
@@ -319,7 +315,7 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
319
315
  // stands, the key is named, and the older copy is in the backup.
320
316
  expect(team?.models?.agents).toEqual({ "dev:architect": "smart" });
321
317
  expect(prep.lines).toContain(
322
- 'Profile "team": models differs between the older layout and profiles.json — kept profiles.json\'s value; the older copy is in the -layout backup under .claude/.magus-backups/',
318
+ 'Profile "team": models differs between the old profile format and profiles.json — kept profiles.json\'s value (the old one is backed up)',
323
319
  );
324
320
  const live = await fs.readJson(settingsPath());
325
321
  expect(live.enabledPlugins["code-search@magus"]).toBe(true);
@@ -429,7 +425,7 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
429
425
  // Removals are not applied, and the run says so, once.
430
426
  expect(
431
427
  prep.lines.filter((l) =>
432
- l.includes("a removal made under the older layout is not applied"),
428
+ l.includes("a removal made in the old format is not applied"),
433
429
  ),
434
430
  ).toHaveLength(1);
435
431
  const live = await fs.readJson(settingsPath());
@@ -481,7 +477,7 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
481
477
  "mcpServers.db",
482
478
  ]) {
483
479
  expect(prep.lines).toContain(
484
- `Profile "team": ${at} differs between the older layout and profiles.json — kept profiles.json's value; the older copy is in the -layout backup under .claude/.magus-backups/`,
480
+ `Profile "team": ${at} differs between the old profile format and profiles.json — kept profiles.json's value (the old one is backed up)`,
485
481
  );
486
482
  }
487
483
  // The older copy really is in the backup.
@@ -580,7 +576,7 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
580
576
  const prep = await prepareProfileState(project);
581
577
 
582
578
  expect(prep.problems).toHaveLength(1);
583
- expect(prep.problems[0]).toContain("did not convert it");
579
+ expect(prep.problems[0]).toContain("did not update the old profile format");
584
580
  expect(
585
581
  (await lstat(join(project, ".claude", "settings.json"))).isSymbolicLink(),
586
582
  ).toBe(true);
@@ -626,7 +622,7 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
626
622
  }
627
623
 
628
624
  expect(await snapshot()).toEqual(before);
629
- expect(printed()).toContain("is on an older magus layout");
625
+ expect(printed()).toContain("uses an old magus profile format");
630
626
  expect(await fs.pathExists(activeMarkerPath(project))).toBe(false);
631
627
  });
632
628
 
@@ -640,11 +636,9 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
640
636
  const counts = await reportProfileState(project, false);
641
637
 
642
638
  const out = printed();
643
- expect(out).toContain(
644
- 'No active-profile marker, but the live config matches profile "other" exactly',
645
- );
639
+ expect(out).toContain('This project\'s plugins match profile "other"');
646
640
  // The contradicting line is gone, and so is the failure.
647
- expect(out).not.toContain("matches no single profile");
641
+ expect(out).not.toContain("match none of the profiles");
648
642
  expect(counts).toEqual({ fixable: 0, unfixable: 0 });
649
643
  // Report only: the marker is not restored.
650
644
  expect(await readActiveMarker(project)).toBeNull();
@@ -686,7 +680,7 @@ function git(cwd: string, ...args: string[]) {
686
680
  );
687
681
  }
688
682
 
689
- describe("generated files git still tracks", () => {
683
+ describe("generated files git still tracks: written, and warned about", () => {
690
684
  /** A repo that committed profiles.json AND settings.json, like magus-src. */
691
685
  async function commitTrackedSettings(): Promise<void> {
692
686
  expect(git(project, "init", "-q").status).toBe(0);
@@ -698,61 +692,70 @@ describe("generated files git still tracks", () => {
698
692
  expect(git(project, "commit", "-q", "-m", "init").status).toBe(0);
699
693
  }
700
694
 
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
- }
695
+ /** magus never runs git: nothing is staged, whatever it wrote. */
696
+ const nothingStaged = () =>
697
+ expect(git(project, "diff", "--cached", "--name-only").stdout).toBe("");
710
698
 
711
- test("B1: activating over a tracked settings.json writes nothing and names the fix", async () => {
699
+ // The notice is short lines: the problem, then the command on its own line.
700
+ const PROBLEM = "git tracks .claude/settings.json, but magus rewrites it";
701
+ const FIX = "git rm --cached .claude/settings.json, then commit";
702
+ /** Both lines, each printed exactly once. */
703
+ const warnedOnce = (out: string) => {
704
+ expect(out.split(PROBLEM).length - 1).toBe(1);
705
+ expect(out.split(FIX).length - 1).toBe(1);
706
+ };
707
+
708
+ test("B7: activating over a tracked settings.json writes it and names the fix", async () => {
712
709
  await commitTrackedSettings();
713
710
 
714
- const error = await activateProfile(MANIFEST, "team", project, {
711
+ const result = await activateProfile(MANIFEST, "team", project, {
715
712
  onDisplace: () => {},
716
- }).then(
717
- () => null,
718
- (e: unknown) => e,
719
- );
713
+ });
720
714
 
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();
715
+ expect(result.tracked).toEqual([".claude/settings.json"]);
716
+ expect(activationNotes(result)).toContain(PROBLEM);
717
+ expect(activationNotes(result)).toContain(FIX);
718
+ expect((await fs.readJson(settingsPath())).enabledPlugins).toEqual({
719
+ "dev@magus": true,
720
+ "terminal@magus": false,
721
+ });
722
+ expect((await readActiveMarker(project))?.profile).toBe("team");
723
+ expect(git(project, "status", "--porcelain").stdout).toContain(
724
+ " M .claude/settings.json",
725
+ );
726
+ nothingStaged();
727
727
  });
728
728
 
729
- test("B1: the gate, the switch path and a startup choice all refuse, writing nothing", async () => {
729
+ test("B7: the gate, the switch path and a startup choice all write", async () => {
730
730
  await commitTrackedSettings();
731
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);
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
732
  for (const choice of [
744
733
  { kind: "activate", id: "team" },
745
734
  { kind: "adopt", id: "default-2" },
746
735
  ] as const) {
747
- await expect(
748
- applyProfileChoice(choice, MANIFEST, project, { onDisplace: () => {} }),
749
- ).rejects.toBeInstanceOf(TrackedLiveFileError);
736
+ const result = await applyProfileChoice(choice, MANIFEST, project, {
737
+ onDisplace: () => {},
738
+ });
739
+ expect(result.profileId).toBe(choice.id);
750
740
  }
751
- await untouched();
741
+ const prep = await prepareProfileState(project);
742
+ expect(prep.problems).toEqual([]);
743
+ expect(await profileGate(project)).toBe(true);
744
+
745
+ const switched = await switchToProfile(project, "other", {
746
+ onDisplace: () => {},
747
+ });
748
+ expect(switched.problems).toEqual([]);
749
+ expect(switched.activation?.profileId).toBe("other");
750
+ expect(
751
+ (await fs.readJson(settingsPath())).enabledPlugins["terminal@magus"],
752
+ ).toBe(true);
753
+ nothingStaged();
752
754
  });
753
755
 
754
- test("B1: the CLI router exits 1 for `magus profile switch`, and writes nothing", async () => {
756
+ test("B7: `magus profile switch` exits 0 and warns once, running no git", async () => {
755
757
  await commitTrackedSettings();
758
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
756
759
  const cwd = process.cwd();
757
760
  process.chdir(project);
758
761
  let outcome: Awaited<ReturnType<typeof route>>;
@@ -761,63 +764,47 @@ describe("generated files git still tracks", () => {
761
764
  } finally {
762
765
  process.chdir(cwd);
763
766
  }
764
- expect(outcome.exitCode).toBe(1);
765
- expect(printed()).toContain("git rm --cached .claude/settings.json");
766
- await untouched();
767
+ expect(outcome.exitCode).toBe(0);
768
+ warnedOnce(printed());
769
+ expect((await readActiveMarker(project))?.profile).toBe("other");
770
+ nothingStaged();
771
+ });
772
+
773
+ test("B7: `magus update` warns once about a tracked generated file, running no git", async () => {
774
+ await commitTrackedSettings();
775
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
776
+ // A named profile that does not exist: the command stops right after the
777
+ // warning, before anything reaches the network.
778
+ expect(await runUpdateCommand(["no-such-profile"], project)).toBe(1);
779
+ warnedOnce(printed());
780
+ nothingStaged();
767
781
  });
768
782
 
769
- test("B1: once settings.json is tracked, a pulled change regenerates nothing", async () => {
783
+ test("B7: once settings.json is tracked, a pulled change is still regenerated", async () => {
770
784
  expect(git(project, "init", "-q").status).toBe(0);
771
785
  await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
772
786
  // Somebody commits the generated file after all.
773
787
  git(project, "add", "-f", ".claude/settings.json");
774
788
  git(project, "add", ".claude/profiles.json");
775
789
  expect(git(project, "commit", "-q", "-m", "oops").status).toBe(0);
776
- const before = await fs.readFile(settingsPath(), "utf8");
777
790
  // A teammate's pull changes the profile.
778
791
  const pulled = structuredClone(MANIFEST);
779
792
  (pulled.profiles.team as { plugins: Record<string, string> }).plugins[
780
793
  "mnemex@magus"
781
794
  ] = "latest";
782
795
  await writeManifest(pulled, project);
783
- const manifestBefore = await fs.readFile(
784
- join(project, ".claude", "profiles.json"),
785
- "utf8",
786
- );
787
796
 
788
797
  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
-
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
798
 
811
- await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
812
-
813
- expect((await lstat(settingsPath())).isFile()).toBe(true);
814
- expect((await readActiveMarker(project))?.profile).toBe("team");
799
+ expect(prep.problems).toEqual([]);
800
+ expect(prep.lines.join("\n")).toContain("regenerated");
815
801
  expect(
816
- await fs.readFile(join(project, ".claude", "profiles.json"), "utf8"),
817
- ).toBe(manifestBytes);
802
+ (await fs.readJson(settingsPath())).enabledPlugins["mnemex@magus"],
803
+ ).toBe(true);
804
+ nothingStaged();
818
805
  });
819
806
 
820
- test("B1: adopting a repo with no profiles.json and a tracked settings.json writes nothing", async () => {
807
+ test("B7: adopting a repo with no profiles.json and a tracked settings.json writes and warns", async () => {
821
808
  await fs.remove(join(project, ".claude", "profiles.json"));
822
809
  expect(git(project, "init", "-q").status).toBe(0);
823
810
  await fs.outputJson(settingsPath(), {
@@ -829,112 +816,113 @@ describe("generated files git still tracks", () => {
829
816
  const savedHome = process.env.HOME;
830
817
  process.env.HOME = home;
831
818
  try {
832
- await expect(
833
- ensureManifest(project, { allowAdopt: true }),
834
- ).rejects.toBeInstanceOf(TrackedLiveFileError);
819
+ expect(
820
+ await ensureManifest(project, { allowAdopt: true }),
821
+ ).not.toBeNull();
835
822
  } finally {
836
823
  process.env.HOME = savedHome;
837
824
  await rm(home, { recursive: true, force: true });
838
825
  }
839
- expect(await fs.pathExists(join(project, ".claude", "profiles.json"))).toBe(
840
- false,
826
+ const manifest = await readManifest(project);
827
+ expect(Object.keys(manifest.profiles)).toHaveLength(1);
828
+ expect(await activeProfile(project)).toBe(
829
+ Object.keys(manifest.profiles)[0],
841
830
  );
842
- await untouched();
831
+ warnedOnce(printed());
832
+ nothingStaged();
843
833
  });
844
834
 
845
- test("B1: doctor counts a tracked live file as a problem", async () => {
835
+ test("B7: doctor notes a tracked live file with the fix, and does not count it", async () => {
846
836
  await commitTrackedSettings();
837
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
847
838
  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();
839
+ expect(counts.unfixable).toBe(0);
840
+ expect(printed()).toContain(`• ${PROBLEM}`);
841
+ expect(printed()).toContain(FIX);
851
842
  });
852
843
 
853
- test("review M1: writeSettings and writeMcpConfig refuse a tracked live file and leave it as it is", async () => {
844
+ test("B7: writeSettings and writeMcpConfig write a tracked live file", async () => {
854
845
  await commitTrackedSettings();
855
846
  await fs.outputJson(join(project, ".mcp.json"), { mcpServers: {} });
856
847
  git(project, "add", ".mcp.json");
857
848
  expect(git(project, "commit", "-q", "-m", "mcp").status).toBe(0);
858
- const before = await fs.readFile(settingsPath(), "utf8");
859
849
 
860
850
  // What the TUI Profiles tab's "apply predefined" does.
861
851
  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
852
  (settings.enabledPlugins as Record<string, boolean>)["frontend@magus"] =
905
853
  true;
906
854
  await writeSettings(settings, project);
855
+ await writeMcpConfig({ mcpServers: { x: { command: "x" } } }, project);
856
+
907
857
  expect(
908
858
  (await fs.readJson(settingsPath())).enabledPlugins["frontend@magus"],
909
859
  ).toBe(true);
860
+ expect((await fs.readJson(join(project, ".mcp.json"))).mcpServers).toEqual({
861
+ x: { command: "x" },
862
+ });
863
+ nothingStaged();
910
864
  });
911
865
 
912
- test("review 3a: inside a work tree git cannot read, activation refuses with git's own message", async () => {
866
+ test("B7: inside a work tree git cannot read, activation writes and says git could not be asked", async () => {
913
867
  // A .git pointing at a repository that is not there: git exits 128.
914
868
  await fs.outputFile(
915
869
  join(project, ".git"),
916
870
  `gitdir: ${join(project, "no-such-repository")}\n`,
917
871
  );
918
872
 
919
- const error = await activateProfile(MANIFEST, "team", project, {
873
+ const result = await activateProfile(MANIFEST, "team", project, {
920
874
  onDisplace: () => {},
921
- }).then(
922
- () => null,
923
- (e: unknown) => e,
875
+ });
876
+
877
+ expect(await fs.pathExists(settingsPath())).toBe(true);
878
+ expect(typeof result.tracked).toBe("string");
879
+ expect(activationNotes(result).join("\n")).toContain(
880
+ "magus could not check which files git tracks in this project",
924
881
  );
882
+ expect((await prepareProfileState(project)).problems).toEqual([]);
883
+ });
925
884
 
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,
885
+ test("B7 review L3: converting an older layout where git cannot answer exits 1 with git's message, no stack trace", async () => {
886
+ // 7.4.1 layout, a listed skill linked: the conversion must ask git which
887
+ // skill folders the team committed. `--dry-run` skips the entry gate, so
888
+ // the error reaches the router's own handler.
889
+ const withSkill = structuredClone(MANIFEST);
890
+ (withSkill.profiles.team as { skills: unknown[] }).skills = [
891
+ { name: "alpha", repo: "a/b", path: "skills/alpha" },
892
+ ];
893
+ await writeManifest(withSkill, project);
894
+ const old = join(project, ".claude", "_profiles", "team");
895
+ await fs.outputFile(join(old, "skills", "alpha", "SKILL.md"), "# alpha\n");
896
+ await fs.outputJson(join(old, "settings.json"), {
897
+ enabledPlugins: { "dev@magus": true },
898
+ });
899
+ await fs.symlink("_profiles/team/settings.json", settingsPath());
900
+ await fs.symlink(
901
+ "_profiles/team/skills",
902
+ join(project, ".claude", "skills"),
903
+ );
904
+ await fs.outputFile(
905
+ join(project, ".git"),
906
+ `gitdir: ${join(project, "no-such-repository")}\n`,
907
+ );
908
+ const cwd = process.cwd();
909
+ process.chdir(project);
910
+ let outcome: Awaited<ReturnType<typeof route>>;
911
+ try {
912
+ outcome = await route(
913
+ ["profile", "switch", "team", "--dry-run"],
914
+ "0.0.0-test",
915
+ );
916
+ } finally {
917
+ process.chdir(cwd);
918
+ }
919
+ expect(outcome.exitCode).toBe(1);
920
+ expect(printed()).toContain(
921
+ "magus could not check which files git tracks in this project. git said:",
932
922
  );
933
- const prep = await prepareProfileState(project);
934
- expect(prep.refusal).toBeInstanceOf(GitCheckError);
935
923
  });
936
924
 
937
- test("review 3a: git missing is a refusal inside a work tree, and no check outside one", async () => {
925
+ test("git missing is an error inside a work tree, and no check outside one", async () => {
938
926
  expect(git(project, "init", "-q").status).toBe(0);
939
927
  const outside = await mkdtemp(join(tmpdir(), "no-git-"));
940
928
  const pathBefore = process.env.PATH;
@@ -8,41 +8,37 @@ import { planPluginUpdates, scopeIsBehind } from "../services/update-plan.js";
8
8
 
9
9
  /**
10
10
  * The scope keys (u / p / l) and the Enter scope picker both decide between
11
- * install, update and uninstall. That decision used to read `scope.enabled` —
12
- * the `enabledPlugins` flag from a settings file — and ignore `scope.version`,
13
- * which is the registry-backed answer to "did Claude Code actually install
14
- * this".
11
+ * install, update and uninstall. That decision used to read the settings flag
12
+ * alone and ignore `scope.version`.
15
13
  *
16
- * Those two disagree in a real, common state: a plugin listed in
14
+ * The two disagree in a real, common state: a plugin listed in
17
15
  * `enabledPlugins` with no entry in `installed_plugins.json` for the current
18
- * project path. The list row renders it "not installed" (isEnabledButNotInstalled),
19
- * and pressing its scope key ran `claude plugin uninstall`. The user asked to
20
- * install the thing the UI told them was missing, and it was removed instead.
16
+ * project path — installed, with its files missing (hasMissingFiles). Pressing
17
+ * its scope key ran `claude plugin uninstall`. The user asked to repair the
18
+ * thing the UI told them was broken, and it was removed instead.
21
19
  */
22
20
 
23
21
  describe("isInstalledInScope", () => {
24
- test("enabled with a registry version is installed", () => {
25
- expect(isInstalledInScope({ enabled: true, version: "3.0.2" })).toBe(true);
22
+ test("installed with a registry version is installed", () => {
23
+ expect(isInstalledInScope({ installed: true, version: "3.0.2" })).toBe(
24
+ true,
25
+ );
26
26
  });
27
27
 
28
- test("enabled with NO version is not installed — the broken state", () => {
28
+ test("installed with NO version is still installed — files missing", () => {
29
29
  // dev@magus, measured: `.claude/settings.json` had "dev@magus": true while
30
30
  // installed_plugins.json had no entry resolving for the project path.
31
- expect(isInstalledInScope({ enabled: true })).toBe(false);
31
+ // Installed is the settings fact; the missing version is the broken
32
+ // sub-state, and the scope key still resolves it to `install` (below).
33
+ expect(isInstalledInScope({ installed: true })).toBe(true);
32
34
  });
33
35
 
34
- test("a registry version with the flag off IS installed", () => {
35
- // Installation and enablement are separate facts. This asserted `false`
36
- // until it was measured against the other direction: a plugin with a real
37
- // registry row that the user had simply switched off reported "not
38
- // installed", so its row offered to install what was already on disk, and
39
- // the header counted it out.
40
- //
41
- // The hazard the sibling test above guards — pressing a scope key on
42
- // enabled-with-no-version and having it run `uninstall` — is untouched:
43
- // that case has no version, so it is still not installed, and still
44
- // resolves to `install`.
45
- expect(isInstalledInScope({ enabled: false, version: "3.0.2" })).toBe(true);
36
+ test("a registry version with the settings flag off is NOT installed", () => {
37
+ // A registry row alone never makes Claude Code load a plugin, and it can
38
+ // be inherited from another checkout of the same repository.
39
+ expect(isInstalledInScope({ installed: false, version: "3.0.2" })).toBe(
40
+ false,
41
+ );
46
42
  });
47
43
 
48
44
  test("an absent scope is not installed", () => {
@@ -50,7 +46,9 @@ describe("isInstalledInScope", () => {
50
46
  });
51
47
 
52
48
  test('"0.0.0" counts as installed — Anthropic official plugins record it', () => {
53
- expect(isInstalledInScope({ enabled: true, version: "0.0.0" })).toBe(true);
49
+ expect(isInstalledInScope({ installed: true, version: "0.0.0" })).toBe(
50
+ true,
51
+ );
54
52
  });
55
53
  });
56
54
 
@@ -59,34 +57,34 @@ describe("resolveScopeAction", () => {
59
57
  // takes from the classifier (`scopeIsBehind`). It used to be the catalog
60
58
  // version, compared here with raw string inequality — a fourth rule.
61
59
 
62
- test("REGRESSION: enabled but not installed resolves to install, never uninstall", () => {
60
+ test("REGRESSION: files missing resolves to install, never uninstall", () => {
63
61
  // This is the reported bug. Before the fix this returned "uninstall".
64
- expect(resolveScopeAction({ enabled: true }, false)).toBe("install");
62
+ expect(resolveScopeAction({ installed: true }, false)).toBe("install");
65
63
  // Whatever the classifier says: the safety branch comes first.
66
- expect(resolveScopeAction({ enabled: true }, true)).toBe("install");
64
+ expect(resolveScopeAction({ installed: true }, true)).toBe("install");
67
65
  });
68
66
 
69
- test("a scope that was never enabled resolves to install", () => {
67
+ test("a scope that was never installed resolves to install", () => {
70
68
  expect(resolveScopeAction(undefined, false)).toBe("install");
71
- expect(resolveScopeAction({ enabled: false }, false)).toBe("install");
69
+ expect(resolveScopeAction({ installed: false }, false)).toBe("install");
72
70
  });
73
71
 
74
72
  test("a healthy install at the latest version resolves to uninstall (toggle off)", () => {
75
- expect(resolveScopeAction({ enabled: true, version: "3.0.2" }, false)).toBe(
76
- "uninstall",
77
- );
73
+ expect(
74
+ resolveScopeAction({ installed: true, version: "3.0.2" }, false),
75
+ ).toBe("uninstall");
78
76
  });
79
77
 
80
78
  test("a healthy install behind the catalog resolves to update", () => {
81
- expect(resolveScopeAction({ enabled: true, version: "2.9.0" }, true)).toBe(
82
- "update",
83
- );
79
+ expect(
80
+ resolveScopeAction({ installed: true, version: "2.9.0" }, true),
81
+ ).toBe("update");
84
82
  });
85
83
  });
86
84
 
87
85
  /** The screen's path: classify, then ask `scopeIsBehind` for the pressed scope. */
88
86
  function keyFor(installed: string, catalogVersion: string | null) {
89
- const scope = { enabled: true, version: installed };
87
+ const scope = { installed: true, version: installed };
90
88
  const [status] = planPluginUpdates(
91
89
  { "x@magus": "latest" },
92
90
  new Map([
@@ -99,7 +97,6 @@ function keyFor(installed: string, catalogVersion: string | null) {
99
97
  description: "",
100
98
  marketplace: "magus",
101
99
  marketplaceDisplay: "Magus",
102
- enabled: true,
103
100
  projectScope: scope,
104
101
  } as PluginInfo,
105
102
  ],