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.
- package/README.md +9 -8
- package/package.json +4 -4
- package/src/__tests__/blind/c19-paste-routing.test.ts +252 -0
- package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
- package/src/__tests__/install-command.test.ts +4 -3
- package/src/__tests__/models-cli.test.ts +4 -7
- package/src/__tests__/models-manager.test.ts +52 -40
- package/src/__tests__/profile-absorb.test.ts +1138 -0
- package/src/__tests__/profile-command.test.ts +90 -32
- package/src/__tests__/profile-migration-offer.test.ts +18 -15
- package/src/__tests__/profile-migration.test.ts +246 -117
- package/src/__tests__/profile-render.test.ts +141 -0
- package/src/__tests__/profile-skills.test.ts +425 -0
- package/src/__tests__/profile-startup.test.ts +44 -14
- package/src/__tests__/profile-state.test.ts +795 -0
- package/src/__tests__/settings-file.test.ts +5 -7
- package/src/__tests__/stale-load-repair.test.ts +5 -4
- package/src/__tests__/styles-manager.test.ts +47 -74
- package/src/__tests__/styles-screen-state.test.ts +11 -10
- package/src/cli/bootstrap.ts +87 -27
- package/src/cli/doctor.ts +152 -44
- package/src/cli/install.ts +92 -127
- package/src/cli/models.ts +21 -1
- package/src/cli/profile.ts +48 -77
- package/src/cli/router.ts +32 -6
- package/src/cli/update.ts +43 -6
- package/src/data/gitignore-defaults.ts +3 -3
- package/src/services/active-closure.ts +3 -3
- package/src/services/claude-settings.ts +283 -125
- package/src/services/cli-tool-commands.ts +30 -0
- package/src/services/conventions-manager.ts +88 -0
- package/src/services/git-worktree.ts +21 -0
- package/src/services/gitignore-service.ts +39 -22
- package/src/services/manifest.ts +4 -5
- package/src/services/models-manager.ts +36 -73
- package/src/services/profile-absorb.ts +1532 -0
- package/src/services/profile-activate.ts +348 -96
- package/src/services/profile-live.ts +825 -0
- package/src/services/profile-render.ts +115 -0
- package/src/services/profile-skills.ts +462 -0
- package/src/services/profile-startup.ts +88 -16
- package/src/services/profile-state.ts +622 -0
- package/src/services/profiles.ts +20 -32
- package/src/services/resolver.ts +6 -1
- package/src/services/settings-file.ts +14 -8
- package/src/services/skills-manager.ts +14 -8
- package/src/services/styles-manager.ts +45 -28
- package/src/services/tool-probe.ts +48 -3
- package/src/types/index.ts +15 -9
- package/src/ui/App.tsx +25 -2
- package/src/ui/components/modals/ModalContainer.tsx +9 -1
- package/src/ui/hooks/useProfileStartupModal.ts +20 -15
- package/src/ui/input/KeyRouter.tsx +147 -23
- package/src/ui/input/key-dispatch.ts +48 -0
- package/src/ui/keymap.ts +8 -1
- package/src/ui/renderers/cliToolRenderers.tsx +65 -27
- package/src/ui/screens/CliToolsScreen.tsx +74 -13
- package/src/ui/screens/PluginsScreen.tsx +6 -2
- package/src/ui/screens/ProfilesScreen.tsx +75 -27
- package/src/ui/screens/StylesScreen.tsx +2 -1
- package/src/ui/state/types.ts +1 -1
- package/src/__tests__/profile-materializer.test.ts +0 -238
- package/src/__tests__/profile-sync.test.ts +0 -154
- package/src/__tests__/symlink-manager.test.ts +0 -180
- package/src/services/profile-materializer.ts +0 -133
- package/src/services/profile-sync.ts +0 -158
- 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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* system layer (Ctrl+C) is not
|
|
8
|
-
*
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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()}
|
|
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(() =>
|
|
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(
|
|
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
|
-
{
|
|
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
|
-
*
|
|
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
|
-
*
|
|
59
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
<
|
|
331
|
-
{
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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={
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
</
|
|
349
|
-
|
|
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 {
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
287
|
-
|
|
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,
|
|
161
|
-
//
|
|
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(), {
|