magus-cli 7.4.0 → 7.5.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 (67) hide show
  1. package/README.md +9 -8
  2. package/package.json +4 -4
  3. package/src/__tests__/blind/c19-paste-routing.test.ts +252 -0
  4. package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
  5. package/src/__tests__/install-command.test.ts +4 -3
  6. package/src/__tests__/models-cli.test.ts +4 -7
  7. package/src/__tests__/models-manager.test.ts +52 -40
  8. package/src/__tests__/profile-absorb.test.ts +1138 -0
  9. package/src/__tests__/profile-command.test.ts +90 -32
  10. package/src/__tests__/profile-migration-offer.test.ts +18 -15
  11. package/src/__tests__/profile-migration.test.ts +246 -117
  12. package/src/__tests__/profile-render.test.ts +141 -0
  13. package/src/__tests__/profile-skills.test.ts +425 -0
  14. package/src/__tests__/profile-startup.test.ts +44 -14
  15. package/src/__tests__/profile-state.test.ts +795 -0
  16. package/src/__tests__/settings-file.test.ts +5 -7
  17. package/src/__tests__/stale-load-repair.test.ts +5 -4
  18. package/src/__tests__/styles-manager.test.ts +47 -74
  19. package/src/__tests__/styles-screen-state.test.ts +11 -10
  20. package/src/cli/bootstrap.ts +87 -27
  21. package/src/cli/doctor.ts +152 -44
  22. package/src/cli/install.ts +92 -127
  23. package/src/cli/models.ts +21 -1
  24. package/src/cli/profile.ts +48 -77
  25. package/src/cli/router.ts +32 -6
  26. package/src/cli/update.ts +43 -6
  27. package/src/data/gitignore-defaults.ts +3 -3
  28. package/src/services/active-closure.ts +3 -3
  29. package/src/services/claude-settings.ts +283 -125
  30. package/src/services/cli-tool-commands.ts +30 -0
  31. package/src/services/conventions-manager.ts +88 -0
  32. package/src/services/git-worktree.ts +21 -0
  33. package/src/services/gitignore-service.ts +39 -22
  34. package/src/services/manifest.ts +4 -5
  35. package/src/services/models-manager.ts +36 -73
  36. package/src/services/profile-absorb.ts +1532 -0
  37. package/src/services/profile-activate.ts +348 -96
  38. package/src/services/profile-live.ts +825 -0
  39. package/src/services/profile-render.ts +115 -0
  40. package/src/services/profile-skills.ts +462 -0
  41. package/src/services/profile-startup.ts +88 -16
  42. package/src/services/profile-state.ts +622 -0
  43. package/src/services/profiles.ts +20 -32
  44. package/src/services/resolver.ts +6 -1
  45. package/src/services/settings-file.ts +14 -8
  46. package/src/services/skills-manager.ts +14 -8
  47. package/src/services/styles-manager.ts +45 -28
  48. package/src/services/tool-probe.ts +48 -3
  49. package/src/types/index.ts +15 -9
  50. package/src/ui/App.tsx +25 -2
  51. package/src/ui/components/modals/ModalContainer.tsx +9 -1
  52. package/src/ui/hooks/useProfileStartupModal.ts +20 -15
  53. package/src/ui/input/KeyRouter.tsx +147 -23
  54. package/src/ui/input/key-dispatch.ts +48 -0
  55. package/src/ui/keymap.ts +8 -1
  56. package/src/ui/renderers/cliToolRenderers.tsx +65 -27
  57. package/src/ui/screens/CliToolsScreen.tsx +74 -13
  58. package/src/ui/screens/PluginsScreen.tsx +6 -2
  59. package/src/ui/screens/ProfilesScreen.tsx +75 -27
  60. package/src/ui/screens/StylesScreen.tsx +2 -1
  61. package/src/ui/state/types.ts +1 -1
  62. package/src/__tests__/profile-materializer.test.ts +0 -238
  63. package/src/__tests__/profile-sync.test.ts +0 -154
  64. package/src/__tests__/symlink-manager.test.ts +0 -180
  65. package/src/services/profile-materializer.ts +0 -133
  66. package/src/services/profile-sync.ts +0 -158
  67. package/src/services/symlink-manager.ts +0 -343
