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
@@ -14,8 +14,11 @@ import {
14
14
  readSettings,
15
15
  writeSettings,
16
16
  } from "../../services/claude-settings.js";
17
+ import { reconcileActiveProfile } from "../../services/profile-absorb.js";
18
+ import { activationNotes } from "../../services/profile-activate.js";
19
+ import { switchToProfile } from "../../services/profile-state.js";
17
20
  import {
18
- applyProfile,
21
+ applyUserProfile,
19
22
  deleteProfile,
20
23
  exportProfileToJson,
21
24
  importProfileFromJson,
@@ -29,6 +32,7 @@ import {
29
32
  writeClipboard,
30
33
  } from "../../utils/clipboard.js";
31
34
  import { ScrollableList } from "../components/ScrollableList.js";
35
+ import { describeOutcome } from "../hooks/useProfileStartupModal.js";
32
36
  import {
33
37
  type ProfileListItem,
34
38
  buildProfileListItems,
@@ -156,11 +160,17 @@ export function ProfilesScreen() {
156
160
  }
157
161
  }
158
162
  await writeSettings(settings, state.projectPath);
163
+ // In a profiled project the merge is a change to the active profile:
164
+ // record it there now, rather than at the next magus command.
165
+ const recorded = await reconcileActiveProfile(state.projectPath);
159
166
  modal.hideModal();
160
167
  dispatch({ type: "DATA_REFRESH_COMPLETE" });
161
168
  await modal.message(
162
169
  "Applied",
163
- `Profile "${profile.name}" merged into project settings.`,
170
+ [
171
+ `Profile "${profile.name}" merged into project settings.`,
172
+ ...recorded.lines,
173
+ ].join("\n"),
164
174
  "success",
165
175
  );
166
176
  } catch (error) {
@@ -173,28 +183,71 @@ export function ProfilesScreen() {
173
183
  }
174
184
  };
175
185
 
