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.
@@ -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(), {