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
@@ -48,9 +48,20 @@ import { bringLoadForward } from "../services/update-engine.js";
48
48
  import type { ResolvedBin, ResolvedClosure } from "../types/index.js";
49
49
  import { formatCommand, resolveExecutable } from "../utils/command-utils.js";
50
50
  import { runCommand, runShellScript } from "../utils/run.js";
51
+ import { bold } from "./ansi.js";
51
52
  import { ensureManifest, printActivationNotes } from "./bootstrap.js";
52
- import { confirm, promptValue } from "./prompt.js";
53
- import { note as noteLine } from "./update-view.js";
53
+ import { askYesNo, promptValue } from "./prompt.js";
54
+ import { selectOption } from "./select.js";
55
+ import {
56
+ commandHeader,
57
+ fail as failLine,
58
+ field,
59
+ info as infoLine,
60
+ note as noteLine,
61
+ ok as okLine,
62
+ question,
63
+ warn as warnLine,
64
+ } from "./update-view.js";
54
65
 
55
66
  interface InstallFlags {
56
67
  check: boolean;
@@ -119,8 +130,10 @@ async function ensureToolchains(
119
130
  }
120
131
  const ok =
121
132
  yes ||
122
- (await confirm(
123
- `${tc.name} is missing (needed by ${tc.requiredBy.join(", ")}). Install it?`,
133
+ (await askYesNo(
134
+ question(
135
+ `${tc.name} is missing — ${tc.requiredBy.join(", ")} need${tc.requiredBy.length === 1 ? "s" : ""} it. Install it?`,
136
+ ),
124
137
  ));
125
138
  if (ok) {
126
139
  console.log(`Installing ${tc.name}…`);
@@ -149,15 +162,18 @@ async function ensureToolchains(
149
162
  * EVERY profile's plugins — so the caller runs this inside
150
163
  * `withCliBookkeeping`, which renders the live files over it afterwards.
151
164
  */
152
- async function installPlugins(closure: ResolvedClosure): Promise<void> {
165
+ async function installPlugins(closure: ResolvedClosure): Promise<string[]> {
166
+ const installed: string[] = [];
153
167
  for (const pluginId of Object.keys(closure.plugins)) {
154
168
  try {
155
169
  console.log(`+ plugin ${pluginId}`);
156
170
  await installPlugin(pluginId, "project");
171
+ installed.push(pluginId);
157
172
  } catch (e) {
158
173
  console.warn(`⚠ plugin ${pluginId}: ${(e as Error).message}`);
159
174
  }
160
175
  }
176
+ return installed;
161
177
  }
162
178
 
163
179
  /**
@@ -176,7 +192,15 @@ async function bringLoadsForward(
176
192
  "project",
177
193
  projectPath,
178
194
  );
179
- if (!here) continue;
195
+ if (!here) {
196
+ // Skipped rather than checked against another scope's record: that
197
+ // would repair toward a version this project does not have. Said, so a
198
+ // plugin that installed but left nothing to check is not silent.
199
+ console.warn(
200
+ `⚠ ${pluginId}: installed, but Claude Code recorded no version for this project, so magus cannot check which version loads here`,
201
+ );
202
+ continue;
203
+ }
180
204
  const forward = await bringLoadForward(pluginId, here, projectPath);
181
205
  if (!forward) continue;
182
206
  if (forward.ok) {
@@ -314,18 +338,32 @@ export async function runInstallCommand(
314
338
  }
315
339
 
316
340
  // Plan
317
- console.log(`\nPlan for ${flags.profile ?? "all profiles"}:`);
318
- for (const line of summarizeClosure(closure)) console.log(` ${line}`);
341
+ // In the report style `update` uses: a header, then the plan as aligned
342
+ // rows. `summarizeClosure` pads each line as "label values"; split at the
343
+ // padding so the label takes the dim label column.
344
+ for (const line of commandHeader(
345
+ "install",
346
+ flags.profile
347
+ ? { label: "profile", value: flags.profile }
348
+ : { label: "profiles", value: "all" },
349
+ )) {
350
+ console.log(line);
351
+ }
352
+ for (const line of summarizeClosure(closure)) {
353
+ const [label, ...rest] = line.split(/ {2,}/);
354
+ console.log(field(label ?? "", rest.join(" ")));
355
+ }
356
+ console.log("");
319
357
  if (closure.conflicts?.length) {
320
358
  console.log(
321
- "\n⚠ Version conflicts across profiles (resolved to the higher pin):",
359
+ warnLine("Profiles pin different versions — the higher pin wins"),
322
360
  );
323
- for (const c of closure.conflicts) console.log(` ${c}`);
361
+ for (const c of closure.conflicts) console.log(noteLine(c));
362
+ console.log("");
324
363
  }
325
- console.log();
326
364
 
327
- if (!flags.yes && !(await confirm("Apply?"))) {
328
- console.log("Aborted.");
365
+ if (!flags.yes && !(await askYesNo(question("Install this plan?")))) {
366
+ console.log(infoLine("Nothing installed."));
329
367
  return 0;
330
368
  }
331
369
 
@@ -363,6 +401,10 @@ export async function runInstallCommand(
363
401
  flags.profile ??
364
402
  current ??
365
403
  (await pickActiveProfile(profileIds, flags.yes));
404
+ if (activeId === null) {
405
+ console.error(failLine("No profile chosen — stopping."));
406
+ return 1;
407
+ }
366
408
  if (activeId) {
367
409
  // The one switch path: what Claude Code wrote into the live config — or
368
410
  // through an older layout's links — goes into the profile it was written
@@ -375,10 +417,10 @@ export async function runInstallCommand(
375
417
  });
376
418
  for (const line of switched.lines) console.log(noteLine(line));
377
419
  if (!switched.activation) {
378
- for (const problem of switched.problems) console.error(problem);
420
+ for (const problem of switched.problems) console.error(failLine(problem));
379
421
  return 1;
380
422
  }
381
- console.log(`\n✓ Active profile: ${activeId}`);
423
+ console.log(okLine(`Active profile ${bold(activeId)}`));
382
424
  printActivationNotes(switched.activation);
383
425
  }
384
426
 
@@ -387,11 +429,16 @@ export async function runInstallCommand(
387
429
  // while they run and reconciles in `finally`: what the user changed in the
388
430
  // meantime is kept, only the install's own records are discarded, and a
389
431
  // Ctrl-C leaves the flag for the next command to do the same.
390
- await withCliBookkeeping(projectPath, () => installPlugins(closure), {
391
- installable: Object.keys(closure.plugins),
392
- report: (line) => console.log(noteLine(line)),
393
- });
394
- await bringLoadsForward(Object.keys(closure.plugins), projectPath);
432
+ const installed = await withCliBookkeeping(
433
+ projectPath,
434
+ () => installPlugins(closure),
435
+ {
436
+ installable: Object.keys(closure.plugins),
437
+ report: (line) => console.log(noteLine(line)),
438
+ },
439
+ );
440
+ // Only what installed: a failed install already printed its own warning.
441
+ await bringLoadsForward(installed, projectPath);
395
442
  await installBins(closure.bins);
396
443
 
397
444
  if (activeId) {
@@ -406,16 +453,25 @@ export async function runInstallCommand(
406
453
  return 0;
407
454
  }
408
455
 
456
+ /**
457
+ * Which profile to make active when none is: the only one, the first under
458
+ * `--yes` or with nobody at the terminal, else the user's pick from an
459
+ * arrow-key list. Null when the user cancels the list — the install stops then,
460
+ * since magus does not run a project without a profile.
461
+ */
409
462
  async function pickActiveProfile(
410
463
  profileIds: string[],
411
464
  yes: boolean,
412
- ): Promise<string | undefined> {
465
+ ): Promise<string | undefined | null> {
413
466
  if (profileIds.length === 0) return undefined;
414
467
  if (profileIds.length === 1) return profileIds[0];
415
- if (yes) return profileIds[0];
416
- console.log("\nWhich profile should be active?");
417
- profileIds.forEach((id, i) => console.log(` ${i + 1}) ${id}`));
418
- const answer = await promptValue("Choose [1]: ");
419
- const idx = answer ? Number.parseInt(answer, 10) - 1 : 0;
420
- return profileIds[idx] ?? profileIds[0];
468
+ if (yes || !process.stdin.isTTY || !process.stdout.isTTY) {
469
+ return profileIds[0];
470
+ }
471
+ console.log("");
472
+ const picked = await selectOption(
473
+ "Which profile should be active?",
474
+ profileIds.map((id) => ({ label: id })),
475
+ );
476
+ return picked === null ? null : profileIds[picked];
421
477
  }
package/src/cli/prompt.ts CHANGED
@@ -52,9 +52,13 @@ async function ask(question: string): Promise<string> {
52
52
  }
53
53
  }
54
54
 
55
- /** Y/n prompt. Empty input means yes. */
56
- export async function confirm(question: string): Promise<boolean> {
57
- const answer = (await ask(`${question} [Y/n] `)).toLowerCase();
55
+ /**
56
+ * A yes/no answer to `prompt`, printed as given — the caller styles it (see
57
+ * `question` in update-view.ts). Empty input means yes. Line-based, so a piped
58
+ * stdin (`yes | magus install`) answers it like a person does.
59
+ */
60
+ export async function askYesNo(prompt: string): Promise<boolean> {
61
+ const answer = (await ask(prompt)).toLowerCase();
58
62
  return answer === "" || answer === "y" || answer === "yes";
59
63
  }
60
64
 
package/src/cli/router.ts CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  import type { ThemeMode } from "@opentui/core";
10
10
  import { SettingsParseError } from "../services/settings-file.js";
11
- import { LiveFileRefusal } from "../services/tracked-live-files.js";
11
+ import { GitCheckError } from "../services/tracked-live-files.js";
12
12
  import { checkForUpdates } from "../services/version-check.js";
13
13
  import { type ThemeEnv, parseThemeWord } from "../ui/theme-resolve.js";
14
14
  import { profileGate, reportUnparseable } from "./bootstrap.js";
@@ -192,6 +192,7 @@ export async function route(
192
192
  // Both of these fix a marker naming a missing profile by activating
193
193
  // one that exists.
194
194
  allowUnknown: command === "install" || command === "profile",
195
+ yes: rest.includes("--yes") || rest.includes("-y"),
195
196
  });
196
197
  if (!ok) return HANDLED(1);
197
198
  }
@@ -224,9 +225,10 @@ export async function route(
224
225
  reportUnparseable(error);
225
226
  return HANDLED(1);
226
227
  }
227
- // A live file git tracks (or git could not say): nothing was written,
228
- // and the message says what to do. A refusal, not a crash.
229
- if (error instanceof LiveFileRefusal) {
228
+ // git could not say what it tracks, and converting an older layout needs
229
+ // to know which skill folders the team committed. git's own message, not
230
+ // a stack trace.
231
+ if (error instanceof GitCheckError) {
230
232
  console.error(error.message);
231
233
  return HANDLED(1);
232
234
  }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * An arrow-key choice in the terminal — interactive, but not a TUI.
3
+ *
4
+ * No renderer and no alternate screen: the rows of `selectFrame` are written
5
+ * under the report, redrawn in place on each keypress, and collapse to one
6
+ * `selectDone` line once answered. The owner asked for this in place of typing
7
+ * a number ("not this ugly thing … not tui, interactive cli").
8
+ *
9
+ * The key handling is `selectKey`, pure, so it is testable without a terminal;
10
+ * `selectOption` is only the terminal around it.
11
+ */
12
+
13
+ import { emitKeypressEvents } from "node:readline";
14
+ import { selectDone, selectFrame } from "./update-view.js";
15
+
16
+ export interface SelectItem {
17
+ label: string;
18
+ meta?: string;
19
+ }
20
+
21
+ /** The fields of a readline keypress that the choice reads. */
22
+ export interface SelectKeypress {
23
+ name?: string;
24
+ ctrl?: boolean;
25
+ sequence?: string;
26
+ }
27
+
28
+ export type SelectStep =
29
+ | { index: number }
30
+ | { chosen: number }
31
+ | { cancelled: true };
32
+
33
+ /**
34
+ * What one keypress does: ↑/↓ (and k/j) move with wrap-around, Enter picks the
35
+ * current row, a digit picks that row at once, Esc and Ctrl+C cancel. Any other
36
+ * key leaves the cursor where it is.
37
+ */
38
+ export function selectKey(
39
+ index: number,
40
+ key: SelectKeypress,
41
+ count: number,
42
+ ): SelectStep {
43
+ if ((key.ctrl && key.name === "c") || key.name === "escape") {
44
+ return { cancelled: true };
45
+ }
46
+ if (key.name === "return" || key.name === "enter") return { chosen: index };
47
+ if (key.name === "up" || key.name === "k") {
48
+ return { index: (index - 1 + count) % count };
49
+ }
50
+ if (key.name === "down" || key.name === "j") {
51
+ return { index: (index + 1) % count };
52
+ }
53
+ const digit = Number(key.sequence);
54
+ if (Number.isInteger(digit) && digit >= 1 && digit <= count) {
55
+ return { chosen: digit - 1 };
56
+ }
57
+ return { index };
58
+ }
59
+
60
+ /** 128 + signum, the shell convention `cli/live.ts` exits with too. */
61
+ const SIGNAL_EXIT = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 } as const;
62
+
63
+ /**
64
+ * Ask. Resolves to the chosen index, or null when the user cancels — and at
65
+ * once when there is no terminal on either end, since nobody can answer and a
66
+ * redraw would write cursor codes into a pipe. Callers decide what null means.
67
+ *
68
+ * Whatever ends it — an answer, a throw, a signal, or the process exiting —
69
+ * puts the terminal back: raw mode off, the keypress listener gone, the cursor
70
+ * shown. Only Enter, Esc and Ctrl+C used to, so anything else left the shell
71
+ * typing blind until `reset`. Ctrl+C typed at the list arrives as a keypress
72
+ * in raw mode; the SIGINT handler is for one sent from outside (`kill -INT`).
73
+ */
74
+ export async function selectOption(
75
+ title: string,
76
+ items: SelectItem[],
77
+ defaultIndex = 0,
78
+ ): Promise<number | null> {
79
+ const { stdin, stdout } = process;
80
+ if (!stdin.isTTY || !stdout.isTTY || items.length === 0) return null;
81
+
82
+ let index = Math.min(Math.max(0, defaultIndex), items.length - 1);
83
+ let height = 0;
84
+ const draw = (): void => {
85
+ const lines = selectFrame(title, items, index);
86
+ // Back to the block's first row, then rewrite every row in full.
87
+ if (height > 0) stdout.write(`\x1b[${height}F`);
88
+ stdout.write(`${lines.map((l) => `\x1b[2K${l}`).join("\n")}\n`);
89
+ height = lines.length;
90
+ };
91
+
92
+ emitKeypressEvents(stdin);
93
+ const wasRaw = stdin.isRaw;
94
+ let onKey: ((_: string, key: SelectKeypress | undefined) => void) | null =
95
+ null;
96
+
97
+ // Once only: `finish`, a throw's `finally` and a signal can all reach it.
98
+ let released = false;
99
+ const release = (): void => {
100
+ if (released) return;
101
+ released = true;
102
+ process.off("exit", release);
103
+ process.off("SIGINT", onSigint);
104
+ process.off("SIGTERM", onSigterm);
105
+ process.off("SIGHUP", onSighup);
106
+ if (onKey) stdin.off("keypress", onKey);
107
+ stdin.setRawMode(wasRaw);
108
+ stdin.pause();
109
+ stdout.write("\x1b[?25h");
110
+ };
111
+ // A listener for a signal replaces its default action, so each one exits
112
+ // itself once the terminal is back.
113
+ const onSigint = (): void => {
114
+ release();
115
+ process.exit(SIGNAL_EXIT.SIGINT);
116
+ };
117
+ const onSigterm = (): void => {
118
+ release();
119
+ process.exit(SIGNAL_EXIT.SIGTERM);
120
+ };
121
+ const onSighup = (): void => {
122
+ release();
123
+ process.exit(SIGNAL_EXIT.SIGHUP);
124
+ };
125
+ process.once("exit", release);
126
+ process.once("SIGINT", onSigint);
127
+ process.once("SIGTERM", onSigterm);
128
+ process.once("SIGHUP", onSighup);
129
+
130
+ try {
131
+ stdin.setRawMode(true);
132
+ stdin.resume();
133
+ stdout.write("\x1b[?25l"); // hide the cursor while the list is open
134
+
135
+ return await new Promise<number | null>((resolve, reject) => {
136
+ const finish = (chosen: number | null): void => {
137
+ // Clear the open block; an answer leaves one line behind it.
138
+ stdout.write(`\x1b[${height}F\x1b[0J`);
139
+ if (chosen !== null) {
140
+ stdout.write(`${selectDone(title, items[chosen]?.label ?? "")}\n`);
141
+ }
142
+ // Before resolving, not in `finally`: one read can carry several
143
+ // keys, and the listener must be gone before the next is emitted.
144
+ release();
145
+ resolve(chosen);
146
+ };
147
+ onKey = (_, key) => {
148
+ try {
149
+ const step = selectKey(index, key ?? {}, items.length);
150
+ if ("cancelled" in step) finish(null);
151
+ else if ("chosen" in step) finish(step.chosen);
152
+ else {
153
+ index = step.index;
154
+ draw();
155
+ }
156
+ } catch (error) {
157
+ // Thrown inside an event listener it would reach no caller.
158
+ release();
159
+ reject(error);
160
+ }
161
+ };
162
+ stdin.on("keypress", onKey);
163
+ draw();
164
+ });
165
+ } finally {
166
+ release();
167
+ }
168
+ }
@@ -1,5 +1,7 @@
1
1
  /**
2
- * update-view.ts — every string `magus update` prints, and nothing else.
2
+ * update-view.ts — every string `magus update` prints, plus the pieces the
3
+ * other CLI commands reuse so they look the same (`commandHeader`, `field`,
4
+ * the `ok`/`warn`/`fail`/`info`/`note` rows, and `question`).
3
5
  *
4
6
  * Pure functions: plan in, lines out. No IO, no timers, no terminal. That is
5
7
  * what makes the look testable — the old report was `console.log` calls braided
@@ -128,16 +130,126 @@ function rowText(key: ActionKey, text: string): string {
128
130
 
129
131
  // -- header ------------------------------------------------------------------
130
132
 
131
- export function header(profileId: string): string[] {
133
+ /**
134
+ * `◆ magus <command> <label> <value>` — the line a CLI report opens with.
135
+ *
136
+ * Shared so `install` and the adoption question open the way `update` does,
137
+ * rather than each command printing its own plain "Plan for …:" heading.
138
+ */
139
+ export function commandHeader(
140
+ command: string,
141
+ context?: { label: string; value: string },
142
+ ): string[] {
143
+ const tail = context
144
+ ? ` ${dim(context.label)} ${fg(brand.accent, context.value)}`
145
+ : "";
132
146
  return [
133
147
  "",
134
- `${PAD}${fg(brand.accent, "◆")} ${bold("magus update")} ${dim(
135
- "profile",
136
- )} ${fg(brand.accent, profileId)}`,
148
+ `${PAD}${fg(brand.accent, "◆")} ${bold(`magus ${command}`)}${tail}`,
137
149
  "",
138
150
  ];
139
151
  }
140
152
 
153
+ export function header(profileId: string): string[] {
154
+ return commandHeader("update", { label: "profile", value: profileId });
155
+ }
156
+
157
+ /** Width of the label column in `field` rows. */
158
+ const FIELD_WIDTH = 14;
159
+
160
+ /**
161
+ * `label value`, indented to the text column of the glyph rows (`ok`, `warn`,
162
+ * …) so a block of facts lines up under the notice it belongs to.
163
+ */
164
+ export function field(label: string, value: string): string {
165
+ return `${PAD} ${dim(padEnd(label, FIELD_WIDTH))}${value}`;
166
+ }
167
+
168
+ /**
169
+ * A multi-paragraph note, word-wrapped to the terminal in the `note` column.
170
+ * Paragraphs are separated by "\n"; an empty paragraph stays an empty line. A
171
+ * note printed as one line per paragraph wrapped mid-word at the terminal edge.
172
+ */
173
+ export function noteBlock(text: string, columns = 80): string[] {
174
+ const room = Math.max(20, Math.min(columns, 100) - PAD.length - 4);
175
+ const out: string[] = [];
176
+ for (const paragraph of text.split("\n")) {
177
+ if (paragraph === "") {
178
+ out.push("");
179
+ continue;
180
+ }
181
+ const indent = /^ */.exec(paragraph)?.[0] ?? "";
182
+ let line = indent;
183
+ for (const word of paragraph.trimStart().split(" ")) {
184
+ if (line.trim() !== "" && width(line) + 1 + width(word) > room) {
185
+ out.push(note(line));
186
+ line = word;
187
+ } else {
188
+ line = line.trim() === "" ? `${line}${word}` : `${line} ${word}`;
189
+ }
190
+ }
191
+ if (line.trim() !== "") out.push(note(line));
192
+ }
193
+ return out;
194
+ }
195
+
196
+ /** One entry of a list under a `field`, indented to the field's value column. */
197
+ export function listItem(text: string): string {
198
+ return `${PAD} ${" ".repeat(FIELD_WIDTH)}${text}`;
199
+ }
200
+
201
+ /** The scopes a user deals in. Where each is stored is magus's business. */
202
+ export type ScopeName = "user" | "project" | "local";
203
+
204
+ const SCOPE_BADGE: Record<ScopeName, [string, string]> = {
205
+ user: ["USER", brand.accent],
206
+ project: ["PROJECT", brand.info],
207
+ local: ["LOCAL", brand.warning],
208
+ };
209
+
210
+ /** A scope as a badge — the word a user knows it by, never a file name. */
211
+ export function scopeBadge(scope: ScopeName): string {
212
+ const [label, color] = SCOPE_BADGE[scope];
213
+ return badge(label, color);
214
+ }
215
+
216
+ /**
217
+ * The prompt for a yes/no question, in the glyph column: `? <question> [Y/n] `.
218
+ * Printed by readline, so the answer is typed on the same line.
219
+ */
220
+ export function question(text: string): string {
221
+ return `${PAD}${fg(brand.accent, "?")} ${bold(text)} ${dim("[Y/n]")} `;
222
+ }
223
+
224
+ /**
225
+ * An arrow-key choice while it is open: the question, one row per option with
226
+ * `❯` on the current one, then the keys. `select.ts` redraws the whole block in
227
+ * place on every keypress, so its height never changes while it is open.
228
+ */
229
+ export function selectFrame(
230
+ title: string,
231
+ options: { label: string; meta?: string }[],
232
+ index: number,
233
+ ): string[] {
234
+ const labelWidth = Math.max(0, ...options.map((o) => width(o.label)));
235
+ return [
236
+ `${PAD}${fg(brand.accent, "?")} ${bold(title)}`,
237
+ ...options.map((o, i) => {
238
+ const label = padEnd(o.label, labelWidth);
239
+ const meta = o.meta ? ` ${dim(o.meta)}` : "";
240
+ return i === index
241
+ ? `${PAD} ${fg(brand.accent, "❯")} ${bold(fg(brand.accent, label))}${meta}`
242
+ : `${PAD} ${label}${meta}`;
243
+ }),
244
+ `${PAD} ${dim("↑↓ move · enter select · esc cancel")}`,
245
+ ];
246
+ }
247
+
248
+ /** The one line an answered choice collapses to: `✓ <title> <answer>`. */
249
+ export function selectDone(title: string, answer: string): string {
250
+ return `${PAD}${fg(brand.success, "✓")} ${title} ${fg(brand.accent, answer)}`;
251
+ }
252
+
141
253
  // -- phase checklist ---------------------------------------------------------
142
254
 
143
255
  export type StepState = "pending" | "running" | "done" | "warn" | "fail";
@@ -346,7 +458,7 @@ function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
346
458
  *
347
459
  * Update something an apply would move forward
348
460
  * Deprecated no configured marketplace offers it
349
- * Fix required declared or enabled, with nothing on disk
461
+ * Fix required declared or installed, with its files missing
350
462
  * Unverified the check itself did not complete
351
463
  * Current settled — the version was compared, or there was none to compare
352
464
  *
package/src/cli/update.ts CHANGED
@@ -51,6 +51,7 @@ import {
51
51
  import {
52
52
  recordRefetchedSkills,
53
53
  trackFetchedSkills,
54
+ trackedGeneratedNotices,
54
55
  updatableSkills,
55
56
  } from "../services/profile-activate.js";
56
57
  import { activeProfile } from "../services/profile-live.js";
@@ -83,7 +84,6 @@ import { brand } from "../ui/theme.js";
83
84
  import { bold, dim, fg, width } from "./ansi.js";
84
85
  import { ensureManifest, requireActiveProfile } from "./bootstrap.js";
85
86
  import { LiveRegion, withPaused } from "./live.js";
86
- import { selectDialog } from "./tui-prompt.js";
87
87
  import {
88
88
  type ApplyState,
89
89
  type Step,
@@ -202,6 +202,13 @@ export async function runUpdateCommand(
202
202
  return 1;
203
203
  }
204
204
 
205
+ // A generated file git tracks is written like any other; say so once, with
206
+ // the fix. magus never runs git itself.
207
+ for (const n of await trackedGeneratedNotices(projectPath)) {
208
+ console.log(warnLine(n.problem));
209
+ for (const line of n.fix) console.log(noteLine(line));
210
+ }
211
+
205
212
  // No active profile, no update: that is where plugin state is read from and
206
213
  // where changes are written. Asked before the profile is resolved, so what
207
214
  // the user activates here is what this run uses.
@@ -231,6 +238,8 @@ export async function runUpdateCommand(
231
238
  ? manifest
232
239
  : await requireActiveProfile(projectPath, manifest, {
233
240
  check: flags.check || flags.dryRun,
241
+ // Said once already, by the check above.
242
+ reportTracked: false,
234
243
  });
235
244
  // A refused dry run exits 0: it wrote nothing and previewed nothing, which is
236
245
  // what it promised. A refused `--check` exits 1, because that is a gate.
@@ -10,7 +10,6 @@
10
10
  */
11
11
 
12
12
  import { execFile } from "node:child_process";
13
- import path from "node:path";
14
13
  import { promisify } from "node:util";
15
14
  import { defaultMarketplaces } from "../data/marketplaces.js";
16
15
  import { which } from "../utils/command-utils.js";
@@ -20,7 +19,6 @@ import {
20
19
  removeLocalInstalledPluginVersion,
21
20
  } from "./claude-settings.js";
22
21
  import { removeInstalledPluginVersion } from "./plugin-manager.js";
23
- import { refuseTrackedLiveFiles } from "./tracked-live-files.js";
24
22
 
25
23
  const execFileAsync = promisify(execFile);
26
24
 
@@ -140,23 +138,6 @@ async function execPluginCommand(
140
138
  }
141
139
  }
142
140
 
143
- /**
144
- * Every `claude plugin … --scope project` command writes the project's
145
- * `.claude/settings.json` (its enabledPlugins entry). In a project with
146
- * profiles that file is generated; where git tracks it, refuse — before the
147
- * CLI runs — rather than let the CLI write it (see `tracked-live-files.ts`).
148
- * The CLI works on the current directory.
149
- */
150
- async function refuseProjectSettingsWrite(
151
- scope: PluginScope,
152
- projectPath: string = process.cwd(),
153
- ): Promise<void> {
154
- if (scope !== "project") return;
155
- await refuseTrackedLiveFiles(projectPath, [
156
- path.join(".claude", "settings.json"),
157
- ]);
158
- }
159
-
160
141
  /**
161
142
  * Install a plugin using claude CLI
162
143
  * Handles enabling + version tracking + cache copy in one shot.
@@ -173,7 +154,6 @@ export async function installPlugin(
173
154
  pluginId: string,
174
155
  scope: PluginScope = "user",
175
156
  ): Promise<void> {
176
- await refuseProjectSettingsWrite(scope);
177
157
  // 60s, matching update and repair. Before the install/update split this path
178
158
  // ran through `updatePlugin`'s 60s budget; leaving it on the 30s default
179
159
  // would have quietly halved the time a fresh install — the SLOWEST of the
@@ -192,7 +172,6 @@ export async function uninstallPlugin(
192
172
  scope: PluginScope = "user",
193
173
  projectPath?: string,
194
174
  ): Promise<void> {
195
- await refuseProjectSettingsWrite(scope, projectPath);
196
175
  try {
197
176
  await execClaude(["plugin", "uninstall", pluginId, "--scope", scope]);
198
177
  } catch {
@@ -245,7 +224,6 @@ export async function enablePlugin(
245
224
  pluginId: string,
246
225
  scope: PluginScope = "user",
247
226
  ): Promise<void> {
248
- await refuseProjectSettingsWrite(scope);
249
227
  await execClaude(["plugin", "enable", pluginId, "--scope", scope]);
250
228
  }
251
229
 
@@ -256,7 +234,6 @@ export async function disablePlugin(
256
234
  pluginId: string,
257
235
  scope: PluginScope = "user",
258
236
  ): Promise<void> {
259
- await refuseProjectSettingsWrite(scope);
260
237
  await execClaude(["plugin", "disable", pluginId, "--scope", scope]);
261
238
  }
262
239
 
@@ -299,7 +276,6 @@ export async function updatePlugin(
299
276
  pluginId: string,
300
277
  scope: PluginScope = "user",
301
278
  ): Promise<void> {
302
- await refuseProjectSettingsWrite(scope);
303
279
  await execPluginCommand("update", pluginId, scope, 60000);
304
280
  }
305
281