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.
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 +6 -32
  16. package/src/__tests__/gitignore-profile.test.ts +1 -9
  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 -57
  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/plugin-env-needs.ts +69 -0
  36. package/src/services/plugin-manager.ts +31 -19
  37. package/src/services/plugin-resolution.ts +34 -42
  38. package/src/services/profile-activate.ts +1 -2
  39. package/src/services/profile-startup.ts +18 -43
  40. package/src/services/profile-state.ts +72 -2
  41. package/src/services/resolver.ts +95 -1
  42. package/src/services/update-engine.ts +326 -31
  43. package/src/services/update-plan.ts +293 -33
  44. package/src/types/index.ts +7 -0
  45. package/src/ui/App.tsx +90 -146
  46. package/src/ui/adapters/cliToolsAdapter.ts +203 -1
  47. package/src/ui/adapters/failureReport.ts +137 -0
  48. package/src/ui/adapters/pluginsAdapter.ts +297 -45
  49. package/src/ui/adapters/startupReports.ts +282 -0
  50. package/src/ui/components/modals/FailureListModal.tsx +231 -0
  51. package/src/ui/components/modals/MessageModal.tsx +7 -12
  52. package/src/ui/components/modals/ModalContainer.tsx +10 -5
  53. package/src/ui/components/modals/index.ts +1 -0
  54. package/src/ui/hooks/startupQueue.ts +56 -0
  55. package/src/ui/hooks/useGitignoreModal.ts +30 -52
  56. package/src/ui/hooks/useProfileStartupModal.ts +119 -142
  57. package/src/ui/hooks/useStaleLoadModal.ts +35 -65
  58. package/src/ui/keymap.ts +14 -1
  59. package/src/ui/renderers/mcpRenderers.tsx +110 -3
  60. package/src/ui/renderers/pluginRenderers.tsx +77 -6
  61. package/src/ui/renderers/pluginRowText.ts +25 -25
  62. package/src/ui/renderers/pluginStatusText.ts +18 -1
  63. package/src/ui/screens/AliasScreen.tsx +38 -45
  64. package/src/ui/screens/CliToolsScreen.tsx +65 -54
  65. package/src/ui/screens/EnvVarsScreen.tsx +17 -5
  66. package/src/ui/screens/GitignoreScreen.tsx +48 -67
  67. package/src/ui/screens/McpRegistryScreen.tsx +67 -38
  68. package/src/ui/screens/McpScreen.tsx +46 -19
  69. package/src/ui/screens/ModelsScreen.tsx +46 -86
  70. package/src/ui/screens/PluginsScreen.tsx +313 -221
  71. package/src/ui/screens/ProfilesScreen.tsx +79 -40
  72. package/src/ui/screens/SkillsScreen.tsx +83 -80
  73. package/src/ui/screens/StylesScreen.tsx +211 -275
  74. package/src/ui/state/AppContext.tsx +66 -44
  75. package/src/ui/state/DimensionsContext.tsx +3 -22
  76. package/src/ui/state/reducer.ts +12 -100
  77. package/src/ui/state/types.ts +48 -69
  78. package/src/utils/user-path.ts +60 -0
  79. package/src/__tests__/notices-rules.test.ts +0 -476
  80. package/src/__tests__/notices.test.tsx +0 -604
  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, useNotify } 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 {
@@ -31,7 +36,6 @@ import {
31
36
  writeClipboard,
32
37
  } from "../../utils/clipboard.js";
33
38
  import { ScrollableList } from "../components/ScrollableList.js";
34
- import { activationNotices } from "../hooks/useProfileStartupModal.js";
35
39
  import {
36
40
  type ProfileListItem,
37
41
  buildProfileListItems,
@@ -44,7 +48,7 @@ export function ProfilesScreen() {
44
48
  const { state, dispatch } = useApp();
45
49
  const { profiles: profilesState } = state;
46
50
  const modal = useModal();
47
- const notify = useNotify();
51
+ const showFailures = useFailurePopup();
48
52
  const dimensions = useDimensions();
49
53
 
50
54
  const fetchData = useCallback(async () => {
@@ -165,16 +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
- notify(`Merged "${profile.name}" into this project`);
169
- // What was recorded is routine; a refusal to record it is not, and
170
- // the user has to fix it before the merge reaches the profile.
171
- for (const problem of recorded.problems) notify(problem, "warning");
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
+ ),
180
+ );
172
181
  } catch (error) {
173
182
  modal.hideModal();
174
- await modal.message(
175
- "Error",
176
- `Failed to apply profile: ${error}`,
177
- "error",
183
+ await showFailures(
184
+ stepFailureReport(
185
+ `"${profile.name}" was not applied`,
186
+ profile.name,
187
+ error,
188
+ ),
178
189
  );
179
190
  }
180
191
  };
@@ -213,31 +224,35 @@ export function ProfilesScreen() {
213
224
  modal.hideModal();
214
225
  if (result.problems.length > 0 || !result.activation) {
215
226
  // Nothing activated, but what was recorded on the way may have
216
- // changed the list.
227
+ // changed the list. Each refusal goes into the failure popup, the
228
+ // file it is about named with what to do.
217
229
  dispatch({ type: "DATA_REFRESH_COMPLETE" });
218
- await modal.message(
219
- "Could not switch",
220
- [...result.lines, ...result.problems].join("\n"),
221
- "error",
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}"`,
222
235
  );
236
+ if (refused) await modal.failures(refused);
223
237
  return;
224
238
  }
225
239
  // The same signal as the startup question: every screen reloads,
226
240
  // and the app re-runs its gitignore check — the profile switched
227
241
  // to may generate files git still tracks, and that question is how
228
- // the user hears of them (activationNotices leaves them out).
242
+ // the user hears of them.
229
243
  dispatch({ type: "PROFILE_ACTIVATED" });
230
- // `result.lines` (edits saved into the profile left behind) are not
231
- // repeated: the confirmation above already said that happens.
232
- for (const line of activationNotices(result.activation)) {
233
- notify(line.text, line.tone);
234
- }
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);
235
248
  } catch (error) {
236
249
  modal.hideModal();
237
- await modal.message(
238
- "Error",
239
- `Failed to switch profile: ${error}`,
240
- "error",
250
+ await showFailures(
251
+ stepFailureReport(
252
+ `Did not switch to "${selectedProfile.name}"`,
253
+ selectedProfile.name,
254
+ error,
255
+ ),
241
256
  );
242
257
  }
243
258
  return;
@@ -260,13 +275,14 @@ export function ProfilesScreen() {
260
275
  await applyUserProfile(selectedProfile);
261
276
  modal.hideModal();
262
277
  dispatch({ type: "DATA_REFRESH_COMPLETE" });
263
- notify(`Applied "${selectedProfile.name}" at USER scope`);
264
278
  } catch (error) {
265
279
  modal.hideModal();
266
- await modal.message(
267
- "Error",
268
- `Failed to apply profile: ${error}`,
269
- "error",
280
+ await showFailures(
281
+ stepFailureReport(
282
+ `"${selectedProfile.name}" was not applied`,
283
+ "USER",
284
+ error,
285
+ ),
270
286
  );
271
287
  }
272
288
  };
@@ -295,7 +311,13 @@ export function ProfilesScreen() {
295
311
  await fetchData();
296
312
  } catch (error) {
297
313
  modal.hideModal();
298
- 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
+ );
299
321
  }
