magus-cli 7.0.0 → 7.2.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-parity.test.ts +58 -17
  3. package/src/__tests__/blackbox-plugin-state.test.ts +41 -19
  4. package/src/__tests__/cli-update-view.test.ts +100 -21
  5. package/src/__tests__/local-marketplace-scan.test.ts +74 -0
  6. package/src/__tests__/marketplace-refresh.test.ts +12 -7
  7. package/src/__tests__/plugin-row-text.test.ts +33 -24
  8. package/src/__tests__/profile-migration-offer.test.ts +211 -0
  9. package/src/__tests__/profile-migration.test.ts +242 -0
  10. package/src/__tests__/profile-startup.test.ts +458 -0
  11. package/src/__tests__/theme-env.test.ts +29 -0
  12. package/src/__tests__/update-plan.test.ts +57 -34
  13. package/src/__tests__/version-unknown-state.test.ts +29 -14
  14. package/src/cli/ansi.ts +58 -11
  15. package/src/cli/bootstrap.ts +169 -3
  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/router.ts +59 -2
  20. package/src/cli/tui-prompt.tsx +143 -0
  21. package/src/cli/update-view.ts +237 -45
  22. package/src/cli/update.ts +59 -29
  23. package/src/main.tsx +11 -2
  24. package/src/services/claude-settings.ts +15 -40
  25. package/src/services/local-marketplace.ts +76 -6
  26. package/src/services/manifest.ts +21 -0
  27. package/src/services/marketplace-refresh.ts +7 -15
  28. package/src/services/plugin-manager.ts +0 -31
  29. package/src/services/profile-activate.ts +147 -0
  30. package/src/services/profile-adopt.ts +50 -4
  31. package/src/services/profile-startup.ts +311 -0
  32. package/src/services/profiles.ts +49 -11
  33. package/src/services/resolver.ts +33 -0
  34. package/src/services/symlink-manager.ts +184 -30
  35. package/src/services/update-plan.ts +23 -66
  36. package/src/types/index.ts +44 -23
  37. package/src/ui/App.tsx +60 -3
  38. package/src/ui/components/layout/FooterHints.tsx +21 -9
  39. package/src/ui/components/modals/ModalContainer.tsx +1 -0
  40. package/src/ui/components/modals/SelectModal.tsx +142 -21
  41. package/src/ui/hooks/useProfileStartupModal.ts +260 -0
  42. package/src/ui/renderers/profileRenderers.tsx +27 -4
  43. package/src/ui/screens/PluginsScreen.tsx +9 -50
  44. package/src/ui/screens/ProfilesScreen.tsx +1 -1
  45. package/src/ui/state/types.ts +18 -0
  46. package/src/ui/theme-mode.ts +142 -0
  47. package/src/utils/command-utils.ts +19 -14
  48. package/src/__tests__/moved-marketplace.test.ts +0 -86
@@ -247,10 +247,16 @@ describe("REVIEW-B regressions — found by the patch-quality panel", () => {
247
247
  expect(items[0]?.reason).toBe("absent");
248
248
  });
249
249
 
