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,8 +1,13 @@
1
1
  import React, { useEffect, useCallback } from "react";
2
+ import {
3
+ activationFailureReport,
4
+ profileProblemsReport,
5
+ stepFailureReport,
6
+ } from "../adapters/startupReports.js";
2
7
  import { ScreenLayout } from "../components/layout/index.js";
3
8
  import { useScreenKeys } from "../input/KeyRouter.js";
4
9
  import { moveDirection } from "../keymap.js";
5
- import { useApp, useModal } from "../state/AppContext.js";
10
+ import { useApp, useFailurePopup, useModal } from "../state/AppContext.js";
6
11
  import { useDimensions } from "../state/DimensionsContext.js";
7
12
 
8
13
  import {
@@ -15,7 +20,6 @@ import {
15
20
  writeSettings,
16
21
  } from "../../services/claude-settings.js";
17
22
  import { reconcileActiveProfile } from "../../services/profile-absorb.js";
18
- import { activationNotes } from "../../services/profile-activate.js";
19
23
  import { switchToProfile } from "../../services/profile-state.js";
20
24
  import {
21
25
  applyUserProfile,
@@ -32,7 +36,6 @@ import {
32
36
  writeClipboard,
33
37
  } from "../../utils/clipboard.js";
34
38
  import { ScrollableList } from "../components/ScrollableList.js";
35
- import { describeOutcome } from "../hooks/useProfileStartupModal.js";
36
39
  import {
37
40
  type ProfileListItem,
38
41
  buildProfileListItems,
@@ -45,6 +48,7 @@ export function ProfilesScreen() {
45
48
  const { state, dispatch } = useApp();
46
49
  const { profiles: profilesState } = state;
47
50
  const modal = useModal();
51
+ const showFailures = useFailurePopup();
48
52
  const dimensions = useDimensions();
49
53
 
50
54
  const fetchData = useCallback(async () => {
@@ -165,20 +169,23 @@ export function ProfilesScreen() {
165
169
  const recorded = await reconcileActiveProfile(state.projectPath);
166
170
  modal.hideModal();
167
171
  dispatch({ type: "DATA_REFRESH_COMPLETE" });
168
- await modal.message(
169
- "Applied",
170
- [
171
- `Profile "${profile.name}" merged into project settings.`,
172
- ...recorded.lines,
173
- ].join("\n"),
174
- "success",
172
+ // What was recorded is routine and the list shows it; a refusal to
173
+ // record it is a failure the user has to fix before the merge reaches
174
+ // the profile.
175
+ await showFailures(
176
+ profileProblemsReport(
177
+ recorded.problems,
178
+ `"${profile.name}" was merged in, but the active profile does not record it`,
179
+ ),
175
180
  );
176
181
  } catch (error) {
177
182
  modal.hideModal();
178
- await modal.message(
179
- "Error",
180
- `Failed to apply profile: ${error}`,
181
- "error",
183
+ await showFailures(
184
+ stepFailureReport(
185
+ `"${profile.name}" was not applied`,
186
+ profile.name,
187
+ error,
188
+ ),
182
189
  );
183
190
  }
184
191
  };
@@ -215,35 +222,37 @@ export function ProfilesScreen() {
215
222
  { onDisplace: () => {} },
216
223
  );
217
224
  modal.hideModal();
218
- dispatch({ type: "DATA_REFRESH_COMPLETE" });
219
225
  if (result.problems.length > 0 || !result.activation) {
220
- await modal.message(
221
- "Could not switch",
222
- [...result.lines, ...result.problems].join("\n"),
223
- "error",
226
+ // Nothing activated, but what was recorded on the way may have
227
+ // changed the list. Each refusal goes into the failure popup, the
228
+ // file it is about named with what to do.
229
+ dispatch({ type: "DATA_REFRESH_COMPLETE" });
230
+ const refused = profileProblemsReport(
231
+ result.problems.length > 0
232
+ ? result.problems
233
+ : ["the profile was not activated"],
234
+ `Did not switch to "${selectedProfile.name}"`,
224
235
  );
236
+ if (refused) await modal.failures(refused);
225
237
  return;
226
238
  }
227
- const outcome = describeOutcome(
228
- result.activation.profileId,
229
- result.activation.displaced,
230
- state.projectPath,
231
- false,
232
- activationNotes(result.activation),
233
- );
234
- await modal.message(
235
- "Profile active",
236
- result.lines.length > 0
237
- ? `${result.lines.join("\n")}\n\n${outcome}`
238
- : outcome,
239
- "success",
240
- );
239
+ // The same signal as the startup question: every screen reloads,
240
+ // and the app re-runs its gitignore check — the profile switched
241
+ // to may generate files git still tracks, and that question is how
242
+ // the user hears of them.
243
+ dispatch({ type: "PROFILE_ACTIVATED" });
244
+ // Switched; what the activation could not do — a plugin that will
245
+ // not load, a skill that did not install — once it has settled.
246
+ const report = activationFailureReport([result.activation]);
247
+ if (report) await modal.failures(report);
241
248
  } catch (error) {
242
249
  modal.hideModal();
243
- await modal.message(
244
- "Error",
245
- `Failed to switch profile: ${error}`,
246
- "error",
250
+ await showFailures(
251
+ stepFailureReport(
252
+ `Did not switch to "${selectedProfile.name}"`,
253
+ selectedProfile.name,
254
+ error,
255
+ ),
247
256
  );
248
257
  }
249
258
  return;
@@ -266,17 +275,14 @@ export function ProfilesScreen() {
266
275
  await applyUserProfile(selectedProfile);
267
276
  modal.hideModal();
268
277
  dispatch({ type: "DATA_REFRESH_COMPLETE" });
269
- await modal.message(
270
- "Applied",
271
- `Profile "${selectedProfile.name}" applied to ~/.claude/settings.json.`,
272
- "success",
273
- );
274
278
  } catch (error) {
275
279
  modal.hideModal();
276
- await modal.message(
277
- "Error",
278
- `Failed to apply profile: ${error}`,
279
- "error",
280
+ await showFailures(
281
+ stepFailureReport(
282
+ `"${selectedProfile.name}" was not applied`,
283
+ "USER",
284
+ error,
285
+ ),
280
286
  );
281
287
  }
282
288
  };
@@ -301,15 +307,17 @@ export function ProfilesScreen() {
301
307
  state.projectPath,
302
308
  );
303
309
  modal.hideModal();
310
+ // The row shows the new name; nothing else to say.
304
311
  await fetchData();
305
- await modal.message(
306
- "Renamed",
307
- `Profile renamed to "${newName.trim()}".`,
308
- "success",
309
- );
310
312
  } catch (error) {
311
313
  modal.hideModal();
312
- await modal.message("Error", `Failed to rename: ${error}`, "error");
314
+ await showFailures(
315
+ stepFailureReport(
316
+ `"${selectedProfile.name}" was not renamed`,
317
+ selectedProfile.name,
318
+ error,
319
+ ),
320
+ );
313
321
  }
314
322
  };
315
323
 
@@ -336,11 +344,17 @@ export function ProfilesScreen() {
336
344
  Math.min(profilesState.selectedIndex, allItems.length - 2),
337
345
  );
338
346
  dispatch({ type: "PROFILES_SELECT", index: newIndex });
347
+ // The row is gone from the list; nothing else to say.
339
348
  await fetchData();
340
- await modal.message("Deleted", "Profile deleted.", "success");
341
349
  } catch (error) {
342
350
  modal.hideModal();
343
- await modal.message("Error", `Failed to delete: ${error}`, "error");
351
+ await showFailures(
352
+ stepFailureReport(
353
+ `"${selectedProfile.name}" was not deleted`,
354
+ selectedProfile.name,
355
+ error,
356
+ ),
357
+ );
344
358
  }
345
359
  };
346
360
 
@@ -358,21 +372,22 @@ export function ProfilesScreen() {
358
372
  modal.hideModal();
359
373
  try {
360
374
  await writeClipboard(json);
361
- await modal.message(
362
- "Copied",
363
- "Profile JSON copied to clipboard.\nShare it with teammates to import.",
364
- "success",
365
- );
366
375
  } catch (err) {
367
376
  if (err instanceof ClipboardUnavailableError) {
368
- await modal.message("Profile JSON (copy manually)", json, "info");
377
+ await modal.message("Profile JSON (copy manually)", json);
369
378
  } else {
370
379
  throw err;
371
380
  }
372
381
  }
373
382
  } catch (error) {
374
383
  modal.hideModal();
375
- await modal.message("Error", `Failed to export: ${error}`, "error");
384
+ await showFailures(
385
+ stepFailureReport(
386
+ `"${selectedProfile.name}" was not copied`,
387
+ selectedProfile.name,
388
+ error,
389
+ ),
390
+ );
376
391
  }
377
392
  };
378
393
 
@@ -385,7 +400,9 @@ export function ProfilesScreen() {
385
400
  if (err instanceof ClipboardUnavailableError) {
386
401
  json = await modal.input("Import Profile", "Paste profile JSON:");
387
402
  } else {
388
- await modal.message("Error", `Clipboard error: ${err}`, "error");
403
+ await showFailures(
404
+ stepFailureReport("No profile was imported", "clipboard", err),
405
+ );
389
406
  return;
390
407
  }
391
408
  }
@@ -409,21 +426,19 @@ export function ProfilesScreen() {
409
426
 
410
427
  modal.loading("Importing...");
411
428
  try {
412
- const id = await importProfileFromJson(
413
- json,
414
- targetScope,
415
- state.projectPath,
416
- );
429
+ await importProfileFromJson(json, targetScope, state.projectPath);
417
430
  modal.hideModal();
431
+ // The imported profile appears in the list; nothing else to say.
418
432
  await fetchData();
419
- await modal.message(
420
- "Imported",
421
- `Profile imported successfully (id: ${id}).`,
422
- "success",
423
- );
424
433
  } catch (error) {
425
434
  modal.hideModal();
426
- await modal.message("Error", `Failed to import: ${error}`, "error");
435
+ await showFailures(
436
+ stepFailureReport(
437
+ "No profile was imported",
438
+ targetScope.toUpperCase(),
439
+ error,
440
+ ),
441
+ );
427
442
  }
428
443
  };
429
444
 
@@ -8,11 +8,15 @@ import React, {
8
8
  useState,
9
9
  useRef,
10
10
  } from "react";
11
+ import { counted, failureReport } from "../adapters/failureReport.js";
12
+ import { exceptionReason } from "../adapters/pluginsAdapter.js";
13
+ import { stepFailureReport } from "../adapters/startupReports.js";
11
14
  import { ScreenLayout } from "../components/layout/index.js";
12
15
  import { useScreenKeys, useSearchInput } from "../input/KeyRouter.js";
13
16
  import { moveDirection } from "../keymap.js";
14
- import { useApp, useModal } from "../state/AppContext.js";
17
+ import { useApp, useFailurePopup } from "../state/AppContext.js";
15
18
  import { useDimensions } from "../state/DimensionsContext.js";
19
+ import type { FailureRow } from "../state/types.js";
16
20
 
17
21
  import {
18
22
  DEFAULT_SKILL_REPOS,
@@ -53,7 +57,6 @@ import { theme } from "../theme.js";
53
57
  export function SkillsScreen() {
54
58
  const { state, dispatch } = useApp();
55
59
  const { skills: skillsState } = state;
56
- const modal = useModal();
57
60
  const dimensions = useDimensions();
58
61
  const [curatedSkills, setCuratedSkills] = useState<RecommendedSkill[]>([]);
59
62
  const [skillSets, setSkillSets] = useState<SkillSetInfo[]>([]);
@@ -181,20 +184,9 @@ export function SkillsScreen() {
181
184
  };
182
185
  }, [skillsState.searchQuery]);
183
186
 
184
- // Status bar message (auto-clears)
185
- const [statusMsg, setStatusMsg] = useState<{
186
- text: string;
187
- tone: "success" | "error";
188
- } | null>(null);
189
- const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
190
- const showStatus = useCallback(
191
- (text: string, tone: "success" | "error" = "success") => {
192
- setStatusMsg({ text, tone });
193
- if (statusTimerRef.current) clearTimeout(statusTimerRef.current);
194
- statusTimerRef.current = setTimeout(() => setStatusMsg(null), 3000);
195
- },
196
- [],
197
- );
187
+ // No status messages: a success shows as the rows' new state, and what
188
+ // failed goes into the one failure popup.
189
+ const showFailures = useFailurePopup();
198
190
 
199
191
  // ── Disk scan for installed skills ────────────────────────────────────────
200
192
 
@@ -214,13 +206,13 @@ export function SkillsScreen() {
214
206
  * Which skills are on disk, per scope. Never rejects: it runs fire-and-forget
215
207
  * from effects and after every install, and a rejection there is an
216
208
  * unhandled one, which ends the app. A directory that exists but cannot be
217
- * read is said on the status line — silently treating it as "no skills
209
+ * read goes into the failure popup — silently treating it as "no skills
218
210
  * installed" would misreport the installed rows.
219
211
  */
220
212
  const scanDisk = useCallback(async () => {
221
213
  const user = new Map<string, SkillFrontmatter | null>();
222
214
  const project = new Map<string, SkillFrontmatter | null>();
223
- const unreadable: string[] = [];
215
+ const unreadable: FailureRow[] = [];
224
216
  const userDir = path.join(os.homedir(), ".claude", "skills");
225
217
  const projDir = path.join(
226
218
  state.projectPath || process.cwd(),
@@ -242,16 +234,22 @@ export function SkillsScreen() {
242
234
  }
243
235
  }
244
236
  } catch (error) {
245
- unreadable.push(
246
- `${dir} (${error instanceof Error ? error.message : String(error)})`,
247
- );
237
+ unreadable.push({
238
+ subject: dir,
239
+ reason: exceptionReason(
240
+ error instanceof Error ? error.message : String(error),
241
+ ).reason,
242
+ });
248
243
  }
249
244
  }
250
245
  setInstalledFromDisk({ user, project, scanned: true });
251
- if (unreadable.length > 0) {
252
- showStatus(`Could not read ${unreadable.join("; ")}`, "error");
253
- }
254
- }, [state.projectPath, showStatus]);
246
+ void showFailures(
247
+ failureReport(
248
+ `${counted(unreadable.length, "skills folder")} could not be read`,
249
+ unreadable,
250
+ ),
251
+ );
252
+ }, [state.projectPath, showFailures]);
255
253
 
