magus-cli 7.6.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 +9 -34
  16. package/src/__tests__/gitignore-profile.test.ts +0 -4
  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 -42
  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/gitignore-service.ts +0 -19
  36. package/src/services/plugin-env-needs.ts +69 -0
  37. package/src/services/plugin-manager.ts +31 -19
  38. package/src/services/plugin-resolution.ts +34 -42
  39. package/src/services/profile-absorb.ts +9 -0
  40. package/src/services/profile-activate.ts +1 -1
  41. package/src/services/profile-startup.ts +30 -27
  42. package/src/services/profile-state.ts +81 -2
  43. package/src/services/resolver.ts +95 -1
  44. package/src/services/update-engine.ts +326 -31
  45. package/src/services/update-plan.ts +293 -33
  46. package/src/types/index.ts +7 -0
  47. package/src/ui/App.tsx +186 -123
  48. package/src/ui/adapters/cliToolsAdapter.ts +203 -1
  49. package/src/ui/adapters/failureReport.ts +137 -0
  50. package/src/ui/adapters/pluginsAdapter.ts +299 -28
  51. package/src/ui/adapters/startupReports.ts +282 -0
  52. package/src/ui/components/modals/FailureListModal.tsx +231 -0
  53. package/src/ui/components/modals/MessageModal.tsx +7 -13
  54. package/src/ui/components/modals/ModalContainer.tsx +10 -5
  55. package/src/ui/components/modals/index.ts +1 -0
  56. package/src/ui/hooks/startupQueue.ts +56 -0
  57. package/src/ui/hooks/useGitignoreModal.ts +31 -31
  58. package/src/ui/hooks/useProfileStartupModal.ts +127 -122
  59. package/src/ui/hooks/useStaleLoadModal.ts +33 -53
  60. package/src/ui/keymap.ts +14 -1
  61. package/src/ui/renderers/mcpRenderers.tsx +110 -3
  62. package/src/ui/renderers/pluginRenderers.tsx +77 -6
  63. package/src/ui/renderers/pluginRowText.ts +25 -25
  64. package/src/ui/renderers/pluginStatusText.ts +18 -1
  65. package/src/ui/screens/AliasScreen.tsx +38 -44
  66. package/src/ui/screens/CliToolsScreen.tsx +65 -51
  67. package/src/ui/screens/EnvVarsScreen.tsx +21 -6
  68. package/src/ui/screens/GitignoreScreen.tsx +49 -75
  69. package/src/ui/screens/McpRegistryScreen.tsx +72 -40
  70. package/src/ui/screens/McpScreen.tsx +55 -24
  71. package/src/ui/screens/ModelsScreen.tsx +46 -86
  72. package/src/ui/screens/PluginsScreen.tsx +316 -245
  73. package/src/ui/screens/ProfilesScreen.tsx +88 -73
  74. package/src/ui/screens/SkillsScreen.tsx +83 -80
  75. package/src/ui/screens/StylesScreen.tsx +211 -275
  76. package/src/ui/state/AppContext.tsx +72 -7
  77. package/src/ui/state/DimensionsContext.tsx +3 -22
  78. package/src/ui/state/reducer.ts +25 -14
  79. package/src/ui/state/types.ts +61 -31
  80. package/src/utils/user-path.ts +60 -0
  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
@@ -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,44 +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
- /** One line per unchanged item, with its cause when one was reported. */
251
- lines(unchanged: readonly string[]): string;
500
+ /**
501
+ * The popup for `failed` and then `unchanged`, in that order; null when
502
+ * both are empty.
503
+ */
504
+ report(
505
+ failed: readonly string[],
506
+ actions?: ReadonlyMap<string, PluginUpdateAction>,
507
+ unchanged?: readonly string[],
508
+ ): FailureReport | null;
252
509
  } {
253
- const why = new Map<string, string>();
510
+ const why = new Map<string, { detail: string; cause?: FinishCause }>();
254
511
  return {
255
512
  reporter: {
256
513
  begin: () => {},
257
- finish: (_ok, name, detail) => {
258
- const reason = detail.replace(/^no change — /, "");
259
- if (reason !== detail) why.set(name, reason);
514
+ finish: (_ok, name, detail, _ms, cause) => {
515
+ why.set(name, { detail, cause });
260
516
  },
261
517
  detach: (work) => work(),
262
518
  },
263
- lines: (unchanged) =>
264
- unchanged
265
- .map((name) => {
266
- const reason = why.get(name);
267
- return reason ? `${name}: ${reason}` : name;
268
- })
269
- .join("\n"),
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
+ ]),
270
537
  };
