magus-cli 7.5.3 → 7.6.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 (68) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-convergence.test.ts +1 -2
  3. package/src/__tests__/blackbox-parity.test.ts +16 -14
  4. package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
  5. package/src/__tests__/cli-apply-seams.test.ts +3 -3
  6. package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
  7. package/src/__tests__/content-drift.test.ts +0 -1
  8. package/src/__tests__/files-missing-scope.test.ts +235 -0
  9. package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
  10. package/src/__tests__/format-update-outcome.test.ts +367 -0
  11. package/src/__tests__/installed-version-scope.test.tsx +425 -0
  12. package/src/__tests__/key-focus-guard.test.tsx +260 -0
  13. package/src/__tests__/mate-availability.test.ts +11 -13
  14. package/src/__tests__/plugin-contents.test.ts +0 -1
  15. package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
  16. package/src/__tests__/plugin-row-text.test.ts +18 -21
  17. package/src/__tests__/profile-command.test.ts +2 -2
  18. package/src/__tests__/profile-migration-offer.test.ts +5 -4
  19. package/src/__tests__/profile-name-on-save.test.ts +443 -0
  20. package/src/__tests__/profile-skills.test.ts +3 -3
  21. package/src/__tests__/profile-startup-nested.test.ts +329 -0
  22. package/src/__tests__/profile-startup-old-format.test.ts +390 -0
  23. package/src/__tests__/profile-startup.test.ts +1 -1
  24. package/src/__tests__/profile-state.test.ts +27 -22
  25. package/src/__tests__/prompt-eof.test.ts +245 -0
  26. package/src/__tests__/scope-action.test.ts +35 -38
  27. package/src/__tests__/scope-row-lookup.test.ts +126 -0
  28. package/src/__tests__/select-restore.test.ts +209 -0
  29. package/src/__tests__/stale-load-repair.test.ts +1 -2
  30. package/src/__tests__/update-apply.test.ts +18 -2
  31. package/src/__tests__/update-null-readback.test.ts +300 -0
  32. package/src/__tests__/update-plan-parity.test.ts +249 -0
  33. package/src/__tests__/update-plan.test.ts +66 -67
  34. package/src/__tests__/version-unknown-state.test.ts +38 -39
  35. package/src/cli/bootstrap.ts +271 -66
  36. package/src/cli/doctor.ts +4 -3
  37. package/src/cli/install.ts +83 -27
  38. package/src/cli/prompt.ts +19 -5
  39. package/src/cli/router.ts +1 -0
  40. package/src/cli/select.ts +168 -0
  41. package/src/cli/update-view.ts +120 -6
  42. package/src/cli/update.ts +6 -4
  43. package/src/services/claude-settings.ts +28 -0
  44. package/src/services/manifest.ts +31 -0
  45. package/src/services/mate-availability.ts +23 -29
  46. package/src/services/plugin-manager.ts +247 -105
  47. package/src/services/profile-absorb.ts +10 -7
  48. package/src/services/profile-activate.ts +41 -19
  49. package/src/services/profile-adopt.ts +9 -2
  50. package/src/services/profile-startup.ts +206 -5
  51. package/src/services/profile-state.ts +8 -7
  52. package/src/services/tracked-live-files.ts +24 -3
  53. package/src/services/update-engine.ts +28 -10
  54. package/src/services/update-plan.ts +78 -94
  55. package/src/ui/App.tsx +96 -56
  56. package/src/ui/adapters/pluginsAdapter.ts +1 -1
  57. package/src/ui/components/modals/ModalContainer.tsx +3 -0
  58. package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
  59. package/src/ui/hooks/useGitignoreModal.ts +21 -3
  60. package/src/ui/hooks/useProfileStartupModal.ts +214 -15
  61. package/src/ui/hooks/useStaleLoadModal.ts +5 -1
  62. package/src/ui/input/KeyRouter.tsx +43 -2
  63. package/src/ui/keymap.ts +1 -1
  64. package/src/ui/renderers/pluginRenderers.tsx +39 -45
  65. package/src/ui/renderers/pluginRowText.ts +6 -2
  66. package/src/ui/renderers/pluginStatusText.ts +4 -3
  67. package/src/ui/screens/PluginsScreen.tsx +28 -41
  68. package/src/cli/tui-prompt.tsx +0 -143
@@ -18,6 +18,7 @@ import {
18
18
  type ActivationResult,
19
19
  activateProfile,
20
20
  activationNotes,
21
+ activationTrackedNotices,
21
22
  reportDisplaced,
22
23
  } from "../services/profile-activate.js";
