magus-cli 7.7.0 → 7.9.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 (140) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -4
  3. package/src/__tests__/blackbox-parity.test.ts +45 -26
  4. package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
  5. package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
  6. package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
  7. package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
  8. package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
  9. package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
  10. package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
  11. package/src/__tests__/blind-780-cli-tools.test.ts +796 -0
  12. package/src/__tests__/blind-780-deprecated.test.ts +631 -0
  13. package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
  14. package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
  15. package/src/__tests__/blind-780-review2.test.ts +1178 -0
  16. package/src/__tests__/cli-apply-seams.test.ts +2 -67
  17. package/src/__tests__/cli-tool-commands.test.ts +131 -6
  18. package/src/__tests__/cli-tools-adapter.test.ts +52 -2
  19. package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
  20. package/src/__tests__/cli-update-view.test.ts +3 -3
  21. package/src/__tests__/deprecated-remove.test.ts +827 -0
  22. package/src/__tests__/deps-adapter.test.ts +191 -0
  23. package/src/__tests__/deps-check-hook.test.ts +220 -0
  24. package/src/__tests__/deps-plan.test.ts +896 -0
  25. package/src/__tests__/deps-report.test.ts +197 -0
  26. package/src/__tests__/doctor-bins.test.ts +134 -36
  27. package/src/__tests__/doctor-json.test.ts +261 -0
  28. package/src/__tests__/doctor.test.ts +159 -1
  29. package/src/__tests__/failure-popup-review2.test.tsx +896 -0
  30. package/src/__tests__/failure-popup.test.tsx +419 -0
  31. package/src/__tests__/final-fixes-780.test.tsx +740 -0
  32. package/src/__tests__/fixtures/bins.ts +55 -0
  33. package/src/__tests__/format-update-outcome.test.ts +6 -32
  34. package/src/__tests__/gitignore-profile.test.ts +1 -9
  35. package/src/__tests__/install-plan.test.ts +15 -1
  36. package/src/__tests__/marketplace-refresh.test.ts +9 -3
  37. package/src/__tests__/models-screen-state.test.ts +18 -40
  38. package/src/__tests__/no-shell-spawn.test.ts +77 -0
  39. package/src/__tests__/npm-package-name.test.ts +6 -9
  40. package/src/__tests__/plugin-requires.test.ts +138 -153
  41. package/src/__tests__/plugin-resolution.test.ts +52 -13
  42. package/src/__tests__/plugin-row-text.test.ts +30 -37
  43. package/src/__tests__/profile-dependencies.test.ts +7 -0
  44. package/src/__tests__/profile-startup-old-format.test.ts +0 -3
  45. package/src/__tests__/profile-startup.test.ts +40 -12
  46. package/src/__tests__/requires-probe.test.ts +858 -0
  47. package/src/__tests__/resolver.test.ts +129 -52
  48. package/src/__tests__/run.test.ts +108 -0
  49. package/src/__tests__/stale-load-repair.test.ts +31 -23
  50. package/src/__tests__/tool-probe.test.ts +1 -1
  51. package/src/__tests__/toolchain.test.ts +123 -52
  52. package/src/__tests__/update-apply.test.ts +13 -29
  53. package/src/__tests__/update-plan.test.ts +24 -18
  54. package/src/__tests__/useStaleLoadModal.test.ts +1 -57
  55. package/src/__tests__/version-unknown-state.test.ts +15 -17
  56. package/src/cli/ansi.ts +2 -7
  57. package/src/cli/deps-view.ts +213 -0
  58. package/src/cli/doctor.ts +320 -54
  59. package/src/cli/install.ts +55 -71
  60. package/src/cli/profile.ts +20 -2
  61. package/src/cli/router.ts +6 -2
  62. package/src/cli/update-view.ts +58 -15
  63. package/src/cli/update.ts +55 -12
  64. package/src/data/cli-tools.ts +24 -9
  65. package/src/services/active-closure.ts +3 -3
  66. package/src/services/catalog-cache-store.ts +8 -6
  67. package/src/services/claude-cli.ts +106 -19
  68. package/src/services/claude-settings.ts +88 -384
  69. package/src/services/cli-tool-commands.ts +104 -3
  70. package/src/services/deps-plan.ts +962 -0
  71. package/src/services/deps-report.ts +265 -0
  72. package/src/services/doctor-bins.ts +115 -43
  73. package/src/services/install-plan.ts +11 -1
  74. package/src/services/local-marketplace.ts +12 -206
  75. package/src/services/marketplace-refresh.ts +4 -4
  76. package/src/services/plugin-env-needs.ts +69 -0
  77. package/src/services/plugin-manager.ts +35 -37
  78. package/src/services/plugin-requires.ts +93 -162
  79. package/src/services/plugin-resolution.ts +34 -42
  80. package/src/services/profile-activate.ts +1 -2
  81. package/src/services/profile-startup.ts +18 -43
  82. package/src/services/profile-state.ts +72 -2
  83. package/src/services/resolver.ts +263 -59
  84. package/src/services/tool-probe.ts +18 -25
  85. package/src/services/toolchain.ts +117 -111
  86. package/src/services/update-engine.ts +345 -45
  87. package/src/services/update-plan.ts +296 -36
  88. package/src/shared/plugin/hooks/bun-missing.sh +39 -0
  89. package/src/shared/plugin/hooks/deps-check.ts +224 -0
  90. package/src/shared/plugin/hooks/lib/requires-probe.ts +1183 -0
  91. package/src/types/index.ts +60 -16
  92. package/src/ui/App.tsx +81 -171
  93. package/src/ui/adapters/cliToolsAdapter.ts +252 -12
  94. package/src/ui/adapters/depsAdapter.ts +129 -0
  95. package/src/ui/adapters/failureReport.ts +137 -0
  96. package/src/ui/adapters/pluginsAdapter.ts +297 -45
  97. package/src/ui/adapters/startupReports.ts +260 -0
  98. package/src/ui/components/modals/FailureListModal.tsx +231 -0
  99. package/src/ui/components/modals/MessageModal.tsx +7 -12
  100. package/src/ui/components/modals/ModalContainer.tsx +10 -5
  101. package/src/ui/components/modals/index.ts +1 -0
  102. package/src/ui/hooks/startupQueue.ts +56 -0
  103. package/src/ui/hooks/useGitignoreModal.ts +30 -52
  104. package/src/ui/hooks/useProfileStartupModal.ts +119 -142
  105. package/src/ui/hooks/useStaleLoadModal.ts +35 -65
  106. package/src/ui/keymap.ts +14 -1
  107. package/src/ui/renderers/cliToolRenderers.tsx +135 -2
  108. package/src/ui/renderers/mcpRenderers.tsx +110 -3
  109. package/src/ui/renderers/pluginRenderers.tsx +77 -6
  110. package/src/ui/renderers/pluginRowText.ts +25 -25
  111. package/src/ui/renderers/pluginStatusText.ts +18 -1
  112. package/src/ui/screens/AliasScreen.tsx +38 -45
  113. package/src/ui/screens/CliToolsScreen.tsx +199 -98
  114. package/src/ui/screens/EnvVarsScreen.tsx +17 -5
  115. package/src/ui/screens/GitignoreScreen.tsx +48 -67
  116. package/src/ui/screens/McpRegistryScreen.tsx +67 -38
  117. package/src/ui/screens/McpScreen.tsx +46 -19
  118. package/src/ui/screens/ModelsScreen.tsx +46 -86
  119. package/src/ui/screens/PluginsScreen.tsx +315 -259
  120. package/src/ui/screens/ProfilesScreen.tsx +79 -40
  121. package/src/ui/screens/SkillsScreen.tsx +83 -80
  122. package/src/ui/screens/StylesScreen.tsx +211 -275
  123. package/src/ui/state/AppContext.tsx +66 -44
  124. package/src/ui/state/DimensionsContext.tsx +3 -22
  125. package/src/ui/state/reducer.ts +12 -100
  126. package/src/ui/state/types.ts +48 -69
  127. package/src/utils/command-utils.ts +4 -5
  128. package/src/utils/run.ts +123 -52
  129. package/src/utils/string-utils.ts +9 -0
  130. package/src/utils/user-path.ts +60 -0
  131. package/src/__tests__/go-module.test.ts +0 -43
  132. package/src/__tests__/notices-rules.test.ts +0 -476
  133. package/src/__tests__/notices.test.tsx +0 -604
  134. package/src/__tests__/plugin-setup.test.ts +0 -1016
  135. package/src/__tests__/shell-script-callers.test.ts +0 -92
  136. package/src/__tests__/styles-status-line.test.ts +0 -72
  137. package/src/__tests__/version-snapshot.test.ts +0 -212
  138. package/src/services/plugin-setup.ts +0 -965
  139. package/src/services/version-snapshot.ts +0 -189
  140. package/src/utils/go-module.ts +0 -36
