magus-cli 7.10.4 → 7.11.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 (38) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blind/plugin-deps/doctor.test.ts +2 -2
  3. package/src/__tests__/blind/plugin-deps/hook.test.ts +2 -2
  4. package/src/__tests__/blind/plugin-deps/read-only.test.ts +1 -1
  5. package/src/__tests__/blind/plugin-deps/schema.test.ts +6 -6
  6. package/src/__tests__/blind/requires-min-version/TEST-PLAN.md +54 -0
  7. package/src/__tests__/blind/requires-min-version/manifests.test.ts +68 -0
  8. package/src/__tests__/blind/requires-min-version/parse.test.ts +266 -0
  9. package/src/__tests__/blind/requires-min-version/probe.test.ts +359 -0
  10. package/src/__tests__/blind/requires-min-version/render.test.ts +252 -0
  11. package/src/__tests__/blind/requires-min-version/support.ts +83 -0
  12. package/src/__tests__/blind/requires-min-version/surfaces.test.ts +231 -0
  13. package/src/__tests__/blind/review-followups/bun-install-path.test.ts +1 -1
  14. package/src/__tests__/blind/review-followups/ledger-permissions.test.ts +1 -1
  15. package/src/__tests__/blind/review-followups/ledger-pruning.test.ts +1 -1
  16. package/src/__tests__/blind/review-followups/path-advice.test.ts +1 -1
  17. package/src/__tests__/deps-check-hook.test.ts +9 -9
  18. package/src/__tests__/deps-plan.test.ts +1 -1
  19. package/src/__tests__/deps-report.test.ts +5 -5
  20. package/src/__tests__/doctor-json.test.ts +2 -2
  21. package/src/__tests__/marketplace-refresh.test.ts +1 -1
  22. package/src/__tests__/plugin-requires.test.ts +2 -2
  23. package/src/__tests__/profile-worktree-include.test.ts +212 -0
  24. package/src/__tests__/requires-probe.test.ts +2 -2
  25. package/src/cli/deps-view.ts +5 -3
  26. package/src/services/deps-plan.ts +38 -14
  27. package/src/services/deps-report.ts +24 -7
  28. package/src/services/doctor-bins.ts +40 -12
  29. package/src/services/profile-activate.ts +9 -0
  30. package/src/services/profile-live.ts +26 -0
  31. package/src/services/profile-skills.ts +14 -4
  32. package/src/services/profile-state.ts +18 -0
  33. package/src/services/resolver.ts +1 -0
  34. package/src/shared/plugin/hooks/deps-check.ts +7 -3
  35. package/src/shared/plugin/hooks/lib/requires-probe.ts +257 -33
  36. package/src/ui/adapters/cliToolsAdapter.ts +6 -1
  37. package/src/ui/renderers/cliToolRenderers.tsx +7 -1
  38. package/src/ui/screens/CliToolsScreen.tsx +8 -1
@@ -24,8 +24,11 @@ import { createHash } from "node:crypto";
24
24
  import { constants, accessSync, readFileSync, statSync } from "node:fs";
25
25
  import { delimiter, join } from "node:path";
26
26
 
27
- /** The one `requires` shape this reader knows. Any change to it bumps this. */
28
- export const REQUIRES_SCHEMA = 1;
27
+ /**
28
+ * The one `requires` shape this reader knows. Any change to it bumps this.
29
+ * 2 added a requirement's optional `minVersion`.
30
+ */
31
+ export const REQUIRES_SCHEMA = 2;
29
32
 
30
33
  export type Os = "darwin" | "linux";
31
34
  export type Arch = "amd64" | "arm64";
@@ -129,6 +132,11 @@ export interface Requirement {
129
132
  name: string;
130
133
  check: string[];
131
134
  os?: Os[];
135
+ /**
136
+ * The lowest version that will do, `X.Y.Z`. The check's output is read for
137
+ * the version it reports; one below this is `outdated`.
138
+ */
139
+ minVersion?: string;
132
140
  install: InstallOption[];
133
141
  }
134
142
 
@@ -154,6 +162,10 @@ const SHA256_RE = /^[0-9a-f]{64}$/;
154
162
  const MODULE_RE = /^[A-Za-z_][A-Za-z0-9_.]*$/;
155
163
  const SCRIPT_RE = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
