magus-cli 7.7.0 → 7.8.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 (83) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-parity.test.ts +29 -15
  3. package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
  4. package/src/__tests__/blind-780-cli-tools.test.ts +808 -0
  5. package/src/__tests__/blind-780-deprecated.test.ts +631 -0
  6. package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
  7. package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
  8. package/src/__tests__/blind-780-review2.test.ts +1203 -0
  9. package/src/__tests__/cli-update-view.test.ts +3 -3
  10. package/src/__tests__/deprecated-remove.test.ts +827 -0
  11. package/src/__tests__/doctor.test.ts +159 -1
  12. package/src/__tests__/failure-popup-review2.test.tsx +920 -0
  13. package/src/__tests__/failure-popup.test.tsx +419 -0
  14. package/src/__tests__/final-fixes-780.test.tsx +724 -0
  15. package/src/__tests__/format-update-outcome.test.ts +6 -32
  16. package/src/__tests__/gitignore-profile.test.ts +1 -9
  17. package/src/__tests__/models-screen-state.test.ts +18 -40
  18. package/src/__tests__/plugin-resolution.test.ts +52 -13
  19. package/src/__tests__/plugin-row-text.test.ts +30 -37
  20. package/src/__tests__/profile-dependencies.test.ts +7 -0
  21. package/src/__tests__/profile-startup-old-format.test.ts +0 -3
  22. package/src/__tests__/profile-startup.test.ts +40 -12
  23. package/src/__tests__/stale-load-repair.test.ts +31 -23
  24. package/src/__tests__/update-apply.test.ts +13 -29
  25. package/src/__tests__/update-plan.test.ts +23 -17
  26. package/src/__tests__/useStaleLoadModal.test.ts +1 -57
  27. package/src/__tests__/version-unknown-state.test.ts +15 -17
  28. package/src/cli/doctor.ts +151 -30
  29. package/src/cli/install.ts +5 -6
  30. package/src/cli/update-view.ts +58 -15
  31. package/src/cli/update.ts +27 -5
  32. package/src/services/catalog-cache-store.ts +8 -6
  33. package/src/services/claude-cli.ts +106 -19
  34. package/src/services/claude-settings.ts +84 -36
  35. package/src/services/plugin-env-needs.ts +69 -0
  36. package/src/services/plugin-manager.ts +31 -19
  37. package/src/services/plugin-resolution.ts +34 -42
  38. package/src/services/profile-activate.ts +1 -2
  39. package/src/services/profile-startup.ts +18 -43
  40. package/src/services/profile-state.ts +72 -2
  41. package/src/services/resolver.ts +95 -1
  42. package/src/services/update-engine.ts +326 -31
  43. package/src/services/update-plan.ts +293 -33
  44. package/src/types/index.ts +7 -0
  45. package/src/ui/App.tsx +90 -146
  46. package/src/ui/adapters/cliToolsAdapter.ts +203 -1
  47. package/src/ui/adapters/failureReport.ts +137 -0
  48. package/src/ui/adapters/pluginsAdapter.ts +297 -45
  49. package/src/ui/adapters/startupReports.ts +282 -0
  50. package/src/ui/components/modals/FailureListModal.tsx +231 -0
  51. package/src/ui/components/modals/MessageModal.tsx +7 -12
  52. package/src/ui/components/modals/ModalContainer.tsx +10 -5
  53. package/src/ui/components/modals/index.ts +1 -0
  54. package/src/ui/hooks/startupQueue.ts +56 -0
  55. package/src/ui/hooks/useGitignoreModal.ts +30 -52
  56. package/src/ui/hooks/useProfileStartupModal.ts +119 -142
  57. package/src/ui/hooks/useStaleLoadModal.ts +35 -65
  58. package/src/ui/keymap.ts +14 -1
  59. package/src/ui/renderers/mcpRenderers.tsx +110 -3
  60. package/src/ui/renderers/pluginRenderers.tsx +77 -6
  61. package/src/ui/renderers/pluginRowText.ts +25 -25
  62. package/src/ui/renderers/pluginStatusText.ts +18 -1
  63. package/src/ui/screens/AliasScreen.tsx +38 -45
  64. package/src/ui/screens/CliToolsScreen.tsx +65 -54
  65. package/src/ui/screens/EnvVarsScreen.tsx +17 -5
  66. package/src/ui/screens/GitignoreScreen.tsx +48 -67
  67. package/src/ui/screens/McpRegistryScreen.tsx +67 -38
  68. package/src/ui/screens/McpScreen.tsx +46 -19
  69. package/src/ui/screens/ModelsScreen.tsx +46 -86
  70. package/src/ui/screens/PluginsScreen.tsx +313 -221
  71. package/src/ui/screens/ProfilesScreen.tsx +79 -40
  72. package/src/ui/screens/SkillsScreen.tsx +83 -80
  73. package/src/ui/screens/StylesScreen.tsx +211 -275
  74. package/src/ui/state/AppContext.tsx +66 -44
  75. package/src/ui/state/DimensionsContext.tsx +3 -22
  76. package/src/ui/state/reducer.ts +12 -100
  77. package/src/ui/state/types.ts +48 -69
  78. package/src/utils/user-path.ts +60 -0
  79. package/src/__tests__/notices-rules.test.ts +0 -476
  80. package/src/__tests__/notices.test.tsx +0 -604
  81. package/src/__tests__/styles-status-line.test.ts +0 -72
  82. package/src/__tests__/version-snapshot.test.ts +0 -212
  83. package/src/services/version-snapshot.ts +0 -189
