magus-cli 7.3.1 → 7.4.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 (123) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
  3. package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
  4. package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
  5. package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
  6. package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
  7. package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
  8. package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
  9. package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
  10. package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
  11. package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
  12. package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
  13. package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
  14. package/src/__tests__/blind/c5-keymap.test.ts +461 -0
  15. package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
  16. package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
  17. package/src/__tests__/blind/c8-text-width.test.ts +137 -0
  18. package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
  19. package/src/__tests__/blind/child-run.ts +114 -0
  20. package/src/__tests__/cli-tools-adapter.test.ts +5 -2
  21. package/src/__tests__/component-badge.test.ts +20 -36
  22. package/src/__tests__/footer-hints.test.ts +1 -1
  23. package/src/__tests__/manifest.test.ts +25 -17
  24. package/src/__tests__/marketplace-badge.test.ts +4 -4
  25. package/src/__tests__/models-screen-state.test.ts +36 -13
  26. package/src/__tests__/scope-squares.test.tsx +28 -114
  27. package/src/__tests__/shutdown.test.ts +578 -0
  28. package/src/__tests__/styles-manager.test.ts +4 -4
  29. package/src/__tests__/styles-screen-state.test.ts +26 -12
  30. package/src/__tests__/tabbar-layout.test.ts +44 -32
  31. package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
  32. package/src/__tests__/tool-probe.test.ts +47 -22
  33. package/src/__tests__/uppercase-keybindings.test.ts +25 -12
  34. package/src/cli/ansi.ts +2 -2
  35. package/src/cli/bootstrap.ts +33 -2
  36. package/src/cli/doctor.ts +105 -14
  37. package/src/cli/install.ts +8 -2
  38. package/src/cli/profile.ts +18 -1
  39. package/src/cli/router.ts +31 -18
  40. package/src/cli/update-view.ts +1 -1
  41. package/src/data/cli-tools.ts +9 -1
  42. package/src/services/claude-settings.ts +151 -73
  43. package/src/services/cli-tool-commands.ts +15 -5
  44. package/src/services/cli-tool-version.ts +101 -0
  45. package/src/services/hook-registration.ts +23 -6
  46. package/src/services/manifest.ts +117 -52
  47. package/src/services/plugin-manager.ts +19 -5
  48. package/src/services/profile-adopt.ts +7 -2
  49. package/src/services/profile-startup.ts +87 -7
  50. package/src/services/profiles.ts +36 -23
  51. package/src/services/settings-file.ts +158 -4
  52. package/src/services/settings-manager.ts +19 -5
  53. package/src/services/skills-manager.ts +46 -0
  54. package/src/services/tool-probe.ts +272 -45
  55. package/src/services/toolchain.ts +39 -0
  56. package/src/services/update-engine.ts +5 -0
  57. package/src/services/update-plan.ts +9 -0
  58. package/src/tui.tsx +48 -21
  59. package/src/ui/App.tsx +58 -197
  60. package/src/ui/adapters/cliToolsAdapter.ts +141 -12
  61. package/src/ui/adapters/pluginsAdapter.ts +10 -7
  62. package/src/ui/adapters/settingsAdapter.ts +0 -21
  63. package/src/ui/adapters/skillsAdapter.ts +27 -8
  64. package/src/ui/adapters/stylesAdapter.ts +15 -11
  65. package/src/ui/components/CategoryHeader.tsx +7 -1
  66. package/src/ui/components/EmptyFilterState.tsx +2 -2
  67. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  68. package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
  69. package/src/ui/components/TabBar.tsx +93 -21
  70. package/src/ui/components/layout/FooterHints.tsx +6 -27
  71. package/src/ui/components/layout/ScreenLayout.tsx +68 -42
  72. package/src/ui/components/layout/index.ts +1 -5
  73. package/src/ui/components/modals/ConfirmModal.tsx +2 -19
  74. package/src/ui/components/modals/HelpOverlay.tsx +186 -0
  75. package/src/ui/components/modals/MessageModal.tsx +3 -15
  76. package/src/ui/components/modals/ModalContainer.tsx +99 -60
  77. package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
  78. package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
  79. package/src/ui/components/primitives/MetaText.tsx +1 -1
  80. package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
  81. package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
  82. package/src/ui/components/primitives/SelectableRow.tsx +24 -1
  83. package/src/ui/components/primitives/index.ts +4 -3
  84. package/src/ui/hooks/useProfileStartupModal.ts +15 -3
  85. package/src/ui/input/KeyRouter.tsx +180 -0
  86. package/src/ui/input/key-dispatch.ts +149 -0
  87. package/src/ui/keymap.ts +898 -0
  88. package/src/ui/renderers/cliToolRenderers.tsx +128 -66
  89. package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
  90. package/src/ui/renderers/markdownRenderer.tsx +2 -2
  91. package/src/ui/renderers/mcpRenderers.tsx +28 -14
  92. package/src/ui/renderers/modelRenderers.tsx +12 -13
  93. package/src/ui/renderers/modelVisuals.tsx +0 -2
  94. package/src/ui/renderers/pluginRenderers.tsx +47 -54
  95. package/src/ui/renderers/profileRenderers.tsx +32 -37
  96. package/src/ui/renderers/settingsRenderers.tsx +84 -55
  97. package/src/ui/renderers/skillRenderers.tsx +237 -190
  98. package/src/ui/renderers/styleRenderers.tsx +13 -2
  99. package/src/ui/runtime/shutdown.ts +268 -0
  100. package/src/ui/screens/AliasScreen.tsx +118 -100
  101. package/src/ui/screens/CliToolsScreen.tsx +98 -87
  102. package/src/ui/screens/EnvVarsScreen.tsx +44 -42
  103. package/src/ui/screens/GitignoreScreen.tsx +50 -55
  104. package/src/ui/screens/McpRegistryScreen.tsx +52 -86
  105. package/src/ui/screens/McpScreen.tsx +47 -33
  106. package/src/ui/screens/ModelsScreen.tsx +59 -104
  107. package/src/ui/screens/PluginsScreen.tsx +123 -127
  108. package/src/ui/screens/ProfilesScreen.tsx +49 -46
  109. package/src/ui/screens/SkillsScreen.tsx +184 -204
  110. package/src/ui/screens/StylesScreen.tsx +104 -157
  111. package/src/ui/state/AppContext.tsx +6 -0
  112. package/src/ui/state/DimensionsContext.tsx +1 -10
  113. package/src/ui/state/reducer.ts +5 -8
  114. package/src/ui/state/types.ts +11 -9
  115. package/src/ui/text-width.ts +341 -0
  116. package/src/ui/theme-mode.ts +34 -71
  117. package/src/ui/theme.ts +50 -28
  118. package/src/utils/request-gate.ts +37 -0
  119. package/src/ui/components/ScopeIndicator.tsx +0 -63
  120. package/src/ui/components/SearchInput.tsx +0 -57
  121. package/src/ui/hooks/index.ts +0 -2
  122. package/src/ui/hooks/useKeyboard.ts +0 -24
  123. package/src/ui/hooks/useKeyboardHandler.ts +0 -63
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Blind tests — contract C18 (requirement R2.8.6), with C2 / C13 for the
3
+ * SettingsParseError class.
4
+ *
5
+ * Written from requirements.md and architecture.md only.
6
+ *
7
+ * R2.8.6: registering the agent-model hook refuses a user settings file whose
8
+ * `hooks` is present but not a JSON object, or whose `hooks.PreToolUse` is
9
+ * present but not an array — with a `SettingsParseError`-class error naming
10
+ * the file — and leaves it byte-identical. It never replaces them with empty
11
+ * ones (the user's own hooks live there). Well-shaped `hooks` keep every
12
+ * existing entry and gain the magus one.
13
+ *
14
+ * C18: `registerAgentModelHook({ configDir })` writes `<configDir>/settings.json`
15
+ * and resolves "registered" | "already". It adds
16
+ * `{ matcher: "Agent", hooks: [{ type: "command", command: AGENT_MODEL_HOOK_COMMAND, … }] }`
17
+ * under `hooks.PreToolUse`. AGENT_MODEL_HOOK_COMMAND is "magus hook agent-model".
18
+ *
19
+ * Isolation: the function's default target is user scope (under HOME), and a
20
+ * black-box test cannot rule out a fallback to it despite `configDir`. So every
21
+ * call runs in a child `bun` with a temp HOME and a temp cwd (architecture.md
22
+ * C2 correction; see ./child-run.ts), and one control checks that nothing
23
+ * appears under that HOME. The child reports booleans, class names and the
24
+ * contract's own return value — never an error message or a value read from a
25
+ * file. The settings files read back here are this test's own temp testdata.
26
+ */
27
+ import { afterEach, describe, expect, test } from "bun:test";
28
+ import {
29
+ existsSync,
30
+ mkdtempSync,
31
+ readFileSync,
32
+ realpathSync,
33
+ rmSync,
34
+ writeFileSync,
35
+ } from "node:fs";
36
+ import os from "node:os";
37
+ import path from "node:path";
38
+ import { type ChildResult, runInChild } from "./child-run.js";
39
+
40
+ const SERVICES = path.resolve(import.meta.dir, "../../services");
41
+ const HOOK_REGISTRATION = path.join(SERVICES, "hook-registration.ts");
42
+ const SETTINGS_FILE = path.join(SERVICES, "settings-file.ts");
43
+ const CLAUDE_SETTINGS = path.join(SERVICES, "claude-settings.ts");
44
+ const T = 30_000; // per-test timeout: each child bun spawn gets up to 25s
45
+
46
+ const AGENT_MODEL_HOOK_COMMAND = "magus hook agent-model";
47
+
48
+ const dirs: string[] = [];
49
+ afterEach(() => {
50
+ for (const dir of dirs.splice(0))
51
+ rmSync(dir, { recursive: true, force: true });
52
+ });
53
+
54
+ function tempDir(tag: string): string {
55
+ // realpath: on macOS tmpdir is /var/… → /private/var/…; one spelling only.
56
+ const dir = realpathSync(
57
+ mkdtempSync(path.join(os.tmpdir(), `magus-blind-c18-${tag}-`)),
58
+ );
59
+ dirs.push(dir);
60
+ return dir;
61
+ }
62
+
63
+ interface Case {
64
+ home: string;
65
+ cwd: string;
66
+ configDir: string;
67
+ /** <configDir>/settings.json — the only file the call may touch. */
68
+ file: string;
69
+ }
70
+
71
+ /** A fresh temp HOME, cwd and configDir; `content` null means no settings.json. */
72
+ function newCase(content: string | null): Case {
73
+ const home = tempDir("home");
74
+ const cwd = tempDir("cwd");
75
+ const configDir = tempDir("cfg");
76
+ const file = path.join(configDir, "settings.json");
77
+ if (content !== null) writeFileSync(file, content);
78
+ return { home, cwd, configDir, file };
79
+ }
80
+
81
+ // ── the child call ──────────────────────────────────────────────────────────
82
+
83
+ /**
84
+ * Child body: `await mod.registerAgentModelHook({ configDir: job.configDir })`.
85
+ * Success → `{ ok: true, result }`, where `result` is reported only when it is
86
+ * one of the contract's two strings. Failure → whether the error is a
87
+ * SettingsParseError (by name, by a constructor named SettingsParseError on
88
+ * its prototype chain — i.e. a subclass — or by instanceof an exported
89
+ * class), and whether its message names the settings file. The message itself
90
+ * is never reported.
91
+ */
92
+ const REGISTER_BODY = `
93
+ const fs = await import("node:fs");
94
+ const real = (p) => { try { return fs.realpathSync(p); } catch { return null; } };
95
+ const classes = [];
96
+ for (const m of job.errorModules || []) {
97
+ try {
98
+ const x = await import(m);
99
+ if (typeof x.SettingsParseError === "function") classes.push(x.SettingsParseError);
100
+ } catch {}
101
+ }
102
+ const k = mod.AGENT_MODEL_HOOK_COMMAND;
103
+ const commandConst = typeof k === "string" && k.length < 200 ? k : "<" + typeof k + ">";
104
+ if (typeof mod.registerAgentModelHook !== "function") {
105
+ await out({ stage: "missing-export", commandConst });
106
+ return;
107
+ }
108
+ try {
109
+ const r = await mod.registerAgentModelHook({ configDir: job.configDir });
110
+ await out({
111
+ stage: "done",
112
+ ok: true,
113
+ result: r === "registered" || r === "already" ? r : "<" + typeof r + ">",
114
+ commandConst,
115
+ });
116
+ } catch (e) {
117
+ const isErr = e instanceof Error;
118
+ const chain = [];
119
+ for (let p = e !== null && typeof e === "object" ? Object.getPrototypeOf(e) : null; p; p = Object.getPrototypeOf(p)) {
120
+ const c = Object.prototype.hasOwnProperty.call(p, "constructor") ? p.constructor : null;
121
+ if (c && typeof c.name === "string") chain.push(c.name);
122
+ }
123
+ const msg = isErr && typeof e.message === "string" ? e.message : "";
124
+ const want = real(job.file);
125
+ await out({
126
+ stage: "done",
127
+ ok: false,
128
+ isError: isErr,
129
+ errorName: isErr ? String(e.name) : typeof e,
130
+ nameIsSettingsParseError: isErr && e.name === "SettingsParseError",
131
+ chainHasSettingsParseError: chain.includes("SettingsParseError"),
132
+ instanceOfSettingsParseError: classes.some((C) => e instanceof C),
133
+ messageNamesFile: msg.includes(job.file) || (!!want && msg.includes(want)),
134
+ commandConst,
135
+ });
136
+ }
137
+ `;
138
+
139
+ function register(c: Case): ChildResult {
140
+ return runInChild({
141
+ home: c.home,
142
+ cwd: c.cwd,
143
+ module: HOOK_REGISTRATION,
144
+ job: {
145
+ configDir: c.configDir,
146
+ file: c.file,
147
+ errorModules: [SETTINGS_FILE, CLAUDE_SETTINGS, HOOK_REGISTRATION],
148
+ },
149
+ body: REGISTER_BODY,
150
+ });
151
+ }
152
+
153
+ /** The refusal R2.8.6 asks for, reduced to what the tests assert on. */
154
+ function refusal(r: ChildResult) {
155
+ return {
156
+ stage: r.stage,
157
+ ok: r.ok,
158
+ settingsParseError:
159
+ r.nameIsSettingsParseError === true ||
160
+ r.chainHasSettingsParseError === true ||
161
+ r.instanceOfSettingsParseError === true,
162
+ messageNamesFile: r.messageNamesFile,
163
+ // Diagnostic only (e.g. a raw TypeError, R2.8.4). Not asserted.
164
+ errorName: r.errorName,
165
+ result: r.result,
166
+ };
167
+ }
168
+
169
+ // ── wrong shapes: refused ───────────────────────────────────────────────────
170
+
171
+ const USER_BASH_BLOCK = {
172
+ matcher: "Bash",
173
+ hooks: [{ type: "command", command: "my-guard.sh" }],
174
+ };
175
+
176
+ /** R2.8.6: `hooks` not an object, or `hooks.PreToolUse` not an array. */
177
+ const WRONG_SHAPES: Record<string, string> = {
178
+ 'hooks is an array {"hooks": []}': '{"hooks": []}\n',
179
+ 'hooks is a string {"hooks": "x"}': '{"hooks": "x"}\n',
180
+ 'hooks is a number {"hooks": 5}': '{"hooks": 5}\n',
181
+ "hooks.PreToolUse is one block object, not an array": `${JSON.stringify({
182
+ hooks: { PreToolUse: USER_BASH_BLOCK },
183
+ })}\n`,
184
+ 'hooks.PreToolUse is a string {"hooks": {"PreToolUse": "x"}}':
185
+ '{"hooks": {"PreToolUse": "x"}}\n',
186
+ };
187
+
188
+ describe("C18 — registerAgentModelHook refuses a wrong-shape hooks in <configDir>/settings.json", () => {
189
+ for (const [shape, content] of Object.entries(WRONG_SHAPES)) {
190
+ test(
191
+ `R2.8.6 ${shape}: rejects with a SettingsParseError-class error and never resolves`,
192
+ () => {
193
+ const c = newCase(content);
194
+ expect(refusal(register(c))).toMatchObject({
195
+ stage: "done",
196
+ ok: false,
197
+ settingsParseError: true,
198
+ });
199
+ },
200
+ T,
201
+ );
202
+
203
+ test(
204
+ `R2.8.6 ${shape}: the error message names <configDir>/settings.json`,
205
+ () => {
206
+ const c = newCase(content);
207
+ expect(refusal(register(c))).toMatchObject({
208
+ stage: "done",
209
+ ok: false,
210
+ messageNamesFile: true,
211
+ });
212
+ },
213
+ T,
214
+ );
215
+
216
+ test(
217
+ `R2.8.6 ${shape}: settings.json stays byte-identical`,
218
+ () => {
219
+ const c = newCase(content);
220
+ const before = readFileSync(c.file);
221
+ const r = register(c);
222
+ expect(r.stage).toBe("done");
223
+ expect(readFileSync(c.file).equals(before)).toBe(true);
224
+ },
225
+ T,
226
+ );
227
+ }
228
+ });
229
+
230
+ // ── controls: absent or well-shaped hooks gain the magus Agent block ────────
231
+
232
+ const CONTROLS: Record<string, string | null> = {
233
+ "no settings.json": null,
234
+ "{}": "{}\n",
235
+ '{"hooks": {}}': '{"hooks": {}}\n',
236
+ "a user Bash hook in PreToolUse and other: 1": `${JSON.stringify({
237
+ hooks: { PreToolUse: [USER_BASH_BLOCK] },
238
+ other: 1,
239
+ })}\n`,
240
+ };
241
+
242
+ function readBack(c: Case): Record<string, unknown> {
243
+ // This test's own temp testdata — safe to read.
244
+ return JSON.parse(readFileSync(c.file, "utf8")) as Record<string, unknown>;
245
+ }
246
+
247
+ function preToolUse(settings: Record<string, unknown>): unknown[] | null {
248
+ const hooks = settings.hooks as Record<string, unknown> | undefined;
249
+ const pre = hooks?.PreToolUse;
250
+ return Array.isArray(pre) ? pre : null;
251
+ }
252
+
253
+ /** Every hook entry of type "command" running the magus command in an Agent block. */
254
+ function magusAgentHooks(settings: Record<string, unknown>): unknown[] {
255
+ const found: unknown[] = [];
256
+ for (const block of preToolUse(settings) ?? []) {
257
+ const b = block as { matcher?: unknown; hooks?: unknown };
258
+ if (b?.matcher !== "Agent" || !Array.isArray(b.hooks)) continue;
259
+ for (const h of b.hooks as { type?: unknown; command?: unknown }[]) {
260
+ if (h?.type === "command" && h.command === AGENT_MODEL_HOOK_COMMAND)
261
+ found.push(h);
262
+ }
263
+ }
264
+ return found;
265
+ }
266
+
267
+ describe("C18 — registerAgentModelHook controls (absent or well-shaped hooks)", () => {
268
+ test(
269
+ `R2.8.6 AGENT_MODEL_HOOK_COMMAND is "${AGENT_MODEL_HOOK_COMMAND}"`,
270
+ () => {
271
+ const c = newCase("{}\n");
272
+ expect(register(c).commandConst).toBe(AGENT_MODEL_HOOK_COMMAND);
273
+ },
274
+ T,
275
+ );
276
+
277
+ for (const [label, content] of Object.entries(CONTROLS)) {
278
+ test(
279
+ `R2.8.6 control (${label}): resolves "registered"`,
280
+ () => {
281
+ const c = newCase(content);
282
+ expect(refusal(register(c))).toMatchObject({
283
+ stage: "done",
284
+ ok: true,
285
+ result: "registered",
286
+ });
287
+ },
288
+ T,
289
+ );
290
+
291
+ test(
292
+ `R2.8.6 control (${label}): hooks.PreToolUse gains one Agent block running "${AGENT_MODEL_HOOK_COMMAND}"`,
293
+ () => {
294
+ const c = newCase(content);
295
+ expect(register(c)).toMatchObject({ stage: "done", ok: true });
296
+ const after = readBack(c);
297
+ expect(Array.isArray(preToolUse(after))).toBe(true);
298
+ expect(magusAgentHooks(after).length).toBe(1);
299
+ },
300
+ T,
301
+ );
302
+
303
+ test(
304
+ `R2.8.6 control (${label}): a second call resolves "already" and leaves settings.json byte-identical`,
305
+ () => {
306
+ const c = newCase(content);
307
+ expect(register(c)).toMatchObject({
308
+ stage: "done",
309
+ ok: true,
310
+ result: "registered",
311
+ });
312
+ const afterFirst = readFileSync(c.file);
313
+ // A fresh process: "already" must come from the file, not memory.
314
+ expect(refusal(register(c))).toMatchObject({
315
+ stage: "done",
316
+ ok: true,
317
+ result: "already",
318
+ });
319
+ expect(readFileSync(c.file).equals(afterFirst)).toBe(true);
320
+ },
321
+ 2 * T,
322
+ );
323
+ }
324
+
325
+ test(
326
+ "R2.8.6 control (a user Bash hook in PreToolUse and other: 1): the user's my-guard.sh block and other: 1 are kept",
327
+ () => {
328
+ const c = newCase(
329
+ CONTROLS["a user Bash hook in PreToolUse and other: 1"] as string,
330
+ );
331
+ expect(register(c)).toMatchObject({ stage: "done", ok: true });
332
+ const after = readBack(c);
333
+ expect(after.other).toBe(1);
334
+ const pre = preToolUse(after) ?? [];
335
+ const bash = pre.filter(
336
+ (b) => (b as { matcher?: unknown })?.matcher === "Bash",
337
+ );
338
+ expect(bash).toEqual([USER_BASH_BLOCK]);
339
+ expect(magusAgentHooks(after).length).toBe(1);
340
+ },
341
+ T,
342
+ );
343
+
344
+ test(
345
+ "R2.8.6 control (no settings.json): configDir is the only target — nothing appears under HOME/.claude",
346
+ () => {
347
+ const c = newCase(null);
348
+ expect(register(c)).toMatchObject({ stage: "done", ok: true });
349
+ expect(existsSync(c.file)).toBe(true);
350
+ expect(existsSync(path.join(c.home, ".claude"))).toBe(false);
351
+ },
352
+ T,
353
+ );
354
+ });
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Blind tests — contract C2 (requirements R2.1, R2.2, R2.3).
3
+ *
4
+ * Written from requirements.md and architecture.md only.
5
+ *
6
+ * NOTHING from claude-settings runs in this process. Under Bun, a runtime
7
+ * HOME change does not move `os.homedir()` (architecture.md C2 correction),
8
+ * and this module can resolve `~/.claude` — so every call runs in a child
9
+ * `bun` spawned with a temp HOME (see ./child-run.ts). The child reports only
10
+ * booleans and names: never a value read from a file, never an error
11
+ * message, so no failure can print a real user file.
12
+ *
13
+ * Valid-file cases write a per-test random token into the file; the child
14
+ * reports whether the value it read contains that token, which proves the
15
+ * temp file — and not some other file — was the one read.
16
+ */
17
+ import { afterEach, describe, expect, test } from "bun:test";
18
+ import { randomUUID } from "node:crypto";
19
+ import {
20
+ mkdirSync,
21
+ mkdtempSync,
22
+ readFileSync,
23
+ rmSync,
24
+ writeFileSync,
25
+ } from "node:fs";
26
+ import os from "node:os";
27
+ import path from "node:path";
28
+ import { type ChildResult, runInChild } from "./child-run.js";
29
+
30
+ const MODULE = path.resolve(
31
+ import.meta.dir,
32
+ "../../services/claude-settings.ts",
33
+ );
34
+ const T = 30_000; // per-test timeout: each test spawns one child bun
35
+
36
+ const dirs: string[] = [];
37
+ afterEach(() => {
38
+ for (const dir of dirs.splice(0))
39
+ rmSync(dir, { recursive: true, force: true });
40
+ });
41
+
42
+ function tempDir(tag: string): string {
43
+ const dir = mkdtempSync(path.join(os.tmpdir(), `magus-blind-c2-${tag}-`));
44
+ dirs.push(dir);
45
+ return dir;
46
+ }
47
+
48
+ function write(file: string, content: string): void {
49
+ mkdirSync(path.dirname(file), { recursive: true });
50
+ writeFileSync(file, content);
51
+ }
52
+
53
+ /** Three distinct ways a file can exist and not be JSON. */
54
+ const INVALID: Record<string, string> = {
55
+ truncated: '{\n "enabledPlugins": {\n "a@b": true,\n',
56
+ "plain text": "this is not json at all\n",
57
+ "trailing comma": '{ "enabledPlugins": { "a@b": true, }, }\n',
58
+ };
59
+
60
+ /**
61
+ * Child body: call `mod[job.call](...job.args)` and report a summary.
62
+ * Reports whether a value is `{}` and whether it contains `job.expect`
63
+ * (subset match) — never the value itself. On a throw, reports the error's
64
+ * class and whether it carries/names `job.file` — never its message.
65
+ */
66
+ const CALL_BODY = `
67
+ const fs = await import("node:fs");
68
+ const real = (p) => { try { return fs.realpathSync(p); } catch { return null; } };
69
+ const subset = (a, e) => {
70
+ if (e === null || typeof e !== "object") return Object.is(a, e);
71
+ if (a === null || typeof a !== "object") return false;
72
+ if (Array.isArray(e)) return Array.isArray(a) && a.length === e.length && e.every((v, i) => subset(a[i], v));
73
+ return Object.keys(e).every((k) => subset(a[k], e[k]));
74
+ };
75
+ try {
76
+ const value = await mod[job.call](...job.args);
77
+ const isObj = value !== null && typeof value === "object" && !Array.isArray(value);
78
+ await out({
79
+ stage: "done",
80
+ ok: true,
81
+ isEmptyObject: isObj && Object.keys(value).length === 0,
82
+ matchesExpected: job.expect === undefined ? null : subset(value, job.expect),
83
+ });
84
+ } catch (e) {
85
+ const isErr = e instanceof Error;
86
+ const P = mod.SettingsParseError;
87
+ const want = job.file ? real(job.file) : null;
88
+ const msg = isErr && typeof e.message === "string" ? e.message : "";
89
+ const p = e && typeof e.path === "string" ? e.path : null;
90
+ await out({
91
+ stage: "done",
92
+ ok: false,
93
+ isError: isErr,
94
+ name: isErr ? e.name : typeof e,
95
+ isSettingsParseError: typeof P === "function" && e instanceof P,
96
+ pathIsString: p !== null,
97
+ pathMatchesFile: !!want && p !== null && real(p) === want,
98
+ messageNamesFile: !!job.file && (msg.includes(job.file) || (!!want && msg.includes(want))),
99
+ });
100
+ }
101
+ `;
102
+
103
+ interface Case {
104
+ home: string;
105
+ project: string;
106
+ }
107
+
108
+ function newCase(): Case {
109
+ return { home: tempDir("home"), project: tempDir("proj") };
110
+ }
111
+
112
+ function call(
113
+ c: Case,
114
+ fn: string,
115
+ args: unknown[],
116
+ extra: { file?: string; expect?: unknown } = {},
117
+ ): ChildResult {
118
+ return runInChild({
119
+ home: c.home,
120
+ cwd: c.project,
121
+ module: MODULE,
122
+ job: { call: fn, args, ...extra },
123
+ body: CALL_BODY,
124
+ });
125
+ }
126
+
127
+ interface Reader {
128
+ name: string;
129
+ call: string;
130
+ args: (c: Case) => unknown[];
131
+ file: (c: Case) => string;
132
+ /** Valid content carrying `token`, and the subset the reader must return. */
133
+ valid: (token: string) => Record<string, unknown>;
134
+ }
135
+
136
+ const READERS: Reader[] = [
137
+ {
138
+ name: "readSettings (<project>/.claude/settings.json)",
139
+ call: "readSettings",
140
+ args: (c) => [c.project],
141
+ file: (c) => path.join(c.project, ".claude", "settings.json"),
142
+ valid: (t) => ({ enabledPlugins: { [`${t}@blind`]: true } }),
143
+ },
144
+ {
145
+ name: "readLocalSettings (<project>/.claude/settings.local.json)",
146
+ call: "readLocalSettings",
147
+ args: (c) => [c.project],
148
+ file: (c) => path.join(c.project, ".claude", "settings.local.json"),
149
+ valid: (t) => ({ enabledPlugins: { [`${t}@blind`]: false } }),
150
+ },
151
+ {
152
+ name: "readMcpConfig (<project>/.mcp.json)",
153
+ call: "readMcpConfig",
154
+ args: (c) => [c.project],
155
+ file: (c) => path.join(c.project, ".mcp.json"),
156
+ valid: (t) => ({ mcpServers: { [t]: { command: "echo", args: ["hi"] } } }),
157
+ },
158
+ {
159
+ name: "readGlobalSettings (<HOME>/.claude/settings.json, child process with temp HOME)",
160
+ call: "readGlobalSettings",
161
+ args: () => [],
162
+ file: (c) => path.join(c.home, ".claude", "settings.json"),
163
+ valid: (t) => ({ enabledPlugins: { [`${t}@global-blind`]: true } }),
164
+ },
165
+ ];
166
+
167
+ describe("C2 — child-process harness", () => {
168
+ test(
169
+ "R2 control: the child's os.homedir() is the temp HOME",
170
+ () => {
171
+ const c = newCase();
172
+ const r = runInChild({
173
+ home: c.home,
174
+ cwd: c.project,
175
+ body: 'await out({ stage: "done", ok: true });',
176
+ });
177
+ expect(r).toEqual({ stage: "done", ok: true });
178
+ },
179
+ T,
180
+ );
181
+ });
182
+
183
+ for (const reader of READERS) {
184
+ describe(`C2 — ${reader.name}`, () => {
185
+ test(
186
+ "R2.1 a missing file reads as an empty object",
187
+ () => {
188
+ const c = newCase();
189
+ expect(call(c, reader.call, reader.args(c))).toEqual({
190
+ stage: "done",
191
+ ok: true,
192
+ isEmptyObject: true,
193
+ matchesExpected: null,
194
+ });
195
+ },
196
+ T,
197
+ );
198
+
199
+ test(
200
+ "R2.1 control: a valid file reads as its content (the temp file, identified by a random token)",
201
+ () => {
202
+ const c = newCase();
203
+ const content = reader.valid(`blind-${randomUUID()}`);
204
+ write(reader.file(c), JSON.stringify(content, null, 2));
205
+ expect(
206
+ call(c, reader.call, reader.args(c), { expect: content }),
207
+ ).toMatchObject({
208
+ stage: "done",
209
+ ok: true,
210
+ matchesExpected: true,
211
+ });
212
+ },
213
+ T,
214
+ );
215
+
216
+ for (const [kind, content] of Object.entries(INVALID)) {
217
+ test(
218
+ `R2.2 an existing file that is not valid JSON (${kind}) throws SettingsParseError`,
219
+ () => {
220
+ const c = newCase();
221
+ write(reader.file(c), content);
222
+ expect(call(c, reader.call, reader.args(c))).toMatchObject({
223
+ stage: "done",
224
+ ok: false,
225
+ isError: true,
226
+ isSettingsParseError: true,
227
+ });
228
+ },
229
+ T,
230
+ );
231
+ }
232
+
233
+ test(
234
+ "R2.2 the SettingsParseError carries the path of the file that failed",
235
+ () => {
236
+ const c = newCase();
237
+ const file = reader.file(c);
238
+ write(file, INVALID.truncated as string);
239
+ // pathMatchesFile compares real paths: macOS tmpdir is /var -> /private/var.
240
+ expect(call(c, reader.call, reader.args(c), { file })).toMatchObject({
241
+ stage: "done",
242
+ ok: false,
243
+ isSettingsParseError: true,
244
+ pathIsString: true,
245
+ pathMatchesFile: true,
246
+ });
247
+ },
248
+ T,
249
+ );
250
+
251
+ test(
252
+ "R2.2 an existing unparseable file never reads as {}",
253
+ () => {
254
+ const c = newCase();
255
+ write(reader.file(c), INVALID["plain text"] as string);
256
+ const r = call(c, reader.call, reader.args(c));
257
+ expect(r.stage).toBe("done");
258
+ expect(r.ok).toBe(false);
259
+ },
260
+ T,
261
+ );
262
+ });
263
+ }
264
+
265
+ describe("C2 — enablePlugin read-modify-write", () => {
266
+ const PLUGIN = "demo@magus-blind";
267
+ const settingsFile = (c: Case) =>
268
+ path.join(c.project, ".claude", "settings.json");
269
+
270
+ test(
271
+ "R2.3 control: enablePlugin on a valid settings.json sets enabledPlugins[id] and keeps other keys",
272
+ () => {
273
+ const c = newCase();
274
+ const file = settingsFile(c);
275
+ write(
276
+ file,
277
+ JSON.stringify(
278
+ {
279
+ enabledPlugins: { "other@x": true },
280
+ permissions: { allow: ["Read"] },
281
+ },
282
+ null,
283
+ 2,
284
+ ),
285
+ );
286
+ expect(call(c, "enablePlugin", [PLUGIN, true, c.project])).toMatchObject({
287
+ stage: "done",
288
+ ok: true,
289
+ });
290
+ // The file is this test's temp testdata, so reading it here is safe.
291
+ const after = JSON.parse(readFileSync(file, "utf8"));
292
+ expect(after.enabledPlugins[PLUGIN]).toBe(true);
293
+ expect(after.enabledPlugins["other@x"]).toBe(true);
294
+ expect(after.permissions).toEqual({ allow: ["Read"] });
295
+ },
296
+ T,
297
+ );
298
+
299
+ for (const enabled of [true, false]) {
300
+ for (const [kind, content] of Object.entries(INVALID)) {
301
+ test(
302
+ `R2.3 enablePlugin(…, ${enabled}) fails on an unparseable settings.json (${kind})`,
303
+ () => {
304
+ const c = newCase();
305
+ write(settingsFile(c), content);
306
+ expect(
307
+ call(c, "enablePlugin", [PLUGIN, enabled, c.project]),
308
+ ).toMatchObject({
309
+ stage: "done",
310
+ ok: false,
311
+ isError: true,
312
+ });
313
+ },
314
+ T,
315
+ );
316
+
317
+ test(
318
+ `R2.3 enablePlugin(…, ${enabled}) leaves an unparseable settings.json (${kind}) byte-for-byte unchanged`,
319
+ () => {
320
+ const c = newCase();
321
+ const file = settingsFile(c);
322
+ write(file, content);
323
+ const before = readFileSync(file);
324
+ const r = call(c, "enablePlugin", [PLUGIN, enabled, c.project]);
325
+ expect(r.stage).toBe("done");
326
+ expect(readFileSync(file).equals(before)).toBe(true);
327
+ },
328
+ T,
329
+ );
330
+ }
331
+ }
332
+
333
+ test(
334
+ "R2.3 the enablePlugin failure identifies the unparseable file",
335
+ () => {
336
+ // R2.4 needs the TUI to name the file, so the error that reaches it must
337
+ // carry the path: as a SettingsParseError, or in its message.
338
+ const c = newCase();
339
+ const file = settingsFile(c);
340
+ write(file, INVALID.truncated as string);
341
+ const r = call(c, "enablePlugin", [PLUGIN, true, c.project], { file });
342
+ expect(r).toMatchObject({ stage: "done", ok: false, isError: true });
343
+ const namesFile =
344
+ (r.isSettingsParseError === true && r.pathMatchesFile === true) ||
345
+ r.messageNamesFile === true;
346
+ expect(namesFile).toBe(true);
347
+ },
348
+ T,
349
+ );
350
+ });