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.
- package/package.json +4 -4
- package/src/__tests__/blackbox-parity.test.ts +29 -15
- package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
- package/src/__tests__/blind-780-cli-tools.test.ts +808 -0
- package/src/__tests__/blind-780-deprecated.test.ts +631 -0
- package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
- package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
- package/src/__tests__/blind-780-review2.test.ts +1203 -0
- package/src/__tests__/cli-update-view.test.ts +3 -3
- package/src/__tests__/deprecated-remove.test.ts +827 -0
- package/src/__tests__/doctor.test.ts +159 -1
- package/src/__tests__/failure-popup-review2.test.tsx +920 -0
- package/src/__tests__/failure-popup.test.tsx +419 -0
- package/src/__tests__/final-fixes-780.test.tsx +724 -0
- package/src/__tests__/format-update-outcome.test.ts +6 -32
- package/src/__tests__/gitignore-profile.test.ts +1 -9
- package/src/__tests__/models-screen-state.test.ts +18 -40
- package/src/__tests__/plugin-resolution.test.ts +52 -13
- package/src/__tests__/plugin-row-text.test.ts +30 -37
- package/src/__tests__/profile-dependencies.test.ts +7 -0
- package/src/__tests__/profile-startup-old-format.test.ts +0 -3
- package/src/__tests__/profile-startup.test.ts +40 -12
- package/src/__tests__/stale-load-repair.test.ts +31 -23
- package/src/__tests__/update-apply.test.ts +13 -29
- package/src/__tests__/update-plan.test.ts +23 -17
- package/src/__tests__/useStaleLoadModal.test.ts +1 -57
- package/src/__tests__/version-unknown-state.test.ts +15 -17
- package/src/cli/doctor.ts +151 -30
- package/src/cli/install.ts +5 -6
- package/src/cli/update-view.ts +58 -15
- package/src/cli/update.ts +27 -5
- package/src/services/catalog-cache-store.ts +8 -6
- package/src/services/claude-cli.ts +106 -19
- package/src/services/claude-settings.ts +84 -36
- package/src/services/plugin-env-needs.ts +69 -0
- package/src/services/plugin-manager.ts +31 -19
- package/src/services/plugin-resolution.ts +34 -42
- package/src/services/profile-activate.ts +1 -2
- package/src/services/profile-startup.ts +18 -43
- package/src/services/profile-state.ts +72 -2
- package/src/services/resolver.ts +95 -1
- package/src/services/update-engine.ts +326 -31
- package/src/services/update-plan.ts +293 -33
- package/src/types/index.ts +7 -0
- package/src/ui/App.tsx +90 -146
- package/src/ui/adapters/cliToolsAdapter.ts +203 -1
- package/src/ui/adapters/failureReport.ts +137 -0
- package/src/ui/adapters/pluginsAdapter.ts +297 -45
- package/src/ui/adapters/startupReports.ts +282 -0
- package/src/ui/components/modals/FailureListModal.tsx +231 -0
- package/src/ui/components/modals/MessageModal.tsx +7 -12
- package/src/ui/components/modals/ModalContainer.tsx +10 -5
- package/src/ui/components/modals/index.ts +1 -0
- package/src/ui/hooks/startupQueue.ts +56 -0
- package/src/ui/hooks/useGitignoreModal.ts +30 -52
- package/src/ui/hooks/useProfileStartupModal.ts +119 -142
- package/src/ui/hooks/useStaleLoadModal.ts +35 -65
- package/src/ui/keymap.ts +14 -1
- package/src/ui/renderers/mcpRenderers.tsx +110 -3
- package/src/ui/renderers/pluginRenderers.tsx +77 -6
- package/src/ui/renderers/pluginRowText.ts +25 -25
- package/src/ui/renderers/pluginStatusText.ts +18 -1
- package/src/ui/screens/AliasScreen.tsx +38 -45
- package/src/ui/screens/CliToolsScreen.tsx +65 -54
- package/src/ui/screens/EnvVarsScreen.tsx +17 -5
- package/src/ui/screens/GitignoreScreen.tsx +48 -67
- package/src/ui/screens/McpRegistryScreen.tsx +67 -38
- package/src/ui/screens/McpScreen.tsx +46 -19
- package/src/ui/screens/ModelsScreen.tsx +46 -86
- package/src/ui/screens/PluginsScreen.tsx +313 -221
- package/src/ui/screens/ProfilesScreen.tsx +79 -40
- package/src/ui/screens/SkillsScreen.tsx +83 -80
- package/src/ui/screens/StylesScreen.tsx +211 -275
- package/src/ui/state/AppContext.tsx +66 -44
- package/src/ui/state/DimensionsContext.tsx +3 -22
- package/src/ui/state/reducer.ts +12 -100
- package/src/ui/state/types.ts +48 -69
- package/src/utils/user-path.ts +60 -0
- package/src/__tests__/notices-rules.test.ts +0 -476
- package/src/__tests__/notices.test.tsx +0 -604
- package/src/__tests__/styles-status-line.test.ts +0 -72
- package/src/__tests__/version-snapshot.test.ts +0 -212
- 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
|
|
4
|
-
|
|
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 {
|
|
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
|
-
// ─── "
|
|
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
|
-
*
|
|
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
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
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
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
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
|
|
498
|
+
export function failureReasons(): {
|
|
249
499
|
reporter: ApplyReporter;
|
|
250
500
|
/**
|
|
251
|
-
*
|
|
252
|
-
*
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
}
|