magus-cli 7.4.0 → 7.5.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 (67) hide show
  1. package/README.md +9 -8
  2. package/package.json +4 -4
  3. package/src/__tests__/blind/c19-paste-routing.test.ts +252 -0
  4. package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
  5. package/src/__tests__/install-command.test.ts +4 -3
  6. package/src/__tests__/models-cli.test.ts +4 -7
  7. package/src/__tests__/models-manager.test.ts +52 -40
  8. package/src/__tests__/profile-absorb.test.ts +1138 -0
  9. package/src/__tests__/profile-command.test.ts +90 -32
  10. package/src/__tests__/profile-migration-offer.test.ts +18 -15
  11. package/src/__tests__/profile-migration.test.ts +246 -117
  12. package/src/__tests__/profile-render.test.ts +141 -0
  13. package/src/__tests__/profile-skills.test.ts +425 -0
  14. package/src/__tests__/profile-startup.test.ts +44 -14
  15. package/src/__tests__/profile-state.test.ts +795 -0
  16. package/src/__tests__/settings-file.test.ts +5 -7
  17. package/src/__tests__/stale-load-repair.test.ts +5 -4
  18. package/src/__tests__/styles-manager.test.ts +47 -74
  19. package/src/__tests__/styles-screen-state.test.ts +11 -10
  20. package/src/cli/bootstrap.ts +87 -27
  21. package/src/cli/doctor.ts +152 -44
  22. package/src/cli/install.ts +92 -127
  23. package/src/cli/models.ts +21 -1
  24. package/src/cli/profile.ts +48 -77
  25. package/src/cli/router.ts +32 -6
  26. package/src/cli/update.ts +43 -6
  27. package/src/data/gitignore-defaults.ts +3 -3
  28. package/src/services/active-closure.ts +3 -3
  29. package/src/services/claude-settings.ts +283 -125
  30. package/src/services/cli-tool-commands.ts +30 -0
  31. package/src/services/conventions-manager.ts +88 -0
  32. package/src/services/git-worktree.ts +21 -0
  33. package/src/services/gitignore-service.ts +39 -22
  34. package/src/services/manifest.ts +4 -5
  35. package/src/services/models-manager.ts +36 -73
  36. package/src/services/profile-absorb.ts +1532 -0
  37. package/src/services/profile-activate.ts +348 -96
  38. package/src/services/profile-live.ts +825 -0
  39. package/src/services/profile-render.ts +115 -0
  40. package/src/services/profile-skills.ts +462 -0
  41. package/src/services/profile-startup.ts +88 -16
  42. package/src/services/profile-state.ts +622 -0
  43. package/src/services/profiles.ts +20 -32
  44. package/src/services/resolver.ts +6 -1
  45. package/src/services/settings-file.ts +14 -8
  46. package/src/services/skills-manager.ts +14 -8
  47. package/src/services/styles-manager.ts +45 -28
  48. package/src/services/tool-probe.ts +48 -3
  49. package/src/types/index.ts +15 -9
  50. package/src/ui/App.tsx +25 -2
  51. package/src/ui/components/modals/ModalContainer.tsx +9 -1
  52. package/src/ui/hooks/useProfileStartupModal.ts +20 -15
  53. package/src/ui/input/KeyRouter.tsx +147 -23
  54. package/src/ui/input/key-dispatch.ts +48 -0
  55. package/src/ui/keymap.ts +8 -1
  56. package/src/ui/renderers/cliToolRenderers.tsx +65 -27
  57. package/src/ui/screens/CliToolsScreen.tsx +74 -13
  58. package/src/ui/screens/PluginsScreen.tsx +6 -2
  59. package/src/ui/screens/ProfilesScreen.tsx +75 -27
  60. package/src/ui/screens/StylesScreen.tsx +2 -1
  61. package/src/ui/state/types.ts +1 -1
  62. package/src/__tests__/profile-materializer.test.ts +0 -238
  63. package/src/__tests__/profile-sync.test.ts +0 -154
  64. package/src/__tests__/symlink-manager.test.ts +0 -180
  65. package/src/services/profile-materializer.ts +0 -133
  66. package/src/services/profile-sync.ts +0 -158
  67. package/src/services/symlink-manager.ts +0 -343