271
538
  }
272
539
 
@@ -281,6 +548,7 @@ export function buildPluginBrowserItems({
281
548
  expandedPlugins = new Set(),
282
549
  pluginContents = new Map(),
283
550
  statuses = new Map(),
551
+ mcpSettingsNeeded = new Map(),
284
552
  }: BuildPluginBrowserItemsArgs): PluginBrowserItem[] {
285
553
  const pluginsByMarketplace = new Map<string, PluginInfo[]>();
286
554
  for (const plugin of plugins) {
@@ -337,6 +605,7 @@ export function buildPluginBrowserItems({
337
605
  expandedPlugins,
338
606
  pluginContents,
339
607
  statuses,
608
+ mcpSettingsNeeded,
340
609
  );
341
610
  }
342
611
  }
@@ -375,6 +644,7 @@ export function buildPluginBrowserItems({
375
644
  expandedPlugins,
376
645
  pluginContents,
377
646
  statuses,
647
+ mcpSettingsNeeded,
378
648
  );
379
649
  }
380
650
  }
@@ -407,6 +677,7 @@ export function buildPluginBrowserItems({
407
677
  expandedPlugins,
408
678
  pluginContents,
409
679
  statuses,
680
+ mcpSettingsNeeded,
410
681
  );
411
682
  }
412
683
  }
@@ -0,0 +1,282 @@
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
+ * M8: marketplaces whose refresh failed after startup re-registered them.
217
+ * Until one refreshes, its plugins are read from a copy that may be old.
218
+ */
219
+ export function marketplaceRefreshReport(
220
+ failures: ReadonlyArray<{ marketplace: string; error: unknown }>,
221
+ ): FailureReport | null {
222
+ return failureReport(
223
+ `${counted(failures.length, "marketplace")} could not be refreshed`,
224
+ failures.map(({ marketplace, error }) => {
225
+ const e = exceptionReason(
226
+ error instanceof Error ? error.message : String(error),
227
+ );
228
+ return {
229
+ subject: marketplace,
230
+ reason: e.reason,
231
+ ...(e.fix ? { fix: e.fix } : {}),
232
+ };
233
+ }),
234
+ );
235
+ }
236
+
237
+ /**
238
+ * M9: generated files the startup gitignore fix could not untrack, each with
239
+ * git's own reason.
240
+ */
241
+ export function gitignoreStartupReport(
242
+ failed: ReadonlyArray<{ path: string; reason: string }>,
243
+ ): FailureReport | null {
244
+ return failureReport(
245
+ `${counted(failed.length, "generated file")} ${failed.length === 1 ? "is" : "are"} still tracked by git`,
246
+ failed.map((f) => ({
247
+ subject: f.path,
248
+ reason: exceptionReason(f.reason).reason,
249
+ })),
250
+ );
251
+ }
252
+
253
+ /** Git State fixes that did not apply, each with its reason. */
254
+ export function gitignoreFixReport(
255
+ failed: ReadonlyArray<{ path: string; message: string }>,
256
+ ): FailureReport | null {
257
+ return failureReport(
258
+ `${counted(failed.length, "fix", "fixes")} did not apply`,
259
+ failed.map((f) => ({
260
+ subject: f.path,
261
+ reason: exceptionReason(f.message).reason,
262
+ })),
263
+ );
264
+ }
265
+
266
+ /**
267
+ * M9: template rules a merge skipped because they contradict a rule the
268
+ * manifest already has. They are not in the list, so this is the only place
269
+ * the user learns of them.
270
+ */
271
+ export function gitignoreTemplateReport(
272
+ template: string,
273
+ conflicts: readonly string[],
274
+ ): FailureReport | null {
275
+ return failureReport(
276
+ `${counted(conflicts.length, "rule")} from "${template}" ${conflicts.length === 1 ? "was" : "were"} not added`,
277
+ conflicts.map((pattern) => ({
278
+ subject: pattern,
279
+ reason: "skipped: conflicts with existing rules",
280
+ })),
281
+ );
282
+ }