magus-cli 7.4.1 → 7.5.0

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