156
164
  const ROOT_TOKEN = "${CLAUDE_PLUGIN_ROOT}";
165
+ /** A `minVersion`: three numeric parts, no `v`, no prerelease. */
166
+ const MIN_VERSION_RE = /^\d+\.\d+\.\d+$/;
167
+ /** The version a check reports: the first `X.Y.Z` anywhere in its output. */
168
+ const FOUND_VERSION_RE = /(\d+)\.(\d+)\.(\d+)/;
157
169
 
158
170
  const PROFILE_ONLY: readonly Via[] = ["npm", "pip", "go"];
159
171
 
@@ -185,6 +197,44 @@ export function releaseAsset(
185
197
  .replaceAll("{arch}", option.arch?.[arch] ?? arch);
186
198
  }
187
199
 
200
+ // ─── versions ─────────────────────────────────────────────────────────────────
201
+
202
+ /** `X.Y.Z` (exactly, as `minVersion` spells it) as numbers, or null. */
203
+ export function parseVersion(v: string): [number, number, number] | null {
204
+ if (!MIN_VERSION_RE.test(v)) return null;
205
+ const [a = 0, b = 0, c = 0] = v.split(".").map(Number);
206
+ return [a, b, c];
207
+ }
208
+
209
+ /**
210
+ * Compare two `X.Y.Z` versions numerically, part by part: negative when `a`
211
+ * is lower, zero when equal, positive when higher. Either one malformed
212
+ * compares as equal; callers parse before they compare.
213
+ */
214
+ export function compareVersions(a: string, b: string): number {
215
+ const x = parseVersion(a);
216
+ const y = parseVersion(b);
217
+ if (!x || !y) return 0;
218
+ for (let i = 0; i < 3; i++) {
219
+ const d = (x[i] ?? 0) - (y[i] ?? 0);
220
+ if (d !== 0) return d;
221
+ }
222
+ return 0;
223
+ }
224
+
225
+ /**
226
+ * The version a check's output reports: the first `X.Y.Z` in stdout, or in
227
+ * stderr when stdout has none. A suffix after it (`10.7.0-beta.1`) is not
228
+ * read, so a prerelease satisfies its release. Null when neither has one.
229
+ */
230
+ export function foundVersion(stdout: string, stderr: string): string | null {
231
+ return (
232
+ stdout.match(FOUND_VERSION_RE)?.[0] ??
233
+ stderr.match(FOUND_VERSION_RE)?.[0] ??
234
+ null
235
+ );
236
+ }
237
+
188
238
  /** The keys each `via` allows, besides `via` and `os`. */