256
254
  // biome-ignore lint/correctness/useExhaustiveDependencies: dataRefreshVersion is the external refresh trigger; bumping it must re-scan the disk even though the body never reads it
257
255
  useEffect(() => {
@@ -347,45 +345,50 @@ export function SkillsScreen() {
347
345
  const set = skillSets.find((s) => s.id === setId);
348
346
  if (!set || !set.loaded) return;
349
347
 
348
+ // Every skill already installed: the rows say so.
350
349
  const toInstall = set.skills.filter((s) => !s.installed);
351
- if (toInstall.length === 0) {
352
- showStatus(`All ${set.name} skills already installed`);
353
- return;
354
- }
350
+ if (toInstall.length === 0) return;
355
351
 
356
- showStatus(`Installing ${toInstall.length} skills from ${set.name}...`);
357
- let installed = 0;
358
- let failed = 0;
359
- let degraded = 0;
352
+ dispatch({
353
+ type: "SHOW_MODAL",
354
+ modal: {
355
+ type: "loading",
356
+ message: `Installing ${toInstall.length} skills from ${set.name}…`,
357
+ },
358
+ });
359
+ const problems: FailureRow[] = [];
360
360
  for (const skill of toInstall) {
361
361
  try {
362
362
  const result = await installSkill(skill, scope, state.projectPath);
363
- installed++;
364
- if (result.degraded) degraded++;
365
- } catch {
366
- failed++;
363
+ // Incomplete is not the same as installed. Say so rather than let
364
+ // a half-written skill pass as a clean success.
365
+ if (result.degraded) {
366
+ problems.push({
367
+ subject: skill.name,
368
+ reason: `installed incomplete: ${result.degraded}`,
369
+ });
370
+ }
371
+ } catch (error) {
372
+ problems.push({
373
+ subject: skill.name,
374
+ reason: exceptionReason(
375
+ error instanceof Error ? error.message : String(error),
376
+ ).reason,
377
+ });
367
378
  }
368
379
  }
369
380
  await scanDisk();
370
- if (failed > 0) {
371
- showStatus(
372
- `Installed ${installed}/${toInstall.length} (${failed} failed)`,
373
- "error",
374
- );
375
- } else if (degraded > 0) {
376
- // Incomplete is not the same as installed. Say so rather than let a
377
- // batch of half-written skills report a clean success.
378
- showStatus(
379
- `Installed ${installed} skills from ${set.name} to ${scope} — ${degraded} incomplete (missing supporting files)`,
380
- "error",
381
- );
382
- } else {
383
- showStatus(
384
- `Installed ${installed} skills from ${set.name} to ${scope}`,
385
- );
386
- }
381
+ dispatch({ type: "HIDE_MODAL" });
382
+ await showFailures(
383
+ failureReport(
384
+ `${counted(problems.length, "skill")} from ${set.name} ${
385
+ problems.length === 1 ? "was" : "were"
386
+ } not fully installed`,
387
+ problems,
388
+ ),
389
+ );
387
390
  },
388
- [skillSets, state.projectPath, scanDisk, showStatus],
391
+ [skillSets, state.projectPath, scanDisk, showFailures, dispatch],
389
392
  );
390
393
 
391
394
  // ── Derived data ──────────────────────────────────────────────────────────
@@ -584,23 +587,29 @@ export function SkillsScreen() {
584
587
  state.projectPath,
585
588
  );
586
589
  await scanDisk(); // Re-scan disk only — no network re-fetch needed
587
- const files =
588
- result.fileCount === 1 ? "1 file" : `${result.fileCount} files`;
589
590
  // An install that silently dropped supporting files used to report plain
590
591
  // success, so a half-installed skill looked identical to a good one.
591
592
  if (result.degraded) {
592
- showStatus(
593
- `Installed ${selectedSkill.name} to ${scope} (${files}) — ${result.degraded}`,
594
- "error",
593
+ await showFailures(
594
+ failureReport(`${selectedSkill.name} was not fully installed`, [
595
+ {
596
+ subject: selectedSkill.name,
597
+ reason: `installed incomplete: ${result.degraded}`,
598
+ },
599
+ ]),
595
600
  );
596
- } else {
597
- showStatus(`Installed ${selectedSkill.name} to ${scope} (${files})`);
598
601
  }
599
602
  } catch (error) {
600
- showStatus(`Failed: ${error}`, "error");
603
+ await showFailures(
604
+ stepFailureReport(
605
+ `${selectedSkill.name} was not installed`,
606
+ selectedSkill.name,
607
+ error,
608
+ ),
609
+ );
601
610
  }
602
611
  },