@@ -1,29 +1,43 @@
1
1
  /**
2
- * The one keyboard subscriber in the React tree.
2
+ * The one keyboard subscriber in the React tree — and the one paste subscriber.
3
3
  *
4
- * Components do not call `useKeyboard`. They register a LAYER — modal, input,
5
- * screen, scroll or global — and this router offers each key to the live layers
6
- * through `dispatchKey`, top first, so exactly one of them acts on it. The
7
- * system layer (Ctrl+C) is not here: `src/tui.tsx` takes that key on the
8
- * renderer before this subscriber runs and stops it there.
4
+ * Components do not call `useKeyboard`, and no screen listens for a paste of
5
+ * its own. They register a LAYER — modal, input, screen, scroll or global — and
6
+ * this router offers each key to the live layers through `dispatchKey`, top
7
+ * first, so exactly one of them acts on it. The system layer (Ctrl+C) is not
8
+ * here: `src/tui.tsx` takes that key on the renderer before this subscriber
9
+ * runs and stops it there.
9
10
  *
10
11
  * Each key is dispatched inside `flushSync`, so everything a key changes is
11
12
  * committed — and every layer's `active` flag and handler refreshed — before the
12
- * next key is looked at. A paste or a fast typist delivers several keys in one
13
- * stdin read; without this, `/abc` would open the search box and then send `a`,
14
- * `b` and `c` to the screen underneath, whose handlers still believed the box
15
- * was closed.
13
+ * next key is looked at. A fast typist delivers several keys in one stdin read;
14
+ * without this, `/abc` would open the search box and then send `a`, `b` and `c`
15
+ * to the screen underneath, whose handlers still believed the box was closed.
16
+ *
17
+ * A PASTE is NOT that burst of keys. Bracketed paste is on (OpenTUI's native
18
+ * core writes `ESC[?2004h`), so the terminal wraps the clipboard in
19
+ * `ESC[200~ … ESC[201~` and the renderer emits ONE `paste` event carrying the
20
+ * whole text — and no `keypress` at all. That is why a search term pasted into
21
+ * a filter box used to vanish without a trace: every layer here listened for
22
+ * keys that were never sent. `dispatchPaste` runs the same layer order over the
23
+ * layers that registered a paste handler.
24
+ *
25
+ * The paste event is deliberately NOT stopped: OpenTUI hands it to the focused
26
+ * renderable afterwards, which is how the input modal's `<input>` still takes a
27
+ * paste of its own — exactly the arrangement the modal layer relies on for keys.
16
28
  */
17
29
 
18
30
  import { appendFileSync } from "node:fs";
19
31
  import os from "node:os";
20
32
  import path from "node:path";
21
- import type { KeyEvent } from "@opentui/core";
22
- import { flushSync, useKeyboard } from "@opentui/react";
33
+ import type { KeyEvent, PasteEvent } from "@opentui/core";
34
+ import { decodePasteBytes, stripAnsiSequences } from "@opentui/core";
35
+ import { flushSync, useKeyboard, useRenderer } from "@opentui/react";
23
36
  import {
24
37
  type ReactNode,
25
38
  createContext,
26
39
  useContext,
40
+ useEffect,
27
41
  useLayoutEffect,
28
42
  useState,
29
43
  } from "react";
@@ -37,12 +51,20 @@ import {
37
51
  type KeyLayer,
38
52
  type KeyLayerId,
39
53
  type KeyPress,
54
+ type PasteLayer,
40
55
  dispatchKey,
56
+ dispatchPaste,
41
57
  inputLayerConsumes,
42
58
  typedCharacter,
43
59
  } from "./key-dispatch.js";
44
60
 
45
- const LayersContext = createContext<Set<KeyLayer> | null>(null);
61
+ /** The two registries the router dispatches over. */
62
+ interface LayerRegistry {
63
+ keys: Set<KeyLayer>;
64
+ pastes: Set<PasteLayer>;
65
+ }
66
+
67
+ const LayersContext = createContext<LayerRegistry | null>(null);
46
68
 
