magus-cli 7.4.0 → 7.4.1
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/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__/stale-load-repair.test.ts +5 -4
- package/src/cli/doctor.ts +20 -6
- package/src/cli/install.ts +34 -9
- package/src/services/claude-settings.ts +283 -125
- package/src/services/cli-tool-commands.ts +30 -0
- package/src/services/gitignore-service.ts +39 -22
- package/src/services/settings-file.ts +9 -3
- package/src/services/tool-probe.ts +48 -3
- package/src/ui/components/modals/ModalContainer.tsx +9 -1
- 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
|
@@ -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(), {
|