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
@@ -1,49 +1,165 @@
1
1
  /**
2
- * Migration = adopt AND activate, without destroying what it replaces.
2
+ * Activation writes the live config directly, and backs up only a genuine
3
+ * pre-magus hand-written file at first adoption.
3
4
  *
4
- * REGRESSION: adopting a project wrote `.claude/profiles.json` and stopped, so
5
- * `activeProfile()` still returned null and every later run asked which profile
6
- * to use. The manifest changed; nothing the user could feel did. Fixed in
7
- * /dev:fix session dev-fix-20260918-migration.
5
+ * REGRESSION (dev-fix-20260918-migration): adopting a project wrote
6
+ * `.claude/profiles.json` and stopped, so `activeProfile()` still returned null
7
+ * and every later run asked which profile to use.
8
8
  *
9
- * The second half is the reason activation was left out in the first place:
10
- * activating replaces four real paths with symlinks, and the replacement used
11
- * `fs.remove` with no backup. A migration that loses a hand-written
12
- * `.claude/settings.json` is worse than one that does nothing.
9
+ * REGRESSION (this change): once a project was magus-managed, a Claude Code
10
+ * edit to `.claude/settings.json` was moved into `.magus-backups/` on the next
11
+ * switch, silently disabling the plugin it had just installed. A managed
12
+ * project's live files are the active profile's — they are absorbed, never
13
+ * backed up.
13
14
  */
14
15
 
15
16
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
16
- import { lstat, mkdtemp, rm } from "node:fs/promises";
17
+ import { lstat, mkdtemp, readdir, rm } from "node:fs/promises";
17
18
  import { tmpdir } from "node:os";
18
19
  import { join } from "node:path";
19
20
  import fs from "fs-extra";
20
- import { materializeAndActivate } from "../services/profile-activate.js";
21
+ import { writeManifest } from "../services/manifest.js";
22
+ import { activateProfile } from "../services/profile-activate.js";
21
23
  import { adoptCurrentProject } from "../services/profile-adopt.js";
22
24
  import {
23
- activateProfile,
25
+ activeMarkerPath,
24
26
  activeProfile,
25
27
  backupRoot,
26
- profileDir,
27
28
  profilesRoot,
28
- } from "../services/symlink-manager.js";
29
+ readActiveMarker,
30
+ } from "../services/profile-live.js";
29
31
  import type { ProfileManifest } from "../types/index.js";
30
32
 