@@ -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
  }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * The failure popup's words for everything outside the Plugins screen's apply
3
+ * paths: the profile gate at startup, a profile activation, the startup
4
+ * repairs (stale plugin loads, marketplace refreshes, gitignore fixes) and the
5
+ * Git State screen. Each is a pure builder over what the service returned, on
6
+ * top of {@link failureReport}, so they read like every other failure.
7
+ *
8
+ * Each returns null when nothing failed: success says nothing, because the
9
+ * screen shows it.
10
+ */
11
+
12
+ import type { StaleLoadRepairResult } from "../../services/claude-settings.js";
13
+ import type { ActivationResult } from "../../services/profile-activate.js";
14
+ import { SettingsParseError } from "../../services/settings-file.js";
15
+ import { pluginName } from "../../services/update-engine.js";
16
+ import { userFacingPath, withUserFacingPath } from "../../utils/user-path.js";
17
+ import type { FailureReport, FailureRow } from "../state/types.js";
18
+ import {
19
+ counted,
20
+ failureReport,
21
+ mergeFailureReports,
22
+ } from "./failureReport.js";
23
+ import { exceptionReason, pluginFailureReport } from "./pluginsAdapter.js";
24
+
25
+ /** `run magus install` → `Run magus install`. */
26
+ function sentence(text: string): string {
27
+ return `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
28
+ }
29
+
30
+ /**
31
+ * One line the profile gate refused on, as a row. The gate words them
32
+ * "<file> cannot be read (<why>) — fix it by hand; magus left it as it is and
33
+ * did not switch": the file is the subject, the instruction the fix, and the
34
+ * rest the reason. A line of another shape is about the profile itself.
35
+ */
36
+ export function profileProblemRow(problem: string): FailureRow {
37
+ const dash = problem.indexOf(" — ");
38
+ const head = dash >= 0 ? problem.slice(0, dash) : problem;
39
+ const tail = dash >= 0 ? problem.slice(dash + 3) : "";
40
+ const file = /^(\S+) (cannot be read\b.*)$/.exec(head);
41
+ const subject = file?.[1] ?? "profile";
42
+ let reason = file?.[2] ?? head;
43
+ let fix: string | undefined;
44
+ if (tail) {
45
+ const [instruction = "", ...rest] = tail.split("; ");
46
+ fix = sentence(instruction);
47
+ if (rest.length > 0) reason = `${reason} — ${rest.join("; ")}`;
48
+ }
49
+ return fix ? { subject, reason, fix } : { subject, reason };
50
+ }
51
+
52
+ /**
53
+ * H2: what the startup gate refused — `prepareProfileStartup(...).problems`.
54
+ * Shown before the profile question, which it does not replace: the question
55
+ * is still asked about whatever is left.
56
+ */
57
+ export function profileProblemsReport(
58
+ problems: readonly string[],
59
+ headline = "This project's profile was not brought up to date",
60
+ ): FailureReport | null {
61
+ return failureReport(headline, problems.map(profileProblemRow));
62
+ }
63
+
64
+ /**
65
+ * H3: what a profile activation could not do, from its {@link
66
+ * ActivationResult}s — the startup re-render or format update, the startup
67
+ * choice, a Profiles-tab switch. ONE builder for all of them.
68
+ *
69
+ * Listed: plugins that will not load because the profile turns a dependency
70
+ * off; plugins whose dependencies could not be read because they are not
71
+ * downloaded; skills that did not install, with why; a skill that exists in
72
+ * two copies, saying which was kept and what to do. Everything else an
73
+ * activation reports is either done or shown as the screen's state.
74
+ */
75
+ export function activationFailureReport(
76
+ results: ReadonlyArray<ActivationResult | null | undefined>,
77
+ ): FailureReport | null {
78
+ const rows: FailureRow[] = [];
79
+ const seen = new Set<string>();
80
+ const add = (row: FailureRow) => {
81
+ const key = `${row.subject}\u0000${row.reason}`;
82
+ if (seen.has(key)) return;
83
+ seen.add(key);
84
+ rows.push(row);
85
+ };
86
+ let profile: string | null = null;
87
+
88
+ for (const result of results) {
89
+ if (!result) continue;
90
+ profile ??= result.profileId;
91
+ for (const { plugin, dependency } of result.dependencies?.blocked ?? []) {
92
+ add({
93
+ subject: pluginName(plugin),
94
+ reason: `will not load: it depends on ${pluginName(dependency)}, which profile "${result.profileId}" turns off`,
95
+ fix: `Remove "${dependency}": false from the profile to let it load`,
96
+ });
97
+ }
98
+ for (const plugin of result.dependencies?.unreadable ?? []) {
99
+ add({
100
+ subject: pluginName(plugin),
101
+ reason:
102
+ "is not downloaded on this machine, so nothing it depends on was turned on",
103
+ fix: "Run magus install",
104
+ });
105
+ }
106
+ for (const f of result.skills.failed) {
107
+ add({
108
+ subject: `skill ${f.skill}`,
109
+ reason: `was not installed: ${exceptionReason(f.reason).reason}`,
110
+ });
111
+ }
112
+ for (const c of result.conversion.conflicts) {
113
+ add({
114
+ subject: `skill ${c.folder}`,
115
+ reason: `exists in two copies: ${c.kept} is the one in use, the other is at ${c.other}`,
116
+ fix: "Compare the two and delete the one you do not want",
117
+ });
118
+ }
119
+ }
120
+ if (rows.length === 0) return null;
121
+ return failureReport(
122
+ `Profile "${profile}" is active, but ${counted(rows.length, "thing")} did not work`,
123
+ rows,
124
+ );
125
+ }
126
+
127
+ /** The headline when the startup gate and the re-render both have something. */
128
+ export const PROFILE_STARTUP_HEADLINE =
129
+ "This project's profile was not fully brought up to date";
130
+
131
+ /**
132
+ * R8: everything the startup profile gate could not do, as ONE popup — what
133
+ * it refused ({@link profileProblemsReport}) and what its activation could not
134
+ * do ({@link activationFailureReport}). Both come from the same startup step,
135
+ * so they are one event: two popups in a row made the user dismiss twice for
136
+ * one thing. Either alone keeps its own headline.
137
+ */
138
+ export function profileStartupReport(
139
+ problems: readonly string[],
140
+ activations: ReadonlyArray<ActivationResult | null | undefined>,
141
+ ): FailureReport | null {
142
+ return mergeFailureReports(PROFILE_STARTUP_HEADLINE, [
143
+ profileProblemsReport(problems),
144
+ activationFailureReport(activations),
145
+ ]);
146
+ }
147
+
148
+ /**
149
+ * H4: the startup "Repair plugins" — each repair that did not bring the
150
+ * installed version forward, with its own reason.
151
+ */
152
+ export function staleRepairReport(
153
+ results: readonly StaleLoadRepairResult[],
154
+ ): FailureReport | null {
155
+ return pluginFailureReport(
156
+ results
157
+ .filter((r) => !r.ok)
158
+ .map((r) => ({
159
+ pluginId: r.pluginId,
160
+ verb: "repaired" as const,
161
+ detail:
162
+ r.error ??
163
+ `Claude Code still does not load v${r.target} in this folder`,
164
+ })),
165
+ );
166
+ }
167
+
168
+ /** The fix for a settings file that does not parse: magus will not write over it. */
169
+ export const FIX_BY_HAND = "Fix the file by hand, then try again";
170
+
171
+ /**
172
+ * A thrown error from a whole step, as ONE row about `subject`.
173
+ *
174
+ * The reason is the error's first line. A cause magus recognises carries its
175
+ * own fix: no `claude` (a `spawn … claude ENOENT` included), no network, or a
176
+ * settings file that does not parse. That last one is about the FILE, so the
177
+ * file is the row's subject — named the way the user knows it
178
+ * (`.claude/settings.json`, `~/.claude/settings.json`), never by absolute path
179
+ * — because fixing it by hand is the only way on. `fallbackFix` is the fix for
180
+ * anything else: the command to run yourself, say. Without one, an
181
+ * unrecognised error has no fix line, because there is no real action to offer.
182
+ */
183
+ export function stepFailureReport(
184
+ headline: string,
185
+ subject: string,
186
+ error: unknown,
187
+ fallbackFix?: string,
188
+ ): FailureReport {
189
+ if (error instanceof SettingsParseError) {
190
+ const detail = withUserFacingPath(
191
+ exceptionReason(error.detail).reason,
192
+ error.path,
193
+ );
194
+ return {
195
+ headline,
196
+ rows: [
197
+ {
198
+ subject: userFacingPath(error.path),
199
+ reason: `${error.problem} (${detail})`,
200
+ },
201
+ ],
202
+ fix: FIX_BY_HAND,
203
+ };
204
+ }
205
+ const e = exceptionReason(
206
+ error instanceof Error ? error.message : String(error),
207
+ );
208
+ return {
209
+ headline,
210
+ rows: [{ subject, reason: e.reason }],
211
+ fix: e.fix ?? fallbackFix ?? null,
212
+ };
213
+ }
214
+
215
+ /**
216
+ * M9: generated files the startup gitignore fix could not untrack, each with
217
+ * git's own reason.
218
+ */
219
+ export function gitignoreStartupReport(
220
+ failed: ReadonlyArray<{ path: string; reason: string }>,
221
+ ): FailureReport | null {
222
+ return failureReport(
223
+ `${counted(failed.length, "generated file")} ${failed.length === 1 ? "is" : "are"} still tracked by git`,
224
+ failed.map((f) => ({
225
+ subject: f.path,
226
+ reason: exceptionReason(f.reason).reason,
227
+ })),
228
+ );
229
+ }
230
+
231
+ /** Git State fixes that did not apply, each with its reason. */
232
+ export function gitignoreFixReport(
233
+ failed: ReadonlyArray<{ path: string; message: string }>,
234
+ ): FailureReport | null {
235
+ return failureReport(
236
+ `${counted(failed.length, "fix", "fixes")} did not apply`,
237
+ failed.map((f) => ({
238
+ subject: f.path,
239
+ reason: exceptionReason(f.message).reason,
240
+ })),
241
+ );
242
+ }
243
+
244
+ /**
245
+ * M9: template rules a merge skipped because they contradict a rule the
246
+ * manifest already has. They are not in the list, so this is the only place
247
+ * the user learns of them.
248
+ */
249
+ export function gitignoreTemplateReport(
250
+ template: string,
251
+ conflicts: readonly string[],
252
+ ): FailureReport | null {
253
+ return failureReport(
254
+ `${counted(conflicts.length, "rule")} from "${template}" ${conflicts.length === 1 ? "was" : "were"} not added`,
255
+ conflicts.map((pattern) => ({
256
+ subject: pattern,
257
+ reason: "skipped: conflicts with existing rules",
258
+ })),
259
+ );
260
+ }