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
@@ -21,7 +21,7 @@ import type {
21
21
  } from "../types/index.js";
22
22
  import { readManifest } from "./manifest.js";
23
23
  import { resolvePluginBinRequirements } from "./plugin-requires.js";
24
- import { activeProfile } from "./symlink-manager.js";
24
+ import { activeProfile } from "./profile-live.js";
25
25
 
26
26
  export interface ResolveOptions {
27
27
  /**
@@ -236,9 +236,14 @@ export async function resolveProfile(
236
236
  const plugins = normalizePlugins(entry.plugins);
237
237
  const bins = await resolveBins(entry, Object.keys(plugins), binResolver);
238
238
 
239
+ const disabledPlugins = Object.entries(entry.plugins ?? {})
240
+ .filter(([, ver]) => ver === false)
241
+ .map(([id]) => id);
242
+
239
243
  return {
240
244
  marketplaces: withDerivedMarketplaces(entry.marketplaces, plugins),
241
245
  plugins,
246
+ ...(disabledPlugins.length > 0 ? { disabledPlugins } : {}),
242
247
  mcpServers: { ...(entry.mcpServers ?? {}) },
243
248
  bins,
244
249
  skills: [...(entry.skills ?? [])],
@@ -3,11 +3,11 @@
3
3
  *
4
4
  * Two rules, and both exist because of damage they prevent:
5
5
  *
6
- * 1. **Write IN PLACE, never remove-and-recreate.** `.claude/settings.json` is
7
- * a SYMLINK into `.claude/_profiles/<name>/` whenever a profile is active,
8
- * and `writeFile` follows it — which is what we want. Unlinking the file
9
- * first would break the link and silently detach the project from its
10
- * profile, leaving the next read looking at a fresh, empty file.
6
+ * 1. **Write IN PLACE, never remove-and-recreate.** A settings file may be a
7
+ * symlink somebody set up deliberately, and `writeFile` follows it — which
8
+ * is what we want. Unlinking the file first would break the link and leave
9
+ * the next read looking at a fresh, empty file. (magus's own profile files
10
+ * are real files; `profile-live.ts` replaces them via a rename.)
11
11
  * 2. **Unparseable is an ERROR, not an empty object.** A settings file that
12
12
  * fails to parse is a file we do not understand, usually because a human is
13
13
  * mid-edit. Treating it as `{}` and writing our one key back is how a
@@ -40,6 +40,18 @@ export function getProjectSkillsDir(projectPath?: string): string {
40
40
  return path.join(projectPath ?? process.cwd(), ".claude", "skills");
41
41
  }
42
42
 
43
+ /**
44
+ * The folder a skill installs into: a slug of its display name, since display
45
+ * names can carry slashes and spaces. One definition, because the profile code
46
+ * must predict exactly where `installSkill` will write.
47
+ */
48
+ export function skillFolderName(name: string): string {
49
+ return name
50
+ .toLowerCase()
51
+ .replace(/[^a-z0-9]+/g, "-")
52
+ .replace(/^-|-$/g, "");
53
+ }
54
+
43
55
  // ─── GitHub Tree API ──────────────────────────────────────────────────────────
44
56
 
45
57
  async function fetchGitTree(repo: string): Promise<GitTreeResponse> {
@@ -1003,10 +1015,7 @@ export async function installSkill(
1003
1015
  }
1004
1016
 
1005
1017
  // Use slug for directory name — display names can have slashes/spaces
1006
- const dirName = skill.name
1007
- .toLowerCase()
1008
- .replace(/[^a-z0-9]+/g, "-")
1009
- .replace(/^-|-$/g, "");
1018
+ const dirName = skillFolderName(skill.name);
1010
1019
 
1011
1020
  const installDir =
1012
1021
  scope === "user"
@@ -1090,10 +1099,7 @@ export async function uninstallSkill(
1090
1099
  scope: "user" | "project",
1091
1100
  projectPath?: string,
1092
1101
  ): Promise<void> {
1093
- const dirName = skillName
1094
- .toLowerCase()
1095
- .replace(/[^a-z0-9]+/g, "-")
1096
- .replace(/^-|-$/g, "");
1102
+ const dirName = skillFolderName(skillName);
1097
1103
 
1098
1104
  const installDir =
1099
1105
  scope === "user"
@@ -19,12 +19,11 @@
19
19
  *
20
20
  * ## Profile awareness
21
21
  *
22
- * `.claude/settings.json` is a symlink into `.claude/_profiles/<name>/` when a
23
- * profile is active, so writing `outputStyle` through it lands in that
24
- * profile. But `materializeProfile` REWRITES that file from the manifest on
25
- * every `magus install`, so the live write alone silently regresses. The
26
- * selection is therefore also recorded in `.claude/profiles.json` under
27
- * `profiles.<name>.settings.outputStyle`, which is the durable source.
22
+ * `.claude/settings.json` is rendered from the active profile when there is
23
+ * one, so the selection is recorded in `.claude/profiles.json` under
24
+ * `profiles.<name>.settings.outputStyle` — the durable source — and the live
25
+ * file is rendered again from it. Without a profile the live file is edited in
26
+ * place.
28
27
  *
29
28
  * The generated style file is named per-profile (`composed-<name>`) so two
30
29
  * profiles can hold different styles without clobbering each other's file
@@ -43,8 +42,10 @@ import {
43
42
  readManifest,
44
43
  writeManifest,
45
44
  } from "./manifest.js";
45
+ import { regenerateLiveFiles } from "./profile-activate.js";
46
+ import { activeProfile } from "./profile-live.js";
47
+ import { prepareProfileWrite } from "./profile-state.js";
46
48
  import { updateSettingsFile } from "./settings-file.js";
47
- import { activeProfile } from "./symlink-manager.js";
48
49
 
49
50
  // ─── Types ────────────────────────────────────────────────────────────────────
50
51
 
@@ -188,7 +189,7 @@ export interface StylesSnapshot {
188
189
  settingsPath: string;
189
190
  /** Name of the generated style, e.g. "composed" or "composed-dev". */
190
191
  styleName: string;
191
- /** Active profile name, when .claude/settings.json is a profile symlink. */
192
+ /** Active profile name, when a profile is active (see `activeProfile`). */
192
193
  profile: string | null;
193
194
  /** The value currently in settings.json, whatever set it. */
194
195
  currentOutputStyle: string | null;
@@ -1112,6 +1113,10 @@ export async function applyStyles({
1112
1113
  throw new Error("Nothing selected. Pick at least one style.");
1113
1114
  }
1114
1115
 
1116
+ // The gate before anything is written: whatever Claude Code wrote to the
1117
+ // live config goes into the profile, so the render below replaces nothing
1118
+ // unrecorded — and a live file that does not parse stops everything.
1119
+ await prepareProfileWrite(projectPath);
1115
1120
  const profile = await activeProfile(projectPath);
1116
1121
  const styleName = generatedStyleName(profile);
1117
1122
  const stylePath = stylePathFor(projectPath, styleName);
@@ -1122,7 +1127,19 @@ export async function applyStyles({
1122
1127
 
1123
1128
  await fs.ensureDir(path.dirname(stylePath));
1124
1129
  await fs.writeFile(stylePath, contents, "utf8");
1125
- await setOutputStyle(settingsPath, styleName);
1130
+
1131
+ // With a profile active, the manifest is where the selection lives and the
1132
+ // live file is rendered from it.
1133
+ let recordedInManifest = false;
1134
+ if (profile !== null) {
1135
+ recordedInManifest = await recordInManifest(
1136
+ projectPath,
1137
+ profile,
1138
+ styleName,
1139
+ );
1140
+ }
1141
+ if (recordedInManifest) await regenerateLiveFiles(projectPath);
1142
+ else await setOutputStyle(settingsPath, styleName);
1126
1143
 
1127
1144
  // Verify by re-reading, not by trusting the write.
1128
1145
  const verify = splitFrontmatter(await fs.readFile(stylePath, "utf8"));
@@ -1144,12 +1161,6 @@ export async function applyStyles({
1144
1161
  throw new Error(`Verification failed — ${problems.join("; ")}`);
1145
1162
  }
1146
1163
 
1147
- const recordedInManifest = await recordInManifest(
1148
- projectPath,
1149
- profile,
1150
- styleName,
1151
- );
1152
-
1153
1164
  // Write the declaration LAST, once the artifact is verified. It is the thing
1154
1165
  // that gets committed and that teammates are told to match, so it must never
1155
1166
  // claim a composition that failed to produce a valid file.
@@ -1268,6 +1279,25 @@ export async function createTeamStyle(
1268
1279
  */
1269
1280
  export async function clearStyle(projectPath: string): Promise<void> {
1270
1281
  const settingsPath = settingsPathFor(projectPath);
1282
+ // The gate first; a live file that does not parse stops everything.
1283
+ await prepareProfileWrite(projectPath);
1284
+ const profile = await activeProfile(projectPath);
1285
+
1286
+ if (profile !== null && (await fs.pathExists(getManifestPath(projectPath)))) {
1287
+ const manifest = await readManifest(projectPath);
1288
+ const entry = manifest.profiles[profile];
1289
+ if (entry?.settings && "outputStyle" in entry.settings) {
1290
+ // The guard above uses `in`, so assigning undefined would make a second
1291
+ // call a no-op that still leaves the key serialized into profiles.json.
1292
+ // biome-ignore lint/performance/noDelete: removal is the intent, not a shortcut
1293
+ delete entry.settings.outputStyle;
1294
+ entry.updatedAt = new Date().toISOString();
1295
+ await writeManifest(manifest, projectPath);
1296
+ }
1297
+ await regenerateLiveFiles(projectPath);
1298
+ return;
1299
+ }
1300
+
1271
1301
  // The guard stays: a project that never had a style must not gain a
1272
1302
  // settings.json just because the style was cleared.
1273
1303
  if (await fs.pathExists(settingsPath)) {
@@ -1278,17 +1308,4 @@ export async function clearStyle(projectPath: string): Promise<void> {
1278
1308
  delete settings.outputStyle;
1279
1309
  });
1280
1310
  }
1281
-
1282
- const profile = await activeProfile(projectPath);
1283
- if (!profile) return;
1284
- if (!(await fs.pathExists(getManifestPath(projectPath)))) return;
1285
- const manifest = await readManifest(projectPath);
1286
- const entry = manifest.profiles[profile];
1287
- if (!entry?.settings || !("outputStyle" in entry.settings)) return;
1288
- // The guard above uses `in`, so assigning undefined would make a second call
1289
- // a no-op that still leaves the key serialized into profiles.json.
1290
- // biome-ignore lint/performance/noDelete: removal is the intent, not a shortcut
1291
- delete entry.settings.outputStyle;
1292
- entry.updatedAt = new Date().toISOString();
1293
- await writeManifest(manifest, projectPath);
1294
1311
  }
@@ -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,14 +452,20 @@ 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[] };
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: () => {
@@ -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,
@@ -29,6 +32,7 @@ import {
29
32
  writeClipboard,
30
33
  } from "../../utils/clipboard.js";
31
34
  import { ScrollableList } from "../components/ScrollableList.js";
35
+ import { describeOutcome } from "../hooks/useProfileStartupModal.js";
32
36
  import {
33
37
  type ProfileListItem,
34
38
  buildProfileListItems,
@@ -156,11 +160,17 @@ export function ProfilesScreen() {
156
160
  }
157
161
  }
158
162
  await writeSettings(settings, state.projectPath);
163
+ // In a profiled project the merge is a change to the active profile:
164
+ // record it there now, rather than at the next magus command.
165
+ const recorded = await reconcileActiveProfile(state.projectPath);
159
166
  modal.hideModal();
160
167
  dispatch({ type: "DATA_REFRESH_COMPLETE" });
161
168
  await modal.message(
162
169
  "Applied",
163
- `Profile "${profile.name}" merged into project settings.`,
170
+ [
171
+ `Profile "${profile.name}" merged into project settings.`,
172
+ ...recorded.lines,
173
+ ].join("\n"),
164
174
  "success",
165
175
  );
166
176
  } catch (error) {
@@ -173,28 +183,71 @@ export function ProfilesScreen() {
173
183
  }
174
184
  };
175
185
 
186
+ /**
187
+ * Apply a saved profile.
188
+ *
189
+ * A PROJECT profile is switched to, through the same path as `magus profile
190
+ * switch`: what was changed in the live config goes into the profile being
191
+ * left, then this one is rendered. Writing its enabledPlugins straight into
192
+ * `.claude/settings.json` made the apply invisible to the profile and undone
193
+ * by the next render.
194
+ *
195
+ * A USER-scope profile is a template in `~/.claude/profiles.json` and applies
196
+ * to `~/.claude/settings.json` only — never to this project's live files,
197
+ * which belong to its active profile.
198
+ */
176
199
  const handleApply = async () => {
177
200
  if (selectedItem?.kind !== "saved") return;
178
201
  const selectedProfile = selectedItem.entry;
179
202
 
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;
203
+ if (selectedProfile.scope === "project") {
204
+ const confirmed = await modal.confirm(
205
+ "Switch profile",
206
+ `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.`,
207
+ );
208
+ if (!confirmed) return;
192
209
 
193
- const targetScope = scopeChoice as "user" | "project";
194
- const scopeLabel =
195
- targetScope === "user"
196
- ? "~/.claude/settings.json"
197
- : ".claude/settings.json";
210
+ modal.loading(`Switching to "${selectedProfile.name}"...`);
211
+ try {
212
+ const result = await switchToProfile(
213
+ state.projectPath,
214
+ selectedProfile.id,
215
+ { onDisplace: () => {} },
216
+ );
217
+ modal.hideModal();
218
+ dispatch({ type: "DATA_REFRESH_COMPLETE" });
219
+ if (result.problems.length > 0 || !result.activation) {
220
+ await modal.message(
221
+ "Could not switch",
222
+ [...result.lines, ...result.problems].join("\n"),
223
+ "error",
224
+ );
225
+ return;
226
+ }
227
+ const outcome = describeOutcome(
228
+ result.activation.profileId,
229
+ result.activation.displaced,
230
+ state.projectPath,
231
+ false,
232
+ activationNotes(result.activation),
233
+ );
234
+ await modal.message(
235
+ "Profile active",
236
+ result.lines.length > 0
237
+ ? `${result.lines.join("\n")}\n\n${outcome}`
238
+ : outcome,
239
+ "success",
240
+ );
241
+ } catch (error) {
242
+ modal.hideModal();
243
+ await modal.message(
244
+ "Error",
245
+ `Failed to switch profile: ${error}`,
246
+ "error",
247
+ );
248
+ }
249
+ return;
250
+ }
198
251
 
199
252
  const pluginCount = Object.keys(selectedProfile.plugins ?? {}).length;
200
253
  const emptyWarning =
@@ -204,23 +257,18 @@ export function ProfilesScreen() {
204
257
 
205
258
  const confirmed = await modal.confirm(
206
259
  "Confirm Apply",
207
- `This will REPLACE all enabledPlugins in ${scopeLabel}.${emptyWarning}\n\nContinue?`,
260
+ `This will REPLACE all enabledPlugins in ~/.claude/settings.json (every project on this machine).${emptyWarning}\n\nContinue?`,
208
261
  );
209
262
  if (!confirmed) return;
210
263
 
211
264
  modal.loading(`Applying "${selectedProfile.name}"...`);
212
265
  try {
213
- await applyProfile(
214
- selectedProfile.id,
215
- selectedProfile.scope,
216
- targetScope,
217
- state.projectPath,
218
- );
266
+ await applyUserProfile(selectedProfile);
219
267
  modal.hideModal();
220
268
  dispatch({ type: "DATA_REFRESH_COMPLETE" });
221
269
  await modal.message(
222
270
  "Applied",
223
- `Profile "${selectedProfile.name}" applied to ${scopeLabel}.`,
271
+ `Profile "${selectedProfile.name}" applied to ~/.claude/settings.json.`,
224
272
  "success",
225
273
  );
226
274
  } catch (error) {
@@ -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;