magus-cli 7.10.0 → 7.10.2

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.
@@ -52,13 +52,26 @@ import {
52
52
  versionFromVersionedPath,
53
53
  } from "./cli-tool-version.js";
54
54
  import {
55
+ binPinCommand,
56
+ binUpgradeCommand,
55
57
  claudeNativeUpgradeCommand,
56
58
  npmPrefixInstallCommand,
57
59
  } from "./toolchain.js";
58
60
 
59
61
  // ── Types ────────────────────────────────────────────────────────────────────
60
62
 
61
- export type ToolState = "install" | "current" | "upgrade" | "unknown";
63
+ /**
64
+ * `external` is a determined fact, not a failure to check: the tool is on
65
+ * PATH, and something other than the installer magus would use put it there
66
+ * (bun, an official installer, the OS). magus leaves it as is, so it is
67
+ * neither work nor "could not be checked".
68
+ */
69
+ export type ToolState =
70
+ | "install"
71
+ | "current"
72
+ | "upgrade"
73
+ | "external"
74
+ | "unknown";
62
75
 
63
76
  export type ToolReason =
64
77
  | "absent"
@@ -215,16 +228,51 @@ function withFailure(text: string, failure: string | undefined): string {
215
228
  return failure ? `${text} (${failure})` : text;
216
229
  }
217
230
 
218
- /** The `not-managed` sentence. Names the file and its owner, never the version. */
231
+ /** The installer as the user knows it. */
232
+ export function installerWord(via: ManagedTool["via"]): string {
233
+ return via === "brew" ? "Homebrew" : via;
234
+ }
235
+
236
+ /** The directories the operating system itself installs programs into. */
237
+ const SYSTEM_DIRS = ["/bin", "/sbin", "/usr/bin", "/usr/sbin", "/System"];
238
+
239
+ function isSystemPath(resolved: string | null): boolean {
240
+ return (
241
+ resolved !== null && SYSTEM_DIRS.some((dir) => isInside(resolved, dir))
242
+ );
243
+ }
244
+
245
+ /**
246
+ * The `not-managed` sentence, in the user's terms: who did not install it,
247
+ * and what magus does about it. Never the path or the installer's directory:
248
+ * the user acts on neither.
249
+ */
219
250
  function notManagedNote(spec: ManagedTool, facts: ToolFacts): string {
251
+ const pin = pinOf(spec);
252
+ if (facts.managed === false && pin) {
253
+ return `installed outside ${installerWord(spec.via)}, so magus cannot move it to the pin ${pin}`;
254
+ }
255
+ // The KEPT chip already says magus leaves it as is; the note says only who
256
+ // installed it.
220
257
  return facts.managed === false
221
- ? `on PATH at ${facts.resolvedPath ?? "?"}, which ${facts.owner ?? spec.via} does not own — upgrading through ${spec.via} would install a second copy`
258
+ ? isSystemPath(facts.resolvedPath)
259
+ ? "comes with the operating system"
260
+ : `installed outside ${installerWord(spec.via)}`
222
261
  : withFailure(
223
- `could not tell whether ${spec.via} owns ${facts.resolvedPath ?? spec.name}`,
262
+ `could not tell whether ${installerWord(spec.via)} installed it`,
224
263
  facts.failure,
225
264
  );
226
265
  }
227
266
 