300
322
  };
301
323
 
@@ -326,7 +348,13 @@ export function ProfilesScreen() {
326
348
  await fetchData();
327
349
  } catch (error) {
328
350
  modal.hideModal();
329
- 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
+ );
330
358
  }
331
359
  };
332
360
 
@@ -344,19 +372,22 @@ export function ProfilesScreen() {
344
372
  modal.hideModal();
345
373
  try {
346
374
  await writeClipboard(json);
347
- notify(
348
- `Copied "${selectedProfile.name}" — share it for teammates to import`,
349
- );
350
375
  } catch (err) {
351
376
  if (err instanceof ClipboardUnavailableError) {
352
- await modal.message("Profile JSON (copy manually)", json, "info");
377
+ await modal.message("Profile JSON (copy manually)", json);
353
378
  } else {
354
379
  throw err;
355
380
  }
356
381
  }
357
382
  } catch (error) {
358
383
  modal.hideModal();
359
- 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
+ );
360
391
  }
361
392
  };
362
393
 
@@ -369,7 +400,9 @@ export function ProfilesScreen() {
369
400
  if (err instanceof ClipboardUnavailableError) {
370
401
  json = await modal.input("Import Profile", "Paste profile JSON:");
371
402
  } else {
372
- await modal.message("Error", `Clipboard error: ${err}`, "error");
403
+ await showFailures(
404
+ stepFailureReport("No profile was imported", "clipboard", err),
405
+ );
373
406
  return;
374
407
  }
375
408
  }
@@ -399,7 +432,13 @@ export function ProfilesScreen() {
399
432
  await fetchData();
400
433
  } catch (error) {
401
434
  modal.hideModal();
402
- 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
+ );
403
442
  }
404
443
  };
405
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 ? (