47
69
  /** OpenTUI's key event, reduced to the fields the dispatcher reads. */
48
70
  export function toKeyPress(event: KeyEvent): KeyPress {
@@ -55,35 +77,96 @@ export function toKeyPress(event: KeyEvent): KeyPress {
55
77
  };
56
78
  }
57
79
 
80
+ /**
81
+ * The text a paste event carries, as a single-line search term.
82
+ *
83
+ * Three things are stripped, in this order. The bytes are decoded as UTF-8
84
+ * (`decodePasteBytes`). Any ANSI escape the clipboard carried is removed — a
85
+ * terminal forwards the clipboard verbatim inside the brackets, so copying
86
+ * coloured output pastes its escape sequences too, and they are not a search
87
+ * term. Then every remaining control character goes: a newline or a tab becomes
88
+ * one space, so a multi-line clipboard reads as words rather than running the
89
+ * lines together, and the rest are dropped outright.
90
+ *
91
+ * The ends are trimmed last, because a copied line almost always carries its
92
+ * trailing newline and a query ending in a space matches nothing.
93
+ */
94
+ export function pastedText(event: PasteEvent): string {
95
+ const decoded = stripAnsiSequences(decodePasteBytes(event.bytes));
96
+ let out = "";
97
+ for (const ch of decoded) {
98
+ const cp = ch.codePointAt(0) ?? 0;
99
+ // Newline, carriage return, tab: one space — and a RUN of them one space
100
+ // too, so a CRLF clipboard does not double-space every line break.
101
+ if (cp === 0x0a || cp === 0x0d || cp === 0x09) {
102
+ if (!out.endsWith(" ")) out += " ";
103
+ continue;
104
+ }
105
+ // The rest of C0 and C1, dropped. Written as codepoint bounds rather
106
+ // than a regex character class because the formatter rewrites a
107
+ // `\u0000` escape inside a regex literal into the raw byte it stands
108
+ // for — which puts literal control characters in this source file.
109
+ if (cp < 0x20 || (cp >= 0x7f && cp <= 0x9f)) continue;
110
+ out += ch;
111
+ }
112
+ return out.trim();
113
+ }
114
+
58
115
  /**
59
116
  * `MAGUS_DEBUG_KEYS=1` appends every key and the layer that took it to
60
117
  * `magus-keys.log` in the temp directory — the first thing to look at when a
61
- * key "does nothing".
118
+ * key "does nothing". A paste is logged the same way, as `paste`.
62
119
  */
