magus-cli 7.1.0 → 7.3.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 (48) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-plugin-state.test.ts +3 -0
  3. package/src/__tests__/doctor.test.ts +31 -0
  4. package/src/__tests__/plugin-resolution.test.ts +431 -0
  5. package/src/__tests__/plugin-row-text.test.ts +66 -0
  6. package/src/__tests__/profile-migration-offer.test.ts +211 -0
  7. package/src/__tests__/profile-migration.test.ts +242 -0
  8. package/src/__tests__/profile-startup.test.ts +458 -0
  9. package/src/__tests__/stale-load-repair.test.ts +1076 -0
  10. package/src/__tests__/update-apply.test.ts +146 -1
  11. package/src/__tests__/update-plan.test.ts +338 -0
  12. package/src/__tests__/useStaleLoadModal.test.ts +177 -0
  13. package/src/cli/ansi.ts +39 -0
  14. package/src/cli/bootstrap.ts +169 -3
  15. package/src/cli/doctor.ts +128 -2
  16. package/src/cli/install.ts +5 -1
  17. package/src/cli/profile.ts +5 -1
  18. package/src/cli/prompt.ts +44 -12
  19. package/src/cli/tui-prompt.tsx +143 -0
  20. package/src/cli/update-view.ts +32 -1
  21. package/src/cli/update.ts +50 -7
  22. package/src/main.tsx +10 -1
  23. package/src/services/active-closure.ts +23 -0
  24. package/src/services/claude-settings.ts +645 -34
  25. package/src/services/manifest.ts +21 -0
  26. package/src/services/plugin-manager.ts +61 -0
  27. package/src/services/plugin-resolution.ts +280 -0
  28. package/src/services/profile-activate.ts +147 -0
  29. package/src/services/profile-adopt.ts +50 -4
  30. package/src/services/profile-startup.ts +311 -0
  31. package/src/services/profiles.ts +49 -11
  32. package/src/services/symlink-manager.ts +184 -30
  33. package/src/services/update-engine.ts +104 -2
  34. package/src/services/update-plan.ts +188 -1
  35. package/src/types/index.ts +44 -23
  36. package/src/ui/App.tsx +123 -0
  37. package/src/ui/components/layout/FooterHints.tsx +21 -9
  38. package/src/ui/components/modals/ModalContainer.tsx +1 -0
  39. package/src/ui/components/modals/SelectModal.tsx +142 -21
  40. package/src/ui/hooks/useProfileStartupModal.ts +260 -0
  41. package/src/ui/hooks/useStaleLoadModal.ts +157 -0
  42. package/src/ui/renderers/pluginRenderers.tsx +29 -0
  43. package/src/ui/renderers/pluginStatusText.ts +14 -0
  44. package/src/ui/renderers/profileRenderers.tsx +27 -4
  45. package/src/ui/screens/PluginsScreen.tsx +46 -13
  46. package/src/ui/screens/ProfilesScreen.tsx +1 -1
  47. package/src/ui/state/types.ts +18 -0
  48. package/src/ui/theme-mode.ts +54 -0
package/src/cli/prompt.ts CHANGED
@@ -8,25 +8,57 @@
8
8
 
9
9
  import { createInterface } from "node:readline/promises";
10
10
 