250
- test("an installed version that cannot be compared is unknown, not current", () => {
251
- // 21 registry rows carry the literal string "unknown" as their version.
252
- // Against a semver catalog they are unorderable, so no scope is provably
253
- // behind — and falling through to `current` claims they are up to date.
250
+ // REVIEW-B's finding is accepted and its remedy REVERSED. 21 registry rows
251
+ // carry the literal string "unknown" as their version; against a semver
252
+ // catalog they are unorderable, so nothing is provably behind. REVIEW-B
253
+ // concluded that made `current` a false claim.
254
+ //
255
+ // The claim is now explicitly "nothing is KNOWN to be behind", and that is
256
+ // what `current` means here. Raising a warning instead put a permanent amber
257
+ // row in front of the user with no action that could ever clear it, which is
258
+ // a worse falsehood: it says "do something" when there is nothing to do.
259
+ test("an installed version that cannot be compared is assumed current", () => {
254
260
  const items = planPluginUpdates(
255
261
  { "playground@official": "latest" },
256
262
  new Map([
@@ -264,9 +270,11 @@ describe("REVIEW-B regressions — found by the patch-quality panel", () => {
264
270
  ],
265
271
  ]),
266
272
  );
267
- expect(items[0]?.action).toBe("unknown");
268
- expect(items[0]?.note).toContain("cannot be compared");
269
- expect(items[0]?.reason).toBe("unorderable");
273
+ expect(items[0]?.action).toBe("current");
274
+ expect(items[0]?.reason).not.toBe("unorderable");
275
+ // The row stays silent: no note, and nothing planned against it.
276
+ expect(items[0]?.note).toBeUndefined();
277
+ expect(items[0]?.scopes).toEqual([]);
270
278
  });
271
279
 
272
280
  test("a genuinely current install is still current", () => {
@@ -335,10 +343,14 @@ describe("installed with no comparable catalog version — two different causes"
335
343
  ],
336
344
  ]),
337
345
  );
338
- expect(items[0]?.action).toBe("unknown");
339
- expect(items[0]?.note).toContain("publishes no version");
340
- expect(items[0]?.note).not.toContain("not offered");
346
+ // The distinction this test exists for is between the two REASONS, and it
347
+ // still holds. What changed is the verdict: declaring no version is not a
348
+ // problem, so the row is `current` and says nothing. Being withdrawn IS a
349
+ // problem, so the next test's row is `unknown` and says so.
341
350
  expect(items[0]?.reason).toBe("no-published-version");
351
+ expect(items[0]?.reason).not.toBe("not-offered");
352
+ expect(items[0]?.action).toBe("current");
353
+ expect(items[0]?.note).toBeUndefined();
342
354
  });
343
355
 
344
356
  test("absent from the catalog entirely IS 'not offered'", () => {
@@ -363,12 +375,15 @@ describe("installed with no comparable catalog version — two different causes"
363
375
  });
364
376
  });
365
377
 
