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,706 @@
1
+ /**
2
+ * Which profile is active — strictly — and getting the project into a state
3
+ * magus can act on, before any command does.
4
+ *
5
+ * {@link checkActiveProfile} answers the question without guessing:
6
+ *
7
+ * active the marker names a profile profiles.json defines
8
+ * unknown the marker names a profile profiles.json does NOT define —
9
+ * magus must not act on profile state; the fix is
10
+ * `magus profile switch <name>`
11
+ * old-layout a defined profile is named, and something of an older layout
12
+ * remains (links into `.claude/_profiles/`, the directory
13
+ * itself, the older marker); converted by
14
+ * {@link prepareProfileState} after what Claude Code wrote
15
+ * through the old links is added to the profile
16
+ * unmarked no marker. If the live config equals the rendered output of
17
+ * exactly one profile, the marker is restored for it and the
18
+ * state is `active`; otherwise the TUI asks once and a
19
+ * non-interactive CLI stops with the instruction.
20
+ * no-profiles profiles.json defines none (or does not exist)
21
+ *
22
+ * {@link prepareProfileState} is what entry points call: the check, then —
23
+ * for an active profile — absorbing Claude Code's edits and regenerating from
24
+ * a changed profiles.json (`profile-absorb.ts`). {@link inspectProfileState}
25
+ * is its read-only twin, for `magus doctor` without `--fix`.
26
+ */
27
+
28
+ import path from "node:path";
29
+ import type { ProfileManifest } from "../types/index.js";
30
+ import { readManifest, writeManifest } from "./manifest.js";
31
+ import {
32
+ type ReconcileResult,
33
+ absorbOlderLayout,
34
+ absorbOrphanedProfile,
35
+ reconcileActiveProfile,
36
+ refuseOnProblems,
37
+ } from "./profile-absorb.js";
38
+ import {
39
+ type ActivateOptions,
40
+ type ActivationResult,
41
+ type ProfileChoice,
42
+ activateProfile,
43
+ activationNotes,
44
+ activeWritableSkillFolders,
45
+ assertNoTrackedWriteTargets,
46
+ writableSkillFolders,
47
+ writeRefusal,
48
+ } from "./profile-activate.js";
49
+ import { adoptCurrentProject } from "./profile-adopt.js";
50
+ import {
51
+ type ActiveMarker,
52
+ LIVE_FILE_KEYS,
53
+ MACHINE_LOCAL_SETTINGS_KEYS,
54
+ folderHash,
55
+ hasCurrentMarker,
56
+ hasOlderLayout,
57
+ jsonEqual,
58
+ liveHash,
59
+ liveKind,
60
+ livePath,
61
+ markerFor,
62
+ namedActiveProfile,
63
+ readActiveMarker,
64
+ readLiveJsonSafely,
65
+ skillsRoot,
66
+ unreadableLiveFiles,
67
+ writeActiveMarker,
68
+ } from "./profile-live.js";
69
+ import { renderProfile } from "./profile-render.js";
70
+ import {
71
+ SkillClashError,
72
+ inspectManagedSkills,
73
+ skillFolder,
74
+ } from "./profile-skills.js";
75
+ import type { SettingsParseError } from "./settings-file.js";
76
+ import type { LiveFileRefusal } from "./tracked-live-files.js";
77
+
78
+ export type ActiveProfileCheck =
79
+ | { state: "no-profiles" }
80
+ | { state: "active"; profile: string; restored: boolean }
81
+ | { state: "old-layout"; profile: string }
82
+ | {
83
+ state: "unknown";
84
+ profile: string;
85
+ available: string[];
86
+ /** The profile to suggest switching to, or null to say `<name>`. */
87
+ suggested: string | null;
88
+ }
89
+ | {
90
+ state: "unmarked";
91
+ available: string[];
92
+ liveFiles: boolean;
93
+ suggested: string | null;
94
+ /**
95
+ * The one profile the live config matches exactly, when the check
96
+ * was told not to restore the marker for it (`restore: false`).
97
+ */
98
+ match: string | null;
99
+ };
100
+
101
+ /**
102
+ * The profile a "run `magus profile switch …`" hint should name: the one the
103
+ * manifest declares as its default, or the only one there is. Null when
104
+ * neither says, and the hint names `<name>` with the choices listed.
105
+ */
106
+ export function suggestedProfile(manifest: ProfileManifest): string | null {
107
+ const ids = Object.keys(manifest.profiles);
108
+ if (manifest.default !== undefined && ids.includes(manifest.default)) {
109
+ return manifest.default;
110
+ }
111
+ return ids.length === 1 ? (ids[0] as string) : null;
112
+ }
113
+
114
+ /**
115
+ * The switch command to print: a real profile name when there is one to
116
+ * suggest, never literal angle brackets around one.
117
+ */
118
+ export function switchCommand(suggested: string | null): string {
119
+ return suggested !== null
120
+ ? `magus profile switch ${suggested}`
121
+ : "magus profile switch <name>";
122
+ }
123
+
124
+ /** The hint sentence: the command, plus the choices when it says `<name>`. */
125
+ export function switchHint(
126
+ available: readonly string[],
127
+ suggested: string | null,
128
+ ): string {
129
+ const command = `\`${switchCommand(suggested)}\``;
130
+ return suggested !== null
131
+ ? command
132
+ : `${command} (profiles: ${available.join(", ")})`;
133
+ }
134
+
135
+ function withoutMachineKeys(
136
+ settings: Record<string, unknown>,
137
+ ): Record<string, unknown> {
138
+ const out = { ...settings };
139
+ for (const key of MACHINE_LOCAL_SETTINGS_KEYS) delete out[key];
140
+ return out;
141
+ }
142
+
143
+ /** Profiles whose rendered output equals the live config exactly. */
144
+ async function matchingProfiles(
145
+ manifest: ProfileManifest,
146
+ projectPath: string,
147
+ ): Promise<string[]> {
148
+ const settings = await readLiveJsonSafely("settings", projectPath);
149
+ if (!settings) return [];
150
+ const mcp = (await readLiveJsonSafely("mcp", projectPath)) ?? {
151
+ mcpServers: {},
152
+ };
153
+ const models = await readLiveJsonSafely("models", projectPath);
154
+ const matches: string[] = [];
155
+ for (const id of Object.keys(manifest.profiles)) {
156
+ const rendered = await renderProfile(manifest, id, projectPath);
157
+ if (
158
+ jsonEqual(withoutMachineKeys(settings), rendered.settings) &&
159
+ jsonEqual(mcp, rendered.mcp) &&
160
+ jsonEqual(models, rendered.models)
161
+ ) {
162
+ matches.push(id);
163
+ }
164
+ }
165
+ return matches;
166
+ }
167
+
168
+ /**
169
+ * Write a marker for `profile` from the live files as they stand. Its skills
170
+ * whose folders exist are recorded as magus-installed: the live config is
171
+ * exactly this profile's, so they are its own.
172
+ */
173
+ async function restoreMarker(
174
+ manifest: ProfileManifest,
175
+ profile: string,
176
+ projectPath: string,
177
+ ): Promise<void> {
178
+ const entry = manifest.profiles[profile];
179
+ if (!entry) return;
180
+ const files: ActiveMarker["files"] = {};
181
+ for (const key of LIVE_FILE_KEYS) {
182
+ const hash = await liveHash(key, projectPath);
183
+ if (hash) files[key] = hash;
184
+ }
185
+ const skills: Record<string, string> = {};
186
+ for (const ref of entry.skills ?? []) {
187
+ const folder = skillFolder(ref);
188
+ const hash = await folderHash(path.join(skillsRoot(projectPath), folder));
189
+ if (hash) skills[folder] = hash;
190
+ }
191
+ await writeActiveMarker(
192
+ markerFor(
193
+ profile,
194
+ await renderProfile(manifest, profile, projectPath),
195
+ files,
196
+ skills,
197
+ ),
198
+ projectPath,
199
+ );
200
+ }
201
+
202
+ export interface CheckOptions {
203
+ /**
204
+ * Restore a lost marker when the live config matches exactly one profile —
205
+ * the check's only write. `magus doctor` without `--fix` turns it off.
206
+ */
207
+ restore?: boolean;
208
+ }
209
+
210
+ /**
211
+ * The strict active-profile check. Restoring a lost marker for a unique match
212
+ * is its only write, and only unless `restore: false`. Throws
213
+ * `SettingsParseError` when profiles.json does not parse.
214
+ */
215
+ export async function checkActiveProfile(
216
+ projectPath: string,
217
+ opts: CheckOptions = {},
218
+ ): Promise<ActiveProfileCheck> {
219
+ const manifest = await readManifest(projectPath);
220
+ const available = Object.keys(manifest.profiles);
221
+ if (available.length === 0) return { state: "no-profiles" };
222
+
223
+ const named = await namedActiveProfile(projectPath);
224
+ if (named) {
225
+ if (!available.includes(named.profile)) {
226
+ return {
227
+ state: "unknown",
228
+ profile: named.profile,
229
+ available,
230
+ suggested: suggestedProfile(manifest),
231
+ };
232
+ }
233
+ return (await hasOlderLayout(projectPath))
234
+ ? { state: "old-layout", profile: named.profile }
235
+ : { state: "active", profile: named.profile, restored: false };
236
+ }
237
+
238
+ const matches = await matchingProfiles(manifest, projectPath);
239
+ if (matches.length === 1 && opts.restore !== false) {
240
+ const [profile] = matches as [string];
241
+ await restoreMarker(manifest, profile, projectPath);
242
+ return { state: "active", profile, restored: true };
243
+ }
244
+ let liveFiles = false;
245
+ for (const key of LIVE_FILE_KEYS) {
246
+ if ((await liveKind(key, projectPath)) !== "missing") liveFiles = true;
247
+ }
248
+ const match = matches.length === 1 ? (matches[0] as string) : null;
249
+ return {
250
+ state: "unmarked",
251
+ available,
252
+ liveFiles,
253
+ suggested: match ?? suggestedProfile(manifest),
254
+ match,
255
+ };
256
+ }
257
+
258
+ /** The message for a marker naming a profile profiles.json does not define. */
259
+ export function describeUnknownProfile(
260
+ check: Extract<ActiveProfileCheck, { state: "unknown" }>,
261
+ ): string {
262
+ return `The active profile is recorded as "${check.profile}", but .claude/profiles.json defines no such profile. Run ${switchHint(check.available, check.suggested)}.`;
263
+ }
264
+
265
+ /** The message for a project with profiles and no marker to say which is live. */
266
+ export function describeUnmarked(
267
+ check: Extract<ActiveProfileCheck, { state: "unmarked" }>,
268
+ ): string {
269
+ return `No profile is active in this project, and its live config matches no single profile. Run ${switchHint(check.available, check.suggested)}.`;
270
+ }
271
+
272
+ /**
273
+ * Before an older layout is converted to activate `target`: add what Claude
274
+ * Code wrote through its links to the profile it belongs to — the one the
275
+ * layout names, or `target` when profiles.json no longer defines that one.
276
+ *
277
+ * Null when there is nothing of the kind to do: a current marker exists (its
278
+ * live files are reconciled instead), there is no older layout, or the layout
279
+ * names no profile (nothing was written through a link; a real file there is
280
+ * a first adoption's to back up).
281
+ */
282
+ export async function absorbBeforeConversion(
283
+ projectPath: string,
284
+ target: string,
285
+ ): Promise<ReconcileResult | null> {
286
+ if (await hasCurrentMarker(projectPath)) return null;
287
+ if (!(await hasOlderLayout(projectPath))) return null;
288
+ const named = (await namedActiveProfile(projectPath))?.profile ?? null;
289
+ if (named === null) return null;
290
+ const manifest = await readManifest(projectPath);
291
+ const into = manifest.profiles[named] ? named : target;
292
+ return absorbOlderLayout(projectPath, into, named);
293
+ }
294
+
295
+ export interface ProfileStatePrep {
296
+ check: ActiveProfileCheck;
297
+ reconcile: ReconcileResult | null;
298
+ /** Everything worth printing, in order. */
299
+ lines: string[];
300
+ /**
301
+ * Things that stop a command which would write profile state. When
302
+ * non-empty, nothing was written.
303
+ */
304
+ problems: string[];
305
+ /** The files behind `problems` that could not be parsed. */
306
+ unreadable: SettingsParseError[];
307
+ /**
308
+ * Set when a file magus would write is tracked by git, or git could not
309
+ * say: nothing ran, and `problems` holds its message.
310
+ */
311
+ refusal?: LiveFileRefusal;
312
+ }
313
+
314
+ /**
315
+ * The entry-point gate: check which profile is active, restore a lost marker,
316
+ * absorb/regenerate (see `profile-absorb.ts`), and convert an older layout —
317
+ * after adding what Claude Code wrote through its links to the profile.
318
+ * Idempotent and cheap when nothing changed. Writes nothing when a live file
319
+ * cannot be read (`problems`).
320
+ */
321
+ export async function prepareProfileState(
322
+ projectPath: string,
323
+ opts: Pick<ActivateOptions, "installSkill"> = {},
324
+ ): Promise<ProfileStatePrep> {
325
+ // A project with profiles whose live files — or the active profile's
326
+ // magus-installed skill folders — git tracks: every path below would write
327
+ // one of them (or the marker, or the manifest on the way), so nothing runs,
328
+ // not even the marker a unique match would restore.
329
+ const manifest = await readManifest(projectPath);
330
+ if (Object.keys(manifest.profiles).length > 0) {
331
+ const refusal = await writeRefusal(
332
+ projectPath,
333
+ await activeWritableSkillFolders(projectPath, manifest),
334
+ );
335
+ if (refusal) {
336
+ return {
337
+ check: await checkActiveProfile(projectPath, { restore: false }),
338
+ reconcile: null,
339
+ lines: [],
340
+ problems: [refusal.message],
341
+ unreadable: [],
342
+ refusal,
343
+ };
344
+ }
345
+ }
346
+ const check = await checkActiveProfile(projectPath);
347
+ const out: ProfileStatePrep = {
348
+ check,
349
+ reconcile: null,
350
+ lines: [],
351
+ problems: [],
352
+ unreadable: [],
353
+ };
354
+ const take = (r: ReconcileResult) => {
355
+ out.lines.push(...r.lines.filter((l) => !r.problems.includes(l)));
356
+ out.problems.push(...r.problems);
357
+ out.unreadable.push(...r.unreadable);
358
+ };
359
+
360
+ if (check.state === "active" && check.restored) {
361
+ out.lines.push(
362
+ `No active-profile marker, but the live config matches profile "${check.profile}" exactly — restored it`,
363
+ );
364
+ }
365
+
366
+ if (check.state === "active" || check.state === "old-layout") {
367
+ const profile = check.profile;
368
+ if (await hasCurrentMarker(projectPath)) {
369
+ out.reconcile = await reconcileActiveProfile(projectPath, opts);
370
+ take(out.reconcile);
371
+ if (out.problems.length > 0) return out;
372
+ }
373
+ if (await hasOlderLayout(projectPath)) {
374
+ const absorbed = await absorbBeforeConversion(projectPath, profile);
375
+ if (absorbed) {
376
+ take(absorbed);
377
+ if (out.problems.length > 0) return out;
378
+ }
379
+ const result = await activateProfile(
380
+ await readManifest(projectPath),
381
+ profile,
382
+ projectPath,
383
+ { installSkill: opts.installSkill },
384
+ );
385
+ if (check.state === "old-layout") {
386
+ out.lines.push(
387
+ `Converted profile "${profile}" from the older layout to real files`,
388
+ );
389
+ }
390
+ out.lines.push(...activationNotes(result));
391
+ }
392
+ out.check = { state: "active", profile, restored: false };
393
+ }
394
+ return out;
395
+ }
396
+
397
+ export interface ProfileStateReport {
398
+ /** The strict check, made without restoring anything. */
399
+ check: ActiveProfileCheck;
400
+ /** What the next magus command (or `magus doctor --fix`) would do. */
401
+ pending: string[];
402
+ /** Live files that do not parse; magus writes none of them. */
403
+ unreadable: SettingsParseError[];
404
+ }
405
+
406
+ /**
407
+ * What {@link prepareProfileState} would do, without doing any of it — for
408
+ * `magus doctor` without `--fix`, which must not convert or write anything.
409
+ */
410
+ export async function inspectProfileState(
411
+ projectPath: string,
412
+ ): Promise<ProfileStateReport> {
413
+ const check = await checkActiveProfile(projectPath, { restore: false });
414
+ const pending: string[] = [];
415
+ const unreadable = await unreadableLiveFiles(projectPath);
416
+ const rel = (p: string) => path.relative(projectPath, p);
417
+
418
+ if (check.state === "old-layout") {
419
+ pending.push(
420
+ `Profile "${check.profile}" is on an older magus layout (links into .claude/_profiles/). The next magus command, or \`magus doctor --fix\`, adds any changes Claude Code made under it to profiles.json, converts it to real files, and keeps .claude/_profiles/ in .claude/.magus-backups/.`,
421
+ );
422
+ } else if (check.state === "unmarked" && check.match !== null) {
423
+ pending.push(
424
+ `No active-profile marker, but the live config matches profile "${check.match}" exactly — the next magus command, or \`magus doctor --fix\`, restores it.`,
425
+ );
426
+ } else if (
427
+ check.state === "active" &&
428
+ (await hasCurrentMarker(projectPath))
429
+ ) {
430
+ const marker = await readActiveMarker(projectPath);
431
+ if (marker) {
432
+ const p = check.profile;
433
+ for (const key of LIVE_FILE_KEYS) {
434
+ const kind = await liveKind(key, projectPath);
435
+ const file = rel(livePath(key, projectPath));
436
+ if (kind === "file") {
437
+ if ((await liveHash(key, projectPath)) !== marker.files[key]) {
438
+ pending.push(
439
+ `${file} has changes not yet recorded in profile "${p}" — the next magus command records them.`,
440
+ );
441
+ }
442
+ } else if (kind === "missing" && marker.files[key] !== undefined) {
443
+ pending.push(
444
+ `${file} is missing — the next magus command writes it again from profile "${p}".`,
445
+ );
446
+ }
447
+ }
448
+ if (marker.cliBookkeeping) {
449
+ pending.push(
450
+ "An interrupted magus install left the Claude CLI's plugin records in .claude/settings.json — the next magus command replaces them.",
451
+ );
452
+ }
453
+ const manifest = await readManifest(projectPath);
454
+ if (
455
+ (await renderProfile(manifest, p, projectPath)).definition !==
456
+ marker.definition
457
+ ) {
458
+ pending.push(
459
+ "profiles.json changed since the live config was generated — the next magus command regenerates it.",
460
+ );
461
+ }
462
+ const { edited } = await inspectManagedSkills(marker.skills, projectPath);
463
+ for (const folder of edited) {
464
+ pending.push(
465
+ `Skill .claude/skills/${folder} was edited — the next magus command makes it a custom skill.`,
466
+ );
467
+ }
468
+ }
469
+ }
470
+ return { check, pending, unreadable };
471
+ }
472
+
473
+ export interface SwitchResult {
474
+ /** What was recorded on the way: absorbed edits, an orphaned marker's changes. */
475
+ lines: string[];
476
+ /** Why nothing was activated. Empty when the switch happened. */
477
+ problems: string[];
478
+ /** The files behind `problems` that could not be parsed. */
479
+ unreadable: SettingsParseError[];
480
+ /** The activation, when there was one. */
481
+ activation: ActivationResult | null;
482
+ }
483
+
484
+ /**
485
+ * Make `name` the active profile — the ONE switch path, for `magus profile
486
+ * switch`, `magus install` and the TUI's Profiles tab alike.
487
+ *
488
+ * 1. On an older layout, what Claude Code wrote through its links is added
489
+ * to the profile it belongs to (`absorbBeforeConversion`).
490
+ * 2. Whatever Claude Code wrote into the live config belongs to the profile
491
+ * being LEFT, so it is absorbed there (`reconcileActiveProfile`).
492
+ * 3. If that profile no longer exists in profiles.json, its unsaved changes
493
+ * have nowhere to go but `name` (`absorbOrphanedProfile`).
494
+ * 4. `name` is activated: its live files rendered, its skills installed.
495
+ *
496
+ * Stops — having written nothing — when `name` is not defined or a live file
497
+ * cannot be read, and before activating when one of `name`'s skills would
498
+ * land on a custom skill folder.
499
+ */
500
+ export async function switchToProfile(
501
+ projectPath: string,
502
+ name: string,
503
+ opts: ActivateOptions = {},
504
+ ): Promise<SwitchResult> {
505
+ const result: SwitchResult = {
506
+ lines: [],
507
+ problems: [],
508
+ unreadable: [],
509
+ activation: null,
510
+ };
511
+ const take = (r: ReconcileResult | null): boolean => {
512
+ if (!r) return true;
513
+ result.lines.push(...r.lines.filter((l) => !r.problems.includes(l)));
514
+ result.problems.push(...r.problems);
515
+ result.unreadable.push(...r.unreadable);
516
+ return r.problems.length === 0;
517
+ };
518
+
519
+ const before = await readManifest(projectPath);
520
+ const target = before.profiles[name];
521
+ if (!target) {
522
+ result.problems.push(
523
+ `Profile "${name}" not found. Available: ${Object.keys(before.profiles).join(", ")}`,
524
+ );
525
+ return result;
526
+ }
527
+ // Before the absorbs below write profiles.json: nothing at all is written
528
+ // when a live file this switch would write is tracked by git.
529
+ const marker = await readActiveMarker(projectPath);
530
+ const refusal = await writeRefusal(projectPath, [
531
+ ...(await writableSkillFolders(
532
+ projectPath,
533
+ target.skills ?? [],
534
+ marker?.skills,
535
+ )),
536
+ ...(await activeWritableSkillFolders(projectPath, before)),
537
+ ]);
538
+ if (refusal) {
539
+ result.problems.push(refusal.message);
540
+ return result;
541
+ }
542
+
543
+ if (!take(await absorbBeforeConversion(projectPath, name))) return result;
544
+ const installSkill = { installSkill: opts.installSkill };
545
+ if (!take(await reconcileActiveProfile(projectPath, installSkill))) {
546
+ return result;
547
+ }
548
+ if (!take(await absorbOrphanedProfile(projectPath, name))) return result;
549
+
550
+ try {
551
+ result.activation = await activateProfile(
552
+ await readManifest(projectPath),
553
+ name,
554
+ projectPath,
555
+ opts,
556
+ );
557
+ } catch (error) {
558
+ if (!(error instanceof SkillClashError)) throw error;
559
+ result.problems.push(error.message);
560
+ }
561
+ return result;
562
+ }
563
+
564
+ /**
565
+ * Carry out the user's choice from the "which profile?" question, whichever
566
+ * surface asked.
567
+ *
568
+ * Activating first adds anything unsaved to a profile — what Claude Code wrote
569
+ * under an older layout, or under a recorded profile profiles.json no longer
570
+ * defines — so the activation renders over nothing unrecorded; those lines are
571
+ * returned in `absorbed`. Throws, having written nothing, when a live file
572
+ * cannot be read.
573
+ *
574
+ * Adopting is two writes that must not come apart — write the manifest, then
575
+ * activate it.
576
+ */
577
+ export async function applyProfileChoice(
578
+ choice: ProfileChoice,
579
+ manifest: ProfileManifest,
580
+ projectPath: string,
581
+ opts: ActivateOptions = {},
582
+ ): Promise<ActivationResult> {
583
+ // Nothing is written — not the adopted manifest, not an absorbed edit —
584
+ // when a live file this choice would write is tracked by git.
585
+ const skills =
586
+ choice.kind === "activate"
587
+ ? (manifest.profiles[choice.id]?.skills ?? [])
588
+ : [];
589
+ await assertNoTrackedWriteTargets(
590
+ projectPath,
591
+ await writableSkillFolders(
592
+ projectPath,
593
+ skills,
594
+ (await readActiveMarker(projectPath))?.skills,
595
+ ),
596
+ );
597
+ if (choice.kind === "activate") {
598
+ const absorbed: string[] = [];
599
+ let changed = false;
600
+ for (const step of [
601
+ await absorbBeforeConversion(projectPath, choice.id),
602
+ await absorbOrphanedProfile(projectPath, choice.id),
603
+ ]) {
604
+ if (!step) continue;
605
+ refuseOnProblems(step);
606
+ absorbed.push(...step.lines);
607
+ if (step.changes.length > 0) changed = true;
608
+ }
609
+ const result = await activateProfile(
610
+ changed ? await readManifest(projectPath) : manifest,
611
+ choice.id,
612
+ projectPath,
613
+ opts,
614
+ );
615
+ return absorbed.length > 0 ? { ...result, absorbed } : result;
616
+ }
617
+ const result = await adoptCurrentProject(
618
+ projectPath,
619
+ undefined,
620
+ manifest,
621
+ choice.id,
622
+ );
623
+ await writeManifest(result.manifest, projectPath);
624
+ return activateProfile(result.manifest, choice.id, projectPath, opts);
625
+ }
626
+
627
+ /**
628
+ * The gate, for a service about to write profile state (`models use`/`off`,
629
+ * a style set or cleared — from the CLI or the TUI): absorb and regenerate
630
+ * first, and throw — having written nothing — when that could not be done
631
+ * safely. The first unreadable file's `SettingsParseError` is thrown as is,
632
+ * so every surface reports it the way it reports any damaged settings file.
633
+ */
634
+ export async function prepareProfileWrite(
635
+ projectPath: string,
636
+ ): Promise<ProfileStatePrep> {
637
+ const prep = await prepareProfileState(projectPath);
638
+ if (prep.refusal) throw prep.refusal;
639
+ refuseOnProblems(prep);
640
+ return prep;
641
+ }
642
+
643
+ export interface CliBookkeepingOptions {
644
+ /**
645
+ * The plugin ids `work` installs. Only a `true` for one of these is taken
646
+ * for the CLI's record; every other id in settings.json is the user's.
647
+ * Recorded in the marker, so a killed run's next command applies the same
648
+ * set. Omitted, every profile's plugins count.
649
+ */
650
+ installable?: readonly string[];
651
+ /**
652
+ * Runs after `work`, before the reconcile — for `magus update`, which must
653
+ * record the skill folders it re-fetched before they are judged edited.
654
+ */
655
+ beforeReconcile?: () => Promise<unknown>;
656
+ /** Receives each line the reconcile prints (absorbed edits, problems). */
657
+ report?: (line: string) => void;
658
+ }
659
+
660
+ /**
661
+ * Run the Claude CLI's plugin installs while a profile is active, so their
662
+ * writes to the live settings.json are never taken for the user's edits — and
663
+ * the user's own edits in the same window are never lost to them.
664
+ *
665
+ * `claude plugin install --scope project` enables each plugin it installs in
666
+ * `.claude/settings.json`, for every profile's plugins when `magus install`
667
+ * runs without an argument. Before `work` starts the marker is flagged
668
+ * (`cliBookkeeping`). In `finally`, while the flag is still set, the live
669
+ * config is reconciled: anything Claude Code (or the user) wrote meanwhile —
670
+ * an "always allow" rule, a plugin of their own — is absorbed into the
671
+ * profile, and only the install's own records (a `true` for a plugin some
672
+ * profile names that the active one does not enable) are discarded. The
673
+ * reconcile renders the file and writes a fresh marker, which clears the flag.
674
+ *
675
+ * A run killed outright (Ctrl-C, a crash) leaves the flag set; the next
676
+ * command's reconcile does the same then, and says the run was interrupted.
677
+ * If the reconcile cannot read a live file, nothing is written and the flag
678
+ * stays for the next command. Without an active profile `work` just runs.
679
+ */
680
+ export async function withCliBookkeeping<T>(
681
+ projectPath: string,
682
+ work: () => Promise<T>,
683
+ opts: CliBookkeepingOptions = {},
684
+ ): Promise<T> {
685
+ const marker = (await hasCurrentMarker(projectPath))
686
+ ? await readActiveMarker(projectPath)
687
+ : null;
688
+ if (!marker) return work();
689
+ await writeActiveMarker(
690
+ {
691
+ ...marker,
692
+ cliBookkeeping: true,
693
+ ...(opts.installable ? { cliInstallable: [...opts.installable] } : {}),
694
+ },
695
+ projectPath,
696
+ );
697
+ try {
698
+ return await work();
699
+ } finally {
700
+ await opts.beforeReconcile?.();
701
+ const reconciled = await reconcileActiveProfile(projectPath, {
702
+ cliRunFinished: true,
703
+ });
704
+ for (const line of reconciled.lines) opts.report?.(line);
705
+ }
706
+ }