11
- /** Y/n prompt. Empty input means yes. */
12
- export async function confirm(question: string): Promise<boolean> {
13
- const rl = createInterface({ input: process.stdin, output: process.stdout });
14
- try {
15
- const answer = (await rl.question(`${question} [Y/n] `))
16
- .trim()
17
- .toLowerCase();
18
- return answer === "" || answer === "y" || answer === "yes";
19
- } finally {
20
- rl.close();
11
+ /**
12
+ * Ctrl+C at a prompt.
13
+ *
14
+ * `readline/promises` rejects `question()` with an `AbortError` carrying
15
+ * `code: "ABORT_ERR"`. That is a Node internal, and letting it escape printed
16
+ * nine frames of `internal:repl/node-errors` under "Error starting magus:" —
17
+ * a stack trace for the one input every terminal user knows how to give.
18
+ *
19
+ * It is converted here rather than at each call site so no future prompt can
20
+ * forget, and it stays a throw rather than a sentinel return so a caller that
21
+ * ignores it aborts instead of proceeding with a missing answer.
22
+ */
23
+ export class PromptAborted extends Error {
24
+ constructor() {
25
+ super("Aborted with Ctrl+C");
26
+ this.name = "PromptAborted";
21
27
  }
22
28
  }
23
29
 
24
- /** Free-text prompt, trimmed. */
25
- export async function promptValue(question: string): Promise<string> {
30
+ /** True for a Ctrl+C at a prompt, ours or readline's own. */
31
+ export function isPromptAborted(error: unknown): boolean {
32
+ if (error instanceof PromptAborted) return true;
33
+ // Belt and braces: an abort raised somewhere that does not route through
34
+ // `ask` still reads as a cancellation rather than a crash.
35
+ return (
36
+ typeof error === "object" &&
37
+ error !== null &&
38
+ (error as { code?: unknown }).code === "ABORT_ERR"
39
+ );
40
+ }
41
+
42
+ /** One readline question, with Ctrl+C normalised. */
43
+ async function ask(question: string): Promise<string> {
26
44
  const rl = createInterface({ input: process.stdin, output: process.stdout });
27
45
  try {
28
46
  return (await rl.question(question)).trim();
47
+ } catch (error) {
48
+ if (isPromptAborted(error)) throw new PromptAborted();
49
+ throw error;
29
50
  } finally {
30
51
  rl.close();
31
52
  }
32
53
  }
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();
58
+ return answer === "" || answer === "y" || answer === "yes";
59
+ }
60
+
61
+ /** Free-text prompt, trimmed. */
62
+ export async function promptValue(question: string): Promise<string> {
63
+ return ask(question);
64
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * One OpenTUI dialog, for a command that is otherwise not a TUI.
3
+ *
4
+ * `magus update` prints a report and exits; it is not the interactive app. But
5
+ * a question it must ask deserves the same dialog the TUI asks with, so this
6
+ * mounts the REAL `SelectModal` — the component tab 5 and tab 0 use — for the
7
+ * length of one question, then tears the renderer down and lets the command
8
+ * carry on printing. Nothing here re-draws a list, a border or a highlight: the
9
+ * component and the theme own all of that, and a second implementation is how
10
+ * the two surfaces drift apart.
11
+ *
12
+ * Every import of `@opentui/*` and `ui/` is DYNAMIC. `main.tsx` splits the TUI
13
+ * out precisely so `magus hook …`, which Claude Code runs on every matching
14
+ * tool call, never pays to load the renderer — a static import here would undo
15
+ * that for every command that imports this module's file, whether or not it
16
+ * ever asks a question.
17
+ *
18
+ * `screenMode: "main-screen"` keeps the dialog in the scrollback beside the
19
+ * report. The alternate screen would take the terminal over and then hand back
20
+ * a screen with no trace the question was ever asked.
21
+ */
22
+
23
+ import type { SelectOption } from "../ui/state/types.js";
24
+
25
+ export interface SelectDialogInput {
26
+ title: string;
27
+ message: string;
28
+ options: SelectOption[];
29
+ /** Row to start on. Defaults to the first. */
30
+ defaultIndex?: number;
31
+ }
32
+
33
+ /**
34
+ * Ask the user to pick one option. Resolves to the chosen value, or null when
35
+ * they cancel with Esc or Ctrl+C — the caller decides whether that is fatal.
36
+ *
37
+ * Falls back to `null` when stdin or stdout is not a TTY. A renderer needs both:
38
+ * `magus update > file` has a TTY stdin and a pipe for stdout, and mounting
39
+ * there writes escape sequences into the file.
40
+ */
41
+ export async function selectDialog(
42
+ input: SelectDialogInput,
43
+ ): Promise<string | null> {
44
+ if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
45
+ return null;
46
+ }
47
+
48
+ const [{ createCliRenderer }, { createRoot }, React, modals, dimensions] =
49
+ await Promise.all([
50
+ import("@opentui/core"),
51
+ import("@opentui/react"),
52
+ import("react"),
53
+ import("../ui/components/modals/index.js"),
54
+ import("../ui/state/DimensionsContext.js"),
55
+ ]);
56
+
57
+ const renderer = await createCliRenderer({
58
+ screenMode: "main-screen",
59
+ // BOTH, so the renderer registers no handler of its own and teardown
60
+ // below is the only path. With either left at its default, a Ctrl+C
61
+ // destroys the renderer underneath this promise and it never settles.
62
+ exitSignals: [],
63
+ exitOnCtrlC: false,
64
+ });
65
+
66
+ const root = createRoot(renderer);
67
+
68
+ return await new Promise<string | null>((resolve) => {
69
+ // Clamped, not trusted: a caller computing an index from a list it built
70
+ // separately could hand over one that is out of range, and a cursor
71
+ // pointing past the end makes Enter resolve to null.
72
+ let index = Math.min(
73
+ Math.max(0, input.defaultIndex ?? 0),
74
+ Math.max(0, input.options.length - 1),
75
+ );
76
+ let done = false;
77
+
78
+ const finish = (value: string | null): void => {
79
+ if (done) return;
80
+ done = true;
81
+ renderer.keyInput.off("keypress", onKey);
82
+ root.unmount();
83
+ renderer.destroy();
84
+ resolve(value);
85
+ };
86
+
87
+ function onKey(key: {
88
+ name?: string;
89
+ ctrl?: boolean;
90
+ sequence?: string;
91
+ }): void {
92
+ if ((key.ctrl && key.name === "c") || key.name === "escape") {
93
+ finish(null);
94
+ return;
95
+ }
96
+ if (key.name === "return" || key.name === "enter") {
97
+ finish(input.options[index]?.value ?? null);
98
+ return;
99
+ }
100
+ if (key.name === "up" || key.name === "k") {
101
+ index = (index - 1 + input.options.length) % input.options.length;
102
+ draw();
103
+ return;
104
+ }
105
+ if (key.name === "down" || key.name === "j") {
106
+ index = (index + 1) % input.options.length;
107
+ draw();
108
+ return;
109
+ }
110
+ // The numbers stay live: the list is short, and the plain-text prompt
111
+ // this replaced numbered it, so the habit already exists.
112
+ const digit = Number(key.sequence);
113
+ if (
114
+ Number.isInteger(digit) &&
115
+ digit >= 1 &&
116
+ digit <= input.options.length
117
+ ) {
118
+ index = digit - 1;
119
+ finish(input.options[index]?.value ?? null);
120
+ }
121
+ }
122
+
123
+ const draw = (): void => {
124
+ root.render(
125
+ React.createElement(
126
+ dimensions.DimensionsProvider,
127
+ null,
128
+ React.createElement(modals.SelectModal, {
129
+ title: input.title,
130
+ message: input.message,
131
+ options: input.options,
132
+ defaultIndex: index,
133
+ onSelect: finish,
134
+ onCancel: () => finish(null),
135
+ }),
136
+ ),
137
+ );
138
+ };
139
+
140
+ renderer.keyInput.on("keypress", onKey);
141
+ draw();
142
+ });
143
+ }
@@ -37,6 +37,7 @@ import { formatCommand } from "../utils/command-utils.js";
37
37
  import {
38
38
  badge,
39
39
  bold,
40
+ dialog,
40
41
  dim,
41
42
  elapsed,
42
43
  fg,
@@ -80,6 +81,7 @@ type ActionKey =
80
81
  | "upgrade"
81
82
  | "repair"
82
83
  | "refresh"
84
+ | "stale-load"
83
85
  | "unknown";
84
86
 
85
87
  const ACTIONS: Record<ActionKey, ActionStyle> = {
@@ -92,6 +94,11 @@ const ACTIONS: Record<ActionKey, ActionStyle> = {
92
94
  upgrade: { glyph: "↑", label: "UPGRADE", color: brand.success },
93
95
  repair: { glyph: "⟳", label: "REPAIR", color: brand.info },
94
96
  refresh: { glyph: "⟳", label: "REFRESH", color: brand.info },
97
+ // The installed version is right and an older one loads, so this session runs
98
+ // a version nobody installed. Danger, and the word is BROKEN: "STALE" read as
99
+ // a mild version of `repair`, and this is not that. Six characters, one under
100
+ // UNKNOWN, so CHIP_WIDTH is unchanged and no other section pays for it.
101
+ "stale-load": { glyph: "✗", label: "BROKEN", color: brand.danger },
95
102
  // Warning, not danger: "cannot tell" is not a failure, and red UNKNOWN read
96
103
  // as "broken". The summary line and the TUI already use warning for it.
97
104
  unknown: { glyph: "?", label: "UNKNOWN", color: brand.warning },
@@ -248,8 +255,12 @@ function pluginDetail(item: PluginUpdateItem): string {
248
255
  // The note stays on the item. The TUI has no heading to lean on and renders
249
256
  // it as that row's only explanation — this is a rendering decision about a
250
257
  // table that carries the fact twice, not a change to what is known.
258
+ // `older-version-loads` is excluded for the same reason: its note says
259
+ // "loads v0.3.0, installed v0.5.0" and the two columns beside it already do.
251
260
  const note =
252
- item.note && item.reason !== "not-offered"
261
+ item.note &&
262
+ item.reason !== "not-offered" &&
263
+ item.reason !== "older-version-loads"
253
264
  ? ` ${dim(`(${item.note})`)}`
254
265
  : "";
255
266
  switch (item.action) {
@@ -267,6 +278,16 @@ function pluginDetail(item: PluginUpdateItem): string {
267
278
  case "repair":
268
279
  case "unknown":
269
280
  return `${item.installed ?? "—"}${note}`;
281
+ case "stale-load":
282
+ // Both versions, in the order the reader needs them: what runs, then
283
+ // what is installed. This is a report, not a one-line picker row — the
284
+ // TUI's row deliberately shows neither, because there the verdict and
285
+ // the key are all that fit. The note says the same thing in words; the
286
+ // columns are what makes a table of these scannable.
287
+ return `${fg(
288
+ groupStyle("Broken").text,
289
+ item.loaded ?? "?",
290
+ )} ${dim("≠")} ${item.installed ?? "—"}${note}`;
270
291
  case "current":
271
292
  return `${item.installed ?? "—"}${note}`;
272
293
  }
@@ -346,6 +367,10 @@ function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
346
367
  function pluginGroup(item: PluginUpdateItem): string {
347
368
  if (item.action === "update" || item.action === "repair") return "Update";
348
369
  if (item.action === "install") return "Fix required";
370
+ // Never "Current". The installed version matching is exactly what makes this
371
+ // state deceptive, and a row that reads as up to date under a CURRENT
372
+ // heading is the defect this state was added to end.
373
+ if (item.action === "stale-load") return "Broken";
349
374
  if (item.action === "unknown") {
350
375
  return item.reason === "not-offered" ? "Deprecated" : "Unverified";
351
376
  }
@@ -353,6 +378,7 @@ function pluginGroup(item: PluginUpdateItem): string {
353
378
  }
354
379
 
355
380
  const GROUP_ORDER = [
381
+ "Broken",
356
382
  "Update",
357
383
  "Deprecated",
358
384
  "Fix required",
@@ -372,6 +398,11 @@ const GROUP_KIND: Record<string, ReportBadgeKind> = {
372
398
  Update: "update",
373
399
  Deprecated: "deprecated",
374
400
  "Fix required": "fix",
401
+ // Red earth, borrowed from `deprecated`. The kinds are measured contrast
402
+ // pairs, not meanings — "Hue, not meaning; the badge says the word", as
403
+ // theme-mode.ts puts it — so an unmeasured fourth palette would be the only
404
+ // thing here nobody had checked against both pages.
405
+ Broken: "deprecated",
375
406
  };
376
407
 
377
408
  interface GroupStyle {
package/src/cli/update.ts CHANGED
@@ -70,8 +70,9 @@ import {
70
70
  } from "../services/update-plan.js";
71
71
  import { brand } from "../ui/theme.js";
72
72
  import { bold, dim, fg, width } from "./ansi.js";
73
- import { ensureManifest } from "./bootstrap.js";
73
+ import { ensureManifest, requireActiveProfile } from "./bootstrap.js";
74
74
  import { LiveRegion, withPaused } from "./live.js";
75
+ import { selectDialog } from "./tui-prompt.js";
75
76
  import {
76
77
  type ApplyState,
77
78
  type Step,
@@ -190,8 +191,42 @@ export async function runUpdateCommand(
190
191
  return 1;
191
192
  }
192
193
 
193
- const profileIds = Object.keys(manifest.profiles);
194
- if (flags.profile && !manifest.profiles[flags.profile]) {
194
+ // No active profile, no update: that is where plugin state is read from and
195
+ // where changes are written. Asked before the profile is resolved, so what
196
+ // the user activates here is what this run uses.
197
+ //
198
+ // BOTH `--check` and `--dry-run` are read-only here. An earlier version let
199
+ // `--dry-run` activate, reasoning that a preview needs a real profile to
200
+ // report against — but activating writes symlinks and edits a TRACKED
201
+ // `.gitignore`, and `--dry-run` promises to write nothing and exit 0. A
202
+ // preview that mutates the repo is not a preview.
203
+ //
204
+ // So a read-only run refuses instead, and the two flags differ only in what
205
+ // that refusal MEANS: `--check` is a gate, so it fails; `--dry-run` is a
206
+ // preview, and "nothing to preview until a profile is active" is a legitimate
207
+ // preview result rather than an error.
208
+ //
209
+ // NAMING A PROFILE SKIPS THE GATE. `magus update lean` says which profile to
210
+ // update, and updating it needs the manifest, not the symlink — installs go
211
+ // to project scope either way. Gating it would refuse a question the user
212
+ // already answered on the command line, and would break `magus update lean`
213
+ // and `--check lean` in CI, both of which worked before this gate existed.
214
+ // ANY positional argument skips it, not just a valid one. A wrong name has to
215
+ // reach the "Profile not found. Available: …" check below, which says what
216
+ // the user got wrong; gating it first answers a different question — "no
217
+ // profile is selected" — which is true, unhelpful, and not the mistake.
218
+ const current =
219
+ flags.profile !== undefined
220
+ ? manifest
221
+ : await requireActiveProfile(projectPath, manifest, {
222
+ check: flags.check || flags.dryRun,
223
+ });
224
+ // A refused dry run exits 0: it wrote nothing and previewed nothing, which is
225
+ // what it promised. A refused `--check` exits 1, because that is a gate.
226
+ if (!current) return flags.dryRun ? 0 : 1;
227
+
228
+ const profileIds = Object.keys(current.profiles);
229
+ if (flags.profile && !current.profiles[flags.profile]) {
195
230
  console.error(
196
231
  failLine(
197
232
  `Profile "${flags.profile}" not found. Available: ${profileIds.join(", ")}`,
@@ -204,19 +239,27 @@ export async function runUpdateCommand(
204
239
  // use. A single-profile manifest needs no activation to be unambiguous.
205
240
  // Chosen through the same function the TUI uses, so both judge a plugin
206
241
  // against the same pin.
207
- const targetId = selectProfileId(
242
+ const resolvedId = selectProfileId(
208
243
  profileIds,
209
244
  flags.profile,
210
245
  await activeProfile(projectPath),
211
246
  );
212
- if (!targetId) {
247
+
248
+ // `requireActiveProfile` above has already guaranteed one is active, so rule
249
+ // 2 of `selectProfileId` resolves and this is only unreachable-guard. There
250
+ // is deliberately no "pick one for this run" fallback any more: it used to
251
+ // ask here and then update a profile the project was not running, which is
252
+ // the two-worlds behaviour the gate exists to end. An explicit argument is
253
+ // still honoured — naming a profile is naming intent.
254
+ if (!resolvedId) {
213
255
  console.error(
214
256
  failLine(
215
- `No active profile. Pass one: magus update <${profileIds.join("|")}>`,
257
+ `No active profile. Run \`magus profile switch <${profileIds.join("|")}>\`.`,
216
258
  ),
217
259
  );
218
260
  return 1;
219
261
  }
262
+ const targetId = resolvedId;
220
263
 
221
264
  for (const line of header(targetId)) console.log(line);
222
265
 
@@ -243,7 +286,7 @@ export async function runUpdateCommand(
243
286
 
244
287
  const closure = await runStep(steps.profile, async (say) => {
245
288
  say(`resolving "${targetId}"`);
246
- const resolved = await resolveProfile(manifest, targetId);
289
+ const resolved = await resolveProfile(current, targetId);
247
290
  steps.profile.detail = [
248
291
  `${Object.keys(resolved.plugins).length} plugins`,
249
292
  `${resolved.bins.length} tools`,
package/src/main.tsx CHANGED
@@ -104,7 +104,16 @@ async function main(): Promise<void> {
104
104
  });
105
105
  }
106
106
 
107
- main().catch((error) => {
107
+ main().catch(async (error) => {
108
+ // Ctrl+C at a prompt is an answer — "stop" — not a failure. 130 is the
109
+ // shell's own convention for SIGINT (128 + 2), so `magus update && next`
110
+ // stops rather than running `next`. The newline lands the shell prompt on
111
+ // its own line instead of against the half-finished question.
112
+ const { isPromptAborted } = await import("./cli/prompt.js");
113
+ if (isPromptAborted(error)) {
114
+ process.stderr.write("\n");
115
+ process.exit(130);
116
+ }
108
117
  console.error("Error starting magus:", error);
109
118
  process.exit(1);
110
119
  });
@@ -115,3 +115,26 @@ export async function loadActiveClosure(
115
115
  return null;
116
116
  }
117
117
  }
118
+
119
+ /**
120
+ * The active profile's plugin pins — `pluginId -> exact version | "latest"` — and
121
+ * `{}` when no profile resolves.
122
+ *
123
+ * The one shape every surface that judges a plugin needs: `classifyPlugin`'s
124
+ * `pin`, the Plugins screen's `pins`, and the ceiling `findStaleLoads` caps its
125
+ * repair target at. Reading it through one helper is what keeps the startup popup
126
+ * and the Plugins header from capping differently — a pinned plugin read as
127
+ * BROKEN in one and current in the other is exactly the drift the classifier was
128
+ * centralised to end.
129
+ *
130
+ * `{}` means "latest for everything", which is what `pinForRow` assumes for an
131
+ * installed plugin no profile declares. `resolveBins: false` because no caller of
132
+ * this needs a plugin's binary requirements, and the default resolver reads every
133
+ * declared plugin's `plugin.json` to build them.
134
+ */
135
+ export async function activePluginPins(
136
+ projectPath: string,
137
+ ): Promise<Readonly<Record<string, string>>> {
138
+ const active = await loadActiveClosure(projectPath, { resolveBins: false });
139
+ return active?.closure.plugins ?? {};
140
+ }