189
239
  const OPTION_KEYS: Record<Via, readonly string[]> = {
190
240
  bun: ["package", "version"],
@@ -449,7 +499,12 @@ function parseRequirement(
449
499
  errors.push(`${at}: must be an object`);
450
500
  return null;
451
501
  }
452
- unknownKeys(at, raw, ["name", "check", "os", "install"], errors);
502
+ unknownKeys(
503
+ at,
504
+ raw,
505
+ ["name", "check", "os", "minVersion", "install"],
506
+ errors,
507
+ );
453
508
  const name = raw.name;
454
509
  if (typeof name !== "string" || !NAME_RE.test(name)) {
455
510
  errors.push(`${at}.name: missing or malformed`);
@@ -469,6 +524,16 @@ function parseRequirement(
469
524
  }
470
525
  }
471
526
  const os = parseOs(named, raw.os, errors);
527
+ let minVersion: string | undefined;
528
+ if (raw.minVersion !== undefined) {
529
+ if (typeof raw.minVersion !== "string" || !parseVersion(raw.minVersion)) {
530
+ errors.push(
531
+ `${named}.minVersion: must be X.Y.Z (three numbers, no "v", no prerelease)`,
532
+ );
533
+ } else {
534
+ minVersion = raw.minVersion;
535
+ }
536
+ }
472
537
  if (!Array.isArray(raw.install) || raw.install.length === 0) {
473
538
  errors.push(`${named}.install: must be a non-empty list of options`);
474
539
  return null;
@@ -476,15 +541,59 @@ function parseRequirement(
476
541
  const install = raw.install
477
542
  .map((o, i) => parseOption(`${named}.install[${i}]`, o, name, errors))
478
543
  .filter((o): o is InstallOption => o !== null);
544
+ if (minVersion !== undefined) {
545
+ install.forEach((option, i) =>
546
+ checkPinFloor(`${named}.install[${i}]`, option, minVersion, errors),
547
+ );
548
+ }
479
549
  const requirement: Requirement = {
480
550
  name,
481
551
  check: isStrArray(check) ? check : [],
482
552
  install,
483
553
  };
484
554
  if (os) requirement.os = os;
555
+ if (minVersion !== undefined) requirement.minVersion = minVersion;
485
556
  return requirement;
486
557
  }
487
558
 
559
+ /**
560
+ * A pinned option must install at least `minVersion`: a pin below the floor
561
+ * installs a binary the probe then reports as outdated. A release's `tag`
562
+ * (one leading `v` removed) must be `X.Y.Z`; a bun `version` is compared only
563
+ * when it is an exact `X.Y.Z`, never a range or a dist-tag.
564
+ */
565
+ function checkPinFloor(
566
+ at: string,
567
+ option: InstallOption,
568
+ minVersion: string,
569
+ errors: string[],
570
+ ): void {
571
+ if (option.via === "release") {
572
+ if (!option.tag) return; // already reported as malformed
573
+ const tag = option.tag.replace(/^v/, "");
574
+ if (!parseVersion(tag)) {
575
+ errors.push(
576
+ `${at}.tag: "${option.tag}" is not X.Y.Z, so it cannot be held to minVersion ${minVersion}`,
577
+ );
578
+ } else if (compareVersions(tag, minVersion) < 0) {
579
+ errors.push(
580
+ `${at}.tag: "${option.tag}" is below minVersion ${minVersion}; it would install an outdated binary`,
581
+ );
582
+ }
583
+ return;
584
+ }
585
+ if (
586
+ option.via === "bun" &&
587
+ option.version !== undefined &&
588
+ parseVersion(option.version) &&
589
+ compareVersions(option.version, minVersion) < 0
590
+ ) {
591
+ errors.push(
592
+ `${at}.version: ${option.version} is below minVersion ${minVersion}; it would install an outdated package`,
593
+ );
594
+ }
595
+ }
596
+
488
597
  /**
489
598
  * Parse the `requires` key of a plugin.json. Nothing else in the manifest is
490
599
  * read. A manifest without `requires` declares nothing and parses as empty.
@@ -507,7 +616,9 @@ export function parseRequires(pluginJson: unknown): ParseResult {
507
616
  return {
508
617
  ok: false,
509
618
  kind: "invalid",
510
- errors: ["requires.schema: missing (this reader reads schema 1)"],
619
+ errors: [
620
+ `requires.schema: missing (this reader reads schema ${REQUIRES_SCHEMA})`,
621
+ ],
511
622
  };
512
623
  }
513
624
  if (schema > REQUIRES_SCHEMA) {
@@ -589,7 +700,9 @@ export function dedupeKey(r: Requirement): string {
589
700
  * binary (`check[0]` is the requirement's name) is decided by the PATH lookup
590
701
  * alone, so a session start never waits on a slow `--version`; a dangling
591
702
  * symlink still reads as missing. Every other check runs, because it answers
592
- * something a lookup cannot (a script env, a marker file).
703
+ * something a lookup cannot (a script env, a marker file) — and so does every
704
+ * check of a requirement with a `minVersion`, because a lookup cannot read a
705
+ * version.
593
706
  *
594
707
  * "full" (`magus doctor`): every check runs.
595
708
  */
@@ -771,7 +884,9 @@ export type Presence =
771
884
  | { name: string; status: "present" }
772
885
  | { name: string; status: "missing"; blockedBy?: string }
773
886
  | { name: string; status: "off-path"; prefix: UserPrefix }
774
- | { name: string; status: "unknown"; reason: string };
887
+ | { name: string; status: "unknown"; reason: string }
888
+ /** The check ran and reported `found`, below the requirement's `minVersion`. */
889
+ | { name: string; status: "outdated"; found: string; minVersion: string };
775
890
 
776
891
  /** The check argv with `${CLAUDE_PLUGIN_ROOT}` expanded, and nothing else. */
777
892
  export function expandCheck(r: Requirement, pluginRoot: string): string[] {
@@ -788,35 +903,78 @@ function offPathPrefix(tool: string, home: string): UserPrefix | null {
788
903
  return null;
789
904
  }
790
905
 
791
- function runCheck(
792
- argv: string[],
793
- env: ProbeEnv,
794
- ): Promise<"pass" | "fail" | "timeout"> {
906
+ /** How much of each output stream a check keeps: a version is near the top. */
907
+ const OUTPUT_KEEP_BYTES = 64 * 1024;
908
+ /** How the `unknown` of a check that printed no version begins. */
909
+ const NO_VERSION = "no version in";
910
+ /** How long after a check exits its output may take to end. */
911
+ const CLOSE_GRACE_MS = 250;
912
+
913
+ interface CheckResult {
914
+ outcome: "pass" | "fail" | "timeout";
915
+ stdout: string;
916
+ stderr: string;
917
+ }
918
+
919
+ /**
920
+ * Run one check argv, no shell, under `env.capMs`. Its output is kept (up to
921
+ * {@link OUTPUT_KEEP_BYTES} a stream) for the version a `minVersion` reads.
922
+ * A check that exited but left its output open (a daemon it started holds the
923
+ * pipe) is decided by its exit, not called slow.
924
+ */
925
+ function runCheck(argv: string[], env: ProbeEnv): Promise<CheckResult> {
795
926
  return new Promise((resolve) => {
796
927
  const [cmd = "", ...args] = argv;
797
928
  const exe = onPath(cmd, env.path);
798
929
  if (!exe) {
799
- resolve("fail");
930
+ resolve({ outcome: "fail", stdout: "", stderr: "" });
800
931
  return;
801
932
  }
933
+ const out: Buffer[] = [];
934
+ const err: Buffer[] = [];
935
+ const keep = (into: Buffer[]) => (chunk: Buffer) => {
936
+ const held = into.reduce((n, b) => n + b.length, 0);
937
+ if (held < OUTPUT_KEEP_BYTES) into.push(chunk);
938
+ };
939
+ let exitCode: number | null | undefined;
802
940
  let settled = false;
803
- const settle = (outcome: "pass" | "fail" | "timeout"): void => {
941
+ const settle = (outcome: CheckResult["outcome"]): void => {
804
942
  if (settled) return;
805
943
  settled = true;
806
944
  clearTimeout(timer);
807
- resolve(outcome);
945
+ // A pipe a daemon still holds must not keep this process alive.
946
+ child.stdout?.destroy();
947
+ child.stderr?.destroy();
948
+ resolve({
949
+ outcome,
950
+ stdout: Buffer.concat(out).toString("utf8"),
951
+ stderr: Buffer.concat(err).toString("utf8"),
952
+ });
808
953
  };
809
954
  const child = spawn(exe, args, {
810
955
  cwd: env.pluginRoot,
811
956
  env: { ...process.env, PATH: env.path, HOME: env.home },
812
- stdio: "ignore",
957
+ stdio: ["ignore", "pipe", "pipe"],
813
958
  });
959
+ child.stdout?.on("data", keep(out));
960
+ child.stderr?.on("data", keep(err));
814
961
  const timer = setTimeout(() => {
815
962
  child.kill("SIGKILL");
816
- settle("timeout");
963
+ if (exitCode !== undefined) settle(exitCode === 0 ? "pass" : "fail");
964
+ else settle("timeout");
817
965
  }, env.capMs);
966
+ const byExit = () => settle(exitCode === 0 ? "pass" : "fail");
818
967
  child.on("error", () => settle("fail"));
819
- child.on("exit", (code) => settle(code === 0 ? "pass" : "fail"));
968
+ child.on("exit", (code) => {
969
+ exitCode = code;
970
+ // The output normally ends at once; a pipe held open past the grace
971
+ // costs that grace, never the whole cap.
972
+ setTimeout(byExit, CLOSE_GRACE_MS).unref();
973
+ });
974
+ // "close" comes after the output streams end, so every byte is read.
975
+ child.on("close", (code) =>
976
+ settle((exitCode ?? code) === 0 ? "pass" : "fail"),
977
+ );
820
978
  });
821
979
  }
822
980
 
@@ -832,13 +990,31 @@ function notFound(r: Requirement, tool: string, env: ProbeEnv): Presence {
832
990
  async function probeOne(r: Requirement, env: ProbeEnv): Promise<Presence> {
833
991
  const argv = expandCheck(r, env.pluginRoot);
834
992
  const tool = argv[0] ?? "";
835
- if (env.depth === "presence" && tool === r.name) {
993
+ // A lookup cannot read a version: a requirement with a floor always runs.
994
+ if (
995
+ env.depth === "presence" &&
996
+ tool === r.name &&
997
+ r.minVersion === undefined
998
+ ) {
836
999
  return onPath(tool, env.path)
837
1000
  ? { name: r.name, status: "present" }
838
1001
  : notFound(r, tool, env);
839
1002
  }
840
- const outcome = await runCheck(argv, env);
841
- if (outcome === "pass") return { name: r.name, status: "present" };
1003
+ const { outcome, stdout, stderr } = await runCheck(argv, env);
1004
+ if (outcome === "pass") {
1005
+ if (r.minVersion === undefined) return { name: r.name, status: "present" };
1006
+ const found = foundVersion(stdout, stderr);
1007
+ if (found === null) {
1008
+ return {
1009
+ name: r.name,
1010
+ status: "unknown",
1011
+ reason: `${NO_VERSION} "${argv.join(" ")}" output`,
1012
+ };
1013
+ }
1014
+ return compareVersions(found, r.minVersion) < 0
1015
+ ? { name: r.name, status: "outdated", found, minVersion: r.minVersion }
1016
+ : { name: r.name, status: "present" };
1017
+ }
842
1018
  if (outcome === "timeout") {
843
1019
  return {
844
1020
  name: r.name,
@@ -852,7 +1028,8 @@ async function probeOne(r: Requirement, env: ProbeEnv): Promise<Presence> {
852
1028
  /**
853
1029
  * Probe every requirement that applies on `env.os`, in parallel. A check whose
854
1030
  * argv[0] is another declared requirement waits for it, and is `missing` with
855
- * `blockedBy` when that one is not present — its own check could not run.
1031
+ * `blockedBy` when that one is neither present nor outdated — its own check
1032
+ * could not run.
856
1033
  */
857
1034
  export async function probe(
858
1035
  reqs: Requirement[],
@@ -876,9 +1053,10 @@ export async function probe(
876
1053
  .map(async (r) => {
877
1054
  const dep = dependsOn(r) as string;
878
1055
  const depStatus = first.get(dep)?.status;
1056
+ // An outdated tool still exists and runs, so its dependents' checks can.
879
1057
  first.set(
880
1058
  r.name,
881
- depStatus === "present"
1059
+ depStatus === "present" || depStatus === "outdated"
882
1060
  ? await probeOne(r, env)
883
1061
  : { name: r.name, status: "missing", blockedBy: dep },
884
1062
  );
@@ -1022,23 +1200,44 @@ export function uvScriptArgv(
1022
1200
  return [uv, ...sub, "--no-config", "--script", script];
1023
1201
  }
1024
1202
 
1025
- /** The one rendering of an option into the step that installs it. */
1203
+ /**
1204
+ * Why a step installs: the requirement is `missing`, or it is there but
1205
+ * `outdated` and the step must replace it.
1206
+ */
1207
+ export type InstallReason = "missing" | "outdated";
1208
+
1209
+ /**
1210
+ * The one rendering of an option into the step that installs it. An
1211
+ * `outdated` requirement is upgraded where the installer tells the two apart:
1212
+ * bun asks for a version (the pin, or `latest`, so an installed copy is not
1213
+ * kept), Homebrew runs `upgrade`. Every other option installs the same way
1214
+ * either way.
1215
+ */
1026
1216
  export function renderInstall(
1027
1217
  r: Requirement,
1028
1218
  option: InstallOption,
1029
1219
  env: Pick<ProbeEnv, "os" | "arch" | "pluginRoot">,
1220
+ reason: InstallReason = "missing",
1030
1221
  ): InstallStep {
1031
1222
  const name = r.name;
1032
1223
  const argv = (args: string[], escalation: "none" | "sudo" = "none") =>
1033
1224
  ({ kind: "argv", name, escalation, argv: args }) as const;
1225
+ const brewVerb = reason === "outdated" ? "upgrade" : "install";
1034
1226
  switch (option.via) {
1035
1227
  case "bun":
1036
- return argv(["bun", "add", "-g", pinned(option.package, option.version)]);
1228
+ return argv([
1229
+ "bun",
1230
+ "add",
1231
+ "-g",
1232
+ reason === "outdated"
1233
+ ? `${option.package}@${option.version ?? "latest"}`
1234
+ : pinned(option.package, option.version),
1235
+ ]);
1037
1236
  case "brew":
1038
1237
  return argv(
1039
1238
  option.cask !== undefined
1040
- ? ["brew", "install", "--cask", option.cask]
1041
- : ["brew", "install", option.formula],
1239
+ ? ["brew", brewVerb, "--cask", option.cask]
1240
+ : ["brew", brewVerb, option.formula],
1042
1241
  );
1043
1242
  case "apt":
1044
1243
  return argv(
@@ -1185,10 +1384,11 @@ export function doctorFixCommand(projectDir: string | null): string {
1185
1384
  }
1186
1385
 
1187
1386
  /**
1188
- * The banner text for one plugin, or null when nothing is missing or off PATH:
1189
- * a healthy machine prints nothing. An `unknown` (a check that was too slow to
1190
- * answer) is mentioned only beside a real finding, and never called missing.
1191
- * `projectDir` is the directory the session runs in, when the hook input says.
1387
+ * The banner text for one plugin, or null when nothing is missing, outdated or
1388
+ * off PATH: a healthy machine prints nothing. An `unknown` (a check that was
1389
+ * too slow to answer, or printed no version) is mentioned only beside a real
1390
+ * finding, and never called missing. `projectDir` is the directory the session
1391
+ * runs in, when the hook input says.
1192
1392
  */
1193
1393
  export function renderFix(
1194
1394
  plugin: string,
@@ -1199,11 +1399,17 @@ export function renderFix(
1199
1399
  projectDir: string | null = null,
1200
1400
  ): string | null {
1201
1401
  const missing = presences.filter((p) => p.status === "missing");
1402
+ const outdated = presences.filter(
1403
+ (p): p is Extract<Presence, { status: "outdated" }> =>
1404
+ p.status === "outdated",
1405
+ );
1202
1406
  const offPath = presences.filter(
1203
1407
  (p): p is Extract<Presence, { status: "off-path" }> =>
1204
1408
  p.status === "off-path",
1205
1409
  );
1206
- if (missing.length === 0 && offPath.length === 0) return null;
1410
+ if (missing.length === 0 && outdated.length === 0 && offPath.length === 0) {
1411
+ return null;
1412
+ }
1207
1413
  const unknown = presences.filter(
1208
1414
  (p): p is Extract<Presence, { status: "unknown" }> =>
1209
1415
  p.status === "unknown",
@@ -1218,8 +1424,21 @@ export function renderFix(
1218
1424
  .join(", ");
1219
1425
  const head: string[] = [];
1220
1426
  if (missing.length > 0) head.push(`missing ${names(missing)}`);
1427
+ if (outdated.length > 0) {
1428
+ const each = [...outdated]
1429
+ .sort((a, b) => a.name.localeCompare(b.name))
1430
+ .map((p) => `${p.name} ${p.found} (needs ${p.minVersion})`)
1431
+ .join(", ");
1432
+ head.push(`outdated ${each}`);
1433
+ }
1221
1434
  if (offPath.length > 0) head.push(`not on PATH ${names(offPath)}`);
1222
- if (unknown.length > 0) head.push(`unknown (slow) ${names(unknown)}`);
1435
+ // Answered with no version to hold to a floor, or too slow to answer.
1436
+ const noVersion = unknown.filter((p) => p.reason.startsWith(NO_VERSION));
1437
+ const slow = unknown.filter((p) => !p.reason.startsWith(NO_VERSION));
1438
+ if (slow.length > 0) head.push(`unknown (slow) ${names(slow)}`);
1439
+ if (noVersion.length > 0) {
1440
+ head.push(`unknown (no version) ${names(noVersion)}`);
1441
+ }
1223
1442
  const lines = [`magus-deps · ${plugin}: ${head.join("; ")}.`];
1224
1443
  lines.push(
1225
1444
  ` Fix all: ${magusOnPath ? "" : `${MAGUS_CLI_INSTALL}, then `}${doctorFixCommand(projectDir)}`,
@@ -1231,7 +1450,10 @@ export function renderFix(
1231
1450
  const apt: string[] = [];
1232
1451
  const aptNames: string[] = [];
1233
1452
  const byHand: string[] = [];
1234
- for (const p of [...missing].sort((a, b) => a.name.localeCompare(b.name))) {
1453
+ const toFix = [...missing, ...outdated].sort((a, b) =>
1454
+ a.name.localeCompare(b.name),
1455
+ );
1456
+ for (const p of toFix) {
1235
1457
  const r = byName.get(p.name);
1236
1458
  if (!r) continue;
1237
1459
  const option = selectOption(r, env, has);
@@ -1246,8 +1468,10 @@ export function renderFix(
1246
1468
  }
1247
1469
  const blocked =
1248
1470
  p.status === "missing" && p.blockedBy ? ` (after ${p.blockedBy})` : "";
1471
+ const reason: InstallReason =
1472
+ p.status === "outdated" ? "outdated" : "missing";
1249
1473
  byHand.push(
1250
- `${p.name}${blocked}: ${describeStep(renderInstall(r, option, env), env.os)}`,
1474
+ `${p.name}${blocked}: ${describeStep(renderInstall(r, option, env, reason), env.os)}`,
1251
1475
  );
1252
1476
  }
1253
1477
  if (apt.length > 0) {
@@ -97,7 +97,10 @@ export function rowName(row: CliToolsRow): string {
97
97
  return isPluginManagedRow(row) ? row.entry.name : row.tool.name;
98
98
  }
99
99
 
100
- /** On PATH or in a user prefix, as far as the row knows. */
100
+ /**
101
+ * On PATH or in a user prefix, as far as the row knows. An `outdated` tool is
102
+ * not installed: it is below its plugin's floor and needs a fix.
103
+ */
101
104
  function rowInstalled(row: CliToolsRow): boolean {
102
105
  if (!isPluginManagedRow(row)) return isInstalled(row.status);
103
106
  const status = row.presence?.status;
@@ -366,6 +369,8 @@ export function countToolRows(rows: readonly CliToolsRow[]): {
366
369
  if (rowInstalled(row)) installed++;
367
370
  if (isPluginManagedRow(row)) {
368
371
  if (row.presence?.status === "unknown") unverified++;
372
+ // Below its plugin's floor: an update the row needs.
373
+ if (row.presence?.status === "outdated") updates++;
369
374
  continue;
370
375
  }
371
376
  if (row.status?.state === "upgrade") updates++;
@@ -119,6 +119,12 @@ function managedStatus(presence: PluginManagedRow["presence"]): {
119
119
  };
120
120
  case "missing":
121
121
  return { icon: "○", color: theme.colors.muted, text: "○ Not installed" };
122
+ case "outdated":
123
+ return {
124
+ icon: "!",
125
+ color: theme.colors.danger,
126
+ text: `! Outdated: ${presence.found}, needs ${presence.minVersion} or later`,
127
+ };
122
128
  case "unknown":
123
129
  return {
124
130
  icon: "?",
@@ -192,7 +198,7 @@ function renderPluginManagedDetail(
192
198
  {!here && presence !== null && row.installText ? (
193
199
  <box flexDirection="row">
194
200
  <text fg={theme.colors.muted} flexShrink={0}>
195
- {"Install "}
201
+ {presence.status === "outdated" ? "Update " : "Install "}
196
202
  </text>
197
203
  <text fg={theme.colors.info} minWidth={0}>
198
204
  {wrapValue(row.installText, width)}
@@ -17,6 +17,7 @@ import {
17
17
  } from "../../services/cli-tool-commands.js";
18
18
  import {
19
19
  DOCTOR_CHECK_CAP_MS,
20
+ installReason,
20
21
  installText,
21
22
  machineEnv,
22
23
  probeBins,
@@ -201,7 +202,13 @@ export function CliToolsScreen() {
201
202
  entry: m.entry,
202
203
  owned: m.owned,
203
204
  presence,
204
- installText: env ? installText(m.owned, env) : "",
205
+ installText: env
206
+ ? installText(
207
+ m.owned,
208
+ env,
209
+ presence ? installReason(presence) : "missing",
210
+ )
211
+ : "",
205
212
  });
206
213
  const setRow = (row: number, value: CliToolsRow) =>
207
214
  setAndCache((prev) => {