magus-cli 7.4.1 → 7.5.1

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 (65) hide show
  1. package/README.md +9 -8
  2. package/package.json +5 -4
  3. package/src/__tests__/install-command.test.ts +4 -3
  4. package/src/__tests__/models-cli.test.ts +4 -7
  5. package/src/__tests__/models-manager.test.ts +52 -40
  6. package/src/__tests__/profile-absorb.test.ts +1210 -0
  7. package/src/__tests__/profile-command.test.ts +90 -32
  8. package/src/__tests__/profile-dependencies.test.ts +442 -0
  9. package/src/__tests__/profile-manifest-write.test.ts +231 -0
  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 +524 -0
  14. package/src/__tests__/profile-startup.test.ts +44 -14
  15. package/src/__tests__/profile-state.test.ts +1110 -0
  16. package/src/__tests__/settings-file.test.ts +5 -7
  17. package/src/__tests__/stale-load-repair.test.ts +105 -0
  18. package/src/__tests__/styles-manager.test.ts +47 -74
  19. package/src/__tests__/styles-screen-state.test.ts +11 -10
  20. package/src/__tests__/support/preload.ts +14 -0
  21. package/src/__tests__/update-apply.test.ts +145 -0
  22. package/src/cli/bootstrap.ts +91 -27
  23. package/src/cli/doctor.ts +165 -38
  24. package/src/cli/install.ts +126 -122
  25. package/src/cli/models.ts +21 -1
  26. package/src/cli/profile.ts +48 -77
  27. package/src/cli/router.ts +39 -6
  28. package/src/cli/update.ts +46 -6
  29. package/src/data/gitignore-defaults.ts +3 -3
  30. package/src/services/active-closure.ts +3 -3
  31. package/src/services/claude-cli.ts +24 -0
  32. package/src/services/claude-settings.ts +27 -0
  33. package/src/services/conventions-manager.ts +88 -0
  34. package/src/services/git-worktree.ts +113 -9
  35. package/src/services/manifest.ts +224 -8
  36. package/src/services/models-manager.ts +36 -73
  37. package/src/services/plugin-dependencies.ts +48 -0
  38. package/src/services/plugin-requires.ts +137 -14
  39. package/src/services/profile-absorb.ts +1586 -0
  40. package/src/services/profile-activate.ts +492 -94
  41. package/src/services/profile-live.ts +884 -0
  42. package/src/services/profile-render.ts +182 -0
  43. package/src/services/profile-skills.ts +487 -0
  44. package/src/services/profile-startup.ts +88 -16
  45. package/src/services/profile-state.ts +706 -0
  46. package/src/services/profiles.ts +26 -37
  47. package/src/services/resolver.ts +100 -2
  48. package/src/services/settings-file.ts +5 -5
  49. package/src/services/skills-manager.ts +14 -8
  50. package/src/services/styles-manager.ts +45 -28
  51. package/src/services/tracked-live-files.ts +125 -0
  52. package/src/services/update-engine.ts +87 -2
  53. package/src/types/index.ts +34 -9
  54. package/src/ui/App.tsx +25 -2
  55. package/src/ui/hooks/useProfileStartupModal.ts +20 -15
  56. package/src/ui/screens/PluginsScreen.tsx +11 -2
  57. package/src/ui/screens/ProfilesScreen.tsx +84 -29
  58. package/src/ui/screens/StylesScreen.tsx +2 -1
  59. package/src/ui/state/types.ts +1 -1
  60. package/src/__tests__/profile-materializer.test.ts +0 -238
  61. package/src/__tests__/profile-sync.test.ts +0 -154
  62. package/src/__tests__/symlink-manager.test.ts +0 -180
  63. package/src/services/profile-materializer.ts +0 -133
  64. package/src/services/profile-sync.ts +0 -158
  65. package/src/services/symlink-manager.ts +0 -343
