magus-cli 7.4.0 → 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 (67) hide show
  1. package/README.md +9 -8
  2. package/package.json +4 -4
  3. package/src/__tests__/blind/c19-paste-routing.test.ts +252 -0
  4. package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
  5. package/src/__tests__/install-command.test.ts +4 -3
  6. package/src/__tests__/models-cli.test.ts +4 -7
  7. package/src/__tests__/models-manager.test.ts +52 -40
  8. package/src/__tests__/profile-absorb.test.ts +1138 -0
  9. package/src/__tests__/profile-command.test.ts +90 -32
  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 +425 -0
  14. package/src/__tests__/profile-startup.test.ts +44 -14
  15. package/src/__tests__/profile-state.test.ts +795 -0
  16. package/src/__tests__/settings-file.test.ts +5 -7
  17. package/src/__tests__/stale-load-repair.test.ts +5 -4
  18. package/src/__tests__/styles-manager.test.ts +47 -74
  19. package/src/__tests__/styles-screen-state.test.ts +11 -10
  20. package/src/cli/bootstrap.ts +87 -27
  21. package/src/cli/doctor.ts +152 -44
  22. package/src/cli/install.ts +92 -127
  23. package/src/cli/models.ts +21 -1
  24. package/src/cli/profile.ts +48 -77
  25. package/src/cli/router.ts +32 -6
  26. package/src/cli/update.ts +43 -6
  27. package/src/data/gitignore-defaults.ts +3 -3
  28. package/src/services/active-closure.ts +3 -3
  29. package/src/services/claude-settings.ts +283 -125
  30. package/src/services/cli-tool-commands.ts +30 -0
  31. package/src/services/conventions-manager.ts +88 -0
  32. package/src/services/git-worktree.ts +21 -0
  33. package/src/services/gitignore-service.ts +39 -22
  34. package/src/services/manifest.ts +4 -5
  35. package/src/services/models-manager.ts +36 -73
  36. package/src/services/profile-absorb.ts +1532 -0
  37. package/src/services/profile-activate.ts +348 -96
  38. package/src/services/profile-live.ts +825 -0
  39. package/src/services/profile-render.ts +115 -0
  40. package/src/services/profile-skills.ts +462 -0
  41. package/src/services/profile-startup.ts +88 -16
  42. package/src/services/profile-state.ts +622 -0
  43. package/src/services/profiles.ts +20 -32
  44. package/src/services/resolver.ts +6 -1
  45. package/src/services/settings-file.ts +14 -8
  46. package/src/services/skills-manager.ts +14 -8
  47. package/src/services/styles-manager.ts +45 -28
  48. package/src/services/tool-probe.ts +48 -3
  49. package/src/types/index.ts +15 -9
  50. package/src/ui/App.tsx +25 -2
  51. package/src/ui/components/modals/ModalContainer.tsx +9 -1
  52. package/src/ui/hooks/useProfileStartupModal.ts +20 -15
  53. package/src/ui/input/KeyRouter.tsx +147 -23
  54. package/src/ui/input/key-dispatch.ts +48 -0
  55. package/src/ui/keymap.ts +8 -1
  56. package/src/ui/renderers/cliToolRenderers.tsx +65 -27
  57. package/src/ui/screens/CliToolsScreen.tsx +74 -13
  58. package/src/ui/screens/PluginsScreen.tsx +6 -2
  59. package/src/ui/screens/ProfilesScreen.tsx +75 -27
  60. package/src/ui/screens/StylesScreen.tsx +2 -1
  61. package/src/ui/state/types.ts +1 -1
  62. package/src/__tests__/profile-materializer.test.ts +0 -238
  63. package/src/__tests__/profile-sync.test.ts +0 -154
  64. package/src/__tests__/symlink-manager.test.ts +0 -180
  65. package/src/services/profile-materializer.ts +0 -133
  66. package/src/services/profile-sync.ts +0 -158
  67. 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
