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,603 @@
1
+ /**
2
+ * Blind tests — contract C13 (requirement R2.8: wrong-SHAPE files).
3
+ *
4
+ * Written from requirements.md and architecture.md only.
5
+ *
6
+ * A file that parses as JSON but whose top level is not a plain object, or a
7
+ * profiles file whose `profiles` is not a map of objects, must be refused
8
+ * exactly like an unparseable one: `SettingsParseError` naming the file, and
9
+ * every write fails leaving the file byte-for-byte unchanged.
10
+ *
11
+ * Isolation: `readSettingsFile(path)` takes an explicit path, so it runs in
12
+ * this process. Everything from claude-settings / manifest / profiles runs in
13
+ * a child `bun` with a temp HOME (architecture.md C2 correction; see
14
+ * ./child-run.ts) — `enablePlugin` and `readProfiles` could otherwise reach
15
+ * the real `~/.claude`. The child reports booleans and class names only,
16
+ * never a value read from a file and never an error message.
17
+ */
18
+ import { afterEach, describe, expect, test } from "bun:test";
19
+ import { randomUUID } from "node:crypto";
20
+ import {
21
+ existsSync,
22
+ mkdirSync,
23
+ mkdtempSync,
24
+ readFileSync,
25
+ realpathSync,
26
+ rmSync,
27
+ writeFileSync,
28
+ } from "node:fs";
29
+ import os from "node:os";
30
+ import path from "node:path";
31
+ import { type ChildResult, runInChild } from "./child-run.js";
32
+
33
+ const SERVICES = path.resolve(import.meta.dir, "../../services");
34
+ const CLAUDE_SETTINGS = path.join(SERVICES, "claude-settings.ts");
35
+ const SETTINGS_FILE = path.join(SERVICES, "settings-file.ts");
36
+ const MANIFEST = path.join(SERVICES, "manifest.ts");
37
+ const PROFILES = path.join(SERVICES, "profiles.ts");
38
+ const T = 30_000; // per-test timeout: most tests spawn one child bun
39
+
40
+ const loadSettingsFile = () => import("../../services/settings-file.js");
41
+
42
+ const dirs: string[] = [];
43
+ afterEach(() => {
44
+ for (const dir of dirs.splice(0))
45
+ rmSync(dir, { recursive: true, force: true });
46
+ });
47
+
48
+ function tempDir(tag: string): string {
49
+ // realpath: on macOS tmpdir is /var/… → /private/var/…; one spelling only.
50
+ const dir = realpathSync(
51
+ mkdtempSync(path.join(os.tmpdir(), `magus-blind-c13-${tag}-`)),
52
+ );
53
+ dirs.push(dir);
54
+ return dir;
55
+ }
56
+
57
+ function write(file: string, content: string): void {
58
+ mkdirSync(path.dirname(file), { recursive: true });
59
+ writeFileSync(file, content);
60
+ }
61
+
62
+ /** R2.8.1: top levels that parse but are not a JSON object. */
63
+ const NOT_AN_OBJECT: Record<string, string> = {
64
+ null: "null\n",
65
+ "an empty array []": "[]\n",
66
+ 'an array ["x"]': '["x"]\n',
67
+ "a number 5": "5\n",
68
+ 'a string "text"': '"text"\n',
69
+ "a boolean true": "true\n",
70
+ };
71
+
72
+ /** R2.8.1 + R2.8.2: profiles files that must be refused. */
73
+ const BAD_PROFILES: Record<string, string> = {
74
+ 'a typo\'d key ("profile" instead of "profiles")': `${JSON.stringify({
75
+ version: 2,
76
+ profile: { lean: { name: "Lean" } },
77
+ })}\n`,
78
+ "profiles is a number": `${JSON.stringify({ version: 2, profiles: 5 })}\n`,
79
+ "a profile value that is a string": `${JSON.stringify({
80
+ version: 2,
81
+ profiles: { lean: "oops" },
82
+ })}\n`,
83
+ "a top-level array": "[]\n",
84
+ "a top-level null": "null\n",
85
+ "profiles is an array": `${JSON.stringify({ version: 2, profiles: [] })}\n`,
86
+ };
87
+
88
+ // ── child calls ─────────────────────────────────────────────────────────────
89
+
90
+ /**
91
+ * Child body: call `mod[job.call](...job.args)` and report a summary.
92
+ * Success: shape booleans and counts only. Failure: whether the error is a
93
+ * SettingsParseError (by name, by prototype chain, or by instanceof the
94
+ * exported class) and whether its message names `job.file` — never the
95
+ * message itself.
96
+ */
97
+ const CALL_BODY = `
98
+ const fs = await import("node:fs");
99
+ const real = (p) => { try { return fs.realpathSync(p); } catch { return null; } };
100
+ const classes = [];
101
+ for (const m of job.errorModules || []) {
102
+ try {
103
+ const x = await import(m);
104
+ if (typeof x.SettingsParseError === "function") classes.push(x.SettingsParseError);
105
+ } catch {}
106
+ }
107
+ if (mod && typeof mod.SettingsParseError === "function") classes.push(mod.SettingsParseError);
108
+ try {
109
+ const value = await mod[job.call](...job.args);
110
+ const isObj = value !== null && typeof value === "object" && !Array.isArray(value);
111
+ const profiles = isObj ? value.profiles : undefined;
112
+ const profilesIsObject = profiles !== null && typeof profiles === "object" && !Array.isArray(profiles);
113
+ const key = job.profileKey;
114
+ await out({
115
+ stage: "done",
116
+ ok: true,
117
+ isPlainObject: isObj,
118
+ keyCount: isObj ? Object.keys(value).length : null,
119
+ profilesIsObject,
120
+ profileCount: profilesIsObject ? Object.keys(profiles).length : null,
121
+ hasProfileKey: !!key && profilesIsObject && Object.prototype.hasOwnProperty.call(profiles, key),
122
+ profileNameMatches: !!key && profilesIsObject && profiles[key] != null && profiles[key].name === job.profileName,
123
+ });
124
+ } catch (e) {
125
+ const isErr = e instanceof Error;
126
+ const chain = [];
127
+ for (let p = e !== null && typeof e === "object" ? Object.getPrototypeOf(e) : null; p; p = Object.getPrototypeOf(p)) {
128
+ const c = Object.prototype.hasOwnProperty.call(p, "constructor") ? p.constructor : null;
129
+ if (c && typeof c.name === "string") chain.push(c.name);
130
+ }
131
+ const msg = isErr && typeof e.message === "string" ? e.message : "";
132
+ const want = job.file ? real(job.file) : null;
133
+ await out({
134
+ stage: "done",
135
+ ok: false,
136
+ isError: isErr,
137
+ errorName: isErr ? String(e.name) : typeof e,
138
+ nameIsSettingsParseError: isErr && e.name === "SettingsParseError",
139
+ chainHasSettingsParseError: chain.includes("SettingsParseError"),
140
+ instanceOfSettingsParseError: classes.some((C) => e instanceof C),
141
+ messageNamesFile: !!job.file && (msg.includes(job.file) || (!!want && msg.includes(want))),
142
+ messageSaysObject: /object/i.test(msg),
143
+ });
144
+ }
145
+ `;
146
+
147
+ interface Case {
148
+ home: string;
149
+ project: string;
150
+ }
151
+
152
+ function newCase(): Case {
153
+ return { home: tempDir("home"), project: tempDir("proj") };
154
+ }
155
+
156
+ function call(
157
+ c: Case,
158
+ module: string,
159
+ fn: string,
160
+ args: unknown[],
161
+ extra: { file?: string; profileKey?: string; profileName?: string } = {},
162
+ ): ChildResult {
163
+ return runInChild({
164
+ home: c.home,
165
+ cwd: c.project,
166
+ module,
167
+ job: {
168
+ call: fn,
169
+ args,
170
+ errorModules: [SETTINGS_FILE, CLAUDE_SETTINGS],
171
+ ...extra,
172
+ },
173
+ body: CALL_BODY,
174
+ });
175
+ }
176
+
177
+ /** The refusal R2.8 asks for, reduced to what a test asserts on. */
178
+ function refusal(r: ChildResult) {
179
+ return {
180
+ stage: r.stage,
181
+ ok: r.ok,
182
+ settingsParseError:
183
+ r.nameIsSettingsParseError === true ||
184
+ r.chainHasSettingsParseError === true ||
185
+ r.instanceOfSettingsParseError === true,
186
+ messageNamesFile: r.messageNamesFile,
187
+ // Diagnostic only (e.g. a raw TypeError, R2.8.4). Not asserted.
188
+ errorName: r.errorName,
189
+ };
190
+ }
191
+
192
+ const REFUSED = {
193
+ stage: "done",
194
+ ok: false,
195
+ settingsParseError: true,
196
+ messageNamesFile: true,
197
+ };
198
+
199
+ // ── readSettingsFile (in-process: explicit path, no HOME) ───────────────────
200
+
201
+ describe("C13 — readSettingsFile(settingsPath)", () => {
202
+ const isSettingsParseError = (e: unknown, cls: unknown): boolean => {
203
+ if (typeof cls === "function" && e instanceof cls) return true;
204
+ if (!(e instanceof Error)) return false;
205
+ if (e.name === "SettingsParseError") return true;
206
+ for (let p = Object.getPrototypeOf(e); p; p = Object.getPrototypeOf(p)) {
207
+ if (p.constructor?.name === "SettingsParseError") return true;
208
+ }
209
+ return false;
210
+ };
211
+
212
+ for (const [shape, content] of Object.entries(NOT_AN_OBJECT)) {
213
+ test(`R2.8.1 a top level that is ${shape} throws SettingsParseError naming the file`, async () => {
214
+ const mod = await loadSettingsFile();
215
+ const file = path.join(tempDir("sf"), "settings.json");
216
+ write(file, content);
217
+ let caught: unknown = null;
218
+ let value: unknown;
219
+ try {
220
+ value = await mod.readSettingsFile(file);
221
+ } catch (e) {
222
+ caught = e;
223
+ }
224
+ expect(value).toBeUndefined();
225
+ expect(isSettingsParseError(caught, mod.SettingsParseError)).toBe(true);
226
+ expect(String((caught as Error).message)).toContain(file);
227
+ });
228
+ }
229
+
230
+ test("R2.8.1 the refusal says the file is not a JSON object", async () => {
231
+ const mod = await loadSettingsFile();
232
+ const file = path.join(tempDir("sf"), "settings.json");
233
+ write(file, "[]\n");
234
+ const err = await mod.readSettingsFile(file).then(
235
+ () => null,
236
+ (e: unknown) => e,
237
+ );
238
+ expect(err).toBeInstanceOf(Error);
239
+ expect(String((err as Error).message)).toMatch(/object/i);
240
+ });
241
+
242
+ test("R2.8.1 the SettingsParseError carries the file's path", async () => {
243
+ const mod = await loadSettingsFile();
244
+ const file = path.join(tempDir("sf"), "settings.json");
245
+ write(file, "5\n");
246
+ const err = await mod.readSettingsFile(file).then(
247
+ () => null,
248
+ (e: unknown) => e,
249
+ );
250
+ expect((err as { path?: unknown } | null)?.path).toBe(file);
251
+ });
252
+
253
+ test("R2.8.1 control: {} stays valid and reads as {}", async () => {
254
+ const mod = await loadSettingsFile();
255
+ const file = path.join(tempDir("sf"), "settings.json");
256
+ write(file, "{}\n");
257
+ expect(await mod.readSettingsFile(file)).toEqual({});
258
+ });
259
+
260
+ test("R2.8.1 control: a missing file reads as {}", async () => {
261
+ const mod = await loadSettingsFile();
262
+ const file = path.join(tempDir("sf"), "absent", "settings.json");
263
+ expect(await mod.readSettingsFile(file)).toEqual({});
264
+ });
265
+
266
+ test("R2.8.1 control: a whitespace-only file reads as {}", async () => {
267
+ const mod = await loadSettingsFile();
268
+ const file = path.join(tempDir("sf"), "settings.json");
269
+ write(file, " \n\t\n");
270
+ expect(await mod.readSettingsFile(file)).toEqual({});
271
+ });
272
+ });
273
+
274
+ // ── the four settings readers (child process, temp HOME) ────────────────────
275
+
276
+ interface Reader {
277
+ name: string;
278
+ call: string;
279
+ args: (c: Case) => unknown[];
280
+ file: (c: Case) => string;
281
+ }
282
+
283
+ const READERS: Reader[] = [
284
+ {
285
+ name: "readSettings (<project>/.claude/settings.json)",
286
+ call: "readSettings",
287
+ args: (c) => [c.project],
288
+ file: (c) => path.join(c.project, ".claude", "settings.json"),
289
+ },
290
+ {
291
+ name: "readLocalSettings (<project>/.claude/settings.local.json)",
292
+ call: "readLocalSettings",
293
+ args: (c) => [c.project],
294
+ file: (c) => path.join(c.project, ".claude", "settings.local.json"),
295
+ },
296
+ {
297
+ name: "readMcpConfig (<project>/.mcp.json)",
298
+ call: "readMcpConfig",
299
+ args: (c) => [c.project],
300
+ file: (c) => path.join(c.project, ".mcp.json"),
301
+ },
302
+ {
303
+ name: "readGlobalSettings (<HOME>/.claude/settings.json, temp HOME)",
304
+ call: "readGlobalSettings",
305
+ args: () => [],
306
+ file: (c) => path.join(c.home, ".claude", "settings.json"),
307
+ },
308
+ ];
309
+
310
+ for (const reader of READERS) {
311
+ describe(`C13 — ${reader.name}`, () => {
312
+ for (const [shape, content] of Object.entries(NOT_AN_OBJECT)) {
313
+ test(
314
+ `R2.8.1 a top level that is ${shape} is refused with SettingsParseError naming the file`,
315
+ () => {
316
+ const c = newCase();
317
+ const file = reader.file(c);
318
+ write(file, content);
319
+ const r = call(c, CLAUDE_SETTINGS, reader.call, reader.args(c), {
320
+ file,
321
+ });
322
+ expect(refusal(r)).toMatchObject(REFUSED);
323
+ },
324
+ T,
325
+ );
326
+ }
327
+
328
+ test(
329
+ "R2.8.1 the refusal says the file is not a JSON object",
330
+ () => {
331
+ const c = newCase();
332
+ const file = reader.file(c);
333
+ write(file, '["x"]\n');
334
+ const r = call(c, CLAUDE_SETTINGS, reader.call, reader.args(c), {
335
+ file,
336
+ });
337
+ expect(r).toMatchObject({
338
+ stage: "done",
339
+ ok: false,
340
+ messageSaysObject: true,
341
+ });
342
+ },
343
+ T,
344
+ );
345
+
346
+ test(
347
+ "R2.8.1 control: {} stays valid and reads as {}",
348
+ () => {
349
+ const c = newCase();
350
+ write(reader.file(c), "{}\n");
351
+ expect(
352
+ call(c, CLAUDE_SETTINGS, reader.call, reader.args(c)),
353
+ ).toMatchObject({
354
+ stage: "done",
355
+ ok: true,
356
+ isPlainObject: true,
357
+ keyCount: 0,
358
+ });
359
+ },
360
+ T,
361
+ );
362
+ });
363
+ }
364
+
365
+ // ── enablePlugin: never "success with the change dropped" ───────────────────
366
+
367
+ describe("C13 — enablePlugin on a wrong-shape <project>/.claude/settings.json", () => {
368
+ const PLUGIN = "demo@magus-blind";
369
+ const settingsFile = (c: Case) =>
370
+ path.join(c.project, ".claude", "settings.json");
371
+
372
+ for (const [shape, content] of Object.entries(NOT_AN_OBJECT)) {
373
+ test(
374
+ `R2.8.3 enablePlugin rejects when the top level is ${shape}, with SettingsParseError naming the file`,
375
+ () => {
376
+ const c = newCase();
377
+ const file = settingsFile(c);
378
+ write(file, content);
379
+ const r = call(
380
+ c,
381
+ CLAUDE_SETTINGS,
382
+ "enablePlugin",
383
+ [PLUGIN, true, c.project],
384
+ { file },
385
+ );
386
+ expect(refusal(r)).toMatchObject(REFUSED);
387
+ },
388
+ T,
389
+ );
390
+
391
+ test(
392
+ `R2.8.3 enablePlugin leaves a settings.json whose top level is ${shape} byte-for-byte unchanged`,
393
+ () => {
394
+ const c = newCase();
395
+ const file = settingsFile(c);
396
+ write(file, content);
397
+ const before = readFileSync(file);
398
+ const r = call(c, CLAUDE_SETTINGS, "enablePlugin", [
399
+ PLUGIN,
400
+ true,
401
+ c.project,
402
+ ]);
403
+ expect(r.stage).toBe("done");
404
+ expect(readFileSync(file).equals(before)).toBe(true);
405
+ },
406
+ T,
407
+ );
408
+ }
409
+
410
+ test(
411
+ "R2.8.3 control: enablePlugin on {} succeeds and records the plugin",
412
+ () => {
413
+ const c = newCase();
414
+ const file = settingsFile(c);
415
+ write(file, "{}\n");
416
+ expect(
417
+ call(c, CLAUDE_SETTINGS, "enablePlugin", [PLUGIN, true, c.project]),
418
+ ).toMatchObject({ stage: "done", ok: true });
419
+ // This test's own temp testdata — safe to read here.
420
+ const after = JSON.parse(readFileSync(file, "utf8"));
421
+ expect(after.enabledPlugins?.[PLUGIN]).toBe(true);
422
+ },
423
+ T,
424
+ );
425
+ });
426
+
427
+ // ── profiles.json: readManifest, readProfiles("project"), writeManifest ─────
428
+
429
+ describe("C13 — <project>/.claude/profiles.json", () => {
430
+ const profilesFile = (c: Case) =>
431
+ path.join(c.project, ".claude", "profiles.json");
432
+
433
+ const readersOf = (c: Case) => [
434
+ {
435
+ label: "readManifest",
436
+ module: MANIFEST,
437
+ fn: "readManifest",
438
+ args: [c.project],
439
+ },
440
+ {
441
+ label: 'readProfiles("project")',
442
+ module: PROFILES,
443
+ fn: "readProfiles",
444
+ args: ["project", c.project],
445
+ },
446
+ ];
447
+
448
+ for (const [shape, content] of Object.entries(BAD_PROFILES)) {
449
+ for (const which of ["readManifest", 'readProfiles("project")']) {
450
+ test(
451
+ `R2.8.2 ${which} refuses a profiles file with ${shape}, with SettingsParseError naming the file`,
452
+ () => {
453
+ const c = newCase();
454
+ const file = profilesFile(c);
455
+ write(file, content);
456
+ const r = readersOf(c).find((x) => x.label === which);
457
+ if (!r) throw new Error(`no reader ${which}`);
458
+ expect(
459
+ refusal(call(c, r.module, r.fn, r.args, { file })),
460
+ ).toMatchObject(REFUSED);
461
+ },
462
+ T,
463
+ );
464
+ }
465
+
466
+ test(
467
+ `R2.8.3 writeManifest rejects over a profiles file with ${shape}, with SettingsParseError naming the file`,
468
+ () => {
469
+ const c = newCase();
470
+ const file = profilesFile(c);
471
+ write(file, content);
472
+ const r = call(
473
+ c,
474
+ MANIFEST,
475
+ "writeManifest",
476
+ [{ version: 2, profiles: {} }, c.project],
477
+ { file },
478
+ );
479
+ expect(refusal(r)).toMatchObject(REFUSED);
480
+ },
481
+ T,
482
+ );
483
+
484
+ test(
485
+ `R2.8.3 writeManifest leaves a profiles file with ${shape} byte-for-byte unchanged`,
486
+ () => {
487
+ const c = newCase();
488
+ const file = profilesFile(c);
489
+ write(file, content);
490
+ const before = readFileSync(file);
491
+ const r = call(c, MANIFEST, "writeManifest", [
492
+ { version: 2, profiles: {} },
493
+ c.project,
494
+ ]);
495
+ expect(r.stage).toBe("done");
496
+ expect(readFileSync(file).equals(before)).toBe(true);
497
+ },
498
+ T,
499
+ );
500
+ }
501
+
502
+ for (const [label, content] of [
503
+ ["{}", "{}\n"],
504
+ ['{"version": 2}', `${JSON.stringify({ version: 2 })}\n`],
505
+ ] as const) {
506
+ for (const which of ["readManifest", 'readProfiles("project")']) {
507
+ test(
508
+ `R2.8.2 control: ${which} reads ${label} as zero profiles`,
509
+ () => {
510
+ const c = newCase();
511
+ write(profilesFile(c), content);
512
+ const r = readersOf(c).find((x) => x.label === which);
513
+ if (!r) throw new Error(`no reader ${which}`);
514
+ expect(call(c, r.module, r.fn, r.args)).toMatchObject({
515
+ stage: "done",
516
+ ok: true,
517
+ profilesIsObject: true,
518
+ profileCount: 0,
519
+ });
520
+ },
521
+ T,
522
+ );
523
+ }
524
+ }
525
+
526
+ for (const which of ["readManifest", 'readProfiles("project")']) {
527
+ test(
528
+ `R2.8.2 control: ${which} reads a valid manifest's one profile (identified by a random key)`,
529
+ () => {
530
+ const c = newCase();
531
+ const key = `blind-${randomUUID().slice(0, 8)}`;
532
+ write(
533
+ profilesFile(c),
534
+ JSON.stringify(
535
+ {
536
+ version: 2,
537
+ profiles: {
538
+ [key]: { name: "Lean", plugins: { "dev@magus": true } },
539
+ },
540
+ },
541
+ null,
542
+ 2,
543
+ ),
544
+ );
545
+ const r = readersOf(c).find((x) => x.label === which);
546
+ if (!r) throw new Error(`no reader ${which}`);
547
+ expect(
548
+ call(c, r.module, r.fn, r.args, {
549
+ profileKey: key,
550
+ profileName: "Lean",
551
+ }),
552
+ ).toMatchObject({
553
+ stage: "done",
554
+ ok: true,
555
+ profileCount: 1,
556
+ hasProfileKey: true,
557
+ profileNameMatches: true,
558
+ });
559
+ },
560
+ T,
561
+ );
562
+ }
563
+
564
+ test(
565
+ "R2.8.3 control: writeManifest over {} succeeds and writes the manifest",
566
+ () => {
567
+ // Proves the refusals above are refusals, not a writeManifest that
568
+ // always fails.
569
+ const c = newCase();
570
+ const file = profilesFile(c);
571
+ write(file, "{}\n");
572
+ const key = `blind-${randomUUID().slice(0, 8)}`;
573
+ expect(
574
+ call(c, MANIFEST, "writeManifest", [
575
+ { version: 2, profiles: { [key]: { name: "Lean" } } },
576
+ c.project,
577
+ ]),
578
+ ).toMatchObject({ stage: "done", ok: true });
579
+ const after = JSON.parse(readFileSync(file, "utf8"));
580
+ expect(after.profiles?.[key]?.name).toBe("Lean");
581
+ },
582
+ T,
583
+ );
584
+
585
+ test(
586
+ "R2.8.3 control: writeManifest with no profiles file creates it",
587
+ () => {
588
+ const c = newCase();
589
+ const file = profilesFile(c);
590
+ expect(existsSync(file)).toBe(false);
591
+ const key = `blind-${randomUUID().slice(0, 8)}`;
592
+ expect(
593
+ call(c, MANIFEST, "writeManifest", [
594
+ { version: 2, profiles: { [key]: { name: "Lean" } } },
595
+ c.project,
596
+ ]),
597
+ ).toMatchObject({ stage: "done", ok: true });
598
+ const after = JSON.parse(readFileSync(file, "utf8"));
599
+ expect(after.profiles?.[key]?.name).toBe("Lean");
600
+ },
601
+ T,
602
+ );
603
+ });