186
+ /**
187
+ * Apply a saved profile.
188
+ *
189
+ * A PROJECT profile is switched to, through the same path as `magus profile
190
+ * switch`: what was changed in the live config goes into the profile being
191
+ * left, then this one is rendered. Writing its enabledPlugins straight into
192
+ * `.claude/settings.json` made the apply invisible to the profile and undone
193
+ * by the next render.
194
+ *
195
+ * A USER-scope profile is a template in `~/.claude/profiles.json` and applies
196
+ * to `~/.claude/settings.json` only — never to this project's live files,
197
+ * which belong to its active profile.
198
+ */
176
199
  const handleApply = async () => {
177
200
  if (selectedItem?.kind !== "saved") return;
178
201
  const selectedProfile = selectedItem.entry;
179
202
 
180
- const scopeChoice = await modal.select(
181
- "Apply Profile",
182
- `Apply "${selectedProfile.name}" to which scope?`,
183
- [
184
- { label: "User — ~/.claude/settings.json (global)", value: "user" },
185
- {
186
- label: "Project — .claude/settings.json (this project)",
187
- value: "project",
188
- },
189
- ],
190
- );
191
- if (scopeChoice === null) return;
203
+ if (selectedProfile.scope === "project") {
204
+ const confirmed = await modal.confirm(
205
+ "Switch profile",
206
+ `Make "${selectedProfile.name}" this project's active profile?\n\nIts plugins, MCP servers, model routing and skills replace the current ones. Anything changed in the current config is saved into the profile you are leaving first.`,
207
+ );
208
+ if (!confirmed) return;
192
209
 
193
- const targetScope = scopeChoice as "user" | "project";
194
- const scopeLabel =
195
- targetScope === "user"
196
- ? "~/.claude/settings.json"
197
- : ".claude/settings.json";
210
+ modal.loading(`Switching to "${selectedProfile.name}"...`);
211
+ try {
212
+ const result = await switchToProfile(
213
+ state.projectPath,
214
+ selectedProfile.id,
215
+ { onDisplace: () => {} },
216
+ );
217
+ modal.hideModal();
218
+ dispatch({ type: "DATA_REFRESH_COMPLETE" });
219
+ if (result.problems.length > 0 || !result.activation) {
220
+ await modal.message(
221
+ "Could not switch",
222
+ [...result.lines, ...result.problems].join("\n"),
223
+ "error",
224
+ );
225
+ return;
226
+ }
227
+ const outcome = describeOutcome(
228
+ result.activation.profileId,
229
+ result.activation.displaced,
230
+ state.projectPath,
231
+ false,
232
+ activationNotes(result.activation),
233
+ );
234
+ await modal.message(
235
+ "Profile active",
236
+ result.lines.length > 0
237
+ ? `${result.lines.join("\n")}\n\n${outcome}`
238
+ : outcome,
239
+ "success",
240
+ );
241
+ } catch (error) {
242
+ modal.hideModal();
243
+ await modal.message(
244
+ "Error",
245
+ `Failed to switch profile: ${error}`,
246
+ "error",
247
+ );
248
+ }
249
+ return;
250
+ }
198
251
 
199
252
  const pluginCount = Object.keys(selectedProfile.plugins ?? {}).length;
200
253
  const emptyWarning =
@@ -204,23 +257,18 @@ export function ProfilesScreen() {
204
257
 
205
258
  const confirmed = await modal.confirm(
206
259
  "Confirm Apply",
207
- `This will REPLACE all enabledPlugins in ${scopeLabel}.${emptyWarning}\n\nContinue?`,
260
+ `This will REPLACE all enabledPlugins in ~/.claude/settings.json (every project on this machine).${emptyWarning}\n\nContinue?`,
208
261
  );
209
262
  if (!confirmed) return;
210
263
 
211
264
  modal.loading(`Applying "${selectedProfile.name}"...`);
212
265
  try {
213
- await applyProfile(
214
- selectedProfile.id,
215
- selectedProfile.scope,
216
- targetScope,
217
- state.projectPath,
218
- );
266
+ await applyUserProfile(selectedProfile);
219
267
  modal.hideModal();
220
268
  dispatch({ type: "DATA_REFRESH_COMPLETE" });
221
269
  await modal.message(
222
270
  "Applied",
223
- `Profile "${selectedProfile.name}" applied to ${scopeLabel}.`,
271
+ `Profile "${selectedProfile.name}" applied to ~/.claude/settings.json.`,
224
272
  "success",
225
273
  );
226
274
  } catch (error) {
@@ -535,7 +535,8 @@ export function StylesScreen() {
535
535
  ? `profile "${result.profile}"`
536
536
  : "this project";
537
537
  // Say plainly when the manifest was NOT updated: without that record a
538
- // `magus install` re-materializes settings.json and drops the style.
538
+ // the next activation renders settings.json from the manifest and
539
+ // drops the style.
539
540
  const durability =
540
541
  result.profile && !result.recordedInManifest
541
542
  ? " — not in profiles.json, so `magus install` will drop it"
@@ -306,7 +306,7 @@ export interface ModelsScreenState {
306
306
  * True while an apply or a clear is writing.
307
307
  *
308
308
  * App state, not screen state, because the screen UNMOUNTS on a tab switch.
309
- * An apply rewrites the profile, re-materializes it and registers a hook —
309
+ * An apply rewrites the profile, re-renders its live files and registers a hook —
310
310
  * two of those running at once would interleave writes to the same files.
311
311
  */
312
312
  isApplying: boolean;
@@ -1,238 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdtemp, rm } from "node:fs/promises";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import fs from "fs-extra";
6
- import type { ModelsConfig } from "../services/models-core.js";
7
- import {
8
- buildProfileMcp,
9
- buildProfileSettings,
10
- materializeProfile,
11
- } from "../services/profile-materializer.js";
12
- import { profileDir } from "../services/symlink-manager.js";
13
- import type { ResolvedClosure } from "../types/index.js";
14
-
15
- const ROUTING: ModelsConfig = {
16
- version: 1,
17
- preset: "test",
18
- main: { model: "opus", effort: "medium" },
19
- grades: {
20
- smart: { model: "fable", effort: "xhigh" },
21
- normal: { model: "opus", effort: "medium" },
22
- cheap: { model: "sonnet", effort: "low" },
23
- },
24
- agents: { "dev:architect": "smart" },
25
- fallback: "normal",
26
- };
27
-
28
- function closure(overrides: Partial<ResolvedClosure> = {}): ResolvedClosure {
29
- return {
30
- marketplaces: {},
31
- plugins: {},
32
- mcpServers: {},
33
- bins: [],
34
- skills: [],
35
- settings: {},
36
- env: { required: [], optional: [] },
37
- ...overrides,
38
- };
39
- }
40
-
41
- describe("buildProfileSettings", () => {
42
- test("enables every resolved plugin and merges declared settings", () => {
43
- const s = buildProfileSettings(
44
- closure({
45
- plugins: { "dev@magus": "2.9.0", "terminal@magus": "latest" },
46
- settings: { enableAllProjectMcpServers: true },
47
- }),
48
- );
49
- expect(s.enabledPlugins).toEqual({
50
- "dev@magus": true,
51
- "terminal@magus": true,
52
- });
53
- expect(s.enableAllProjectMcpServers).toBe(true);
54
- });
55
-
56
- // Regression: `install` enables the manifest-wide union at USER scope, and
57
- // Claude Code resolves enabledPlugins per plugin id — an id the project
58
- // scope omits falls through to the user scope's `true`. A profile that
59
- // merely omits another profile's plugins therefore never disables them, so
60
- // `profile switch` silently becomes additive instead of exclusive.
61
- test("explicitly disables union plugins the profile excludes", () => {
62
- const s = buildProfileSettings(
63
- closure({ plugins: { "designer@magus": "latest" } }),
64
- ["designer@magus", "terminal@magus", "madbench@magus"],
65
- );
66
- expect(s.enabledPlugins).toEqual({
67
- "designer@magus": true,
68
- "terminal@magus": false,
69
- "madbench@magus": false,
70
- });
71
- });
72
-
73
- test("a profile's own plugin wins over its union entry", () => {
74
- const s = buildProfileSettings(
75
- closure({ plugins: { "terminal@magus": "4.1.4" } }),
76
- ["terminal@magus"],
77
- );
78
- expect(s.enabledPlugins).toEqual({ "terminal@magus": true });
79
- });
80
-
81
- test("no union given -> no false entries (unchanged legacy shape)", () => {
82
- const s = buildProfileSettings(
83
- closure({ plugins: { "dev@magus": "latest" } }),
84
- );
85
- expect(s.enabledPlugins).toEqual({ "dev@magus": true });
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("materializeProfile", () => {
99
- let project: string;
100
- beforeEach(async () => {
101
- project = await mkdtemp(join(tmpdir(), "materialize-"));
102
- });
103
- afterEach(async () => {
104
- await rm(project, { recursive: true, force: true });
105
- });
106
-
107
- test("writes settings.json, mcp.json, and an empty skills/ dir", async () => {
108
- const dir = await materializeProfile(
109
- "frontend",
110
- closure({
111
- plugins: { "dev@magus": "latest" },
112
- mcpServers: { x: { command: "y" } },
113
- }),
114
- project,
115
- );
116
- expect(dir).toBe(profileDir("frontend", project));
117
- expect(
118
- (await fs.readJson(join(dir, "settings.json"))).enabledPlugins[
119
- "dev@magus"
120
- ],
121
- ).toBe(true);
122
- expect(
123
- (await fs.readJson(join(dir, "mcp.json"))).mcpServers.x.command,
124
- ).toBe("y");
125
- expect(await fs.pathExists(join(dir, "skills"))).toBe(true);
126
- });
127
-
128
- test("is idempotent (re-materialize overwrites cleanly)", async () => {
129
- await materializeProfile(
130
- "p",
131
- closure({ plugins: { "a@m": "latest" } }),
132
- project,
133
- );
134
- const dir = await materializeProfile(
135
- "p",
136
- closure({ plugins: { "b@m": "latest" } }),
137
- project,
138
- );
139
- const settings = await fs.readJson(join(dir, "settings.json"));
140
- expect(settings.enabledPlugins).toEqual({ "b@m": true });
141
- });
142
-
143
- // Regression: activating a profile replaces .claude/skills with a symlink,
144
- // and replacing means fs.remove first. A repo that committed its own project
145
- // skills before adopting profiles would lose them at that moment.
146
- test("seeds skills/ from the project's pre-existing .claude/skills", async () => {
147
- const src = join(project, ".claude", "skills", "systematic-debugging");
148
- await fs.outputFile(join(src, "SKILL.md"), "# committed project skill\n");
149
-
150
- const dir = await materializeProfile("p", closure(), project);
151
-
152
- expect(
153
- await fs.readFile(
154
- join(dir, "skills", "systematic-debugging", "SKILL.md"),
155
- "utf8",
156
- ),
157
- ).toContain("committed project skill");
158
- });
159
-
160
- test("does not re-seed skills/ once the profile dir exists", async () => {
161
- await fs.outputFile(
162
- join(project, ".claude", "skills", "gone", "SKILL.md"),
163
- "# removed later\n",
164
- );
165
- const dir = await materializeProfile("p", closure(), project);
166
- await fs.remove(join(dir, "skills", "gone"));
167
-
168
- await materializeProfile("p", closure(), project);
169
-
170
- expect(await fs.pathExists(join(dir, "skills", "gone"))).toBe(false);
171
- });
172
-
173
- test("still creates an empty skills/ when the project has none", async () => {
174
- const dir = await materializeProfile("p", closure(), project);
175
- expect(await fs.pathExists(join(dir, "skills"))).toBe(true);
176
- expect(await fs.readdir(join(dir, "skills"))).toEqual([]);
177
- });
178
-
179
- test("writes models.json when the closure carries routing", async () => {
180
- const dir = await materializeProfile(
181
- "p",
182
- closure({ models: ROUTING }),
183
- project,
184
- );
185
- expect(await fs.readJson(join(dir, "models.json"))).toEqual(ROUTING);
186
- });
187
-
188
- // The one artifact materialization DELETES. Every other file here is
189
- // unconditional, so overwriting is enough; routing is optional, and a
190
- // profile that dropped it would otherwise keep routing from a file nothing
191
- // writes any more — `magus models off` could not turn routing off,
192
- // because the hook walks up to the nearest models.json and finds this one.
193
- test("deletes a stale models.json when the closure has no routing", async () => {
194
- const dir = await materializeProfile(
195
- "p",
196
- closure({ models: ROUTING }),
197
- project,
198
- );
199
- expect(await fs.pathExists(join(dir, "models.json"))).toBe(true);
200
-
201
- await materializeProfile("p", closure(), project);
202
-
203
- expect(await fs.pathExists(join(dir, "models.json"))).toBe(false);
204
- });
205
-
206
- test("a profile that never had routing gets no models.json", async () => {
207
- const dir = await materializeProfile("p", closure(), project);
208
- expect(await fs.pathExists(join(dir, "models.json"))).toBe(false);
209
- });
210
-
211
- test("two profiles from one union each disable the other's plugins", async () => {
212
- const union = ["designer@magus", "terminal@magus"];
213
- const fe = await materializeProfile(
214
- "frontend",
215
- closure({ plugins: { "designer@magus": "latest" } }),
216
- project,
217
- union,
218
- );
219
- const be = await materializeProfile(
220
- "backend",
221
- closure({ plugins: { "terminal@magus": "latest" } }),
222
- project,
223
- union,
224
- );
225
- expect(
226
- (await fs.readJson(join(fe, "settings.json"))).enabledPlugins,
227
- ).toEqual({
228
- "designer@magus": true,
229
- "terminal@magus": false,
230
- });
231
- expect(
232
- (await fs.readJson(join(be, "settings.json"))).enabledPlugins,
233
- ).toEqual({
234
- "designer@magus": false,
235
- "terminal@magus": true,
236
- });
237
- });
238
- });
@@ -1,154 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdtemp, rm } from "node:fs/promises";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import fs from "fs-extra";
6
- import { computeSync, syncProfile } from "../services/profile-sync.js";
7
- import { profileDir } from "../services/symlink-manager.js";
8
- import type { ProfileManifestEntry } from "../types/index.js";
9
-
10
- describe("computeSync (pure)", () => {
11
- const entry: ProfileManifestEntry = {
12
- name: "Frontend",
13
- plugins: { "dev@magus": "2.9.0", "terminal@magus": "latest" },
14
- settings: { effortLevel: "high" },
15
- };
16
-
17
- test("adds a newly-enabled plugin, preserving existing pins", () => {
18
- const { entry: next, changes } = computeSync(
19
- entry,
20
- {
21
- enabledPlugins: {
22
- "dev@magus": true,
23
- "terminal@magus": true,
24
- "designer@magus": true,
25
- },
26
- effortLevel: "high",
27
- },
28
- { mcpServers: {} },
29
- );
30
- // existing pin preserved (not downgraded to latest)
31
- expect(next.plugins!["dev@magus"]).toBe("2.9.0");
32
- // new plugin added as latest
33
- expect(next.plugins!["designer@magus"]).toBe("latest");
34
- expect(changes).toContainEqual({
35
- kind: "plugin-added",
36
- detail: "designer@magus",
37
- });
38
- });
39
-
40
- test("removes a plugin that was disabled", () => {
41
- const { entry: next, changes } = computeSync(
42
- entry,
43
- { enabledPlugins: { "dev@magus": true }, effortLevel: "high" },
44
- { mcpServers: {} },
45
- );
46
- expect(next.plugins!["terminal@magus"]).toBeUndefined();
47
- expect(changes).toContainEqual({
48
- kind: "plugin-removed",
49
- detail: "terminal@magus",
50
- });
51
- });
52
-
53
- test("captures a settings change and an mcp change", () => {
54
- const { entry: next, changes } = computeSync(
55
- entry,
56
- {
57
- enabledPlugins: { "dev@magus": true, "terminal@magus": true },
58
- effortLevel: "max",
59
- },
60
- { mcpServers: { "chrome-devtools": { command: "npx" } } },
61
- );
62
- expect(next.settings!.effortLevel).toBe("max");
63
- expect(next.mcpServers!["chrome-devtools"]).toEqual({ command: "npx" });
64
- expect(changes.some((c) => c.kind === "settings-changed")).toBe(true);
65
- expect(changes.some((c) => c.kind === "mcp-changed")).toBe(true);
66
- });
67
-
68
- test("no changes when the materialized state matches the manifest", () => {
69
- const { changes } = computeSync(
70
- entry,
71
- {
72
- enabledPlugins: { "dev@magus": true, "terminal@magus": true },
73
- effortLevel: "high",
74
- },
75
- { mcpServers: {} },
76
- );
77
- expect(changes).toEqual([]);
78
- });
79
-
80
- test("refreshes updatedAt to the injected timestamp when changed", () => {
81
- const withStamp: ProfileManifestEntry = { ...entry, updatedAt: "OLD" };
82
- const { entry: changed } = computeSync(
83
- withStamp,
84
- {
85
- enabledPlugins: {
86
- "dev@magus": true,
87
- "terminal@magus": true,
88
- "new@magus": true,
89
- },
90
- effortLevel: "high",
91
- },
92
- { mcpServers: {} },
93
- "2026-07-27T00:00:00.000Z",
94
- );
95
- expect(changed.updatedAt).toBe("2026-07-27T00:00:00.000Z");
96
-
97
- // Unchanged sync leaves updatedAt alone.
98
- const { entry: same } = computeSync(
99
- withStamp,
100
- {
101
- enabledPlugins: { "dev@magus": true, "terminal@magus": true },
102
- effortLevel: "high",
103
- },
104
- { mcpServers: {} },
105
- "2026-07-27T00:00:00.000Z",
106
- );
107
- expect(same.updatedAt).toBe("OLD");
108
- });
109
- });
110
-
111
- describe("syncProfile (io)", () => {
112
- let dir: string;
113
- beforeEach(async () => {
114
- dir = await mkdtemp(join(tmpdir(), "sync-"));
115
- await fs.ensureDir(join(dir, ".claude"));
116
- await fs.writeJson(join(dir, ".claude", "profiles.json"), {
117
- version: 2,
118
- profiles: {
119
- frontend: { name: "Frontend", plugins: { "dev@magus": "2.9.0" } },
120
- },
121
- });
122
- });
123
- afterEach(async () => {
124
- await rm(dir, { recursive: true, force: true });
125
- });
126
-
127
- test("promotes an edit in the materialized dir into the manifest file", async () => {
128
- // A teammate enabled a new plugin directly in the active profile's settings.
129
- const pd = profileDir("frontend", dir);
130
- await fs.ensureDir(pd);
131
- await fs.writeJson(join(pd, "settings.json"), {
132
- enabledPlugins: { "dev@magus": true, "designer@magus": true },
133
- });
134
- await fs.writeJson(join(pd, "mcp.json"), { mcpServers: {} });
135
-
136
- const { changes, wrote } = await syncProfile("frontend", dir);
137
- expect(wrote).toBe(true);
138
- expect(changes.some((c) => c.detail === "designer@magus")).toBe(true);
139
-
140
- const manifest = await fs.readJson(join(dir, ".claude", "profiles.json"));
141
- expect(manifest.profiles.frontend.plugins["designer@magus"]).toBe("latest");
142
- expect(manifest.profiles.frontend.plugins["dev@magus"]).toBe("2.9.0"); // pin kept
143
- });
144
-
145
- test("no-op when nothing drifted", async () => {
146
- const pd = profileDir("frontend", dir);
147
- await fs.ensureDir(pd);
148
- await fs.writeJson(join(pd, "settings.json"), {
149
- enabledPlugins: { "dev@magus": true },
150
- });
151
- const { wrote } = await syncProfile("frontend", dir);
152
- expect(wrote).toBe(false);
153
- });
154
- });