603
- [selectedSkill, state.projectPath, scanDisk, showStatus],
612
+ [selectedSkill, state.projectPath, scanDisk, showFailures],
604
613
  );
605
614
 
606
615
  const handleUninstall = useCallback(async () => {
@@ -611,11 +620,16 @@ export function SkillsScreen() {
611
620
  try {
612
621
  await uninstallSkill(selectedSkill.name, scope, state.projectPath);
613
622
  await scanDisk(); // Re-scan disk only — no network re-fetch needed
614
- showStatus(`Removed ${selectedSkill.name} from ${scope}`);
615
623
  } catch (error) {
616
- showStatus(`Failed: ${error}`, "error");
624
+ await showFailures(
625
+ stepFailureReport(
626
+ `${selectedSkill.name} was not removed`,
627
+ selectedSkill.name,
628
+ error,
629
+ ),
630
+ );
617
631
  }
618
- }, [selectedSkill, state.projectPath, scanDisk, showStatus]);
632
+ }, [selectedSkill, state.projectPath, scanDisk, showFailures]);
619
633
 
620
634
  // ── Keys ──────────────────────────────────────────────────────────────────
621
635
 
@@ -709,19 +723,8 @@ export function SkillsScreen() {
709
723
  ).length;
710
724
  const query = skillsState.searchQuery.trim();
711
725
 
712
- const statusContent = statusMsg ? (
713
- <text fg={theme.colors.text}>
714
- <span
715
- fg={
716
- statusMsg.tone === "success"
717
- ? theme.colors.success
718
- : theme.colors.danger
719
- }
720
- >
721
- {statusMsg.text}
722
- </span>
723
- </text>
724
- ) : (
726
+ // The header's state line only — counts and search progress, never a message.
727
+ const statusContent = (
725
728
  <text fg={theme.colors.text}>
726
729
  <span fg={theme.colors.muted}>Skills: </span>
727
730
  {installedFromDisk.scanned ? (