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
@@ -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
+ }
@@ -31,11 +31,13 @@ import type {
31
31
  SkillUpdateItem,
32
32
  UpdatePlan,
33
33
  } from "../services/update-plan.js";
34
+ import { type ReportBadgeKind, reportBadge } from "../ui/theme-mode.js";
34
35
  import { brand } from "../ui/theme.js";
35
36
  import { formatCommand } from "../utils/command-utils.js";
36
37
  import {
37
38
  badge,
38
39
  bold,
40
+ dialog,
39
41
  dim,
40
42
  elapsed,
41
43
  fg,
@@ -84,8 +86,11 @@ type ActionKey =
84
86
  const ACTIONS: Record<ActionKey, ActionStyle> = {
85
87
  current: { glyph: "=", label: "CURRENT", color: brand.muted, chrome: true },
86
88
  install: { glyph: "+", label: "INSTALL", color: brand.link },
87
- update: { glyph: "↑", label: "UPDATE", color: brand.warning },
88
- upgrade: { glyph: "↑", label: "UPGRADE", color: brand.warning },
89
+ // Green, the tone magus uses for an update everywhere else. Amber stays
90
+ // on `unknown` below, so the two states remain distinguishable: green is
91
+ // "a newer version exists", amber is "we could not tell".
92
+ update: { glyph: "↑", label: "UPDATE", color: brand.success },
93
+ upgrade: { glyph: "↑", label: "UPGRADE", color: brand.success },
89
94
  repair: { glyph: "⟳", label: "REPAIR", color: brand.info },
90
95
  refresh: { glyph: "⟳", label: "REFRESH", color: brand.info },
91
96
  // Warning, not danger: "cannot tell" is not a failure, and red UNKNOWN read
@@ -207,24 +212,24 @@ export function stepFrame(steps: Step[], tick: number, now: number): string[] {
207
212
  }
208
213
 
209
214
  /**
210
- * The permanent record, with a duration bar per step.
215
+ * The permanent record: one row per step, its duration, and what it found.
211
216
  *
212
- * The bar is each step's share of the SLOWEST step, not of the total — the
213
- * question a reader has when an update felt slow is "which part was it?", and
214
- * shares of a total flatten to a uniform smear once there are four of them.
215
- * Colour ramps green to red with that share, so the offender is the red one.
217
+ * NO BAR. Each row carried an 18-cell meter of that step's share of the
218
+ * slowest step — which is precisely what the right-aligned millisecond column
219
+ * beside it already says, so the row stated one fact twice. A shape earns its
220
+ * width by making something readable that the number does not; a share of a
221
+ * five-row set, already sorted into a column, is not that.
222
+ *
223
+ * The LIVE frame keeps its bar (`stepFrame` above) and that is not the same
224
+ * widget: there the bar is a progress meter or an indeterminate sweep on the
225
+ * step running right now, and it answers "is this working or hung?" — a
226
+ * question no printed number can answer.
216
227
  */
217
228
  export function stepRecord(steps: Step[], now: number): string[] {
218
- const times = steps.map((s) => stepDuration(s, now) ?? 0);
219
- const slowest = Math.max(1, ...times);
220
- return steps.map((step, i) => {
221
- const share = (times[i] / slowest) * 100;
222
- const bar = meter(share, BAR, ramps.severity);
229
+ return steps.map((step) => {
223
230
  const label = padEnd(step.label, STEP_LABEL_WIDTH);
224
- const time = dim(padStart(elapsed(times[i]), 6));
225
- return `${PAD}${stepMark(step, 0)} ${label} ${bar} ${time} ${dim(
226
- step.detail,
227
- )}`;
231
+ const time = dim(padStart(elapsed(stepDuration(step, now) ?? 0), 6));
232
+ return `${PAD}${stepMark(step, 0)} ${label} ${time} ${dim(step.detail)}`;
228
233
  });
229
234
  }
230
235
 
@@ -236,15 +241,30 @@ function pluginAction(item: PluginUpdateItem): ActionKey {
236
241
 
237
242
  /** What the row says on its right: versions, never the action word again. */
238
243
  function pluginDetail(item: PluginUpdateItem): string {
239
- const note = item.note ? ` ${dim(`(${item.note})`)}` : "";
244
+ // A note the row's own badge already states is noise, not information. The
245
+ // DEPRECATED chip and the heading above it both say "no configured
246
+ // marketplace offers this", so printing the sentence again put the longest
247
+ // string in the table on the least interesting rows.
248
+ //
249
+ // The note stays on the item. The TUI has no heading to lean on and renders
250
+ // it as that row's only explanation — this is a rendering decision about a
251
+ // table that carries the fact twice, not a change to what is known.
252
+ const note =
253
+ item.note && item.reason !== "not-offered"
254
+ ? ` ${dim(`(${item.note})`)}`
255
+ : "";
240
256
  switch (item.action) {
241
257
  case "install":
242
258
  return `${item.target ?? "latest"}${note}`;
243
259
  case "update":
260
+ // The GROUP's page tone, not a literal and not ACTIONS: the arrow and
261
+ // the target sit in the same row as that group's glyph and chip, so
262
+ // they must resolve per page from the same place. A hardcoded tone
263
+ // here is how the row ended up half green and half amber.
244
264
  return `${dim(item.installed ?? "—")} ${fg(
245
- brand.warning,
265
+ groupStyle("Update").text,
246
266
  "→",
247
- )} ${fg(brand.warning, item.target ?? "?")}${note}`;
267
+ )} ${fg(groupStyle("Update").text, item.target ?? "?")}${note}`;
248
268
  case "repair":
249
269
  case "unknown":
250
270
  return `${item.installed ?? "—"}${note}`;
@@ -272,8 +292,11 @@ function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
272
292
  item.version ? ` ${dim(`pinned ${item.version}`)}` : ""
273
293
  }`;
274
294
  case "upgrade":
275
- return `${dim(item.installed ?? "—")} ${fg(brand.warning, "→")} ${fg(
276
- brand.warning,
295
+ return `${dim(item.installed ?? "—")} ${fg(
296
+ ACTIONS.upgrade.color,
297
+ "→",
298
+ )} ${fg(
299
+ ACTIONS.upgrade.color,
277
300
  item.target ?? "?",
278
301
  )}${command ? ` ${command}` : ""}${
279
302
  // An upgrade that also changes the tool's owner says so here; a
@@ -295,6 +318,183 @@ function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
295
318
  * The bar is the section's whole story in one line — a reader who takes in
296
319
  * nothing else knows whether this section is mostly settled or mostly work.
297
320
  */
321
+ /**
322
+ * Which heading a plugin row sits under.
323
+ *
324
+ * Read off the plan's machine codes — `action` and `reason` — never the human
325
+ * note. Five headings, and no more:
326
+ *
327
+ * Update something an apply would move forward
328
+ * Deprecated no configured marketplace offers it
329
+ * Fix required declared or enabled, with nothing on disk
330
+ * Unverified the check itself did not complete
331
+ * Current settled — the version was compared, or there was none to compare
332
+ *
333
+ * `Current` is last on purpose: it is the biggest group and the least
334
+ * interesting, and leading with it buries the few rows that need a decision.
335
+ *
336
+ * UNVERIFIED IS NOT CURRENT, and the two must not share a heading. A row whose
337
+ * catalog read failed used to land under `Current` carrying a `?` glyph and a
338
+ * note saying the catalog was unreachable — a row that contradicted the heading
339
+ * above it, while the closing notices insisted those rows are NOT reported as up
340
+ * to date. That is a different claim from "nothing could be compared, so we
341
+ * assume the install is the latest", which IS `current` and reaches it in the
342
+ * classifier, not here.
343
+ *
344
+ * "Unverified" is the word `summaryLine` already prints for this count; it is
345
+ * this report's existing vocabulary, not a new one.
346
+ */
347
+ function pluginGroup(item: PluginUpdateItem): string {
348
+ if (item.action === "update" || item.action === "repair") return "Update";
349
+ if (item.action === "install") return "Fix required";
350
+ if (item.action === "unknown") {
351
+ return item.reason === "not-offered" ? "Deprecated" : "Unverified";
352
+ }
353
+ return "Current";
354
+ }
355
+
356
+ const GROUP_ORDER = [
357
+ "Update",
358
+ "Deprecated",
359
+ "Fix required",
360
+ "Unverified",
361
+ "Current",
362
+ ];
363
+
364
+ /**
365
+ * Colour and emphasis per GROUP.
366
+ *
367
+ * "Dim the chrome, saturate the signal" is this file's own rule, stated at the
368
+ * top; the signal is the groups an apply would act on. Every colour comes from
369
+ * `brand`, which ui/theme.ts contrast-checks against BOTH a cream and a
370
+ * near-black terminal, so a light theme needs no second palette.
371
+ */
372
+ const GROUP_KIND: Record<string, ReportBadgeKind> = {
373
+ Update: "update",
374
+ Deprecated: "deprecated",
375
+ "Fix required": "fix",
376
+ };
377
+
378
+ interface GroupStyle {
379
+ /** Heading and glyph, drawn on the page. */
380
+ text: string;
381
+ /** Badge fill and its ink. Absent on a chrome group, which carries no badge. */
382
+ fill?: string;
383
+ ink?: string;
384
+ chrome?: boolean;
385
+ }
386
+
387
+ /**
388
+ * A group's colours, for the page this run resolved.
389
+ *
390
+ * `reportBadge` answers per page — pale fill with deep ink on cream, saturated
391
+ * fill with pale ink on near-black — so this is the one place the report stops
392
+ * using a single set of hues for both. `brand` is still right for everything
393
+ * else here: those tokens are chosen to clear 3:1 as INK on either page, which
394
+ * is a different question from what a filled chip should look like.
395
+ */
396
+ function groupStyle(group: string): GroupStyle {
397
+ const kind = GROUP_KIND[group];
398
+ if (!kind) return { text: brand.muted, chrome: true };
399
+ const ink = reportBadge(kind);
400
+ return { text: ink.text, fill: ink.fill, ink: ink.ink };
401
+ }
402
+
403
+ /** The badge's word: the heading, uppercased. Never a second vocabulary. */
404
+ function groupBadge(group: string): string {
405
+ return group.toUpperCase();
406
+ }
407
+
408
+ /**
409
+ * Badge column width, from the labels THIS report shows — a run with nothing
410
+ * deprecated does not pay for the longest word. `width` ignores escape bytes,
411
+ * so measuring an already painted badge is safe.
412
+ */
413
+ function badgeWidth(items: PluginUpdateItem[]): number {
414
+ return Math.max(
415
+ 0,
416
+ ...items
417
+ .filter((item) => !groupStyle(pluginGroup(item)).chrome)
418
+ .map((item) => {
419
+ const style = groupStyle(pluginGroup(item));
420
+ return (
421
+ width(
422
+ badge(
423
+ groupBadge(pluginGroup(item)),
424
+ style.fill ?? brand.muted,
425
+ style.ink,
426
+ ),
427
+ ) + 2
428
+ );
429
+ }),
430
+ );
431
+ }
432
+
433
+ function pluginRow(
434
+ item: PluginUpdateItem,
435
+ nameWidth: number,
436
+ chipWidth: number,
437
+ ): string {
438
+ const group = pluginGroup(item);
439
+ const style = groupStyle(group);
440
+ const name = padEnd(item.pluginId, nameWidth);
441
+ // Only an actionable row is badged; a chrome row gets the same width in
442
+ // spaces, so every version still starts in one column.
443
+ const chip = padEnd(
444
+ style.chrome
445
+ ? ""
446
+ : badge(groupBadge(group), style.fill ?? brand.muted, style.ink),
447
+ chipWidth,
448
+ );
449
+ return `${PAD}${fg(style.text, ACTIONS[item.action].glyph)} ${
450
+ style.chrome ? dim(name) : name
451
+ } ${chip}${pluginDetail(item)}`;
452
+ }
453
+
454
+ /** The Plugins section: grouped by state, `Current` last. */
455
+ function pluginSection(items: PluginUpdateItem[], nameWidth: number): string[] {
456
+ if (items.length === 0) return [];
457
+
458
+ const byGroup = new Map<string, PluginUpdateItem[]>();
459
+ for (const item of items) {
460
+ const group = pluginGroup(item);
461
+ const list = byGroup.get(group);
462
+ if (list) list.push(item);
463
+ else byGroup.set(group, [item]);
464
+ }
465
+
466
+ // GROUP_ORDER first, then anything it does not name. A report may never
467
+ // silently drop a row: driving the output purely from a fixed list would
468
+ // render an unexpected group as nothing at all while the header still
469
+ // counted it.
470
+ const ordered = [
471
+ ...GROUP_ORDER.filter((g) => byGroup.has(g)),
472
+ ...[...byGroup.keys()].filter((g) => !GROUP_ORDER.includes(g)),
473
+ ];
474
+
475
+ const chipWidth = badgeWidth(items);
476
+ // No bar: it stacked the mix into one strip above groups that already print
477
+ // their own counts, in a form nobody can read a number off.
478
+ const lines: string[] = [
479
+ "",
480
+ `${PAD}${bold("Plugins")} ${dim(String(items.length))}`,
481
+ ];
482
+
483
+ for (const group of ordered) {
484
+ const rows = byGroup.get(group);
485
+ if (!rows || rows.length === 0) continue;
486
+ lines.push(
487
+ "",
488
+ `${PAD}${bold(fg(groupStyle(group).text, group))} ${dim(
489
+ String(rows.length),
490
+ )}`,
491
+ );
492
+ for (const row of rows) lines.push(pluginRow(row, nameWidth, chipWidth));
493
+ }
494
+
495
+ return lines;
496
+ }
497
+
298
498
  function section(
299
499
  title: string,
300
500
  rows: Array<{ action: ActionKey; name: string; detail: string }>,
@@ -304,13 +504,6 @@ function section(
304
504
  const counts = new Map<ActionKey, number>();
305
505
  for (const row of rows)
306
506
  counts.set(row.action, (counts.get(row.action) ?? 0) + 1);
307
- const bar = stackedBar(
308
- BAR,
309
- [...counts].map(([action, value]) => ({
310
- value,
311
- color: ACTIONS[action].color,
312
- })),
313
- );
314
507
  const legend = [...counts]
315
508
  .map(([action, n]) =>
316
509
  fg(ACTIONS[action].color, `${n} ${ACTIONS[action].label.toLowerCase()}`),
@@ -319,7 +512,7 @@ function section(
319
512
 
320
513
  return [
321
514
  "",
322
- `${PAD}${bold(padEnd(title, 9))} ${bar} ${dim(
515
+ `${PAD}${bold(padEnd(title, 9))} ${dim(
323
516
  padStart(String(rows.length), 3),
324
517
  )} ${legend}`,
325
518
  "",
@@ -341,16 +534,22 @@ export function planReport(plan: UpdatePlan): string[] {
341
534
  ...plan.skills.map((s) => width(s.name)),
342
535
  );
343
536
 
537
+ // EVERY planned plugin gets a row. There is no filter here and there must
538
+ // not be one.
539
+ //
540
+ // A previous revision dropped `unknown` + `not-offered` + not-installed rows
541
+ // — a pin naming a plugin no marketplace offers — on the grounds that a
542
+ // notice named them instead. That notice did not exist: the function the
543
+ // comment cited was never written. The row appeared in no table and no
544
+ // notice, while the closing lines still counted it and said "Reasons are
545
+ // shown above", pointing at nothing.
546
+ //
547
+ // A report may never silently drop a row. That is the whole defect this
548
+ // file's grouping was written to end, and a filter here reintroduces it in a
549
+ // form that is harder to see, because the header count agrees with the rows
550
+ // and only the plan knows something is missing.
344
551
  return [
345
- ...section(
346
- "Plugins",
347
- plan.plugins.map((item) => ({
348
- action: pluginAction(item),
349
- name: item.pluginId,
350
- detail: pluginDetail(item),
351
- })),
352
- nameWidth,
353
- ),
552
+ ...pluginSection(plan.plugins, nameWidth),
354
553
  ...section(
355
554
  "CLI",
356
555
  plan.bins.map((item: BinUpdateItem) => ({
@@ -488,13 +687,6 @@ export function note(text: string): string {
488
687
  // false for a plugin whose marketplace publishes no version: re-running changes
489
688
  // nothing, and the reader went looking for a network fault that did not exist.
490
689
 
491
- /** Unverified plugins, under the plan. Never "up to date" — that is the point. */
492
- export function unknownPluginsNotice(count: number): string {
493
- return warn(
494
- `${bold(String(count))} plugin(s) could not be checked — not applied. They are NOT reported as up to date. Reasons are shown above.`,
495
- );
496
- }
497
-
498
690
  /** Unverified tools, under the plan. */
499
691
  export function unknownToolsNotice(count: number, names: string[]): string {
500
692
  return warn(
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,
@@ -88,7 +89,6 @@ import {
88
89
  stepRecord,
89
90
  summaryLine,
90
91
  uncheckedGateNotice,
91
- unknownPluginsNotice,
92
92
  unknownToolsNotice,
93
93
  warn as warnLine,
94
94
  } from "./update-view.js";
@@ -191,8 +191,42 @@ export async function runUpdateCommand(
191
191
  return 1;
192
192
  }
193
193
 
194
- const profileIds = Object.keys(manifest.profiles);
195
- 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]) {
196
230
  console.error(
197
231
  failLine(
198
232
  `Profile "${flags.profile}" not found. Available: ${profileIds.join(", ")}`,
@@ -205,19 +239,27 @@ export async function runUpdateCommand(
205
239
  // use. A single-profile manifest needs no activation to be unambiguous.
206
240
  // Chosen through the same function the TUI uses, so both judge a plugin
207
241
  // against the same pin.
208
- const targetId = selectProfileId(
242
+ const resolvedId = selectProfileId(
209
243
  profileIds,
210
244
  flags.profile,
211
245
  await activeProfile(projectPath),
212
246
  );
213
- 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) {
214
255
  console.error(
215
256
  failLine(
216
- `No active profile. Pass one: magus update <${profileIds.join("|")}>`,
257
+ `No active profile. Run \`magus profile switch <${profileIds.join("|")}>\`.`,
217
258
  ),
218
259
  );
219
260
  return 1;
220
261
  }
262
+ const targetId = resolvedId;
221
263
 
222
264
  for (const line of header(targetId)) console.log(line);
223
265
 
@@ -244,7 +286,7 @@ export async function runUpdateCommand(
244
286
 
245
287
  const closure = await runStep(steps.profile, async (say) => {
246
288
  say(`resolving "${targetId}"`);
247
- const resolved = await resolveProfile(manifest, targetId);
289
+ const resolved = await resolveProfile(current, targetId);
248
290
  steps.profile.detail = [
249
291
  `${Object.keys(resolved.plugins).length} plugins`,
250
292
  `${resolved.bins.length} tools`,
@@ -293,30 +335,13 @@ export async function runUpdateCommand(
293
335
  });
294
336
  steps.marketplaces.detail = [
295
337
  `${refresh.refreshed.length} refreshed`,
296
- refresh.autoUpdateDisabled.length > 0
297
- ? `${refresh.autoUpdateDisabled.length} auto-update off`
298
- : "",
299
338
  refresh.failed.length > 0 ? `${refresh.failed.length} failed` : "",
300
339
  refresh.skipped.length > 0 ? `${refresh.skipped.length} skipped` : "",
301
340
  ]
302
341
  .filter(Boolean)
303
342
  .join(" · ");
304
343
  if (refresh.failed.length > 0) steps.marketplaces.state = "fail";
305
- else if (refresh.autoUpdateDisabled.length > 0)
306
- steps.marketplaces.state = "warn";
307
344
 
308
- for (const name of refresh.autoUpdateDisabled) {
309
- notices.push(
310
- warnLine(
311
- `${bold(name)}: auto-update disabled — its catalog will not refresh, so updates stay hidden.`,
312
- ),
313
- );
314
- notices.push(
315
- noteLine(
316
- `claude plugin marketplace update ${name} # to refresh it once`,
317
- ),
318
- );
319
- }
320
345
  for (const name of refresh.failed) {
321
346
  notices.push(
322
347
  failLine(
@@ -405,11 +430,16 @@ export async function runUpdateCommand(
405
430
  .map((b) => b.name);
406
431
  const toolUnknownLine = () =>
407
432
  unknownToolsNotice(counts.unknownTools, unknownToolNames);
408
- if (counts.unknownPlugins > 0 || counts.unknownTools > 0) console.log("");
409
- if (counts.unknownPlugins > 0) {
410
- console.log(unknownPluginsNotice(counts.unknownPlugins));
433
+ // No plugin equivalent of `toolUnknownLine`. Every unverified plugin is
434
+ // already a row in the table above, under a heading that names its state,
435
+ // so a trailing "N plugin(s) could not be checked" restated the table in
436
+ // prose and — since the grouping landed — miscounted it: the count is raw
437
+ // `unknown`, which now includes rows filed under Current. A tool has no
438
+ // such row, which is why that notice stays and names them.
439
+ if (counts.unknownTools > 0) {
440
+ console.log("");
441
+ console.log(toolUnknownLine());
411
442
  }
412
- if (counts.unknownTools > 0) console.log(toolUnknownLine());
413
443
 
414
444
  // `--check` gates on planIsBehind, not planHasWork. An already-installed
415
445
  // skill is re-fetched on every apply because its manifest ref carries no
package/src/main.tsx CHANGED
@@ -84,7 +84,7 @@ async function main(): Promise<void> {
84
84
  // top-level flags (--version/--help). A bare invocation falls through to
85
85
  // the interactive TUI below.
86
86
  const { route } = await import("./cli/router.js");
87
- const outcome = await route(args, VERSION);
87
+ const outcome = await route(args, VERSION, themeEnv);
88
88
  if (outcome.handled) {
89
89
  process.exit(outcome.exitCode ?? 0);
90
90
  }
@@ -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
  });