@@ -0,0 +1,1110 @@
1
+ /**
2
+ * The strict active-profile check, the entry-point gate, older-layout
3
+ * conversion, and generated files git still tracks.
4
+ */
5
+
6
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
7
+ import { spawnSync } from "node:child_process";
8
+ import { lstat, mkdtemp, readdir, rm } from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import fs from "fs-extra";
12
+ import { ensureManifest, profileGate } from "../cli/bootstrap.js";
13
+ import { reportProfileState, runDoctorCommand } from "../cli/doctor.js";
14
+ import { runModelsCommand } from "../cli/models.js";
15
+ import { route } from "../cli/router.js";
16
+ import { installPlugin, uninstallPlugin } from "../services/claude-cli.js";
17
+ import {
18
+ readSettings,
19
+ writeMcpConfig,
20
+ writeSettings,
21
+ } from "../services/claude-settings.js";
22
+ import { trackedByGit } from "../services/git-worktree.js";
23
+ import { readManifest, writeManifest } from "../services/manifest.js";
24
+ import type { ModelsConfig } from "../services/models-core.js";
25
+ import { reconcileActiveProfile } from "../services/profile-absorb.js";
26
+ import {
27
+ activateProfile,
28
+ regenerateLiveFiles,
29
+ trackedGeneratedFiles,
30
+ } from "../services/profile-activate.js";
31
+ import {
32
+ activeMarkerPath,
33
+ activeProfile,
34
+ backupRoot,
35
+ readActiveMarker,
36
+ sha256,
37
+ writeActiveMarker,
38
+ } from "../services/profile-live.js";
39
+ import { renderProfile } from "../services/profile-render.js";
40
+ import {
41
+ applyProfileChoice,
42
+ checkActiveProfile,
43
+ prepareProfileState,
44
+ switchToProfile,
45
+ } from "../services/profile-state.js";
46
+ import { applyUserProfile } from "../services/profiles.js";
47
+ import {
48
+ GitCheckError,
49
+ TrackedLiveFileError,
50
+ } from "../services/tracked-live-files.js";
51
+ import type { ProfileManifest } from "../types/index.js";
52
+
53
+ const MANIFEST: ProfileManifest = {
54
+ version: 2,
55
+ profiles: {
56
+ team: { name: "Team", plugins: { "dev@magus": "latest" } },
57
+ other: { name: "Other", plugins: { "terminal@magus": "latest" } },
58
+ },
59
+ };
60
+
61
+ const ROUTING: ModelsConfig = {
62
+ version: 1,
63
+ preset: "test",
64
+ main: { model: "opus", effort: "medium" },
65
+ grades: {
66
+ smart: { model: "fable", effort: "xhigh" },
67
+ normal: { model: "opus", effort: "medium" },
68
+ cheap: { model: "sonnet", effort: "low" },
69
+ },
70
+ agents: { "dev:architect": "smart" },
71
+ fallback: "normal",
72
+ };
73
+
74
+ let project: string;
75
+ let wasTTY: boolean | undefined;
76
+ let log: ReturnType<typeof spyOn>;
77
+ let err: ReturnType<typeof spyOn>;
78
+
79
+ const settingsPath = () => join(project, ".claude", "settings.json");
80
+ const printed = () =>
81
+ [...log.mock.calls, ...err.mock.calls].map((c) => String(c[0])).join("\n");
82
+
83
+ beforeEach(async () => {
84
+ project = await mkdtemp(join(tmpdir(), "state-"));
85
+ await writeManifest(MANIFEST, project);
86
+ wasTTY = process.stdin.isTTY;
87
+ Object.defineProperty(process.stdin, "isTTY", {
88
+ value: false,
89
+ configurable: true,
90
+ });
91
+ log = spyOn(console, "log").mockImplementation(() => {});
92
+ err = spyOn(console, "error").mockImplementation(() => {});
93
+ });
94
+ afterEach(async () => {
95
+ log.mockRestore();
96
+ err.mockRestore();
97
+ Object.defineProperty(process.stdin, "isTTY", {
98
+ value: wasTTY,
99
+ configurable: true,
100
+ });
101
+ await rm(project, { recursive: true, force: true });
102
+ });
103
+
104
+ describe("strict active-profile check", () => {
105
+ test("a marker naming a defined profile is active", async () => {
106
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
107
+ expect(await checkActiveProfile(project)).toEqual({
108
+ state: "active",
109
+ profile: "team",
110
+ restored: false,
111
+ });
112
+ });
113
+
114
+ test("a marker naming a profile profiles.json lacks is an error naming the fix", async () => {
115
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
116
+ await writeManifest(
117
+ { version: 2, profiles: { other: MANIFEST.profiles.other as never } },
118
+ project,
119
+ );
120
+
121
+ expect(await checkActiveProfile(project)).toEqual({
122
+ state: "unknown",
123
+ profile: "team",
124
+ available: ["other"],
125
+ // The only profile left is the one to suggest.
126
+ suggested: "other",
127
+ });
128
+ expect(await activeProfile(project)).toBeNull();
129
+
130
+ // magus must not act on profile state...
131
+ expect(await profileGate(project)).toBe(false);
132
+ expect(printed()).toContain('recorded as "team"');
133
+ expect(printed()).toContain("`magus profile switch other`");
134
+ expect(printed()).not.toContain("<other>");
135
+ // ...except the command that fixes it.
136
+ expect(await profileGate(project, { allowUnknown: true })).toBe(true);
137
+ });
138
+
139
+ test("no marker, live config equal to exactly one profile: marker restored", async () => {
140
+ const rendered = await renderProfile(MANIFEST, "other");
141
+ await fs.outputJson(settingsPath(), rendered.settings);
142
+ await fs.writeJson(join(project, ".mcp.json"), rendered.mcp);
143
+
144
+ const prep = await prepareProfileState(project);
145
+
146
+ expect(prep.check).toEqual({
147
+ state: "active",
148
+ profile: "other",
149
+ restored: false,
150
+ });
151
+ expect(prep.lines[0]).toContain('matches profile "other" exactly');
152
+ expect((await readActiveMarker(project))?.profile).toBe("other");
153
+ expect(await activeProfile(project)).toBe("other");
154
+ });
155
+
156
+ test("no marker, live config matching no profile: non-interactive CLI stops with the instruction", async () => {
157
+ await fs.outputJson(settingsPath(), { enabledPlugins: { "x@y": true } });
158
+
159
+ expect(await checkActiveProfile(project)).toEqual({
160
+ state: "unmarked",
161
+ available: ["team", "other"],
162
+ liveFiles: true,
163
+ // Two profiles and no declared default: nothing to suggest.
164
+ suggested: null,
165
+ match: null,
166
+ });
167
+ // A command that writes profile state refuses rather than hanging.
168
+ expect(await runModelsCommand(["off"], project)).toBe(1);
169
+ expect(printed()).toContain(
170
+ "`magus profile switch <name>` (profiles: team, other)",
171
+ );
172
+ expect(await readActiveMarker(project)).toBeNull();
173
+ });
174
+
175
+ test("no manifest profiles at all is its own state", async () => {
176
+ await writeManifest({ version: 2, profiles: {} }, project);
177
+ expect(await checkActiveProfile(project)).toEqual({ state: "no-profiles" });
178
+ });
179
+ });
180
+
181
+ /** The profile directory an older layout's links point into. */
182
+ const oldDir = () => join(project, ".claude", "_profiles", "team");
183
+
184
+ /** Every `.claude/.magus-backups/*-layout` directory. */
185
+ async function layoutBackups(): Promise<string[]> {
186
+ try {
187
+ return (await readdir(backupRoot(project))).filter((d) =>
188
+ d.endsWith("-layout"),
189
+ );
190
+ } catch {
191
+ return [];
192
+ }
193
+ }
194
+
195
+ describe("older layouts: Claude Code's edits are kept, the old directory is backed up", () => {
196
+ // magus 7.4.1: settings.json, .mcp.json and skills are links into
197
+ // `.claude/_profiles/team/`, and there is no marker. Claude Code before
198
+ // 2.1.280 wrote its edits THROUGH the links — they are in `_profiles/`.
199
+ async function stage741(): Promise<void> {
200
+ await fs.ensureDir(join(oldDir(), "skills"));
201
+ await fs.outputJson(join(oldDir(), "settings.json"), {
202
+ enabledPlugins: { "dev@magus": true, "code-search@magus": true },
203
+ permissions: { allow: ["Bash(bun test:*)"] },
204
+ });
205
+ await fs.outputJson(join(oldDir(), "mcp.json"), {
206
+ mcpServers: { db: { command: "pg-mcp" } },
207
+ });
208
+ await fs.symlink(
209
+ "_profiles/team/settings.json",
210
+ join(project, ".claude", "settings.json"),
211
+ );
212
+ await fs.symlink(
213
+ ".claude/_profiles/team/mcp.json",
214
+ join(project, ".mcp.json"),
215
+ );
216
+ await fs.symlink(
217
+ "_profiles/team/skills",
218
+ join(project, ".claude", "skills"),
219
+ );
220
+ }
221
+
222
+ test("7.4.1 layout: a plugin, an allow rule and an MCP server written through the links survive conversion", async () => {
223
+ await stage741();
224
+ expect((await checkActiveProfile(project)).state).toBe("old-layout");
225
+
226
+ const prep = await prepareProfileState(project);
227
+
228
+ expect(prep.problems).toEqual([]);
229
+ expect(prep.lines).toContain(
230
+ 'Changes Claude Code made under the older layout were added to profile "team"',
231
+ );
232
+ expect(prep.lines).toContain('Added code-search@magus to profile "team"');
233
+ expect(prep.lines).toContain(
234
+ 'Converted profile "team" from the older layout to real files',
235
+ );
236
+ // In profiles.json...
237
+ const team = (await readManifest(project)).profiles.team;
238
+ expect(team?.plugins).toEqual({
239
+ "dev@magus": "latest",
240
+ "code-search@magus": "latest",
241
+ });
242
+ expect(team?.settings).toEqual({
243
+ permissions: { allow: ["Bash(bun test:*)"] },
244
+ });
245
+ expect(team?.mcpServers).toEqual({ db: { command: "pg-mcp" } });
246
+ // ...and in the live files, which are real files now.
247
+ for (const rel of [
248
+ ".claude/settings.json",
249
+ ".mcp.json",
250
+ ".claude/skills",
251
+ ]) {
252
+ expect((await lstat(join(project, rel))).isSymbolicLink()).toBe(false);
253
+ }
254
+ const live = await fs.readJson(settingsPath());
255
+ expect(live.enabledPlugins["code-search@magus"]).toBe(true);
256
+ expect(live.permissions).toEqual({ allow: ["Bash(bun test:*)"] });
257
+ expect(await fs.readJson(join(project, ".mcp.json"))).toEqual({
258
+ mcpServers: { db: { command: "pg-mcp" } },
259
+ });
260
+ // The old directory was moved, not deleted, and the run said where.
261
+ expect(await fs.pathExists(join(project, ".claude", "_profiles"))).toBe(
262
+ false,
263
+ );
264
+ const kept = await layoutBackups();
265
+ expect(kept).toHaveLength(1);
266
+ const keptDir = join(backupRoot(project), kept[0] as string);
267
+ expect(await fs.readJson(join(keptDir, "team", "settings.json"))).toEqual({
268
+ enabledPlugins: { "dev@magus": true, "code-search@magus": true },
269
+ permissions: { allow: ["Bash(bun test:*)"] },
270
+ });
271
+ expect(prep.lines.join("\n")).toContain(
272
+ `was kept at .claude/.magus-backups/${kept[0]}/`,
273
+ );
274
+ expect(await readdir(join(project, ".claude", "profiles"))).toEqual([
275
+ "active.json",
276
+ ]);
277
+ });
278
+
279
+ test("the release before (7b330366): real files, marker at _profiles/.active, symlinked models.json", async () => {
280
+ const withRouting = structuredClone(MANIFEST);
281
+ (withRouting.profiles.team as { models?: unknown }).models = ROUTING;
282
+ await writeManifest(withRouting, project);
283
+ const rendered = await renderProfile(withRouting, "team");
284
+ // Real settings.json with a Claude Code edit; an unedited .mcp.json.
285
+ const edited = structuredClone(rendered.settings);
286
+ (edited.enabledPlugins as Record<string, boolean>)["code-search@magus"] =
287
+ true;
288
+ edited.permissions = { allow: ["Read"] };
289
+ await fs.outputFile(settingsPath(), JSON.stringify(edited));
290
+ const mcpBytes = `${JSON.stringify(rendered.mcp, null, 2)}\n`;
291
+ await fs.writeFile(join(project, ".mcp.json"), mcpBytes);
292
+ // models.json and skills were still links; a hand edit went through.
293
+ await fs.ensureDir(join(oldDir(), "skills"));
294
+ const editedRouting = structuredClone(ROUTING);
295
+ editedRouting.agents = { "dev:developer": "cheap" };
296
+ await fs.outputJson(join(oldDir(), "models.json"), editedRouting);
297
+ await fs.symlink(
298
+ "_profiles/team/models.json",
299
+ join(project, ".claude", "models.json"),
300
+ );
301
+ await fs.symlink(
302
+ "_profiles/team/skills",
303
+ join(project, ".claude", "skills"),
304
+ );
305
+ await fs.outputJson(join(project, ".claude", "_profiles", ".active"), {
306
+ version: 1,
307
+ profile: "team",
308
+ files: { settings: "stale", mcp: sha256(mcpBytes) },
309
+ });
310
+
311
+ const prep = await prepareProfileState(project);
312
+
313
+ expect(prep.problems).toEqual([]);
314
+ expect(prep.lines).toContain('Added code-search@magus to profile "team"');
315
+ const team = (await readManifest(project)).profiles.team;
316
+ expect(team?.plugins?.["code-search@magus"]).toBe("latest");
317
+ expect(team?.settings).toEqual({ permissions: { allow: ["Read"] } });
318
+ // The routing file disagrees with profiles.json: profiles.json's value
319
+ // stands, the key is named, and the older copy is in the backup.
320
+ expect(team?.models?.agents).toEqual({ "dev:architect": "smart" });
321
+ expect(prep.lines).toContain(
322
+ 'Profile "team": models differs between the older layout and profiles.json — kept profiles.json\'s value; the older copy is in the -layout backup under .claude/.magus-backups/',
323
+ );
324
+ const live = await fs.readJson(settingsPath());
325
+ expect(live.enabledPlugins["code-search@magus"]).toBe(true);
326
+ expect(live.permissions).toEqual({ allow: ["Read"] });
327
+ expect(
328
+ (await lstat(join(project, ".claude", "models.json"))).isSymbolicLink(),
329
+ ).toBe(false);
330
+ expect(
331
+ (await fs.readJson(join(project, ".claude", "models.json"))).agents,
332
+ ).toEqual({ "dev:architect": "smart" });
333
+ expect(
334
+ (await lstat(join(project, ".claude", "skills"))).isDirectory(),
335
+ ).toBe(true);
336
+ expect(await fs.pathExists(join(project, ".claude", "_profiles"))).toBe(
337
+ false,
338
+ );
339
+ const kept = await layoutBackups();
340
+ expect(kept).toHaveLength(1);
341
+ expect(
342
+ await fs.pathExists(
343
+ join(backupRoot(project), kept[0] as string, ".active"),
344
+ ),
345
+ ).toBe(true);
346
+ expect(await fs.pathExists(activeMarkerPath(project))).toBe(true);
347
+ });
348
+
349
+ test("reviewer r8: a teammate's change pulled after the last 7.4.1 install is not reverted", async () => {
350
+ // profiles.json NOW, after a pull: a teammate added a plugin, a deny rule
351
+ // and an MCP server to "team", and removed z@magus from "other".
352
+ const pulled: ProfileManifest = {
353
+ version: 2,
354
+ profiles: {
355
+ team: {
356
+ name: "Team",
357
+ plugins: { "dev@magus": "latest", "new-p@magus": "latest" },
358
+ settings: {
359
+ permissions: {
360
+ allow: ["Bash(bun test:*)"],
361
+ deny: ["Bash(rm -rf:*)"],
362
+ },
363
+ },
364
+ mcpServers: { team: { command: "team-mcp" } },
365
+ },
366
+ other: { name: "Other", plugins: { "terminal@magus": "latest" } },
367
+ },
368
+ };
369
+ await writeManifest(pulled, project);
370
+ // The 7.4.1 files, rendered from the OLD entry, plus Claude Code's edits.
371
+ await fs.ensureDir(join(oldDir(), "skills"));
372
+ await fs.outputJson(join(oldDir(), "settings.json"), {
373
+ enabledPlugins: {
374
+ "dev@magus": true,
375
+ "terminal@magus": false,
376
+ "z@magus": false,
377
+ "code-search@magus": true,
378
+ },
379
+ permissions: { allow: ["Bash(bun test:*)", "Bash(ls)"] },
380
+ });
381
+ await fs.outputJson(join(oldDir(), "mcp.json"), { mcpServers: {} });
382
+ // 7.4.1 materialized every profile: "other"'s render still names z@magus.
383
+ await fs.outputJson(
384
+ join(project, ".claude", "_profiles", "other", "settings.json"),
385
+ {
386
+ enabledPlugins: {
387
+ "dev@magus": false,
388
+ "terminal@magus": true,
389
+ "z@magus": true,
390
+ },
391
+ },
392
+ );
393
+ await fs.symlink(
394
+ "_profiles/team/settings.json",
395
+ join(project, ".claude", "settings.json"),
396
+ );
397
+ await fs.symlink(
398
+ ".claude/_profiles/team/mcp.json",
399
+ join(project, ".mcp.json"),
400
+ );
401
+ await fs.symlink(
402
+ "_profiles/team/skills",
403
+ join(project, ".claude", "skills"),
404
+ );
405
+
406
+ const prep = await prepareProfileState(project);
407
+
408
+ const team = (await readManifest(project)).profiles.team;
409
+ // The teammate's additions all stand...
410
+ expect(team?.plugins).toEqual({
411
+ "dev@magus": "latest",
412
+ "new-p@magus": "latest",
413
+ "code-search@magus": "latest",
414
+ });
415
+ expect(team?.settings).toEqual({
416
+ permissions: {
417
+ allow: ["Bash(bun test:*)", "Bash(ls)"],
418
+ deny: ["Bash(rm -rf:*)"],
419
+ },
420
+ });
421
+ expect(team?.mcpServers).toEqual({ team: { command: "team-mcp" } });
422
+ // ...no stale `false` is written, and nothing is blamed on Claude Code
423
+ // that it did not add.
424
+ expect(team?.plugins?.["z@magus"]).toBeUndefined();
425
+ const lines = prep.lines.join("\n");
426
+ expect(lines).not.toContain("Removed new-p@magus");
427
+ expect(lines).not.toContain("Disabled z@magus");
428
+ expect(lines).toContain('Added code-search@magus to profile "team"');
429
+ // Removals are not applied, and the run says so, once.
430
+ expect(
431
+ prep.lines.filter((l) =>
432
+ l.includes("a removal made under the older layout is not applied"),
433
+ ),
434
+ ).toHaveLength(1);
435
+ const live = await fs.readJson(settingsPath());
436
+ expect(live.enabledPlugins["new-p@magus"]).toBe(true);
437
+ expect(live.permissions.deny).toEqual(["Bash(rm -rf:*)"]);
438
+ });
439
+
440
+ test("where the old file and profiles.json hold different values, profiles.json's stands and the key is named", async () => {
441
+ await writeManifest(
442
+ {
443
+ version: 2,
444
+ profiles: {
445
+ team: {
446
+ name: "Team",
447
+ plugins: { "dev@magus": "latest" },
448
+ settings: {
449
+ model: "opus",
450
+ permissions: { defaultMode: "plan" },
451
+ },
452
+ mcpServers: { db: { command: "pg-mcp", args: ["--new"] } },
453
+ },
454
+ },
455
+ },
456
+ project,
457
+ );
458
+ await stage741();
459
+ await fs.outputJson(join(oldDir(), "settings.json"), {
460
+ enabledPlugins: { "dev@magus": true },
461
+ model: "sonnet",
462
+ permissions: { defaultMode: "acceptEdits", allow: ["Read"] },
463
+ });
464
+ await fs.outputJson(join(oldDir(), "mcp.json"), {
465
+ mcpServers: { db: { command: "pg-mcp", args: ["--old"] } },
466
+ });
467
+
468
+ const prep = await prepareProfileState(project);
469
+
470
+ const team = (await readManifest(project)).profiles.team;
471
+ expect(team?.settings).toEqual({
472
+ model: "opus",
473
+ permissions: { defaultMode: "plan", allow: ["Read"] },
474
+ });
475
+ expect(team?.mcpServers).toEqual({
476
+ db: { command: "pg-mcp", args: ["--new"] },
477
+ });
478
+ for (const at of [
479
+ "settings.model",
480
+ "settings.permissions.defaultMode",
481
+ "mcpServers.db",
482
+ ]) {
483
+ expect(prep.lines).toContain(
484
+ `Profile "team": ${at} differs between the older layout and profiles.json — kept profiles.json's value; the older copy is in the -layout backup under .claude/.magus-backups/`,
485
+ );
486
+ }
487
+ // The older copy really is in the backup.
488
+ const kept = await layoutBackups();
489
+ expect(
490
+ (
491
+ await fs.readJson(
492
+ join(backupRoot(project), kept[0] as string, "team", "settings.json"),
493
+ )
494
+ ).model,
495
+ ).toBe("sonnet");
496
+ });
497
+
498
+ /** A 7.4.1 layout where "team" is live and "other"'s dir holds its render. */
499
+ async function stage741WithOther(
500
+ teamSettings: Record<string, unknown>,
501
+ otherSettings: Record<string, unknown>,
502
+ ): Promise<void> {
503
+ await fs.ensureDir(join(oldDir(), "skills"));
504
+ await fs.outputJson(join(oldDir(), "settings.json"), teamSettings);
505
+ await fs.outputJson(join(oldDir(), "mcp.json"), { mcpServers: {} });
506
+ await fs.outputJson(
507
+ join(project, ".claude", "_profiles", "other", "settings.json"),
508
+ otherSettings,
509
+ );
510
+ await fs.symlink(
511
+ "_profiles/team/settings.json",
512
+ join(project, ".claude", "settings.json"),
513
+ );
514
+ await fs.symlink(
515
+ ".claude/_profiles/team/mcp.json",
516
+ join(project, ".mcp.json"),
517
+ );
518
+ await fs.symlink(
519
+ "_profiles/team/skills",
520
+ join(project, ".claude", "skills"),
521
+ );
522
+ }
523
+
524
+ test("reviewer r12c: a user's own disable of a plugin no profile named is kept", async () => {
525
+ await stage741WithOther(
526
+ {
527
+ enabledPlugins: {
528
+ "dev@magus": true,
529
+ "terminal@magus": false,
530
+ "noisy@user-mkt": false,
531
+ },
532
+ },
533
+ { enabledPlugins: { "dev@magus": false, "terminal@magus": true } },
534
+ );
535
+
536
+ const prep = await prepareProfileState(project);
537
+
538
+ expect(prep.lines).toContain('Disabled noisy@user-mkt in profile "team"');
539
+ const team = (await readManifest(project)).profiles.team;
540
+ expect(team?.plugins).toEqual({
541
+ "dev@magus": "latest",
542
+ "noisy@user-mkt": false,
543
+ });
544
+ // terminal@magus: false was the render's own output — not added.
545
+ expect(team?.plugins?.["terminal@magus"]).toBeUndefined();
546
+ const live = await fs.readJson(settingsPath());
547
+ expect(live.enabledPlugins["noisy@user-mkt"]).toBe(false);
548
+ });
549
+
550
+ test("a false the older render wrote — the id is in another profile's old file — is skipped", async () => {
551
+ // gone@magus was in "other" at the 7.4.1 render and has since been
552
+ // removed from profiles.json, so the current manifest does not name it.
553
+ await stage741WithOther(
554
+ { enabledPlugins: { "dev@magus": true, "gone@magus": false } },
555
+ {
556
+ enabledPlugins: {
557
+ "dev@magus": false,
558
+ "terminal@magus": true,
559
+ "gone@magus": true,
560
+ },
561
+ },
562
+ );
563
+
564
+ const prep = await prepareProfileState(project);
565
+
566
+ expect(prep.lines.join("\n")).not.toContain("gone@magus");
567
+ expect((await readManifest(project)).profiles.team?.plugins).toEqual({
568
+ "dev@magus": "latest",
569
+ });
570
+ expect(
571
+ (await fs.readJson(settingsPath())).enabledPlugins["gone@magus"],
572
+ ).toBeUndefined();
573
+ });
574
+
575
+ test("an older layout file that cannot be read stops the conversion, and nothing moves", async () => {
576
+ await stage741();
577
+ const broken = '{ "enabledPlugins": { "dev@magus": true, } }';
578
+ await fs.writeFile(join(oldDir(), "settings.json"), broken);
579
+
580
+ const prep = await prepareProfileState(project);
581
+
582
+ expect(prep.problems).toHaveLength(1);
583
+ expect(prep.problems[0]).toContain("did not convert it");
584
+ expect(
585
+ (await lstat(join(project, ".claude", "settings.json"))).isSymbolicLink(),
586
+ ).toBe(true);
587
+ expect(await fs.readFile(join(oldDir(), "settings.json"), "utf8")).toBe(
588
+ broken,
589
+ );
590
+ expect(await layoutBackups()).toEqual([]);
591
+ });
592
+
593
+ test("magus doctor without --fix converts nothing and writes nothing", async () => {
594
+ await stage741();
595
+ const snapshot = async () => ({
596
+ manifest: await fs.readFile(
597
+ join(project, ".claude", "profiles.json"),
598
+ "utf8",
599
+ ),
600
+ settingsIsLink: (
601
+ await lstat(join(project, ".claude", "settings.json"))
602
+ ).isSymbolicLink(),
603
+ old: await fs.readFile(join(oldDir(), "settings.json"), "utf8"),
604
+ claude: (await readdir(join(project, ".claude"))).sort(),
605
+ });
606
+ const before = await snapshot();
607
+ const cwd = process.cwd();
608
+ const home = await mkdtemp(join(tmpdir(), "state-doctor-home-"));
609
+ const saved = {
610
+ HOME: process.env.HOME,
611
+ CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
612
+ };
613
+ process.env.HOME = home;
614
+ process.env.CLAUDE_CONFIG_DIR = join(home, ".claude");
615
+ process.chdir(project);
616
+ try {
617
+ await runDoctorCommand([]);
618
+ } finally {
619
+ process.chdir(cwd);
620
+ process.env.HOME = saved.HOME;
621
+ if (saved.CLAUDE_CONFIG_DIR === undefined) {
622
+ // biome-ignore lint/performance/noDelete: an unset env var must be absent, not "undefined"
623
+ delete process.env.CLAUDE_CONFIG_DIR;
624
+ } else process.env.CLAUDE_CONFIG_DIR = saved.CLAUDE_CONFIG_DIR;
625
+ await rm(home, { recursive: true, force: true });
626
+ }
627
+
628
+ expect(await snapshot()).toEqual(before);
629
+ expect(printed()).toContain("is on an older magus layout");
630
+ expect(await fs.pathExists(activeMarkerPath(project))).toBe(false);
631
+ });
632
+
633
+ test("doctor without --fix: no marker, live config matching one profile — one line, not an error", async () => {
634
+ const rendered = await renderProfile(MANIFEST, "other");
635
+ await fs.outputJson(settingsPath(), rendered.settings);
636
+ await fs.writeJson(join(project, ".mcp.json"), rendered.mcp);
637
+ // The profile section alone: doctor's other sections read this machine's
638
+ // real plugin registry, so the whole command's exit code would test the
639
+ // machine. This section's counts are what it adds to that exit code.
640
+ const counts = await reportProfileState(project, false);
641
+
642
+ const out = printed();
643
+ expect(out).toContain(
644
+ 'No active-profile marker, but the live config matches profile "other" exactly',
645
+ );
646
+ // The contradicting line is gone, and so is the failure.
647
+ expect(out).not.toContain("matches no single profile");
648
+ expect(counts).toEqual({ fixable: 0, unfixable: 0 });
649
+ // Report only: the marker is not restored.
650
+ expect(await readActiveMarker(project)).toBeNull();
651
+ });
652
+
653
+ test("a directory under .claude/profiles/ that is not magus's survives (reviewer S4)", async () => {
654
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
655
+ await fs.outputFile(
656
+ join(project, ".claude", "profiles", "notes", "README.md"),
657
+ "not magus's",
658
+ );
659
+
660
+ const prep = await prepareProfileState(project);
661
+
662
+ expect(prep.lines).toEqual([]);
663
+ expect(
664
+ await fs.readFile(
665
+ join(project, ".claude", "profiles", "notes", "README.md"),
666
+ "utf8",
667
+ ),
668
+ ).toBe("not magus's");
669
+ expect((await checkActiveProfile(project)).state).toBe("active");
670
+ });
671
+ });
672
+
673
+ function git(cwd: string, ...args: string[]) {
674
+ return spawnSync(
675
+ "git",
676
+ [
677
+ "-c",
678
+ "user.email=test@example.com",
679
+ "-c",
680
+ "user.name=test",
681
+ "-c",
682
+ "commit.gpgsign=false",
683
+ ...args,
684
+ ],
685
+ { cwd, encoding: "utf8" },
686
+ );
687
+ }
688
+
689
+ describe("generated files git still tracks", () => {
690
+ /** A repo that committed profiles.json AND settings.json, like magus-src. */
691
+ async function commitTrackedSettings(): Promise<void> {
692
+ expect(git(project, "init", "-q").status).toBe(0);
693
+ await fs.outputJson(settingsPath(), {
694
+ enabledPlugins: { "dev@magus": true },
695
+ installedPluginVersions: { "dev@magus": "8.0.0" },
696
+ });
697
+ git(project, "add", ".claude/settings.json", ".claude/profiles.json");
698
+ expect(git(project, "commit", "-q", "-m", "init").status).toBe(0);
699
+ }
700
+
701
+ /** Everything B1 says must not change when magus refuses. */
702
+ async function untouched(): Promise<void> {
703
+ expect(git(project, "status", "--porcelain").stdout).toBe("");
704
+ expect(await fs.pathExists(join(project, ".claude", "profiles"))).toBe(
705
+ false,
706
+ );
707
+ expect(await fs.pathExists(backupRoot(project))).toBe(false);
708
+ expect(await fs.pathExists(join(project, ".gitignore"))).toBe(false);
709
+ }
710
+
711
+ test("B1: activating over a tracked settings.json writes nothing and names the fix", async () => {
712
+ await commitTrackedSettings();
713
+
714
+ const error = await activateProfile(MANIFEST, "team", project, {
715
+ onDisplace: () => {},
716
+ }).then(
717
+ () => null,
718
+ (e: unknown) => e,
719
+ );
720
+
721
+ expect(error).toBeInstanceOf(TrackedLiveFileError);
722
+ const message = (error as Error).message;
723
+ expect(message).toContain(".claude/settings.json");
724
+ expect(message).toContain("git rm --cached .claude/settings.json");
725
+ expect(message).toContain("Nothing was changed.");
726
+ await untouched();
727
+ });
728
+
729
+ test("B1: the gate, the switch path and a startup choice all refuse, writing nothing", async () => {
730
+ await commitTrackedSettings();
731
+
732
+ const prep = await prepareProfileState(project);
733
+ expect(prep.refusal).toBeInstanceOf(TrackedLiveFileError);
734
+ expect(prep.problems[0]).toContain("git rm --cached .claude/settings.json");
735
+ expect(await profileGate(project)).toBe(false);
736
+
737
+ const switched = await switchToProfile(project, "other", {
738
+ onDisplace: () => {},
739
+ });
740
+ expect(switched.activation).toBeNull();
741
+ expect(switched.problems[0]).toContain(".claude/settings.json");
742
+
743
+ for (const choice of [
744
+ { kind: "activate", id: "team" },
745
+ { kind: "adopt", id: "default-2" },
746
+ ] as const) {
747
+ await expect(
748
+ applyProfileChoice(choice, MANIFEST, project, { onDisplace: () => {} }),
749
+ ).rejects.toBeInstanceOf(TrackedLiveFileError);
750
+ }
751
+ await untouched();
752
+ });
753
+
754
+ test("B1: the CLI router exits 1 for `magus profile switch`, and writes nothing", async () => {
755
+ await commitTrackedSettings();
756
+ const cwd = process.cwd();
757
+ process.chdir(project);
758
+ let outcome: Awaited<ReturnType<typeof route>>;
759
+ try {
760
+ outcome = await route(["profile", "switch", "other"], "0.0.0-test");
761
+ } finally {
762
+ process.chdir(cwd);
763
+ }
764
+ expect(outcome.exitCode).toBe(1);
765
+ expect(printed()).toContain("git rm --cached .claude/settings.json");
766
+ await untouched();
767
+ });
768
+
769
+ test("B1: once settings.json is tracked, a pulled change regenerates nothing", async () => {
770
+ expect(git(project, "init", "-q").status).toBe(0);
771
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
772
+ // Somebody commits the generated file after all.
773
+ git(project, "add", "-f", ".claude/settings.json");
774
+ git(project, "add", ".claude/profiles.json");
775
+ expect(git(project, "commit", "-q", "-m", "oops").status).toBe(0);
776
+ const before = await fs.readFile(settingsPath(), "utf8");
777
+ // A teammate's pull changes the profile.
778
+ const pulled = structuredClone(MANIFEST);
779
+ (pulled.profiles.team as { plugins: Record<string, string> }).plugins[
780
+ "mnemex@magus"
781
+ ] = "latest";
782
+ await writeManifest(pulled, project);
783
+ const manifestBefore = await fs.readFile(
784
+ join(project, ".claude", "profiles.json"),
785
+ "utf8",
786
+ );
787
+
788
+ const prep = await prepareProfileState(project);
789
+ expect(prep.problems[0]).toContain("git rm --cached .claude/settings.json");
790
+ expect((await reconcileActiveProfile(project)).problems[0]).toContain(
791
+ ".claude/settings.json",
792
+ );
793
+ await expect(regenerateLiveFiles(project)).rejects.toBeInstanceOf(
794
+ TrackedLiveFileError,
795
+ );
796
+ expect(await fs.readFile(settingsPath(), "utf8")).toBe(before);
797
+ expect(
798
+ await fs.readFile(join(project, ".claude", "profiles.json"), "utf8"),
799
+ ).toBe(manifestBefore);
800
+ });
801
+
802
+ test("B1: after `git rm --cached` and a commit, activation proceeds and keeps the committed profiles.json", async () => {
803
+ await commitTrackedSettings();
804
+ git(project, "rm", "-q", "--cached", ".claude/settings.json");
805
+ expect(git(project, "commit", "-q", "-m", "untrack").status).toBe(0);
806
+ const manifestBytes = await fs.readFile(
807
+ join(project, ".claude", "profiles.json"),
808
+ "utf8",
809
+ );
810
+
811
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
812
+
813
+ expect((await lstat(settingsPath())).isFile()).toBe(true);
814
+ expect((await readActiveMarker(project))?.profile).toBe("team");
815
+ expect(
816
+ await fs.readFile(join(project, ".claude", "profiles.json"), "utf8"),
817
+ ).toBe(manifestBytes);
818
+ });
819
+
820
+ test("B1: adopting a repo with no profiles.json and a tracked settings.json writes nothing", async () => {
821
+ await fs.remove(join(project, ".claude", "profiles.json"));
822
+ expect(git(project, "init", "-q").status).toBe(0);
823
+ await fs.outputJson(settingsPath(), {
824
+ enabledPlugins: { "dev@magus": true },
825
+ });
826
+ git(project, "add", ".claude/settings.json");
827
+ expect(git(project, "commit", "-q", "-m", "init").status).toBe(0);
828
+ const home = await mkdtemp(join(tmpdir(), "state-adopt-home-"));
829
+ const savedHome = process.env.HOME;
830
+ process.env.HOME = home;
831
+ try {
832
+ await expect(
833
+ ensureManifest(project, { allowAdopt: true }),
834
+ ).rejects.toBeInstanceOf(TrackedLiveFileError);
835
+ } finally {
836
+ process.env.HOME = savedHome;
837
+ await rm(home, { recursive: true, force: true });
838
+ }
839
+ expect(await fs.pathExists(join(project, ".claude", "profiles.json"))).toBe(
840
+ false,
841
+ );
842
+ await untouched();
843
+ });
844
+
845
+ test("B1: doctor counts a tracked live file as a problem", async () => {
846
+ await commitTrackedSettings();
847
+ const counts = await reportProfileState(project, false);
848
+ expect(counts.unfixable).toBeGreaterThan(0);
849
+ expect(printed()).toContain("git rm --cached .claude/settings.json");
850
+ await untouched();
851
+ });
852
+
853
+ test("review M1: writeSettings and writeMcpConfig refuse a tracked live file and leave it as it is", async () => {
854
+ await commitTrackedSettings();
855
+ await fs.outputJson(join(project, ".mcp.json"), { mcpServers: {} });
856
+ git(project, "add", ".mcp.json");
857
+ expect(git(project, "commit", "-q", "-m", "mcp").status).toBe(0);
858
+ const before = await fs.readFile(settingsPath(), "utf8");
859
+
860
+ // What the TUI Profiles tab's "apply predefined" does.
861
+ const settings = await readSettings(project);
862
+ (settings.enabledPlugins as Record<string, boolean>)["frontend@magus"] =
863
+ true;
864
+ await expect(writeSettings(settings, project)).rejects.toBeInstanceOf(
865
+ TrackedLiveFileError,
866
+ );
867
+ await expect(
868
+ writeMcpConfig({ mcpServers: { x: { command: "x" } } }, project),
869
+ ).rejects.toBeInstanceOf(TrackedLiveFileError);
870
+
871
+ expect(await fs.readFile(settingsPath(), "utf8")).toBe(before);
872
+ await untouched();
873
+ });
874
+
875
+ test("review M1: a project-scope Claude CLI install or uninstall is refused before the CLI runs", async () => {
876
+ await commitTrackedSettings();
877
+ const cwd = process.cwd();
878
+ const pathBefore = process.env.PATH;
879
+ // git stays reachable; `claude` does not — were the refusal missing, the
880
+ // CLI could not run here and touch this machine's plugins anyway.
881
+ const gitDir = spawnSync("sh", ["-c", "command -v git"], {
882
+ encoding: "utf8",
883
+ }).stdout.trim();
884
+ process.env.PATH = gitDir.slice(0, gitDir.lastIndexOf("/"));
885
+ process.chdir(project);
886
+ try {
887
+ await expect(
888
+ installPlugin("dev@magus", "project"),
889
+ ).rejects.toBeInstanceOf(TrackedLiveFileError);
890
+ await expect(
891
+ uninstallPlugin("dev@magus", "project", project),
892
+ ).rejects.toBeInstanceOf(TrackedLiveFileError);
893
+ } finally {
894
+ process.chdir(cwd);
895
+ process.env.PATH = pathBefore;
896
+ }
897
+ await untouched();
898
+ });
899
+
900
+ test("review M1: a project without profiles writes its own settings.json as before", async () => {
901
+ await commitTrackedSettings();
902
+ await writeManifest({ version: 2, profiles: {} }, project);
903
+ const settings = await readSettings(project);
904
+ (settings.enabledPlugins as Record<string, boolean>)["frontend@magus"] =
905
+ true;
906
+ await writeSettings(settings, project);
907
+ expect(
908
+ (await fs.readJson(settingsPath())).enabledPlugins["frontend@magus"],
909
+ ).toBe(true);
910
+ });
911
+
912
+ test("review 3a: inside a work tree git cannot read, activation refuses with git's own message", async () => {
913
+ // A .git pointing at a repository that is not there: git exits 128.
914
+ await fs.outputFile(
915
+ join(project, ".git"),
916
+ `gitdir: ${join(project, "no-such-repository")}\n`,
917
+ );
918
+
919
+ const error = await activateProfile(MANIFEST, "team", project, {
920
+ onDisplace: () => {},
921
+ }).then(
922
+ () => null,
923
+ (e: unknown) => e,
924
+ );
925
+
926
+ expect(error).toBeInstanceOf(GitCheckError);
927
+ expect((error as Error).message).toContain("not a git repository");
928
+ expect((error as Error).message).toContain("Nothing was changed.");
929
+ expect(await fs.pathExists(settingsPath())).toBe(false);
930
+ expect(await fs.pathExists(join(project, ".claude", "profiles"))).toBe(
931
+ false,
932
+ );
933
+ const prep = await prepareProfileState(project);
934
+ expect(prep.refusal).toBeInstanceOf(GitCheckError);
935
+ });
936
+
937
+ test("review 3a: git missing is a refusal inside a work tree, and no check outside one", async () => {
938
+ expect(git(project, "init", "-q").status).toBe(0);
939
+ const outside = await mkdtemp(join(tmpdir(), "no-git-"));
940
+ const pathBefore = process.env.PATH;
941
+ // Not empty: an empty PATH falls back to the system default, where git is.
942
+ process.env.PATH = join(outside, "no-bin");
943
+ try {
944
+ await expect(
945
+ trackedByGit(project, [".claude/settings.json"]),
946
+ ).rejects.toThrow("could not run git");
947
+ expect([...(await trackedByGit(outside, [".mcp.json"]))]).toEqual([]);
948
+ } finally {
949
+ process.env.PATH = pathBefore;
950
+ await rm(outside, { recursive: true, force: true });
951
+ }
952
+ });
953
+
954
+ test("committed custom skills are not reported", async () => {
955
+ expect(git(project, "init", "-q").status).toBe(0);
956
+ await fs.outputFile(
957
+ join(project, ".claude", "skills", "mine", "SKILL.md"),
958
+ "# mine\n",
959
+ );
960
+ git(project, "add", ".");
961
+ expect(git(project, "commit", "-q", "-m", "init").status).toBe(0);
962
+
963
+ expect(await trackedGeneratedFiles(project)).toEqual([]);
964
+ });
965
+
966
+ test("outside a git repository nothing is reported", async () => {
967
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
968
+ expect(await trackedGeneratedFiles(project)).toEqual([]);
969
+ });
970
+ });
971
+
972
+ describe("machine-local settings survive every render", () => {
973
+ /** What `magus update` records: eleven plugins, as on the reporting machine. */
974
+ const VERSIONS = Object.fromEntries(
975
+ Array.from({ length: 11 }, (_, i) => [`p${i}@magus`, `1.${i}.0`]),
976
+ );
977
+
978
+ test("B3: first adoption carries installedPluginVersions out of the settings.json it moves aside", async () => {
979
+ await fs.outputJson(settingsPath(), {
980
+ enabledPlugins: { "hand@written": true },
981
+ installedPluginVersions: VERSIONS,
982
+ });
983
+ const moved: string[] = [];
984
+
985
+ await activateProfile(MANIFEST, "team", project, {
986
+ onDisplace: (artifact) => moved.push(artifact.savedTo),
987
+ });
988
+
989
+ expect(moved).toHaveLength(1);
990
+ const live = await fs.readJson(settingsPath());
991
+ expect(live.installedPluginVersions).toEqual(VERSIONS);
992
+ expect(live.enabledPlugins["dev@magus"]).toBe(true);
993
+ expect(live.enabledPlugins["hand@written"]).toBeUndefined();
994
+ // The backup still holds the whole original.
995
+ expect(
996
+ (await fs.readJson(moved[0] as string)).installedPluginVersions,
997
+ ).toEqual(VERSIONS);
998
+ });
999
+
1000
+ test("B3: a switch, then a regeneration from a pulled change, keep what an update recorded", async () => {
1001
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
1002
+ const live = await fs.readJson(settingsPath());
1003
+ live.installedPluginVersions = VERSIONS;
1004
+ await fs.writeJson(settingsPath(), live);
1005
+
1006
+ const switched = await switchToProfile(project, "other");
1007
+ expect(switched.problems).toEqual([]);
1008
+ expect((await fs.readJson(settingsPath())).installedPluginVersions).toEqual(
1009
+ VERSIONS,
1010
+ );
1011
+ // Machine state, not a contract: nothing of it reached profiles.json.
1012
+ expect(JSON.stringify(await readManifest(project))).not.toContain(
1013
+ "installedPluginVersions",
1014
+ );
1015
+
1016
+ // A teammate's commit adds a plugin to the active profile.
1017
+ const manifest = await readManifest(project);
1018
+ (manifest.profiles.other?.plugins as Record<string, string>)[
1019
+ "designer@magus"
1020
+ ] = "latest";
1021
+ await writeManifest(manifest, project);
1022
+ expect(await regenerateLiveFiles(project)).toBe(true);
1023
+
1024
+ const after = await fs.readJson(settingsPath());
1025
+ expect(after.enabledPlugins["designer@magus"]).toBe(true);
1026
+ expect(after.installedPluginVersions).toEqual(VERSIONS);
1027
+ });
1028
+ });
1029
+
1030
+ describe("the gate is idempotent", () => {
1031
+ test("a second pass with nothing changed prints nothing", async () => {
1032
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
1033
+ const marker = await readActiveMarker(project);
1034
+ if (marker) await writeActiveMarker(marker, project);
1035
+ const prep = await prepareProfileState(project);
1036
+ expect(prep.lines).toEqual([]);
1037
+ expect(prep.problems).toEqual([]);
1038
+ });
1039
+ });
1040
+
1041
+ describe("applying a profile from the TUI Profiles tab", () => {
1042
+ test("a project profile goes through the switch path: absorb into the old, activate the new", async () => {
1043
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
1044
+ // Claude Code installs a plugin while "team" is active.
1045
+ const live = await fs.readJson(settingsPath());
1046
+ live.enabledPlugins["code-search@magus"] = true;
1047
+ await fs.writeFile(settingsPath(), JSON.stringify(live));
1048
+
1049
+ const result = await switchToProfile(project, "other", {
1050
+ onDisplace: () => {},
1051
+ });
1052
+
1053
+ expect(result.problems).toEqual([]);
1054
+ expect(result.lines).toEqual(['Added code-search@magus to profile "team"']);
1055
+ expect(result.activation?.profileId).toBe("other");
1056
+ expect(result.activation?.displaced).toEqual([]);
1057
+ // The edit was kept in the profile it was made under...
1058
+ const manifest = await readManifest(project);
1059
+ expect(manifest.profiles.team?.plugins?.["code-search@magus"]).toBe(
1060
+ "latest",
1061
+ );
1062
+ // ...and the live file is "other", rendered from the manifest — not a
1063
+ // copy of its plugins spliced into whatever was there.
1064
+ expect(await fs.readJson(settingsPath())).toEqual(
1065
+ (await renderProfile(manifest, "other")).settings,
1066
+ );
1067
+ expect((await readActiveMarker(project))?.profile).toBe("other");
1068
+ expect(await fs.pathExists(backupRoot(project))).toBe(false);
1069
+ });
1070
+
1071
+ test("an unknown target changes nothing and says why", async () => {
1072
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
1073
+ const before = await fs.readFile(settingsPath(), "utf8");
1074
+
1075
+ const result = await switchToProfile(project, "nope");
1076
+
1077
+ expect(result.activation).toBeNull();
1078
+ expect(result.problems).toEqual([
1079
+ 'Profile "nope" not found. Available: team, other',
1080
+ ]);
1081
+ expect(await fs.readFile(settingsPath(), "utf8")).toBe(before);
1082
+ expect((await readActiveMarker(project))?.profile).toBe("team");
1083
+ });
1084
+
1085
+ test("a user-scope profile writes ~/.claude/settings.json and never the project's live files", async () => {
1086
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
1087
+ const beforeSettings = await fs.readFile(settingsPath(), "utf8");
1088
+ const beforeMarker = await fs.readFile(activeMarkerPath(project), "utf8");
1089
+ const home = await mkdtemp(join(tmpdir(), "state-home-"));
1090
+ const previousHome = process.env.HOME;
1091
+ process.env.HOME = home;
1092
+ try {
1093
+ await applyUserProfile({
1094
+ plugins: { "dev@magus": "latest", "off@elsewhere": false },
1095
+ });
1096
+ expect(await fs.readJson(join(home, ".claude", "settings.json"))).toEqual(
1097
+ {
1098
+ enabledPlugins: { "dev@magus": true, "off@elsewhere": false },
1099
+ },
1100
+ );
1101
+ } finally {
1102
+ process.env.HOME = previousHome;
1103
+ await rm(home, { recursive: true, force: true });
1104
+ }
1105
+ expect(await fs.readFile(settingsPath(), "utf8")).toBe(beforeSettings);
1106
+ expect(await fs.readFile(activeMarkerPath(project), "utf8")).toBe(
1107
+ beforeMarker,
1108
+ );
1109
+ });
1110
+ });