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
@@ -5,11 +5,9 @@
5
5
  * their configuration:
6
6
  *
7
7
  * - an unparseable file must be REFUSED, and left byte-for-byte alone;
8
- * - a symlinked settings.json must be written THROUGH the link, because that
9
- * is the shape an active profile leaves behind (`.claude/settings.json` →
10
- * `.claude/_profiles/<name>/settings.json`). Remove-then-create would
11
- * replace the link with a plain file and detach the project from its
12
- * profile, which nothing downstream would notice.
8
+ * - a symlinked settings.json must be written THROUGH the link: a symlink a
9
+ * person set up deliberately is theirs. Remove-then-create would replace
10
+ * the link with a plain file, which nothing downstream would notice.
13
11
  */
14
12
 
15
13
  import { afterEach, describe, expect, test } from "bun:test";
@@ -154,7 +152,7 @@ describe("updateSettingsFile", () => {
154
152
  expect(readFileSync(file, "utf8")).toBe(original);
155
153
  });
156
154
 
157
- test("S-6: a symlinked settings.json is written THROUGH the link — the profile case", async () => {
155
+ test("S-6: a symlinked settings.json is written THROUGH the link", async () => {
158
156
  const dir = tempDir();
159
157
  const target = path.join(dir, "profile-settings.json");
160
158
  const link = path.join(dir, "settings.json");
@@ -168,7 +166,7 @@ describe("updateSettingsFile", () => {
168
166
  settings.outputStyle = "composed";
169
167
  });
170
168
 
171
- // The profile's real file received the write …
169
+ // The link target received the write …
172
170
  expect(JSON.parse(readFileSync(target, "utf8"))).toEqual({
173
171
  enabledPlugins: { "dev@magus": true },
174
172
  outputStyle: "composed",
@@ -344,10 +344,11 @@ describe("repairStaleLoad — refusals", () => {
344
344
  });
345
345
 
346
346
  test("an unreadable registry is refused rather than replaced", async () => {
347
- // `readInstalledPluginsRegistry` answers an EMPTY registry for a corrupt
348
- // file, and writing that back would erase every project's installs on the
349
- // machine. The repair reads and parses the same bytes it will check before
350
- // writing, so a document it cannot parse stops it here.
347
+ // Writing over a corrupt registry would erase every project's installs on
348
+ // the machine. The repair reads and parses the same bytes it will check
349
+ // before writing, so a document it cannot parse stops it here — and it
350
+ // reports that as a result rather than throwing, because one item in a
351
+ // plan of repairs must not end the plan.
351
352
  await fs.outputFile(registryFile, "{ not json");
352
353
 
353
354
  const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
@@ -4,6 +4,13 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import fs from "fs-extra";
6
6
  import { EMBEDDED_PRESETS } from "../data/styles/index.js";
7
+ import { writeManifest } from "../services/manifest.js";
8
+ import { activateProfile } from "../services/profile-activate.js";
9
+ import {
10
+ liveHash,
11
+ readActiveMarker,
12
+ writeActiveMarker,
13
+ } from "../services/profile-live.js";
7
14
  import {
8
15
  INTEGRITY_BLOCK,
9
16
  type ImportedStyle,
@@ -19,6 +26,7 @@ import {
19
26
  splitFrontmatter,
20
27
  validateSelection,
21
28
  } from "../services/styles-manager.js";
29
+ import type { ProfileManifest } from "../types/index.js";
22
30
  import {
23
31
  buildStyleBrowserItems,
24
32
  firstSelectableIndex,
@@ -405,20 +413,26 @@ describe("applyStyles", () => {
405
413
  ).rejects.toThrow(/Nothing selected/);
406
414
  });
407
415
 
408
- test("writes THROUGH an active profile's settings symlink without breaking it", async () => {
409
- // The regression this guards: removing-then-recreating settings.json
410
- // silently detaches the project from its profile, and every later profile
411
- // switch then writes to a file nothing reads.
412
- const profileSettings = join(
416
+ const DEV: ProfileManifest = {
417
+ version: 2,
418
+ profiles: { dev: { name: "dev", plugins: {} } },
419
+ };
420
+
421
+ test("an older settings symlink is converted to a real file, not written through", async () => {
422
+ // Claude Code refuses to write through a symlink, so the style must not
423
+ // be applied by writing through one either: the file is rendered from
424
+ // the manifest in place of the link.
425
+ const oldSettings = join(
413
426
  dir,
414
427
  ".claude",
415
428
  "_profiles",
416
429
  "dev",
417
430
  "settings.json",
418
431
  );
419
- await fs.outputJson(profileSettings, { enabledPlugins: {} });
432
+ await fs.outputJson(oldSettings, { enabledPlugins: {} });
420
433
  const link = join(dir, ".claude", "settings.json");
421
434
  await symlink("_profiles/dev/settings.json", link);
435
+ await writeManifest(DEV, dir);
422
436
 
423
437
  const result = await applyStyles({
424
438
  projectPath: dir,
@@ -428,33 +442,13 @@ describe("applyStyles", () => {
428
442
 
429
443
  expect(result.profile).toBe("dev");
430
444
  expect(result.styleName).toBe("composed-dev");
431
- expect((await fs.lstat(link)).isSymbolicLink()).toBe(true);
432
- // The write landed in the profile, which is what "apply to the current
433
- // profile" means.
434
- expect((await fs.readJson(profileSettings)).outputStyle).toBe(
435
- "composed-dev",
436
- );
445
+ expect((await fs.lstat(link)).isSymbolicLink()).toBe(false);
446
+ expect((await fs.readJson(link)).outputStyle).toBe("composed-dev");
437
447
  });
438
448
 
439
- test("records the style in profiles.json so re-materializing cannot drop it", async () => {
440
- // materializeProfile rewrites settings.json from the manifest, so a live
441
- // write alone regresses on the next `magus install`.
442
- const profileSettings = join(
443
- dir,
444
- ".claude",
445
- "_profiles",
446
- "dev",
447
- "settings.json",
448
- );
449
- await fs.outputJson(profileSettings, { enabledPlugins: {} });
450
- await symlink(
451
- "_profiles/dev/settings.json",
452
- join(dir, ".claude", "settings.json"),
453
- );
454
- await fs.outputJson(join(dir, ".claude", "profiles.json"), {
455
- version: 2,
456
- profiles: { dev: { name: "dev", plugins: {} } },
457
- });
449
+ test("with an activated profile, the live file stays real and is recorded", async () => {
450
+ await writeManifest(DEV, dir);
451
+ await activateProfile(DEV, "dev", dir, { onDisplace: () => {} });
458
452
 
459
453
  const result = await applyStyles({
460
454
  projectPath: dir,
@@ -462,67 +456,46 @@ describe("applyStyles", () => {
462
456
  imports: [],
463
457
  });
464
458
 
459
+ const live = join(dir, ".claude", "settings.json");
465
460
  expect(result.recordedInManifest).toBe(true);
466
- const manifest = await fs.readJson(join(dir, ".claude", "profiles.json"));
467
- expect(manifest.profiles.dev.settings.outputStyle).toBe("composed-dev");
461
+ expect((await fs.lstat(live)).isFile()).toBe(true);
462
+ expect((await fs.readJson(live)).outputStyle).toBe("composed-dev");
463
+ // Rendered from the manifest and recorded, so it reads as unchanged.
464
+ expect((await readActiveMarker(dir))?.files.settings).toBe(
465
+ (await liveHash("settings", dir)) ?? "",
466
+ );
468
467
  });
469
468
 
470
- test("reports honestly when there is a profile but no manifest entry", async () => {
471
- const profileSettings = join(
472
- dir,
473
- ".claude",
474
- "_profiles",
475
- "dev",
476
- "settings.json",
477
- );
478
- await fs.outputJson(profileSettings, {});
479
- await symlink(
480
- "_profiles/dev/settings.json",
481
- join(dir, ".claude", "settings.json"),
482
- );
469
+ test("records the style in profiles.json, where rendering reads it", async () => {
470
+ await writeManifest(DEV, dir);
471
+ await activateProfile(DEV, "dev", dir, { onDisplace: () => {} });
483
472
 
484
473
  const result = await applyStyles({
485
474
  projectPath: dir,
486
475
  presets: [preset({ name: "direct" })],
487
476
  imports: [],
488
477
  });
489
- expect(result.profile).toBe("dev");
490
- expect(result.recordedInManifest).toBe(false);
491
- });
492
- });
493
478
 
494
- describe("clearStyle", () => {
495
- test("removes the key rather than setting it undefined", async () => {
496
- await applyStyles({
497
- projectPath: dir,
498
- presets: [preset({ name: "direct" })],
499
- imports: [],
500
- });
501
- await clearStyle(dir);
479
+ expect(result.recordedInManifest).toBe(true);
480
+ const manifest = await fs.readJson(join(dir, ".claude", "profiles.json"));
481
+ expect(manifest.profiles.dev.settings.outputStyle).toBe("composed-dev");
482
+ });
502
483
 
503
- const raw = await fs.readFile(
504
- join(dir, ".claude", "settings.json"),
505
- "utf8",
484
+ test("a marker naming a profile profiles.json lacks is no active profile", async () => {
485
+ await writeManifest(DEV, dir);
486
+ await writeActiveMarker(
487
+ { version: 1, profile: "gone", files: {}, skills: {} },
488
+ dir,
506
489
  );
507
- expect(raw).not.toContain("outputStyle");
508
- expect(JSON.parse(raw)).toEqual({});
509
- });
490
+ await fs.outputJson(join(dir, ".claude", "settings.json"), {});
510
491
 
511
- test("leaves the generated file on disk so a re-apply is one keypress away", async () => {
512
492
  const result = await applyStyles({
513
493
  projectPath: dir,
514
494
  presets: [preset({ name: "direct" })],
515
495
  imports: [],
516
496
  });
517
- await clearStyle(dir);
518
- expect(await fs.pathExists(result.stylePath)).toBe(true);
519
- });
520
-
521
- test("is a no-op on a project that never had a style", async () => {
522
- await clearStyle(dir);
523
- expect(await fs.pathExists(join(dir, ".claude", "settings.json"))).toBe(
524
- false,
525
- );
497
+ expect(result.profile).toBeNull();
498
+ expect(result.recordedInManifest).toBe(false);
526
499
  });
527
500
  });
528
501
 
@@ -1,15 +1,18 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdtemp, rm, symlink } from "node:fs/promises";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { parseKeypress } from "@opentui/core";
6
6
  import fs from "fs-extra";
7
+ import { writeManifest } from "../services/manifest.js";
8
+ import { activateProfile } from "../services/profile-activate.js";
7
9
  import {
8
10
  applyStyles,
9
11
  clearStyle,
10
12
  communityCacheDirOrNull,
11
13
  loadStyles,
12
14
  } from "../services/styles-manager.js";
15
+ import type { ProfileManifest } from "../types/index.js";
13
16
  import type { KeyPress } from "../ui/input/key-dispatch.js";
14
17
  import { SCREEN_BINDINGS, matchBinding } from "../ui/keymap.js";
15
18
  import { appReducer, initialState } from "../ui/state/reducer.js";
@@ -291,16 +294,14 @@ describe("loadStyles reports what is actually in force", () => {
291
294
  });
292
295
 
293
296
  test("looks for the per-profile style when a profile is active", async () => {
294
- await fs.outputJson(
295
- join(dir, ".claude", "_profiles", "dev", "settings.json"),
296
- {
297
- outputStyle: "composed-dev",
297
+ const manifest: ProfileManifest = {
298
+ version: 2,
299
+ profiles: {
300
+ dev: { name: "dev", settings: { outputStyle: "composed-dev" } },
298
301
  },
299
- );
300
- await symlink(
301
- "_profiles/dev/settings.json",
302
- join(dir, ".claude", "settings.json"),
303
- );
302
+ };
303
+ await writeManifest(manifest, dir);
304
+ await activateProfile(manifest, "dev", dir, { onDisplace: () => {} });
304
305
  await fs.outputFile(
305
306
  join(dir, ".claude", "output-styles", "composed-dev.md"),
306
307
  "---\nname: composed-dev\nstyle-presets: terse\nstyle-imports: none\n---\n\nbody",
@@ -15,8 +15,9 @@ import {
15
15
  writeManifest,
16
16
  } from "../services/manifest.js";
17
17
  import {
18
- applyProfileChoice,
19
- materializeAndActivate,
18
+ type ActivationResult,
19
+ activateProfile,
20
+ activationNotes,
20
21
  reportDisplaced,
21
22
  } from "../services/profile-activate.js";
22
23
  import {
@@ -31,6 +32,13 @@ import {
31
32
  needsNothing,
32
33
  parseProfileChoice,
33
34
  } from "../services/profile-startup.js";
35
+ import {
36
+ applyProfileChoice,
37
+ describeUnknownProfile,
38
+ prepareProfileState,
39
+ suggestedProfile,
40
+ switchHint,
41
+ } from "../services/profile-state.js";
34
42
  import { SettingsParseError } from "../services/settings-file.js";
35
43
  import type { ProfileManifest } from "../types/index.js";
36
44
  import { dim } from "./ansi.js";
@@ -57,8 +65,8 @@ export function reportUnparseable(error: SettingsParseError): void {
57
65
  * Write the adopted manifest, then put the project ON the profile it describes.
58
66
  *
59
67
  * Both halves or neither. Writing alone is what made migrating feel like a
60
- * no-op: `activeProfile()` is derived from the symlink, so a manifest nothing
61
- * links to changes no behaviour, and the run that just migrated still has to
68
+ * no-op: `activeProfile()` reads the marker activation writes, so a manifest
69
+ * nothing activated changes no behaviour, and the run that just migrated still has to
62
70
  * ask which profile to use.
63
71
  *
64
72
  * Activation is reported in full, including anything it moved aside, because it
@@ -79,20 +87,73 @@ async function writeAndActivate(
79
87
  console.log(`✓ Wrote ${rel(getManifestPath(projectPath))}`);
80
88
 
81
89
  // Each move is printed as it happens, not from the returned list afterwards.
82
- // Activation touches up to four artifacts in sequence, so a throw on the
83
- // third would discard the record of the first two — and the first of those
84
- // is the user's `.claude/settings.json`, now absent from where they left it.
85
- // Printing first means a failed activation still says where everything went.
86
- await materializeAndActivate(
90
+ // Activation writes three files in sequence, so a throw on the third would
91
+ // discard the record of the first two — and the first of those is the
92
+ // user's `.claude/settings.json`, now absent from where they left it.
93
+ const activation = await activateProfile(
87
94
  result.manifest,
88
95
  result.profileId,
89
96
  projectPath,
90
- reportDisplaced(projectPath),
97
+ { onDisplace: reportDisplaced(projectPath) },
91
98
  );
92
99
  console.log(`✓ Active profile: ${result.profileId}`);
100
+ printActivationNotes(activation);
93
101
  console.log();
94
102
  }
95
103
 
104
+ /** Print what activation did beyond the switch itself, one line each. */
105
+ export function printActivationNotes(result: ActivationResult): void {
106
+ for (const line of activationNotes(result)) console.log(noteLine(line));
107
+ }
108
+
109
+ export interface ProfileGateOptions {
110
+ /**
111
+ * Carry on when the marker names a profile profiles.json does not define.
112
+ * Only for commands that fix exactly that by activating a profile that
113
+ * exists — `profile switch`, `install`.
114
+ */
115
+ allowUnknown?: boolean;
116
+ }
117
+
118
+ /**
119
+ * The gate every CLI command that reads or writes profile state passes first.
120
+ *
121
+ * Restores a lost marker when the live config matches exactly one profile,
122
+ * converts an older layout, absorbs what Claude Code wrote to the live config
123
+ * into the active profile, and regenerates the live config when profiles.json
124
+ * changed — printing one line for each. Returns false, having said why, when
125
+ * the command must not act: a marker naming a profile that does not exist, or
126
+ * a live file that cannot be read (it would be overwritten).
127
+ */
128
+ export async function profileGate(
129
+ projectPath: string,
130
+ opts: ProfileGateOptions = {},
131
+ ): Promise<boolean> {
132
+ let prep: Awaited<ReturnType<typeof prepareProfileState>>;
133
+ try {
134
+ prep = await prepareProfileState(projectPath);
135
+ } catch (error) {
136
+ if (error instanceof SettingsParseError) {
137
+ reportUnparseable(error);
138
+ return false;
139
+ }
140
+ console.error(
141
+ failLine(error instanceof Error ? error.message : String(error)),
142
+ );
143
+ return false;
144
+ }
145
+ for (const line of prep.lines) console.log(noteLine(line));
146
+ if (prep.problems.length > 0) {
147
+ for (const problem of prep.problems) console.error(failLine(problem));
148
+ return false;
149
+ }
150
+ if (prep.check.state === "unknown" && !opts.allowUnknown) {
151
+ console.error(failLine(describeUnknownProfile(prep.check)));
152
+ return false;
153
+ }
154
+ return true;
155
+ }
156
+
96
157
  /** Print what adoption would capture, so the user can judge it before writing. */
97
158
  function printAdoptionPlan(result: AdoptResult): void {
98
159
  console.log(
@@ -141,12 +202,11 @@ function printAdoptionPlan(result: AdoptResult): void {
141
202
  // onto the profile, which replaces two files that are in the working tree
142
203
  // right now. The prompt below is the only place to decline.
143
204
  console.log(
144
- "The project then runs this profile: .claude/settings.json, .mcp.json and",
205
+ "The project then runs this profile: .claude/settings.json and .mcp.json are",
145
206
  );
146
207
  console.log(
147
- ".claude/skills become links into it, and your current copies are kept where",
208
+ "generated from it, and your current copies are kept where the run reports.",
148
209
  );
149
- console.log("the run reports.");
150
210
  }
151
211
 
152
212
  export interface EnsureManifestOptions {
@@ -231,7 +291,7 @@ export async function ensureManifest(
231
291
  * state is read from and where updates are written. With none there is nothing
232
292
  * to read and nowhere to write, so there is no useful work to do — and the
233
293
  * previous version of this offered exactly that anyway. It asked only when the
234
- * manifest had drifted, let the user decline, and `update` then carried on with
294
+ * manifest disagreed with `enabledPlugins`, let the user decline, and `update` then carried on with
235
295
  * a profile "for this run only", resolving against a manifest the project was
236
296
  * not running. Two worlds live at once, which is the confusion being removed.
237
297
  *
@@ -262,15 +322,16 @@ export async function requireActiveProfile(
262
322
  // `--check` and with no TTY: this is the committed answer being applied, not
263
323
  // a decision being made, and it is exactly the case CI hits.
264
324
  if (need.autoActivate && need.declaredDefault !== null && !opts.check) {
265
- const { profileId } = await applyProfileChoice(
325
+ const result = await applyProfileChoice(
266
326
  { kind: "activate", id: need.declaredDefault },
267
327
  need.manifest,
268
328
  projectPath,
269
- reportDisplaced(projectPath),
329
+ { onDisplace: reportDisplaced(projectPath) },
270
330
  );
271
331
  console.log(
272
- `✓ Active profile: ${profileId} ${dim("(declared by this project)")}`,
332
+ `✓ Active profile: ${result.profileId} ${dim("(declared by this project)")}`,
273
333
  );
334
+ printActivationNotes(result);
274
335
  console.log("");
275
336
  return readManifest(projectPath);
276
337
  }
@@ -292,9 +353,10 @@ export async function requireActiveProfile(
292
353
  noteLine(
293
354
  need.state === "unmigrated"
294
355
  ? "Run `magus profile init` to create one deliberately."
295
- : `Run \`magus profile switch <${need.activate
296
- .map((p) => p.id)
297
- .join("|")}>\` first.`,
356
+ : `Run ${switchHint(
357
+ need.activate.map((p) => p.id),
358
+ need.declaredDefault ?? suggestedProfile(need.manifest),
359
+ )} first.`,
298
360
  ),
299
361
  );
300
362
  return null;
@@ -316,13 +378,11 @@ export async function requireActiveProfile(
316
378
  return null;
317
379
  }
318
380
 
319
- const { profileId } = await applyProfileChoice(
320
- choice,
321
- need.manifest,
322
- projectPath,
323
- reportDisplaced(projectPath),
324
- );
325
- console.log(`✓ Active profile: ${profileId}`);
381
+ const result = await applyProfileChoice(choice, need.manifest, projectPath, {
382
+ onDisplace: reportDisplaced(projectPath),
383
+ });
384
+ console.log(`✓ Active profile: ${result.profileId}`);
385
+ printActivationNotes(result);
326
386
  console.log("");
327
387
 
328
388
  // Re-read rather than return `need.manifest`: adopting wrote a new profile