magus-cli 7.4.1 → 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 (53) hide show
  1. package/README.md +9 -8
  2. package/package.json +4 -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 +1138 -0
  7. package/src/__tests__/profile-command.test.ts +90 -32
  8. package/src/__tests__/profile-migration-offer.test.ts +18 -15
  9. package/src/__tests__/profile-migration.test.ts +246 -117
  10. package/src/__tests__/profile-render.test.ts +141 -0
  11. package/src/__tests__/profile-skills.test.ts +425 -0
  12. package/src/__tests__/profile-startup.test.ts +44 -14
  13. package/src/__tests__/profile-state.test.ts +795 -0
  14. package/src/__tests__/settings-file.test.ts +5 -7
  15. package/src/__tests__/styles-manager.test.ts +47 -74
  16. package/src/__tests__/styles-screen-state.test.ts +11 -10
  17. package/src/cli/bootstrap.ts +87 -27
  18. package/src/cli/doctor.ts +132 -38
  19. package/src/cli/install.ts +58 -118
  20. package/src/cli/models.ts +21 -1
  21. package/src/cli/profile.ts +48 -77
  22. package/src/cli/router.ts +32 -6
  23. package/src/cli/update.ts +43 -6
  24. package/src/data/gitignore-defaults.ts +3 -3
  25. package/src/services/active-closure.ts +3 -3
  26. package/src/services/conventions-manager.ts +88 -0
  27. package/src/services/git-worktree.ts +21 -0
  28. package/src/services/manifest.ts +4 -5
  29. package/src/services/models-manager.ts +36 -73
  30. package/src/services/profile-absorb.ts +1532 -0
  31. package/src/services/profile-activate.ts +348 -96
  32. package/src/services/profile-live.ts +825 -0
  33. package/src/services/profile-render.ts +115 -0
  34. package/src/services/profile-skills.ts +462 -0
  35. package/src/services/profile-startup.ts +88 -16
  36. package/src/services/profile-state.ts +622 -0
  37. package/src/services/profiles.ts +20 -32
  38. package/src/services/resolver.ts +6 -1
  39. package/src/services/settings-file.ts +5 -5
  40. package/src/services/skills-manager.ts +14 -8
  41. package/src/services/styles-manager.ts +45 -28
  42. package/src/types/index.ts +15 -9
  43. package/src/ui/App.tsx +25 -2
  44. package/src/ui/hooks/useProfileStartupModal.ts +20 -15
  45. package/src/ui/screens/ProfilesScreen.tsx +75 -27
  46. package/src/ui/screens/StylesScreen.tsx +2 -1
  47. package/src/ui/state/types.ts +1 -1
  48. package/src/__tests__/profile-materializer.test.ts +0 -238
  49. package/src/__tests__/profile-sync.test.ts +0 -154
  50. package/src/__tests__/symlink-manager.test.ts +0 -180
  51. package/src/services/profile-materializer.ts +0 -133
  52. package/src/services/profile-sync.ts +0 -158
  53. package/src/services/symlink-manager.ts +0 -343
