magus-cli 7.7.0 → 7.9.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 (140) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -4
  3. package/src/__tests__/blackbox-parity.test.ts +45 -26
  4. package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
  5. package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
  6. package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
  7. package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
  8. package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
  9. package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
  10. package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
  11. package/src/__tests__/blind-780-cli-tools.test.ts +796 -0
  12. package/src/__tests__/blind-780-deprecated.test.ts +631 -0
  13. package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
  14. package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
  15. package/src/__tests__/blind-780-review2.test.ts +1178 -0
  16. package/src/__tests__/cli-apply-seams.test.ts +2 -67
  17. package/src/__tests__/cli-tool-commands.test.ts +131 -6
  18. package/src/__tests__/cli-tools-adapter.test.ts +52 -2
  19. package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
  20. package/src/__tests__/cli-update-view.test.ts +3 -3
  21. package/src/__tests__/deprecated-remove.test.ts +827 -0
  22. package/src/__tests__/deps-adapter.test.ts +191 -0
  23. package/src/__tests__/deps-check-hook.test.ts +220 -0
  24. package/src/__tests__/deps-plan.test.ts +896 -0
  25. package/src/__tests__/deps-report.test.ts +197 -0
  26. package/src/__tests__/doctor-bins.test.ts +134 -36
  27. package/src/__tests__/doctor-json.test.ts +261 -0
  28. package/src/__tests__/doctor.test.ts +159 -1
  29. package/src/__tests__/failure-popup-review2.test.tsx +896 -0
  30. package/src/__tests__/failure-popup.test.tsx +419 -0
  31. package/src/__tests__/final-fixes-780.test.tsx +740 -0
  32. package/src/__tests__/fixtures/bins.ts +55 -0
  33. package/src/__tests__/format-update-outcome.test.ts +6 -32
  34. package/src/__tests__/gitignore-profile.test.ts +1 -9
  35. package/src/__tests__/install-plan.test.ts +15 -1
  36. package/src/__tests__/marketplace-refresh.test.ts +9 -3
  37. package/src/__tests__/models-screen-state.test.ts +18 -40
  38. package/src/__tests__/no-shell-spawn.test.ts +77 -0
  39. package/src/__tests__/npm-package-name.test.ts +6 -9
  40. package/src/__tests__/plugin-requires.test.ts +138 -153
  41. package/src/__tests__/plugin-resolution.test.ts +52 -13
  42. package/src/__tests__/plugin-row-text.test.ts +30 -37
  43. package/src/__tests__/profile-dependencies.test.ts +7 -0
  44. package/src/__tests__/profile-startup-old-format.test.ts +0 -3
  45. package/src/__tests__/profile-startup.test.ts +40 -12
  46. package/src/__tests__/requires-probe.test.ts +858 -0
  47. package/src/__tests__/resolver.test.ts +129 -52
  48. package/src/__tests__/run.test.ts +108 -0
  49. package/src/__tests__/stale-load-repair.test.ts +31 -23
  50. package/src/__tests__/tool-probe.test.ts +1 -1
  51. package/src/__tests__/toolchain.test.ts +123 -52
  52. package/src/__tests__/update-apply.test.ts +13 -29
  53. package/src/__tests__/update-plan.test.ts +24 -18
  54. package/src/__tests__/useStaleLoadModal.test.ts +1 -57
  55. package/src/__tests__/version-unknown-state.test.ts +15 -17
  56. package/src/cli/ansi.ts +2 -7
  57. package/src/cli/deps-view.ts +213 -0
  58. package/src/cli/doctor.ts +320 -54
  59. package/src/cli/install.ts +55 -71
  60. package/src/cli/profile.ts +20 -2
  61. package/src/cli/router.ts +6 -2
  62. package/src/cli/update-view.ts +58 -15
  63. package/src/cli/update.ts +55 -12
  64. package/src/data/cli-tools.ts +24 -9
  65. package/src/services/active-closure.ts +3 -3
  66. package/src/services/catalog-cache-store.ts +8 -6
  67. package/src/services/claude-cli.ts +106 -19
  68. package/src/services/claude-settings.ts +88 -384
  69. package/src/services/cli-tool-commands.ts +104 -3
  70. package/src/services/deps-plan.ts +962 -0
  71. package/src/services/deps-report.ts +265 -0
  72. package/src/services/doctor-bins.ts +115 -43
  73. package/src/services/install-plan.ts +11 -1
  74. package/src/services/local-marketplace.ts +12 -206
  75. package/src/services/marketplace-refresh.ts +4 -4
  76. package/src/services/plugin-env-needs.ts +69 -0
  77. package/src/services/plugin-manager.ts +35 -37
  78. package/src/services/plugin-requires.ts +93 -162
  79. package/src/services/plugin-resolution.ts +34 -42
  80. package/src/services/profile-activate.ts +1 -2
  81. package/src/services/profile-startup.ts +18 -43
  82. package/src/services/profile-state.ts +72 -2
  83. package/src/services/resolver.ts +263 -59
  84. package/src/services/tool-probe.ts +18 -25
  85. package/src/services/toolchain.ts +117 -111
  86. package/src/services/update-engine.ts +345 -45
  87. package/src/services/update-plan.ts +296 -36
  88. package/src/shared/plugin/hooks/bun-missing.sh +39 -0
  89. package/src/shared/plugin/hooks/deps-check.ts +224 -0
  90. package/src/shared/plugin/hooks/lib/requires-probe.ts +1183 -0
  91. package/src/types/index.ts +60 -16
  92. package/src/ui/App.tsx +81 -171
  93. package/src/ui/adapters/cliToolsAdapter.ts +252 -12
  94. package/src/ui/adapters/depsAdapter.ts +129 -0
  95. package/src/ui/adapters/failureReport.ts +137 -0
  96. package/src/ui/adapters/pluginsAdapter.ts +297 -45
  97. package/src/ui/adapters/startupReports.ts +260 -0
  98. package/src/ui/components/modals/FailureListModal.tsx +231 -0
  99. package/src/ui/components/modals/MessageModal.tsx +7 -12
  100. package/src/ui/components/modals/ModalContainer.tsx +10 -5
  101. package/src/ui/components/modals/index.ts +1 -0
  102. package/src/ui/hooks/startupQueue.ts +56 -0
  103. package/src/ui/hooks/useGitignoreModal.ts +30 -52
  104. package/src/ui/hooks/useProfileStartupModal.ts +119 -142
  105. package/src/ui/hooks/useStaleLoadModal.ts +35 -65
  106. package/src/ui/keymap.ts +14 -1
  107. package/src/ui/renderers/cliToolRenderers.tsx +135 -2
  108. package/src/ui/renderers/mcpRenderers.tsx +110 -3
  109. package/src/ui/renderers/pluginRenderers.tsx +77 -6
  110. package/src/ui/renderers/pluginRowText.ts +25 -25
  111. package/src/ui/renderers/pluginStatusText.ts +18 -1
  112. package/src/ui/screens/AliasScreen.tsx +38 -45
  113. package/src/ui/screens/CliToolsScreen.tsx +199 -98
  114. package/src/ui/screens/EnvVarsScreen.tsx +17 -5
  115. package/src/ui/screens/GitignoreScreen.tsx +48 -67
  116. package/src/ui/screens/McpRegistryScreen.tsx +67 -38
  117. package/src/ui/screens/McpScreen.tsx +46 -19
  118. package/src/ui/screens/ModelsScreen.tsx +46 -86
  119. package/src/ui/screens/PluginsScreen.tsx +315 -259
  120. package/src/ui/screens/ProfilesScreen.tsx +79 -40
  121. package/src/ui/screens/SkillsScreen.tsx +83 -80
  122. package/src/ui/screens/StylesScreen.tsx +211 -275
  123. package/src/ui/state/AppContext.tsx +66 -44
  124. package/src/ui/state/DimensionsContext.tsx +3 -22
  125. package/src/ui/state/reducer.ts +12 -100
  126. package/src/ui/state/types.ts +48 -69
  127. package/src/utils/command-utils.ts +4 -5
  128. package/src/utils/run.ts +123 -52
  129. package/src/utils/string-utils.ts +9 -0
  130. package/src/utils/user-path.ts +60 -0
  131. package/src/__tests__/go-module.test.ts +0 -43
  132. package/src/__tests__/notices-rules.test.ts +0 -476
  133. package/src/__tests__/notices.test.tsx +0 -604
  134. package/src/__tests__/plugin-setup.test.ts +0 -1016
  135. package/src/__tests__/shell-script-callers.test.ts +0 -92
  136. package/src/__tests__/styles-status-line.test.ts +0 -72
  137. package/src/__tests__/version-snapshot.test.ts +0 -212
  138. package/src/services/plugin-setup.ts +0 -965
  139. package/src/services/version-snapshot.ts +0 -189
  140. package/src/utils/go-module.ts +0 -36
