magus-cli 7.4.1 → 7.5.1

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