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,12 +1,14 @@
1
1
  import React, { useEffect, useCallback, useMemo } from "react";
2
+ import { failureReport } from "../adapters/failureReport.js";
3
+ import { stepFailureReport } from "../adapters/startupReports.js";
2
4
  import { ScreenLayout } from "../components/layout/index.js";
3
5
  import { useScreenKeys } from "../input/KeyRouter.js";
4
6
  import { moveDirection } from "../keymap.js";
5
7
  import {
6
8
  useApp,
9
+ useFailurePopup,
7
10
  useModal,
8
11
  useNavigation,
9
- useNotify,
10
12
  } from "../state/AppContext.js";
11
13
  import { useDimensions } from "../state/DimensionsContext.js";
12
14
 
@@ -35,7 +37,7 @@ export function McpScreen() {
35
37
  const { state, dispatch } = useApp();
36
38
  const { mcp } = state;
37
39
  const modal = useModal();
38
- const notify = useNotify();
40
+ const showFailures = useFailurePopup();
39
41
  const { navigateToScreen } = useNavigation();
40
42
  const dimensions = useDimensions();
41
43
 
@@ -96,22 +98,34 @@ export function McpScreen() {
96
98
 
97
99
  for (const server of servers) {
98
100
  const isInstalled = mcp.installedServers[server.name] !== undefined;
99
- items.push({ kind: "server", server, isInstalled });
101
+ items.push({
102
+ kind: "server",
103
+ server,
104
+ isInstalled,
105
+ needsRestart: mcp.changedThisSession.includes(server.name),
106
+ });
100
107
  }
101
108
  }
102
109
 
103
110
  return items;
104
- }, [mcp.servers, mcp.installedServers]);
111
+ }, [mcp.servers, mcp.installedServers, mcp.changedThisSession]);
105
112
 