63
- function logKey(key: KeyPress, consumer: KeyLayerId | null): void {
120
+ function logKey(what: string, consumer: KeyLayerId | null): void {
64
121
  if (process.env.MAGUS_DEBUG_KEYS !== "1") return;
65
122
  try {
66
123
  appendFileSync(
67
124
  path.join(os.tmpdir(), "magus-keys.log"),
68
- `${new Date().toISOString()} name=${JSON.stringify(key.name)} seq=${JSON.stringify(key.sequence)} ctrl=${key.ctrl} meta=${key.meta} shift=${key.shift} -> ${consumer ?? "nobody"}\n`,
125
+ `${new Date().toISOString()} ${what} -> ${consumer ?? "nobody"}\n`,
69
126
  );
70
127
  } catch {
71
128
  // Best-effort logging: never let it break a keypress.
72
129
  }
73
130
  }
74
131
 
132
+ function describeKey(key: KeyPress): string {
133
+ return `name=${JSON.stringify(key.name)} seq=${JSON.stringify(key.sequence)} ctrl=${key.ctrl} meta=${key.meta} shift=${key.shift}`;
134
+ }
135
+
75
136
  export function KeyRouter({ children }: { children: ReactNode }) {
76
- const [layers] = useState(() => new Set<KeyLayer>());
137
+ const [layers] = useState<LayerRegistry>(() => ({
138
+ keys: new Set<KeyLayer>(),
139
+ pastes: new Set<PasteLayer>(),
140
+ }));
141
+ const renderer = useRenderer();
77
142
 
78
143
  useKeyboard((event) => {
79
144
  const key = toKeyPress(event);
80
145
  let consumer: KeyLayerId | null = null;
81
146
  flushSync(() => {
82
- consumer = dispatchKey([...layers], key);
147
+ consumer = dispatchKey([...layers.keys], key);
83
148
  });
84
- logKey(key, consumer);
149
+ logKey(describeKey(key), consumer);
85
150
  });
86
151
 
152
+ // The ONE paste subscriber. `useKeyboard` has no paste counterpart in
153
+ // @opentui/react, so this listens on the renderer's key input directly —
154
+ // once, here, rather than once per screen.
155
+ useEffect(() => {
156
+ const onPaste = (event: PasteEvent): void => {
157
+ const text = pastedText(event);
158
+ let consumer: KeyLayerId | null = null;
159
+ flushSync(() => {
160
+ consumer = dispatchPaste([...layers.pastes], text);
161
+ });
162
+ logKey(`paste=${JSON.stringify(text)}`, consumer);
163
+ };
164
+ renderer.keyInput.on("paste", onPaste);
165
+ return () => {
166
+ renderer.keyInput.off("paste", onPaste);
167
+ };
168
+ }, [renderer, layers]);
169
+
87
170
  return (
88
171
  <LayersContext.Provider value={layers}>{children}</LayersContext.Provider>
89
172
  );
@@ -111,9 +194,40 @@ export function useKeyLayer(
111
194
  });
112
195
 
113
196
  useLayoutEffect(() => {
114
- layers.add(entry);
197
+ layers.keys.add(entry);
115
198
  return () => {
116
- layers.delete(entry);
199
+ layers.keys.delete(entry);
200
+ };
201
+ }, [layers, entry]);
202
+ }
203
+
204
+ /**
205
+ * Register a PASTE layer for as long as the calling component is mounted.
206
+ *
207
+ * The counterpart of {@link useKeyLayer}, and refreshed on every commit the
208
+ * same way. A component registers this only when a paste means something to it:
209
+ * a layer that does not is simply absent from the order, and the paste falls
210
+ * through it.
211
+ */
212
+ export function usePasteLayer(
213
+ id: KeyLayerId,
214
+ active: boolean,
215
+ handlePaste: (text: string) => boolean,
216
+ ): void {
217
+ const layers = useContext(LayersContext);
218
+ if (!layers) throw new Error("usePasteLayer needs a <KeyRouter> above it");
219
+ const [entry] = useState<PasteLayer>(() => ({ id, active, handlePaste }));
220
+
221
+ useLayoutEffect(() => {
222
+ entry.id = id;
223
+ entry.active = active;
224
+ entry.handlePaste = handlePaste;
225
+ });
226
+
227
+ useLayoutEffect(() => {
228
+ layers.pastes.add(entry);
229
+ return () => {
230
+ layers.pastes.delete(entry);
117
231
  };
118
232
  }, [layers, entry]);
119
233
  }