@@ -0,0 +1,795 @@
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 { profileGate } from "../cli/bootstrap.js";
13
+ import { reportProfileState, runDoctorCommand } from "../cli/doctor.js";
14
+ import { runModelsCommand } from "../cli/models.js";
15
+ import { readManifest, writeManifest } from "../services/manifest.js";
16
+ import type { ModelsConfig } from "../services/models-core.js";
17
+ import {
18
+ activateProfile,
19
+ activationNotes,
20
+ trackedGeneratedFiles,
21
+ } from "../services/profile-activate.js";
22
+ import {
23
+ activeMarkerPath,
24
+ activeProfile,
25
+ backupRoot,
26
+ readActiveMarker,
27
+ sha256,
28
+ writeActiveMarker,
29
+ } from "../services/profile-live.js";
30
+ import { renderProfile } from "../services/profile-render.js";
31
+ import {
32
+ checkActiveProfile,
33
+ prepareProfileState,
34
+ switchToProfile,
35
+ } from "../services/profile-state.js";
36
+ import { applyUserProfile } from "../services/profiles.js";
37
+ import type { ProfileManifest } from "../types/index.js";
38
+
39
+ const MANIFEST: ProfileManifest = {
40
+ version: 2,
41
+ profiles: {
42
+ team: { name: "Team", plugins: { "dev@magus": "latest" } },
43
+ other: { name: "Other", plugins: { "terminal@magus": "latest" } },
44
+ },
45
+ };
46
+
47
+ const ROUTING: ModelsConfig = {
48
+ version: 1,
49
+ preset: "test",
50
+ main: { model: "opus", effort: "medium" },
51
+ grades: {
52
+ smart: { model: "fable", effort: "xhigh" },
53
+ normal: { model: "opus", effort: "medium" },
54
+ cheap: { model: "sonnet", effort: "low" },
55
+ },
56
+ agents: { "dev:architect": "smart" },
57
+ fallback: "normal",
58
+ };
59
+
60
+ let project: string;
61
+ let wasTTY: boolean | undefined;
62
+ let log: ReturnType<typeof spyOn>;
63
+ let err: ReturnType<typeof spyOn>;
64
+
65
+ const settingsPath = () => join(project, ".claude", "settings.json");
66
+ const printed = () =>
67
+ [...log.mock.calls, ...err.mock.calls].map((c) => String(c[0])).join("\n");
68
+
69
+ beforeEach(async () => {
70
+ project = await mkdtemp(join(tmpdir(), "state-"));
71
+ await writeManifest(MANIFEST, project);
72
+ wasTTY = process.stdin.isTTY;
73
+ Object.defineProperty(process.stdin, "isTTY", {
74
+ value: false,
75
+ configurable: true,
76
+ });
77
+ log = spyOn(console, "log").mockImplementation(() => {});
78
+ err = spyOn(console, "error").mockImplementation(() => {});
79
+ });
80
+ afterEach(async () => {
81
+ log.mockRestore();
82
+ err.mockRestore();
83
+ Object.defineProperty(process.stdin, "isTTY", {
84
+ value: wasTTY,
85
+ configurable: true,
86
+ });
87
+ await rm(project, { recursive: true, force: true });
88
+ });
89
+
90
+ describe("strict active-profile check", () => {
91
+ test("a marker naming a defined profile is active", async () => {
92
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
93
+ expect(await checkActiveProfile(project)).toEqual({
94
+ state: "active",
95
+ profile: "team",
96
+ restored: false,
97
+ });
98
+ });
99
+
100
+ test("a marker naming a profile profiles.json lacks is an error naming the fix", async () => {
101
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
102
+ await writeManifest(
103
+ { version: 2, profiles: { other: MANIFEST.profiles.other as never } },
104
+ project,
105
+ );
106
+
107
+ expect(await checkActiveProfile(project)).toEqual({
108
+ state: "unknown",
109
+ profile: "team",
110
+ available: ["other"],
111
+ // The only profile left is the one to suggest.
112
+ suggested: "other",
113
+ });
114
+ expect(await activeProfile(project)).toBeNull();
115
+
116
+ // magus must not act on profile state...
117
+ expect(await profileGate(project)).toBe(false);
118
+ expect(printed()).toContain('recorded as "team"');
119
+ expect(printed()).toContain("`magus profile switch other`");
120
+ expect(printed()).not.toContain("<other>");
121
+ // ...except the command that fixes it.
122
+ expect(await profileGate(project, { allowUnknown: true })).toBe(true);
123
+ });
124
+
125
+ test("no marker, live config equal to exactly one profile: marker restored", async () => {
126
+ const rendered = await renderProfile(MANIFEST, "other");
127
+ await fs.outputJson(settingsPath(), rendered.settings);
128
+ await fs.writeJson(join(project, ".mcp.json"), rendered.mcp);
129
+
130
+ const prep = await prepareProfileState(project);
131
+
132
+ expect(prep.check).toEqual({
133
+ state: "active",
134
+ profile: "other",
135
+ restored: false,
136
+ });
137
+ expect(prep.lines[0]).toContain('matches profile "other" exactly');
138
+ expect((await readActiveMarker(project))?.profile).toBe("other");
139
+ expect(await activeProfile(project)).toBe("other");
140
+ });
141
+
142
+ test("no marker, live config matching no profile: non-interactive CLI stops with the instruction", async () => {
143
+ await fs.outputJson(settingsPath(), { enabledPlugins: { "x@y": true } });
144
+
145
+ expect(await checkActiveProfile(project)).toEqual({
146
+ state: "unmarked",
147
+ available: ["team", "other"],
148
+ liveFiles: true,
149
+ // Two profiles and no declared default: nothing to suggest.
150
+ suggested: null,
151
+ match: null,
152
+ });
153
+ // A command that writes profile state refuses rather than hanging.
154
+ expect(await runModelsCommand(["off"], project)).toBe(1);
155
+ expect(printed()).toContain(
156
+ "`magus profile switch <name>` (profiles: team, other)",
157
+ );
158
+ expect(await readActiveMarker(project)).toBeNull();
159
+ });
160
+
161
+ test("no manifest profiles at all is its own state", async () => {
162
+ await writeManifest({ version: 2, profiles: {} }, project);
163
+ expect(await checkActiveProfile(project)).toEqual({ state: "no-profiles" });
164
+ });
165
+ });
166
+
167
+ /** The profile directory an older layout's links point into. */
168
+ const oldDir = () => join(project, ".claude", "_profiles", "team");
169
+
170
+ /** Every `.claude/.magus-backups/*-layout` directory. */
171
+ async function layoutBackups(): Promise<string[]> {
172
+ try {
173
+ return (await readdir(backupRoot(project))).filter((d) =>
174
+ d.endsWith("-layout"),
175
+ );
176
+ } catch {
177
+ return [];
178
+ }
179
+ }
180
+
181
+ describe("older layouts: Claude Code's edits are kept, the old directory is backed up", () => {
182
+ // magus 7.4.1: settings.json, .mcp.json and skills are links into
183
+ // `.claude/_profiles/team/`, and there is no marker. Claude Code before
184
+ // 2.1.280 wrote its edits THROUGH the links — they are in `_profiles/`.
185
+ async function stage741(): Promise<void> {
186
+ await fs.ensureDir(join(oldDir(), "skills"));
187
+ await fs.outputJson(join(oldDir(), "settings.json"), {
188
+ enabledPlugins: { "dev@magus": true, "code-search@magus": true },
189
+ permissions: { allow: ["Bash(bun test:*)"] },
190
+ });
191
+ await fs.outputJson(join(oldDir(), "mcp.json"), {
192
+ mcpServers: { db: { command: "pg-mcp" } },
193
+ });
194
+ await fs.symlink(
195
+ "_profiles/team/settings.json",
196
+ join(project, ".claude", "settings.json"),
197
+ );
198
+ await fs.symlink(
199
+ ".claude/_profiles/team/mcp.json",
200
+ join(project, ".mcp.json"),
201
+ );
202
+ await fs.symlink(
203
+ "_profiles/team/skills",
204
+ join(project, ".claude", "skills"),
205
+ );
206
+ }
207
+
208
+ test("7.4.1 layout: a plugin, an allow rule and an MCP server written through the links survive conversion", async () => {
209
+ await stage741();
210
+ expect((await checkActiveProfile(project)).state).toBe("old-layout");
211
+
212
+ const prep = await prepareProfileState(project);
213
+
214
+ expect(prep.problems).toEqual([]);
215
+ expect(prep.lines).toContain(
216
+ 'Changes Claude Code made under the older layout were added to profile "team"',
217
+ );
218
+ expect(prep.lines).toContain('Added code-search@magus to profile "team"');
219
+ expect(prep.lines).toContain(
220
+ 'Converted profile "team" from the older layout to real files',
221
+ );
222
+ // In profiles.json...
223
+ const team = (await readManifest(project)).profiles.team;
224
+ expect(team?.plugins).toEqual({
225
+ "dev@magus": "latest",
226
+ "code-search@magus": "latest",
227
+ });
228
+ expect(team?.settings).toEqual({
229
+ permissions: { allow: ["Bash(bun test:*)"] },
230
+ });
231
+ expect(team?.mcpServers).toEqual({ db: { command: "pg-mcp" } });
232
+ // ...and in the live files, which are real files now.
233
+ for (const rel of [
234
+ ".claude/settings.json",
235
+ ".mcp.json",
236
+ ".claude/skills",
237
+ ]) {
238
+ expect((await lstat(join(project, rel))).isSymbolicLink()).toBe(false);
239
+ }
240
+ const live = await fs.readJson(settingsPath());
241
+ expect(live.enabledPlugins["code-search@magus"]).toBe(true);
242
+ expect(live.permissions).toEqual({ allow: ["Bash(bun test:*)"] });
243
+ expect(await fs.readJson(join(project, ".mcp.json"))).toEqual({
244
+ mcpServers: { db: { command: "pg-mcp" } },
245
+ });
246
+ // The old directory was moved, not deleted, and the run said where.
247
+ expect(await fs.pathExists(join(project, ".claude", "_profiles"))).toBe(
248
+ false,
249
+ );
250
+ const kept = await layoutBackups();
251
+ expect(kept).toHaveLength(1);
252
+ const keptDir = join(backupRoot(project), kept[0] as string);
253
+ expect(await fs.readJson(join(keptDir, "team", "settings.json"))).toEqual({
254
+ enabledPlugins: { "dev@magus": true, "code-search@magus": true },
255
+ permissions: { allow: ["Bash(bun test:*)"] },
256
+ });
257
+ expect(prep.lines.join("\n")).toContain(
258
+ `was kept at .claude/.magus-backups/${kept[0]}/`,
259
+ );
260
+ expect(await readdir(join(project, ".claude", "profiles"))).toEqual([
261
+ "active.json",
262
+ ]);
263
+ });
264
+
265
+ test("the release before (7b330366): real files, marker at _profiles/.active, symlinked models.json", async () => {
266
+ const withRouting = structuredClone(MANIFEST);
267
+ (withRouting.profiles.team as { models?: unknown }).models = ROUTING;
268
+ await writeManifest(withRouting, project);
269
+ const rendered = await renderProfile(withRouting, "team");
270
+ // Real settings.json with a Claude Code edit; an unedited .mcp.json.
271
+ const edited = structuredClone(rendered.settings);
272
+ (edited.enabledPlugins as Record<string, boolean>)["code-search@magus"] =
273
+ true;
274
+ edited.permissions = { allow: ["Read"] };
275
+ await fs.outputFile(settingsPath(), JSON.stringify(edited));
276
+ const mcpBytes = `${JSON.stringify(rendered.mcp, null, 2)}\n`;
277
+ await fs.writeFile(join(project, ".mcp.json"), mcpBytes);
278
+ // models.json and skills were still links; a hand edit went through.
279
+ await fs.ensureDir(join(oldDir(), "skills"));
280
+ const editedRouting = structuredClone(ROUTING);
281
+ editedRouting.agents = { "dev:developer": "cheap" };
282
+ await fs.outputJson(join(oldDir(), "models.json"), editedRouting);
283
+ await fs.symlink(
284
+ "_profiles/team/models.json",
285
+ join(project, ".claude", "models.json"),
286
+ );
287
+ await fs.symlink(
288
+ "_profiles/team/skills",
289
+ join(project, ".claude", "skills"),
290
+ );
291
+ await fs.outputJson(join(project, ".claude", "_profiles", ".active"), {
292
+ version: 1,
293
+ profile: "team",
294
+ files: { settings: "stale", mcp: sha256(mcpBytes) },
295
+ });
296
+
297
+ const prep = await prepareProfileState(project);
298
+
299
+ expect(prep.problems).toEqual([]);
300
+ expect(prep.lines).toContain('Added code-search@magus to profile "team"');
301
+ const team = (await readManifest(project)).profiles.team;
302
+ expect(team?.plugins?.["code-search@magus"]).toBe("latest");
303
+ expect(team?.settings).toEqual({ permissions: { allow: ["Read"] } });
304
+ // The routing file disagrees with profiles.json: profiles.json's value
305
+ // stands, the key is named, and the older copy is in the backup.
306
+ expect(team?.models?.agents).toEqual({ "dev:architect": "smart" });
307
+ expect(prep.lines).toContain(
308
+ '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/',
309
+ );
310
+ const live = await fs.readJson(settingsPath());
311
+ expect(live.enabledPlugins["code-search@magus"]).toBe(true);
312
+ expect(live.permissions).toEqual({ allow: ["Read"] });
313
+ expect(
314
+ (await lstat(join(project, ".claude", "models.json"))).isSymbolicLink(),
315
+ ).toBe(false);
316
+ expect(
317
+ (await fs.readJson(join(project, ".claude", "models.json"))).agents,
318
+ ).toEqual({ "dev:architect": "smart" });
319
+ expect(
320
+ (await lstat(join(project, ".claude", "skills"))).isDirectory(),
321
+ ).toBe(true);
322
+ expect(await fs.pathExists(join(project, ".claude", "_profiles"))).toBe(
323
+ false,
324
+ );
325
+ const kept = await layoutBackups();
326
+ expect(kept).toHaveLength(1);
327
+ expect(
328
+ await fs.pathExists(
329
+ join(backupRoot(project), kept[0] as string, ".active"),
330
+ ),
331
+ ).toBe(true);
332
+ expect(await fs.pathExists(activeMarkerPath(project))).toBe(true);
333
+ });
334
+
335
+ test("reviewer r8: a teammate's change pulled after the last 7.4.1 install is not reverted", async () => {
336
+ // profiles.json NOW, after a pull: a teammate added a plugin, a deny rule
337
+ // and an MCP server to "team", and removed z@magus from "other".
338
+ const pulled: ProfileManifest = {
339
+ version: 2,
340
+ profiles: {
341
+ team: {
342
+ name: "Team",
343
+ plugins: { "dev@magus": "latest", "new-p@magus": "latest" },
344
+ settings: {
345
+ permissions: {
346
+ allow: ["Bash(bun test:*)"],
347
+ deny: ["Bash(rm -rf:*)"],
348
+ },
349
+ },
350
+ mcpServers: { team: { command: "team-mcp" } },
351
+ },
352
+ other: { name: "Other", plugins: { "terminal@magus": "latest" } },
353
+ },
354
+ };
355
+ await writeManifest(pulled, project);
356
+ // The 7.4.1 files, rendered from the OLD entry, plus Claude Code's edits.
357
+ await fs.ensureDir(join(oldDir(), "skills"));
358
+ await fs.outputJson(join(oldDir(), "settings.json"), {
359
+ enabledPlugins: {
360
+ "dev@magus": true,
361
+ "terminal@magus": false,
362
+ "z@magus": false,
363
+ "code-search@magus": true,
364
+ },
365
+ permissions: { allow: ["Bash(bun test:*)", "Bash(ls)"] },
366
+ });
367
+ await fs.outputJson(join(oldDir(), "mcp.json"), { mcpServers: {} });
368
+ // 7.4.1 materialized every profile: "other"'s render still names z@magus.
369
+ await fs.outputJson(
370
+ join(project, ".claude", "_profiles", "other", "settings.json"),
371
+ {
372
+ enabledPlugins: {
373
+ "dev@magus": false,
374
+ "terminal@magus": true,
375
+ "z@magus": true,
376
+ },
377
+ },
378
+ );
379
+ await fs.symlink(
380
+ "_profiles/team/settings.json",
381
+ join(project, ".claude", "settings.json"),
382
+ );
383
+ await fs.symlink(
384
+ ".claude/_profiles/team/mcp.json",
385
+ join(project, ".mcp.json"),
386
+ );
387
+ await fs.symlink(
388
+ "_profiles/team/skills",
389
+ join(project, ".claude", "skills"),
390
+ );
391
+
392
+ const prep = await prepareProfileState(project);
393
+
394
+ const team = (await readManifest(project)).profiles.team;
395
+ // The teammate's additions all stand...
396
+ expect(team?.plugins).toEqual({
397
+ "dev@magus": "latest",
398
+ "new-p@magus": "latest",
399
+ "code-search@magus": "latest",
400
+ });
401
+ expect(team?.settings).toEqual({
402
+ permissions: {
403
+ allow: ["Bash(bun test:*)", "Bash(ls)"],
404
+ deny: ["Bash(rm -rf:*)"],
405
+ },
406
+ });
407
+ expect(team?.mcpServers).toEqual({ team: { command: "team-mcp" } });
408
+ // ...no stale `false` is written, and nothing is blamed on Claude Code
409
+ // that it did not add.
410
+ expect(team?.plugins?.["z@magus"]).toBeUndefined();
411
+ const lines = prep.lines.join("\n");
412
+ expect(lines).not.toContain("Removed new-p@magus");
413
+ expect(lines).not.toContain("Disabled z@magus");
414
+ expect(lines).toContain('Added code-search@magus to profile "team"');
415
+ // Removals are not applied, and the run says so, once.
416
+ expect(
417
+ prep.lines.filter((l) =>
418
+ l.includes("a removal made under the older layout is not applied"),
419
+ ),
420
+ ).toHaveLength(1);
421
+ const live = await fs.readJson(settingsPath());
422
+ expect(live.enabledPlugins["new-p@magus"]).toBe(true);
423
+ expect(live.permissions.deny).toEqual(["Bash(rm -rf:*)"]);
424
+ });
425
+
426
+ test("where the old file and profiles.json hold different values, profiles.json's stands and the key is named", async () => {
427
+ await writeManifest(
428
+ {
429
+ version: 2,
430
+ profiles: {
431
+ team: {
432
+ name: "Team",
433
+ plugins: { "dev@magus": "latest" },
434
+ settings: {
435
+ model: "opus",
436
+ permissions: { defaultMode: "plan" },
437
+ },
438
+ mcpServers: { db: { command: "pg-mcp", args: ["--new"] } },
439
+ },
440
+ },
441
+ },
442
+ project,
443
+ );
444
+ await stage741();
445
+ await fs.outputJson(join(oldDir(), "settings.json"), {
446
+ enabledPlugins: { "dev@magus": true },
447
+ model: "sonnet",
448
+ permissions: { defaultMode: "acceptEdits", allow: ["Read"] },
449
+ });
450
+ await fs.outputJson(join(oldDir(), "mcp.json"), {
451
+ mcpServers: { db: { command: "pg-mcp", args: ["--old"] } },
452
+ });
453
+
454
+ const prep = await prepareProfileState(project);
455
+
456
+ const team = (await readManifest(project)).profiles.team;
457
+ expect(team?.settings).toEqual({
458
+ model: "opus",
459
+ permissions: { defaultMode: "plan", allow: ["Read"] },
460
+ });
461
+ expect(team?.mcpServers).toEqual({
462
+ db: { command: "pg-mcp", args: ["--new"] },
463
+ });
464
+ for (const at of [
465
+ "settings.model",
466
+ "settings.permissions.defaultMode",
467
+ "mcpServers.db",
468
+ ]) {
469
+ expect(prep.lines).toContain(
470
+ `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/`,
471
+ );
472
+ }
473
+ // The older copy really is in the backup.
474
+ const kept = await layoutBackups();
475
+ expect(
476
+ (
477
+ await fs.readJson(
478
+ join(backupRoot(project), kept[0] as string, "team", "settings.json"),
479
+ )
480
+ ).model,
481
+ ).toBe("sonnet");
482
+ });
483
+
484
+ /** A 7.4.1 layout where "team" is live and "other"'s dir holds its render. */
485
+ async function stage741WithOther(
486
+ teamSettings: Record<string, unknown>,
487
+ otherSettings: Record<string, unknown>,
488
+ ): Promise<void> {
489
+ await fs.ensureDir(join(oldDir(), "skills"));
490
+ await fs.outputJson(join(oldDir(), "settings.json"), teamSettings);
491
+ await fs.outputJson(join(oldDir(), "mcp.json"), { mcpServers: {} });
492
+ await fs.outputJson(
493
+ join(project, ".claude", "_profiles", "other", "settings.json"),
494
+ otherSettings,
495
+ );
496
+ await fs.symlink(
497
+ "_profiles/team/settings.json",
498
+ join(project, ".claude", "settings.json"),
499
+ );
500
+ await fs.symlink(
501
+ ".claude/_profiles/team/mcp.json",
502
+ join(project, ".mcp.json"),
503
+ );
504
+ await fs.symlink(
505
+ "_profiles/team/skills",
506
+ join(project, ".claude", "skills"),
507
+ );
508
+ }
509
+
510
+ test("reviewer r12c: a user's own disable of a plugin no profile named is kept", async () => {
511
+ await stage741WithOther(
512
+ {
513
+ enabledPlugins: {
514
+ "dev@magus": true,
515
+ "terminal@magus": false,
516
+ "noisy@user-mkt": false,
517
+ },
518
+ },
519
+ { enabledPlugins: { "dev@magus": false, "terminal@magus": true } },
520
+ );
521
+
522
+ const prep = await prepareProfileState(project);
523
+
524
+ expect(prep.lines).toContain('Disabled noisy@user-mkt in profile "team"');
525
+ const team = (await readManifest(project)).profiles.team;
526
+ expect(team?.plugins).toEqual({
527
+ "dev@magus": "latest",
528
+ "noisy@user-mkt": false,
529
+ });
530
+ // terminal@magus: false was the render's own output — not added.
531
+ expect(team?.plugins?.["terminal@magus"]).toBeUndefined();
532
+ const live = await fs.readJson(settingsPath());
533
+ expect(live.enabledPlugins["noisy@user-mkt"]).toBe(false);
534
+ });
535
+
536
+ test("a false the older render wrote — the id is in another profile's old file — is skipped", async () => {
537
+ // gone@magus was in "other" at the 7.4.1 render and has since been
538
+ // removed from profiles.json, so the current manifest does not name it.
539
+ await stage741WithOther(
540
+ { enabledPlugins: { "dev@magus": true, "gone@magus": false } },
541
+ {
542
+ enabledPlugins: {
543
+ "dev@magus": false,
544
+ "terminal@magus": true,
545
+ "gone@magus": true,
546
+ },
547
+ },
548
+ );
549
+
550
+ const prep = await prepareProfileState(project);
551
+
552
+ expect(prep.lines.join("\n")).not.toContain("gone@magus");
553
+ expect((await readManifest(project)).profiles.team?.plugins).toEqual({
554
+ "dev@magus": "latest",
555
+ });
556
+ expect(
557
+ (await fs.readJson(settingsPath())).enabledPlugins["gone@magus"],
558
+ ).toBeUndefined();
559
+ });
560
+
561
+ test("an older layout file that cannot be read stops the conversion, and nothing moves", async () => {
562
+ await stage741();
563
+ const broken = '{ "enabledPlugins": { "dev@magus": true, } }';
564
+ await fs.writeFile(join(oldDir(), "settings.json"), broken);
565
+
566
+ const prep = await prepareProfileState(project);
567
+
568
+ expect(prep.problems).toHaveLength(1);
569
+ expect(prep.problems[0]).toContain("did not convert it");
570
+ expect(
571
+ (await lstat(join(project, ".claude", "settings.json"))).isSymbolicLink(),
572
+ ).toBe(true);
573
+ expect(await fs.readFile(join(oldDir(), "settings.json"), "utf8")).toBe(
574
+ broken,
575
+ );
576
+ expect(await layoutBackups()).toEqual([]);
577
+ });
578
+
579
+ test("magus doctor without --fix converts nothing and writes nothing", async () => {
580
+ await stage741();
581
+ const snapshot = async () => ({
582
+ manifest: await fs.readFile(
583
+ join(project, ".claude", "profiles.json"),
584
+ "utf8",
585
+ ),
586
+ settingsIsLink: (
587
+ await lstat(join(project, ".claude", "settings.json"))
588
+ ).isSymbolicLink(),
589
+ old: await fs.readFile(join(oldDir(), "settings.json"), "utf8"),
590
+ claude: (await readdir(join(project, ".claude"))).sort(),
591
+ });
592
+ const before = await snapshot();
593
+ const cwd = process.cwd();
594
+ const home = await mkdtemp(join(tmpdir(), "state-doctor-home-"));
595
+ const saved = {
596
+ HOME: process.env.HOME,
597
+ CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
598
+ };
599
+ process.env.HOME = home;
600
+ process.env.CLAUDE_CONFIG_DIR = join(home, ".claude");
601
+ process.chdir(project);
602
+ try {
603
+ await runDoctorCommand([]);
604
+ } finally {
605
+ process.chdir(cwd);
606
+ process.env.HOME = saved.HOME;
607
+ if (saved.CLAUDE_CONFIG_DIR === undefined) {
608
+ // biome-ignore lint/performance/noDelete: an unset env var must be absent, not "undefined"
609
+ delete process.env.CLAUDE_CONFIG_DIR;
610
+ } else process.env.CLAUDE_CONFIG_DIR = saved.CLAUDE_CONFIG_DIR;
611
+ await rm(home, { recursive: true, force: true });
612
+ }
613
+
614
+ expect(await snapshot()).toEqual(before);
615
+ expect(printed()).toContain("is on an older magus layout");
616
+ expect(await fs.pathExists(activeMarkerPath(project))).toBe(false);
617
+ });
618
+
619
+ test("doctor without --fix: no marker, live config matching one profile — one line, not an error", async () => {
620
+ const rendered = await renderProfile(MANIFEST, "other");
621
+ await fs.outputJson(settingsPath(), rendered.settings);
622
+ await fs.writeJson(join(project, ".mcp.json"), rendered.mcp);
623
+ // The profile section alone: doctor's other sections read this machine's
624
+ // real plugin registry, so the whole command's exit code would test the
625
+ // machine. This section's counts are what it adds to that exit code.
626
+ const counts = await reportProfileState(project, false);
627
+
628
+ const out = printed();
629
+ expect(out).toContain(
630
+ 'No active-profile marker, but the live config matches profile "other" exactly',
631
+ );
632
+ // The contradicting line is gone, and so is the failure.
633
+ expect(out).not.toContain("matches no single profile");
634
+ expect(counts).toEqual({ fixable: 0, unfixable: 0 });
635
+ // Report only: the marker is not restored.
636
+ expect(await readActiveMarker(project)).toBeNull();
637
+ });
638
+
639
+ test("a directory under .claude/profiles/ that is not magus's survives (reviewer S4)", async () => {
640
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
641
+ await fs.outputFile(
642
+ join(project, ".claude", "profiles", "notes", "README.md"),
643
+ "not magus's",
644
+ );
645
+
646
+ const prep = await prepareProfileState(project);
647
+
648
+ expect(prep.lines).toEqual([]);
649
+ expect(
650
+ await fs.readFile(
651
+ join(project, ".claude", "profiles", "notes", "README.md"),
652
+ "utf8",
653
+ ),
654
+ ).toBe("not magus's");
655
+ expect((await checkActiveProfile(project)).state).toBe("active");
656
+ });
657
+ });
658
+
659
+ function git(cwd: string, ...args: string[]) {
660
+ return spawnSync(
661
+ "git",
662
+ [
663
+ "-c",
664
+ "user.email=test@example.com",
665
+ "-c",
666
+ "user.name=test",
667
+ "-c",
668
+ "commit.gpgsign=false",
669
+ ...args,
670
+ ],
671
+ { cwd, encoding: "utf8" },
672
+ );
673
+ }
674
+
675
+ describe("generated files git still tracks", () => {
676
+ test("activation reports a committed settings.json with the git rm --cached fix", async () => {
677
+ expect(git(project, "init", "-q").status).toBe(0);
678
+ await fs.outputJson(settingsPath(), { committed: true });
679
+ git(project, "add", ".claude/settings.json");
680
+ expect(git(project, "commit", "-q", "-m", "init").status).toBe(0);
681
+
682
+ const result = await activateProfile(MANIFEST, "team", project, {
683
+ onDisplace: () => {},
684
+ });
685
+
686
+ expect(result.tracked).toEqual([".claude/settings.json"]);
687
+ expect(activationNotes(result)).toContain(
688
+ ".claude/settings.json is generated but tracked by git — run `git rm --cached .claude/settings.json`, then commit",
689
+ );
690
+ // Report only: nothing was untracked.
691
+ expect(
692
+ git(project, "ls-files", "--error-unmatch", ".claude/settings.json")
693
+ .status,
694
+ ).toBe(0);
695
+ });
696
+
697
+ test("committed custom skills are not reported", async () => {
698
+ expect(git(project, "init", "-q").status).toBe(0);
699
+ await fs.outputFile(
700
+ join(project, ".claude", "skills", "mine", "SKILL.md"),
701
+ "# mine\n",
702
+ );
703
+ git(project, "add", ".");
704
+ expect(git(project, "commit", "-q", "-m", "init").status).toBe(0);
705
+
706
+ expect(await trackedGeneratedFiles(project)).toEqual([]);
707
+ });
708
+
709
+ test("outside a git repository nothing is reported", async () => {
710
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
711
+ expect(await trackedGeneratedFiles(project)).toEqual([]);
712
+ });
713
+ });
714
+
715
+ describe("the gate is idempotent", () => {
716
+ test("a second pass with nothing changed prints nothing", async () => {
717
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
718
+ const marker = await readActiveMarker(project);
719
+ if (marker) await writeActiveMarker(marker, project);
720
+ const prep = await prepareProfileState(project);
721
+ expect(prep.lines).toEqual([]);
722
+ expect(prep.problems).toEqual([]);
723
+ });
724
+ });
725
+
726
+ describe("applying a profile from the TUI Profiles tab", () => {
727
+ test("a project profile goes through the switch path: absorb into the old, activate the new", async () => {
728
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
729
+ // Claude Code installs a plugin while "team" is active.
730
+ const live = await fs.readJson(settingsPath());
731
+ live.enabledPlugins["code-search@magus"] = true;
732
+ await fs.writeFile(settingsPath(), JSON.stringify(live));
733
+
734
+ const result = await switchToProfile(project, "other", {
735
+ onDisplace: () => {},
736
+ });
737
+
738
+ expect(result.problems).toEqual([]);
739
+ expect(result.lines).toEqual(['Added code-search@magus to profile "team"']);
740
+ expect(result.activation?.profileId).toBe("other");
741
+ expect(result.activation?.displaced).toEqual([]);
742
+ // The edit was kept in the profile it was made under...
743
+ const manifest = await readManifest(project);
744
+ expect(manifest.profiles.team?.plugins?.["code-search@magus"]).toBe(
745
+ "latest",
746
+ );
747
+ // ...and the live file is "other", rendered from the manifest — not a
748
+ // copy of its plugins spliced into whatever was there.
749
+ expect(await fs.readJson(settingsPath())).toEqual(
750
+ (await renderProfile(manifest, "other")).settings,
751
+ );
752
+ expect((await readActiveMarker(project))?.profile).toBe("other");
753
+ expect(await fs.pathExists(backupRoot(project))).toBe(false);
754
+ });
755
+
756
+ test("an unknown target changes nothing and says why", async () => {
757
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
758
+ const before = await fs.readFile(settingsPath(), "utf8");
759
+
760
+ const result = await switchToProfile(project, "nope");
761
+
762
+ expect(result.activation).toBeNull();
763
+ expect(result.problems).toEqual([
764
+ 'Profile "nope" not found. Available: team, other',
765
+ ]);
766
+ expect(await fs.readFile(settingsPath(), "utf8")).toBe(before);
767
+ expect((await readActiveMarker(project))?.profile).toBe("team");
768
+ });
769
+
770
+ test("a user-scope profile writes ~/.claude/settings.json and never the project's live files", async () => {
771
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
772
+ const beforeSettings = await fs.readFile(settingsPath(), "utf8");
773
+ const beforeMarker = await fs.readFile(activeMarkerPath(project), "utf8");
774
+ const home = await mkdtemp(join(tmpdir(), "state-home-"));
775
+ const previousHome = process.env.HOME;
776
+ process.env.HOME = home;
777
+ try {
778
+ await applyUserProfile({
779
+ plugins: { "dev@magus": "latest", "off@elsewhere": false },
780
+ });
781
+ expect(await fs.readJson(join(home, ".claude", "settings.json"))).toEqual(
782
+ {
783
+ enabledPlugins: { "dev@magus": true, "off@elsewhere": false },
784
+ },
785
+ );
786
+ } finally {
787
+ process.env.HOME = previousHome;
788
+ await rm(home, { recursive: true, force: true });
789
+ }
790
+ expect(await fs.readFile(settingsPath(), "utf8")).toBe(beforeSettings);
791
+ expect(await fs.readFile(activeMarkerPath(project), "utf8")).toBe(
792
+ beforeMarker,
793
+ );
794
+ });
795
+ });