magus-cli 7.5.3 → 7.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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__/installed-version-scope.test.tsx +425 -0
  12. package/src/__tests__/key-focus-guard.test.tsx +260 -0
  13. package/src/__tests__/mate-availability.test.ts +11 -13
  14. package/src/__tests__/plugin-contents.test.ts +0 -1
  15. package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
  16. package/src/__tests__/plugin-row-text.test.ts +18 -21
  17. package/src/__tests__/profile-command.test.ts +2 -2
  18. package/src/__tests__/profile-migration-offer.test.ts +5 -4
  19. package/src/__tests__/profile-name-on-save.test.ts +443 -0
  20. package/src/__tests__/profile-skills.test.ts +3 -3
  21. package/src/__tests__/profile-startup-nested.test.ts +329 -0
  22. package/src/__tests__/profile-startup-old-format.test.ts +390 -0
  23. package/src/__tests__/profile-startup.test.ts +1 -1
  24. package/src/__tests__/profile-state.test.ts +27 -22
  25. package/src/__tests__/prompt-eof.test.ts +245 -0
  26. package/src/__tests__/scope-action.test.ts +35 -38
  27. package/src/__tests__/scope-row-lookup.test.ts +126 -0
  28. package/src/__tests__/select-restore.test.ts +209 -0
  29. package/src/__tests__/stale-load-repair.test.ts +1 -2
  30. package/src/__tests__/update-apply.test.ts +18 -2
  31. package/src/__tests__/update-null-readback.test.ts +300 -0
  32. package/src/__tests__/update-plan-parity.test.ts +249 -0
  33. package/src/__tests__/update-plan.test.ts +66 -67
  34. package/src/__tests__/version-unknown-state.test.ts +38 -39
  35. package/src/cli/bootstrap.ts +271 -66
  36. package/src/cli/doctor.ts +4 -3
  37. package/src/cli/install.ts +83 -27
  38. package/src/cli/prompt.ts +19 -5
  39. package/src/cli/router.ts +1 -0
  40. package/src/cli/select.ts +168 -0
  41. package/src/cli/update-view.ts +120 -6
  42. package/src/cli/update.ts +6 -4
  43. package/src/services/claude-settings.ts +28 -0
  44. package/src/services/manifest.ts +31 -0
  45. package/src/services/mate-availability.ts +23 -29
  46. package/src/services/plugin-manager.ts +247 -105
  47. package/src/services/profile-absorb.ts +10 -7
  48. package/src/services/profile-activate.ts +41 -19
  49. package/src/services/profile-adopt.ts +9 -2
  50. package/src/services/profile-startup.ts +206 -5
  51. package/src/services/profile-state.ts +8 -7
  52. package/src/services/tracked-live-files.ts +24 -3
  53. package/src/services/update-engine.ts +28 -10
  54. package/src/services/update-plan.ts +78 -94
  55. package/src/ui/App.tsx +96 -56
  56. package/src/ui/adapters/pluginsAdapter.ts +1 -1
  57. package/src/ui/components/modals/ModalContainer.tsx +3 -0
  58. package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
  59. package/src/ui/hooks/useGitignoreModal.ts +21 -3
  60. package/src/ui/hooks/useProfileStartupModal.ts +214 -15
  61. package/src/ui/hooks/useStaleLoadModal.ts +5 -1
  62. package/src/ui/input/KeyRouter.tsx +43 -2
  63. package/src/ui/keymap.ts +1 -1
  64. package/src/ui/renderers/pluginRenderers.tsx +39 -45
  65. package/src/ui/renderers/pluginRowText.ts +6 -2
  66. package/src/ui/renderers/pluginStatusText.ts +4 -3
  67. package/src/ui/screens/PluginsScreen.tsx +28 -41
  68. package/src/cli/tui-prompt.tsx +0 -143
@@ -373,7 +373,7 @@ describe("what the user is offered", () => {
373
373
  // "Create <id> from this project" described the mechanism. The reason to
374
374
  // pick this row is that it is the only one that keeps what is turned on.
375
375
  expect(buildProfileOptions(inactive)[0]?.label).toBe(
376
- 'Save what you have as "default"',
376
+ "Save what you have as a new profile",
377
377
  );
378
378
  });
379
379
 
@@ -145,7 +145,7 @@ describe("strict active-profile check", () => {
145
145
  profile: "other",
146
146
  restored: false,
147
147
  });