23
24
  import {
@@ -25,12 +26,16 @@ import {
25
26
  adoptCurrentProject,
26
27
  } from "../services/profile-adopt.js";
27
28
  import {
29
+ type ProfileStartupNeed,
30
+ UPDATE_FORMAT_VALUE,
28
31
  buildProfileOptions,
29
32
  defaultOptionIndex,
30
33
  describeNeed,
31
34
  detectProfileStartupNeed,
32
35
  needsNothing,
36
+ oldFormatNeed,
33
37
  parseProfileChoice,
38
+ profileIdForName,
34
39
  } from "../services/profile-startup.js";
35
40
  import {
36
41
  applyProfileChoice,
@@ -41,10 +46,23 @@ import {
41
46
  } from "../services/profile-state.js";
42
47
  import { SettingsParseError } from "../services/settings-file.js";
43
48
  import type { ProfileManifest } from "../types/index.js";
44
- import { dim } from "./ansi.js";
45
- import { confirm } from "./prompt.js";
46
- import { selectDialog } from "./tui-prompt.js";
47
- import { fail as failLine, note as noteLine } from "./update-view.js";
49
+ import { brand } from "../ui/theme.js";
50
+ import { badge, bold, dim } from "./ansi.js";
51
+ import { askYesNo, isPromptAborted, promptValue } from "./prompt.js";
52
+ import { selectOption } from "./select.js";
53
+ import {
54
+ type ScopeName,
55
+ fail as failLine,
56
+ field,
57
+ info as infoLine,
58
+ listItem,
59
+ noteBlock,
60
+ note as noteLine,
61
+ ok as okLine,
62
+ question,
63
+ scopeBadge,
64
+ warn as warnLine,
65
+ } from "./update-view.js";
48
66
 
49
67
  /**
50
68
  * Report a file that exists and cannot be used — it does not parse, or parses
@@ -76,6 +94,13 @@ export function reportUnparseable(error: SettingsParseError): void {
76
94
  async function writeAndActivate(
77
95
  result: AdoptResult,
78
96
  projectPath: string,
97
+ /**
98
+ * Report a generated file git tracks. Only when adopting IS the command
99
+ * (`profile init`): `install` and `update` report it themselves right
100
+ * after — install's switch, update's own check — so adoption inside them
101
+ * said it twice in one run.
102
+ */
103
+ reportTracked: boolean,
79
104
  ): Promise<void> {
80
105
  // Relative to the project throughout. These are all project paths, and an
81
106
  // absolute one wraps over two terminal lines — several wrapped lines in a row
@@ -84,7 +109,12 @@ async function writeAndActivate(
84
109
  const rel = (p: string) => path.relative(projectPath, p);
85
110
 
86
111
  await writeManifest(result.manifest, projectPath);
87
- console.log(`✓ Wrote ${rel(getManifestPath(projectPath))}`);
112
+ console.log(okLine(`Saved profile ${bold(result.profileId)}`));
113
+ console.log(
114
+ noteLine(
115
+ `Commit ${rel(getManifestPath(projectPath))} so your team gets the same plugins.`,
116
+ ),
117
+ );
88
118
 
89
119
  // Each move is printed as it happens, not from the returned list afterwards.
90
120
  // Activation writes three files in sequence, so a throw on the third would
@@ -96,14 +126,28 @@ async function writeAndActivate(
96
126
  projectPath,
97
127
  { onDisplace: reportDisplaced(projectPath) },
98
128
  );
99
- console.log(`✓ Active profile: ${result.profileId}`);
100
- printActivationNotes(activation);
129
+ console.log(okLine(`Using profile ${bold(result.profileId)}`));
130
+ printActivationNotes(activation, { tracked: reportTracked });
101
131
  console.log();
102
132
  }
103
133
 
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));
134
+ /**
135
+ * Print what activation did beyond the switch itself: notes one line each, and
136
+ * — unless `tracked` is false — a tracked generated file as a warning with its
137
+ * fix beneath.
138
+ */
139
+ export function printActivationNotes(
140
+ result: ActivationResult,
141
+ opts: { tracked?: boolean } = {},
142
+ ): void {
143
+ for (const line of activationNotes(result, { tracked: false })) {
144
+ console.log(noteLine(line));
145
+ }
146
+ if (opts.tracked === false) return;
147
+ for (const n of activationTrackedNotices(result)) {
148
+ console.log(warnLine(n.problem));
149
+ for (const line of n.fix) console.log(noteLine(line));
150
+ }
107
151
  }