366
- describe('the "0.0.0" registry value is unknown, never healthy', () => {
378
+ describe('the "0.0.0" registry value keeps its reason', () => {
367
379
  test("installed at 0.0.0 with no published version is no-published-version", () => {
368
380
  // Real: explanatory-output-style@claude-plugins-official is recorded as
369
381
  // "0.0.0" in the registry for many project paths, and its marketplace
370
- // publishes no version. The TUI row hid every status suffix behind a
371
- // displayable version, so this row rendered with none at all.
382
+ // publishes no version.
383
+ //
384
+ // "0.0.0" must never silently become a DIFFERENT reason — that is what
385
+ // this pins. The verdict is `current`: there is no published version to
386
+ // be behind, so there is nothing to report and nothing to do.
372
387
  const items = planPluginUpdates(
373
388
  { "explanatory-output-style@official": "latest" },
374
389
  new Map([
@@ -382,7 +397,7 @@ describe('the "0.0.0" registry value is unknown, never healthy', () => {
382
397
  ],
383
398
  ]),
384
399
  );
385
- expect(items[0]?.action).toBe("unknown");
386
400
  expect(items[0]?.reason).toBe("no-published-version");
401
+ expect(items[0]?.action).toBe("current");
387
402
  });
388
403
  });
package/src/cli/ansi.ts CHANGED
@@ -21,6 +21,7 @@
21
21
  * No OpenTUI type crosses this boundary: hex in, escaped string out.
22
22
  */
23
23
 
24
+ import { barTrack } from "../ui/theme-mode.js";
24
25
  import { brand } from "../ui/theme.js";
25
26
 
26
27
  // -- colour capability -------------------------------------------------------
@@ -372,14 +373,17 @@ const FILL = "█";
372
373
  const TRACK = "░";
373
374
 
374
375
  /**
375
- * The unfilled part of a bar.
376
+ * The unfilled part of a bar, resolved per page.
376
377
  *
377
- * Deliberately much darker than `brand.muted`. At full `muted` a 20-cell `░`
378
- * track reads as hatched texture rather than as emptiness, and it competes with
379
- * the fill for the eye — which is the opposite of "dim the chrome, saturate the
380
- * signal". A track is chrome.
378
+ * At full `brand.muted` a 20-cell `░` track reads as hatched texture rather
379
+ * than as emptiness, and competes with the fill — the opposite of "dim the
380
+ * chrome, saturate the signal". A track is chrome.
381
+ *
382
+ * Which grey is quiet depends on the page, so the two values and the numbers
383
+ * behind them live in `ui/theme-mode.ts`. Resolved per CALL, never hoisted into
384
+ * a const: the mode is set at startup by the router, after this module loads.
381
385
  */
382
- const TRACK_COLOR = darken(brand.muted, 0.68);
386
+ const TRACK_COLOR = (): string => barTrack();
383
387
 
384
388
  /**
385
389
  * Gradient meter: `cells` wide, one blended colour per filled cell.
@@ -402,7 +406,7 @@ export function meter(
402
406
  );
403
407
  let out = "";
404
408
  for (let i = 0; i < cells; i++) {
405
- out += i < filled ? fg(cols[i], FILL) : fg(TRACK_COLOR, TRACK);
409
+ out += i < filled ? fg(cols[i], FILL) : fg(TRACK_COLOR(), TRACK);
406
410
  }
407
411
  return out;
408
412
  }
@@ -433,7 +437,7 @@ export interface BarSegment {
433
437
  export function stackedBar(cells: number, segments: BarSegment[]): string {
434
438
  if (cells <= 0) return "";
435
439
  let live = segments.filter((s) => s.value > 0);
436
- if (live.length === 0) return fg(TRACK_COLOR, TRACK.repeat(cells));
440
+ if (live.length === 0) return fg(TRACK_COLOR(), TRACK.repeat(cells));
437
441
  // More segments than cells: keep the biggest `cells` of them, in place.
438
442
  if (live.length > cells) {
439
443
  const cutoff = [...live]
@@ -488,15 +492,19 @@ export function sweep(
488
492
  const heat = behind >= 0 && behind < band ? 1 - behind / band : 0;
489
493
  out +=
490
494
  heat <= 0
491
- ? fg(TRACK_COLOR, TRACK)
495
+ ? fg(TRACK_COLOR(), TRACK)
492
496
  : fg(mix(darken(hue, 0.75), hue, heat), FILL);
493
497
  }
494
498
  return out;
495
499
  }
496
500
 
497
501
  /** A discrete status, as dark-or-white ink on a saturated fill. */
498
- export function badge(label: string, hex: string): string {
499
- return on(hex, inkOn(hex), ` ${label} `);
502
+ export function badge(label: string, hex: string, ink?: string): string {
503
+ // `inkOn` picks black or white by luminance, which is right for a saturated
504
+ // fill and blunt for a pale one: a soft ochre chip reads better with the deep
505
+ // member of its own family than with flat black. A caller carrying a measured
506
+ // pair passes it; everyone else keeps the automatic choice.
507
+ return on(hex, ink ?? inkOn(hex), ` ${label} `);
500
508
  }
501
509
 
502
510
  /**
@@ -505,6 +513,45 @@ export function badge(label: string, hex: string): string {
505
513
  */
506
514
  const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
507
515
 
516
+ /**
517
+ * A bordered dialog: title in the top rule, body inside, one blank row of
518
+ * breathing space top and bottom.
519
+ *
520
+ * Built on `padEnd`, which measures visible cells, so a body line carrying
521
+ * colour still aligns — padding a coloured string by `String.length` counts the
522
+ * escape bytes and shears the right border by however many bytes the colour
523
+ * cost.
524
+ *
525
+ * The border and the rule are dim and the body is not: a frame that competes
526
+ * with its own contents is why a boxed message can read as louder but say less.
527
+ */
528
+ export function dialog(
529
+ title: string,
530
+ lines: string[],
531
+ opts: { width?: number; color?: string } = {},
532
+ ): string[] {
533
+ const inner = Math.max(
534
+ opts.width ?? 0,
535
+ ...lines.map((l) => width(l)),
536
+ width(title) + 4,
537
+ );
538
+ const accent = opts.color;
539
+ const paint = (s: string) => (accent ? fg(accent, s) : s);
540
+
541
+ // Every row must be `inner + 4` cells: corner, space, body, space, corner.
542
+ // The head spends 2 on "╭─" and `width(title) + 2` on the titled gap, so the
543
+ // rule takes the rest. Getting this off by one is invisible in source and
544
+ // obvious on screen — the right border jags one column in at the title row.
545
+ const head = `${paint("╭─")}${paint(` ${title} `)}${dim(
546
+ "─".repeat(Math.max(0, inner - width(title) - 1)),
547
+ )}${dim("╮")}`;
548
+ const foot = dim(`╰${"─".repeat(inner + 2)}╯`);
549
+ const row = (text: string) =>
550
+ `${dim("│")} ${padEnd(text, inner)} ${dim("│")}`;
551
+
552
+ return [head, row(""), ...lines.map(row), row(""), foot];
553
+ }
554
+
508
555
  export function spinner(tick: number): string {
509
556
  return SPINNER[Math.abs(Math.trunc(tick)) % SPINNER.length];
510
557
  }
@@ -8,17 +8,74 @@
8
8
  * stands, then the original command proceeds against it normally.
9
9
  */
10
10
 
11
+ import path from "node:path";
11
12
  import {
12
13
  getManifestPath,
13
14
  readManifest,
14
15
  writeManifest,
15
16
  } from "../services/manifest.js";
17
+ import {
18
+ applyProfileChoice,
19
+ materializeAndActivate,
20
+ reportDisplaced,
21
+ } from "../services/profile-activate.js";
16
22
  import {
17
23
  type AdoptResult,
18
24
  adoptCurrentProject,
19
25
  } from "../services/profile-adopt.js";
26
+ import {
27
+ buildProfileOptions,
28
+ defaultOptionIndex,
29
+ describeNeed,
30
+ detectProfileStartupNeed,
31
+ needsNothing,
32
+ parseProfileChoice,
33
+ } from "../services/profile-startup.js";
20
34
  import type { ProfileManifest } from "../types/index.js";
35
+ import { dim } from "./ansi.js";
21
36
  import { confirm } from "./prompt.js";
37
+ import { selectDialog } from "./tui-prompt.js";
38
+ import { fail as failLine, note as noteLine } from "./update-view.js";
39
+
40
+ /**
41
+ * Write the adopted manifest, then put the project ON the profile it describes.
42
+ *
43
+ * Both halves or neither. Writing alone is what made migrating feel like a
44
+ * no-op: `activeProfile()` is derived from the symlink, so a manifest nothing
45
+ * links to changes no behaviour, and the run that just migrated still has to
46
+ * ask which profile to use.
47
+ *
48
+ * Activation is reported in full, including anything it moved aside, because it
49
+ * replaces files the user wrote by hand. A migration is allowed to change the
50
+ * working tree; it is not allowed to do it quietly.
51
+ */
52
+ async function writeAndActivate(
53
+ result: AdoptResult,
54
+ projectPath: string,
55
+ ): Promise<void> {
56
+ // Relative to the project throughout. These are all project paths, and an
57
+ // absolute one wraps over two terminal lines — several wrapped lines in a row
58
+ // is where a reader stops reading, and this notice is the one that says where
59
+ // their hand-written files went.
60
+ const rel = (p: string) => path.relative(projectPath, p);
61
+
62
+ await writeManifest(result.manifest, projectPath);
63
+ console.log(`✓ Wrote ${rel(getManifestPath(projectPath))}`);
64
+
65
+ // Each move is printed as it happens, not from the returned list afterwards.
66
+ // Activation touches up to four artifacts in sequence, so a throw on the
67
+ // third would discard the record of the first two — and the first of those
68
+ // is the user's `.claude/settings.json`, now absent from where they left it.
69
+ // Printing first means a failed activation still says where everything went.
70
+ await materializeAndActivate(
71
+ result.manifest,
72
+ result.profileId,
73
+ projectPath,
74
+ reportDisplaced(projectPath),
75
+ );
76
+ console.log(`✓ Active profile: ${result.profileId}`);
77
+ console.log();
78
+ }
22
79
 
23
80
  /** Print what adoption would capture, so the user can judge it before writing. */
24
81
  function printAdoptionPlan(result: AdoptResult): void {
@@ -64,6 +121,16 @@ function printAdoptionPlan(result: AdoptResult): void {
64
121
  console.log(
65
122
  "\nCommit the file to share this setup with the team; edit it to pin versions.",
66
123
  );
124
+ // Said before the question, not after it: adopting also SWITCHES the project
125
+ // onto the profile, which replaces two files that are in the working tree
126
+ // right now. The prompt below is the only place to decline.
127
+ console.log(
128
+ "The project then runs this profile: .claude/settings.json, .mcp.json and",
129
+ );
130
+ console.log(
131
+ ".claude/skills become links into it, and your current copies are kept where",
132
+ );
133
+ console.log("the run reports.");
67
134
  }
68
135
 
69
136
  export interface EnsureManifestOptions {
@@ -123,12 +190,111 @@ export async function ensureManifest(
123
190
  "\nRefusing to create .claude/profiles.json without a human: run `magus profile init` to create it deliberately.",
124
191
  );
125
192
  return null;
126
- } else if (!(await confirm("\nCreate .claude/profiles.json?"))) {
193
+ } else if (!(await confirm("\nCreate it and switch to it?"))) {
127
194
  console.log("Aborted — nothing written.");
128
195
  return null;
129
196
  }
130
197
 
131
- await writeManifest(result.manifest, projectPath);
132
- console.log(`✓ Wrote ${getManifestPath(projectPath)}\n`);
198
+ await writeAndActivate(result, projectPath);
133
199
  return result.manifest;
134
200
  }
201
+
202
+ /**
203
+ * Require an active profile, or stop the command.
204
+ *
205
+ * magus manages a project THROUGH its active profile: that is where plugin
206
+ * state is read from and where updates are written. With none there is nothing
207
+ * to read and nowhere to write, so there is no useful work to do — and the
208
+ * previous version of this offered exactly that anyway. It asked only when the
209
+ * manifest had drifted, let the user decline, and `update` then carried on with
210
+ * a profile "for this run only", resolving against a manifest the project was
211
+ * not running. Two worlds live at once, which is the confusion being removed.
212
+ *
213
+ * So this asks whenever no profile is active, and the only answers are migrate,
214
+ * activate, or stop. Returns the manifest to carry on with, or null when the
215
+ * command must exit non-zero — by which point it has printed why.
216
+ *
217
+ * Nothing is compared. An earlier version weighed the manifest against
218
+ * `enabledPlugins`; see the header of `services/profile-startup.ts` for why
219
+ * that reads a profiled project's own generated output and cannot be acted on.
220
+ */
221
+ export async function requireActiveProfile(
222
+ projectPath: string,
223
+ manifest: ProfileManifest,
224
+ opts: EnsureManifestOptions = {},
225
+ ): Promise<ProfileManifest | null> {
226
+ const need = await detectProfileStartupNeed(projectPath);
227
+ if (needsNothing(need)) return manifest;
228
+
229
+ // The repo declares which profile it wants and there is nothing of the
230
+ // user's to replace — a fresh clone. Activate it and carry on, even under
231
+ // `--check` and with no TTY: this is the committed answer being applied, not
232
+ // a decision being made, and it is exactly the case CI hits.
233
+ if (need.autoActivate && need.declaredDefault !== null && !opts.check) {
234
+ const { profileId } = await applyProfileChoice(
235
+ { kind: "activate", id: need.declaredDefault },
236
+ need.manifest,
237
+ projectPath,
238
+ reportDisplaced(projectPath),
239
+ );
240
+ console.log(
241
+ `✓ Active profile: ${profileId} ${dim("(declared by this project)")}`,
242
+ );
243
+ console.log("");
244
+ return readManifest(projectPath);
245
+ }
246
+
247
+ const { title } = describeNeed(need);
248
+
249
+ // `--check` is a read-only CI gate, and "this project is not on a profile"
250
+ // is a legitimate thing for it to fail on rather than quietly repair.
251
+ if (opts.check) {
252
+ console.error(failLine(title));
253
+ console.error(noteLine("Run `magus update` at a terminal to resolve it."));
254
+ return null;
255
+ }
256
+
257
+ // CI has nobody to ask. Exiting beats hanging on an answer that cannot come.
258
+ if (!process.stdin.isTTY) {
259
+ console.error(failLine(title));
260
+ console.error(
261
+ noteLine(
262
+ need.state === "unmigrated"
263
+ ? "Run `magus profile init` to create one deliberately."
264
+ : `Run \`magus profile switch <${need.activate
265
+ .map((p) => p.id)
266
+ .join("|")}>\` first.`,
267
+ ),
268
+ );
269
+ return null;
270
+ }
271
+
272
+ console.log("");
273
+ const chosen = await selectDialog({
274
+ title,
275
+ message: describeNeed(need).message,
276
+ options: buildProfileOptions(need),
277
+ defaultIndex: defaultOptionIndex(need),
278
+ });
279
+
280
+ // Esc, Ctrl+C and the "Quit magus" row all land here, and there is no third
281
+ // outcome: the command stops rather than proceed without a profile.
282
+ const choice = chosen === null ? null : parseProfileChoice(chosen, need);
283
+ if (!choice) {
284
+ console.error(failLine("No active profile — stopping."));
285
+ return null;
286
+ }
287
+
288
+ const { profileId } = await applyProfileChoice(
289
+ choice,
290
+ need.manifest,
291
+ projectPath,
292
+ reportDisplaced(projectPath),
293
+ );
294
+ console.log(`✓ Active profile: ${profileId}`);
295
+ console.log("");
296
+
297
+ // Re-read rather than return `need.manifest`: adopting wrote a new profile
298
+ // into the file, and the caller resolves against whatever comes back.
299
+ return readManifest(projectPath);
300
+ }
@@ -27,6 +27,7 @@ import {
27
27
  } from "../services/install-plan.js";
28
28
  import { validateManifest } from "../services/manifest.js";
29
29
  import { registerClosureMarketplaces } from "../services/marketplace-sync.js";
30
+ import { reportDisplaced } from "../services/profile-activate.js";
30
31
  import { materializeProfile } from "../services/profile-materializer.js";
31
32
  import { detectProfileDrift } from "../services/profile-sync.js";
32
33
  import { resolveAllProfiles, resolveProfile } from "../services/resolver.js";
@@ -355,7 +356,10 @@ export async function runInstallCommand(
355
356
  const activeId =
356
357
  flags.profile ?? (await pickActiveProfile(profileIds, flags.yes));
357
358
  if (activeId) {
358
- await activateProfile(activeId, projectPath);
359
+ // The reporter is not optional here. The first `install` in a project that
360
+ // had a hand-written .claude/settings.json moves it aside, and without
361
+ // this the move is invisible — the file is simply not where it was.
362
+ await activateProfile(activeId, projectPath, reportDisplaced(projectPath));
359
363
  console.log(`\n✓ Active profile: ${activeId}`);
360
364
  // Skills route through the now-active .claude/skills symlink.
361
365
  const activeClosure = await resolveProfile(manifest, activeId);
@@ -13,6 +13,7 @@ import { ensureGitignoreEntries } from "../services/conventions-manager.js";
13
13
  import { checkBinaries, missingBinaries } from "../services/doctor-bins.js";
14
14
  import { summarizeClosure } from "../services/install-plan.js";
15
15
  import { getManifestPath, readManifest } from "../services/manifest.js";
16
+ import { reportDisplaced } from "../services/profile-activate.js";
16
17
  import { syncProfile } from "../services/profile-sync.js";
17
18
  import { resolveProfile } from "../services/resolver.js";
18
19
  import {
@@ -147,7 +148,10 @@ export async function switchProfile(
147
148
  "# magus: generated profile build output + active-profile symlinks",
148
149
  );
149
150
 
150
- await activateProfile(name, projectPath);
151
+ // Switching between two already-active profiles displaces nothing — the paths
152
+ // are links magus owns. The FIRST switch in an unmanaged project displaces
153
+ // real files, and that is the run that must say so.
154
+ await activateProfile(name, projectPath, reportDisplaced(projectPath));
151
155
  console.log(`✓ Switched to profile: ${name}`);
152
156
 
153
157
  // Verify the profile's binary deps are actually usable — the switch itself
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
+ }
package/src/cli/router.ts CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  import type { ThemeMode } from "@opentui/core";
10
10
  import { checkForUpdates } from "../services/version-check.js";
11
- import { parseThemeWord } from "../ui/theme-resolve.js";
11
+ import { type ThemeEnv, parseThemeWord } from "../ui/theme-resolve.js";
12
12
  import { runDoctorCommand } from "./doctor.js";
13
13
  import { runInstallCommand } from "./install.js";
14
14
  import { runModelsCommand } from "./models.js";
@@ -84,9 +84,32 @@ const HANDLED = (exitCode: number): RouteOutcome => ({
84
84
  });
85
85
  const LAUNCH_TUI: RouteOutcome = { handled: false, launchTui: true };
86
86
 
87
+ /**
88
+ * Every name the switch below handles.
89
+ *
90
+ * Declared once so the theme resolution and the dispatch cannot disagree about
91
+ * what counts as a subcommand — a name in one and not the other would print an
92
+ * unthemed report, which is the bug this set exists to prevent.
93
+ */
94
+ const SUBCOMMANDS = new Set([
95
+ "update",
96
+ "upgrade",
97
+ "install",
98
+ "profile",
99
+ "models",
100
+ "doctor",
101
+ ]);
102
+
87
103
  export async function route(
88
104
  argv: string[],
89
105
  version: string,
106
+ /**
107
+ * The theme environment, snapshotted by main.tsx BEFORE it loads a project's
108
+ * .env. Optional only so a test may call `route` without one; production
109
+ * always passes it, and omitting it falls back to "no opinion" (dark) rather
110
+ * than reading a process.env that a project may by then have written into.
111
+ */
112
+ themeEnv?: ThemeEnv,
90
113
  ): Promise<RouteOutcome> {
91
114
  // Top-level flags take precedence over any command token.
92
115
  if (argv.includes("--version") || argv.includes("-v")) {
@@ -111,9 +134,43 @@ export async function route(
111
134
  }
112
135
 
113
136
  // Dispatch on argv with the theme tokens removed, so `--theme dark update`
114
- // still runs `update`. Subcommands ignore the flag; only the TUI carries it.
137
+ // still runs `update`.
115
138
  const [command, ...rest] = themeFlag.rest;
116
139
 
140
+ // Every subcommand paints, and until now only the TUI ever asked which page it
141
+ // was painting on: `setThemeMode` was called in tui.tsx alone, so `magus
142
+ // update` kept the "dark" default and printed dark-page fills on a cream
143
+ // terminal. That is why the report read harsh next to the Models screen.
144
+ //
145
+ // No probe here. There is no renderer to ask, and a report must not block on a
146
+ // terminal reply — so this resolves from the flag, MAGUS_THEME, TERM_THEME
147
+ // and COLORFGBG, and still falls back to dark when nothing answers, exactly as
148
+ // before. The TUI keeps its own probing resolution in tui.tsx.
149
+ //
150
+ // `themeEnv` is HANDED IN, never re-snapshotted here. main.tsx takes it as
151
+ // its very first act, before `loadProjectDotenv()` merges a project's .env
152
+ // into process.env — so the theme comes from the environment magus was
153
+ // started with and never from the project being inspected. Calling
154
+ // `snapshotThemeEnv()` at this point would run after that merge and quietly
155
+ // hand a checked-out repo control of the palette.
156
+ if (command !== undefined && SUBCOMMANDS.has(command)) {
157
+ const [{ resolveTheme }, { setThemeMode }] = await Promise.all([
158
+ import("../ui/theme-resolve.js"),
159
+ import("../ui/theme-mode.js"),
160
+ ]);
161
+ const { mode } = await resolveTheme({
162
+ flag: themeFlag.theme,
163
+ env: themeEnv ?? {},
164
+ // A report is not an interactive session: it may be piped, redirected
165
+ // or read by CI. `isInteractive: false` is what stops the probe being
166
+ // consulted at all, and the null probe satisfies the signature without
167
+ // ever writing an OSC query into someone's scrollback.
168
+ isInteractive: false,
169
+ probe: async () => null,
170
+ });
171
+ setThemeMode(mode);
172
+ }
173
+
117
174
  switch (command) {
118
175
  case "update":
119
176
  return HANDLED(await runUpdateCommand(rest));