148
- expect(prep.lines[0]).toContain('matches profile "other" exactly');
148
+ expect(prep.lines[0]).toContain('Using profile "other"');
149
149
  expect((await readActiveMarker(project))?.profile).toBe("other");
150
150
  expect(await activeProfile(project)).toBe("other");
151
151
  });
@@ -224,11 +224,11 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
224
224
 
225
225
  expect(prep.problems).toEqual([]);
226
226
  expect(prep.lines).toContain(
227
- 'Changes Claude Code made under the older layout were added to profile "team"',
227
+ 'Changes Claude Code made were added to profile "team"',
228
228
  );
229
229
  expect(prep.lines).toContain('Added code-search@magus to profile "team"');
230
230
  expect(prep.lines).toContain(
231
- 'Converted profile "team" from the older layout to real files',
231
+ 'Updated profile "team" to the current profile format',
232
232
  );
233
233
  // In profiles.json...
234
234
  const team = (await readManifest(project)).profiles.team;
@@ -265,9 +265,8 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
265
265
  enabledPlugins: { "dev@magus": true, "code-search@magus": true },
266
266
  permissions: { allow: ["Bash(bun test:*)"] },
267
267
  });
268
- expect(prep.lines.join("\n")).toContain(
269
- `was kept at .claude/.magus-backups/${kept[0]}/`,
270
- );
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");
271
270
  expect(await readdir(join(project, ".claude", "profiles"))).toEqual([
272
271
  "active.json",
273
272
  ]);
@@ -316,7 +315,7 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
316
315
  // stands, the key is named, and the older copy is in the backup.
317
316
  expect(team?.models?.agents).toEqual({ "dev:architect": "smart" });
318
317
  expect(prep.lines).toContain(
319
- '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)',
320
319
  );
321
320
  const live = await fs.readJson(settingsPath());
322
321
  expect(live.enabledPlugins["code-search@magus"]).toBe(true);
@@ -426,7 +425,7 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
426
425
  // Removals are not applied, and the run says so, once.
427
426
  expect(
428
427
  prep.lines.filter((l) =>
429
- 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"),
430
429
  ),
431
430
  ).toHaveLength(1);
432
431
  const live = await fs.readJson(settingsPath());
@@ -478,7 +477,7 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
478
477
  "mcpServers.db",
479
478
  ]) {
480
479
  expect(prep.lines).toContain(
481
- `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)`,
482
481
  );
483
482
  }
484
483
  // The older copy really is in the backup.
@@ -577,7 +576,7 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
577
576
  const prep = await prepareProfileState(project);
578
577
 
579
578
  expect(prep.problems).toHaveLength(1);
580
- expect(prep.problems[0]).toContain("did not convert it");
579
+ expect(prep.problems[0]).toContain("did not update the old profile format");
581
580
  expect(
582
581
  (await lstat(join(project, ".claude", "settings.json"))).isSymbolicLink(),
583
582
  ).toBe(true);
@@ -623,7 +622,7 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
623
622
  }
624
623
 
625
624
  expect(await snapshot()).toEqual(before);
626
- expect(printed()).toContain("is on an older magus layout");
625
+ expect(printed()).toContain("uses an old magus profile format");
627
626
  expect(await fs.pathExists(activeMarkerPath(project))).toBe(false);
628
627
  });
629
628
 
@@ -637,11 +636,9 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
637
636
  const counts = await reportProfileState(project, false);
638
637
 
639
638
  const out = printed();
640
- expect(out).toContain(
641
- 'No active-profile marker, but the live config matches profile "other" exactly',
642
- );
639
+ expect(out).toContain('This project\'s plugins match profile "other"');
643
640
  // The contradicting line is gone, and so is the failure.
644
- expect(out).not.toContain("matches no single profile");
641
+ expect(out).not.toContain("match none of the profiles");
645
642
  expect(counts).toEqual({ fixable: 0, unfixable: 0 });
646
643
  // Report only: the marker is not restored.
647
644
  expect(await readActiveMarker(project)).toBeNull();