267
+ /**
268
+ * Another installer's file is `external`, settled, only while nothing is
269
+ * asked of it. With a pin the profile declares a version magus cannot deliver
270
+ * without a second copy, so that is still a warning: `unknown`.
271
+ */
272
+ function externalOr(pin: string | undefined): ToolState {
273
+ return pin ? "unknown" : "external";
274
+ }
275
+
228
276
  /**
229
277
  * THE tool classifier. Pure. The first matching rule wins, and each branch
230
278
  * returns, so reading top to bottom is reading the precedence.
@@ -271,7 +319,7 @@ export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
271
319
  if (installed === null && facts.managed === false) {
272
320
  return {
273
321
  ...base,
274
- state: "unknown",
322
+ state: externalOr(pin),
275
323
  reason: "not-managed",
276
324
  target: null,
277
325
  note: notManagedNote(spec, facts),
@@ -317,11 +365,12 @@ export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
317
365
  };
318
366
  }
319
367
 
320
- // 7. The veto: the upgrade would not replace the file PATH runs.
368
+ // 7. The veto: the upgrade would not replace the file PATH runs. Known to be
369
+ // another installer's file: `external`. Not known either way: `unknown`.
321
370
  if (facts.managed !== true) {
322
371
  return {
323
372
  ...base,
324
- state: "unknown",
373
+ state: facts.managed === false ? externalOr(pin) : "unknown",
325
374
  reason: "not-managed",
326
375
  target: null,
327
376
  note: notManagedNote(spec, facts),
@@ -1189,6 +1238,9 @@ async function probeBrew(
1189
1238
  resolvedPath: string,
1190
1239
  ): Promise<void> {
1191
1240
  const formula = spec.formula ?? spec.name;
1241
+ // A tap formula ("user/tap/f") keeps its keg under the short name, so the
1242
+ // full name never matched and a Homebrew mnemex read as installed outside it.
1243
+ const keg = formula.slice(formula.lastIndexOf("/") + 1);
1192
1244
  const prefix = await s.brewPrefix();
1193
1245
  if ("failure" in prefix) {
1194
1246
  f.managed = null;
@@ -1198,11 +1250,11 @@ async function probeBrew(
1198
1250
  const cellar =
1199
1251
  (await s.io.realpath(path.join(prefix.dir, "Cellar"))) ??
1200
1252
  path.join(prefix.dir, "Cellar");
1201
- const ownerDir = path.join(cellar, formula);
1253
+ const ownerDir = path.join(cellar, keg);
1202
1254
  f.owner = `brew (Cellar ${ownerDir})`;
1203
1255
  f.managed = isInside(resolvedPath, ownerDir);
1204
1256
  if (!f.managed) return;
1205
- f.installed = brewKegVersion(resolvedPath, cellar, formula);
1257
+ f.installed = brewKegVersion(resolvedPath, cellar, keg);
1206
1258
  if (f.installed) addSource(f, "brew");
1207
1259
 
1208
1260
  const r = await s.run("brew", ["outdated", "--json=v2", formula], BREW_ENV);
@@ -1352,6 +1404,129 @@ async function readNodePackage(
1352
1404
  if (!pinned) await npmLatest(s, pkg, f);
1353
1405
  }
1354
1406
 
1407
+ /** Where `bun add -g` puts packages: `$BUN_INSTALL/install/global/node_modules`. */
1408
+ function bunGlobalRoot(io: ToolProbeIO): string {
1409
+ return path.join(
1410
+ io.env.BUN_INSTALL ?? path.join(io.homedir, ".bun"),
1411
+ "install",
1412
+ "global",
1413
+ "node_modules",
1414
+ );
1415
+ }
1416
+
1417
+ /** The package a path names, from its segments under `node_modules`. */
1418
+ function packageFromSegments(rel: string): string | null {
1419
+ const [first, second] = rel.split(path.sep);
1420
+ if (!first || first.startsWith(".")) return null;
1421
+ if (!first.startsWith("@")) return first;
1422
+ return second ? `${first}/${second}` : null;
1423
+ }
1424
+
1425
+ /**
1426
+ * The bun or npm global package that holds `resolvedPath`, read from the path
1427
+ * alone. Null when the file lies in neither tree (a Homebrew keg included:
1428
+ * `npmGlobalPrefixOf` refuses one).
1429
+ */
1430
+ async function nodeGlobalOwner(
1431
+ s: ProbeSession,
1432
+ resolvedPath: string,
1433
+ ): Promise<{ via: "bun" | "npm"; pkg: string } | null> {
1434
+ const bunRoot = await s.io.realpath(bunGlobalRoot(s.io));
1435
+ if (bunRoot && isInside(resolvedPath, bunRoot)) {
1436
+ const pkg = packageFromSegments(path.relative(bunRoot, resolvedPath));
1437
+ return pkg ? { via: "bun", pkg } : null;
1438
+ }
1439
+ const marker = `${path.sep}${path.join("lib", "node_modules")}${path.sep}`;
1440
+ const at = resolvedPath.lastIndexOf(marker);
1441
+ if (at < 0) return null;
1442
+ const pkg = packageFromSegments(resolvedPath.slice(at + marker.length));
1443
+ return pkg && npmGlobalPrefixOf(resolvedPath, pkg) !== null
1444
+ ? { via: "npm", pkg }
1445
+ : null;
1446
+ }
1447
+
1448
+ /**
1449
+ * The formula whose keg holds `resolvedPath` (`…/Cellar/<formula>/<version>/…`),
1450
+ * read from the path alone. `probeBrew` then checks that this Cellar is brew's.
1451
+ */
1452
+ function kegFormulaOf(resolvedPath: string): string | null {
1453
+ const parts = resolvedPath.split(path.sep);
1454
+ const at = parts.lastIndexOf("Cellar");
1455
+ return at >= 0 && parts.length > at + 2 ? (parts[at + 1] ?? null) : null;
1456
+ }
1457
+
1458
+ /**
1459
+ * The spec as the installer that actually holds `resolvedPath` would state it,
1460
+ * or null when that is the spec's own installer or none magus drives.
1461
+ */
1462
+ async function actualOwnerSpec(
1463
+ s: ProbeSession,
1464
+ spec: ManagedTool,
1465
+ resolvedPath: string,
1466
+ ): Promise<ManagedTool | null> {
1467
+ const base = {
1468
+ name: spec.name,
1469
+ ...(spec.check ? { check: spec.check } : {}),
1470
+ ...(spec.version ? { version: spec.version } : {}),
1471
+ };
1472
+ const node = await nodeGlobalOwner(s, resolvedPath);
1473
+ if (node) {
1474
+ return node.via === spec.via
1475
+ ? null
1476
+ : { ...base, via: node.via, package: node.pkg };
1477
+ }
1478
+ const formula = kegFormulaOf(resolvedPath);
1479
+ // Homebrew takes no version, so it cannot honour a pin: such a tool stays
1480
+ // "installed outside", which the classifier reports as unknown.
1481
+ if (!formula || spec.via === "brew" || spec.version) return null;
1482
+ return { ...base, via: "brew", formula };
1483
+ }
1484
+
1485
+ /**
1486
+ * The spec's installer does not own the file PATH runs, but bun's or npm's
1487
+ * global tree or a Homebrew keg does: re-read the tool through THAT installer.
1488
+ *
1489
+ * A plugin's `requires` lists its install options in preference order for a
1490
+ * FRESH install, and magus plans through the first. That order says how to
1491
+ * install a missing tool, not which owner an installed one must have. A
1492
+ * mnemex from `bun add -g` or from Homebrew has a version, a registry and an
1493
+ * upgrade command like any other. Reading it as "installed outside" the
1494
+ * first option's installer showed a settled, upgradeable tool as one magus
1495
+ * could not touch.
1496
+ *
1497
+ * The catalogue's tools already follow how a tool IS installed
1498
+ * (`specForCatalogTool`); this gives a plugin's requirements the same rule.
1499
+ * The upgrade command is carried on the facts, so `magus update` and the TUI
1500
+ * run the owner's command rather than the spec's.
1501
+ */
1502
+ async function adoptActualOwner(
1503
+ s: ProbeSession,
1504
+ spec: ManagedTool,
1505
+ f: ToolFacts,
1506
+ resolvedPath: string,
1507
+ ): Promise<void> {
1508
+ const actual = await actualOwnerSpec(s, spec, resolvedPath);
1509
+ if (!actual) return;
1510
+ const read = blankFacts(spec.name);
1511
+ if (actual.via === "brew") await probeBrew(s, actual, read, resolvedPath);
1512
+ else await probeNodeGlobal(s, actual, read, resolvedPath);
1513
+ if (read.managed !== true) return;
1514
+ f.managed = true;
1515
+ f.owner = read.owner;
1516
+ f.installed = read.installed;
1517
+ f.latest = read.latest;
1518
+ f.behind = read.behind;
1519
+ // What the spec's installer could not answer is moot once the owner is
1520
+ // known: only the owner's own failure describes this tool.
1521
+ f.failure = read.failure;
1522
+ for (const source of read.sources) addSource(f, source);
1523
+ f.upgradeCommand =
1524
+ read.upgradeCommand ??
1525
+ (spec.version
1526
+ ? (binPinCommand(actual) ?? undefined)
1527
+ : binUpgradeCommand(actual));
1528
+ }
1529
+
1355
1530
  async function probeNodeGlobal(
1356
1531
  s: ProbeSession,
1357
1532
  spec: ManagedTool,
@@ -1362,12 +1537,7 @@ async function probeNodeGlobal(
1362
1537
  const pin = pinOf(spec);
1363
1538
  const pinned = pin !== undefined;
1364
1539
  if (spec.via === "bun") {
1365
- const root = path.join(
1366
- s.io.env.BUN_INSTALL ?? path.join(s.io.homedir, ".bun"),
1367
- "install",
1368
- "global",
1369
- "node_modules",
1370
- );
1540
+ const root = bunGlobalRoot(s.io);
1371
1541
  f.owner = `bun (global ${root})`;
1372
1542
  const { inside, real } = await containedIn(
1373
1543
  s,
@@ -1620,6 +1790,11 @@ async function probeInto(
1620
1790
  break;
1621
1791
  }
1622
1792
 
1793
+ // Not the spec's installer's file, or it could not tell (npm with no `npm`
1794
+ // to ask), but the path names bun's, npm's or Homebrew's tree: then that is
1795
+ // the owner, and it can upgrade the file PATH runs.
1796
+ if (f.managed !== true) await adoptActualOwner(s, spec, f, resolvedPath);
1797
+
1623
1798
  // The method's own record was silent. Before anything runs, read what the
1624
1799
  // installed files already say about themselves.
1625
1800
  if (f.installed === null) versionFromFiles(f, resolvedPath);
@@ -397,7 +397,7 @@ async function readRows(
397
397
  * The row Claude Code moved is left as it is: it belongs to this repository,
398
398
  * and bringing this repository's rows forward is what the repair does anyway.
399
399
  * Null when there was nothing to follow — this project's row moved (2.1.280),
400
- * or no row of this repository moved at all.
400
+ * or no row of this repository moved at all and none already holds `target`.
401
401
  */
402
402
  async function followMisdirectedUpdate(
403
403
  deps: PluginApplyDeps,
@@ -422,14 +422,32 @@ async function followMisdirectedUpdate(
422
422
  scope,
423
423
  projectPath: scope === "user" ? undefined : projectPath,
424
424
  });
425
- if (moved.length === 0 || moved.some((row) => rowKey(row) === mine)) {
426
- return null;
427
- }
428
- const version = moved
425
+ if (moved.some((row) => rowKey(row) === mine)) return null;
426
+ // The row Claude Code picked may ALREADY hold the target — an earlier run
427
+ // from another checkout put it there. Then the update writes nothing, so
428
+ // nothing moves, and this project's row stays behind on every run (seen on
429
+ // 2.1.296: "the installer exited 0 but mnemex@magus is still 1.1.0" while
430
+ // tools/claudeup held 1.1.1). A row of this repository at exactly the
431
+ // target is the same misdirection, already landed.
432
+ const landed =
433
+ moved.length > 0 || !target
434
+ ? moved
435
+ : after.filter(
436
+ (row) =>
437
+ row.version === target &&
438
+ rowKey(row) !== mine &&
439
+ inThisRepository(row, projectPath),
440
+ );
441
+ if (landed.length === 0) return null;
442
+ const version = landed
429
443
  .map((row) => row.version)
430
444
  .reduce((a, b) => ((compareVersions(b, a) ?? 0) > 0 ? b : a));
431
445
  if (!isRepairablyBehind(version, now)) return null;
432
- const elsewhere = moved.map((row) => row.projectPath ?? `${row.scope} scope`);
446
+ // Already landed, every checkout at the target matches, but Claude Code
447
+ // wrote to one of them: report one folder, not all of them.
448
+ const elsewhere = (moved.length > 0 ? landed : landed.slice(0, 1)).map(
449
+ (row) => row.projectPath ?? `${row.scope} scope`,
450
+ );
433
451
  if (target && version !== target) {
434
452
  return {
435
453
  elsewhere,
@@ -1235,8 +1235,10 @@ export function summarizePlan(plan: UpdatePlan): {
1235
1235
  unknownPlugins: number;
1236
1236
  unknownTools: number;
1237
1237
  /**
1238
- * No longer published by their marketplace, and kept on purpose: moved, or
1239
- * still required. A determined fact, so not part of `unknown`.
1238
+ * Left as they are, on purpose. Plugins no longer published by their
1239
+ * marketplace and kept (moved, or still required), and tools another
1240
+ * installer put on PATH (`external`). Determined facts, so not part of
1241
+ * `unknown`.
1240
1242
  */
1241
1243
  kept: number;
1242
1244
  } {
@@ -1244,8 +1246,11 @@ export function summarizePlan(plan: UpdatePlan): {
1244
1246
  plan.plugins.filter((p) => p.action === a).length;
1245
1247
  const bins = (a: BinUpdateAction) =>
1246
1248
  plan.bins.filter((b) => b.action === a).length;
1247
- const kept = plan.plugins.filter(isKeptDeprecated).length;
1248
- const unknownPlugins = count("unknown") - kept;
1249
+ const keptPlugins = plan.plugins.filter(isKeptDeprecated).length;
1250
+ const unknownPlugins = count("unknown") - keptPlugins;
1251
+ // A tool another installer put on PATH is left as it is: settled, like a
1252
+ // kept plugin, and never "could not be checked".
1253
+ const kept = keptPlugins + bins("external");
1249
1254
  const unknownTools = bins("unknown");
1250
1255
  return {
1251
1256
  installed: count("install") + bins("install"),
@@ -29,6 +29,7 @@ import {
29
29
  type ToolReason,
30
30
  type ToolStatus,
31
31
  classifyTool,
32
+ installerWord,
32
33
  } from "../../services/tool-probe.js";
33
34
  import { binCommandForState } from "../../services/toolchain.js";
34
35
  import type { Presence } from "../../shared/plugin/hooks/lib/requires-probe.js";
@@ -246,7 +247,9 @@ export function cliToolEnterAction(row: CliToolRow): CliToolEnterAction | null {
246
247
  command,
247
248
  };
248
249
  }
249
- if (status.state === "unknown") {
250
+ // Another installer's file, or one magus could not place: still offered,
251
+ // as an explicit choice, with the note beside it (see the renderer).
252
+ if (status.state === "unknown" || status.state === "external") {
250
253
  return {
251
254
  kind: "update-anyway",
252
255
  label: "update anyway",
@@ -280,13 +283,14 @@ export interface CliToolStatusText {
280
283
  * line, the same string `magus update` prints.
281
284
  *
282
285
  * Every label is at most 19 cells, so the row suffix (" ? " + label) stays
283
- * within 24. Keyed by reason alone — plus the spec's installer, which is a
284
- * word, never a path.
286
+ * within 24. Keyed by reason alone.
285
287
  */
286
- function unknownLabel(reason: ToolReason, via: ManagedTool["via"]): string {
288
+ function unknownLabel(reason: ToolReason): string {
287
289
  switch (reason) {
290
+ // Only the undetermined case: a file known to be another installer's
291
+ // is `external`, not `unknown`.
288
292
  case "not-managed":
289
- return `not managed by ${via}`;
293
+ return "owner not known";
290
294
  case "version-unreadable":
291
295
  return "version unreadable";
292
296
  case "latest-unavailable":
@@ -321,9 +325,14 @@ export function cliToolStatusText(row: CliToolRow): CliToolStatusText {
321
325
  switch (status.state) {
322
326
  case "unknown":
323
327
  return {
324
- suffix: ` ? ${unknownLabel(status.reason, row.spec.via)}`,
328
+ suffix: ` ? ${unknownLabel(status.reason)}`,
325
329
  tone: "warning",
326
330
  };
331
+ case "external":
332
+ return {
333
+ suffix: ` · outside ${installerWord(row.spec.via)}`,
334
+ tone: "muted",
335
+ };
327
336
  case "upgrade":
328
337
  return {
329
338
  suffix: status.target
@@ -250,6 +250,10 @@ export function renderCliToolRow(
250
250
  } else if (status?.state === "unknown") {
251
251
  icon = "?";
252
252
  iconColor = theme.colors.warning;
253
+ } else if (status?.state === "external") {
254
+ // Installed, and left alone on purpose: present, not a finding.
255
+ icon = "●";
256
+ iconColor = theme.colors.muted;
253
257
  } else {
254
258
  icon = "●";
255
259
  iconColor = theme.colors.success;
@@ -353,6 +357,8 @@ export function renderCliToolDetail(
353
357
  </text>
354
358
  ) : state === "upgrade" ? (
355
359
  <text fg={theme.colors.warning}>{"● Update available"}</text>
360
+ ) : state === "external" ? (
361
+ <text fg={theme.colors.muted}>{"● Installed, kept as is"}</text>
356
362
  ) : state === "unknown" ? (
357
363
  // A version that was read but cannot be checked for updates is
358
364
  // still a version: say "installed", and let Why say the rest.
@@ -365,12 +371,17 @@ export function renderCliToolDetail(
365
371
  <text fg={theme.colors.success}>{"● Up to date"}</text>
366
372
  )}
367
373
  </box>
368
- {state === "unknown" && status?.note ? (
374
+ {(state === "unknown" || state === "external") && status?.note ? (
369
375
  <box flexDirection="row">
370
376
  <text fg={theme.colors.muted} flexShrink={0}>
371
377
  {"Why "}
372
378
  </text>
373
- <text fg={theme.colors.warning} minWidth={0}>
379
+ <text
380
+ fg={
381
+ state === "external" ? theme.colors.muted : theme.colors.warning
382
+ }
383
+ minWidth={0}
384
+ >
374
385
  {wrapValue(status.note, width)}
375
386
  </text>
376
387
  </box>