108
152
 
109
153
  export interface ProfileGateOptions {
@@ -113,6 +157,39 @@ export interface ProfileGateOptions {
113
157
  * exists — `profile switch`, `install`.
114
158
  */
115
159
  allowUnknown?: boolean;
160
+ /** `--yes`: update an old profile format without asking. */
161
+ yes?: boolean;
162
+ }
163
+
164
+ /**
165
+ * An old magus profile format: the shared `old-format` state, stated with the
166
+ * TUI's own title and message and — with a person at the terminal and no
167
+ * `--yes` — asked with the TUI's own rows. Returns false when the user
168
+ * declines; the command then stops, since magus cannot manage a project in a
169
+ * format it no longer writes. With nobody to ask it updates, as before: CI and
170
+ * `--yes` must not hang on a question.
171
+ */
172
+ async function confirmFormatUpdate(
173
+ projectPath: string,
174
+ opts: ProfileGateOptions,
175
+ ): Promise<boolean> {
176
+ const need = await oldFormatNeed(projectPath);
177
+ if (need === null || need.state !== "old-format") return true;
178
+ const { title, message } = describeNeed(need);
179
+ console.log("");
180
+ console.log(warnLine(bold(title)));
181
+ for (const line of noteBlock(message, process.stdout.columns)) {
182
+ console.log(line);
183
+ }
184
+ console.log("");
185
+ if (opts.yes || !process.stdin.isTTY || !process.stdout.isTTY) return true;
186
+ const options = buildProfileOptions(need);
187
+ const picked = await selectOption("Update the profile format?", options, 0);
188
+ if (picked !== null && options[picked]?.value === UPDATE_FORMAT_VALUE) {
189
+ return true;
190
+ }
191
+ console.log(infoLine("Nothing changed."));
192
+ return false;
116
193
  }
117
194
 
118
195
  /**
@@ -129,6 +206,7 @@ export async function profileGate(
129
206
  projectPath: string,
130
207
  opts: ProfileGateOptions = {},
131
208
  ): Promise<boolean> {
209
+ if (!(await confirmFormatUpdate(projectPath, opts))) return false;
132
210
  let prep: Awaited<ReturnType<typeof prepareProfileState>>;
133
211
  try {
134
212
  prep = await prepareProfileState(projectPath);
@@ -154,64 +232,128 @@ export async function profileGate(
154
232
  return true;
155
233
  }
156
234
 
157
- /** Print what adoption would capture, so the user can judge it before writing. */
235
+ /** The plugin as a user reads it: its name, and its marketplace only when that is not magus's own. */
236
+ function pluginLabel(id: string): string {
237
+ const at = id.lastIndexOf("@");
238
+ if (at <= 0) return id;
239
+ const market = id.slice(at + 1);
240
+ return market === "magus"
241
+ ? id.slice(0, at)
242
+ : `${id.slice(0, at)} ${dim(market)}`;
243
+ }
244
+
245
+ /** Longest list shown before it is cut to "+ N more". */
246
+ const LIST_MAX = 12;
247
+
248
+ /** Print up to LIST_MAX entries under a field, then how many were left out. */
249
+ function printList(entries: string[]): void {
250
+ for (const entry of entries.slice(0, LIST_MAX)) console.log(listItem(entry));
251
+ if (entries.length > LIST_MAX) {
252
+ console.log(listItem(dim(`+ ${entries.length - LIST_MAX} more`)));
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Print the state and what adoption would capture, so the user can judge it
258
+ * before the question below.
259
+ *
260
+ * In the user's terms only: the state ("no profiles yet"), the plugins as a
261
+ * list with the SCOPE they come from, and anything that affects the team.
262
+ * Never where magus stores things — the owner, on the version that printed
263
+ * "from this folder's .claude/settings.json" and a relative path: users
264
+ * operate in scopes (user, project, local), not in files, and a mechanism
265
+ * they never chose is noise.
266
+ */
158
267
  function printAdoptionPlan(result: AdoptResult): void {
268
+ console.log("");
269
+ console.log(warnLine(bold("No profiles in this project yet")));
159
270
  console.log(
160
- "\nThis project has no .claude/profiles.json — magus can create one from its current setup.\n",
271
+ noteLine(
272
+ "magus keeps a project's plugins in a profile: a named set your team gets",
273
+ ),
161
274
  );
162
- console.log(` profile ${result.profileId} (${result.entry.name})`);
163
275
  console.log(
164
- ` plugins ${result.counts.plugins} enabled, adopted as "latest"`,
276
+ noteLine(
277
+ "by committing it. Save what this project uses now as the first one.",
278
+ ),
165
279
  );
166
- if (result.source === "user") {
167
- // Never let this be invisible: the generated file is meant to be committed,
168
- // and these came from ~/.claude/settings.json — this machine's personal
169
- // global set, not anything this repo chose.
170
- console.log(
171
- " ⚠ read from ~/.claude/settings.json (user scope) — this project",
172
- );
173
- console.log(
174
- " enables none of its own. Review before committing.",
175
- );
176
- }
177
- if (result.counts.mcpServers > 0) {
178
- console.log(` mcp servers ${result.counts.mcpServers} from .mcp.json`);
280
+ console.log("");
281
+
282
+ console.log(
283
+ field(
284
+ "profile",
285
+ `${bold(result.profileId)} ${badge("NEW", brand.success)}`,
286
+ ),
287
+ );
288
+ const scope: ScopeName = result.source === "user" ? "user" : "project";
289
+ console.log(
290
+ field(
291
+ "plugins",
292
+ `${bold(String(result.counts.plugins))} ${scopeBadge(scope)}`,
293
+ ),
294
+ );
295
+ printList(Object.keys(result.entry.plugins ?? {}).map(pluginLabel));
296
+ const servers = Object.keys(result.entry.mcpServers ?? {});
297
+ if (servers.length > 0) {
298
+ console.log(field("mcp servers", bold(String(servers.length))));
299
+ printList(servers);
179
300
  }
180
- if (result.counts.settingsKeys > 0) {
301
+ console.log("");
302
+
303
+ const warned =
304
+ result.source === "user" ||
305
+ result.skipped.length > 0 ||
306
+ result.machinePaths.length > 0;
307
+ if (result.source === "user") {
308
+ // Never let this be invisible: the profile is meant to be committed, and
309
+ // these are one person's global set, not anything this project chose.
310
+ console.log(warnLine("This project has no plugins of its own"));
181
311
  console.log(
182
- ` settings ${result.counts.settingsKeys} keys carried over`,
312
+ noteLine(
313
+ "These are your USER plugins, on in every project. Check them before committing.",
314
+ ),
183
315
  );
184
316
  }
185
317
  for (const id of result.skipped) {
186
318
  console.log(
187
- ` ⚠ skipped ${id} — no marketplace in its id, not reproducible`,
319
+ warnLine(`${id} can't be shared — it isn't from a marketplace`),
188
320
  );
189
321
  }
190
322
  for (const hit of result.machinePaths) {
191
- console.log(` ⚠ machine path ${hit}`);
192
- }
193
- if (result.machinePaths.length > 0) {
194
323
  console.log(
195
- " Those name a directory on THIS machine and will not resolve on another.",
324
+ warnLine(`A setting points to a folder on this computer: ${hit}`),
196
325
  );
197
326
  }
198
- console.log(
199
- "\nCommit the file to share this setup with the team; edit it to pin versions.",
200
- );
201
- // Said before the question, not after it: adopting also SWITCHES the project
202
- // onto the profile, which replaces two files that are in the working tree
203
- // right now. The prompt below is the only place to decline.
204
- console.log(
205
- "The project then runs this profile: .claude/settings.json and .mcp.json are",
206
- );
207
- console.log(
208
- "generated from it, and your current copies are kept where the run reports.",
209
- );
327
+ if (result.machinePaths.length > 0) {
328
+ console.log(noteLine("Your teammates won't have it."));
329
+ }
330
+ if (warned) console.log("");
331
+ }
332
+
333
+ /**
334
+ * A state that stops the command, stated the way the TUI states it: the same
335
+ * title and message from `describeNeed`, dim — nothing is wrong, and there is
336
+ * nothing to choose from the CLI.
337
+ */
338
+ function printNeedNote(need: ProfileStartupNeed): void {
339
+ const { title, message } = describeNeed(need);
340
+ console.log("");
341
+ console.log(noteLine(bold(title)));
342
+ for (const line of noteBlock(message, process.stdout.columns)) {
343
+ console.log(line);
344
+ }
345
+ console.log("");
210
346
  }
211
347
 
212
348
  export interface EnsureManifestOptions {
213
349
  /** Read-only mode: never write, just report that adoption is needed. */
214
350
  check?: boolean;
351
+ /**
352
+ * Report a tracked generated file after activating. False for a caller that
353
+ * already reported it this run (`update` checks before it asks), so it is
354
+ * said once.
355
+ */
356
+ reportTracked?: boolean;
215
357
  /**
216
358
  * Adopt without prompting. Set only by `magus profile init`, where
217
359
  * creating the manifest IS the command the user asked for.
@@ -241,14 +383,30 @@ export async function ensureManifest(
241
383
  }
242
384
  if (Object.keys(manifest.profiles).length > 0) return manifest;
243
385
 
386
+ // Inside a project that has profiles: the shared startup state says so, and
387
+ // the command stops — the TUI shows the same state as its startup question.
388
+ // Only `profile init` (`allowAdopt`), run on purpose, gives a subfolder a
389
+ // setup of its own.
390
+ if (!opts.allowAdopt) {
391
+ const need = await detectProfileStartupNeed(projectPath);
392
+ if (need.state === "nested") {
393
+ printNeedNote(need);
394
+ return null;
395
+ }
396
+ }
397
+
244
398
  const result = await adoptCurrentProject(projectPath);
245
399
 
246
400
  if (result.counts.plugins === 0) {
247
401
  console.error(
248
- `No .claude/profiles.json in ${projectPath}, and no enabled plugins to adopt one from.`,
402
+ failLine(
403
+ `No profile in ${path.basename(projectPath)}, and no plugins to save as one`,
404
+ ),
249
405
  );
250
406
  console.error(
251
- "Open magus and select the plugins this project needs, then re-run.",
407
+ noteLine(
408
+ "Open magus, pick the plugins this folder needs, then run this again.",
409
+ ),
252
410
  );
253
411
  return null;
254
412
  }
@@ -257,8 +415,9 @@ export async function ensureManifest(
257
415
 
258
416
  if (opts.check) {
259
417
  console.error(
260
- "\n--check is read-only and will not create the manifest. Run `magus install` once to adopt.",
418
+ failLine("--check is read-only, so it does not create the profile"),
261
419
  );
420
+ console.error(noteLine("Run `magus install` once to create it."));
262
421
  return null;
263
422
  }
264
423
 
@@ -272,18 +431,48 @@ export async function ensureManifest(
272
431
  // fall through to the write
273
432
  } else if (!process.stdin.isTTY) {
274
433
  console.error(
275
- "\nRefusing to create .claude/profiles.json without a human: run `magus profile init` to create it deliberately.",
434
+ failLine("No profile in this folder, and nobody here to ask"),
435
+ );
436
+ console.error(
437
+ noteLine("Run `magus profile init` to create one deliberately."),
276
438
  );
277
439
  return null;
278
- } else if (!(await confirm("\nCreate it and switch to it?"))) {
279
- console.log("Aborted — nothing written.");
440
+ } else if (
441
+ !(await askYesNo(question(`Save as profile "${result.profileId}"?`)))
442
+ ) {
443
+ console.log(infoLine("Nothing written."));
280
444
  return null;
281
445
  }
282
446
 
283
- await writeAndActivate(result, projectPath);
447
+ await writeAndActivate(result, projectPath, opts.allowAdopt === true);
284
448
  return result.manifest;
285
449
  }
286
450
 
451
+ /**
452
+ * The name for a profile saved from what the project has, or null when the
453
+ * user gave none (Ctrl+C, or input ended). Empty accepts `suggestion`. An
454
+ * invalid name is explained and asked again — never swapped for another id.
455
+ */
456
+ async function askProfileName(
457
+ suggestion: string,
458
+ manifest: ProfileManifest,
459
+ ): Promise<{ id: string; name: string } | null> {
460
+ for (;;) {
461
+ let typed: string;
462
+ try {
463
+ typed = await promptValue(
464
+ question("Name for the new profile", `[${suggestion}]`),
465
+ );
466
+ } catch (error) {
467
+ if (isPromptAborted(error)) return null;
468
+ throw error;
469
+ }
470
+ const check = profileIdForName(typed === "" ? suggestion : typed, manifest);
471
+ if (check.ok) return { id: check.id, name: check.name };
472
+ console.log(warnLine(check.reason));
473
+ }
474
+ }
475
+
287
476
  /**
288
477
  * Require an active profile, or stop the command.
289
478
  *
@@ -331,7 +520,7 @@ export async function requireActiveProfile(
331
520
  console.log(
332
521
  `✓ Active profile: ${result.profileId} ${dim("(declared by this project)")}`,
333
522
  );
334
- printActivationNotes(result);
523
+ printActivationNotes(result, { tracked: opts.reportTracked !== false });
335
524
  console.log("");
336
525
  return readManifest(projectPath);
337
526
  }
@@ -347,7 +536,9 @@ export async function requireActiveProfile(
347
536
  }
348
537
 
349
538
  // CI has nobody to ask. Exiting beats hanging on an answer that cannot come.
350
- if (!process.stdin.isTTY) {
539
+ // Both ends: with stdout piped the list cannot draw, and "No profile chosen"
540
+ // would claim an answer nobody was asked for.
541
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
351
542
  console.error(failLine(title));
352
543
  console.error(
353
544
  noteLine(
@@ -362,27 +553,41 @@ export async function requireActiveProfile(
362
553
  return null;
363
554
  }
364
555
 
556
+ // The choices the TUI's startup modal offers, built by the same functions,
557
+ // as an arrow-key list in the report style — the CLI mounts no TUI.
558
+ const options = buildProfileOptions(need);
559
+ const def = defaultOptionIndex(need);
365
560
  console.log("");
366
- const chosen = await selectDialog({
367
- title,
368
- message: describeNeed(need).message,
369
- options: buildProfileOptions(need),
370
- defaultIndex: defaultOptionIndex(need),
371
- });
561
+ console.log(warnLine(bold(title)));
562
+ for (const line of noteBlock(
563
+ describeNeed(need).message,
564
+ process.stdout.columns,
565
+ )) {
566
+ console.log(line);
567
+ }
568
+ console.log("");
569
+ const picked = await selectOption("Choose a profile", options, def);
372
570
 
373
- // Esc, Ctrl+C and the "Quit magus" row all land here, and there is no third
571
+ // "Quit magus", Esc and Ctrl+C all land here, and there is no third
374
572
  // outcome: the command stops rather than proceed without a profile.
375
- const choice = chosen === null ? null : parseProfileChoice(chosen, need);
573
+ const value = picked === null ? undefined : options[picked]?.value;
574
+ let choice = value === undefined ? null : parseProfileChoice(value, need);
575
+ // Saving asks for the name, and an unanswered name is an unanswered
576
+ // question like any other: nothing is created and the command stops.
577
+ if (choice?.kind === "adopt") {
578
+ const named = await askProfileName(choice.id, need.manifest);
579
+ choice = named === null ? null : { kind: "adopt", ...named };
580
+ }
376
581
  if (!choice) {
377
- console.error(failLine("No active profile — stopping."));
582
+ console.error(failLine("No profile chosen — stopping."));
378
583
  return null;
379
584
  }
380
585
 
381
586
  const result = await applyProfileChoice(choice, need.manifest, projectPath, {
382
587
  onDisplace: reportDisplaced(projectPath),
383
588
  });
384
- console.log(`✓ Active profile: ${result.profileId}`);
385
- printActivationNotes(result);
589
+ console.log(okLine(`Using profile ${bold(result.profileId)}`));
590
+ printActivationNotes(result, { tracked: opts.reportTracked !== false });
386
591
  console.log("");
387
592
 
388
593
  // Re-read rather than return `need.manifest`: adopting wrote a new profile
package/src/cli/doctor.ts CHANGED
@@ -46,7 +46,7 @@ import {
46
46
  type RepoRootCache,
47
47
  pickLoadedEntry,
48
48
  } from "../services/plugin-resolution.js";
49
- import { trackedGeneratedWarnings } from "../services/profile-activate.js";
49
+ import { trackedGeneratedNotices } from "../services/profile-activate.js";
50
50
  import {
51
51
  LIVE_FILE_KEYS,
52
52
  liveKind,
@@ -555,8 +555,9 @@ export async function reportProfileState(
555
555
  // A .gitignore entry does not untrack a file committed before it. A note
556
556
  // with the fix, not a failure: magus writes the file all the same, and
557
557
  // never runs git rm itself.
558
- for (const line of await trackedGeneratedWarnings(projectPath)) {
559
- console.log(`• ${line}`);
558
+ for (const n of await trackedGeneratedNotices(projectPath)) {
559
+ console.log(`• ${n.problem}`);
560
+ for (const line of n.fix) console.log(` ${line}`);
560
561
  }
561
562
  return { fixable, unfixable };
562
563
  }