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
@@ -42,9 +42,11 @@ import type { PluginScope } from "./claude-cli.js";
42
42
  import { installPlugin, repairPlugin, updatePlugin } from "./claude-cli.js";
43
43
  import {
44
44
  type StaleLoadRepairResult,
45
+ loadedVersionHere,
45
46
  repairStaleLoad,
46
47
  } from "./claude-settings.js";
47
48
  import {
49
+ isRepairablyBehind,
48
50
  readInstalledVersionForScope,
49
51
  saveInstalledPluginVersionForScope,
50
52
  } from "./plugin-manager.js";
@@ -181,6 +183,15 @@ export interface PluginApplyDeps {
181
183
  target: string,
182
184
  projectPath: string,
183
185
  ) => Promise<StaleLoadRepairResult>;
186
+ /**
187
+ * The version Claude Code's loader resolves for a plugin in a project — the
188
+ * question an install must ask afterwards (see {@link bringLoadForward}).
189
+ * Optional so a test double that does not model the loader skips the check.
190
+ */
191
+ loadedVersion?: (
192
+ pluginId: string,
193
+ projectPath: string,
194
+ ) => Promise<string | null>;
184
195
  }
185
196
 
186
197
  export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
@@ -190,8 +201,47 @@ export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
190
201
  readInstalled: readInstalledVersionForScope,
191
202
  saveInstalled: saveInstalledPluginVersionForScope,
192
203
  repairStaleLoad,
204
+ loadedVersion: loadedVersionHere,
193
205
  };
194
206
 
207
+ /**
208
+ * After a plugin landed at `target` for `projectPath`: make sure that is the
209
+ * version Claude Code actually loads there, and bring the loader forward when
210
+ * it is not.
211
+ *
212
+ * The loader takes the FIRST registry row relevant to the repository, and rows
213
+ * from this repository's other checkouts — deleted worktrees included — often
214
+ * precede this project's own row. `claude plugin install|update` writes only
215
+ * this project's row, so a successful update could leave the plugin showing
216
+ * "updated" and loading the old version at once (measured: an update wrote
217
+ * 2.0.5 to this project's row while eight earlier rows held 2.0.4, and
218
+ * `--debug-file` showed 2.0.4 loading). So the update runs the stale-load
219
+ * repair as its own last step — the same `repairStaleLoad` the `stale-load`
220
+ * state uses, moving this repository's older rows forward to `target`.
221
+ *
222
+ * Null when nothing needed doing (the loader already resolves `target`, or the
223
+ * dep that asks it is absent); otherwise the repair's result, which the caller
224
+ * reports — `ok: false` means the plugin still does not load `target`.
225
+ */
226
+ export async function bringLoadForward(
227
+ pluginId: string,
228
+ target: string,
229
+ projectPath: string,
230
+ deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
231
+ ): Promise<StaleLoadRepairResult | null> {
232
+ if (!deps.loadedVersion) return null;
233
+ const loaded = await deps.loadedVersion(pluginId, projectPath);
234
+ if (!isRepairablyBehind(target, loaded)) return null;
235
+ return deps.repairStaleLoad(pluginId, target, projectPath);
236
+ }
237
+
238
+ /** The words for a load brought forward, appended to an item's detail. */
239
+ function describeLoadForward(outcome: StaleLoadRepairResult): string {
240
+ return `Claude Code now loads v${outcome.target} here (${outcome.rewritten} older record${
241
+ outcome.rewritten === 1 ? "" : "s"
242
+ } moved forward)`;
243
+ }
244
+
195
245
  // -- selection ---------------------------------------------------------------
196
246
 
