magus-cli 7.5.2 → 7.5.4

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