106
113
  useScreenKeys("mcp", {
107
114
  // Guarded: `installMcpServer` throws on a catalogue entry with no URL or
108
115
  // command, and a rejection nobody catches ends the app.
109
116
  toggle: () => {
117
+ const item = allListItems[mcp.selectedIndex];
118
+ if (item?.kind !== "server") return;
119
+ const { name } = item.server;
110
120
  handleSelect().catch((error: unknown) =>
111
- modal.message(
112
- "Error",
113
- error instanceof Error ? error.message : String(error),
114
- "error",
121
+ showFailures(
122
+ stepFailureReport(
123
+ item.isInstalled
124
+ ? `Could not remove ${name}`
125
+ : `Could not add ${name}`,
126
+ name,
127
+ error,
128
+ ),
115
129
  ),
116
130
  );
117
131
  },
@@ -147,11 +161,19 @@ export function McpScreen() {
147
161
  try {
148
162
  await removeMcpServer(server.name, state.projectPath);
149
163
  modal.hideModal();
150
- // The row turns uninstalled; nothing else to say.
164
+ // The row turns uninstalled, and says a running Claude Code still
165
+ // has it until it restarts.
166
+ dispatch({ type: "MCP_SERVER_CHANGED", name: server.name });
151
167
  fetchData();
152
168
  } catch (error) {
153
169
  modal.hideModal();
154
- await modal.message("Error", `Failed to remove: ${error}`, "error");
170
+ await showFailures(
171
+ stepFailureReport(
172
+ `Could not remove ${server.name}`,
173
+ server.name,
174
+ error,
175
+ ),
176
+ );
155
177
  }
156
178
  }
157
179
  } else {
@@ -202,10 +224,14 @@ export function McpScreen() {
202
224
  if (value === null) return;
203
225
 
204
226
  if (field.required && !value) {
205
- await modal.message(
206
- "Required Field",
207
- `${field.label} is required.`,
208
- "error",
227
+ await showFailures(
228
+ failureReport(`Could not add ${server.name}`, [
229
+ {
230
+ subject: field.label,
231
+ reason: "is required, and was left empty",
232
+ fix: `Press Enter on ${server.name} again and fill it in`,
233
+ },
234
+ ]),
209
235
  );
210
236
  return;
211
237
  }
@@ -221,14 +247,15 @@ export function McpScreen() {
221
247
  try {
222
248
  await addMcpServer(server.name, config, state.projectPath);
223
249
  modal.hideModal();
224
- // The row shows it installed; the restart it needs does not show,
225
- // and it is something the user has to do — so a warning, which also
226
- // stays long enough to be read.
227
- notify(`Added ${server.name} — restart Claude Code to use it`, "warning");
250
+ // The row shows it installed AND that it needs a restart to be used —
251
+ // state on the server, not a message that scrolls away.
252
+ dispatch({ type: "MCP_SERVER_CHANGED", name: server.name });
228
253
  fetchData();
229
254
  } catch (error) {
230
255
  modal.hideModal();
231
- await modal.message("Error", `Failed to install: ${error}`, "error");
256
+ await showFailures(
257
+ stepFailureReport(`Could not add ${server.name}`, server.name, error),
258
+ );
232
259
  }
233
260
  };
234
261
 
@@ -7,11 +7,13 @@ import {
7
7
  readModelsConfig,
8
8
  readModelsStatus,
9
9
  } from "../../services/models-manager.js";
10
+ import { failureReport } from "../adapters/failureReport.js";
10
11
  import {
11
12
  type ModelsBrowserItem,
12
13
  buildModelsItems,
13
14
  firstSelectableIndex,
14
15
  } from "../adapters/modelsAdapter.js";
16
+ import { stepFailureReport } from "../adapters/startupReports.js";
15
17
  import { EmptyFilterState } from "../components/EmptyFilterState.js";
16
18
  import { ScrollableList } from "../components/ScrollableList.js";
17
19
  import { ScreenLayout } from "../components/layout/index.js";
@@ -23,7 +25,7 @@ import {
23
25
  renderModelRow,
24
26
  renderModelsSummary,
25
27
  } from "../renderers/modelRenderers.js";
26
- import { useApp } from "../state/AppContext.js";
28
+ import { useApp, useFailurePopup } from "../state/AppContext.js";
27
29
  import { useDimensions } from "../state/DimensionsContext.js";
28
30
  import { asyncValue } from "../state/types.js";
29
31
  import { theme } from "../theme.js";
@@ -134,22 +136,15 @@ export function ModelsScreen() {
134
136
  };
135
137
  }, [dispatch]);
136
138
 
137
- // ── Status line ───────────────────────────────────────────────────────────
139
+ // ── Feedback ──────────────────────────────────────────────────────────────
138
140
 
139
141
  /**
140
- * Set the status line.
141
- *
142
- * No timer, matching the Styles screen: a message that erased itself after a
143
- * few seconds was usually gone by the time anyone looked. It is cleared when
144
- * the NEXT action starts instead.
142
+ * No status line and no messages. A success shows as the screen's state —
143
+ * the reloaded list, "Model tiers: on", "writing…" while a write runs. What
144
+ * did not happen goes into the one failure popup, with why and, when there
145
+ * is one, what to do. A key pressed where it does nothing does nothing.
145
146
  */
146
- const showStatus = useCallback(
147
- (text: string, tone: "success" | "error" = "success") => {
148
- dispatch({ type: "MODELS_STATUS_SET", status: { text, tone } });
149
- },
150
- [dispatch],
151
- );
152
- const statusMsg = modelsState.status;
147
+ const showFailures = useFailurePopup();
153
148
 
154
149
  // ── Derived ───────────────────────────────────────────────────────────────
155
150
 
@@ -189,18 +184,17 @@ export function ModelsScreen() {
189
184
  *
190
185
  * No `ensureManifest` is passed. That helper prompts on the terminal, and
191
186
  * this process's terminal belongs to OpenTUI — a project with no profiles
192
- * gets a plain `.claude/models.json`, and the status line says so.
187
+ * gets a plain `.claude/models.json`.
188
+ *
189
+ * A press while a write is in flight does nothing: the header already says
190
+ * "writing…". Neither does one with no preset under the cursor — the only
191
+ * other row is the status header, which the cursor never lands on, so that
192
+ * is an empty filtered list.
193
193
  */
194
194
  const handleApply = useCallback(async () => {
195
- if (modelsState.isApplying) {
196
- showStatus("Already writing the routing — one at a time", "error");
197
- return;
198
- }
199
- if (!selectedItem || selectedItem.kind !== "preset") {
200
- showStatus("Move to a preset row and press a", "error");
201
- return;
202
- }
203
- const { config, custom } = selectedItem;
195
+ if (modelsState.isApplying) return;
196
+ if (!selectedItem || selectedItem.kind !== "preset") return;
197
+ const { config, custom, label } = selectedItem;
204
198
 
205
199
  dispatch({ type: "MODELS_APPLY_START" });
206
200
  try {
@@ -213,24 +207,24 @@ export function ModelsScreen() {
213
207
  projectPath: state.projectPath,
214
208
  preset: custom ? config : config.preset,
215
209
  });
210
+ // Applied: the reloaded list and the header show it. Said only when
211
+ // the profile did NOT record it — the next magus install renders the
212
+ // live settings from the profile and drops the routing.
216
213
  await fetchData();
217
- const where = result.profile
218
- ? `profile "${result.profile}"`
219
- : "this project";
220
- // Say plainly when the manifest was NOT updated: without that record a
221
- // `magus install` rewrites the profile and the routing disappears.
222
- const durability =
223
- result.profile && !result.recordedInManifest
224
- ? " — not in profiles.json, so `magus install` will drop it"
225
- : "";
226
- showStatus(
227
- `Applied ${selectedItem.label} in ${where}, hook ${result.hook}${durability}`,
228
- result.profile && !result.recordedInManifest ? "error" : "success",
229
- );
214
+ if (result.profile && !result.recordedInManifest) {
215
+ await showFailures(
216
+ failureReport(`${label} is active, but not saved in the profile`, [
217
+ {
218
+ subject: `profile ${result.profile}`,
219
+ reason:
220
+ "the routing is not recorded in it, so the next magus install drops it",
221
+ },
222
+ ]),
223
+ );
224
+ }
230
225
  } catch (error) {
231
- showStatus(
232
- error instanceof Error ? error.message : String(error),
233
- "error",
226
+ await showFailures(
227
+ stepFailureReport("No model routing was applied", label, error),
234
228
  );
235
229
  } finally {
236
230
  dispatch({ type: "MODELS_APPLY_END" });
@@ -240,26 +234,24 @@ export function ModelsScreen() {
240
234
  selectedItem,
241
235
  state.projectPath,
242
236
  fetchData,
243
- showStatus,
237
+ showFailures,
244
238
  dispatch,
245
239
  ]);
246
240
 
241
+ // Off: the reloaded header reads "Model tiers: off".
247
242
  const handleClear = useCallback(async () => {
248
- if (modelsState.isApplying) {
249
- showStatus("Already writing the routing — one at a time", "error");
250
- return;
251
- }
243
+ if (modelsState.isApplying) return;
252
244
  dispatch({ type: "MODELS_APPLY_START" });
253
245
  try {
254
246
  await clearModels(state.projectPath);
255
247
  await fetchData();
256
- showStatus(
257
- "Model tiers off — subagents inherit the session model again. The hook stays registered and is a no-op without a config.",
258
- );
259
248
  } catch (error) {
260
- showStatus(
261
- error instanceof Error ? error.message : String(error),
262
- "error",
249
+ await showFailures(
250
+ stepFailureReport(
251
+ "Model tiers were not turned off",
252
+ "model routing",
253
+ error,
254
+ ),
263
255
  );
264
256
  } finally {
265
257
  dispatch({ type: "MODELS_APPLY_END" });
@@ -268,7 +260,7 @@ export function ModelsScreen() {
268
260
  modelsState.isApplying,
269
261
  state.projectPath,
270
262
  fetchData,
271
- showStatus,
263
+ showFailures,
272
264
  dispatch,
273
265
  ]);
274
266
 
@@ -301,67 +293,36 @@ export function ModelsScreen() {
301
293
  blur: () => dispatch({ type: "SET_SEARCHING", isSearching: false }),
302
294
  });
303
295
 
304
- /**
305
- * The previous message belongs to the previous ACTION, so every key this
306
- * screen acts on clears it first. Keys another layer takes — a digit or Tab
307
- * leaving the tab, text typed into the search box — leave it alone, so a
308
- * round trip through another tab keeps it.
309
- */
310
- const clearStatus = () => {
311
- if (modelsState.status) dispatch({ type: "MODELS_STATUS_CLEAR" });
312
- };
313
-
314
296
  useScreenKeys("models", {
315
297
  // Enter and `a` are the same action here. There is nothing to "open" on
316
298
  // this screen — a preset row has one thing it can do — so binding Enter
317
299
  // to anything else would leave the most obvious key doing nothing.
318
300
  apply: () => {
319
- clearStatus();
320
301
  void handleApply();
321
302
  },
322
303
  off: () => {
323
- clearStatus();
324
304
  void handleClear();
325
305
  },
326
306
  reload: () => {
327
- clearStatus();
328
307
  void fetchData();
329
308
  },
330
309
  search: () => {
331
- clearStatus();
332
310
  dispatch({ type: "SET_SEARCHING", isSearching: true });
333
311
  },
334
312
  // Only while a committed filter is set; otherwise Esc is the global back.
335
313
  "clear-filter": () => {
336
314
  if (!modelsState.searchQuery) return false;
337
- clearStatus();
338
315
  dispatch({ type: "MODELS_SET_SEARCH", query: "" });
339
316
  dispatch({ type: "MODELS_SELECT", index: 0 });
340
317
  return true;
341
318
  },
342
319
  move: (key) => {
343
- clearStatus();
344
320
  move(moveDirection(key));
345
321
  },
346
322
  });
347
323
 
348
324
  // ── Chrome ────────────────────────────────────────────────────────────────
349
325
 
350
- // Transient feedback takes the full-width notice row under the header, never
351
- // the header itself: a long message beside the title shrinks the title away
352
- // and the two read as one garbled sentence.
353
- const noticeContent = statusMsg ? (
354
- <text
355
- fg={
356
- statusMsg.tone === "success"
357
- ? theme.colors.success
358
- : theme.colors.danger
359
- }
360
- >
361
- {statusMsg.text}
362
- </text>
363
- ) : undefined;
364
-
365
326
  // The file's `preset` string is deliberately not shown beside the state: it records what
366
327
  // was last APPLIED, so a config edited afterwards would wear a built-in's name here while
367
328
  // the list correctly calls it `Custom`.
@@ -398,7 +359,6 @@ export function ModelsScreen() {
398
359
  title="magus Models"
399
360
  currentScreen="models"
400
361
  statusLine={statusContent}
401
- notice={noticeContent}
402
362
  search={
403
363
  modelsState.searchQuery || isSearchActive
404
364
  ? {
@@ -469,8 +429,8 @@ export function ModelsScreen() {
469
429
  configPath: snapshot?.path ?? null,
470
430
  width: listWidth,
471
431
  // Rows the list did not use. One held back because the
472
- // search bar and the notice each take a row from the panel
473
- // without taking one from `listPanelHeight` — and an
432
+ // search bar takes a row from the panel without taking
433
+ // one from `listPanelHeight` — and an
474
434
  // over-tall panel overprints rather than clipping.
475
435
  available:
476
436
  dimensions.listPanelHeight -