@@ -137,8 +251,8 @@ export interface SearchBox {
137
251
  /** The box has focus: what the user types goes into it. */
138
252
  focused: boolean;
139
253
  query: string;
140
- /** Add one typed character. */
141
- append(char: string): void;
254
+ /** Add text: one typed character, or a whole pasted term. */
255
+ append(text: string): void;
142
256
  /** Delete the last character. */
143
257
  erase(): void;
144
258
  /** Empty the box and stay in it. */
@@ -155,8 +269,18 @@ export interface SearchBox {
155
269
  * the box) and Esc (clear the text; on an empty box, leave). Up/Down, Tab and the
156
270
  * scroll keys pass through: the list still moves, tabs still cycle, the panel
157
271
  * still scrolls.
272
+ *
273
+ * It is the input layer for a PASTE too, and the only layer that takes one:
274
+ * pasted text is appended to the query exactly as typing it would be. With no
275
+ * box focused nothing consumes a paste — it must never reach the screen or the
276
+ * global keys, where a clipboard holding `q` would quit the app.
158
277
  */
159
278
  export function useSearchInput(box: SearchBox): void {
279
+ usePasteLayer("input", box.focused, (text) => {
280
+ box.append(text);
281
+ return true;
282
+ });
283
+
160
284
  useKeyLayer("input", box.focused, (key) => {
161
285
  if (!inputLayerConsumes(key)) return false;
162
286
  switch (key.name) {
@@ -24,6 +24,10 @@
24
24
  * - scroll: PgUp / PgDn / Ctrl+U / Ctrl+D scroll the detail panel.
25
25
  * - global: tab digits, Tab, `q`, `?`, Esc.
26
26
  *
27
+ * A PASTE follows the same order through `dispatchPaste`, over the layers that
28
+ * registered a paste handler. It is a separate entry point because a paste is a
29
+ * separate terminal event, not a burst of keys — see {@link PasteLayer}.
30
+ *
27
31
  * Pure: no React, no OpenTUI. `KeyRouter.tsx` feeds it the live layers.
28
32
  */
29
33
 
@@ -88,6 +92,50 @@ export function dispatchKey(
88
92
  return null;
89
93
  }
90
94
 
95
+ /**
96
+ * A layer that can take a PASTE.
97
+ *
98
+ * A terminal does not deliver a paste as keys. With bracketed paste on — and
99
+ * OpenTUI's native core turns it on — the whole clipboard arrives wrapped in
100
+ * `ESC[200~ … ESC[201~` and reaches the renderer as ONE `paste` event; no
101
+ * `keypress` is emitted for any of its characters. So a layer that only
102
+ * implements `handle()` never sees a pasted search term at all.
103
+ *
104
+ * Separate from {@link KeyLayer} because most layers have nothing to do with a
105
+ * paste: a tab digit, `q` or a scroll key is a keystroke, and pasting "q" must
106
+ * not quit.
107
+ */
108
+ export interface PasteLayer {
109
+ id: KeyLayerId;
110
+ /** An inactive layer is skipped without its handler being called. */
111
+ active: boolean;
112
+ /** True when the layer consumed the text; nothing below it will see it. */
113
+ handlePaste(text: string): boolean;
114
+ }
115
+
116
+ /**
117
+ * Offer pasted `text` to the active layers in LAYER_ORDER and stop at the first
118
+ * that consumes it — the same rule {@link dispatchKey} follows, so a paste
119
+ * lands where a keystroke would: a focused search box takes it, a modal above
120
+ * it takes it first, and with neither open nothing consumes it.
121
+ *
122
+ * Empty text is offered to nobody: there is nothing to insert, and a layer that
123
+ * consumed it would only report a phantom edit.
124
+ */
125
+ export function dispatchPaste(
126
+ layers: readonly PasteLayer[],
127
+ text: string,
128
+ ): KeyLayerId | null {
129
+ if (text.length === 0) return null;
130
+ for (const id of LAYER_ORDER) {
131
+ for (const layer of layers) {
132
+ if (layer.id !== id || !layer.active) continue;
133
+ if (layer.handlePaste(text)) return id;
134
+ }
135
+ }
136
+ return null;
137
+ }
138
+
91
139
  /** PgUp, PgDn, Ctrl+U, Ctrl+D — the keys that scroll the detail panel. */
92
140
  export function isScrollKey(key: KeyPress): boolean {
93
141
  if (key.meta) return false;
package/src/ui/keymap.ts CHANGED
@@ -826,7 +826,14 @@ const LEGENDS: Partial<Record<Screen, readonly LegendEntry[]>> = {
826
826
  symbol: "?",
827
827
  meaning: "installed; magus cannot tell whether it is current",
828
828
  },
829
- { symbol: "!", meaning: "installed more than one way; c fixes it" },
829
+ {
830
+ symbol: "!",
831
+ meaning: "installed more than one way; c removes what it can",
832
+ },
833
+ {
834
+ symbol: "⊘",
835
+ meaning: "an install magus cannot remove; take it out by hand",
836
+ },
830
837
  ],
831
838
  };
832
839
 
@@ -3,6 +3,7 @@ import type { CliTool } from "../../data/cli-tools.js";
3
3
  import {
4
4
  type InstallMethod,
5
5
  cliToolUninstall,
6
+ uninstallBlocker,
6
7
  } from "../../services/cli-tool-commands.js";
7
8
  import { formatCommand } from "../../utils/command-utils.js";
8
9
  import {
@@ -47,7 +48,8 @@ function wrapValue(text: string, width: number): string {
47
48
  }
48
49
 
49
50
  /**
50
- * The uninstall command as a line the reader can copy.
51
+ * One line of the conflict list: what happens to this install, and the command
52
+ * that would do it.
51
53
  *
52
54
  * This used to be a second, hand-written copy of the screen's own uninstall
53
55
  * switch — same shape, and already drifted (`pip uninstall` here,
@@ -55,16 +57,26 @@ function wrapValue(text: string, width: number): string {
55
57
  * is not an executable. Now the renderer and the screen ask the same function,
56
58
  * so what is shown is what would run.
57
59
  *
58
- * Empty string when there is nothing to offer, which is what the caller
59
- * interpolates into the conflict list.
60
+ * An install magus cannot remove says so, rather than reading "remove" beside
61
+ * an empty command: the conflict fix will not offer to remove it either
62
+ * (R9.10), and a pane that promises what the dialog refuses is the same lie one
63
+ * step earlier.
60
64
  */
61
- function uninstallHint(
65
+ function removalLine(
62
66
  tool: CliTool,
63
67
  method: InstallMethod,
64
68
  brewFormula?: string,
65
- ): string {
69
+ ): { mark: string; hint: string; removable: boolean } {
70
+ const blocker = uninstallBlocker(tool, method, brewFormula);
71
+ if (blocker !== null) {
72
+ return { mark: " ⊘ stays ", hint: ` ${blocker}`, removable: false };
73
+ }
66
74
  const cmd = cliToolUninstall(tool, method, brewFormula);
67
- return cmd ? formatCommand(cmd) : "";
75
+ return {
76
+ mark: " ○ remove ",
77
+ hint: cmd ? ` ${formatCommand(cmd)}` : "",
78
+ removable: true,
79
+ };
68
80
  }
69
81
 
70
82
  function toneColor(tone: StatusTone): UiColor {
@@ -324,30 +336,56 @@ export function renderCliToolDetail(
324
336
  {"\n"}Keep one, remove the rest:
325
337
  </text>
326
338
  </box>
327
- {allMethods.map((method, i) => (
328
- <box key={method}>
329
- <text fg={theme.colors.text}>
330
- <span fg={i === 0 ? theme.colors.success : theme.colors.danger}>
331
- {i === 0 ? " ● keep " : " ○ remove "}
332
- </span>
333
- <span fg={theme.colors.warning}>{method}</span>
334
- {i > 0 ? (
339
+ {allMethods.map((method, i) => {
340
+ if (i === 0) {
341
+ return (
342
+ <box key={method}>
343
+ <text fg={theme.colors.text}>
344
+ <span fg={theme.colors.success}>{" ● keep "}</span>
345
+ <span fg={theme.colors.warning}>{method}</span>
346
+ <span fg={theme.colors.dim}> (active in PATH)</span>
347
+ </text>
348
+ </box>
349
+ );
350
+ }
351
+ const line = removalLine(tool, method, brewFormula);
352
+ return (
353
+ <box key={method}>
354
+ <text fg={theme.colors.text}>
335
355
  <span
336
- fg={theme.colors.dim}
337
- >{` ${uninstallHint(tool, method, brewFormula)}`}</span>
338
- ) : (
339
- <span fg={theme.colors.dim}> (active in PATH)</span>
340
- )}
356
+ fg={
357
+ line.removable ? theme.colors.danger : theme.colors.muted
358
+ }
359
+ >
360
+ {line.mark}
361
+ </span>
362
+ <span fg={theme.colors.warning}>{method}</span>
363
+ <span fg={theme.colors.dim}>{line.hint}</span>
364
+ </text>
365
+ </box>
366
+ );
367
+ })}
368
+ {/* `c` is advertised only while it can remove something. With
369
+ every install stuck it opens a dialog with no choices in
370
+ it, and a key hint for a key that does nothing reads as a
371
+ hung app. */}
372
+ {allMethods.some(
373
+ (m) => uninstallBlocker(tool, m, brewFormula) === null,
374
+ ) ? (
375
+ <box flexDirection="row" marginTop={1}>
376
+ <text bg={theme.colors.danger} fg={theme.hints.fg}>
377
+ {" "}
378
+ c{" "}
341
379
  </text>
380
+ <text fg={theme.colors.muted}> Resolve — pick which to keep</text>
342
381
  </box>
343
- ))}
344
- <box flexDirection="row" marginTop={1}>
345
- <text bg={theme.colors.danger} fg={theme.hints.fg}>
346
- {" "}
347
- c{" "}
348
- </text>
349
- <text fg={theme.colors.muted}> Resolve — pick which to keep</text>
350
- </box>
382
+ ) : (
383
+ <box marginTop={1}>
384
+ <text fg={theme.colors.muted}>
385
+ magus can remove none of these. Take one out by hand, then r.
386
+ </text>
387
+ </box>
388
+ )}
351
389
  </box>
352
390
  ) : null}
353
391
 
@@ -9,7 +9,11 @@ import { useDimensions } from "../state/DimensionsContext.js";
9
9
 
10
10
  import { cliTools } from "../../data/cli-tools.js";
11
11
  import { loadActiveClosure } from "../../services/active-closure.js";
12
- import { cliToolUninstall } from "../../services/cli-tool-commands.js";
12
+ import {
13
+ type InstallMethod,
14
+ cliToolUninstall,
15
+ uninstallBlocker,
16
+ } from "../../services/cli-tool-commands.js";
13
17
  import {
14
18
  type InstallInfo,
15
19
  PROBE_CONCURRENCY,
@@ -247,6 +251,20 @@ export function CliToolsScreen() {
247
251
  }
248
252
  };
249
253
 
254
+ /**
255
+ * Keep one install of a tool installed more than one way.
256
+ *
257
+ * Two rules, both from R9.10. An install magus cannot remove is never
258
+ * offered as something to remove: a choice reading "Keep npm, remove others"
259
+ * used to include Claude Code's native copy in "others", find no uninstall
260
+ * command for it, `continue` past it and report done — leaving the conflict
261
+ * exactly as it was, with the row still showing "!". And whatever is left
262
+ * behind is named afterwards, rather than silently skipped.
263
+ *
264
+ * Which installs can be removed comes from `cliToolUninstall` itself
265
+ * (through `uninstallBlocker`), so the list of choices and the commands that
266
+ * run cannot disagree about a method.
267
+ */
250
268
  const handleResolveConflict = async () => {
251
269
  const row = rows[cliToolsState.selectedIndex];
252
270
  const install: InstallInfo | undefined = row?.install;
@@ -254,38 +272,81 @@ export function CliToolsScreen() {
254
272
 
255
273
  const { tool } = row;
256
274
  const allMethods = install.all;
275
+ const blockers = new Map(
276
+ allMethods.map((m) => [
277
+ m,
278
+ uninstallBlocker(tool, m, install.brewFormula),
279
+ ]),
280
+ );
281
+ const removable = allMethods.filter((m) => blockers.get(m) === null);
282
+ const stuck = allMethods.filter((m) => blockers.get(m) !== null);
283
+ const stuckList = (methods: readonly InstallMethod[]): string =>
284
+ methods
285
+ .map((m) => `The ${m} install stays: ${blockers.get(m)}.`)
286
+ .join("\n");
287
+
288
+ // One choice per install that can be KEPT — but only where keeping it
289
+ // removes something. "Keep npm" beside an unremovable native copy would
290
+ // promise a removal magus cannot perform.
291
+ const options = allMethods
292
+ .map((keep) => ({ keep, remove: removable.filter((m) => m !== keep) }))
293
+ .filter(({ remove }) => remove.length > 0)
294
+ .map(({ keep, remove }) => ({
295
+ label: `Keep ${keep}${keep === install.primary ? " (active)" : ""}, remove ${remove.join(" + ")}`,
296
+ value: keep,
297
+ }));
257
298
 
258
- // Let user pick which install to keep
259
- const options = allMethods.map((method) => ({
260
- label: `Keep ${method}${method === install.primary ? " (active)" : ""}, remove others`,
261
- value: method,
262
- }));
299
+ if (options.length === 0) {
300
+ await modal.message(
301
+ `${tool.displayName} conflict`,
302
+ `Installed via ${allMethods.join(" + ")}, and magus can remove none of them.\n\n${stuckList(allMethods)}\n\nRemove one by hand, then press r.`,
303
+ "error",
304
+ );
305
+ return;
306
+ }
263
307
 
264
308
  const keep = await modal.select(
265
309
  `Resolve ${tool.displayName} conflict`,
266
- `Installed via ${allMethods.join(" + ")}. Which to keep?`,
310
+ stuck.length > 0
311
+ ? `Installed via ${allMethods.join(" + ")}. Which to keep?\n\n${stuckList(stuck)}`
312
+ : `Installed via ${allMethods.join(" + ")}. Which to keep?`,
267
313
  options,
268
314
  );
269
315
  if (keep === null) return;
270
316
 
271
- const toRemove = allMethods.filter((m) => m !== keep);
317
+ const toRemove = removable.filter((m) => m !== keep);
318
+ const leftBehind = stuck.filter((m) => m !== keep);
272
319
  modal.loading(`Removing ${toRemove.join(", ")} install(s)...`);
273
320
 
274
321
  const errors: string[] = [];
275
322
  for (const method of toRemove) {
276
323
  const cmd = cliToolUninstall(tool, method, install.brewFormula);
277
- if (!cmd) continue;
324
+ // Unreachable: `removable` was built from this same call. Reported
325
+ // rather than skipped, because a silent skip is the defect above.
326
+ if (!cmd) {
327
+ errors.push(`${method}: no uninstall command`);
328
+ continue;
329
+ }
278
330
  const result = await runCommand(cmd);
279
331
  if (!result.ok) errors.push(`${method}: ${formatCommand(cmd)}`);
280
332
  }
281
333
 
282
334
  modal.hideModal();
283
335
 
284
- if (errors.length > 0) {
336
+ const report = [
337
+ errors.length > 0
338
+ ? `These removals failed. Run them by hand:\n${errors.join("\n")}`
339
+ : "",
340
+ leftBehind.length > 0
341
+ ? `${stuckList(leftBehind)}\nRemove it by hand if you want it gone.`
342
+ : "",
343
+ ].filter((part) => part !== "");
344
+
345
+ if (report.length > 0) {
285
346
  await modal.message(
286
- "Partial",
287
- `Some removals failed. Try manually:\n\n${errors.join("\n")}`,
288
- "error",
347
+ errors.length > 0 ? "Partly removed" : "One install stays",
348
+ report.join("\n\n"),
349
+ errors.length > 0 ? "error" : "info",
289
350
  );
290
351
  }
291
352
 
@@ -157,8 +157,12 @@ export function PluginsScreen() {
157
157
  try {
158
158
  const localMarketplaces = await getLocalMarketplacesInfo();
159
159
  const allMarketplaces = getAllMarketplaces(localMarketplaces);
160
- // Read-only, never throws. `resolveBins: false`: this screen needs only
161
- // the plugin pins, not every declared plugin's binary requirements.
160
+ // Read-only, and it throws for exactly one thing: a settings file or an
161
+ // installed_plugins.json that exists and cannot be read. That lands in
162
+ // the catch below and is shown on this screen, which is the point —
163
+ // "nothing installed" would be a lie about a file nobody could read.
164
+ // `resolveBins: false`: this screen needs only the plugin pins, not
165
+ // every declared plugin's binary requirements.
162
166
  const [pluginData, active] = await Promise.all([
163
167
  getAvailablePlugins(state.projectPath),
164
168
  loadActiveClosure(state.projectPath || process.cwd(), {