@@ -0,0 +1,560 @@
1
+ /**
2
+ * Blind tests — contract C20 (requirements R2.8.7, R2.8.8).
3
+ *
4
+ * Written from requirements.md and architecture.md only.
5
+ *
6
+ * NOTHING under test runs in this process: both modules can resolve
7
+ * `~/.claude`, and under Bun a runtime HOME change does not move
8
+ * `os.homedir()` (architecture.md C2 correction). Every call therefore runs in
9
+ * a child `bun` spawned with a temp HOME (see ./child-run.ts). The child
10
+ * reports only booleans and class names — never a value read from a file and
11
+ * never an error message — so no failure can print a real user file. The files
12
+ * this test compares byte-for-byte are its own temp testdata.
13
+ *
14
+ * Two things C20 leaves open, and how they are handled here rather than guessed:
15
+ *
16
+ * - `readInstalledPluginsRegistry(root)` / `writeInstalledPluginsRegistry(…, root, …)`
17
+ * do not say where `root` points. Each case therefore seeds IDENTICAL bytes
18
+ * at every plausible location (`<root>/installed_plugins.json`,
19
+ * `<root>/plugins/…`, `<root>/.claude/plugins/…`), so the assertions hold
20
+ * whichever one the contract means: a corrupt file is corrupt either way,
21
+ * and `expectBytes` is the same string either way. Byte-identity is checked
22
+ * on all of them.
23
+ * - C20 gives `applyTemplate(/* existing signature *\/)`. The call shape used
24
+ * here — `applyTemplate(projectDir, templateId)` with the id "common-dev" —
25
+ * is taken from the existing test `src/__tests__/gitignore-service.test.ts`
26
+ * (a test file, the only source consulted besides the two documents). The
27
+ * "healthy file still applies" case is the control that catches it if that
28
+ * id ever stops being a real template.
29
+ */
30
+ import { afterEach, describe, expect, test } from "bun:test";
31
+ import { randomUUID } from "node:crypto";
32
+ import {
33
+ existsSync,
34
+ mkdirSync,
35
+ mkdtempSync,
36
+ readFileSync,
37
+ rmSync,
38
+ writeFileSync,
39
+ } from "node:fs";
40
+ import os from "node:os";
41
+ import path from "node:path";
42
+ import { type ChildResult, runInChild } from "./child-run.js";
43
+
44
+ const SETTINGS_MODULE = path.resolve(
45
+ import.meta.dir,
46
+ "../../services/claude-settings.ts",
47
+ );
48
+ const GITIGNORE_MODULE = path.resolve(
49
+ import.meta.dir,
50
+ "../../services/gitignore-service.ts",
51
+ );
52
+ /** Modules that may export the SettingsParseError class (C2, C13). */
53
+ const CLASS_MODULES = [
54
+ SETTINGS_MODULE,
55
+ path.resolve(import.meta.dir, "../../services/settings-file.ts"),
56
+ ];
57
+
58
+ const T = 30_000; // per-test timeout: each test spawns one child bun
59
+
60
+ const dirs: string[] = [];
61
+ afterEach(() => {
62
+ for (const dir of dirs.splice(0))
63
+ rmSync(dir, { recursive: true, force: true });
64
+ });
65
+
66
+ function tempDir(tag: string): string {
67
+ const dir = mkdtempSync(path.join(os.tmpdir(), `magus-blind-c20-${tag}-`));
68
+ dirs.push(dir);
69
+ return dir;
70
+ }
71
+
72
+ function write(file: string, content: string): void {
73
+ mkdirSync(path.dirname(file), { recursive: true });
74
+ writeFileSync(file, content);
75
+ }
76
+
77
+ /** Three distinct ways a file can exist and not be JSON. */
78
+ const INVALID: Record<string, string> = {
79
+ truncated: '{\n "version": 2,\n "plugins": {\n',
80
+ "plain text": "this is not json at all\n",
81
+ "trailing comma": '{ "version": 2, "plugins": { }, }\n',
82
+ };
83
+
84
+ /** Three ways a file can parse and not be a JSON object (R2.8.1). */
85
+ const NOT_AN_OBJECT: Record<string, string> = {
86
+ "an array": "[]\n",
87
+ null: "null\n",
88
+ "a number": "5\n",
89
+ };
90
+
91
+ /**
92
+ * Child body: call `mod[job.call](...job.args)` and report a summary.
93
+ * On success, reports only whether the value is an object — never the value.
94
+ * On a throw, reports the error's class and whether it names one of
95
+ * `job.files` — never its message.
96
+ */
97
+ const CALL_BODY = `
98
+ const fs = await import("node:fs");
99
+ const nodePath = await import("node:path");
100
+ const real = (p) => { try { return fs.realpathSync(p); } catch { return null; } };
101
+
102
+ const ctors = [];
103
+ for (const m of (job.classModules || [])) {
104
+ try {
105
+ const mm = await import(m);
106
+ if (typeof mm.SettingsParseError === "function") ctors.push(mm.SettingsParseError);
107
+ } catch {}
108
+ }
109
+ if (mod && typeof mod.SettingsParseError === "function") ctors.push(mod.SettingsParseError);
110
+ /** A subclass counts: walk the constructor chain by name as well as instanceof. */
111
+ const namedChain = (e) => {
112
+ let c = e && e.constructor;
113
+ while (typeof c === "function") {
114
+ if (c.name === "SettingsParseError") return true;
115
+ c = Object.getPrototypeOf(c);
116
+ }
117
+ return false;
118
+ };
119
+
120
+ try {
121
+ const value = await mod[job.call](...job.args);
122
+ await out({
123
+ stage: "done",
124
+ ok: true,
125
+ isObject: value !== null && typeof value === "object" && !Array.isArray(value),
126
+ });
127
+ } catch (e) {
128
+ const isErr = e instanceof Error;
129
+ const msg = isErr && typeof e.message === "string" ? e.message : "";
130
+ const p = e && typeof e.path === "string" ? e.path : null;
131
+ const files = job.files || [];
132
+ const names = (f) => {
133
+ const r = real(f);
134
+ return msg.includes(f) || (!!r && msg.includes(r)) || msg.includes(nodePath.basename(f));
135
+ };
136
+ await out({
137
+ stage: "done",
138
+ ok: false,
139
+ isError: isErr,
140
+ name: isErr ? e.name : typeof e,
141
+ isSettingsParseError: ctors.some((C) => e instanceof C) || namedChain(e),
142
+ pathMatchesAFile: p !== null && files.some((f) => { const r = real(f); return !!r && real(p) === r; }),
143
+ messageNamesFile: files.length > 0 && files.some(names),
144
+ });
145
+ }
146
+ `;
147
+
148
+ interface CallOpts {
149
+ files?: string[];
150
+ }
151
+
152
+ function callIn(
153
+ home: string,
154
+ cwd: string,
155
+ module: string,
156
+ fn: string,
157
+ args: unknown[],
158
+ opts: CallOpts = {},
159
+ ): ChildResult {
160
+ return runInChild({
161
+ home,
162
+ cwd,
163
+ module,
164
+ job: {
165
+ call: fn,
166
+ args,
167
+ classModules: CLASS_MODULES,
168
+ files: opts.files ?? [],
169
+ },
170
+ body: CALL_BODY,
171
+ });
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // installed_plugins.json (R2.8.8)
176
+ // ---------------------------------------------------------------------------
177
+
178
+ interface RegistryCase {
179
+ home: string;
180
+ cwd: string;
181
+ root: string;
182
+ /** Every plausible location of installed_plugins.json under `root`. */
183
+ files: string[];
184
+ }
185
+
186
+ function newRegistryCase(): RegistryCase {
187
+ const root = tempDir("root");
188
+ return {
189
+ home: tempDir("home"),
190
+ cwd: tempDir("cwd"),
191
+ root,
192
+ files: [
193
+ path.join(root, "installed_plugins.json"),
194
+ path.join(root, "plugins", "installed_plugins.json"),
195
+ path.join(root, ".claude", "plugins", "installed_plugins.json"),
196
+ ],
197
+ };
198
+ }
199
+
200
+ /** Seed identical bytes at every candidate location. */
201
+ function seedRegistry(c: RegistryCase, content: string): void {
202
+ for (const f of c.files) write(f, content);
203
+ }
204
+
205
+ /** Snapshot the bytes of every candidate that exists. */
206
+ function snapshot(files: string[]): Map<string, Buffer> {
207
+ const m = new Map<string, Buffer>();
208
+ for (const f of files) if (existsSync(f)) m.set(f, readFileSync(f));
209
+ return m;
210
+ }
211
+
212
+ function expectUnchanged(before: Map<string, Buffer>): void {
213
+ expect(before.size).toBeGreaterThan(0);
214
+ for (const [f, bytes] of before) {
215
+ expect(existsSync(f)).toBe(true);
216
+ expect(readFileSync(f).equals(bytes)).toBe(true);
217
+ }
218
+ }
219
+
220
+ const healthyRegistry = () =>
221
+ JSON.stringify({ version: 2, plugins: {} }, null, 2);
222
+
223
+ describe("C20 — readInstalledPluginsRegistry", () => {
224
+ test(
225
+ "R2.8.8 a missing installed_plugins.json reads without throwing (unchanged behaviour)",
226
+ () => {
227
+ const c = newRegistryCase();
228
+ expect(
229
+ callIn(c.home, c.cwd, SETTINGS_MODULE, "readInstalledPluginsRegistry", [
230
+ c.root,
231
+ ]),
232
+ ).toMatchObject({ stage: "done", ok: true, isObject: true });
233
+ },
234
+ T,
235
+ );
236
+
237
+ test(
238
+ "R2.8.8 control: a healthy installed_plugins.json reads without throwing (unchanged behaviour)",
239
+ () => {
240
+ const c = newRegistryCase();
241
+ seedRegistry(c, healthyRegistry());
242
+ expect(
243
+ callIn(c.home, c.cwd, SETTINGS_MODULE, "readInstalledPluginsRegistry", [
244
+ c.root,
245
+ ]),
246
+ ).toMatchObject({ stage: "done", ok: true, isObject: true });
247
+ },
248
+ T,
249
+ );
250
+
251
+ for (const [kind, content] of Object.entries(INVALID)) {
252
+ test(
253
+ `R2.8.8 an installed_plugins.json that does not parse (${kind}) is refused, names the file, and stays byte-identical`,
254
+ () => {
255
+ const c = newRegistryCase();
256
+ seedRegistry(c, content);
257
+ const before = snapshot(c.files);
258
+ const r = callIn(
259
+ c.home,
260
+ c.cwd,
261
+ SETTINGS_MODULE,
262
+ "readInstalledPluginsRegistry",
263
+ [c.root],
264
+ { files: c.files },
265
+ );
266
+ expect(r).toMatchObject({
267
+ stage: "done",
268
+ ok: false,
269
+ isError: true,
270
+ isSettingsParseError: true,
271
+ messageNamesFile: true,
272
+ });
273
+ expectUnchanged(before);
274
+ },
275
+ T,
276
+ );
277
+ }
278
+
279
+ for (const [kind, content] of Object.entries(NOT_AN_OBJECT)) {
280
+ test(
281
+ `R2.8.8 an installed_plugins.json that parses to ${kind} is refused, names the file, and stays byte-identical`,
282
+ () => {
283
+ const c = newRegistryCase();
284
+ seedRegistry(c, content);
285
+ const before = snapshot(c.files);
286
+ const r = callIn(
287
+ c.home,
288
+ c.cwd,
289
+ SETTINGS_MODULE,
290
+ "readInstalledPluginsRegistry",
291
+ [c.root],
292
+ { files: c.files },
293
+ );
294
+ expect(r).toMatchObject({
295
+ stage: "done",
296
+ ok: false,
297
+ isError: true,
298
+ isSettingsParseError: true,
299
+ messageNamesFile: true,
300
+ });
301
+ expectUnchanged(before);
302
+ },
303
+ T,
304
+ );
305
+ }
306
+
307
+ test(
308
+ "R2.8.8 a corrupt registry never reads as an empty registry",
309
+ () => {
310
+ const c = newRegistryCase();
311
+ seedRegistry(c, INVALID["plain text"] as string);
312
+ const r = callIn(
313
+ c.home,
314
+ c.cwd,
315
+ SETTINGS_MODULE,
316
+ "readInstalledPluginsRegistry",
317
+ [c.root],
318
+ );
319
+ expect(r.stage).toBe("done");
320
+ expect(r.ok).toBe(false);
321
+ },
322
+ T,
323
+ );
324
+ });
325
+
326
+ describe("C20 — writeInstalledPluginsRegistry", () => {
327
+ const TOKEN = () => `blind-${randomUUID()}@magus`;
328
+
329
+ test(
330
+ "R2.8.8 control: with expectBytes equal to the bytes on disk the write succeeds and lands",
331
+ () => {
332
+ const c = newRegistryCase();
333
+ seedRegistry(c, healthyRegistry());
334
+ const current = healthyRegistry();
335
+ const id = TOKEN();
336
+ const r = callIn(
337
+ c.home,
338
+ c.cwd,
339
+ SETTINGS_MODULE,
340
+ "writeInstalledPluginsRegistry",
341
+ [{ version: 2, plugins: { [id]: [] } }, c.root, current],
342
+ );
343
+ expect(r).toMatchObject({ stage: "done", ok: true });
344
+ // The file is this test's own temp testdata, so reading it here is safe.
345
+ const landed = c.files
346
+ .filter((f) => existsSync(f))
347
+ .some((f) => {
348
+ const parsed = JSON.parse(readFileSync(f, "utf8"));
349
+ return (
350
+ parsed !== null &&
351
+ typeof parsed === "object" &&
352
+ Object.keys(parsed.plugins ?? {}).includes(id)
353
+ );
354
+ });
355
+ expect(landed).toBe(true);
356
+ },
357
+ T,
358
+ );
359
+
360
+ test(
361
+ "R2.8.8 a write whose expectBytes no longer match the file is refused and leaves it byte-identical",
362
+ () => {
363
+ const c = newRegistryCase();
364
+ // Read … then someone else rewrites the file … then we write.
365
+ const asRead = healthyRegistry();
366
+ seedRegistry(c, asRead);
367
+ const concurrent = JSON.stringify(
368
+ { version: 2, plugins: {}, note: "written by someone else" },
369
+ null,
370
+ 2,
371
+ );
372
+ seedRegistry(c, concurrent);
373
+ const before = snapshot(c.files);
374
+
375
+ const r = callIn(
376
+ c.home,
377
+ c.cwd,
378
+ SETTINGS_MODULE,
379
+ "writeInstalledPluginsRegistry",
380
+ [{ version: 2, plugins: { [TOKEN()]: [] } }, c.root, asRead],
381
+ { files: c.files },
382
+ );
383
+ expect(r).toMatchObject({ stage: "done", ok: false, isError: true });
384
+ expectUnchanged(before);
385
+ },
386
+ T,
387
+ );
388
+
389
+ for (const [kind, content] of Object.entries(INVALID)) {
390
+ test(
391
+ `R2.8.8 a write is refused when the registry on disk does not parse (${kind}), even with matching expectBytes, and leaves it byte-identical`,
392
+ () => {
393
+ const c = newRegistryCase();
394
+ seedRegistry(c, content);
395
+ const before = snapshot(c.files);
396
+ const r = callIn(
397
+ c.home,
398
+ c.cwd,
399
+ SETTINGS_MODULE,
400
+ "writeInstalledPluginsRegistry",
401
+ [{ version: 2, plugins: { [TOKEN()]: [] } }, c.root, content],
402
+ { files: c.files },
403
+ );
404
+ expect(r).toMatchObject({ stage: "done", ok: false, isError: true });
405
+ // Never replaced by a fresh file holding only the plugin being written.
406
+ expectUnchanged(before);
407
+ },
408
+ T,
409
+ );
410
+ }
411
+
412
+ test(
413
+ "R2.8.8 a write against a corrupt registry is refused when no expectBytes is passed at all",
414
+ () => {
415
+ const c = newRegistryCase();
416
+ seedRegistry(c, INVALID.truncated as string);
417
+ const before = snapshot(c.files);
418
+ const r = callIn(
419
+ c.home,
420
+ c.cwd,
421
+ SETTINGS_MODULE,
422
+ "writeInstalledPluginsRegistry",
423
+ [{ version: 2, plugins: { [TOKEN()]: [] } }, c.root],
424
+ { files: c.files },
425
+ );
426
+ expect(r).toMatchObject({ stage: "done", ok: false, isError: true });
427
+ expectUnchanged(before);
428
+ },
429
+ T,
430
+ );
431
+
432
+ for (const [kind, content] of Object.entries(NOT_AN_OBJECT)) {
433
+ test(
434
+ `R2.8.8 a write is refused when the registry on disk parses to ${kind}, and leaves it byte-identical`,
435
+ () => {
436
+ const c = newRegistryCase();
437
+ seedRegistry(c, content);
438
+ const before = snapshot(c.files);
439
+ const r = callIn(
440
+ c.home,
441
+ c.cwd,
442
+ SETTINGS_MODULE,
443
+ "writeInstalledPluginsRegistry",
444
+ [{ version: 2, plugins: { [TOKEN()]: [] } }, c.root, content],
445
+ { files: c.files },
446
+ );
447
+ expect(r).toMatchObject({ stage: "done", ok: false, isError: true });
448
+ expectUnchanged(before);
449
+ },
450
+ T,
451
+ );
452
+ }
453
+ });
454
+
455
+ // ---------------------------------------------------------------------------
456
+ // .claude/gitignore.json (R2.8.7)
457
+ // ---------------------------------------------------------------------------
458
+
459
+ /**
460
+ * A template id the Gitignore tab offers. C20 does not name one; this is taken
461
+ * from the existing test `src/__tests__/gitignore-service.test.ts`. The control
462
+ * below fails loudly if it stops being a real template.
463
+ */
464
+ const TEMPLATE = "common-dev";
465
+
466
+ interface TemplateCase {
467
+ home: string;
468
+ project: string;
469
+ file: string;
470
+ }
471
+
472
+ function newTemplateCase(): TemplateCase {
473
+ const project = tempDir("proj");
474
+ mkdirSync(path.join(project, ".claude"), { recursive: true });
475
+ return {
476
+ home: tempDir("home"),
477
+ project,
478
+ file: path.join(project, ".claude", "gitignore.json"),
479
+ };
480
+ }
481
+
482
+ describe("C20 — applyTemplate refuses a damaged .claude/gitignore.json", () => {
483
+ test(
484
+ "R2.8.7 control: a healthy .claude/gitignore.json still applies",
485
+ () => {
486
+ const c = newTemplateCase();
487
+ const own = `blind-${randomUUID()}/`;
488
+ write(c.file, JSON.stringify({ ignore: [own], track: [] }, null, 2));
489
+ const r = callIn(c.home, c.project, GITIGNORE_MODULE, "applyTemplate", [
490
+ c.project,
491
+ TEMPLATE,
492
+ ]);
493
+ expect(r).toMatchObject({ stage: "done", ok: true });
494
+ // The file is this test's own temp testdata, so reading it here is safe.
495
+ const after = JSON.parse(readFileSync(c.file, "utf8"));
496
+ expect(after !== null && typeof after === "object").toBe(true);
497
+ // R2.8.7's whole point: the user's own rules survive an apply.
498
+ expect(after.ignore).toContain(own);
499
+ },
500
+ T,
501
+ );
502
+
503
+ for (const [kind, content] of Object.entries(INVALID)) {
504
+ test(
505
+ `R2.8.7 a .claude/gitignore.json that does not parse (${kind}) is refused, names the file, and stays byte-identical`,
506
+ () => {
507
+ const c = newTemplateCase();
508
+ write(c.file, content);
509
+ const before = readFileSync(c.file);
510
+ const r = callIn(
511
+ c.home,
512
+ c.project,
513
+ GITIGNORE_MODULE,
514
+ "applyTemplate",
515
+ [c.project, TEMPLATE],
516
+ { files: [c.file] },
517
+ );
518
+ expect(r).toMatchObject({
519
+ stage: "done",
520
+ ok: false,
521
+ isError: true,
522
+ isSettingsParseError: true,
523
+ messageNamesFile: true,
524
+ });
525
+ // Never read as "no entries" and rewritten — that drops the user's
526
+ // own ignore rules, which is how a secret later reaches a commit.
527
+ expect(readFileSync(c.file).equals(before)).toBe(true);
528
+ },
529
+ T,
530
+ );
531
+ }
532
+
533
+ for (const [kind, content] of Object.entries(NOT_AN_OBJECT)) {
534
+ test(
535
+ `R2.8.7 a .claude/gitignore.json that parses to ${kind} is refused, names the file, and stays byte-identical`,
536
+ () => {
537
+ const c = newTemplateCase();
538
+ write(c.file, content);
539
+ const before = readFileSync(c.file);
540
+ const r = callIn(
541
+ c.home,
542
+ c.project,
543
+ GITIGNORE_MODULE,
544
+ "applyTemplate",
545
+ [c.project, TEMPLATE],
546
+ { files: [c.file] },
547
+ );
548
+ expect(r).toMatchObject({
549
+ stage: "done",
550
+ ok: false,
551
+ isError: true,
552
+ isSettingsParseError: true,
553
+ messageNamesFile: true,
554
+ });
555
+ expect(readFileSync(c.file).equals(before)).toBe(true);
556
+ },
557
+ T,
558
+ );
559
+ }
560
+ });
@@ -7,11 +7,12 @@ import { runInstallCommand } from "../cli/install.js";
7
7
 
8
8
  // Integration coverage for runInstallCommand's VALIDATION wiring — the paths
9
9
  // that return before the closure is resolved. We deliberately don't exercise
10
- // --check / the drift guard here: those resolve the closure, which reaches
10
+ // --check or the install itself here: those resolve the closure, which reaches
11
11
  // claude-settings/plugin-manager — modules other test files replace globally
12
12
  // via bun's mock.module (not restorable across files in bun 1.3.10). The
13
- // --check and drift LOGIC is covered by pure-function tests instead
14
- // (install-plan.test.ts computeVersionDrift, profile-sync.test.ts computeSync).
13
+ // --check logic is covered by install-plan.test.ts (computeVersionDrift); the
14
+ // re-render over the Claude CLI's own settings.json writes by
15
+ // profile-absorb.test.ts (regenerateLiveFiles).
15
16
  //
16
17
  // runInstallCommand takes an explicit projectPath so no cwd juggling is needed.
17
18
  // Console is captured via explicit save/restore.
@@ -14,6 +14,7 @@ import fs from "fs-extra";
14
14
  import { runModelsCommand } from "../cli/models.js";
15
15
  import { presetNames } from "../data/models-presets.js";
16
16
  import { writeManifest } from "../services/manifest.js";
17
+ import { activateProfile } from "../services/profile-activate.js";
17
18
 
18
19
  let logs: string[];
19
20
  let errs: string[];
@@ -56,13 +57,9 @@ afterEach(async () => {
56
57
  * under test rather than on how the runner was launched.
57
58
  */
58
59
  async function seedProfile(name = "team"): Promise<void> {
59
- await writeManifest({ version: 2, profiles: { [name]: { name } } }, project);
60
- const dir = join(project, ".claude", "_profiles", name);
61
- await fs.outputJson(join(dir, "settings.json"), {});
62
- await fs.symlink(
63
- join("_profiles", name, "settings.json"),
64
- join(project, ".claude", "settings.json"),
65
- );
60
+ const manifest = { version: 2 as const, profiles: { [name]: { name } } };
61
+ await writeManifest(manifest, project);
62
+ await activateProfile(manifest, name, project, { onDisplace: () => {} });
66
63
  }
67
64
 
68
65
  describe("models use", () => {