@@ -135,16 +135,22 @@ type CheckedMap<V> = unknown extends V
135
135
 
136
136
  /**
137
137
  * {@link writableMap} for a list the caller pushes into or filters —
138
- * `enabledMcpjsonServers`. Present but not an array throws the same way.
138
+ * `enabledMcpjsonServers`, a gitignore manifest's `ignore` and `track`. Present
139
+ * but not an array throws the same way.
139
140
  */
140
141
  export function writableList<S extends object, K extends keyof S & string>(
141
142
  settings: S,
142
143
  key: K,
143
144
  settingsPath: string,
144
- ): S[K] {
145
- return checkedEntry(settings, key, settingsPath, key, "array");
145
+ ): CheckedList<S[K]> {
146
+ return checkedEntry(settings, key, settingsPath, key, "array") as CheckedList<
147
+ S[K]
148
+ >;
146
149
  }
147
150
 
151
+ /** {@link CheckedMap} for a list: an untyped key comes back as the array it is. */
152
+ type CheckedList<V> = unknown extends V ? unknown[] | undefined : V;
153
+
148
154
  function checkedEntry<S extends object, K extends keyof S & string>(
149
155
  settings: S,
150
156
  key: K,
@@ -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
  }
@@ -1439,6 +1439,36 @@ async function isNpmDefaultPrefix(
1439
1439
  return real !== null && path.resolve(real) === root;
1440
1440
  }
1441
1441
 
1442
+ /**
1443
+ * Is `onPath` — the entry PATH actually runs, before any link is followed —
1444
+ * the launcher the native installer maintains beside `dataHome`?
1445
+ *
1446
+ * The launcher lives one level up from the data home, in `bin`:
1447
+ * `~/.local/share` → `~/.local/bin/claude`. The comparison is on the
1448
+ * DIRECTORIES, never on the files: both the launcher and a hand-made link
1449
+ * resolve into `versions/<version>`, which is precisely what has to be told
1450
+ * apart. Their real paths are compared too, so a symlinked `~/.local` still
1451
+ * matches.
1452
+ */
1453
+ async function isNativeLauncher(
1454
+ s: ProbeSession,
1455
+ onPath: string,
1456
+ dataHome: string,
1457
+ ): Promise<boolean> {
1458
+ const launcherDir = path.resolve(dataHome, "..", "bin");
1459
+ const entryDir = path.resolve(path.dirname(onPath));
1460
+ if (launcherDir === entryDir) return true;
1461
+ const [launcherReal, entryReal] = await Promise.all([
1462
+ s.io.realpath(launcherDir),
1463
+ s.io.realpath(entryDir),
1464
+ ]);
1465
+ return (
1466
+ launcherReal !== null &&
1467
+ entryReal !== null &&
1468
+ path.resolve(launcherReal) === path.resolve(entryReal)
1469
+ );
1470
+ }
1471
+
1442
1472
  /**
1443
1473
  * Claude Code's native installer keeps every release side by side under
1444
1474
  * `<data>/claude/versions/<version>` and points PATH (`~/.local/bin/claude`) at
@@ -1451,12 +1481,23 @@ async function isNpmDefaultPrefix(
1451
1481
  * number. The version is that name; the latest is npm's `latest` dist-tag for
1452
1482
  * Claude Code, the channel the native installer tracks by default.
1453
1483
  *
1484
+ * **Resolving into `versions/` is not enough: the entry PATH runs has to BE the
1485
+ * native launcher.** A hand-made link earlier on PATH — `/usr/local/bin/claude`
1486
+ * pointed at a release directory by hand, a wrapper someone wrote — resolves
1487
+ * into exactly the same place, and planning `claude update` for it would be a
1488
+ * plan that changes nothing: the updater rewrites the launcher, and PATH goes
1489
+ * on running the other file at the old version. Every run would find it behind
1490
+ * and plan the same no-op again. So the check is on `onPath`, the unresolved
1491
+ * entry, and a copy that is not the launcher falls through to the spec's own
1492
+ * installer — which reports it as a second copy it does not own.
1493
+ *
1454
1494
  * True when it recognised the layout; `f` then holds the whole answer.
1455
1495
  */
