magus-cli 7.4.0 → 7.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +9 -8
  2. package/package.json +4 -4
  3. package/src/__tests__/blind/c19-paste-routing.test.ts +252 -0
  4. package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
  5. package/src/__tests__/install-command.test.ts +4 -3
  6. package/src/__tests__/models-cli.test.ts +4 -7
  7. package/src/__tests__/models-manager.test.ts +52 -40
  8. package/src/__tests__/profile-absorb.test.ts +1138 -0
  9. package/src/__tests__/profile-command.test.ts +90 -32
  10. package/src/__tests__/profile-migration-offer.test.ts +18 -15
  11. package/src/__tests__/profile-migration.test.ts +246 -117
  12. package/src/__tests__/profile-render.test.ts +141 -0
  13. package/src/__tests__/profile-skills.test.ts +425 -0
  14. package/src/__tests__/profile-startup.test.ts +44 -14
  15. package/src/__tests__/profile-state.test.ts +795 -0
  16. package/src/__tests__/settings-file.test.ts +5 -7
  17. package/src/__tests__/stale-load-repair.test.ts +5 -4
  18. package/src/__tests__/styles-manager.test.ts +47 -74
  19. package/src/__tests__/styles-screen-state.test.ts +11 -10
  20. package/src/cli/bootstrap.ts +87 -27
  21. package/src/cli/doctor.ts +152 -44
  22. package/src/cli/install.ts +92 -127
  23. package/src/cli/models.ts +21 -1
  24. package/src/cli/profile.ts +48 -77
  25. package/src/cli/router.ts +32 -6
  26. package/src/cli/update.ts +43 -6
  27. package/src/data/gitignore-defaults.ts +3 -3
  28. package/src/services/active-closure.ts +3 -3
  29. package/src/services/claude-settings.ts +283 -125
  30. package/src/services/cli-tool-commands.ts +30 -0
  31. package/src/services/conventions-manager.ts +88 -0
  32. package/src/services/git-worktree.ts +21 -0
  33. package/src/services/gitignore-service.ts +39 -22
  34. package/src/services/manifest.ts +4 -5
  35. package/src/services/models-manager.ts +36 -73
  36. package/src/services/profile-absorb.ts +1532 -0
  37. package/src/services/profile-activate.ts +348 -96
  38. package/src/services/profile-live.ts +825 -0
  39. package/src/services/profile-render.ts +115 -0
  40. package/src/services/profile-skills.ts +462 -0
  41. package/src/services/profile-startup.ts +88 -16
  42. package/src/services/profile-state.ts +622 -0
  43. package/src/services/profiles.ts +20 -32
  44. package/src/services/resolver.ts +6 -1
  45. package/src/services/settings-file.ts +14 -8
  46. package/src/services/skills-manager.ts +14 -8
  47. package/src/services/styles-manager.ts +45 -28
  48. package/src/services/tool-probe.ts +48 -3
  49. package/src/types/index.ts +15 -9
  50. package/src/ui/App.tsx +25 -2
  51. package/src/ui/components/modals/ModalContainer.tsx +9 -1
  52. package/src/ui/hooks/useProfileStartupModal.ts +20 -15
  53. package/src/ui/input/KeyRouter.tsx +147 -23
  54. package/src/ui/input/key-dispatch.ts +48 -0
  55. package/src/ui/keymap.ts +8 -1
  56. package/src/ui/renderers/cliToolRenderers.tsx +65 -27
  57. package/src/ui/screens/CliToolsScreen.tsx +74 -13
  58. package/src/ui/screens/PluginsScreen.tsx +6 -2
  59. package/src/ui/screens/ProfilesScreen.tsx +75 -27
  60. package/src/ui/screens/StylesScreen.tsx +2 -1
  61. package/src/ui/state/types.ts +1 -1
  62. package/src/__tests__/profile-materializer.test.ts +0 -238
  63. package/src/__tests__/profile-sync.test.ts +0 -154
  64. package/src/__tests__/symlink-manager.test.ts +0 -180
  65. package/src/services/profile-materializer.ts +0 -133
  66. package/src/services/profile-sync.ts +0 -158
  67. package/src/services/symlink-manager.ts +0 -343