197
247
  /**
@@ -448,7 +498,28 @@ export async function applyPlugins(
448
498
  }
449
499
  }
450
500
  const noMove = pluginNoMove(item, landed);
451
- if (noMove) {
501
+ // What landed here — the project row when there is one, since that is
502
+ // the row the loader must end up agreeing with.
503
+ const here =
504
+ landed.find(([scope]) => scope === "project")?.[1] ??
505
+ landed.find(([, actual]) => actual)?.[1] ??
506
+ null;
507
+ const forward = here
508
+ ? await bringLoadForward(item.pluginId, here, projectPath, deps)
509
+ : null;
510
+ if (forward && !forward.ok) {
511
+ // Installed, but not what loads: that is not "updated". Reported as
512
+ // a failure with the repair's own reason, so nothing says current.
513
+ report.finish(
514
+ false,
515
+ item.pluginId,
516
+ `installed v${here}, but Claude Code still loads v${
517
+ forward.loaded ?? "an older version"
518
+ } here — ${forward.error ?? "the older records could not be moved forward"}`,
519
+ Date.now() - startedAt,
520
+ );
521
+ result.failed.push(item.pluginId);
522
+ } else if (noMove && !forward) {
452
523
  report.finish(
453
524
  true,
454
525
  item.pluginId,
@@ -457,10 +528,11 @@ export async function applyPlugins(
457
528
  );
458
529
  result.unchanged.push(item.pluginId);
459
530
  } else {
531
+ const moved = `${scopes.join(", ")}${item.target ? ` → ${item.target}` : ""}`;
460
532
  report.finish(
461
533
  true,
462
534
  item.pluginId,
463
- `${scopes.join(", ")}${item.target ? ` → ${item.target}` : ""}`,
535
+ forward ? `${moved} · ${describeLoadForward(forward)}` : moved,
464
536
  Date.now() - startedAt,
465
537
  );
466
538
  result.ok++;
@@ -499,6 +571,19 @@ async function applyOneInScope(
499
571
  await run(pluginId, scope);
500
572
  const actual = await deps.readInstalled(pluginId, scope, projectPath);
501
573
  if (actual) await deps.saveInstalled(pluginId, actual, scope, projectPath);
574
+ // The same last step as the apply loop, at every scope: installed must mean
575
+ // loaded here. `repairStaleLoad` itself refuses — and says why — when the
576
+ // winning record is not one this repository's rows can move.
577
+ if (actual) {
578
+ const forward = await bringLoadForward(pluginId, actual, projectPath, deps);
579
+ if (forward && !forward.ok) {
580
+ throw new Error(
581
+ `installed v${actual}, but Claude Code still loads v${
582
+ forward.loaded ?? "an older version"
583
+ } here — ${forward.error ?? "the older records could not be moved forward"}`,
584
+ );
585
+ }
586
+ }
502
587
  return actual;
503
588
  }
504
589
 
@@ -312,8 +312,8 @@ export interface ProfileEntry extends ProfileManifestEntry {
312
312
  /**
313
313
  * This profile is the one the project is actually running.
314
314
  *
315
- * Derived from the `.claude/settings.json` symlink, not stored — see
316
- * `activeProfile`. Carried on the entry because a list of profiles with no
315
+ * Derived from the per-developer marker `.claude/profiles/active.json`, not
316
+ * stored in the manifest — see `activeProfile`. Carried on the entry because a list of profiles with no
317
317
  * mark on the live one cannot answer the first question anybody asks of it:
318
318
  * the Profiles tab showed two identical-looking rows straight after
319
319
  * activating one of them.
@@ -377,8 +377,7 @@ export interface ProfileManifestEntry {
377
377
  settings?: Record<string, unknown>;
378
378
  /**
379
379
  * Per-subagent model routing. A PROFILE property, so switching profiles
380
- * switches routing, and materialization writes it to
381
- * `_profiles/<name>/models.json` for `.claude/models.json` to link at.
380
+ * switches routing; activation renders it to `.claude/models.json`.
382
381
  */
383
382
  models?: ModelsConfig;
384
383
  env?: ProfileEnvRequirements;