1456
1496
  async function probeClaudeNative(
1457
1497
  s: ProbeSession,
1458
1498
  spec: BinRequirement,
1459
1499
  f: ToolFacts,
1500
+ onPath: string,
1460
1501
  resolvedPath: string,
1461
1502
  ): Promise<boolean> {
1462
1503
  // The cheap, pure shape test first, so no other tool pays for the IO below.
@@ -1471,7 +1512,10 @@ async function probeClaudeNative(
1471
1512
  let owned = false;
1472
1513
  for (const home of dataHomes) {
1473
1514
  const versions = path.resolve(home, "claude", "versions");
1474
- owned = versions === dir || (await s.io.realpath(versions)) === dir;
1515
+ if (versions !== dir && (await s.io.realpath(versions)) !== dir) continue;
1516
+ // The directory is the installer's. Only the launcher it maintains
1517
+ // there speaks for it.
1518
+ owned = await isNativeLauncher(s, onPath, home);
1475
1519
  if (owned) break;
1476
1520
  }
1477
1521
  if (!owned) return false;
@@ -1561,8 +1605,9 @@ async function probeInto(
1561
1605
  f.resolvedPath = resolvedPath;
1562
1606
 
1563
1607
  // A file Claude Code's native installer owns answers for itself, whatever
1564
- // installer the spec names.
1565
- if (await probeClaudeNative(s, spec, f, resolvedPath)) return;
1608
+ // installer the spec names. `onPath` as well as `resolvedPath`: ownership
1609
+ // turns on which file PATH runs, not only on where it points.
1610
+ if (await probeClaudeNative(s, spec, f, onPath, resolvedPath)) return;
1566
1611
 
1567
1612
  const pinned = pinOf(spec) !== undefined;
1568
1613
  const via: BinInstaller = spec.via;
@@ -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
  );
@@ -1,6 +1,6 @@
1
1
  import type { ScrollBoxRenderable } from "@opentui/core";
2
2
  import React, { useRef, useState } from "react";
3
- import { useKeyLayer } from "../../input/KeyRouter.js";
3
+ import { useKeyLayer, usePasteLayer } from "../../input/KeyRouter.js";
4
4
  import { type KeyPress, isScrollKey } from "../../input/key-dispatch.js";
5
5
  import { useApp, useModal } from "../../state/AppContext.js";
6
6
  import { useDimensions } from "../../state/DimensionsContext.js";
@@ -23,6 +23,12 @@ const isPlain = (key: KeyPress) => !key.ctrl && !key.meta;
23
23
  * Enter that confirms a dialog confirms only the dialog. Ctrl+C still quits: it
24
24
  * is taken above every layer, in src/tui.tsx.
25
25
  *
26
+ * It takes every PASTE for the same reason, and does nothing with it: a search
27
+ * box on the screen behind still has focus, and without this its query would
28
+ * grow from a paste aimed at the dialog in front of it. The event itself is not
29
+ * stopped, so the input modal's focused `<input>` still receives it — the
30
+ * arrangement the `input` case below already relies on for keys.
31
+ *
26
32
  * - confirm: y / Enter confirm; n / Esc / q cancel.
27
33
  * - select: ↑↓ (j/k) move, Enter picks, Esc / q cancel (a no-op for a
28
34
  * question that must be answered).
@@ -79,6 +85,8 @@ export function ModalContainer() {
79
85
  }
80
86
  };
81
87
 
88
+ usePasteLayer("modal", modal !== null, () => true);
89
+
82
90
  useKeyLayer("modal", modal !== null, (key) => {
83
91
  if (!modal) return false;
84
92
 
@@ -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: () => {