@@ -0,0 +1,1532 @@
1
+ /**
2
+ * Keep `.claude/profiles.json` and the active profile's live config in step.
3
+ *
4
+ * In a magus project a change to the live config IS a change to the active
5
+ * profile. Claude Code writes `.claude/settings.json` and `.mcp.json` itself —
6
+ * `claude plugin install --scope project`, `/config`, `claude mcp add` — and a
7
+ * person may edit them, or `.claude/models.json`, or a skill folder magus
8
+ * installed. None of that is set aside: it is written into the profile.
9
+ *
10
+ * {@link reconcileActiveProfile} runs at every magus entry point that reads or
11
+ * writes profile state, before the command acts:
12
+ *
13
+ * a. ABSORB. A live file whose hash differs from the marker is diffed against
14
+ * the entry it was rendered from (`marker.base`), and the difference is
15
+ * written into the entry. A magus-installed skill folder whose contents
16
+ * changed stops being managed: it becomes a custom skill, and the profile
17
+ * stops listing it.
18
+ * b. REGENERATE. If the entry in profiles.json no longer hashes to the
19
+ * marker's `definition` — a teammate's change arrived by `git pull` — the
20
+ * live files and skills are regenerated from it. No prompt, no backup.
21
+ * c. Both: a, then b. The local edits are merged into the pulled entry
22
+ * against `marker.base`, the common ancestor, so neither side is lost.
23
+ * When both changed the same key, the local edit wins; the pulled value
24
+ * is still in git history.
25
+ *
26
+ * Idempotent, and cheap when nothing changed: three file hashes, the skill
27
+ * folder hashes and one read of profiles.json.
28
+ *
29
+ * ## How live keys map back to manifest fields
30
+ *
31
+ * The inverse of `profile-render.ts`:
32
+ *
33
+ * enabledPlugins true -> `plugins[id]` ("latest" unless already pinned)
34
+ * enabledPlugins false -> nothing when another profile owns the id (render
35
+ * writes that `false` itself); otherwise
36
+ * `plugins[id] = false`
37
+ * any other settings key -> `settings[key]`, minus keys inherited unchanged
38
+ * through `extends`, minus machine-local keys
39
+ * .mcp.json mcpServers -> `mcpServers`
40
+ * .claude/models.json -> `models`
41
+ *
42
+ * Model routing and output style need no special case, and must not get one:
43
+ * `magus models use` records the settings keys it implies (`model`,
44
+ * `effortLevel`, `modelSettings`) in `profiles.<name>.settings` alongside the
45
+ * routing in `profiles.<name>.models`, and a style records `outputStyle` in
46
+ * `profiles.<name>.settings` too. Those are exactly the fields absorption
47
+ * writes, so an edit lands where the command that owns the key keeps it, once.
48
+ */
49
+
50
+ import path from "node:path";
51
+ import fs from "fs-extra";
52
+ import type {
53
+ McpServerConfig,
54
+ ProfileManifest,
55
+ ProfileManifestEntry,
56
+ ProfileSkillRef,
57
+ } from "../types/index.js";
58
+ import { readManifest, writeManifest } from "./manifest.js";
59
+ import { validateModelsConfig } from "./models-core.js";
60
+ import {
61
+ type ActivateOptions,
62
+ activateProfile,
63
+ activationNotes,
64
+ } from "./profile-activate.js";
65
+ import {
66
+ type ActiveMarker,
67
+ LIVE_FILE_KEYS,
68
+ type LiveFileKey,
69
+ MACHINE_LOCAL_SETTINGS_KEYS,
70
+ type RenderedProfile,
71
+ canonicalJson,
72
+ hasCurrentMarker,
73
+ isJsonRecord,
74
+ jsonEqual,
75
+ legacyProfileDirs,
76
+ liveHash,
77
+ liveKind,
78
+ livePath,
79
+ markerFor,
80
+ readActiveMarker,
81
+ readLegacyMarkerHashes,
82
+ readLiveJson,
83
+ readLiveThrough,
84
+ writeActiveMarker,
85
+ writeLiveFile,
86
+ } from "./profile-live.js";
87
+ import { renderProfile } from "./profile-render.js";
88
+ import {
89
+ inspectManagedSkills,
90
+ skillFolder,
91
+ updateSkillIgnoreLines,
92
+ } from "./profile-skills.js";
93
+ import { resolveExtends, resolveProfile } from "./resolver.js";
94
+ import { SettingsParseError } from "./settings-file.js";
95
+
96
+ // ─── Changes ─────────────────────────────────────────────────────────────────
97
+
98
+ export type AbsorbChange =
99
+ | { kind: "plugin-added"; plugin: string }
100
+ | { kind: "plugin-removed"; plugin: string }
101
+ | { kind: "plugin-disabled"; plugin: string }
102
+ | { kind: "settings-changed" }
103
+ | { kind: "mcp-changed" }
104
+ | { kind: "models-changed" }
105
+ | { kind: "skill-released"; skill: string; folder: string };
106
+
107
+ /** One line per change, naming the profile it went into. */
108
+ export function describeChange(change: AbsorbChange, profile: string): string {
109
+ switch (change.kind) {
110
+ case "plugin-added":
111
+ return `Added ${change.plugin} to profile "${profile}"`;
112
+ case "plugin-removed":
113
+ return `Removed ${change.plugin} from profile "${profile}"`;
114
+ case "plugin-disabled":
115
+ return `Disabled ${change.plugin} in profile "${profile}"`;
116
+ case "settings-changed":
117
+ return `Updated settings of profile "${profile}"`;
118
+ case "mcp-changed":
119
+ return `Updated MCP servers of profile "${profile}"`;
120
+ case "models-changed":
121
+ return `Updated model routing of profile "${profile}"`;
122
+ case "skill-released":
123
+ return `Skill "${change.skill}" was edited — it is now a custom skill in .claude/skills/${change.folder}; commit it`;
124
+ }
125
+ }
126
+
127
+ // ─── The inverse of rendering ────────────────────────────────────────────────
128
+
129
+ /** The live config as absorption reads it. */
130
+ export interface LiveView {
131
+ settings: Record<string, unknown>;
132
+ mcp: Record<string, unknown>;
133
+ models: Record<string, unknown> | null;
134
+ }
135
+
136
+ /** What `extends` contributes on its own, or nothing. */
137
+ function inheritedPart(entry: ProfileManifestEntry): ProfileManifestEntry {
138
+ if (!entry.extends) return { name: entry.name };
139
+ const base = resolveExtends({ name: entry.name, extends: entry.extends });
140
+ return base.extends === entry.extends && base.plugins === undefined
141
+ ? { name: entry.name }
142
+ : base;
143
+ }
144
+
145
+ function isOn(value: string | boolean | undefined): boolean {
146
+ return value !== undefined && value !== false;
147
+ }
148
+
149
+ function withoutMachineKeys(
150
+ settings: Record<string, unknown>,
151
+ ): Record<string, unknown> {
152
+ const out = { ...settings };
153
+ for (const key of MACHINE_LOCAL_SETTINGS_KEYS) delete out[key];
154
+ return out;
155
+ }
156
+
157
+ function setOrDelete<K extends "plugins" | "settings" | "mcpServers">(
158
+ entry: ProfileManifestEntry,
159
+ key: K,
160
+ value: ProfileManifestEntry[K],
161
+ ): void {
162
+ if (value && Object.keys(value).length > 0) entry[key] = value;
163
+ else delete entry[key];
164
+ }
165
+
166
+ /**
167
+ * Write the live config into a manifest entry: the inverse of rendering. Pure.
168
+ *
169
+ * `otherPluginIds` are the plugins other profiles enable — rendering writes
170
+ * those as `false` in this profile on its own, so a `false` for one of them
171
+ * says nothing new.
172
+ */
173
+ export function computeAbsorb(
174
+ entry: ProfileManifestEntry,
175
+ live: LiveView,
176
+ otherPluginIds: ReadonlySet<string>,
177
+ now: string = new Date().toISOString(),
178
+ ): { entry: ProfileManifestEntry; changes: AbsorbChange[] } {
179
+ const changes: AbsorbChange[] = [];
180
+ const next: ProfileManifestEntry = { ...entry };
181
+ const inherited = inheritedPart(entry);
182
+ const resolved = resolveExtends(entry);
183
+
184
+ // Plugins.
185
+ const enabled = isJsonRecord(live.settings.enabledPlugins)
186
+ ? live.settings.enabledPlugins
187
+ : {};
188
+ const own: Record<string, string | boolean> = { ...(entry.plugins ?? {}) };
189
+ const ids = new Set([
190
+ ...Object.keys(enabled),
191
+ ...Object.keys(resolved.plugins ?? {}),
192
+ ...Object.keys(own),
193
+ ]);
194
+ for (const id of ids) {
195
+ const liveValue = enabled[id];
196
+ const on = isOn(resolved.plugins?.[id]);
197
+ const inheritedOn = isOn(inherited.plugins?.[id]);
198
+ if (liveValue === true && !on) {
199
+ // An own `false` over an inherited plugin is what kept it off.
200
+ if (own[id] === false && inheritedOn) delete own[id];
201
+ else own[id] = "latest";
202
+ changes.push({ kind: "plugin-added", plugin: id });
203
+ } else if (liveValue !== true && on) {
204
+ delete own[id];
205
+ const mustSayFalse =
206
+ inheritedOn || (liveValue === false && !otherPluginIds.has(id));
207
+ if (mustSayFalse) own[id] = false;
208
+ changes.push({ kind: "plugin-removed", plugin: id });
209
+ } else if (
210
+ liveValue === false &&
211
+ !on &&
212
+ !otherPluginIds.has(id) &&
213
+ own[id] !== false
214
+ ) {
215
+ own[id] = false;
216
+ changes.push({ kind: "plugin-disabled", plugin: id });
217
+ } else if (liveValue === undefined && own[id] === false && !inheritedOn) {
218
+ delete own[id];
219
+ changes.push({ kind: "plugin-removed", plugin: id });
220
+ }
221
+ }
222
+ setOrDelete(next, "plugins", own);
223
+
224
+ // Settings: everything but enabledPlugins and machine-local keys, minus
225
+ // what `extends` supplies unchanged.
226
+ const { enabledPlugins: _drop, ...rest } = withoutMachineKeys(live.settings);
227
+ if (!jsonEqual(rest, resolved.settings ?? {})) {
228
+ const inheritedSettings = inherited.settings ?? {};
229
+ const ownSettings = entry.settings ?? {};
230
+ const settings: Record<string, unknown> = {};
231
+ for (const [key, value] of Object.entries(rest)) {
232
+ const fromBase =
233
+ key in inheritedSettings &&
234
+ !(key in ownSettings) &&
235
+ jsonEqual(inheritedSettings[key], value);
236
+ if (!fromBase) settings[key] = value;
237
+ }
238
+ setOrDelete(next, "settings", settings);
239
+ changes.push({ kind: "settings-changed" });
240
+ }
241
+
242
+ // MCP servers.
243
+ const servers = isJsonRecord(live.mcp.mcpServers)
244
+ ? (live.mcp.mcpServers as Record<string, McpServerConfig>)
245
+ : {};
246
+ if (!jsonEqual(servers, entry.mcpServers ?? {})) {
247
+ setOrDelete(next, "mcpServers", servers);
248
+ changes.push({ kind: "mcp-changed" });
249
+ }
250
+
251
+ // Model routing.
252
+ if (!jsonEqual(live.models, resolved.models ?? null)) {
253
+ if (live.models) {
254
+ next.models = live.models as unknown as ProfileManifestEntry["models"];
255
+ } else {
256
+ // biome-ignore lint/performance/noDelete: the entry is serialised to profiles.json; the key must be absent, not undefined
257
+ delete next.models;
258
+ }
259
+ changes.push({ kind: "models-changed" });
260
+ }
261
+
262
+ if (changes.length > 0) next.updatedAt = now;
263
+ return { entry: next, changes };
264
+ }
265
+
266
+ /**
267
+ * Would writing `rendered` over `live` lose anything the live file says?
268
+ *
269
+ * The rendered file may ADD `false` entries to enabledPlugins — that is how a
270
+ * profile keeps other profiles' plugins off — but may not drop or change any
271
+ * key. Machine-local settings keys are carried across writes, so they are not
272
+ * compared.
273
+ */
274
+ export function losesNothing(
275
+ key: LiveFileKey,
276
+ live: Record<string, unknown> | null,
277
+ rendered: Record<string, unknown> | null,
278
+ ): boolean {
279
+ if (key !== "settings") return jsonEqual(live, rendered);
280
+ if (!live || !rendered) return live === rendered;
281
+ const { enabledPlugins: liveEnabled, ...liveRest } = withoutMachineKeys(live);
282
+ const { enabledPlugins: renderedEnabled, ...renderedRest } = rendered;
283
+ if (!jsonEqual(liveRest, renderedRest)) return false;
284
+ const l = isJsonRecord(liveEnabled) ? liveEnabled : {};
285
+ const r = isJsonRecord(renderedEnabled) ? renderedEnabled : {};
286
+ for (const [id, value] of Object.entries(l)) {
287
+ if (r[id] !== value) return false;
288
+ }
289
+ for (const [id, value] of Object.entries(r)) {
290
+ if (!(id in l) && value !== false) return false;
291
+ }
292
+ return true;
293
+ }
294
+
295
+ // ─── Three-way merge ─────────────────────────────────────────────────────────
296
+
297
+ /**
298
+ * A three-way merge of one JSON value: `base` is the common ancestor, `local`
299
+ * what the live config says, `remote` what profiles.json says now.
300
+ *
301
+ * Whichever side changed a value wins; where both did:
302
+ * - plain objects are merged key by key, recursively — a local allow rule and
303
+ * a teammate's deny rule under the same `permissions` both survive;
304
+ * - arrays of scalars are merged as sets: remote's items, plus what local
305
+ * added, minus what local removed, in remote's order then local's
306
+ * (permission lists, additionalDirectories);
307
+ * - anything else — a scalar, or an array holding objects, such as a list of
308
+ * hooks — is one value, and both sides changing it differently is a true
309
+ * conflict: local wins, and the path is recorded in `conflicts` so the
310
+ * caller can say so. An array of objects is NOT merged as a set: one hook
311
+ * edited on both sides would come out as two hooks, each with half the
312
+ * edits.
313
+ */
314
+ function merge3Deep(
315
+ base: unknown,
316
+ local: unknown,
317
+ remote: unknown,
318
+ at: string,
319
+ conflicts: string[],
320
+ ): unknown {
321
+ if (jsonEqual(local, remote)) return local;
322
+ if (jsonEqual(local, base)) return remote;
323
+ if (jsonEqual(remote, base)) return local;
324
+ if (
325
+ isJsonRecord(local) &&
326
+ isJsonRecord(remote) &&
327
+ (base === undefined || isJsonRecord(base))
328
+ ) {
329
+ const b = (base ?? {}) as Record<string, unknown>;
330
+ const out: Record<string, unknown> = {};
331
+ const keys = new Set([
332
+ ...Object.keys(remote),
333
+ ...Object.keys(local),
334
+ ...Object.keys(b),
335
+ ]);
336
+ for (const key of keys) {
337
+ const value = merge3Deep(
338
+ b[key],
339
+ local[key],
340
+ remote[key],
341
+ `${at}.${key}`,
342
+ conflicts,
343
+ );
344
+ if (value !== undefined) out[key] = value;
345
+ }
346
+ return out;
347
+ }
348
+ if (
349
+ isScalarArray(local) &&
350
+ isScalarArray(remote) &&
351
+ (base === undefined || isScalarArray(base))
352
+ ) {
353
+ const key = (v: unknown) => canonicalJson(v);
354
+ const baseKeys = new Set(((base ?? []) as unknown[]).map(key));
355
+ const localKeys = new Set(local.map(key));
356
+ const out: unknown[] = [];
357
+ const seen = new Set<string>();
358
+ for (const item of remote) {
359
+ const k = key(item);
360
+ // In base and gone locally: local removed it.
361
+ if ((baseKeys.has(k) && !localKeys.has(k)) || seen.has(k)) continue;
362
+ out.push(item);
363
+ seen.add(k);
364
+ }
365
+ for (const item of local) {
366
+ const k = key(item);
367
+ // In base: either remote kept it (already added) or removed it.
368
+ if (baseKeys.has(k) || seen.has(k)) continue;
369
+ out.push(item);
370
+ seen.add(k);
371
+ }
372
+ return out;
373
+ }
374
+ conflicts.push(at);
375
+ return local;
376
+ }
377
+
378
+ /** An array whose items are all strings, numbers, booleans or null. */
379
+ function isScalarArray(value: unknown): value is unknown[] {
380
+ return (
381
+ Array.isArray(value) &&
382
+ value.every((item) => item === null || typeof item !== "object")
383
+ );
384
+ }
385
+
386
+ /**
387
+ * {@link merge3Deep} one level deep only: each value is merged whole. For maps
388
+ * whose values are units — an MCP server's config, whose `args` are an ordered
389
+ * command line and must not be merged as a set.
390
+ *
391
+ * `bothChanged`, when given, settles a key both sides changed differently
392
+ * before it counts as a conflict; returning undefined means it is one.
393
+ */
394
+ function merge3Entries(
395
+ base: Record<string, unknown> | undefined,
396
+ local: Record<string, unknown> | undefined,
397
+ remote: Record<string, unknown> | undefined,
398
+ at: string,
399
+ conflicts: string[],
400
+ bothChanged?: (local: unknown, remote: unknown) => unknown,
401
+ ): Record<string, unknown> {
402
+ const out: Record<string, unknown> = {};
403
+ const keys = new Set([
404
+ ...Object.keys(remote ?? {}),
405
+ ...Object.keys(local ?? {}),
406
+ ...Object.keys(base ?? {}),
407
+ ]);
408
+ for (const key of keys) {
409
+ const [b, l, r] = [base?.[key], local?.[key], remote?.[key]];
410
+ let value: unknown;
411
+ if (jsonEqual(l, r) || jsonEqual(r, b)) value = l;
412
+ else if (jsonEqual(l, b)) value = r;
413
+ else {
414
+ const settled = bothChanged?.(l, r);
415
+ if (settled !== undefined) value = settled;
416
+ else {
417
+ conflicts.push(`${at}.${key}`);
418
+ value = l;
419
+ }
420
+ }
421
+ if (value !== undefined) out[key] = value;
422
+ }
423
+ return out;
424
+ }
425
+
426
+ function nonEmpty<T extends Record<string, unknown>>(
427
+ value: unknown,
428
+ ): T | undefined {
429
+ return isJsonRecord(value) && Object.keys(value).length > 0
430
+ ? (value as T)
431
+ : undefined;
432
+ }
433
+
434
+ /**
435
+ * Both sides enabled a plugin, one with a version pin and one with "latest".
436
+ *
437
+ * Absorption can only ever write "latest" — Claude Code's settings say a
438
+ * plugin is on, never which version — so "latest" means "enabled", and a
439
+ * concrete version is somebody's deliberate pin. The pin wins, as it does when
440
+ * the resolver unions two profiles, and it is not a conflict. Anything else
441
+ * (two different pins, a pin against `false`) is left to be one.
442
+ */
443
+ function pinOverLatest(local: unknown, remote: unknown): unknown {
444
+ const isPin = (v: unknown) =>
445
+ typeof v === "string" && v !== "" && v !== "latest";
446
+ const isLatest = (v: unknown) => v === "latest" || v === true || v === "";
447
+ if (isLatest(local) && isPin(remote)) return remote;
448
+ if (isPin(local) && isLatest(remote)) return local;
449
+ return undefined;
450
+ }
451
+
452
+ /** The result of {@link merge3}. */
453
+ export interface Merge3Result {
454
+ entry: ProfileManifestEntry;
455
+ /**
456
+ * Key paths both sides changed differently, where the local value was
457
+ * kept — `settings.permissions.defaultMode`, `plugins.dev@magus`.
458
+ */
459
+ conflicts: string[];
460
+ }
461
+
462
+ /**
463
+ * Merge a locally absorbed entry and the entry now in profiles.json against
464
+ * their common ancestor. Absorption only ever touches plugins, settings,
465
+ * mcpServers, models and (by releasing skills) skills, so every other field
466
+ * is taken from profiles.json as it stands.
467
+ *
468
+ * `settings` merge recursively (see {@link merge3Deep}); plugin pins, MCP
469
+ * servers and the routing config are each merged as whole values.
470
+ */
471
+ export function merge3(
472
+ base: ProfileManifestEntry,
473
+ local: ProfileManifestEntry,
474
+ remote: ProfileManifestEntry,
475
+ ): Merge3Result {
476
+ const conflicts: string[] = [];
477
+ const out: ProfileManifestEntry = { ...remote };
478
+ const put = <K extends keyof ProfileManifestEntry>(
479
+ key: K,
480
+ value: ProfileManifestEntry[K] | undefined,
481
+ ) => {
482
+ if (value === undefined) delete out[key];
483
+ else out[key] = value;
484
+ };
485
+ put(
486
+ "plugins",
487
+ nonEmpty(
488
+ merge3Entries(
489
+ base.plugins,
490
+ local.plugins,
491
+ remote.plugins,
492
+ "plugins",
493
+ conflicts,
494
+ pinOverLatest,
495
+ ),
496
+ ),
497
+ );
498
+ put(
499
+ "settings",
500
+ nonEmpty(
501
+ merge3Deep(
502
+ base.settings ?? {},
503
+ local.settings ?? {},
504
+ remote.settings ?? {},
505
+ "settings",
506
+ conflicts,
507
+ ),
508
+ ),
509
+ );
510
+ put(
511
+ "mcpServers",
512
+ nonEmpty(
513
+ merge3Entries(
514
+ base.mcpServers as Record<string, unknown> | undefined,
515
+ local.mcpServers as Record<string, unknown> | undefined,
516
+ remote.mcpServers as Record<string, unknown> | undefined,
517
+ "mcpServers",
518
+ conflicts,
519
+ ),
520
+ ),
521
+ );
522
+ // The routing config is validated as a unit, so it merges as one value.
523
+ let models = local.models;
524
+ if (!jsonEqual(local.models, remote.models)) {
525
+ if (jsonEqual(local.models, base.models)) models = remote.models;
526
+ else if (!jsonEqual(remote.models, base.models)) conflicts.push("models");
527
+ }
528
+ put("models", models);
529
+
530
+ const folders = (refs: ProfileSkillRef[] | undefined) =>
531
+ new Set((refs ?? []).map(skillFolder));
532
+ const localFolders = folders(local.skills);
533
+ const released = [...folders(base.skills)].filter(
534
+ (f) => !localFolders.has(f),
535
+ );
536
+ if (released.length > 0 && remote.skills) {
537
+ const kept = remote.skills.filter(
538
+ (ref) => !released.includes(skillFolder(ref)),
539
+ );
540
+ put("skills", kept.length > 0 ? kept : undefined);
541
+ }
542
+ if (!jsonEqual(local, base) && local.updatedAt) {
543
+ out.updatedAt = local.updatedAt;
544
+ }
545
+ return { entry: out, conflicts };
546
+ }
547
+
548
+ /** One line per leaf where the local value was kept over a pulled change. */
549
+ export function describeConflict(at: string, profile: string): string {
550
+ return `Profile "${profile}": ${at} was changed both here and in profiles.json — kept the local value; the other is in git history`;
551
+ }
552
+
553
+ // ─── Reconcile ───────────────────────────────────────────────────────────────
554
+
555
+ export interface ReconcileResult {
556
+ /** The profile reconciled, or null when there was no usable marker. */
557
+ profile: string | null;
558
+ /** What was absorbed into the profile. */
559
+ changes: AbsorbChange[];
560
+ /** Live paths rewritten because profiles.json changed underneath them. */
561
+ regenerated: string[];
562
+ /** Live files that were missing and were written again. */
563
+ restored: string[];
564
+ /**
565
+ * Why nothing was written. When non-empty, the manifest and every live file
566
+ * are exactly as they were — a caller that writes profile state must refuse
567
+ * too (see {@link refuseOnProblems}).
568
+ */
569
+ problems: string[];
570
+ /** The files behind `problems` that could not be parsed. */
571
+ unreadable: SettingsParseError[];
572
+ /** Leaf key paths where a local value was kept over a pulled change. */
573
+ conflicts: string[];
574
+ /** Everything worth printing, in order. */
575
+ lines: string[];
576
+ }
577
+
578
+ function emptyResult(profile: string | null): ReconcileResult {
579
+ return {
580
+ profile,
581
+ changes: [],
582
+ regenerated: [],
583
+ restored: [],
584
+ problems: [],
585
+ unreadable: [],
586
+ conflicts: [],
587
+ lines: [],
588
+ };
589
+ }
590
+
591
+ /**
592
+ * Stop a caller that is about to write profile state when absorbing could
593
+ * not: throws the first unreadable file's `SettingsParseError` — which every
594
+ * surface already reports as "fix it by hand, magus leaves it as it is" — or
595
+ * an Error listing the problems.
596
+ */
597
+ export function refuseOnProblems(result: {
598
+ problems: string[];
599
+ unreadable?: SettingsParseError[];
600
+ }): void {
601
+ const [first] = result.unreadable ?? [];
602
+ if (first) throw first;
603
+ if (result.problems.length > 0) throw new Error(result.problems.join("\n"));
604
+ }
605
+
606
+ /**
607
+ * The plugin ids whose `false` in the live settings is the render's own
608
+ * output: every other profile's plugins now, plus every id recorded as
609
+ * rendered (`marker.allPluginIds`) that `base` does not enable. The recorded
610
+ * ids matter when a teammate removes a plugin from every profile — the live
611
+ * `false` for it is left over from the old render, not a deliberate disable.
612
+ */
613
+ function renderedFalseIds(
614
+ others: ReadonlySet<string>,
615
+ recorded: readonly string[] | undefined,
616
+ base: ProfileManifestEntry,
617
+ ): Set<string> {
618
+ const out = new Set(others);
619
+ const enabled = resolveExtends(base).plugins ?? {};
620
+ for (const id of recorded ?? []) {
621
+ if (!isOn(enabled[id])) out.add(id);
622
+ }
623
+ return out;
624
+ }
625
+
626
+ /** Every profile's plugin ids in the manifest as it is now. */
627
+ async function allPluginIdsOf(manifest: ProfileManifest): Promise<string[]> {
628
+ const out = new Set<string>();
629
+ for (const id of Object.keys(manifest.profiles)) {
630
+ const closure = await resolveProfile(manifest, id, { binResolver: noBins });
631
+ for (const pluginId of Object.keys(closure.plugins)) out.add(pluginId);
632
+ }
633
+ return [...out];
634
+ }
635
+
636
+ /**
637
+ * The live settings with the Claude CLI's install records taken out.
638
+ *
639
+ * `claude plugin install --scope project` only ever writes `true` for the
640
+ * plugin it installs, and magus only runs it for plugins some profile names
641
+ * (`installable`). So a `true` for one of those that `base` does not enable is
642
+ * the install's record, and gives way to what the render says for that id. A
643
+ * `true` for any other id — a plugin the user installed themselves — every
644
+ * `false`, and every other key are the user's, and stay. The ids taken out
645
+ * are appended to `discarded`.
646
+ */
647
+ function withoutCliRecords(
648
+ settings: Record<string, unknown>,
649
+ installable: ReadonlySet<string>,
650
+ base: ProfileManifestEntry,
651
+ renderedBase: RenderedProfile,
652
+ discarded: string[],
653
+ ): Record<string, unknown> {
654
+ if (!isJsonRecord(settings.enabledPlugins)) return settings;
655
+ const enabled: Record<string, unknown> = { ...settings.enabledPlugins };
656
+ const rendered = (renderedBase.settings.enabledPlugins ?? {}) as Record<
657
+ string,
658
+ unknown
659
+ >;
660
+ const baseEnabled = resolveExtends(base).plugins ?? {};
661
+ for (const [id, value] of Object.entries(enabled)) {
662
+ if (value !== true || !installable.has(id) || isOn(baseEnabled[id])) {
663
+ continue;
664
+ }
665
+ discarded.push(id);
666
+ if (id in rendered) enabled[id] = rendered[id];
667
+ else delete enabled[id];
668
+ }
669
+ return { ...settings, enabledPlugins: enabled };
670
+ }
671
+
672
+ /**
673
+ * Where `pinned` pins a plugin to a version and `entry` has it as "latest",
674
+ * the pin stands: absorption writes "latest" for any enable, because Claude
675
+ * Code never records a version.
676
+ */
677
+ function keepPins(
678
+ entry: ProfileManifestEntry,
679
+ pinned: ProfileManifestEntry,
680
+ ): ProfileManifestEntry {
681
+ let out = entry;
682
+ for (const [id, pin] of Object.entries(pinned.plugins ?? {})) {
683
+ if (
684
+ typeof pin === "string" &&
685
+ pin !== "" &&
686
+ pin !== "latest" &&
687
+ entry.plugins?.[id] === "latest"
688
+ ) {
689
+ out = { ...out, plugins: { ...out.plugins, [id]: pin } };
690
+ }
691
+ }
692
+ return out;
693
+ }
694
+
695
+ /**
696
+ * Keep everything the live config holds that `target` lacks: its plugins are
697
+ * added, and its settings, MCP servers and routing win where they differ. For
698
+ * when nothing records what the live config was rendered from, so an edit
699
+ * cannot be told apart from the rest — adding never drops a key.
700
+ */
701
+ function mergeAdditively(
702
+ target: ProfileManifestEntry,
703
+ everything: ProfileManifestEntry,
704
+ ): ProfileManifestEntry {
705
+ const merged: ProfileManifestEntry = { ...target };
706
+ const plugins = { ...(target.plugins ?? {}) };
707
+ for (const [id, pin] of Object.entries(everything.plugins ?? {})) {
708
+ if (!(id in plugins)) plugins[id] = pin;
709
+ }
710
+ if (Object.keys(plugins).length > 0) merged.plugins = plugins;
711
+ if (everything.settings) {
712
+ merged.settings = { ...(target.settings ?? {}), ...everything.settings };
713
+ }
714
+ if (everything.mcpServers) {
715
+ merged.mcpServers = {
716
+ ...(target.mcpServers ?? {}),
717
+ ...everything.mcpServers,
718
+ };
719
+ }
720
+ if (everything.models) merged.models = everything.models;
721
+ return merged;
722
+ }
723
+
724
+ /** A routing file that parses but is not a valid config, as a parse error. */
725
+ function checkModels(key: LiveFileKey, value: Record<string, unknown>): void {
726
+ if (key !== "models") return;
727
+ const errors = validateModelsConfig(value);
728
+ if (errors.length > 0) {
729
+ throw new SyntaxError(
730
+ errors.map((e) => `${e.path || "(root)"}: ${e.message}`).join("; "),
731
+ );
732
+ }
733
+ }
734
+
735
+ const noBins = async () => [];
736
+
737
+ /** Plugins enabled by profiles other than `name`, in the manifest as it is. */
738
+ async function otherProfilesPlugins(
739
+ manifest: ProfileManifest,
740
+ name: string,
741
+ ): Promise<Set<string>> {
742
+ const out = new Set<string>();
743
+ for (const id of Object.keys(manifest.profiles)) {
744
+ if (id === name) continue;
745
+ const closure = await resolveProfile(manifest, id, { binResolver: noBins });
746
+ for (const pluginId of Object.keys(closure.plugins)) out.add(pluginId);
747
+ }
748
+ return out;
749
+ }
750
+
751
+ function rel(projectPath: string, key: LiveFileKey): string {
752
+ return livePath(key, projectPath).slice(projectPath.length + 1);
753
+ }
754
+
755
+ /** What changed from `before` to `after`, as the lines absorption prints. */
756
+ function diffEntries(
757
+ before: ProfileManifestEntry,
758
+ after: ProfileManifestEntry,
759
+ ): AbsorbChange[] {
760
+ const changes: AbsorbChange[] = [];
761
+ const b = before.plugins ?? {};
762
+ const a = after.plugins ?? {};
763
+ for (const id of new Set([...Object.keys(b), ...Object.keys(a)])) {
764
+ if (jsonEqual(b[id], a[id])) continue;
765
+ if (a[id] === false) changes.push({ kind: "plugin-disabled", plugin: id });
766
+ else if (a[id] === undefined)
767
+ changes.push({ kind: "plugin-removed", plugin: id });
768
+ else if (!isOn(b[id])) changes.push({ kind: "plugin-added", plugin: id });
769
+ }
770
+ if (!jsonEqual(before.settings ?? {}, after.settings ?? {})) {
771
+ changes.push({ kind: "settings-changed" });
772
+ }
773
+ if (!jsonEqual(before.mcpServers ?? {}, after.mcpServers ?? {})) {
774
+ changes.push({ kind: "mcp-changed" });
775
+ }
776
+ if (!jsonEqual(before.models ?? null, after.models ?? null)) {
777
+ changes.push({ kind: "models-changed" });
778
+ }
779
+ return changes;
780
+ }
781
+
782
+ /**
783
+ * The marker names a profile profiles.json no longer defines, and the user is
784
+ * about to activate `target` (`profile switch`, `install`): carry whatever was
785
+ * changed in the live config since magus last wrote it into `target` first, so
786
+ * the activation that follows does not render over it.
787
+ *
788
+ * With the marker's `base` — the vanished profile's entry — the edits are the
789
+ * difference between the live files and what that entry rendered, merged into
790
+ * `target` like a pulled change. Without one, nothing tells an edit apart from
791
+ * what the vanished profile said, so everything the live config holds that
792
+ * `target` lacks is kept: its plugins are added, and its settings, MCP servers
793
+ * and routing win where they differ. Never a key dropped.
794
+ *
795
+ * Magus-installed skill folders of the vanished profile that were edited
796
+ * become custom skills; unedited ones are removed by the activation as usual.
797
+ * A live file that cannot be read is a problem, and nothing is changed.
798
+ */
799
+ export async function absorbOrphanedProfile(
800
+ projectPath: string,
801
+ target: string,
802
+ ): Promise<ReconcileResult> {
803
+ // An older layout's marker belongs to the older-layout path, which reads
804
+ // through its links (`absorbOlderLayout`).
805
+ if (!(await hasCurrentMarker(projectPath))) return emptyResult(null);
806
+ const marker = await readActiveMarker(projectPath);
807
+ if (!marker) return emptyResult(null);
808
+ const manifest = await readManifest(projectPath);
809
+ const targetEntry = manifest.profiles[target];
810
+ const orphan = marker.profile;
811
+ if (manifest.profiles[orphan] || !targetEntry) return emptyResult(null);
812
+ const result = emptyResult(target);
813
+
814
+ const edited: LiveFileKey[] = [];
815
+ for (const key of LIVE_FILE_KEYS) {
816
+ if ((await liveKind(key, projectPath)) !== "file") continue;
817
+ if ((await liveHash(key, projectPath)) !== marker.files[key]) {
818
+ edited.push(key);
819
+ }
820
+ }
821
+ const { edited: editedSkills } = await inspectManagedSkills(
822
+ marker.skills,
823
+ projectPath,
824
+ );
825
+ if (edited.length === 0 && editedSkills.length === 0) return result;
826
+
827
+ const base = marker.base ?? null;
828
+ const renderedBase = base
829
+ ? await renderProfile(
830
+ { ...manifest, profiles: { ...manifest.profiles, [orphan]: base } },
831
+ orphan,
832
+ )
833
+ : null;
834
+ const live: Record<LiveFileKey, Record<string, unknown> | null> = {
835
+ settings: renderedBase?.settings ?? {},
836
+ mcp: renderedBase?.mcp ?? { mcpServers: {} },
837
+ models: renderedBase?.models ?? null,
838
+ };
839
+ for (const key of edited) {
840
+ try {
841
+ const value = await readLiveJson(key, projectPath);
842
+ if (value) checkModels(key, value);
843
+ live[key] = value;
844
+ } catch (error) {
845
+ const why = error instanceof Error ? error.message : String(error);
846
+ result.unreadable.push(
847
+ new SettingsParseError(livePath(key, projectPath), error),
848
+ );
849
+ result.problems.push(
850
+ `${rel(projectPath, key)} cannot be read (${why}) — fix it by hand; magus left it as it is and did not switch`,
851
+ );
852
+ }
853
+ }
854
+ if (result.problems.length > 0) {
855
+ result.lines.push(...result.problems);
856
+ return result;
857
+ }
858
+
859
+ // Every manifest plugin is rendered as `false` where not wanted, so a
860
+ // `false` for one of them says nothing about an edit — nor does one for a
861
+ // plugin the last render named that no profile has any more.
862
+ const union = new Set<string>(marker.allPluginIds ?? []);
863
+ for (const id of Object.keys(manifest.profiles)) {
864
+ const closure = await resolveProfile(manifest, id, { binResolver: noBins });
865
+ for (const pluginId of Object.keys(closure.plugins)) union.add(pluginId);
866
+ }
867
+ const view: LiveView = {
868
+ settings: live.settings ?? {},
869
+ mcp: live.mcp ?? {},
870
+ models: live.models,
871
+ };
872
+
873
+ let merged: ProfileManifestEntry;
874
+ if (base) {
875
+ const local = computeAbsorb(base, view, union).entry;
876
+ const merge = merge3(base, local, targetEntry);
877
+ merged = merge.entry;
878
+ result.conflicts.push(...merge.conflicts);
879
+ } else {
880
+ const everything = computeAbsorb({ name: orphan }, view, union).entry;
881
+ merged = mergeAdditively(targetEntry, everything);
882
+ }
883
+ // A plugin the target pins keeps its pin when the edit merely enabled it.
884
+ for (const [id, pin] of Object.entries(targetEntry.plugins ?? {})) {
885
+ if (isOn(pin) && merged.plugins?.[id] === "latest") {
886
+ merged.plugins[id] = pin;
887
+ }
888
+ }
889
+
890
+ const changes = diffEntries(targetEntry, merged);
891
+ if (changes.length > 0) {
892
+ merged.updatedAt = new Date().toISOString();
893
+ await writeManifest(
894
+ { ...manifest, profiles: { ...manifest.profiles, [target]: merged } },
895
+ projectPath,
896
+ );
897
+ }
898
+ result.changes.push(...changes);
899
+
900
+ if (editedSkills.length > 0) {
901
+ const managed = { ...marker.skills };
902
+ for (const folder of editedSkills) {
903
+ delete managed[folder];
904
+ const ref = (base?.skills ?? []).find((r) => skillFolder(r) === folder);
905
+ result.changes.push({
906
+ kind: "skill-released",
907
+ skill: ref?.name ?? folder,
908
+ folder,
909
+ });
910
+ }
911
+ await writeActiveMarker({ ...marker, skills: managed }, projectPath);
912
+ await updateSkillIgnoreLines(
913
+ projectPath,
914
+ editedSkills,
915
+ Object.keys(managed),
916
+ );
917
+ }
918
+
919
+ if (result.changes.length > 0) {
920
+ result.lines.push(
921
+ `Profile "${orphan}" no longer exists in profiles.json — its unsaved changes were added to profile "${target}"`,
922
+ );
923
+ if (!base && changes.length > 0) {
924
+ result.lines.push(
925
+ `There is no record of what "${orphan}" contained, so everything in the live config that "${target}" lacked was kept`,
926
+ );
927
+ }
928
+ result.lines.push(...result.changes.map((c) => describeChange(c, target)));
929
+ result.lines.push(
930
+ ...result.conflicts.map((at) => describeConflict(at, target)),
931
+ );
932
+ }
933
+ return result;
934
+ }
935
+
936
+ /**
937
+ * Before an older layout is converted: record what Claude Code wrote through
938
+ * its links into profile `into`.
939
+ *
940
+ * magus 7.4.1 and earlier linked `.claude/settings.json`, `.mcp.json` and
941
+ * `.claude/models.json` into `.claude/_profiles/<name>/`, and Claude Code
942
+ * before 2.1.280 wrote its edits THROUGH those links. Each file is read
943
+ * through its link. (The release after 7.4.1 kept real settings/mcp files and
944
+ * a marker at `_profiles/.active` with their hashes; a real file whose hash
945
+ * still matches it is unedited and skipped.)
946
+ *
947
+ * The absorption is ADDITIVE ({@link absorbAdditively}). Those files were
948
+ * rendered at the last `magus install`, and 7.4.1 never re-rendered on a
949
+ * pull, so they may predate a teammate's change to profiles.json: a plugin
950
+ * profiles.json has that the old file lacks is as likely a pulled addition as
951
+ * a local removal, and the two cannot be told apart. So what the old files add
952
+ * is added; where they disagree with profiles.json, profiles.json's value is
953
+ * kept and the key named; and nothing is removed — a removal made under the
954
+ * older layout is not applied, which is the direction that loses nothing. The
955
+ * old files themselves are kept in the `-layout` backup.
956
+ *
957
+ * `named` is the profile the older layout names. When profiles.json no longer
958
+ * defines it, `into` is the profile about to be activated instead.
959
+ *
960
+ * A file that cannot be read stops everything: nothing is written, and the
961
+ * caller must not convert.
962
+ */
963
+ export async function absorbOlderLayout(
964
+ projectPath: string,
965
+ into: string,
966
+ named: string | null,
967
+ ): Promise<ReconcileResult> {
968
+ const manifest = await readManifest(projectPath);
969
+ const entry = manifest.profiles[into];
970
+ if (!entry) return emptyResult(null);
971
+ const result = emptyResult(into);
972
+ const legacyHashes = await readLegacyMarkerHashes(projectPath);
973
+ const rendered = await renderProfile(manifest, into);
974
+ const view: Record<LiveFileKey, Record<string, unknown> | null> = {
975
+ settings: rendered.settings,
976
+ mcp: rendered.mcp,
977
+ models: rendered.models,
978
+ };
979
+ let readAny = false;
980
+ for (const key of LIVE_FILE_KEYS) {
981
+ const read = await readLiveThrough(key, projectPath);
982
+ if (read.missing) continue;
983
+ if (
984
+ !read.isLink &&
985
+ legacyHashes?.[key] &&
986
+ read.hash === legacyHashes[key]
987
+ ) {
988
+ continue; // The release before's own unedited copy.
989
+ }
990
+ try {
991
+ if (read.error) throw read.error;
992
+ if (read.value) checkModels(key, read.value);
993
+ } catch (error) {
994
+ const parseError =
995
+ error instanceof SettingsParseError
996
+ ? error
997
+ : new SettingsParseError(livePath(key, projectPath), error);
998
+ result.unreadable.push(parseError);
999
+ result.problems.push(
1000
+ `${rel(projectPath, key)} cannot be read (${parseError.detail}) — fix it by hand; magus left the older layout as it is and did not convert it`,
1001
+ );
1002
+ continue;
1003
+ }
1004
+ view[key] = read.value;
1005
+ readAny = true;
1006
+ }
1007
+ if (result.problems.length > 0) {
1008
+ result.lines.push(...result.problems);
1009
+ return result;
1010
+ }
1011
+ if (!readAny) return result;
1012
+
1013
+ const exact = named === into;
1014
+ const absorbed = absorbAdditively(
1015
+ entry,
1016
+ {
1017
+ settings: view.settings ?? {},
1018
+ mcp: view.mcp ?? {},
1019
+ models: view.models,
1020
+ },
1021
+ await olderRenderIds(projectPath, named, rendered.allPluginIds),
1022
+ );
1023
+ const next = absorbed.entry;
1024
+ const changes = diffEntries(entry, next);
1025
+ if (changes.length > 0) {
1026
+ next.updatedAt = new Date().toISOString();
1027
+ await writeManifest(
1028
+ { ...manifest, profiles: { ...manifest.profiles, [into]: next } },
1029
+ projectPath,
1030
+ );
1031
+ result.changes.push(...changes);
1032
+ result.lines.push(
1033
+ exact
1034
+ ? `Changes Claude Code made under the older layout were added to profile "${into}"`
1035
+ : `Profile "${named ?? "?"}" no longer exists in profiles.json — its unsaved changes under the older layout were added to profile "${into}"`,
1036
+ );
1037
+ if (!exact) {
1038
+ result.lines.push(
1039
+ `There is no record of what "${named ?? "?"}" contained, so everything in its files that "${into}" lacked was added`,
1040
+ );
1041
+ }
1042
+ result.lines.push(...changes.map((c) => describeChange(c, into)));
1043
+ }
1044
+ result.conflicts.push(...absorbed.conflicts);
1045
+ result.lines.push(
1046
+ ...absorbed.conflicts.map((at) => describeLayoutConflict(at, into)),
1047
+ );
1048
+ if (absorbed.keptMore) {
1049
+ result.lines.push(
1050
+ `profiles.json has entries the older layout's files lack; they were kept — a removal made under the older layout is not applied (the older files are in the -layout backup under .claude/.magus-backups/)`,
1051
+ );
1052
+ }
1053
+ return result;
1054
+ }
1055
+
1056
+ /**
1057
+ * The plugin ids an older layout's render could have written as `false`.
1058
+ *
1059
+ * magus 7.4.1 wrote `false` only for plugins some profile named when it
1060
+ * rendered, and it rendered every profile: each OTHER profile's
1061
+ * `_profiles/<m>/settings.json` — a file Claude Code never writes, since only
1062
+ * the active profile's was linked — lists every id any profile named at that
1063
+ * render. Those, plus every id the manifest names now, are render output. A
1064
+ * `false` for any other id was written by the user (a project-scope disable
1065
+ * of a plugin enabled elsewhere) and is kept. With no other profile directory
1066
+ * to read, the manifest's ids alone decide.
1067
+ */
1068
+ async function olderRenderIds(
1069
+ projectPath: string,
1070
+ named: string | null,
1071
+ currentIds: readonly string[],
1072
+ ): Promise<Set<string>> {
1073
+ const ids = new Set(currentIds);
1074
+ for (const { name, dir } of await legacyProfileDirs(projectPath)) {
1075
+ if (name === named) continue;
1076
+ try {
1077
+ const settings: unknown = await fs.readJson(
1078
+ path.join(dir, "settings.json"),
1079
+ );
1080
+ if (isJsonRecord(settings) && isJsonRecord(settings.enabledPlugins)) {
1081
+ for (const id of Object.keys(settings.enabledPlugins)) ids.add(id);
1082
+ }
1083
+ } catch {
1084
+ // No readable render there; the others and the manifest still decide.
1085
+ }
1086
+ }
1087
+ return ids;
1088
+ }
1089
+
1090
+ /** One line for a value the older layout's file and profiles.json disagree on. */
1091
+ export function describeLayoutConflict(at: string, profile: string): string {
1092
+ return `Profile "${profile}": ${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/`;
1093
+ }
1094
+
1095
+ /**
1096
+ * Add to `entry` what `old` has and `entry` lacks, recursively, and nothing
1097
+ * else: objects gain missing keys, arrays of scalars gain missing items, and
1098
+ * a value both hold differently keeps `entry`'s — its path goes in
1099
+ * `conflicts`. `keptMore` records that `entry` holds something `old` lacks.
1100
+ */
1101
+ function addMissing(
1102
+ entryValue: unknown,
1103
+ oldValue: unknown,
1104
+ at: string,
1105
+ conflicts: string[],
1106
+ flags: { keptMore: boolean },
1107
+ ): unknown {
1108
+ if (oldValue === undefined) {
1109
+ if (entryValue !== undefined) flags.keptMore = true;
1110
+ return entryValue;
1111
+ }
1112
+ if (entryValue === undefined) return oldValue;
1113
+ if (jsonEqual(entryValue, oldValue)) return entryValue;
1114
+ if (isJsonRecord(entryValue) && isJsonRecord(oldValue)) {
1115
+ const out: Record<string, unknown> = { ...entryValue };
1116
+ for (const key of new Set([
1117
+ ...Object.keys(entryValue),
1118
+ ...Object.keys(oldValue),
1119
+ ])) {
1120
+ const value = addMissing(
1121
+ entryValue[key],
1122
+ oldValue[key],
1123
+ `${at}.${key}`,
1124
+ conflicts,
1125
+ flags,
1126
+ );
1127
+ if (value !== undefined) out[key] = value;
1128
+ }
1129
+ return out;
1130
+ }
1131
+ if (isScalarArray(entryValue) && isScalarArray(oldValue)) {
1132
+ const have = new Set(entryValue.map((v) => canonicalJson(v)));
1133
+ const oldHas = new Set(oldValue.map((v) => canonicalJson(v)));
1134
+ if ([...have].some((k) => !oldHas.has(k))) flags.keptMore = true;
1135
+ const out = [...entryValue];
1136
+ for (const item of oldValue) {
1137
+ const k = canonicalJson(item);
1138
+ if (have.has(k)) continue;
1139
+ out.push(item);
1140
+ have.add(k);
1141
+ }
1142
+ return out;
1143
+ }
1144
+ conflicts.push(at);
1145
+ return entryValue;
1146
+ }
1147
+
1148
+ /**
1149
+ * Absorb an older layout's files into `entry` ADDITIVELY — see
1150
+ * {@link absorbOlderLayout} for why nothing is removed.
1151
+ *
1152
+ * plugins a plugin the old file enables and the entry does not name is
1153
+ * added ("latest"); a `false` for a plugin the entry does not
1154
+ * name is skipped when `renderIds` holds it (the render wrote
1155
+ * it) and added as `false` when it does not (the user wrote
1156
+ * it); where the old file and the entry disagree — one
1157
+ * enables, the other says `false` — the entry's value stands
1158
+ * settings missing keys and missing array items are added, recursively
1159
+ * mcpServers servers the entry lacks are added
1160
+ * models added when the entry routes nothing
1161
+ *
1162
+ * Everywhere else the two disagree, the entry's value is kept and the key
1163
+ * path is returned in `conflicts`.
1164
+ */
1165
+ export function absorbAdditively(
1166
+ entry: ProfileManifestEntry,
1167
+ old: LiveView,
1168
+ renderIds: ReadonlySet<string>,
1169
+ ): { entry: ProfileManifestEntry; conflicts: string[]; keptMore: boolean } {
1170
+ const conflicts: string[] = [];
1171
+ const flags = { keptMore: false };
1172
+ const next: ProfileManifestEntry = { ...entry };
1173
+ const resolved = resolveExtends(entry);
1174
+
1175
+ // Plugins.
1176
+ const enabled = isJsonRecord(old.settings.enabledPlugins)
1177
+ ? old.settings.enabledPlugins
1178
+ : {};
1179
+ const own: Record<string, string | boolean> = { ...(entry.plugins ?? {}) };
1180
+ for (const [id, value] of Object.entries(enabled)) {
1181
+ const declared = resolved.plugins?.[id];
1182
+ if (value === true) {
1183
+ if (isOn(declared)) continue;
1184
+ if (declared === false) conflicts.push(`plugins.${id}`);
1185
+ else own[id] = "latest";
1186
+ } else if (value === false && isOn(declared)) {
1187
+ conflicts.push(`plugins.${id}`);
1188
+ } else if (
1189
+ value === false &&
1190
+ declared === undefined &&
1191
+ !renderIds.has(id)
1192
+ ) {
1193
+ own[id] = false;
1194
+ }
1195
+ }
1196
+ for (const [id, declared] of Object.entries(resolved.plugins ?? {})) {
1197
+ if (isOn(declared) && enabled[id] === undefined) flags.keptMore = true;
1198
+ }
1199
+ setOrDelete(next, "plugins", own);
1200
+
1201
+ // Settings, recursively; `extends` keys compare against what they resolve to.
1202
+ const { enabledPlugins: _drop, ...oldSettings } = withoutMachineKeys(
1203
+ old.settings,
1204
+ );
1205
+ const resolvedSettings = resolved.settings ?? {};
1206
+ const merged = addMissing(
1207
+ resolvedSettings,
1208
+ oldSettings,
1209
+ "settings",
1210
+ conflicts,
1211
+ flags,
1212
+ ) as Record<string, unknown>;
1213
+ const ownSettings: Record<string, unknown> = { ...(entry.settings ?? {}) };
1214
+ for (const [key, value] of Object.entries(merged)) {
1215
+ if (!jsonEqual(value, resolvedSettings[key])) ownSettings[key] = value;
1216
+ }
1217
+ setOrDelete(next, "settings", ownSettings);
1218
+
1219
+ // MCP servers: added whole, never merged into a server the entry has.
1220
+ const oldServers = isJsonRecord(old.mcp.mcpServers)
1221
+ ? (old.mcp.mcpServers as Record<string, McpServerConfig>)
1222
+ : {};
1223
+ const servers: Record<string, McpServerConfig> = {
1224
+ ...(entry.mcpServers ?? {}),
1225
+ };
1226
+ for (const [name, config] of Object.entries(oldServers)) {
1227
+ if (!(name in servers)) servers[name] = config;
1228
+ else if (!jsonEqual(servers[name], config)) {
1229
+ conflicts.push(`mcpServers.${name}`);
1230
+ }
1231
+ }
1232
+ for (const name of Object.keys(entry.mcpServers ?? {})) {
1233
+ if (!(name in oldServers)) flags.keptMore = true;
1234
+ }
1235
+ setOrDelete(next, "mcpServers", servers);
1236
+
1237
+ // Model routing: a unit.
1238
+ if (old.models) {
1239
+ if (!resolved.models) {
1240
+ next.models = old.models as unknown as ProfileManifestEntry["models"];
1241
+ } else if (!jsonEqual(resolved.models, old.models)) {
1242
+ conflicts.push("models");
1243
+ }
1244
+ }
1245
+
1246
+ return { entry: next, conflicts, keptMore: flags.keptMore };
1247
+ }
1248
+
1249
+ /**
1250
+ * Absorb edits to the live config into the active profile, then regenerate the
1251
+ * live config if the profile changed in profiles.json. See the module doc.
1252
+ *
1253
+ * Does nothing without a current-format marker naming a profile profiles.json
1254
+ * defines — the active-profile check (`profile-state.ts`) reports those
1255
+ * states, and an older layout is absorbed through its links instead
1256
+ * (`absorbOlderLayout`).
1257
+ *
1258
+ * Writes NOTHING when a live file cannot be read: `problems` then says why,
1259
+ * and the manifest and every live file are exactly as they were.
1260
+ */
1261
+ export interface ReconcileOptions
1262
+ extends Pick<ActivateOptions, "installSkill"> {
1263
+ /**
1264
+ * Set by `withCliBookkeeping` when the Claude CLI's installs have just
1265
+ * finished normally: discarding their records is expected then, and not
1266
+ * worth a line. Without it, discarding them means a run was interrupted.
1267
+ */
1268
+ cliRunFinished?: boolean;
1269
+ }
1270
+
1271
+ export async function reconcileActiveProfile(
1272
+ projectPath: string,
1273
+ opts: ReconcileOptions = {},
1274
+ ): Promise<ReconcileResult> {
1275
+ if (!(await hasCurrentMarker(projectPath))) return emptyResult(null);
1276
+ const marker = await readActiveMarker(projectPath);
1277
+ if (!marker) return emptyResult(null);
1278
+ const manifest = await readManifest(projectPath);
1279
+ const current = manifest.profiles[marker.profile];
1280
+ if (!current) return emptyResult(null);
1281
+ const name = marker.profile;
1282
+
1283
+ const edited: LiveFileKey[] = [];
1284
+ const missing: LiveFileKey[] = [];
1285
+ for (const key of LIVE_FILE_KEYS) {
1286
+ const kind = await liveKind(key, projectPath);
1287
+ if (kind === "file") {
1288
+ if ((await liveHash(key, projectPath)) !== marker.files[key]) {
1289
+ edited.push(key);
1290
+ }
1291
+ } else if (kind === "missing" && marker.files[key] !== undefined) {
1292
+ missing.push(key);
1293
+ }
1294
+ }
1295
+ const { edited: editedSkills } = await inspectManagedSkills(
1296
+ marker.skills,
1297
+ projectPath,
1298
+ );
1299
+ // Everything the live files are rendered from, not just this entry: a
1300
+ // teammate's change to ANOTHER profile's plugins changes which `false`
1301
+ // entries this profile writes.
1302
+ const definitionChanged =
1303
+ (await renderProfile(manifest, name)).definition !== marker.definition;
1304
+ // Whether THIS entry changed underneath the live files — the case that
1305
+ // needs merging against `base`, and the one the message names.
1306
+ const entryChanged =
1307
+ marker.base !== undefined && !jsonEqual(marker.base, current);
1308
+
1309
+ if (
1310
+ edited.length === 0 &&
1311
+ missing.length === 0 &&
1312
+ editedSkills.length === 0 &&
1313
+ !definitionChanged &&
1314
+ !marker.cliBookkeeping
1315
+ ) {
1316
+ return emptyResult(name);
1317
+ }
1318
+
1319
+ const result = emptyResult(name);
1320
+
1321
+ // ── a. absorb ────────────────────────────────────────────────────────────
1322
+ const base = marker.base ?? current;
1323
+ const renderedBase = await renderProfile(
1324
+ { ...manifest, profiles: { ...manifest.profiles, [name]: base } },
1325
+ name,
1326
+ );
1327
+ const live: Record<LiveFileKey, Record<string, unknown> | null> = {
1328
+ settings: renderedBase.settings,
1329
+ mcp: renderedBase.mcp,
1330
+ models: renderedBase.models,
1331
+ };
1332
+ const parsed = new Map<LiveFileKey, Record<string, unknown>>();
1333
+ const discarded: string[] = [];
1334
+ for (const key of LIVE_FILE_KEYS) {
1335
+ if ((await liveKind(key, projectPath)) !== "file") continue;
1336
+ try {
1337
+ let value = await readLiveJson(key, projectPath);
1338
+ if (!value) continue;
1339
+ checkModels(key, value);
1340
+ // The Claude CLI's installs ran (or were interrupted) since the last
1341
+ // render: see `withCliBookkeeping`. Its records are bookkeeping, not
1342
+ // edits — but only a `true` for a plugin the install could have
1343
+ // written is one of them. Everything else is the user's: a plugin
1344
+ // they installed themselves, a `false`, every other key.
1345
+ if (key === "settings" && marker.cliBookkeeping) {
1346
+ value = withoutCliRecords(
1347
+ value,
1348
+ new Set(marker.allPluginIds ?? (await allPluginIdsOf(manifest))),
1349
+ base,
1350
+ renderedBase,
1351
+ discarded,
1352
+ );
1353
+ }
1354
+ parsed.set(key, value);
1355
+ live[key] = value;
1356
+ } catch (error) {
1357
+ const why = error instanceof Error ? error.message : String(error);
1358
+ if (edited.includes(key)) {
1359
+ result.unreadable.push(
1360
+ new SettingsParseError(livePath(key, projectPath), error),
1361
+ );
1362
+ result.problems.push(
1363
+ `${rel(projectPath, key)} cannot be read (${why}) — fix it by hand; magus left it as it is and did not absorb it`,
1364
+ );
1365
+ }
1366
+ }
1367
+ }
1368
+ // Nothing is written — not the manifest, not a regenerated file — when a
1369
+ // live file cannot be read. It was reported as left as it is; it must be.
1370
+ if (result.problems.length > 0) {
1371
+ result.lines.push(...result.problems);
1372
+ return result;
1373
+ }
1374
+ if (discarded.length > 0 && !opts.cliRunFinished) {
1375
+ result.lines.push(
1376
+ `An interrupted magus install left the Claude CLI's plugin records in .claude/settings.json — discarded them (${discarded.join(", ")})`,
1377
+ );
1378
+ }
1379
+
1380
+ let local: ProfileManifestEntry = base;
1381
+ if (edited.some((key) => parsed.has(key))) {
1382
+ const absorbed = computeAbsorb(
1383
+ base,
1384
+ {
1385
+ settings: live.settings ?? {},
1386
+ mcp: live.mcp ?? {},
1387
+ models: live.models,
1388
+ },
1389
+ renderedFalseIds(
1390
+ await otherProfilesPlugins(manifest, name),
1391
+ marker.allPluginIds,
1392
+ base,
1393
+ ),
1394
+ );
1395
+ local = absorbed.entry;
1396
+ result.changes.push(...absorbed.changes);
1397
+ }
1398
+
1399
+ const released = new Set(editedSkills);
1400
+ if (released.size > 0) {
1401
+ const refs = local.skills ?? [];
1402
+ for (const folder of released) {
1403
+ const ref = refs.find((r) => skillFolder(r) === folder);
1404
+ result.changes.push({
1405
+ kind: "skill-released",
1406
+ skill: ref?.name ?? folder,
1407
+ folder,
1408
+ });
1409
+ }
1410
+ const kept = refs.filter((r) => !released.has(skillFolder(r)));
1411
+ local = { ...local, updatedAt: new Date().toISOString() };
1412
+ if (kept.length > 0) local.skills = kept;
1413
+ // biome-ignore lint/performance/noDelete: absent, not undefined, in profiles.json
1414
+ else delete local.skills;
1415
+ }
1416
+
1417
+ let merged = local;
1418
+ if (entryChanged) {
1419
+ const merge = merge3(base, local, current);
1420
+ merged = merge.entry;
1421
+ result.conflicts.push(...merge.conflicts);
1422
+ }
1423
+ // An absorbed enable is "latest" — Claude Code never says which version —
1424
+ // so a plugin profiles.json pins keeps its pin (the orphan path's rule).
1425
+ merged = keepPins(merged, current);
1426
+ const nextManifest: ProfileManifest = {
1427
+ ...manifest,
1428
+ profiles: { ...manifest.profiles, [name]: merged },
1429
+ };
1430
+ if (!jsonEqual(merged, current))
1431
+ await writeManifest(nextManifest, projectPath);
1432
+
1433
+ const managed: Record<string, string> = {};
1434
+ for (const [folder, hash] of Object.entries(marker.skills)) {
1435
+ if (!released.has(folder)) managed[folder] = hash;
1436
+ }
1437
+ if (released.size > 0) {
1438
+ await updateSkillIgnoreLines(
1439
+ projectPath,
1440
+ [...released],
1441
+ Object.keys(managed),
1442
+ );
1443
+ }
1444
+
1445
+ // ── b. regenerate ────────────────────────────────────────────────────────
1446
+ const notes: string[] = [];
1447
+ if (definitionChanged) {
1448
+ // The marker loses the released folders first, so activation neither
1449
+ // removes them nor keeps an ignore line for them.
1450
+ await writeActiveMarker({ ...marker, skills: managed }, projectPath);
1451
+ const before = new Map<LiveFileKey, string | null>();
1452
+ for (const key of LIVE_FILE_KEYS) {
1453
+ before.set(key, await liveHash(key, projectPath));
1454
+ }
1455
+ try {
1456
+ const activation = await activateProfile(
1457
+ nextManifest,
1458
+ name,
1459
+ projectPath,
1460
+ {
1461
+ installSkill: opts.installSkill,
1462
+ },
1463
+ );
1464
+ notes.push(...activationNotes(activation));
1465
+ } catch (error) {
1466
+ result.problems.push(
1467
+ error instanceof Error ? error.message : String(error),
1468
+ );
1469
+ }
1470
+ for (const key of LIVE_FILE_KEYS) {
1471
+ if ((await liveHash(key, projectPath)) !== before.get(key)) {
1472
+ result.regenerated.push(rel(projectPath, key));
1473
+ }
1474
+ }
1475
+ } else {
1476
+ const rendered: RenderedProfile = await renderProfile(nextManifest, name);
1477
+ const files: ActiveMarker["files"] = { ...marker.files };
1478
+ for (const key of LIVE_FILE_KEYS) {
1479
+ if (missing.includes(key)) {
1480
+ const hash = await writeLiveFile(key, rendered[key], projectPath);
1481
+ if (hash) {
1482
+ files[key] = hash;
1483
+ result.restored.push(rel(projectPath, key));
1484
+ } else {
1485
+ delete files[key];
1486
+ }
1487
+ continue;
1488
+ }
1489
+ if (!edited.includes(key) || !parsed.has(key)) continue;
1490
+ const value = parsed.get(key) ?? null;
1491
+ if (losesNothing(key, value, rendered[key])) {
1492
+ const hash = await writeLiveFile(key, rendered[key], projectPath);
1493
+ if (hash) files[key] = hash;
1494
+ else delete files[key];
1495
+ } else if (key === "settings" && marker.cliBookkeeping) {
1496
+ // It says something the manifest cannot express, and it still
1497
+ // holds the CLI's records: written without them, never kept as is.
1498
+ const hash = await writeLiveFile(key, value, projectPath);
1499
+ if (hash) files[key] = hash;
1500
+ } else {
1501
+ // The file says something the manifest cannot express; it is
1502
+ // kept byte for byte and recorded as it stands.
1503
+ const hash = await liveHash(key, projectPath);
1504
+ if (hash) files[key] = hash;
1505
+ }
1506
+ }
1507
+ await writeActiveMarker(
1508
+ markerFor(name, rendered, files, managed),
1509
+ projectPath,
1510
+ );
1511
+ }
1512
+
1513
+ result.lines.push(...result.changes.map((c) => describeChange(c, name)));
1514
+ result.lines.push(
1515
+ ...result.conflicts.map((at) => describeConflict(at, name)),
1516
+ );
1517
+ // A marker from before definitions were recorded regenerates silently: that
1518
+ // is a conversion, not a change anybody made to profiles.json.
1519
+ if (result.regenerated.length > 0 && marker.definition !== undefined) {
1520
+ result.lines.push(
1521
+ entryChanged || marker.base === undefined
1522
+ ? `Profile "${name}" changed in profiles.json — regenerated ${result.regenerated.join(", ")}`
1523
+ : `Another profile's plugins changed in profiles.json — regenerated ${result.regenerated.join(", ")} for profile "${name}"`,
1524
+ );
1525
+ }
1526
+ result.lines.push(...notes);
1527
+ for (const file of result.restored) {
1528
+ result.lines.push(`Restored ${file} from profile "${name}"`);
1529
+ }
1530
+ result.lines.push(...result.problems);
1531
+ return result;
1532
+ }