31
- /** Materialize a fake profile dir, as `magus install` would have. */
32
- async function materialize(
33
- project: string,
34
- name: string,
35
- settings: object,
36
- ): Promise<void> {
37
- const dir = profileDir(name, project);
38
- await fs.ensureDir(dir);
39
- await fs.writeJson(join(dir, "settings.json"), settings);
40
- await fs.writeJson(join(dir, "mcp.json"), { mcpServers: {} });
33
+ const MANIFEST: ProfileManifest = {
34
+ version: 2,
35
+ profiles: {
36
+ team: {
37
+ name: "Team",
38
+ plugins: { "dev@magus": "latest" },
39
+ mcpServers: { srv: { command: "srv" } },
40
+ },
41
+ other: { name: "Other", plugins: { "terminal@magus": "latest" } },
42
+ },
43
+ };
44
+
45
+ async function setup(project: string, manifest = MANIFEST): Promise<void> {
46
+ await writeManifest(manifest, project);
41
47
  }
42
48
 
43
- describe("activation never deletes a real file", () => {
49
+ describe("activation writes the live config directly", () => {
44
50
  let project: string;
45
51
  beforeEach(async () => {
46
- project = await mkdtemp(join(tmpdir(), "migrate-"));
52
+ project = await mkdtemp(join(tmpdir(), "activate-"));
53
+ await setup(project);
54
+ });
55
+ afterEach(async () => {
56
+ await rm(project, { recursive: true, force: true });
57
+ });
58
+
59
+ test("settings.json and .mcp.json are real files rendered from the manifest", async () => {
60
+ await activateProfile(MANIFEST, "team", project);
61
+
62
+ const settingsPath = join(project, ".claude", "settings.json");
63
+ const mcpPath = join(project, ".mcp.json");
64
+ expect((await lstat(settingsPath)).isFile()).toBe(true);
65
+ expect((await lstat(mcpPath)).isFile()).toBe(true);
66
+ expect(await fs.readJson(settingsPath)).toEqual({
67
+ enabledPlugins: { "dev@magus": true, "terminal@magus": false },
68
+ });
69
+ expect(await fs.readJson(mcpPath)).toEqual({
70
+ mcpServers: { srv: { command: "srv" } },
71
+ });
72
+ });
73
+
74
+ test("no per-profile directory and no copies: .claude/profiles/ holds only the marker", async () => {
75
+ await activateProfile(MANIFEST, "team", project);
76
+
77
+ expect(await readdir(profilesRoot(project))).toEqual(["active.json"]);
78
+ expect(await fs.pathExists(join(profilesRoot(project), "team"))).toBe(
79
+ false,
80
+ );
81
+ expect(
82
+ await fs.pathExists(join(profilesRoot(project), "team", "settings.json")),
83
+ ).toBe(false);
84
+ });
85
+
86
+ test("nothing generated is a symlink", async () => {
87
+ await activateProfile(
88
+ {
89
+ ...MANIFEST,
90
+ profiles: {
91
+ ...MANIFEST.profiles,
92
+ team: {
93
+ ...(MANIFEST.profiles.team as never),
94
+ models: {
95
+ version: 1,
96
+ preset: "t",
97
+ main: { model: "opus", effort: "medium" },
98
+ grades: {
99
+ smart: { model: "opus", effort: "high" },
100
+ normal: { model: "opus", effort: "medium" },
101
+ cheap: { model: "sonnet", effort: "low" },
102
+ },
103
+ agents: {},
104
+ fallback: "normal",
105
+ },
106
+ },
107
+ },
108
+ },
109
+ "team",
110
+ project,
111
+ );
112
+ for (const rel of [
113
+ ".claude/settings.json",
114
+ ".mcp.json",
115
+ ".claude/models.json",
116
+ ]) {
117
+ expect((await lstat(join(project, rel))).isSymbolicLink()).toBe(false);
118
+ }
119
+ });
120
+
121
+ test("the marker records the profile, the definition, and each file's hash", async () => {
122
+ await activateProfile(MANIFEST, "team", project);
123
+ const marker = await readActiveMarker(project);
124
+ expect(marker?.profile).toBe("team");
125
+ expect(marker?.definition).toMatch(/^[0-9a-f]{64}$/);
126
+ expect(marker?.base).toEqual(MANIFEST.profiles.team as never);
127
+ expect(Object.keys(marker?.files ?? {}).sort()).toEqual([
128
+ "mcp",
129
+ "settings",
130
+ ]);
131
+ expect(activeMarkerPath(project)).toBe(
132
+ join(project, ".claude", "profiles", "active.json"),
133
+ );
134
+ });
135
+
136
+ test("switching between profiles backs up nothing", async () => {
137
+ await activateProfile(MANIFEST, "team", project);
138
+ const second = await activateProfile(MANIFEST, "other", project);
139
+
140
+ expect(second.displaced).toEqual([]);
141
+ expect(await fs.pathExists(backupRoot(project))).toBe(false);
142
+ });
143
+
144
+ test("switching over a live file Claude Code edited backs up nothing", async () => {
145
+ await activateProfile(MANIFEST, "team", project);
146
+ const settingsPath = join(project, ".claude", "settings.json");
147
+ const live = await fs.readJson(settingsPath);
148
+ live.enabledPlugins["code-search@magus"] = true;
149
+ await fs.writeJson(settingsPath, live);
150
+
151
+ const second = await activateProfile(MANIFEST, "other", project);
152
+
153
+ expect(second.displaced).toEqual([]);
154
+ expect(await fs.pathExists(backupRoot(project))).toBe(false);
155
+ });
156
+ });
157
+
158
+ describe("first adoption keeps a hand-written file", () => {
159
+ let project: string;
160
+ beforeEach(async () => {
161
+ project = await mkdtemp(join(tmpdir(), "adopt-"));
162
+ await setup(project);
47
163
  });
48
164
  afterEach(async () => {
49
165
  await rm(project, { recursive: true, force: true });
@@ -52,14 +168,11 @@ describe("activation never deletes a real file", () => {
52
168
  test("a hand-written settings.json is moved aside, not destroyed", async () => {
53
169
  const settingsPath = join(project, ".claude", "settings.json");
54
170
  await fs.outputJson(settingsPath, { hand: "written", keep: "me" });
55
- await materialize(project, "team", { enabledPlugins: {} });
56
171
 
57
- const backups = await activateProfile("team", project);
172
+ const { displaced } = await activateProfile(MANIFEST, "team", project);
58
173
 
59
- // The link is live...
60
- expect((await lstat(settingsPath)).isSymbolicLink()).toBe(true);
61
- // ...and the file it displaced is still readable somewhere.
62
- const saved = backups.find((b) => b.link === settingsPath);
174
+ expect((await lstat(settingsPath)).isFile()).toBe(true);
175
+ const saved = displaced.find((b) => b.link === settingsPath);
63
176
  expect(saved).toBeDefined();
64
177
  expect(await fs.readJson(saved?.savedTo ?? "")).toEqual({
65
178
  hand: "written",
@@ -67,81 +180,50 @@ describe("activation never deletes a real file", () => {
67
180
  });
68
181
  });
69
182
 
70
- test("switching between profiles reports no backups", async () => {
71
- await materialize(project, "a", { enabledPlugins: {} });
72
- await materialize(project, "b", { enabledPlugins: {} });
73
-
74
- await activateProfile("a", project);
75
- // Everything at the link paths is now a symlink magus itself wrote.
76
- // Backing those up on every switch would fill the repo with copies of
77
- // generated state that the manifest can regenerate at any time.
78
- const second = await activateProfile("b", project);
79
-
80
- expect(second).toEqual([]);
81
- });
82
-
83
- test("a symlink magus does not own is preserved too", async () => {
84
- // Someone pointed .mcp.json at a shared config outside the project. It is
85
- // not generated state, so it gets the same protection a real file gets.
183
+ test("a symlink magus does not own is preserved, its target untouched", async () => {
86
184
  const outside = join(project, "shared-mcp.json");
87
185
  await fs.writeJson(outside, { mcpServers: { shared: {} } });
88
186
  await fs.symlink(outside, join(project, ".mcp.json"));
89
- await materialize(project, "team", { enabledPlugins: {} });
90
187
 
91
- const backups = await activateProfile("team", project);
188
+ const { displaced } = await activateProfile(MANIFEST, "team", project);
92
189
 
93
- expect(backups.map((b) => b.link)).toContain(join(project, ".mcp.json"));
94
- // The target was never touched — only the link moved.
190
+ expect(displaced.map((b) => b.link)).toContain(join(project, ".mcp.json"));
95
191
  expect(await fs.readJson(outside)).toEqual({ mcpServers: { shared: {} } });
96
192
  });
97
193
 
98
194
  test("a RELATIVE user symlink still resolves from the backup", async () => {
99
- // A symlink's target resolves against the directory the link sits in, so
100
- // moving the link into a backup two levels deeper silently repoints it.
101
- // Preserving a link that no longer resolves preserves nothing.
102
195
  await fs.writeJson(join(project, "shared-mcp.json"), { mcpServers: {} });
103
196
  await fs.symlink("shared-mcp.json", join(project, ".mcp.json"));
104
- await materialize(project, "team", { enabledPlugins: {} });
105
197
 
106
- const backups = await activateProfile("team", project);
107
- const saved = backups.find((b) => b.link === join(project, ".mcp.json"));
198
+ const { displaced } = await activateProfile(MANIFEST, "team", project);
199
+ const saved = displaced.find((b) => b.link === join(project, ".mcp.json"));
108
200
 
109
201
  expect(saved).toBeDefined();
110
- // readJson follows the link: if it were still relative this would throw.
111
202
  expect(await fs.readJson(saved?.savedTo ?? "")).toEqual({ mcpServers: {} });
112
203
  });
113
204
 
114
- test("backups live OUTSIDE the regenerable _profiles/ tree", async () => {
115
- // `_profiles/` is generated and documented as safe to delete. A backup kept
116
- // there is the only copy of a hand-written file, stored in the one
117
- // directory everybody is told they can remove.
205
+ test("backups live OUTSIDE the regenerable .claude/profiles/ tree", async () => {
118
206
  await fs.outputJson(join(project, ".claude", "settings.json"), { a: 1 });
119
- await materialize(project, "team", { enabledPlugins: {} });
120
207
 
121
- const backups = await activateProfile("team", project);
122
- const saved = backups[0]?.savedTo ?? "";
208
+ const { displaced } = await activateProfile(MANIFEST, "team", project);
209
+ const saved = displaced[0]?.savedTo ?? "";
123
210
 
124
211
  expect(saved.startsWith(profilesRoot(project))).toBe(false);
125
212
  expect(saved.startsWith(backupRoot(project))).toBe(true);
126
-
127
213
  await fs.remove(profilesRoot(project));
128
214
  expect(await fs.readJson(saved)).toEqual({ a: 1 });
129
215
  });
130
216
 
131
- test("every displaced artifact is announced as it moves", async () => {
132
- // The returned array is lost if a later link throws, and `magus install`
133
- // and `profile switch` discard it outright. The callback is what makes the
134
- // move visible at the moment it happens.
217
+ test("every displaced file is announced as it moves", async () => {
135
218
  await fs.outputJson(join(project, ".claude", "settings.json"), { a: 1 });
136
219
  await fs.writeJson(join(project, ".mcp.json"), { mcpServers: {} });
137
- await materialize(project, "team", { enabledPlugins: {} });
138
220
 
139
221
  const announced: string[] = [];
140
- const returned = await activateProfile("team", project, (d) =>
141
- announced.push(d.link),
142
- );
222
+ const { displaced } = await activateProfile(MANIFEST, "team", project, {
223
+ onDisplace: (d) => announced.push(d.link),
224
+ });
143
225
 
144
- expect(announced.sort()).toEqual(returned.map((d) => d.link).sort());
226
+ expect(announced.sort()).toEqual(displaced.map((d) => d.link).sort());
145
227
  expect(announced).toHaveLength(2);
146
228
  });
147
229
  });
@@ -155,20 +237,24 @@ describe("migration leaves the project ON the profile it created", () => {
155
237
  await rm(project, { recursive: true, force: true });
156
238
  });
157
239
 
158
- test("after adopting, activeProfile() names the adopted profile", async () => {
159
- await fs.outputJson(join(project, ".claude", "settings.json"), {
160
- enabledPlugins: { "dev@magus": true, "code-search@magus": true },
161
- });
162
-
240
+ async function adoptAndActivate(existing?: ProfileManifest) {
163
241
  const result = await adoptCurrentProject(
164
242
  project,
165
243
  undefined,
166
- undefined,
244
+ existing,
167
245
  "default",
168
246
  );
169
- await materializeAndActivate(result.manifest, "default", project);
247
+ await writeManifest(result.manifest, project);
248
+ await activateProfile(result.manifest, "default", project, {
249
+ onDisplace: () => {},
250
+ });
251
+ }
170
252
 
171
- // The whole point: the question "which profile?" now has an answer.
253
+ test("after adopting, activeProfile() names the adopted profile", async () => {
254
+ await fs.outputJson(join(project, ".claude", "settings.json"), {
255
+ enabledPlugins: { "dev@magus": true, "code-search@magus": true },
256
+ });
257
+ await adoptAndActivate();
172
258
  expect(await activeProfile(project)).toBe("default");
173
259
  });
174
260
 
@@ -177,16 +263,8 @@ describe("migration leaves the project ON the profile it created", () => {
177
263
  enabledPlugins: { "dev@magus": true, "code-search@magus": true },
178
264
  someOtherSetting: 42,
179
265
  });
266
+ await adoptAndActivate();
180
267
 
181
- const result = await adoptCurrentProject(
182
- project,
183
- undefined,
184
- undefined,
185
- "default",
186
- );
187
- await materializeAndActivate(result.manifest, "default", project);
188
-
189
- // Read through the live symlink — this is what Claude Code will load.
190
268
  const live = await fs.readJson(join(project, ".claude", "settings.json"));
191
269
  expect(live.enabledPlugins).toEqual({
192
270
  "dev@magus": true,
@@ -199,44 +277,95 @@ describe("migration leaves the project ON the profile it created", () => {
199
277
  await fs.outputJson(join(project, ".claude", "settings.json"), {
200
278
  enabledPlugins: { "dev@magus": true },
201
279
  });
202
- const existing: ProfileManifest = {
280
+ await adoptAndActivate({
203
281
  version: 2,
204
282
  profiles: {
205
283
  lean: { name: "Lean", plugins: { "terminal@magus": "latest" } },
206
284
  },
207
- };
208
-
209
- const result = await adoptCurrentProject(
210
- project,
211
- undefined,
212
- existing,
213
- "default",
214
- );
215
- await materializeAndActivate(result.manifest, "default", project);
285
+ });
216
286
 
217
287
  const live = await fs.readJson(join(project, ".claude", "settings.json"));
218
- // `terminal@magus` belongs to another profile, so this one must say so
219
- // explicitly — an omitted id falls through to user scope and stays on.
220
288
  expect(live.enabledPlugins["terminal@magus"]).toBe(false);
221
289
  expect(live.enabledPlugins["dev@magus"]).toBe(true);
222
290
  });
223
291
 
224
- test("the generated dir and the symlinks are gitignored", async () => {
292
+ test("the marker dir and live config are gitignored; .claude/skills is not", async () => {
225
293
  await fs.outputJson(join(project, ".claude", "settings.json"), {
226
294
  enabledPlugins: { "dev@magus": true },
227
295
  });
228
- const result = await adoptCurrentProject(
229
- project,
230
- undefined,
231
- undefined,
232
- "default",
296
+ await adoptAndActivate();
297
+
298
+ const lines = (await fs.readFile(join(project, ".gitignore"), "utf8"))
299
+ .split("\n")
300
+ .map((l) => l.trim());
301
+ expect(lines).toContain(".claude/profiles/");
302
+ expect(lines).toContain(".claude/settings.json");
303
+ expect(lines).toContain(".mcp.json");
304
+ // Custom skills in .claude/skills are committed; a blanket ignore hid them.
305
+ expect(lines).not.toContain(".claude/skills");
306
+ expect(lines).not.toContain(".claude/_profiles/");
307
+ });
308
+
309
+ test("a user's own .claude/skills/ line outside magus's block survives activation and switching", async () => {
310
+ await fs.writeFile(
311
+ join(project, ".gitignore"),
312
+ "node_modules\n# my skills are local\n.claude/skills/\n.claude/skills\n",
313
+ );
314
+ await fs.outputJson(join(project, ".claude", "settings.json"), {
315
+ enabledPlugins: { "dev@magus": true },
316
+ });
317
+ await adoptAndActivate();
318
+
319
+ const lines = (await fs.readFile(join(project, ".gitignore"), "utf8"))
320
+ .split("\n")
321
+ .map((l) => l.trim());
322
+ expect(lines).toContain("node_modules");
323
+ expect(lines).toContain(".claude/skills/");
324
+ expect(lines).toContain(".claude/skills");
325
+ expect(lines).toContain(".claude/profiles/");
326
+ });
327
+
328
+ test("converting a 7.4.1 layout removes the blanket skills line from magus's block only", async () => {
329
+ // magus 7.4.1 wrote this block; the user's own line sits elsewhere.
330
+ await fs.writeFile(
331
+ join(project, ".gitignore"),
332
+ [
333
+ "node_modules",
334
+ ".claude/skills/",
335
+ "",
336
+ "# magus: generated profile build output + active-profile config",
337
+ ".claude/_profiles/",
338
+ ".claude/.magus-backups/",
339
+ ".claude/settings.json",
340
+ ".claude/skills",
341
+ ".claude/models.json",
342
+ ".mcp.json",
343
+ "",
344
+ ].join("\n"),
345
+ );
346
+ await writeManifest(MANIFEST, project);
347
+ const old = join(project, ".claude", "_profiles", "team");
348
+ await fs.outputJson(join(old, "settings.json"), { enabledPlugins: {} });
349
+ await fs.ensureDir(join(old, "skills"));
350
+ await fs.symlink(
351
+ "_profiles/team/settings.json",
352
+ join(project, ".claude", "settings.json"),
353
+ );
354
+ await fs.symlink(
355
+ "_profiles/team/skills",
356
+ join(project, ".claude", "skills"),
233
357
  );
234
- await materializeAndActivate(result.manifest, "default", project);
235
358
 
236
- const gitignore = await fs.readFile(join(project, ".gitignore"), "utf8");
237
- // `.claude/` is committed, so a symlink into generated state that lands in
238
- // git breaks every teammate who does not have that profile materialized.
239
- expect(gitignore).toContain(".claude/_profiles/");
240
- expect(gitignore).toContain(".claude/settings.json");
359
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
360
+
361
+ const text = await fs.readFile(join(project, ".gitignore"), "utf8");
362
+ const lines = text.split("\n").map((l) => l.trim());
363
+ // The user's line, outside the block: kept.
364
+ expect(lines.slice(0, 2)).toEqual(["node_modules", ".claude/skills/"]);
365
+ // magus's obsolete lines, inside its block: gone.
366
+ expect(lines).not.toContain(".claude/skills");
367
+ expect(lines).not.toContain(".claude/_profiles/");
368
+ expect(lines).toContain(".claude/settings.json");
369
+ expect(lines).toContain(".claude/profiles/");
241
370
  });
242
371
  });
@@ -0,0 +1,141 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ModelsConfig } from "../services/models-core.js";
3
+ import {
4
+ buildProfileMcp,
5
+ buildProfileSettings,
6
+ renderProfile,
7
+ } from "../services/profile-render.js";
8
+ import type { ProfileManifest, ResolvedClosure } from "../types/index.js";
9
+
10
+ const ROUTING: ModelsConfig = {
11
+ version: 1,
12
+ preset: "test",
13
+ main: { model: "opus", effort: "medium" },
14
+ grades: {
15
+ smart: { model: "fable", effort: "xhigh" },
16
+ normal: { model: "opus", effort: "medium" },
17
+ cheap: { model: "sonnet", effort: "low" },
18
+ },
19
+ agents: { "dev:architect": "smart" },
20
+ fallback: "normal",
21
+ };
22
+
23
+ function closure(overrides: Partial<ResolvedClosure> = {}): ResolvedClosure {
24
+ return {
25
+ marketplaces: {},
26
+ plugins: {},
27
+ mcpServers: {},
28
+ bins: [],
29
+ skills: [],
30
+ settings: {},
31
+ env: { required: [], optional: [] },
32
+ ...overrides,
33
+ };
34
+ }
35
+
36
+ describe("buildProfileSettings", () => {
37
+ test("enables every resolved plugin and merges declared settings", () => {
38
+ const s = buildProfileSettings(
39
+ closure({
40
+ plugins: { "dev@magus": "2.9.0", "terminal@magus": "latest" },
41
+ settings: { enableAllProjectMcpServers: true },
42
+ }),
43
+ );
44
+ expect(s.enabledPlugins).toEqual({
45
+ "dev@magus": true,
46
+ "terminal@magus": true,
47
+ });
48
+ expect(s.enableAllProjectMcpServers).toBe(true);
49
+ });
50
+
51
+ // Claude Code resolves enabledPlugins per plugin id — an id the project
52
+ // scope omits falls through to user scope. A profile that merely omits
53
+ // another profile's plugins would never disable them, so `profile switch`
54
+ // would be additive instead of exclusive.
55
+ test("explicitly disables union plugins the profile excludes", () => {
56
+ const s = buildProfileSettings(
57
+ closure({ plugins: { "designer@magus": "latest" } }),
58
+ ["designer@magus", "terminal@magus", "madbench@magus"],
59
+ );
60
+ expect(s.enabledPlugins).toEqual({
61
+ "designer@magus": true,
62
+ "terminal@magus": false,
63
+ "madbench@magus": false,
64
+ });
65
+ });
66
+
67
+ test("a profile's own plugin wins over its union entry", () => {
68
+ const s = buildProfileSettings(
69
+ closure({ plugins: { "terminal@magus": "4.1.4" } }),
70
+ ["terminal@magus"],
71
+ );
72
+ expect(s.enabledPlugins).toEqual({ "terminal@magus": true });
73
+ });
74
+
75
+ test("a plugin the profile declares false is rendered false", () => {
76
+ const s = buildProfileSettings(
77
+ closure({
78
+ plugins: { "dev@magus": "latest" },
79
+ disabledPlugins: ["noisy@elsewhere"],
80
+ }),
81
+ );
82
+ expect(s.enabledPlugins).toEqual({
83
+ "dev@magus": true,
84
+ "noisy@elsewhere": false,
85
+ });
86
+ });
87
+ });
88
+
89
+ describe("buildProfileMcp", () => {
90
+ test("wraps the closure's mcpServers", () => {
91
+ const m = buildProfileMcp(
92
+ closure({ mcpServers: { "chrome-devtools": { command: "npx" } } }),
93
+ );
94
+ expect(m.mcpServers["chrome-devtools"]).toEqual({ command: "npx" });
95
+ });
96
+ });
97
+
98
+ describe("renderProfile", () => {
99
+ const manifest: ProfileManifest = {
100
+ version: 2,
101
+ profiles: {
102
+ frontend: {
103
+ name: "Frontend",
104
+ plugins: { "designer@magus": "latest", "off@elsewhere": false },
105
+ mcpServers: { x: { command: "y" } },
106
+ settings: { outputStyle: "composed-frontend" },
107
+ models: ROUTING,
108
+ },
109
+ backend: { name: "Backend", plugins: { "terminal@magus": "latest" } },
110
+ },
111
+ };
112
+
113
+ test("renders settings, mcp and models in memory from the entry", async () => {
114
+ const r = await renderProfile(manifest, "frontend");
115
+ expect(r.settings).toEqual({
116
+ enabledPlugins: {
117
+ "designer@magus": true,
118
+ "terminal@magus": false,
119
+ "off@elsewhere": false,
120
+ },
121
+ outputStyle: "composed-frontend",
122
+ });
123
+ expect(r.mcp).toEqual({ mcpServers: { x: { command: "y" } } });
124
+ expect(r.models).toEqual(ROUTING as unknown as Record<string, unknown>);
125
+ expect(r.entry).toBe(manifest.profiles.frontend as never);
126
+ });
127
+
128
+ test("a profile without routing renders no models.json", async () => {
129
+ const r = await renderProfile(manifest, "backend");
130
+ expect(r.models).toBeNull();
131
+ expect(r.settings.enabledPlugins).toEqual({
132
+ "designer@magus": false,
133
+ "terminal@magus": true,
134
+ });
135
+ expect(r.mcp).toEqual({ mcpServers: {} });
136
+ });
137
+
138
+ test("an unknown profile throws", async () => {
139
+ await expect(renderProfile(manifest, "nope")).rejects.toThrow(/nope/);
140
+ });
141
+ });