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
|
@@ -4,7 +4,7 @@ import React, { useEffect, useCallback, useState, useRef } from "react";
|
|
|
4
4
|
import { ScreenLayout } from "../components/layout/index.js";
|
|
5
5
|
import { useScreenKeys } from "../input/KeyRouter.js";
|
|
6
6
|
import { moveDirection } from "../keymap.js";
|
|
7
|
-
import { useApp,
|
|
7
|
+
import { useApp, useFailurePopup, useModal } from "../state/AppContext.js";
|
|
8
8
|
import { useDimensions } from "../state/DimensionsContext.js";
|
|
9
9
|
|
|
10
10
|
import { cliTools } from "../../data/cli-tools.js";
|
|
@@ -23,12 +23,18 @@ import {
|
|
|
23
23
|
specForCatalogTool,
|
|
24
24
|
} from "../../services/tool-probe.js";
|
|
25
25
|
import type { Command } from "../../utils/command-utils.js";
|
|
26
|
-
import { formatCommand } from "../../utils/command-utils.js";
|
|
27
26
|
import { createRequestGate } from "../../utils/request-gate.js";
|
|
28
27
|
import {
|
|
28
|
+
type CliToolCommandFailure,
|
|
29
29
|
type CliToolRow,
|
|
30
30
|
buildToolRow,
|
|
31
|
+
cliToolConflictPartialReport,
|
|
32
|
+
cliToolConflictStuckReport,
|
|
31
33
|
cliToolEnterAction,
|
|
34
|
+
cliToolFailureReport,
|
|
35
|
+
cliToolNotDone,
|
|
36
|
+
cliToolsNotUpdatedReport,
|
|
37
|
+
commandFailureReason,
|
|
32
38
|
countToolRows,
|
|
33
39
|
} from "../adapters/cliToolsAdapter.js";
|
|
34
40
|
import { ScrollableList } from "../components/ScrollableList.js";
|
|
@@ -39,6 +45,8 @@ import {
|
|
|
39
45
|
import { theme } from "../theme.js";
|
|
40
46
|
|
|
41
47
|
const execFileAsync = promisify(execFile);
|
|
48
|
+
/** How long one installer command may run before it is stopped. */
|
|
49
|
+
const COMMAND_TIMEOUT_MS = 60_000;
|
|
42
50
|
|
|
43
51
|
// ─── Session cache ─────────────────────────────────────────────────────────────
|
|
44
52
|
//
|
|
@@ -91,7 +99,7 @@ export function CliToolsScreen() {
|
|
|
91
99
|
const { state, dispatch } = useApp();
|
|
92
100
|
const { cliTools: cliToolsState } = state;
|
|
93
101
|
const modal = useModal();
|
|
94
|
-
const
|
|
102
|
+
const showFailures = useFailurePopup();
|
|
95
103
|
const dimensions = useDimensions();
|
|
96
104
|
|
|
97
105
|
const [rows, setRows] = useState<CliToolRow[]>(
|
|
@@ -209,23 +217,22 @@ export function CliToolsScreen() {
|
|
|
209
217
|
};
|
|
210
218
|
|
|
211
219
|
/**
|
|
212
|
-
* Run one installer command
|
|
220
|
+
* Run one installer command: null when it succeeded, else why it failed.
|
|
213
221
|
*
|
|
214
222
|
* `execFile`, so there is no shell to re-parse a package name — the same
|
|
215
223
|
* reason `utils/run.ts` exists. It does NOT call that shared `runCommand`:
|
|
216
224
|
* that one inherits stdio, and a TUI must never hand the terminal to a
|
|
217
|
-
* child. Output is captured
|
|
218
|
-
*
|
|
225
|
+
* child. Output is captured; the modal is the progress indicator, and on a
|
|
226
|
+
* failure the installer's own words become the popup's reason.
|
|
219
227
|
*/
|
|
220
|
-
const runCommand = async (
|
|
221
|
-
command: Command,
|
|
222
|
-
): Promise<{ ok: boolean; error?: string }> => {
|
|
228
|
+
const runCommand = async (command: Command): Promise<string | null> => {
|
|
223
229
|
try {
|
|
224
|
-
await execFileAsync(command.cmd, [...command.args], {
|
|
225
|
-
|
|
230
|
+
await execFileAsync(command.cmd, [...command.args], {
|
|
231
|
+
timeout: COMMAND_TIMEOUT_MS,
|
|
232
|
+
});
|
|
233
|
+
return null;
|
|
226
234
|
} catch (err) {
|
|
227
|
-
|
|
228
|
-
return { ok: false, error: msg };
|
|
235
|
+
return commandFailureReason(command, err, COMMAND_TIMEOUT_MS);
|
|
229
236
|
}
|
|
230
237
|
};
|
|
231
238
|
|
|
@@ -238,18 +245,19 @@ export function CliToolsScreen() {
|
|
|
238
245
|
const { command, progress: action } = enter;
|
|
239
246
|
|
|
240
247
|
modal.loading(`${action} ${tool.displayName}...`);
|
|
241
|
-
const
|
|
248
|
+
const reason = await runCommand(command);
|
|
242
249
|
modal.hideModal();
|
|
243
250
|
|
|
244
|
-
if (
|
|
251
|
+
if (reason === null) {
|
|
245
252
|
handleRefresh();
|
|
246
|
-
|
|
247
|
-
await modal.message(
|
|
248
|
-
"Error",
|
|
249
|
-
`Failed to ${action.toLowerCase()} ${tool.displayName}.\n\nTry running manually:\n${formatCommand(command)}`,
|
|
250
|
-
"error",
|
|
251
|
-
);
|
|
253
|
+
return;
|
|
252
254
|
}
|
|
255
|
+
await showFailures(
|
|
256
|
+
cliToolFailureReport(
|
|
257
|
+
`${tool.displayName} was not ${cliToolNotDone(enter.kind)}`,
|
|
258
|
+
[{ tool: tool.displayName, command, reason }],
|
|
259
|
+
),
|
|
260
|
+
);
|
|
253
261
|
};
|
|
254
262
|
|
|
255
263
|
/**
|
|
@@ -285,6 +293,11 @@ export function CliToolsScreen() {
|
|
|
285
293
|
methods
|
|
286
294
|
.map((m) => `The ${m} install stays: ${blockers.get(m)}.`)
|
|
287
295
|
.join("\n");
|
|
296
|
+
const blockerRows = (methods: readonly InstallMethod[]) =>
|
|
297
|
+
methods.map((method) => ({
|
|
298
|
+
method,
|
|
299
|
+
reason: blockers.get(method) ?? "magus cannot remove it",
|
|
300
|
+
}));
|
|
288
301
|
|
|
289
302
|
// One choice per install that can be KEPT — but only where keeping it
|
|
290
303
|
// removes something. "Keep npm" beside an unremovable native copy would
|
|
@@ -298,10 +311,8 @@ export function CliToolsScreen() {
|
|
|
298
311
|
}));
|
|
299
312
|
|
|
300
313
|
if (options.length === 0) {
|
|
301
|
-
await
|
|
302
|
-
|
|
303
|
-
`Installed via ${allMethods.join(" + ")}, and magus can remove none of them.\n\n${stuckList(allMethods)}\n\nRemove one by hand, then press r.`,
|
|
304
|
-
"error",
|
|
314
|
+
await showFailures(
|
|
315
|
+
cliToolConflictStuckReport(tool.displayName, blockerRows(allMethods)),
|
|
305
316
|
);
|
|
306
317
|
return;
|
|
307
318
|
}
|
|
@@ -319,42 +330,37 @@ export function CliToolsScreen() {
|
|
|
319
330
|
const leftBehind = stuck.filter((m) => m !== keep);
|
|
320
331
|
modal.loading(`Removing ${toRemove.join(", ")} install(s)...`);
|
|
321
332
|
|
|
322
|
-
const
|
|
333
|
+
const failed: Array<{
|
|
334
|
+
method: InstallMethod;
|
|
335
|
+
command: Command;
|
|
336
|
+
reason: string;
|
|
337
|
+
}> = [];
|
|
338
|
+
const unremovable = blockerRows(leftBehind);
|
|
323
339
|
for (const method of toRemove) {
|
|
324
340
|
const cmd = cliToolUninstall(tool, method, install.brewFormula);
|
|
325
341
|
// Unreachable: `removable` was built from this same call. Reported
|
|
326
342
|
// rather than skipped, because a silent skip is the defect above.
|
|
327
343
|
if (!cmd) {
|
|
328
|
-
|
|
344
|
+
unremovable.push({
|
|
345
|
+
method,
|
|
346
|
+
reason: "magus has no uninstall command for it",
|
|
347
|
+
});
|
|
329
348
|
continue;
|
|
330
349
|
}
|
|
331
|
-
const
|
|
332
|
-
if (
|
|
350
|
+
const reason = await runCommand(cmd);
|
|
351
|
+
if (reason !== null) failed.push({ method, command: cmd, reason });
|
|
333
352
|
}
|
|
334
353
|
|
|
335
354
|
modal.hideModal();
|
|
336
355
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
// failed, and the row still shows the conflict until it is dealt with.
|
|
345
|
-
if (errors.length > 0) {
|
|
346
|
-
await modal.message(
|
|
347
|
-
"Partly removed",
|
|
348
|
-
[
|
|
349
|
-
`These removals failed. Run them by hand:\n${errors.join("\n")}`,
|
|
350
|
-
stays,
|
|
351
|
-
]
|
|
352
|
-
.filter((part) => part !== "")
|
|
353
|
-
.join("\n\n"),
|
|
354
|
-
"error",
|
|
356
|
+
// A failed removal goes into the failure popup with the command to run,
|
|
357
|
+
// and so does an install left behind beside it. An install magus could
|
|
358
|
+
// never remove is not, on its own, a failure: nothing failed, and the row
|
|
359
|
+
// still shows the conflict until it is dealt with.
|
|
360
|
+
if (failed.length > 0 || unremovable.length > leftBehind.length) {
|
|
361
|
+
await showFailures(
|
|
362
|
+
cliToolConflictPartialReport(tool.displayName, failed, unremovable),
|
|
355
363
|
);
|
|
356
|
-
} else if (stays !== "") {
|
|
357
|
-
notify(stays.replace(/\n/g, " "), "warning");
|
|
358
364
|
}
|
|
359
365
|
|
|
360
366
|
handleRefresh();
|
|
@@ -365,19 +371,24 @@ export function CliToolsScreen() {
|
|
|
365
371
|
// swept up in a bulk action — that is exactly the "cannot tell, so run
|
|
366
372
|
// it anyway" the CLI no longer does either.
|
|
367
373
|
const updatable = rows.filter((r) => r.status?.state === "upgrade");
|
|
368
|
-
if (updatable.length === 0)
|
|
369
|
-
notify("No tool is known to be behind");
|
|
370
|
-
return;
|
|
371
|
-
}
|
|
374
|
+
if (updatable.length === 0) return;
|
|
372
375
|
|
|
373
376
|
modal.loading(`Updating ${updatable.length} tool(s)...`);
|
|
374
377
|
|
|
378
|
+
// Every failure is kept: the reload shows the tool still behind, and
|
|
379
|
+
// only this says why.
|
|
380
|
+
const failed: CliToolCommandFailure[] = [];
|
|
375
381
|
for (const row of updatable) {
|
|
376
|
-
|
|
382
|
+
const command = row.updateCommand ?? row.installCommand;
|
|
383
|
+
const reason = await runCommand(command);
|
|
384
|
+
if (reason !== null) {
|
|
385
|
+
failed.push({ tool: row.tool.displayName, command, reason });
|
|
386
|
+
}
|
|
377
387
|
}
|
|
378
388
|
|
|
379
389
|
modal.hideModal();
|
|
380
390
|
handleRefresh();
|
|
391
|
+
await showFailures(cliToolsNotUpdatedReport(failed));
|
|
381
392
|
};
|
|
382
393
|
|
|
383
394
|
const selectedRow = rows[cliToolsState.selectedIndex];
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type SettingsBrowserItem,
|
|
10
10
|
buildSettingsBrowserItems,
|
|
11
11
|
} from "../adapters/settingsAdapter.js";
|
|
12
|
+
import { stepFailureReport } from "../adapters/startupReports.js";
|
|
12
13
|
import { ScrollableList } from "../components/ScrollableList.js";
|
|
13
14
|
import { ScreenLayout } from "../components/layout/index.js";
|
|
14
15
|
import { ErrorNotice } from "../components/primitives/index.js";
|
|
@@ -18,7 +19,7 @@ import {
|
|
|
18
19
|
renderSettingDetail,
|
|
19
20
|
renderSettingRow,
|
|
20
21
|
} from "../renderers/settingsRenderers.js";
|
|
21
|
-
import { useApp, useModal } from "../state/AppContext.js";
|
|
22
|
+
import { useApp, useFailurePopup, useModal } from "../state/AppContext.js";
|
|
22
23
|
import { useDimensions } from "../state/DimensionsContext.js";
|
|
23
24
|
import { theme } from "../theme.js";
|
|
24
25
|
|
|
@@ -26,8 +27,19 @@ export function SettingsScreen() {
|
|
|
26
27
|
const { state, dispatch } = useApp();
|
|
27
28
|
const { settings } = state;
|
|
28
29
|
const modal = useModal();
|
|
30
|
+
const showFailures = useFailurePopup();
|
|
29
31
|
const dimensions = useDimensions();
|
|
30
32
|
|
|
33
|
+
/** A setting that did not save, at the scope it was being saved to. */
|
|
34
|
+
const reportNotSaved = (
|
|
35
|
+
name: string,
|
|
36
|
+
scope: "user" | "project",
|
|
37
|
+
error: unknown,
|
|
38
|
+
): Promise<void> =>
|
|
39
|
+
showFailures(
|
|
40
|
+
stepFailureReport(`${name} was not saved`, scope.toUpperCase(), error),
|
|
41
|
+
);
|
|
42
|
+
|
|
31
43
|
const fetchData = useCallback(async () => {
|
|
32
44
|
dispatch({ type: "SETTINGS_DATA_LOADING" });
|
|
33
45
|
try {
|
|
@@ -84,7 +96,7 @@ export function SettingsScreen() {
|
|
|
84
96
|
await writeSettingValue(setting, newValue, scope, state.projectPath);
|
|
85
97
|
await fetchData();
|
|
86
98
|
} catch (error) {
|
|
87
|
-
await
|
|
99
|
+
await reportNotSaved(setting.name, scope, error);
|
|
88
100
|
}
|
|
89
101
|
} else if (setting.type === "select" && setting.options) {
|
|
90
102
|
const options = setting.options.map((o) => ({
|
|
@@ -110,7 +122,7 @@ export function SettingsScreen() {
|
|
|
110
122
|
await writeSettingValue(setting, val, scope, state.projectPath);
|
|
111
123
|
await fetchData();
|
|
112
124
|
} catch (error) {
|
|
113
|
-
await
|
|
125
|
+
await reportNotSaved(setting.name, scope, error);
|
|
114
126
|
}
|
|
115
127
|
} else {
|
|
116
128
|
// String type: if already set, clear it; if unset, show input modal
|
|
@@ -119,7 +131,7 @@ export function SettingsScreen() {
|
|
|
119
131
|
await writeSettingValue(setting, undefined, scope, state.projectPath);
|
|
120
132
|
await fetchData();
|
|
121
133
|
} catch (error) {
|
|
122
|
-
await
|
|
134
|
+
await reportNotSaved(setting.name, scope, error);
|
|
123
135
|
}
|
|
124
136
|
} else {
|
|
125
137
|
const newValue = await modal.input(
|
|
@@ -137,7 +149,7 @@ export function SettingsScreen() {
|
|
|
137
149
|
);
|
|
138
150
|
await fetchData();
|
|
139
151
|
} catch (error) {
|
|
140
|
-
await
|
|
152
|
+
await reportNotSaved(setting.name, scope, error);
|
|
141
153
|
}
|
|
142
154
|
}
|
|
143
155
|
}
|
|
@@ -12,6 +12,11 @@ import {
|
|
|
12
12
|
loadGitignoreState,
|
|
13
13
|
} from "../../services/gitignore-service.js";
|
|
14
14
|
import type { ResolvedRule, Tier, Violation } from "../../types/gitignore.js";
|
|
15
|
+
import {
|
|
16
|
+
gitignoreFixReport,
|
|
17
|
+
gitignoreTemplateReport,
|
|
18
|
+
stepFailureReport,
|
|
19
|
+
} from "../adapters/startupReports.js";
|
|
15
20
|
import { ScreenLayout } from "../components/layout/index.js";
|
|
16
21
|
import { useScreenKeys } from "../input/KeyRouter.js";
|
|
17
22
|
import { moveDirection } from "../keymap.js";
|
|
@@ -19,7 +24,8 @@ import {
|
|
|
19
24
|
type RuleRow,
|
|
20
25
|
renderRuleRow,
|
|
21
26
|
} from "../renderers/gitignoreRenderers.js";
|
|
22
|
-
import { useApp, useModal
|
|
27
|
+
import { useApp, useModal } from "../state/AppContext.js";
|
|
28
|
+
import type { FailureReport } from "../state/types.js";
|
|
23
29
|
import { theme } from "../theme.js";
|
|
24
30
|
|
|
25
31
|
// Clean rows are grouped by the manifest that defined them. Order = display
|
|
@@ -36,7 +42,6 @@ const SOURCE_LABELS: Record<Tier, string> = {
|
|
|
36
42
|
export function GitignoreScreen() {
|
|
37
43
|
const { state } = useApp();
|
|
38
44
|
const modal = useModal();
|
|
39
|
-
const notify = useNotify();
|
|
40
45
|
const renderer = useRenderer();
|
|
41
46
|
const cwd = state.projectPath;
|
|
42
47
|
|
|
@@ -139,109 +144,83 @@ export function GitignoreScreen() {
|
|
|
139
144
|
const handleFixAll = useCallback(async () => {
|
|
140
145
|
if (!data || toFixRows.length === 0) return;
|
|
141
146
|
try {
|
|
142
|
-
const
|
|
143
|
-
const failures: string[] = [];
|
|
147
|
+
const failures: Array<{ path: string; message: string }> = [];
|
|
144
148
|
for (const { row } of toFixRows) {
|
|
145
149
|
for (const v of row.violations) {
|
|
146
150
|
const r = await applyDestructiveFix(cwd, v, row.rule.pattern);
|
|
147
|
-
(r.applied
|
|
151
|
+
if (!r.applied) failures.push({ path: v.path, message: r.message });
|
|
148
152
|
}
|
|
149
153
|
}
|
|
150
154
|
await reload();
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
[...failures, ...messages].join("\n"),
|
|
155
|
-
"error",
|
|
156
|
-
);
|
|
157
|
-
}
|
|
155
|
+
// Only what did not apply, each with why; the reloaded rows show the rest.
|
|
156
|
+
const report = gitignoreFixReport(failures);
|
|
157
|
+
if (report) await modal.failures(report);
|
|
158
158
|
} catch (err) {
|
|
159
|
-
await modal.
|
|
160
|
-
"
|
|
161
|
-
err instanceof Error ? err.message : String(err),
|
|
162
|
-
"error",
|
|
159
|
+
await modal.failures(
|
|
160
|
+
stepFailureReport("The fixes were not applied", ".gitignore", err),
|
|
163
161
|
);
|
|
164
162
|
}
|
|
165
163
|
}, [cwd, data, toFixRows, reload, modal]);
|
|
166
164
|
|
|
167
165
|
// Fix just the highlighted row.
|
|
168
166
|
const handleFixOne = useCallback(async () => {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
if (selectedRow.violations.length === 0) {
|
|
174
|
-
notify(`${selectedRow.rule.pattern} already matches — nothing to fix`);
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
167
|
+
// Nothing highlighted, or a row that already matches: nothing to fix.
|
|
168
|
+
if (!selectedRow || selectedRow.violations.length === 0) return;
|
|
177
169
|
|
|
178
170
|
try {
|
|
179
|
-
const
|
|
171
|
+
const failures: Array<{ path: string; message: string }> = [];
|
|
180
172
|
for (const v of selectedRow.violations) {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
);
|
|
173
|
+
const r = await applyDestructiveFix(cwd, v, selectedRow.rule.pattern);
|
|
174
|
+
if (!r.applied) failures.push({ path: v.path, message: r.message });
|
|
184
175
|
}
|
|
185
|
-
const failed = results.filter((r) => !r.applied);
|
|
186
176
|
await reload();
|
|
187
|
-
// Success is silent — the reload removes the fixed row. Only
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
"Some fixes didn't apply",
|
|
192
|
-
results.map((r) => r.message).join("\n"),
|
|
193
|
-
"error",
|
|
194
|
-
);
|
|
195
|
-
}
|
|
177
|
+
// Success is silent — the reload removes the fixed row. Only what did
|
|
178
|
+
// not apply goes into the failure popup.
|
|
179
|
+
const report = gitignoreFixReport(failures);
|
|
180
|
+
if (report) await modal.failures(report);
|
|
196
181
|
} catch (err) {
|
|
197
|
-
await modal.
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
182
|
+
await modal.failures(
|
|
183
|
+
stepFailureReport(
|
|
184
|
+
"The fix was not applied",
|
|
185
|
+
selectedRow.rule.pattern,
|
|
186
|
+
err,
|
|
187
|
+
),
|
|
201
188
|
);
|
|
202
189
|
}
|
|
203
|
-
}, [selectedRow, modal,
|
|
190
|
+
}, [selectedRow, modal, cwd, reload]);
|
|
204
191
|
|
|
205
192
|
const handleApplyTemplate = useCallback(async () => {
|
|
206
193
|
const names = Object.keys(BUILTIN_TEMPLATES);
|
|
207
|
-
if (names.length === 0)
|
|
208
|
-
notify("No built-in templates available", "warning");
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
194
|
+
if (names.length === 0) return;
|
|
211
195
|
const selected = await modal.select(
|
|
212
196
|
"Apply template",
|
|
213
197
|
"Merge the template into the project manifest at .claude/gitignore.json.",
|
|
214
198
|
names.map((n) => ({ label: n, value: n })),
|
|
215
199
|
);
|
|
216
200
|
if (!selected) return;
|
|
201
|
+
let report: FailureReport | null = null;
|
|
217
202
|
try {
|
|
203
|
+
// What was merged shows in the list. What was skipped does not, so it
|
|
204
|
+
// is the one thing said — in the failure popup.
|
|
218
205
|
const r = await applyTemplate(cwd, selected);
|
|
219
|
-
|
|
220
|
-
`Applied "${selected}": ${r.added.ignore.length} ignore + ${r.added.track.length} track entries`,
|
|
221
|
-
);
|
|
222
|
-
// Skipped entries are not visible in the list, so they get their own line.
|
|
223
|
-
if (r.conflicts.length > 0) {
|
|
224
|
-
notify(
|
|
225
|
-
`Skipped, conflicting with existing rules: ${r.conflicts.join(", ")}`,
|
|
226
|
-
"warning",
|
|
227
|
-
);
|
|
228
|
-
}
|
|
206
|
+
report = gitignoreTemplateReport(selected, r.conflicts);
|
|
229
207
|
} catch (err) {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
208
|
+
report = stepFailureReport(
|
|
209
|
+
`Template "${selected}" was not applied`,
|
|
210
|
+
".claude/gitignore.json",
|
|
211
|
+
err,
|
|
234
212
|
);
|
|
235
213
|
}
|
|
236
214
|
await reload();
|
|
237
|
-
|
|
215
|
+
if (report) await modal.failures(report);
|
|
216
|
+
}, [cwd, modal, reload]);
|
|
238
217
|
|
|
239
218
|
/**
|
|
240
219
|
* Open the project manifest in $EDITOR, creating it first if needed.
|
|
241
220
|
*
|
|
242
221
|
* Every await sits inside the try: this runs fire-and-forget from a key, and a
|
|
243
222
|
* rejection nobody catches ends the app. A manifest that cannot be read or
|
|
244
|
-
* created is said in
|
|
223
|
+
* created is said in the failure popup instead.
|
|
245
224
|
*/
|
|
246
225
|
const handleEditManifest = useCallback(async () => {
|
|
247
226
|
let projectManifest: string;
|
|
@@ -252,10 +231,12 @@ export function GitignoreScreen() {
|
|
|
252
231
|
current.resolved,
|
|
253
232
|
);
|
|
254
233
|
} catch (err) {
|
|
255
|
-
await modal.
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
234
|
+
await modal.failures(
|
|
235
|
+
stepFailureReport(
|
|
236
|
+
"The project's rules were not opened for editing",
|
|
237
|
+
".claude/gitignore.json",
|
|
238
|
+
err,
|
|
239
|
+
),
|
|
259
240
|
);
|
|
260
241
|
return;
|
|
261
242
|
}
|
|
@@ -1,20 +1,30 @@
|
|
|
1
1
|
import React, { useEffect, useCallback, useState, useRef } from "react";
|
|
2
|
+
import { stepFailureReport } from "../adapters/startupReports.js";
|
|
2
3
|
import { ScreenLayout } from "../components/layout/index.js";
|
|
3
4
|
import { useScreenKeys, useSearchInput } from "../input/KeyRouter.js";
|
|
4
5
|
import { moveDirection } from "../keymap.js";
|
|
5
6
|
import {
|
|
6
7
|
useApp,
|
|
8
|
+
useFailurePopup,
|
|
7
9
|
useModal,
|
|
8
10
|
useNavigation,
|
|
9
|
-
useNotify,
|
|
10
11
|
} from "../state/AppContext.js";
|
|
11
12
|
import { useDimensions } from "../state/DimensionsContext.js";
|
|
12
13
|
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
addMcpServer,
|
|
16
|
+
getEnabledMcpServers,
|
|
17
|
+
getInstalledMcpServers,
|
|
18
|
+
setAllowMcp,
|
|
19
|
+
} from "../../services/claude-settings.js";
|
|
14
20
|
import { formatDate, searchMcpServers } from "../../services/mcp-registry.js";
|
|
15
21
|
import type { McpRegistryServer, McpServerConfig } from "../../types/index.js";
|
|
16
22
|
import { ScrollableList } from "../components/ScrollableList.js";
|
|
17
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
type McpRegistryListItem,
|
|
25
|
+
renderMcpRegistryRow,
|
|
26
|
+
renderMcpRegistryStatus,
|
|
27
|
+
} from "../renderers/mcpRenderers.js";
|
|
18
28
|
import { theme } from "../theme.js";
|
|
19
29
|
|
|
20
30
|
/**
|
|
@@ -80,7 +90,7 @@ export function McpRegistryScreen() {
|
|
|
80
90
|
const { state, dispatch } = useApp();
|
|
81
91
|
const { mcpRegistry } = state;
|
|
82
92
|
const modal = useModal();
|
|
83
|
-
const
|
|
93
|
+
const showFailures = useFailurePopup();
|
|
84
94
|
const { navigateToScreen } = useNavigation();
|
|
85
95
|
const dimensions = useDimensions();
|
|
86
96
|
|
|
@@ -94,6 +104,33 @@ export function McpRegistryScreen() {
|
|
|
94
104
|
|
|
95
105
|
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
96
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The servers this project has configured, read from the same place the MCP
|
|
109
|
+
* screen reads. A row here shows installed from THIS, and the restart from
|
|
110
|
+
* `mcp.changedThisSession` — the same two states a curated row carries.
|
|
111
|
+
*/
|
|
112
|
+
const [installedNames, setInstalledNames] = useState<ReadonlySet<string>>(
|
|
113
|
+
new Set(),
|
|
114
|
+
);
|
|
115
|
+
const loadInstalled = useCallback(async () => {
|
|
116
|
+
try {
|
|
117
|
+
const [installed, enabled] = await Promise.all([
|
|
118
|
+
getInstalledMcpServers(state.projectPath),
|
|
119
|
+
getEnabledMcpServers(state.projectPath),
|
|
120
|
+
]);
|
|
121
|
+
setInstalledNames(
|
|
122
|
+
new Set([...Object.keys(installed), ...Object.keys(enabled)]),
|
|
123
|
+
);
|
|
124
|
+
} catch {
|
|
125
|
+
// A configuration that cannot be read is the MCP screen's to report,
|
|
126
|
+
// where it replaces the list; here the rows simply carry no marks.
|
|
127
|
+
setInstalledNames(new Set());
|
|
128
|
+
}
|
|
129
|
+
}, [state.projectPath]);
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
void loadInstalled();
|
|
132
|
+
}, [loadInstalled]);
|
|
133
|
+
|
|
97
134
|
// Load servers
|
|
98
135
|
const loadServers = useCallback(async (query: string) => {
|
|
99
136
|
setIsLoading(true);
|
|
@@ -196,16 +233,28 @@ export function McpRegistryScreen() {
|
|
|
196
233
|
await setAllowMcp(true, state.projectPath);
|
|
197
234
|
await addMcpServer(server.name, config, state.projectPath);
|
|
198
235
|
modal.hideModal();
|
|
199
|
-
//
|
|
200
|
-
|
|
236
|
+
// Shown on THIS row as its state — installed, and "↻ restart Claude
|
|
237
|
+
// Code" — not as a message. The MCP screen lists curated servers only,
|
|
238
|
+
// so this row is where the user can see it.
|
|
239
|
+
dispatch({ type: "MCP_SERVER_CHANGED", name: server.name });
|
|
240
|
+
await loadInstalled();
|
|
201
241
|
} catch (error) {
|
|
202
242
|
modal.hideModal();
|
|
203
|
-
await
|
|
243
|
+
await showFailures(
|
|
244
|
+
stepFailureReport(`Could not add ${server.name}`, server.name, error),
|
|
245
|
+
);
|
|
204
246
|
}
|
|
205
247
|
};
|
|
206
248
|
|
|
249
|
+
const listItems: McpRegistryListItem[] = servers.map((server) => ({
|
|
250
|
+
server,
|
|
251
|
+
isInstalled: installedNames.has(server.name),
|
|
252
|
+
needsRestart: state.mcp.changedThisSession.includes(server.name),
|
|
253
|
+
}));
|
|
254
|
+
|
|
207
255
|
// Get selected server
|
|
208
|
-
const
|
|
256
|
+
const selectedItem = listItems[mcpRegistry.selectedIndex];
|
|
257
|
+
const selectedServer = selectedItem?.server;
|
|
209
258
|
|
|
210
259
|
const renderDetail = () => {
|
|
211
260
|
if (isLoading) {
|
|
@@ -216,7 +265,7 @@ export function McpRegistryScreen() {
|
|
|
216
265
|
return <text fg={theme.colors.danger}>Error: {error}</text>;
|
|
217
266
|
}
|
|
218
267
|
|
|
219
|
-
if (!selectedServer) {
|
|
268
|
+
if (!selectedServer || !selectedItem) {
|
|
220
269
|
return (
|
|
221
270
|
<text fg={theme.colors.muted}>Select a server to see details</text>
|
|
222
271
|
);
|
|
@@ -238,6 +287,7 @@ export function McpRegistryScreen() {
|
|
|
238
287
|
<box marginTop={1}>
|
|
239
288
|
<text fg={theme.colors.text}>{selectedServer.short_description}</text>
|
|
240
289
|
</box>
|
|
290
|
+
<box marginTop={1}>{renderMcpRegistryStatus(selectedItem)}</box>
|
|
241
291
|
<box flexDirection="row" marginTop={1}>
|
|
242
292
|
<text fg={theme.colors.text}>
|
|
243
293
|
<strong>Version: </strong>
|
|
@@ -266,36 +316,15 @@ export function McpRegistryScreen() {
|
|
|
266
316
|
</text>
|
|
267
317
|
</box>
|
|
268
318
|
)}
|
|
269
|
-
|
|
270
|
-
<
|
|
271
|
-
|
|
319
|
+
{selectedItem.isInstalled ? null : (
|
|
320
|
+
<box marginTop={1}>
|
|
321
|
+
<text fg={theme.colors.muted}>Press Enter to install</text>
|
|
322
|
+
</box>
|
|
323
|
+
)}
|
|
272
324
|
</box>
|
|
273
325
|
);
|
|
274
326
|
};
|
|
275
327
|
|
|
276
|
-
const renderListItem = (
|
|
277
|
-
server: McpRegistryServer,
|
|
278
|
-
_idx: number,
|
|
279
|
-
isSelected: boolean,
|
|
280
|
-
) => {
|
|
281
|
-
const version = server.version ? ` v${server.version}` : "";
|
|
282
|
-
return isSelected ? (
|
|
283
|
-
<text bg={theme.selection.bg} fg={theme.selection.fg}>
|
|
284
|
-
{rowLead(true)}
|
|
285
|
-
{server.name}
|
|
286
|
-
{version}{" "}
|
|
287
|
-
</text>
|
|
288
|
-
) : (
|
|
289
|
-
<text fg={theme.colors.text}>
|
|
290
|
-
<span>{rowLead(false)}</span>
|
|
291
|
-
<span>
|
|
292
|
-
<strong>{server.name}</strong>
|
|
293
|
-
</span>
|
|
294
|
-
<span fg={theme.colors.info}>{version}</span>
|
|
295
|
-
</text>
|
|
296
|
-
);
|
|
297
|
-
};
|
|
298
|
-
|
|
299
328
|
// Status for search placeholder
|
|
300
329
|
const searchPlaceholder = `${servers.length} servers │ / to search`;
|
|
301
330
|
|
|
@@ -319,11 +348,11 @@ export function McpRegistryScreen() {
|
|
|
319
348
|
<text fg={theme.colors.muted}>No servers found</text>
|
|
320
349
|
) : (
|
|
321
350
|
<ScrollableList
|
|
322
|
-
items={
|
|
351
|
+
items={listItems}
|
|
323
352
|
selectedIndex={mcpRegistry.selectedIndex}
|
|
324
|
-
renderItem={
|
|
353
|
+
renderItem={renderMcpRegistryRow}
|
|
325
354
|
maxHeight={dimensions.listPanelHeight}
|
|
326
|
-
getKey={(
|
|
355
|
+
getKey={(item) => item.server.name}
|
|
327
356
|
/>
|
|
328
357
|
)
|
|
329
358
|
}
|