@@ -1,24 +1,30 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { PREDEFINED_PROFILES } from "../data/predefined-profiles.js";
3
3
  import {
4
- dedupeBins,
4
+ mergeBins,
5
+ pinOfBin,
5
6
  resolveAllProfiles,
6
7
  resolveExtends,
7
8
  resolveProfile,
8
9
  } from "../services/resolver.js";
9
10
  import type {
10
- BinRequirement,
11
+ ManagedTool,
11
12
  ProfileManifest,
12
13
  ProfileManifestEntry,
13
14
  } from "../types/index.js";
15
+ import { ownedAsDeclared, selected } from "./fixtures/bins.js";
14
16
 
15
- // A binResolver that returns canned requires.bin per plugin — keeps the
16
- // resolver tests hermetic (no marketplace cache / filesystem).
17
- function fakeBinResolver(
18
- table: Record<string, BinRequirement[]>,
19
- ): (pluginId: string) => Promise<BinRequirement[]> {
20
- return async (pluginId) => table[pluginId] ?? [];
17
+ // A binResolver that returns canned requirements per plugin — keeps the
18
+ // resolver tests hermetic (no marketplace cache / filesystem). A catalogue
19
+ // tool a plugin owns resolves as that plugin declares it today.
20
+ function fakeBinResolver(table: Record<string, ManagedTool[]>) {
21
+ return async (pluginId: string) =>
22
+ (table[pluginId] ?? []).map((t) => selected(t));
21
23
  }
24
+ const hermetic = (table: Record<string, ManagedTool[]> = {}) => ({
25
+ binResolver: fakeBinResolver(table),
26
+ ownedResolver: ownedAsDeclared,
27
+ });
22
28
 
23
29
  function manifestOf(
24
30
  profiles: Record<string, ProfileManifestEntry>,
@@ -26,44 +32,60 @@ function manifestOf(
26
32
  return { version: 2, profiles };
27
33
  }
28
34
 
29
- describe("dedupeBins — precedence by order", () => {
30
- test("first-seen wins installer/version; later contributes only provenance", () => {
31
- const claudishPin: BinRequirement = {
32
- name: "claudish",
33
- via: "bun",
34
- package: "claudish",
35
- version: "1.2.0",
36
- };
37
- const claudishLoose: BinRequirement = {
38
- name: "claudish",
39
- via: "npm",
40
- package: "claudish",
41
- };
42
- const result = dedupeBins([
43
- { bin: claudishPin, source: "profile:x.cliTools" },
44
- { bin: claudishLoose, source: "plugin:foo@magus" },
35
+ describe("mergeBins — precedence by order", () => {
36
+ test("a profile pin absorbs a later declaration of the name; it adds only provenance", () => {
37
+ const result = mergeBins([
38
+ {
39
+ bin: selected({ name: "claudish", via: "bun", version: "1.2.0" }),
40
+ source: "profile:x.cliTools",
41
+ },
42
+ {
43
+ bin: selected({ name: "claudish", via: "npm" }),
44
+ source: "plugin:foo@magus",
45
+ },
45
46
  ]);
46
47
  expect(result).toHaveLength(1);
47
- expect(result[0]!.via).toBe("bun");
48
- expect(result[0]!.version).toBe("1.2.0");
48
+ expect(result[0]!.chosen?.via).toBe("bun");
49
+ expect(pinOfBin(result[0]!)).toBe("1.2.0");
49
50
  expect(result[0]!.sources).toEqual([
50
51
  "profile:x.cliTools",
51
52
  "plugin:foo@magus",
52
53
  ]);
53
54
  });
54
55
 
55
- test("a later entry fills a version the winner left unset", () => {
56
- const loose: BinRequirement = { name: "tmux", via: "brew" };
57
- const pinned: BinRequirement = {
58
- name: "tmux",
59
- via: "brew",
60
- version: "3.4",
56
+ test("identical declarations merge; different plugin declarations of one name stay two", () => {
57
+ const jq = selected({ name: "jq", via: "brew", formula: "jq" });
58
+ const result = mergeBins([
59
+ { bin: jq, source: "plugin:dev@magus" },
60
+ { bin: { ...jq }, source: "plugin:code-search@magus" },
61
+ {
62
+ bin: selected({
63
+ name: "jq",
64
+ via: "brew",
65
+ formula: "jq",
66
+ check: "jq -V",
67
+ }),
68
+ source: "plugin:other@elsewhere",
69
+ },
70
+ ]);
71
+ expect(result).toHaveLength(2);
72
+ expect(result[0]!.sources).toEqual([
73
+ "plugin:dev@magus",
74
+ "plugin:code-search@magus",
75
+ ]);
76
+ expect(result[1]!.sources).toEqual(["plugin:other@elsewhere"]);
77
+ });
78
+
79
+ test("a check that reads the plugin root never merges across plugins", () => {
80
+ const env = {
81
+ ...selected({ name: "env", via: "bun" }),
82
+ check: ["${CLAUDE_PLUGIN_ROOT}/x"],
61
83
  };
62
- const result = dedupeBins([
63
- { bin: loose, source: "plugin:a" },
64
- { bin: pinned, source: "plugin:b" },
84
+ const result = mergeBins([
85
+ { bin: { ...env, root: "/a" }, source: "plugin:a@m" },
86
+ { bin: { ...env, root: "/b" }, source: "plugin:b@m" },
65
87
  ]);
66
- expect(result[0]!.version).toBe("3.4");
88
+ expect(result).toHaveLength(2);
67
89
  });
68
90
  });
69
91
 
@@ -161,8 +183,10 @@ describe("resolveProfile", () => {
161
183
  },
162
184
  });
163
185
 
164
- const closure = await resolveProfile(manifest, "frontend", {
165
- binResolver: fakeBinResolver({
186
+ const closure = await resolveProfile(
187
+ manifest,
188
+ "frontend",
189
+ hermetic({
166
190
  "terminal@magus": [
167
191
  { name: "tmux", via: "brew", formula: "tmux" },
168
192
  // terminal ALSO wants claudish, but the profile pinned it →
@@ -170,7 +194,7 @@ describe("resolveProfile", () => {
170
194
  { name: "claudish", via: "npm", package: "claudish" },
171
195
  ],
172
196
  }),
173
- });
197
+ );
174
198
 
175
199
  expect(closure.plugins).toEqual({
176
200
  "terminal@magus": "4.1.0",
@@ -179,12 +203,13 @@ describe("resolveProfile", () => {
179
203
  expect(closure.env.required).toEqual(["FIGMA_ACCESS_TOKEN"]);
180
204
 
181
205
  const claudish = closure.bins.find((b) => b.name === "claudish")!;
182
- expect(claudish.via).toBe("bun"); // from the cli-tools catalog, not npm
183
- expect(claudish.version).toBe("1.2.0");
206
+ // claudish@magus's own declaration (bun), pinned by the profile — not npm.
207
+ expect(claudish.chosen?.via).toBe("bun");
208
+ expect(pinOfBin(claudish)).toBe("1.2.0");
184
209
  expect(claudish.sources[0]).toContain("cliTools");
185
210
 
186
211
  const tmux = closure.bins.find((b) => b.name === "tmux")!;
187
- expect(tmux.via).toBe("brew");
212
+ expect(tmux.chosen?.via).toBe("brew");
188
213
  expect(tmux.sources).toContain("plugin:terminal@magus");
189
214
  });
190
215
 
@@ -213,9 +238,7 @@ describe("resolveAllProfiles — union", () => {
213
238
  },
214
239
  });
215
240
 
216
- const union = await resolveAllProfiles(manifest, {
217
- binResolver: fakeBinResolver({}),
218
- });
241
+ const union = await resolveAllProfiles(manifest, hermetic());
219
242
 
220
243
  expect(union.plugins["dev@magus"]).toBe("2.9.0"); // pin beat latest
221
244
  expect(union.plugins["terminal@magus"]).toBe("4.1.0");
@@ -224,7 +247,9 @@ describe("resolveAllProfiles — union", () => {
224
247
  "DATABASE_URL",
225
248
  "FIGMA_ACCESS_TOKEN",
226
249
  ]);
227
- expect(union.bins.find((b) => b.name === "mnemex")?.via).toBe("bun");
250
+ expect(union.bins.find((b) => b.name === "mnemex")?.chosen?.via).toBe(
251
+ "bun",
252
+ );
228
253
  expect(union.conflicts).toBeUndefined(); // latest-vs-pin isn't a conflict
229
254
  });
230
255
 
@@ -260,16 +285,68 @@ describe("resolveAllProfiles — union", () => {
260
285
  a: { name: "A", cliTools: { claudish: "1.2.0" } },
261
286
  b: { name: "B", cliTools: { claudish: "1.5.0" } },
262
287
  });
263
- const union = await resolveAllProfiles(manifest, {
264
- binResolver: fakeBinResolver({}),
265
- });
266
- expect(union.bins.find((x) => x.name === "claudish")?.version).toBe(
267
- "1.5.0",
268
- );
288
+ const union = await resolveAllProfiles(manifest, hermetic());
289
+ const claudish = union.bins.find((x) => x.name === "claudish");
290
+ expect(claudish && pinOfBin(claudish)).toBe("1.5.0");
269
291
  expect(union.conflicts?.some((c) => c.includes("claudish"))).toBe(true);
270
292
  });
271
293
  });
272
294
 
295
+ describe("a pin on a tool its plugin installs without a version", () => {
296
+ // claudish@magus as it declares claudish off Homebrew: its pinned release.
297
+ const RELEASE = {
298
+ via: "release" as const,
299
+ repo: "MadAppGang/claudish",
300
+ tag: "v10.3.0",
301
+ asset: "claudish-{os}-{arch}",
302
+ binary: "claudish",
303
+ sha256: { linux_amd64: "a".repeat(64) },
304
+ };
305
+ const ownedRelease = async (_pluginId: string, name: string) => ({
306
+ name,
307
+ check: [name, "--version"],
308
+ install: [RELEASE],
309
+ chosen: RELEASE,
310
+ });
311
+ const opts = {
312
+ binResolver: fakeBinResolver({}),
313
+ ownedResolver: ownedRelease,
314
+ };
315
+
316
+ test("is kept as refused: the release stays what installs, and no pin is invented", async () => {
317
+ const closure = await resolveProfile(
318
+ manifestOf({ p: { name: "P", cliTools: { claudish: "6.1.0" } } }),
319
+ "p",
320
+ opts,
321
+ );
322
+ const claudish = closure.bins.find((b) => b.name === "claudish");
323
+ expect(claudish?.chosen).toEqual(RELEASE);
324
+ expect(claudish && pinOfBin(claudish)).toBeUndefined();
325
+ expect(claudish?.refusedPins).toEqual(["6.1.0"]);
326
+ });
327
+
328
+ test("every profile's refused pin survives the union", async () => {
329
+ const union = await resolveAllProfiles(
330
+ manifestOf({
331
+ a: { name: "A", cliTools: { claudish: "6.1.0" } },
332
+ b: { name: "B", cliTools: { claudish: "7.0.0" } },
333
+ }),
334
+ opts,
335
+ );
336
+ const claudish = union.bins.find((b) => b.name === "claudish");
337
+ expect(claudish?.refusedPins).toEqual(["6.1.0", "7.0.0"]);
338
+ });
339
+
340
+ test("latest is not a pin, so nothing is refused", async () => {
341
+ const closure = await resolveProfile(
342
+ manifestOf({ p: { name: "P", cliTools: { claudish: "latest" } } }),
343
+ "p",
344
+ opts,
345
+ );
346
+ expect(closure.bins[0]?.refusedPins).toBeUndefined();
347
+ });
348
+ });
349
+
273
350
  describe("marketplaces derived from plugin ids", () => {
274
351
  const noBins = { binResolver: fakeBinResolver({}) };
275
352
 
@@ -0,0 +1,108 @@
1
+ /**
2
+ * `runArgv` — the one runner: argv only, output captured and tailed, never
3
+ * written to the terminal, bounded by SIGTERM then SIGKILL.
4
+ */
5
+
6
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
7
+ import {
8
+ OUTPUT_TAIL_BYTES,
9
+ runArgv,
10
+ runFailureReason,
11
+ succeeded,
12
+ } from "../utils/run.js";
13
+
14
+ const sh = (script: string) => ({ cmd: "/bin/sh", args: ["-c", script] });
15
+
16
+ describe("runArgv", () => {
17
+ let out: ReturnType<typeof spyOn>;
18
+ let err: ReturnType<typeof spyOn>;
19
+ beforeEach(() => {
20
+ out = spyOn(process.stdout, "write");
21
+ err = spyOn(process.stderr, "write");
22
+ });
23
+ afterEach(() => {
24
+ out.mockRestore();
25
+ err.mockRestore();
26
+ });
27
+
28
+ test("captures stdout and stderr together, ANSI stripped, and never writes to the terminal", async () => {
29
+ const chunks: string[] = [];
30
+ const r = await runArgv(
31
+ sh("printf 'to-out\\n'; printf '\\033[31mto-err\\033[0m\\n' >&2"),
32
+ { timeoutMs: 5_000, onOutput: (c) => chunks.push(c) },
33
+ );
34
+ expect(r.exitCode).toBe(0);
35
+ expect(succeeded(r)).toBe(true);
36
+ expect(r.outputTail).toContain("to-out");
37
+ expect(r.outputTail).toContain("to-err");
38
+ expect(r.outputTail).not.toContain("\u001b[");
39
+ expect(chunks.join("")).toContain("to-out");
40
+ expect(out).not.toHaveBeenCalled();
41
+ expect(err).not.toHaveBeenCalled();
42
+ });
43
+
44
+ test("keeps only the last 16 KiB of output", async () => {
45
+ const r = await runArgv(
46
+ sh(
47
+ "i=0; while [ $i -lt 2000 ]; do echo line-$i-xxxxxxxxxxxxxxxx; i=$((i+1)); done",
48
+ ),
49
+ { timeoutMs: 10_000 },
50
+ );
51
+ expect(Buffer.byteLength(r.outputTail)).toBeLessThanOrEqual(
52
+ OUTPUT_TAIL_BYTES,
53
+ );
54
+ expect(r.outputTail).toContain("line-1999-");
55
+ expect(r.outputTail).not.toContain("line-0-");
56
+ });
57
+
58
+ test("an argument is one argument: nothing reaches a shell", async () => {
59
+ const r = await runArgv(
60
+ { cmd: "/bin/echo", args: ["a b; echo injected"] },
61
+ { timeoutMs: 5_000 },
62
+ );
63
+ expect(r.outputTail.trim()).toBe("a b; echo injected");
64
+ });
65
+
66
+ test("stdin is not the terminal", async () => {
67
+ const r = await runArgv(sh("cat; echo done"), { timeoutMs: 5_000 });
68
+ expect(r.outputTail.trim()).toBe("done");
69
+ });
70
+
71
+ test("a missing executable is a spawn error, not a throw", async () => {
72
+ const r = await runArgv(
73
+ { cmd: "/nonexistent/tool", args: [] },
74
+ { timeoutMs: 5_000 },
75
+ );
76
+ expect(r.exitCode).toBeNull();
77
+ expect(r.spawnError).toContain("ENOENT");
78
+ expect(succeeded(r)).toBe(false);
79
+ });
80
+
81
+ test("over the limit: SIGTERM, then SIGKILL once the grace runs out", async () => {
82
+ const started = Date.now();
83
+ const r = await runArgv(sh("trap '' TERM; while :; do sleep 0.05; done"), {
84
+ timeoutMs: 200,
85
+ killGraceMs: 300,
86
+ });
87
+ expect(r.timedOut).toBe(true);
88
+ expect(r.signal).toBe("SIGKILL");
89
+ expect(Date.now() - started).toBeGreaterThanOrEqual(450);
90
+ expect(runFailureReason(r)).toStartWith("timed out");
91
+ });
92
+
93
+ test("a child that honours SIGTERM ends there", async () => {
94
+ const r = await runArgv(sh("exec sleep 10"), { timeoutMs: 200 });
95
+ expect(r.timedOut).toBe(true);
96
+ expect(r.signal).toBe("SIGTERM");
97
+ });
98
+
99
+ test("the failure reason is the installer's last line", async () => {
100
+ const r = await runArgv(
101
+ sh("echo progress; echo 'error: no such formula' >&2; exit 3"),
102
+ {
103
+ timeoutMs: 5_000,
104
+ },
105
+ );
106
+ expect(runFailureReason(r)).toBe("exit 3: error: no such formula");
107
+ });
108
+ });
@@ -303,7 +303,9 @@ describe("repairStaleLoad — the backup", () => {
303
303
  const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
304
304
 
305
305
  expect(result.ok).toBe(false);
306
- expect(result.error).toContain("could not back up");
306
+ expect(result.error).toBe(
307
+ "could not save a backup before changing anything, so nothing was changed",
308
+ );
307
309
  expect(result.rewritten).toBe(0);
308
310
  expect(result.backup).toBeNull();
309
311
  expect(await fs.readFile(registryFile, "utf8")).toBe(before);
@@ -328,7 +330,7 @@ describe("repairStaleLoad — refusals", () => {
328
330
  const result = await repairStaleLoad(PLUGIN, "0.7.0", here, root);
329
331
 
330
332
  expect(result.ok).toBe(false);
331
- expect(result.error).toContain("no cache directory for v0.7.0");
333
+ expect(result.error).toBe("v0.7.0 is not downloaded — run magus install");
332
334
  expect(result.backup).toBeNull();
333
335
  expect(await fs.readFile(registryFile, "utf8")).toBe(before);
334
336
  expect(await backups()).toEqual([]);
@@ -340,7 +342,7 @@ describe("repairStaleLoad — refusals", () => {
340
342
  const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
341
343
 
342
344
  expect(result.ok).toBe(false);
343
- expect(result.error).toContain("has no rows");
345
+ expect(result.error).toBe("it is not installed on this machine");
344
346
  });
345
347
 
346
348
  test("a string that is not a plugin id is refused", async () => {
@@ -360,7 +362,9 @@ describe("repairStaleLoad — refusals", () => {
360
362
  const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
361
363
 
362
364
  expect(result.ok).toBe(false);
363
- expect(result.error).toContain("not a readable registry");
365
+ expect(result.error).toBe(
366
+ "Claude Code's list of installed plugins cannot be read, so nothing was changed — run magus doctor",
367
+ );
364
368
  expect(await fs.readFile(registryFile, "utf8")).toBe("{ not json");
365
369
  });
366
370
  });
@@ -400,8 +404,9 @@ describe("repairStaleLoad — scope reach", () => {
400
404
  const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
401
405
 
402
406
  expect(result.ok).toBe(false);
403
- expect(result.error).toContain("machine-wide user-scope record at v0.3.0");
404
- expect(result.error).toContain("--scope user");
407
+ expect(result.error).toBe(
408
+ "Claude Code loads the USER install of it here — update it at USER scope",
409
+ );
405
410
  expect(result.rewritten).toBe(0);
406
411
  expect(await fs.readFile(registryFile, "utf8")).toBe(before);
407
412
  expect(await backups()).toEqual([]);
@@ -457,8 +462,9 @@ describe("repairStaleLoad — scope reach", () => {
457
462
  const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
458
463
 
459
464
  expect(result.ok).toBe(false);
460
- expect(result.error).toContain("machine-wide managed-scope record");
461
- expect(result.error).toContain("administrator policy");
465
+ expect(result.error).toBe(
466
+ "Claude Code loads the install your administrator manages here — ask them to update it",
467
+ );
462
468
  });
463
469
 
464
470
  test("a user-scope row that is not the winner stays where it is", async () => {
@@ -485,13 +491,11 @@ describe("repairStaleLoad — scope reach", () => {
485
491
  expect(after[1].installPath).toBe(cacheFor("0.4.0"));
486
492
  });
487
493
 
488
- test("a rootless row from another directory is relevant but never rewritten", async () => {
489
- // The loader's filter compares repository roots and two nulls are equal
490
- // there, so this row genuinely competes — `isRelevantInstallation` says so
491
- // and plugin-resolution.test.ts pins it. Writing it is a different question:
492
- // "the same repository" cannot be true of two directories that are in no
493
- // repository, so the repair reports that it could not fix this rather than
494
- // editing a stranger's row.
494
+ test("a rootless row from another directory neither competes nor is rewritten", async () => {
495
+ // Claude Code 2.1.284 matches nothing by root in a folder that is in no
496
+ // repository, so the other folder's older row does not load here and this
497
+ // folder's own row does. Nothing to repair, and the stranger's row is left
498
+ // exactly as it was.
495
499
  const looseHere = path.join(base, "loose-here");
496
500
  const looseOther = path.join(base, "loose-other");
497
501
  await fs.ensureDir(looseHere);
@@ -507,13 +511,13 @@ describe("repairStaleLoad — scope reach", () => {
507
511
  looseHere,
508
512
  repoRootForPath(looseHere),
509
513
  ),
510
- ).toBe(true);
514
+ ).toBe(false);
511
515
 
512
516
  const result = await repairStaleLoad(PLUGIN, "0.5.0", looseHere, root);
513
517
 
514
518
  expect(result.rewritten).toBe(0);
515
- expect(result.ok).toBe(false);
516
- expect(result.error).toContain("still resolves v0.3.0");
519
+ expect(result.ok).toBe(true);
520
+ expect(result.loaded).toBe("0.5.0");
517
521
  expect((await readRegistry()).plugins[PLUGIN][0].version).toBe("0.3.0");
518
522
  });
519
523
  });
@@ -557,7 +561,9 @@ describe("repairStaleLoad — a concurrent write is refused, not clobbered", ()
557
561
  const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
558
562
 
559
563
  expect(result.ok).toBe(false);
560
- expect(result.error).toContain("changed on disk after it was read");
564
+ expect(result.error).toBe(
565
+ "another Claude Code session changed its plugins at the same moment, so nothing was changed — run it again",
566
+ );
561
567
  expect(result.rewritten).toBe(0);
562
568
  } finally {
563
569
  spy.mockRestore();
@@ -694,7 +700,9 @@ describe("repairStaleLoad — the post-write assertion", () => {
694
700
  expect(result.rewritten).toBe(1);
695
701
  expect(result.loaded).toBe("0.9.0");
696
702
  expect(result.ok).toBe(false);
697
- expect(result.error).toContain("still resolves v0.9.0");
703
+ expect(result.error).toContain(
704
+ "Claude Code still loads v0.9.0 in this folder",
705
+ );
698
706
  });
699
707
 
700
708
  test("the assertion reads the file from DISK, not the object it mutated", async () => {
@@ -1147,7 +1155,7 @@ describe("B4: an update brings the loader forward in the same run", () => {
1147
1155
  expect(result.ok).toBe(1);
1148
1156
  expect(result.failed).toEqual([]);
1149
1157
  expect(details).toEqual([
1150
- "project → 0.5.0 · Claude Code now loads v0.5.0 here (1 older record moved forward)",
1158
+ "project → 0.5.0 · Claude Code now loads v0.5.0 in this folder",
1151
1159
  ]);
1152
1160
  expect(await loadedVersionHere(PLUGIN, here, root)).toBe("0.5.0");
1153
1161
  // Moved forward, nothing removed.
@@ -1265,7 +1273,7 @@ describe("7.5.2: Claude Code 2.1.281 updates another project's row of the reposi
1265
1273
  [here, "0.5.0"],
1266
1274
  ]);
1267
1275
  expect(details).toEqual([
1268
- `project → 0.5.0 · Claude Code updated ${mainTree} instead of this project — moved this project's record to v0.5.0 too`,
1276
+ "project → 0.5.0 · Claude Code updated another folder of this repository instead of this one — this folder now loads v0.5.0 too",
1269
1277
  ]);
1270
1278
  expect(await loadedVersionHere(PLUGIN, here, root)).toBe("0.5.0");
1271
1279
  });
@@ -1335,7 +1343,7 @@ describe("7.5.2: Claude Code 2.1.281 updates another project's row of the reposi
1335
1343
  ?.version,
1336
1344
  ).toBe("0.4.0");
1337
1345
  expect(details[0]).toContain(
1338
- `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`,
1346
+ `no change — the installer exited 0 but ${PLUGIN} is still 0.4.0 (Claude Code updated another folder of this repository instead of this one, and this folder could not follow: v0.6.0 is not downloaded — run magus install)`,
1339
1347
  );
1340
1348
  });
1341
1349
  });
@@ -1395,7 +1395,7 @@ describe("binPinCommand — moving an installed tool to exactly its pin", () =>
1395
1395
  test("bun/npm/go carry the version in the package token", () => {
1396
1396
  expect(
1397
1397
  binPinCommand({ name: "m", via: "bun", version: "1.0.0" }, uv),
1398
- ).toEqual({ cmd: "bun", args: ["install", "-g", "m@1.0.0"] });
1398
+ ).toEqual({ cmd: "bun", args: ["add", "-g", "m@1.0.0"] });
1399
1399
  expect(
1400
1400
  binPinCommand(
1401
1401
  { name: "t", via: "go", module: "github.com/x/t", version: "v2.0.0" },