@@ -399,8 +398,9 @@ export interface ProfileManifest {
399
398
  * use, so every teammate cloning the repo is asked to choose and has nothing
400
399
  * to choose by — the repo knows the answer and had no way to write it down.
401
400
  *
402
- * It is intent, not state: the profile that is ACTUALLY active is the symlink
403
- * (see `activeProfile`), which is per-developer and gitignored. This field is
401
+ * It is intent, not state: the profile that is ACTUALLY active is recorded in
402
+ * the marker `.claude/profiles/active.json` (see `activeProfile`), which is
403
+ * per-developer and gitignored. This field is
404
404
  * what a fresh clone activates so nobody is asked at all.
405
405
  *
406
406
  * Must name a profile that exists; `validateManifest` rejects it otherwise.
@@ -452,17 +452,42 @@ export interface ResolvedClosure {
452
452
  marketplaces: Record<string, ProfileMarketplaceRef>;
453
453
  /** pluginId -> version pin | "latest". */
454
454
  plugins: Record<string, string>;
455
+ /**
456
+ * Plugin ids the profile declares as `false` — explicitly off in this
457
+ * project. Rendered as `false` in enabledPlugins so a plugin enabled at user
458
+ * scope stays off here. Set per profile; a union carries none.
459
+ */
460
+ disabledPlugins?: string[];
455
461
  mcpServers: Record<string, McpServerConfig>;
456
462
  bins: ResolvedBin[];
457
463
  skills: ProfileSkillRef[];
458
464
  settings: Record<string, unknown>;
459
465
  /**
460
- * The profile's model routing, if it declares any. Absent means the
461
- * materialized profile carries no models.json — and materialization DELETES
462
- * a stale one, which is what makes `magus models off` take effect.
466
+ * The profile's model routing, if it declares any. Absent means rendering
467
+ * REMOVES `.claude/models.json`, which is what makes `magus models off`
468
+ * take effect.
463
469
  */
464
470
  models?: ModelsConfig;
465
471
  env: { required: string[]; optional: string[] };
466
472
  /** Cross-profile version conflicts detected while unioning profiles. */
467
473
  conflicts?: string[];
474
+ /**
475
+ * What the enabled plugins' own plugin.json files require — present when
476
+ * anything was derived or could not be read. The derived ids are already
477
+ * in `plugins`.
478
+ */
479
+ dependencies?: DependencyReport;
480
+ }
481
+
482
+ /**
483
+ * The dependencies a profile's enabled plugins declare, resolved transitively.
484
+ * Derived, never recorded: profiles.json names none of them.
485
+ */
486
+ export interface DependencyReport {
487
+ /** Enabled only because an enabled plugin depends on them. Sorted. */
488
+ derived: string[];
489
+ /** Enabled plugins whose plugin.json could not be read here. Sorted. */
490
+ unreadable: string[];
491
+ /** A dependency the profile itself turns off: `plugin` will not load. */
492
+ blocked: Array<{ plugin: string; dependency: string }>;
468
493
  }
package/src/ui/App.tsx CHANGED
@@ -16,8 +16,8 @@ import { repairAllMarketplaces } from "../services/local-marketplace.js";
16
16
  import { autoAddMissingMarketplaces } from "../services/marketplace-sync.js";
17
17
  import {
18
18
  type ProfileStartupNeed,
19
- detectProfileStartupNeed,
20
19
  needsNothing,
20
+ prepareProfileStartup,
21
21
  } from "../services/profile-startup.js";
22
22
  import {
23
23
  type VersionCheckResult,
@@ -205,12 +205,15 @@ interface AppContentInnerProps {
205
205
  updateInfo: VersionCheckResult | null;
206
206
  onExit: () => void;
207
207
  recoveryReport: string | null;
208
+ /** What the startup profile gate did: absorbed edits, regeneration, repairs. */
209
+ profileNotices: string[];
208
210
  }
209
211
 
210
212
  function AppContentInner({
211
213
  updateInfo,
212
214
  onExit,
213
215
  recoveryReport,
216
+ profileNotices,
214
217
  }: AppContentInnerProps) {
215
218
  const { state } = useApp();
216
219
  const { progress } = state;
@@ -224,6 +227,11 @@ function AppContentInner({
224
227
  <text fg={theme.colors.success}>✓ Fixed: {recoveryReport}</text>
225
228
  </box>
226
229
  )}
230
+ {profileNotices.map((line) => (
231
+ <box key={line} paddingLeft={1} paddingRight={1}>
232
+ <text fg={theme.colors.success}>• {line}</text>
233
+ </box>
234
+ ))}
227
235
  {progress && <ProgressIndicator {...progress} />}
228
236
  <box
229
237
  flexDirection="column"
@@ -252,6 +260,7 @@ function AppContent({ onExit }: AppContentProps) {
252
260
  const { progress } = state;
253
261
  const [updateInfo, setUpdateInfo] = useState<VersionCheckResult | null>(null);
254
262
  const [recoveryReport, setRecoveryReport] = useState<string | null>(null);
263
+ const [profileNotices, setProfileNotices] = useState<string[]>([]);
255
264
  const [gitignoreData, setGitignoreData] = useState<{
256
265
  violationCount: number;
257
266
  safeCount: number;
@@ -286,6 +295,14 @@ function AppContent({ onExit }: AppContentProps) {
286
295
  return () => clearInterval(id);
287
296
  }, []);
288
297
 
298
+ // Auto-dismiss the profile gate's notices after 10 seconds — long enough to
299
+ // read a line per absorbed change.
300
+ useEffect(() => {
301
+ if (profileNotices.length === 0) return;
302
+ const timer = setTimeout(() => setProfileNotices([]), 10000);
303
+ return () => clearTimeout(timer);
304
+ }, [profileNotices]);
305
+
289
306
  // Auto-dismiss recovery banner after 5 seconds
290
307
  useEffect(() => {
291
308
  if (!recoveryReport) return;
@@ -384,7 +401,12 @@ function AppContent({ onExit }: AppContentProps) {
384
401
  useEffect(() => {
385
402
  (async () => {
386
403
  try {
387
- const need = await detectProfileStartupNeed(process.cwd());
404
+ // The same gate every CLI command runs — absorb Claude Code's
405
+ // edits into the active profile, regenerate from a changed
406
+ // profiles.json, restore a lost marker — then the question, if one
407
+ // is left to ask.
408
+ const { need, notices } = await prepareProfileStartup(process.cwd());
409
+ if (notices.length > 0) setProfileNotices(notices);
388
410
  if (!needsNothing(need)) setProfileNeed(need);
389
411
  } catch {
390
412
  // non-fatal: the Profiles tab still works by hand
@@ -492,6 +514,7 @@ function AppContent({ onExit }: AppContentProps) {
492
514
  updateInfo={updateInfo}
493
515
  onExit={onExit}
494
516
  recoveryReport={recoveryReport}
517
+ profileNotices={profileNotices}
495
518
  />
496
519
  </DimensionsProvider>
497
520
  );
@@ -18,9 +18,11 @@
18
18
  import { useCallback } from "react";
19
19
  import { getManifestPath } from "../../services/manifest.js";
20
20
  import {
21
+ type ActivationResult,
21
22
  type ProfileChoice,
22
- applyProfileChoice,
23
+ activationNotes,
23
24
  } from "../../services/profile-activate.js";
25
+ import type { DisplacedArtifact } from "../../services/profile-live.js";
24
26
  import {
25
27
  type ProfileStartupNeed,
26
28
  buildProfileOptions,
@@ -29,8 +31,8 @@ import {
29
31
  parseProfileChoice,
30
32
  unparseableNeed,
31
33
  } from "../../services/profile-startup.js";
34
+ import { applyProfileChoice } from "../../services/profile-state.js";
32
35
  import { SettingsParseError } from "../../services/settings-file.js";
33
- import type { DisplacedArtifact } from "../../services/symlink-manager.js";
34
36
  import { useApp, useNavigation } from "../state/AppContext.js";
35
37
  import type { ModalState } from "../state/types.js";
36
38
 
@@ -80,6 +82,8 @@ export function describeOutcome(
80
82
  projectPath: string,
81
83
  /** True when this happened without asking, following the committed default. */
82
84
  automatic = false,
85
+ /** Anything else activation reported (see `activationNotes`). */
86
+ notes: string[] = [],
83
87
  ): string {
84
88
  const lines = [
85
89
  automatic
@@ -89,7 +93,7 @@ export function describeOutcome(
89
93
  if (displaced.length > 0) {
90
94
  lines.push(
91
95
  "",
92
- "These were moved aside to make room for links into the profile, and are kept:",
96
+ "These were moved aside to make room for the profile's files, and are kept:",
93
97
  ...displaced.map((d) => {
94
98
  const from = d.link.startsWith(projectPath)
95
99
  ? d.link.slice(projectPath.length + 1)
@@ -101,6 +105,7 @@ export function describeOutcome(
101
105
  }),
102
106
  );
103
107
  }
108
+ if (notes.length > 0) lines.push("", ...notes);
104
109
  return lines.join("\n");
105
110
  }
106
111
 
@@ -112,9 +117,10 @@ function rel(p: string, projectPath: string): string {
112
117
  /**
113
118
  * What to say when activation threw partway through.
114
119
  *
115
- * The hard part is that a failure is NOT always a no-op. Activation moves up to
116
- * four artifacts in sequence and `settings.json` goes first, so a throw on the
117
- * second leaves it already in the backup. The old message said "Nothing was
120
+ * The hard part is that a failure is NOT always a no-op. At first adoption
121
+ * activation moves up to three hand-written files in sequence and
122
+ * `settings.json` goes first, so a throw on the second leaves it already in
123
+ * the backup. The old message said "Nothing was
118
124
  * activated" unconditionally, which in exactly that case is false about the
119
125
  * user's own file — and `.magus-backups/` is gitignored, so `git status` showed
120
126
  * the deletion with no sign of the copy. It read as "magus deleted my settings".
@@ -172,8 +178,8 @@ export function useProfileStartupModal(onQuit: () => void) {
172
178
  });
173
179
  // Collected as they move, NOT read from the resolved result.
174
180
  //
175
- // Activation moves up to four artifacts in sequence and
176
- // `settings.json` goes first, so a throw on the second leaves the
181
+ // First adoption moves up to three hand-written files in sequence
182
+ // and `settings.json` goes first, so a throw on the second leaves the
177
183
  // user's file already in the backup — and the resolved value that
178
184
  // would have listed it never arrives. This array is the only record
179
185
  // the error path has, and without it the failure modal claimed
@@ -181,13 +187,11 @@ export function useProfileStartupModal(onQuit: () => void) {
181
187
  // where they left it. `.magus-backups/` is gitignored, so git status
182
188
  // showed the deletion and no sign of the copy.
183
189
  const moved: DisplacedArtifact[] = [];
184
- void applyProfileChoice(
185
- choice,
186
- need.manifest,
187
- projectPath,
188
- (artifact) => moved.push(artifact),
189
- )
190
- .then(({ profileId, displaced }) => {
190
+ void applyProfileChoice(choice, need.manifest, projectPath, {
191
+ onDisplace: (artifact) => moved.push(artifact),
192
+ })
193
+ .then((result: ActivationResult) => {
194
+ const { profileId, displaced } = result;
191
195
  dispatch({
192
196
  type: "SHOW_MODAL",
193
197
  modal: {
@@ -198,6 +202,7 @@ export function useProfileStartupModal(onQuit: () => void) {
198
202
  displaced,
199
203
  projectPath,
200
204
  automatic,
205
+ activationNotes(result),
201
206
  ),
202
207
  variant: "success",
203
208
  onDismiss: () => {
@@ -44,6 +44,7 @@ import {
44
44
  fetchPluginContents,
45
45
  fetchPluginSkillDetail,
46
46
  } from "../../services/skills-manager.js";
47
+ import { LiveFileRefusal } from "../../services/tracked-live-files.js";
47
48
  // The one update implementation, shared with `magus update`. See the header
48
49
  // of update-engine.ts for what drifted while the TUI had its own loop.
49
50
  import {
@@ -982,7 +983,11 @@ export function PluginsScreen() {
982
983
  fetchData();
983
984
  } catch (error) {
984
985
  modal.hideModal();
985
- await modal.message("Error", `Failed: ${error}`, "error");
986
+ await modal.message(
987
+ "Error",
988
+ error instanceof LiveFileRefusal ? error.message : `Failed: ${error}`,
989
+ "error",
990
+ );
986
991
  }
987
992
  }
988
993
  };
@@ -1202,7 +1207,11 @@ export function PluginsScreen() {
1202
1207
  fetchData();
1203
1208
  } catch (error) {
1204
1209
  modal.hideModal();
1205
- await modal.message("Error", `Failed: ${error}`, "error");
1210
+ await modal.message(
1211
+ "Error",
1212
+ error instanceof LiveFileRefusal ? error.message : `Failed: ${error}`,
1213
+ "error",
1214
+ );
1206
1215
  }
1207
1216
  };
1208
1217
 
@@ -14,8 +14,11 @@ import {
14
14
  readSettings,
15
15
  writeSettings,
16
16
  } from "../../services/claude-settings.js";
17
+ import { reconcileActiveProfile } from "../../services/profile-absorb.js";
18
+ import { activationNotes } from "../../services/profile-activate.js";
19
+ import { switchToProfile } from "../../services/profile-state.js";
17
20
  import {
18
- applyProfile,
21
+ applyUserProfile,
19
22
  deleteProfile,
20
23
  exportProfileToJson,
21
24
  importProfileFromJson,
@@ -23,12 +26,14 @@ import {
23
26
  renameProfile,
24
27
  } from "../../services/profiles.js";
25
28
  import { writableMap } from "../../services/settings-file.js";
29
+ import { LiveFileRefusal } from "../../services/tracked-live-files.js";
26
30
  import {
27
31
  ClipboardUnavailableError,
28
32
  readClipboard,
29
33
  writeClipboard,
30
34
  } from "../../utils/clipboard.js";
31
35
  import { ScrollableList } from "../components/ScrollableList.js";
36
+ import { describeOutcome } from "../hooks/useProfileStartupModal.js";
32
37
  import {
33
38
  type ProfileListItem,
34
39
  buildProfileListItems,
@@ -156,45 +161,98 @@ export function ProfilesScreen() {
156
161
  }
157
162
  }
158
163
  await writeSettings(settings, state.projectPath);
164
+ // In a profiled project the merge is a change to the active profile:
165
+ // record it there now, rather than at the next magus command.
166
+ const recorded = await reconcileActiveProfile(state.projectPath);
159
167
  modal.hideModal();
160
168
  dispatch({ type: "DATA_REFRESH_COMPLETE" });
161
169
  await modal.message(
162
170
  "Applied",
163
- `Profile "${profile.name}" merged into project settings.`,
171
+ [
172
+ `Profile "${profile.name}" merged into project settings.`,
173
+ ...recorded.lines,
174
+ ].join("\n"),
164
175
  "success",
165
176
  );
166
177
  } catch (error) {
167
178
  modal.hideModal();
168
179
  await modal.message(
169
180
  "Error",
170
- `Failed to apply profile: ${error}`,
181
+ error instanceof LiveFileRefusal
182
+ ? error.message
183
+ : `Failed to apply profile: ${error}`,
171
184
  "error",
172
185
  );
173
186
  }
174
187
  };
175
188
 
189
+ /**
190
+ * Apply a saved profile.
191
+ *
192
+ * A PROJECT profile is switched to, through the same path as `magus profile
193
+ * switch`: what was changed in the live config goes into the profile being
194
+ * left, then this one is rendered. Writing its enabledPlugins straight into
195
+ * `.claude/settings.json` made the apply invisible to the profile and undone
196
+ * by the next render.
197
+ *
198
+ * A USER-scope profile is a template in `~/.claude/profiles.json` and applies
199
+ * to `~/.claude/settings.json` only — never to this project's live files,
200
+ * which belong to its active profile.
201
+ */
176
202
  const handleApply = async () => {
177
203
  if (selectedItem?.kind !== "saved") return;
178
204
  const selectedProfile = selectedItem.entry;
179
205
 
180
- const scopeChoice = await modal.select(
181
- "Apply Profile",
182
- `Apply "${selectedProfile.name}" to which scope?`,
183
- [
184
- { label: "User — ~/.claude/settings.json (global)", value: "user" },
185
- {
186
- label: "Project — .claude/settings.json (this project)",
187
- value: "project",
188
- },
189
- ],
190
- );
191
- if (scopeChoice === null) return;
206
+ if (selectedProfile.scope === "project") {
207
+ const confirmed = await modal.confirm(
208
+ "Switch profile",
209
+ `Make "${selectedProfile.name}" this project's active profile?\n\nIts plugins, MCP servers, model routing and skills replace the current ones. Anything changed in the current config is saved into the profile you are leaving first.`,
210
+ );
211
+ if (!confirmed) return;
192
212
 
193
- const targetScope = scopeChoice as "user" | "project";
194
- const scopeLabel =
195
- targetScope === "user"
196
- ? "~/.claude/settings.json"
197
- : ".claude/settings.json";
213
+ modal.loading(`Switching to "${selectedProfile.name}"...`);
214
+ try {
215
+ const result = await switchToProfile(
216
+ state.projectPath,
217
+ selectedProfile.id,
218
+ { onDisplace: () => {} },
219
+ );
220
+ modal.hideModal();
221
+ dispatch({ type: "DATA_REFRESH_COMPLETE" });
222
+ if (result.problems.length > 0 || !result.activation) {
223
+ await modal.message(
224
+ "Could not switch",
225
+ [...result.lines, ...result.problems].join("\n"),
226
+ "error",
227
+ );
228
+ return;
229
+ }
230
+ const outcome = describeOutcome(
231
+ result.activation.profileId,
232
+ result.activation.displaced,
233
+ state.projectPath,
234
+ false,
235
+ activationNotes(result.activation),
236
+ );
237
+ await modal.message(
238
+ "Profile active",
239
+ result.lines.length > 0
240
+ ? `${result.lines.join("\n")}\n\n${outcome}`
241
+ : outcome,
242
+ "success",
243
+ );
244
+ } catch (error) {
245
+ modal.hideModal();
246
+ await modal.message(
247
+ "Error",
248
+ error instanceof LiveFileRefusal
249
+ ? error.message
250
+ : `Failed to switch profile: ${error}`,
251
+ "error",
252
+ );
253
+ }
254
+ return;
255
+ }
198
256
 
199
257
  const pluginCount = Object.keys(selectedProfile.plugins ?? {}).length;
200
258
  const emptyWarning =
@@ -204,30 +262,27 @@ export function ProfilesScreen() {
204
262
 
205
263
  const confirmed = await modal.confirm(
206
264
  "Confirm Apply",
207
- `This will REPLACE all enabledPlugins in ${scopeLabel}.${emptyWarning}\n\nContinue?`,
265
+ `This will REPLACE all enabledPlugins in ~/.claude/settings.json (every project on this machine).${emptyWarning}\n\nContinue?`,
208
266
  );
209
267
  if (!confirmed) return;
210
268
 
211
269
  modal.loading(`Applying "${selectedProfile.name}"...`);
212
270
  try {
213
- await applyProfile(
214
- selectedProfile.id,
215
- selectedProfile.scope,
216
- targetScope,
217
- state.projectPath,
218
- );
271
+ await applyUserProfile(selectedProfile);
219
272
  modal.hideModal();
220
273
  dispatch({ type: "DATA_REFRESH_COMPLETE" });
221
274
  await modal.message(
222
275
  "Applied",
223
- `Profile "${selectedProfile.name}" applied to ${scopeLabel}.`,
276
+ `Profile "${selectedProfile.name}" applied to ~/.claude/settings.json.`,
224
277
  "success",
225
278
  );
226
279
  } catch (error) {
227
280
  modal.hideModal();
228
281
  await modal.message(
229
282
  "Error",
230
- `Failed to apply profile: ${error}`,
283
+ error instanceof LiveFileRefusal
284
+ ? error.message
285
+ : `Failed to apply profile: ${error}`,
231
286
  "error",
232
287
  );
233
288
  }
@@ -535,7 +535,8 @@ export function StylesScreen() {
535
535
  ? `profile "${result.profile}"`
536
536
  : "this project";
537
537
  // Say plainly when the manifest was NOT updated: without that record a
538
- // `magus install` re-materializes settings.json and drops the style.
538
+ // the next activation renders settings.json from the manifest and
539
+ // drops the style.
539
540
  const durability =
540
541
  result.profile && !result.recordedInManifest
541
542
  ? " — not in profiles.json, so `magus install` will drop it"
@@ -306,7 +306,7 @@ export interface ModelsScreenState {
306
306
  * True while an apply or a clear is writing.
307
307
  *
308
308
  * App state, not screen state, because the screen UNMOUNTS on a tab switch.
309
- * An apply rewrites the profile, re-materializes it and registers a hook —
309
+ * An apply rewrites the profile, re-renders its live files and registers a hook —
310
310
  * two of those running at once would interleave writes to the same files.
311
311
  */
312
312
  isApplying: boolean;