@@ -0,0 +1,137 @@
1
+ /**
2
+ * THE failure popup's words, for every screen and every kind of subject.
3
+ *
4
+ * The TUI shows no status lines, notices or success messages: a screen reloads
5
+ * and shows state. What the user must KNOW because something failed — or must
6
+ * ACT on — goes into ONE popup (`modal.failures`, `FailureListModal`), and its
7
+ * shape is built here: a headline, one row per thing that failed (a plugin, a
8
+ * marketplace, a skill, a file, the profile), and the one fix when every row
9
+ * shares it. Pure, so the words are tested without a renderer.
10
+ *
11
+ * Each domain has a builder on top of {@link failureReport} —
12
+ * `pluginFailureReport` (pluginsAdapter.ts), `activationFailureReport` and the
13
+ * startup and Git State builders (startupReports.ts) — so they all read the
14
+ * same way.
15
+ */
16
+
17
+ import type { FailureReport, FailureRow } from "../state/types.js";
18
+
19
+ /**
20
+ * A fix a reason names at its end — "… — run magus install", "… — fix it by
21
+ * hand". Taken off the reason and carried as the row's fix, so it is said once
22
+ * (under the rows when every row shares it) rather than inside every reason.
23
+ */
24
+ const FIX_CLAUSE =
25
+ / — ((?:run|update it|ask them|fix it|remove|install|check|compare|commit)\b.*)$/;
26
+
27
+ /** `run magus install` → `Run magus install`. */
28
+ function sentence(text: string): string {
29
+ return `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
30
+ }
31
+
32
+ /**
33
+ * An executable named by absolute path — `/usr/local/bin/npm`,
34
+ * `/Users/someone/.local/bin/claude` — is the mechanism: which copy the
35
+ * machine happened to find. The user knows it by its name.
36
+ */
37
+ const BINARY_PATH = /(?:\/[^\s'"/:]+)+\/bin\/([\w.-]+)/g;
38
+
39
+ /** Every absolute binary path in `text` replaced by the binary's name. */
40
+ export function withoutBinaryPaths(text: string): string {
41
+ return text.replace(BINARY_PATH, "$1");
42
+ }
43
+
44
+ /**
45
+ * Split a reason into what went wrong and what to do about it, when the reason
46
+ * ends in a fix clause ({@link FIX_CLAUSE}). A row's own `fix` wins.
47
+ */
48
+ export function splitFix(row: FailureRow): FailureRow {
49
+ if (row.fix) return row;
50
+ const match = FIX_CLAUSE.exec(row.reason);
51
+ if (!match?.[1]) return row;
52
+ return {
53
+ subject: row.subject,
54
+ reason: row.reason.slice(0, match.index).trim() || row.reason,
55
+ fix: sentence(match[1]),
56
+ };
57
+ }
58
+
59
+ export interface FailureReportOptions {
60
+ /**
61
+ * Merge rows whose reason AND fix are identical into one row naming every
62
+ * subject (default true). A caller with its own grouping rules — the plugin
63
+ * builder keeps unexplained exceptions apart — passes false.
64
+ */
65
+ group?: boolean;
66
+ }
67
+
68
+ /**
69
+ * The popup for `rows`, headed by `headline`; null when nothing failed — a
70
+ * success says nothing, because the reloaded screen shows it.
71
+ *
72
+ * Fixes: a fix clause at the end of a reason becomes the row's fix. When every
73
+ * row has the same fix, it is the report's `fix`, said once under the rows,
74
+ * and taken off each row; otherwise each row keeps its own (or none).
75
+ */
76
+ export function failureReport(
77
+ headline: string,
78
+ rows: readonly FailureRow[],
79
+ opts: FailureReportOptions = {},
80
+ ): FailureReport | null {
81
+ if (rows.length === 0) return null;
82
+ let out = rows.map(splitFix);
83
+
84
+ if (opts.group !== false) {
85
+ const grouped: FailureRow[] = [];
86
+ const byKey = new Map<string, FailureRow>();
87
+ for (const row of out) {
88
+ const key = `${row.reason}\u0000${row.fix ?? ""}`;
89
+ const existing = byKey.get(key);
90
+ if (existing) {
91
+ existing.subject = `${existing.subject}, ${row.subject}`;
92
+ continue;
93
+ }
94
+ const copy = { ...row };
95
+ byKey.set(key, copy);
96
+ grouped.push(copy);
97
+ }
98
+ out = grouped;
99
+ }
100
+
101
+ const [first] = out;
102
+ const shared =
103
+ first?.fix && out.every((row) => row.fix === first.fix) ? first.fix : null;
104
+ if (shared) {
105
+ out = out.map(({ fix: _fix, ...row }) => row);
106
+ }
107
+ return { headline, rows: out, fix: shared };
108
+ }
109
+
110
+ /**
111
+ * Several reports about ONE event, as one popup under `headline` — so a user
112
+ * reads one list, not two popups in a row. A single report passes through
113
+ * with its own headline; none is null.
114
+ *
115
+ * Each report's shared fix goes back onto its own rows first, so no row loses
116
+ * its fix; the merged report then says a fix once again only when every row
117
+ * shares it.
118
+ */
119
+ export function mergeFailureReports(
120
+ headline: string,
121
+ reports: ReadonlyArray<FailureReport | null | undefined>,
122
+ ): FailureReport | null {
123
+ const present = reports.filter((r): r is FailureReport => !!r);
124
+ const [only] = present;
125
+ if (present.length <= 1) return only ?? null;
126
+ const rows = present.flatMap((report) =>
127
+ report.rows.map((row) =>
128
+ row.fix || !report.fix ? row : { ...row, fix: report.fix },
129
+ ),
130
+ );
131
+ return failureReport(headline, rows, { group: false });
132
+ }
133
+
134
+ /** `1 plugin`, `3 plugins`. */
135
+ export function counted(n: number, noun: string, plural = `${noun}s`): string {
136
+ return `${n} ${n === 1 ? noun : plural}`;
137
+ }
@@ -1,10 +1,26 @@
1
1
  import type { PluginInfo } from "../../services/plugin-manager.js";
2
2
  import type { PluginComponent } from "../../services/skills-manager.js";
3
- import type { ApplyReporter } from "../../services/update-engine.js";
4
- import type { PluginUpdateItem } from "../../services/update-plan.js";
3
+ import {
4
+ type ApplyReporter,
5
+ type FinishCause,
6
+ pluginName,
7
+ } from "../../services/update-engine.js";
8
+ import type {
9
+ PluginUpdateAction,
10
+ PluginUpdateItem,
11
+ } from "../../services/update-plan.js";
5
12
  import type { Marketplace } from "../../types/index.js";
6
- import type { PluginContentsEntry } from "../state/types.js";
13
+ import type {
14
+ FailureReport,
15
+ FailureRow,
16
+ PluginContentsEntry,
17
+ } from "../state/types.js";
7
18
  import type { BadgeTone } from "../theme.js";
19
+ import {
20
+ failureReport,
21
+ splitFix,
22
+ withoutBinaryPaths,
23
+ } from "./failureReport.js";
8
24
 
9
25
  // Virtual marketplace name for the community sub-section of claude-plugins-official
10
26
  export const COMMUNITY_VIRTUAL_MARKETPLACE =
@@ -46,6 +62,12 @@ export interface PluginPluginItem {
46
62
  * declared by the profile nor installed): it has no state to claim.
47
63
  */
48
64
  status: PluginUpdateItem | null;
65
+ /**
66
+ * How many variables its MCP server references and nothing sets — from
67
+ * current config (`missingPluginEnvVars`), so it is gone once they are set.
68
+ * Absent means none, or not checked.
69
+ */
70
+ mcpSettingsNeeded?: number;
49
71
  }
50
72
 
51
73
  /**
@@ -98,6 +120,8 @@ export interface BuildPluginBrowserItemsArgs {
98
120
  pluginContents?: Map<string, PluginContentsEntry>;
99
121
  /** `classifyPluginRows` output, by plugin id. A plugin absent from it gets no status. */
100
122
  statuses?: ReadonlyMap<string, PluginUpdateItem>;
123
+ /** Plugin id → variables its MCP server still needs; see `mcpSettingsNeeded`. */
124
+ mcpSettingsNeeded?: ReadonlyMap<string, number>;
101
125
  }
102
126
 
103
127
  /**
@@ -123,10 +147,12 @@ function pushPluginItems(
123
147
  expandedPlugins: Set<string>,
124
148
  pluginContents: Map<string, PluginContentsEntry>,
125
149
  statuses: ReadonlyMap<string, PluginUpdateItem>,
150
+ mcpSettingsNeeded: ReadonlyMap<string, number>,
126
151
  ): void {
127
152
  const repo = pluginContentsRepo(plugin, marketplace);
128
153
  const isExpandable = !plugin.isOrphaned && !!repo;
129
154
  const isExpanded = isExpandable && expandedPlugins.has(plugin.id);
155
+ const needed = mcpSettingsNeeded.get(plugin.id) ?? 0;
130
156
 
131
157
  items.push({
132
158
  id: `pl:${plugin.id}`,
@@ -136,6 +162,7 @@ function pushPluginItems(
136
162
  isExpandable,
137
163
  isExpanded,
138
164
  status: statuses.get(plugin.id) ?? null,
165
+ ...(needed > 0 ? { mcpSettingsNeeded: needed } : {}),
139
166
  });
140
167
 
141
168
  if (!isExpanded) return;
@@ -229,63 +256,284 @@ function isMadAppGang(mp: Marketplace): boolean {
229
256
  return (mp.source.repo ?? "").toLowerCase().startsWith("madappgang/");
230
257
  }
231
258
 
232
- // ─── "Ran, but nothing moved" ─────────────────────────────────────────────────
259
+ // ─── "It failed, and here is why" ────────────────────────────────────────────
260
+
261
+ /** A version token — `v2.0.3`, `8.1.3`, `1.0.0-beta.2`. */
262
+ const VERSION = String.raw`v?\d+(?:\.\d+)+(?:[-+][0-9A-Za-z.-]+)?`;
263
+
264
+ /**
265
+ * Where a version sat in a reason, and the noun that keeps the sentence true
266
+ * without it. Checked in order; the last is the fallback.
267
+ */
268
+ const VERSION_NOUNS: ReadonlyArray<[RegExp, string]> = [
269
+ [
270
+ new RegExp(`^${VERSION} is not downloaded`),
271
+ "that version is not downloaded",
272
+ ],
273
+ [new RegExp(`\\binstalled ${VERSION}`, "g"), "installed the new version"],
274
+ [
275
+ new RegExp(`\\bstill loads ${VERSION}`, "g"),
276
+ "still loads an older version",
277
+ ],
278
+ [new RegExp(`\\bnow loads ${VERSION}`, "g"), "now loads the new version"],
279
+ [
280
+ new RegExp(`\\bdoes not load ${VERSION}`, "g"),
281
+ "does not load the installed version",
282
+ ],
283
+ [new RegExp(`\\bis still ${VERSION}`, "g"), "is still on the same version"],
284
+ [new RegExp(`\\bwent to ${VERSION}`, "g"), "went to another version"],
285
+ [
286
+ new RegExp(`\\bnot the ${VERSION} this update asked for`, "g"),
287
+ "not the one this update asked for",
288
+ ],
289
+ [new RegExp(VERSION, "g"), "another version"],
290
+ ];
291
+
292
+ /**
293
+ * A reason with each version number replaced by a noun that keeps it true —
294
+ * the grouping key, and the shared line's words when the plugins in a group
295
+ * sit at different versions. "installed v2.0.6, but Claude Code still loads
296
+ * v2.0.3 in this folder" and the same at v1.4.0 are one reason, said as
297
+ * "installed the new version, but Claude Code still loads an older version in
298
+ * this folder". Deleting the versions instead produced "still loads in this
299
+ * folder", which is not a sentence about anything.
300
+ */
301
+ export function reasonWithoutVersions(reason: string): string {
302
+ let out = reason;
303
+ for (const [pattern, noun] of VERSION_NOUNS) out = out.replace(pattern, noun);
304
+ return out.replace(/\s{2,}/g, " ").trim();
305
+ }
306
+
307
+ /**
308
+ * What a plugin failure did not do: the headline's verb. `repaired` is the
309
+ * startup "Repair plugins", which brings a stale load forward and installs
310
+ * nothing.
311
+ */
312
+ export type FailureVerb = "installed" | "updated" | "removed" | "repaired";
313
+
314
+ /** The verb for a plan action — an install, a removal, or anything that updates. */
315
+ export function failureVerb(
316
+ action: PluginUpdateAction | undefined,
317
+ ): FailureVerb {
318
+ if (action === "install") return "installed";
319
+ if (action === "remove") return "removed";
320
+ return "updated";
321
+ }
322
+
323
+ /** One plugin that failed, as the engine reported it. */
324
+ export interface PluginFailure {
325
+ pluginId: string;
326
+ verb: FailureVerb;
327
+ /** The engine's reason — the SAME string `magus update` prints for it. */
328
+ detail: string;
329
+ /** The detail is a thrown error's message, not a reason the engine worded. */
330
+ exception?: boolean;
331
+ /**
332
+ * The command ran and exited cleanly, but nothing moved — the engine's
333
+ * `unchanged`, detail "no change — …". Not an error, and not a change
334
+ * either: the plugin was not updated, which is what the popup says.
335
+ */
336
+ unchanged?: boolean;
337
+ }
338
+
339
+ /** The fix for a machine with no `claude` on PATH — said once, not per row. */
340
+ export const INSTALL_CLAUDE_FIX =
341
+ "Install Claude Code: npm install -g @anthropic-ai/claude-code";
342
+ /** The fix for a transient network failure. */
343
+ export const RETRY_NETWORK_FIX = "Check your network connection and try again";
344
+
345
+ /** A row's reason when there is no `claude` to run — one wording everywhere. */
346
+ export const CLAUDE_MISSING_REASON = "claude CLI not found in PATH";
347
+
348
+ /**
349
+ * No `claude` to run, in each shape it arrives: magus's own PATH lookup, and
350
+ * the spawn failing because the binary is gone — Node's `spawn <path> ENOENT`,
351
+ * Bun's `Executable not found in $PATH`.
352
+ */
353
+ const CLAUDE_MISSING =
354
+ /^claude CLI not found in PATH|^spawn (?:\S*\/)?claude ENOENT\b|^Executable not found in \$PATH: "?claude"?$/i;
355
+ const NETWORK =
356
+ /\b(?:ECONNRESET|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|network|Could not resolve host)\b/i;
357
+
358
+ /**
359
+ * An unexpected exception, as a row: its first line only — no stack, no raw
360
+ * command output — and, for the kinds magus recognises, the fix. `known` is
361
+ * true for those kinds: a machine-wide cause (no `claude`, no network) is the
362
+ * same failure for every plugin it hits, so they may share a row; an
363
+ * exception magus cannot explain keeps a row of its own.
364
+ */
365
+ export function exceptionReason(message: string): {
366
+ reason: string;
367
+ fix?: string;
368
+ known: boolean;
369
+ } {
370
+ const first = withoutBinaryPaths(message.split("\n")[0]?.trim() || "failed");
371
+ if (CLAUDE_MISSING.test(first)) {
372
+ // The message carries its own install hint; the fix line says it, once.
373
+ return {
374
+ reason: CLAUDE_MISSING_REASON,
375
+ fix: INSTALL_CLAUDE_FIX,
376
+ known: true,
377
+ };
378
+ }
379
+ if (NETWORK.test(first)) {
380
+ return { reason: first, fix: RETRY_NETWORK_FIX, known: true };
381
+ }
382
+ return { reason: first, known: false };
383
+ }
384
+
385
+ /**
386
+ * The engine's "no change — …" detail for an item that ran and did not move,
387
+ * in the user's terms: the same fact `magus update` lists under "N item(s) ran
388
+ * but did not change", without "the installer exited 0".
389
+ */
390
+ export function unchangedReason(detail: string): string {
391
+ let text = detail.replace(/^no change — /, "");
392
+ let note = "";
393
+ const paren = /\s\((.*)\)$/.exec(text);
394
+ if (paren) {
395
+ note = paren[1] ?? "";
396
+ text = text.slice(0, paren.index);
397
+ }
398
+ text = text
399
+ .replace(
400
+ /^the installer exited 0 but \S+ is still (\S+)$/,
401
+ (_, v: string) =>
402
+ `it ran, but it is still ${v.startsWith("v") ? v : `v${v}`}`,
403
+ )
404
+ .replace(
405
+ /^the installer exited 0 but no version of \S+ was recorded$/,
406
+ "it ran, but no installed version of it can be found afterwards",
407
+ );
408
+ return note ? `${text}; ${note}` : text;
409
+ }
233
410
 
234
411
  /**
235
- * Collect the engine's no-move reasons so the screen can name them.
412
+ * THE plugin failure popup's words — for `a`, a single update, a scope key,
413
+ * a removal and a repair alike, so every apply path on the Plugins screen
414
+ * reports a failure the same way. Null when nothing failed: a success says
415
+ * nothing, because the reloaded rows show it.
236
416
  *
237
- * `ApplyResult.unchanged` is the whole point of the read-back: an item that
238
- * ran, exited 0, and left the machine exactly as it was. The CLI prints
239
- * `N item(s) ran but did not change:` with each cause; the TUI used to branch
240
- * on `failed.length` alone, show a success, and refetch a row that had not
241
- * moved — "press U, press U, forever", which is the experience the read-back
242
- * was added to remove.
417
+ * Each reason is the engine's, which is the string the CLI prints on that
418
+ * item's `✗` line, so the two front ends name the same cause in the same
419
+ * words. Plugins failing for the same reason share a row (versions replaced by
420
+ * nouns when they differ). An exception keeps only its first line; one magus
421
+ * recognises (no `claude`, no network) carries its fix and shares a row with
422
+ * the same message, one it cannot explain keeps a row of its own. An item that
423
+ * ran and changed nothing is "not updated", with the reason it did not move.
424
+ */
425
+ export function pluginFailureReport(
426
+ failures: readonly PluginFailure[],
427
+ ): FailureReport | null {
428
+ if (failures.length === 0) return null;
429
+
430
+ interface Group {
431
+ names: string[];
432
+ reasons: string[];
433
+ fix?: string;
434
+ }
435
+ const rows: Group[] = [];
436
+ const groups = new Map<string, Group>();
437
+ const add = (
438
+ key: string | null,
439
+ name: string,
440
+ reason: string,
441
+ fix?: string,
442
+ ) => {
443
+ const group = key === null ? undefined : groups.get(key);
444
+ if (group) {
445
+ group.names.push(name);
446
+ group.reasons.push(reason);
447
+ return;
448
+ }
449
+ const created: Group = { names: [name], reasons: [reason], fix };
450
+ if (key !== null) groups.set(key, created);
451
+ rows.push(created);
452
+ };
453
+
454
+ for (const f of failures) {
455
+ const name = pluginName(f.pluginId);
456
+ if (f.exception) {
457
+ const e = exceptionReason(f.detail);
458
+ add(e.known ? `exception:${e.reason}` : null, name, e.reason, e.fix);
459
+ continue;
460
+ }
461
+ const split = splitFix({
462
+ subject: name,
463
+ reason: f.unchanged ? unchangedReason(f.detail) : f.detail,
464
+ });
465
+ const key = `${reasonWithoutVersions(split.reason)}\u0000${split.fix ?? ""}`;
466
+ add(key, name, split.reason || "failed", split.fix);
467
+ }
468
+
469
+ const out: FailureRow[] = rows.map((group) => {
470
+ const [first = ""] = group.reasons;
471
+ const reason = group.reasons.every((r) => r === first)
472
+ ? first
473
+ : reasonWithoutVersions(first);
474
+ return {
475
+ subject: group.names.join(", "),
476
+ reason: reason || "failed",
477
+ ...(group.fix ? { fix: group.fix } : {}),
478
+ };
479
+ });
480
+
481
+ const verbs = [...new Set(failures.map((f) => f.verb))];
482
+ const [only] = failures;
483
+ const headline =
484
+ failures.length === 1 && only
485
+ ? `${pluginName(only.pluginId)} was not ${only.verb}`
486
+ : `${failures.length} plugins were not ${verbs.join(" or ")}`;
487
+ return failureReport(headline, out, { group: false });
488
+ }
489
+
490
+ /**
491
+ * Collect why each plugin in one apply failed, for {@link pluginFailureReport}.
243
492
  *
244
- * The cause itself only ever reaches the reporter (`applyPlugins` formats it as
245
- * `no change — <why>`), so this captures it there, exactly as `cli/update.ts`
246
- * does with `finishedDetail`.
493
+ * `applyPlugins` passes each item's detail to `report.finish` — already in the
494
+ * user's terms, the same string the CLI prints. Only failures and items that
495
+ * ran without changing are reported: a success shows as the plugin's new
496
+ * state once the screen reloads.
247
497
  */
248
- export function noMoveReasons(): {
498
+ export function failureReasons(): {
249
499
  reporter: ApplyReporter;
250
500
  /**
251
- * One line per unchanged item, with its cause when one was reported, so one
252
- * plugin's reason never runs into the next. The screen posts
253
- * {@link notice} instead; this is the per-item form it is built from.
501
+ * The popup for `failed` and then `unchanged`, in that order; null when
502
+ * both are empty.
254
503
  */
255
- lines(unchanged: readonly string[]): string[];
256
- /**
257
- * The same, as ONE notice line, or null when nothing was unchanged. "Update
258
- * all" can leave many plugins where they were, and a line each would fill
259
- * the banner and push out whatever else it had to say. So several become
260
- * `3 plugins unchanged — a, b, c`, with the cause only when they all share
261
- * it; one keeps its own cause.
262
- */
263
- notice(unchanged: readonly string[]): string | null;
504
+ report(
505
+ failed: readonly string[],
506
+ actions?: ReadonlyMap<string, PluginUpdateAction>,
507
+ unchanged?: readonly string[],
508
+ ): FailureReport | null;
264
509
  } {
265
- const why = new Map<string, string>();
266
- const lines = (unchanged: readonly string[]): string[] =>
267
- unchanged.map((name) => {
268
- const reason = why.get(name);
269
- return reason ? `${name}: ${reason}` : name;
270
- });
510
+ const why = new Map<string, { detail: string; cause?: FinishCause }>();
271
511
  return {
272
512
  reporter: {
273
513
  begin: () => {},
274
- finish: (_ok, name, detail) => {
275
- const reason = detail.replace(/^no change — /, "");
276
- if (reason !== detail) why.set(name, reason);
514
+ finish: (_ok, name, detail, _ms, cause) => {
515
+ why.set(name, { detail, cause });
277
516
  },
278
517
  detach: (work) => work(),
279
518
  },
280
- lines,
281
- notice: (unchanged) => {
282
- if (unchanged.length === 0) return null;
283
- if (unchanged.length === 1) return `No change: ${lines(unchanged)[0]}`;
284
- const reasons = new Set(unchanged.map((name) => why.get(name)));
285
- const [shared] = reasons;
286
- const cause = reasons.size === 1 && shared ? `: ${shared}` : "";
287
- return `${unchanged.length} plugins unchanged — ${unchanged.join(", ")}${cause}`;
288
- },
519
+ report: (failed, actions, unchanged = []) =>
520
+ pluginFailureReport([
521
+ ...failed.map((pluginId) => {
522
+ const captured = why.get(pluginId);
523
+ return {
524
+ pluginId,
525
+ verb: failureVerb(actions?.get(pluginId)),
526
+ detail: captured?.detail ?? "",
527
+ exception: captured?.cause === "exception",
528
+ };
529
+ }),
530
+ ...unchanged.map((pluginId) => ({
531
+ pluginId,
532
+ verb: failureVerb(actions?.get(pluginId)),
533
+ detail: why.get(pluginId)?.detail ?? "",
534
+ unchanged: true,
535
+ })),
536
+ ]),
289
537
  };
290
538
  }
291
539
 
@@ -300,6 +548,7 @@ export function buildPluginBrowserItems({
300
548
  expandedPlugins = new Set(),
301
549
  pluginContents = new Map(),
302
550
  statuses = new Map(),
551
+ mcpSettingsNeeded = new Map(),
303
552
  }: BuildPluginBrowserItemsArgs): PluginBrowserItem[] {
304
553
  const pluginsByMarketplace = new Map<string, PluginInfo[]>();
305
554
  for (const plugin of plugins) {
@@ -356,6 +605,7 @@ export function buildPluginBrowserItems({
356
605
  expandedPlugins,
357
606
  pluginContents,
358
607
  statuses,
608
+ mcpSettingsNeeded,
359
609
  );
360
610
  }
361
611
  }
@@ -394,6 +644,7 @@ export function buildPluginBrowserItems({
394
644
  expandedPlugins,
395
645
  pluginContents,
396
646
  statuses,
647
+ mcpSettingsNeeded,
397
648
  );
398
649
  }
399
650
  }
@@ -426,6 +677,7 @@ export function buildPluginBrowserItems({
426
677
  expandedPlugins,
427
678
  pluginContents,
428
679
  statuses,
680
+ mcpSettingsNeeded,
429
681
  );
430
682
  }
431
683
  }