@@ -699,8 +696,14 @@ describe("generated files git still tracks: written, and warned about", () => {
699
696
  const nothingStaged = () =>
700
697
  expect(git(project, "diff", "--cached", "--name-only").stdout).toBe("");
701
698
 
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";
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
+ };
704
707
 
705
708
  test("B7: activating over a tracked settings.json writes it and names the fix", async () => {
706
709
  await commitTrackedSettings();
@@ -710,7 +713,8 @@ describe("generated files git still tracks: written, and warned about", () => {
710
713
  });
711
714
 
712
715
  expect(result.tracked).toEqual([".claude/settings.json"]);
713
- expect(activationNotes(result)).toContain(WARNING);
716
+ expect(activationNotes(result)).toContain(PROBLEM);
717
+ expect(activationNotes(result)).toContain(FIX);
714
718
  expect((await fs.readJson(settingsPath())).enabledPlugins).toEqual({
715
719
  "dev@magus": true,
716
720
  "terminal@magus": false,
@@ -761,7 +765,7 @@ describe("generated files git still tracks: written, and warned about", () => {
761
765
  process.chdir(cwd);
762
766
  }
763
767
  expect(outcome.exitCode).toBe(0);
764
- expect(printed().split(WARNING).length - 1).toBe(1);
768
+ warnedOnce(printed());
765
769
  expect((await readActiveMarker(project))?.profile).toBe("other");
766
770
  nothingStaged();
767
771
  });
@@ -772,7 +776,7 @@ describe("generated files git still tracks: written, and warned about", () => {
772
776
  // A named profile that does not exist: the command stops right after the
773
777
  // warning, before anything reaches the network.
774
778
  expect(await runUpdateCommand(["no-such-profile"], project)).toBe(1);
775
- expect(printed().split(WARNING).length - 1).toBe(1);
779
+ warnedOnce(printed());
776
780
  nothingStaged();
777
781
  });
778
782
 
@@ -824,7 +828,7 @@ describe("generated files git still tracks: written, and warned about", () => {
824
828
  expect(await activeProfile(project)).toBe(
825
829
  Object.keys(manifest.profiles)[0],
826
830
  );
827
- expect(printed()).toContain(WARNING);
831
+ warnedOnce(printed());
828
832
  nothingStaged();
829
833
  });
830
834
 
@@ -833,7 +837,8 @@ describe("generated files git still tracks: written, and warned about", () => {
833
837
  await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
834
838
  const counts = await reportProfileState(project, false);
835
839
  expect(counts.unfixable).toBe(0);
836
- expect(printed()).toContain(`• ${WARNING}`);
840
+ expect(printed()).toContain(`• ${PROBLEM}`);
841
+ expect(printed()).toContain(FIX);
837
842
  });
838
843
 
839
844
  test("B7: writeSettings and writeMcpConfig write a tracked live file", async () => {
@@ -0,0 +1,245 @@
1
+ /**
2
+ * CLI prompts at end of input.
3
+ *
4
+ * Written blind — from the spec and the export signatures of src/cli/prompt.ts
5
+ * only. `promptValue(question)` and `askYesNo(question)` take no stream, so they
6
+ * read the process's own stdin: each case runs them in a CHILD `bun` process
7
+ * whose stdin is a pipe this test writes to and then closes. The child writes
8
+ * its outcome to a result file; the prompt text it prints is ignored.
9
+ *
10
+ * REQ-B1 stdin ends before a line is answered → promptValue rejects with
11
+ * PromptAborted (instanceof, and isPromptAborted(err) is true),
12
+ * instead of waiting forever.
13
+ * REQ-B2 same for askYesNo.
14
+ * REQ-B3 stdin that stays open a while and THEN ends still rejects.
15
+ * REQ-B4 a line followed by a newline is still returned; promptValue trims it.
16
+ * REQ-B5 askYesNo still answers a line followed by a newline.
17
+ *
18
+ * Each child has HOME, XDG_* and the cwd inside a temp dir and runs with
19
+ * `--no-env-file`, so nothing here reads or writes the real home or a project.
20
+ */
21
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
22
+ import {
23
+ mkdir,
24
+ mkdtemp,
25
+ readFile,
26
+ realpath,
27
+ rm,
28
+ writeFile,
29
+ } from "node:fs/promises";
30
+ import { tmpdir } from "node:os";
31
+ import { join } from "node:path";
32
+
33
+ const PROMPT_MODULE = join(import.meta.dir, "..", "cli", "prompt.ts");
34
+
35
+ /** Bounded wait for a child. A hang past this is the "waits forever" bug. */
36
+ const CHILD_TIMEOUT_MS = 8_000;
37
+
38
+ const CHILD = `
39
+ const [fn, question, out] = process.argv.slice(2);
40
+ const mod = await import(${JSON.stringify(PROMPT_MODULE)});
41
+ let result;
42
+ try {
43
+ const value = await mod[fn](question);
44
+ result = { outcome: "resolved", value };
45
+ } catch (err) {
46
+ result = {
47
+ outcome: "rejected",
48
+ name: err?.name ?? null,
49
+ isPromptAbortedClass: err instanceof mod.PromptAborted,
50
+ isPromptAborted: mod.isPromptAborted(err),
51
+ message: String(err?.message ?? err),
52
+ };
53
+ }
54
+ await Bun.write(out, JSON.stringify(result));
55
+ process.exit(0);
56
+ `;
57
+
58
+ type Outcome =
59
+ | { outcome: "resolved"; value: unknown }
60
+ | {
61
+ outcome: "rejected";
62
+ name: string | null;
63
+ isPromptAbortedClass: boolean;
64
+ isPromptAborted: boolean;
65
+ message: string;
66
+ };
67
+
68
+ interface ChildRun {
69
+ result: Outcome | null;
70
+ timedOut: boolean;
71
+ exitCode: number | null;
72
+ stderr: string;
73
+ }
74
+
75
+ let root: string;
76
+ let childScript: string;
77
+ let runs = 0;
78
+
79
+ beforeAll(async () => {
80
+ root = await realpath(await mkdtemp(join(tmpdir(), "prompt-eof-")));
81
+ childScript = join(root, "child.ts");
82
+ await writeFile(childScript, CHILD);
83
+ });
84
+
85
+ afterAll(async () => {
86
+ await rm(root, { recursive: true, force: true });
87
+ });
88
+
89
+ async function runPrompt(
90
+ fn: "promptValue" | "askYesNo",
91
+ input: string,
92
+ opts: { holdOpenMs?: number } = {},
93
+ ): Promise<ChildRun> {
94
+ const dir = join(root, `run-${++runs}`);
95
+ const home = join(dir, "home");
96
+ await mkdir(home, { recursive: true });
97
+ const out = join(dir, "result.json");
98
+
99
+ const proc = Bun.spawn(
100
+ [process.execPath, "--no-env-file", childScript, fn, "Question? ", out],
101
+ {
102
+ cwd: dir,
103
+ stdin: "pipe",
104
+ stdout: "pipe",
105
+ stderr: "pipe",
106
+ env: {
107
+ PATH: process.env.PATH ?? "/usr/bin:/bin",
108
+ HOME: home,
109
+ XDG_CONFIG_HOME: join(dir, "xdg-config"),
110
+ XDG_CACHE_HOME: join(dir, "xdg-cache"),
111
+ XDG_DATA_HOME: join(dir, "xdg-data"),
112
+ XDG_STATE_HOME: join(dir, "xdg-state"),
113
+ TMPDIR: tmpdir(),
114
+ NO_COLOR: "1",
115
+ TERM: "dumb",
116
+ },
117
+ },
118
+ );
119
+
120
+ if (input.length > 0) proc.stdin.write(input);
121
+ await proc.stdin.flush();
122
+ if (opts.holdOpenMs) await Bun.sleep(opts.holdOpenMs);
123
+ await proc.stdin.end();
124
+
125
+ let timedOut = false;
126
+ const timer = setTimeout(() => {
127
+ timedOut = true;
128
+ proc.kill();
129
+ }, CHILD_TIMEOUT_MS);
130
+ const exitCode = await proc.exited;
131
+ clearTimeout(timer);
132
+ const stderr = await new Response(proc.stderr).text();
133
+
134
+ let result: Outcome | null = null;
135
+ try {
136
+ result = JSON.parse(await readFile(out, "utf8")) as Outcome;
137
+ } catch {
138
+ result = null;
139
+ }
140
+ return { result, timedOut, exitCode, stderr };
141
+ }
142
+
143
+ function expectAborted(run: ChildRun): void {
144
+ expect({
145
+ timedOut: run.timedOut,
146
+ stderr: run.timedOut ? run.stderr : "",
147
+ }).toEqual({
148
+ timedOut: false,
149
+ stderr: "",
150
+ });
151
+ if (run.result === null) {
152
+ throw new Error(
153
+ `child produced no result (exit ${run.exitCode}):\n${run.stderr}`,
154
+ );
155
+ }
156
+ expect(run.result.outcome).toBe("rejected");
157
+ if (run.result.outcome !== "rejected") return;
158
+ expect(run.result.name).toBe("PromptAborted");
159
+ expect(run.result.isPromptAbortedClass).toBe(true);
160
+ expect(run.result.isPromptAborted).toBe(true);
161
+ }
162
+
163
+ function expectResolved(run: ChildRun, value: unknown): void {
164
+ expect(run.timedOut).toBe(false);
165
+ if (run.result === null) {
166
+ throw new Error(
167
+ `child produced no result (exit ${run.exitCode}):\n${run.stderr}`,
168
+ );
169
+ }
170
+ expect(run.result).toEqual({ outcome: "resolved", value });
171
+ }
172
+
173
+ describe("promptValue at end of input", () => {
174
+ test(
175
+ "REQ-B1 rejects with PromptAborted when stdin closes with nothing on it",
176
+ async () => {
177
+ expectAborted(await runPrompt("promptValue", ""));
178
+ },
179
+ CHILD_TIMEOUT_MS + 4_000,
180
+ );
181
+
182
+ test(
183
+ "REQ-B3 rejects with PromptAborted when stdin stays open, then closes unanswered",
184
+ async () => {
185
+ expectAborted(await runPrompt("promptValue", "", { holdOpenMs: 300 }));
186
+ },
187
+ CHILD_TIMEOUT_MS + 4_000,
188
+ );
189
+
190
+ test(
191
+ "REQ-B4 returns a newline-terminated line, trimmed",
192
+ async () => {
193
+ expectResolved(
194
+ await runPrompt("promptValue", " team-profile \n"),
195
+ "team-profile",
196
+ );
197
+ },
198
+ CHILD_TIMEOUT_MS + 4_000,
199
+ );
200
+
201
+ test(
202
+ "REQ-B4 returns the first line when stdin then ends",
203
+ async () => {
204
+ expectResolved(
205
+ await runPrompt("promptValue", "hello\n", { holdOpenMs: 100 }),
206
+ "hello",
207
+ );
208
+ },
209
+ CHILD_TIMEOUT_MS + 4_000,
210
+ );
211
+ });
212
+
213
+ describe("askYesNo at end of input", () => {
214
+ test(
215
+ "REQ-B2 rejects with PromptAborted when stdin closes with nothing on it",
216
+ async () => {
217
+ expectAborted(await runPrompt("askYesNo", ""));
218
+ },
219
+ CHILD_TIMEOUT_MS + 4_000,
220
+ );
221
+
222
+ test(
223
+ "REQ-B3 rejects with PromptAborted when stdin stays open, then closes unanswered",
224
+ async () => {
225
+ expectAborted(await runPrompt("askYesNo", "", { holdOpenMs: 300 }));
226
+ },
227
+ CHILD_TIMEOUT_MS + 4_000,
228
+ );
229
+
230
+ test(
231
+ 'REQ-B5 answers "y" + newline with true',
232
+ async () => {
233
+ expectResolved(await runPrompt("askYesNo", "y\n"), true);
234
+ },
235
+ CHILD_TIMEOUT_MS + 4_000,
236
+ );
237
+
238
+ test(
239
+ 'REQ-B5 answers "n" + newline with false',
240
+ async () => {
241
+ expectResolved(await runPrompt("askYesNo", "n\n"), false);
242
+ },
243
+ CHILD_TIMEOUT_MS + 4_000,
244
+ );
245
+ });
@@ -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
  ],
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Which registry row a question reads — the pure halves of two fixes whose
3
+ * end-to-end cases are in installed-version-scope.test.tsx.
4
+ *
5
+ * - `ownRegistryEntry`: "does THIS project hold a row at this scope?" — the
6
+ * apply engine's guard before `update`. It used to be answered by the lookup
7
+ * that falls back to inherited rows and then the user row.
8
+ * - `contentDriftEntry`: the row whose commit a content-drift check compares.
9
+ * It used to be the project lookup for every plugin, installed there or not.
10
+ */
11
+
12
+ import { describe, expect, test } from "bun:test";
13
+ import { ownRegistryEntry } from "../services/claude-settings.js";
14
+ import {
15
+ type PluginInfo,
16
+ contentDriftEntry,
17
+ } from "../services/plugin-manager.js";
18
+ import type { InstalledPluginEntry } from "../types/index.js";
19
+
20
+ const REPO = "/work/repo";
21
+ const HERE = "/work/repo/sub";
22
+
23
+ const row = (over: Partial<InstalledPluginEntry>): InstalledPluginEntry => ({
24
+ scope: "user",
25
+ installPath: "/cache",
26
+ version: "1.0.0",
27
+ installedAt: "",
28
+ lastUpdated: "",
29
+ ...over,
30
+ });
31
+
32
+ describe("ownRegistryEntry — this project's own row, exactly", () => {
33
+ test("never falls back to the user row for project or local scope", () => {
34
+ const rows = [row({ scope: "user" })];
35
+ expect(ownRegistryEntry(rows, "project", HERE)).toBeUndefined();
36
+ expect(ownRegistryEntry(rows, "local", HERE)).toBeUndefined();
37
+ expect(ownRegistryEntry(rows, "user", HERE)?.scope).toBe("user");
38
+ });
39
+
40
+ test("never takes a row inherited from the repository root", () => {
41
+ const rows = [row({ scope: "project", projectPath: REPO })];
42
+ expect(ownRegistryEntry(rows, "project", HERE)).toBeUndefined();
43
+ });
44
+
45
+ test("matches scope and path exactly — a local row is not a project row", () => {
46
+ const local = row({ scope: "local", projectPath: HERE, version: "3.0.0" });
47
+ const project = row({
48
+ scope: "project",
49
+ projectPath: `${HERE}/`,
50
+ version: "2.0.0",
51
+ });
52
+ expect(ownRegistryEntry([local], "project", HERE)).toBeUndefined();
53
+ expect(ownRegistryEntry([local, project], "project", HERE)?.version).toBe(
54
+ "2.0.0",
55
+ );
56
+ expect(ownRegistryEntry([local, project], "local", HERE)?.version).toBe(
57
+ "3.0.0",
58
+ );
59
+ });
60
+ });
61
+
62
+ describe("contentDriftEntry — the row behind an installed scope", () => {
63
+ const USER = row({ scope: "user", version: "0.9.0", gitCommitSha: "user" });
64
+ const INHERITED = row({
65
+ scope: "project",
66
+ projectPath: REPO,
67
+ version: "0.8.1",
68
+ gitCommitSha: "inherited",
69
+ });
70
+ const OWN = row({
71
+ scope: "project",
72
+ projectPath: HERE,
73
+ version: "0.9.0",
74
+ gitCommitSha: "own",
75
+ });
76
+ const plugin = (over: Partial<PluginInfo>): PluginInfo => ({
77
+ id: "probe@magus",
78
+ name: "probe",
79
+ version: "0.9.0",
80
+ description: "",
81
+ marketplace: "magus",
82
+ marketplaceDisplay: "Magus",
83
+ installedVersion: "0.9.0",
84
+ ...over,
85
+ });
86
+
87
+ test("installed at user, beside an inherited project row of another version: the user row", () => {
88
+ const entry = contentDriftEntry(
89
+ plugin({
90
+ userScope: { installed: true, version: "0.9.0" },
91
+ projectScope: { installed: false, version: "0.8.1" },
92
+ }),
93
+ [USER, INHERITED],
94
+ HERE,
95
+ [REPO],
96
+ );
97
+ expect(entry?.gitCommitSha).toBe("user");
98
+ });
99
+
100
+ test("installed at both at the same version: this directory's row first", () => {
101
+ const entry = contentDriftEntry(
102
+ plugin({
103
+ userScope: { installed: true, version: "0.9.0" },
104
+ projectScope: { installed: true, version: "0.9.0" },
105
+ }),
106
+ [USER, OWN],
107
+ HERE,
108
+ [REPO],
109
+ );
110
+ expect(entry?.gitCommitSha).toBe("own");
111
+ });
112
+
113
+ test("no installed scope with a version: no row", () => {
114
+ const entry = contentDriftEntry(
115
+ plugin({
116
+ installedVersion: undefined,
117
+ userScope: { installed: true },
118
+ projectScope: { installed: false, version: "0.8.1" },
119
+ }),
120
+ [INHERITED],
121
+ HERE,
122
+ [REPO],
123
+ );
124
+ expect(entry).toBeUndefined();
125
+ });
126
+ });