magus-cli 7.10.5 → 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 (33) 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__/requires-probe.test.ts +2 -2
  24. package/src/cli/deps-view.ts +5 -3
  25. package/src/services/deps-plan.ts +38 -14
  26. package/src/services/deps-report.ts +24 -7
  27. package/src/services/doctor-bins.ts +40 -12
  28. package/src/services/resolver.ts +1 -0
  29. package/src/shared/plugin/hooks/deps-check.ts +7 -3
  30. package/src/shared/plugin/hooks/lib/requires-probe.ts +257 -33
  31. package/src/ui/adapters/cliToolsAdapter.ts +6 -1
  32. package/src/ui/renderers/cliToolRenderers.tsx +7 -1
  33. package/src/ui/screens/CliToolsScreen.tsx +8 -1
@@ -0,0 +1,231 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ import {
6
+ type Raw,
7
+ type World,
8
+ configureClaude,
9
+ linkExecutable,
10
+ makeHookPlugin,
11
+ makePlugin,
12
+ makeWorld,
13
+ removeWorld,
14
+ requirement,
15
+ requires,
16
+ runDoctor,
17
+ runHook,
18
+ sessionInput,
19
+ writeExecutable,
20
+ } from "./support";
21
+
22
+ // Fake bun: answers --version, does nothing for `add` (so an "upgrade" leaves
23
+ // the old binary in place). Never the real bun.
24
+ async function fakeBun(world: World): Promise<void> {
25
+ await writeExecutable(
26
+ join(world.bin, "bun"),
27
+ 'if [ "$1" = "--version" ]; then echo 1.4.0; exit 0; fi\nexit 0',
28
+ );
29
+ }
30
+
31
+ function bunAndWidget(minVersion: string): Raw {
32
+ return requires([
33
+ requirement("bun", [{ tool: "bun", via: "bootstrap" }]),
34
+ requirement("widget", [{ package: "widget", via: "bun" }], { minVersion }),
35
+ ]);
36
+ }
37
+
38
+ async function installPlugin(
39
+ world: World,
40
+ name: string,
41
+ value: Raw,
42
+ ): Promise<string> {
43
+ const root = await makePlugin(world, name, value);
44
+ await configureClaude(world, [{ key: `${name}@test`, root, scope: "user" }]);
45
+ return root;
46
+ }
47
+
48
+ function parseReport<T>(result: {
49
+ stdout: string;
50
+ stderr: string;
51
+ exitCode: number | null;
52
+ }): T {
53
+ try {
54
+ return JSON.parse(result.stdout) as T;
55
+ } catch {
56
+ throw new Error(
57
+ `doctor printed no JSON (exit ${result.exitCode}); stderr: ${result.stderr.slice(0, 400)}`,
58
+ );
59
+ }
60
+ }
61
+
62
+ interface Dep {
63
+ name: string;
64
+ status: string;
65
+ found?: string;
66
+ minVersion?: string;
67
+ fix?: { text: string };
68
+ }
69
+
70
+ describe("magus doctor with an outdated dependency", () => {
71
+ // REQ-27: DEPS_REPORT_SCHEMA 2; status "outdated"; found/minVersion only then.
72
+ test("REQ-27 --json reports schema 2 and an outdated dep with found and floor", async () => {
73
+ const world = await makeWorld();
74
+ try {
75
+ await fakeBun(world);
76
+ await writeExecutable(join(world.bin, "widget"), "echo widget 1.2.0");
77
+ await installPlugin(world, "testdata", bunAndWidget("2.0.0"));
78
+ const result = await runDoctor(world, ["--json"]);
79
+ expect(result.timedOut).toBe(false);
80
+ const report = parseReport<{ schema: number; deps: Dep[] }>(result);
81
+ expect(report.schema).toBe(2);
82
+ const widget = report.deps.find((d) => d.name === "widget");
83
+ expect(widget).toMatchObject({
84
+ found: "1.2.0",
85
+ minVersion: "2.0.0",
86
+ status: "outdated",
87
+ });
88
+ expect(widget?.fix?.text).toBe("bun add -g widget@latest");
89
+ const bun = report.deps.find((d) => d.name === "bun");
90
+ expect(bun?.status).toBe("present");
91
+ expect(bun).not.toHaveProperty("found");
92
+ expect(bun).not.toHaveProperty("minVersion");
93
+ } finally {
94
+ await removeWorld(world);
95
+ }
96
+ });
97
+
98
+ // REQ-28: "magus doctor text: Outdated rows are failures and count toward the exit code."
99
+ test("REQ-28 text doctor exits non-zero for an outdated dep, zero once satisfied", async () => {
100
+ const world = await makeWorld();
101
+ try {
102
+ await fakeBun(world);
103
+ await writeExecutable(join(world.bin, "widget"), "echo widget 1.2.0");
104
+ await installPlugin(world, "testdata", bunAndWidget("2.0.0"));
105
+ const outdated = await runDoctor(world, []);
106
+ expect(outdated.timedOut).toBe(false);
107
+ expect(outdated.exitCode).not.toBe(0);
108
+ expect(outdated.stdout + outdated.stderr).toContain("1.2.0");
109
+
110
+ await writeExecutable(join(world.bin, "widget"), "echo widget 2.0.0");
111
+ const healthy = await runDoctor(world, []);
112
+ expect(healthy.exitCode).toBe(0);
113
+ } finally {
114
+ await removeWorld(world);
115
+ }
116
+ });
117
+
118
+ // REQ-29: after install, still outdated → failed; remedy names found, floor, onPath.
119
+ test("REQ-29 --fix that leaves the old binary first on PATH reports failed with a remedy", async () => {
120
+ const world = await makeWorld();
121
+ try {
122
+ await fakeBun(world);
123
+ await writeExecutable(join(world.bin, "widget"), "echo widget 1.2.0");
124
+ await installPlugin(world, "testdata", bunAndWidget("2.0.0"));
125
+ const result = await runDoctor(world, ["--fix", "--yes", "--json"]);
126
+ expect(result.timedOut).toBe(false);
127
+ expect(result.exitCode).not.toBe(0);
128
+ const report = parseReport<{
129
+ applied?: Array<{
130
+ name: string;
131
+ status: string;
132
+ remedy?: string;
133
+ argv?: string[];
134
+ }>;
135
+ }>(result);
136
+ expect(report.applied).toBeDefined();
137
+ const applied = (report.applied ?? []).find((a) => a.name === "widget");
138
+ expect(applied?.status).toBe("failed");
139
+ // The executor may resolve argv[0] to an absolute path ("exactly what
140
+ // ran"); the spec fixes the rendered step, not how bun is located.
141
+ expect(applied?.argv?.[0]?.split("/").pop()).toBe("bun");
142
+ expect(applied?.argv?.slice(1)).toEqual(["add", "-g", "widget@latest"]);
143
+ expect(applied?.remedy).toContain("1.2.0");
144
+ expect(applied?.remedy).toContain("2.0.0");
145
+ expect(applied?.remedy).toContain(join(world.bin, "widget"));
146
+ } finally {
147
+ await removeWorld(world);
148
+ }
149
+ });
150
+ });
151
+
152
+ describe("SessionStart hook with an outdated dependency", () => {
153
+ // REQ-30: "An outdated dep is claimed and reported once per session, like a missing one."
154
+ test("REQ-30 reports an outdated dep once per session", async () => {
155
+ const world = await makeWorld();
156
+ try {
157
+ const plugin = await makeHookPlugin(
158
+ world,
159
+ "outdated",
160
+ requires([
161
+ requirement("widget", [{ formula: "widget", via: "brew" }], {
162
+ minVersion: "2.0.0",
163
+ }),
164
+ ]),
165
+ );
166
+ // The hook itself needs a bun to execute TypeScript, as in the schema-1 suite.
167
+ await linkExecutable(world.bin, "bun", process.execPath);
168
+ await writeExecutable(join(world.bin, "brew"), "exit 0");
169
+ await writeExecutable(join(world.bin, "widget"), "echo widget 1.5.0");
170
+ const input = sessionInput("outdated-session");
171
+ const first = await runHook(
172
+ world,
173
+ plugin,
174
+ "outdated",
175
+ [world.bin],
176
+ input,
177
+ );
178
+ expect(first.timedOut).toBe(false);
179
+ expect(first.exitCode).toBe(0);
180
+ const banner = JSON.parse(first.stdout) as { systemMessage: string };
181
+ expect(banner.systemMessage).toContain("magus-deps");
182
+ expect(banner.systemMessage).toContain(
183
+ "outdated widget 1.5.0 (needs 2.0.0)",
184
+ );
185
+ const second = await runHook(
186
+ world,
187
+ plugin,
188
+ "outdated",
189
+ [world.bin],
190
+ input,
191
+ );
192
+ expect(second.exitCode).toBe(0);
193
+ expect(second.stdout).toBe("");
194
+ expect(
195
+ await readdir(
196
+ join(world.tmp, "magus-deps-outdated-session", "claimed"),
197
+ ),
198
+ ).toHaveLength(1);
199
+ } finally {
200
+ await removeWorld(world);
201
+ }
202
+ });
203
+
204
+ test("REQ-30 a satisfied floor keeps the hook silent", async () => {
205
+ const world = await makeWorld();
206
+ try {
207
+ const plugin = await makeHookPlugin(
208
+ world,
209
+ "fresh",
210
+ requires([
211
+ requirement("widget", [{ formula: "widget", via: "brew" }], {
212
+ minVersion: "2.0.0",
213
+ }),
214
+ ]),
215
+ );
216
+ await linkExecutable(world.bin, "bun", process.execPath);
217
+ await writeExecutable(join(world.bin, "widget"), "echo widget 2.3.0");
218
+ const result = await runHook(
219
+ world,
220
+ plugin,
221
+ "fresh",
222
+ [world.bin],
223
+ sessionInput("fresh-session"),
224
+ );
225
+ expect(result.exitCode).toBe(0);
226
+ expect(result.stdout).toBe("");
227
+ } finally {
228
+ await removeWorld(world);
229
+ }
230
+ });
231
+ });
@@ -18,7 +18,7 @@ test("a Bun under BUN_INSTALL is reported as installed but absent from Claude Co
18
18
  try {
19
19
  const plugin = await makeHookPlugin(world, "bun-install", {
20
20
  bin: [],
21
- schema: 1,
21
+ schema: 2,
22
22
  });
23
23
  const shellPath = await makeShellPath(world);
24
24
  const bunInstall = join(world.root, "bun-install");
@@ -43,7 +43,7 @@ test("the TypeScript hook and shell fallback create ledger directories private t
43
43
  const world = await makeWorld();
44
44
  try {
45
45
  const plugin = await makeHookPlugin(world, "private-ledger", {
46
- schema: 1,
46
+ schema: 2,
47
47
  bin: [],
48
48
  });
49
49
  const shellPath = await makeShellPath(world);
@@ -26,7 +26,7 @@ test("each stale ledger is pruned even when another stale ledger cannot be remov
26
26
  let blocked = "";
27
27
  try {
28
28
  const plugin = await makeHookPlugin(world, "ledger-pruning", {
29
- schema: 1,
29
+ schema: 2,
30
30
  bin: [],
31
31
  });
32
32
  const shellPath = await makeShellPath(world);
@@ -17,7 +17,7 @@ test("doctor does not advise adding a PATH line already present in the shell rc
17
17
  const rcLine = 'export PATH="$HOME/.bun/bin:$PATH"';
18
18
  await writeFile(join(world.home, ".zshrc"), `${rcLine}\n`);
19
19
  const plugin = await makePlugin(world, "path-advice", {
20
- schema: 1,
20
+ schema: 2,
21
21
  bin: [
22
22
  {
23
23
  name: "bun",
@@ -105,14 +105,14 @@ const ledger = (sessionId = "s-1") => join(tmp, `magus-deps-${sessionId}`);
105
105
  describe("deps-check hook", () => {
106
106
  test("healthy: prints nothing, and the ledger proves it ran", () => {
107
107
  tool("jq");
108
- const dir = plugin("dev", { schema: 1, bin: [JQ] });
108
+ const dir = plugin("dev", { schema: 2, bin: [JQ] });
109
109
  const r = runHook("dev", dir);
110
110
  expect(r).toEqual({ stdout: "", exitCode: 0 });
111
111
  expect(readFileSync(join(ledger(), "seen", "dev"), "utf8")).toBe("ts");
112
112
  });
113
113
 
114
114
  test("missing: one systemMessage with the marker, the dep and its fix", () => {
115
- const dir = plugin("dev", { schema: 1, bin: [JQ] });
115
+ const dir = plugin("dev", { schema: 2, bin: [JQ] });
116
116
  const r = runHook("dev", dir);
117
117
  expect(r.exitCode).toBe(0);
118
118
  const out = JSON.parse(r.stdout) as {
@@ -134,7 +134,7 @@ describe("deps-check hook", () => {
134
134
 
135
135
  test("magus on PATH: the fix names magus alone", () => {
136
136
  tool("magus");
137
- const dir = plugin("dev", { schema: 1, bin: [JQ] });
137
+ const dir = plugin("dev", { schema: 2, bin: [JQ] });
138
138
  const out = JSON.parse(runHook("dev", dir).stdout) as {
139
139
  systemMessage: string;
140
140
  };
@@ -149,7 +149,7 @@ describe("deps-check hook", () => {
149
149
  JSON.parse(
150
150
  runHook(
151
151
  "dev",
152
- plugin("dev", { schema: 1, bin: [JQ] }),
152
+ plugin("dev", { schema: 2, bin: [JQ] }),
153
153
  session,
154
154
  input,
155
155
  ).stdout,
@@ -174,10 +174,10 @@ describe("deps-check hook", () => {
174
174
  });
175
175
 
176
176
  test("one identical declaration in two plugins is reported once per session", () => {
177
- const first = runHook("dev", plugin("dev", { schema: 1, bin: [JQ] }));
177
+ const first = runHook("dev", plugin("dev", { schema: 2, bin: [JQ] }));
178
178
  const second = runHook(
179
179
  "code-search",
180
- plugin("code-search", { schema: 1, bin: [JQ] }),
180
+ plugin("code-search", { schema: 2, bin: [JQ] }),
181
181
  );
182
182
  expect(first.stdout).toContain("magus-deps · dev: missing jq");
183
183
  expect(second.stdout).toBe("");
@@ -188,14 +188,14 @@ describe("deps-check hook", () => {
188
188
  });
189
189
 
190
190
  test("no readable session id: no ledger, and the banner still prints", () => {
191
- const dir = plugin("dev", { schema: 1, bin: [JQ] });
191
+ const dir = plugin("dev", { schema: 2, bin: [JQ] });
192
192
  const r = runHook("dev", dir, null);
193
193
  expect(r.stdout).toContain("magus-deps · dev: missing jq");
194
194
  expect(existsSync(tmp) && readdirSync(tmp)).toEqual([]);
195
195
  });
196
196
 
197
197
  test("an invalid declaration is reported, not swallowed", () => {
198
- const dir = plugin("dev", { schema: 1, bin: [{ name: "jq" }] });
198
+ const dir = plugin("dev", { schema: 2, bin: [{ name: "jq" }] });
199
199
  const out = JSON.parse(runHook("dev", dir).stdout) as {
200
200
  systemMessage: string;
201
201
  };
@@ -213,7 +213,7 @@ describe("deps-check hook", () => {
213
213
  const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
214
214
  require("node:fs").utimesSync(old, twoDaysAgo, twoDaysAgo);
215
215
  tool("jq");
216
- runHook("dev", plugin("dev", { schema: 1, bin: [JQ] }));
216
+ runHook("dev", plugin("dev", { schema: 2, bin: [JQ] }));
217
217
  expect(existsSync(old)).toBe(false);
218
218
  expect(existsSync(fresh)).toBe(true);
219
219
  });
@@ -262,7 +262,7 @@ describe("planDeps", () => {
262
262
  for (const via of ["npm", "pip", "go"]) {
263
263
  const parsed = parseRequires({
264
264
  requires: {
265
- schema: 1,
265
+ schema: 2,
266
266
  bin: [{ name: "x", check: ["x"], install: [{ via, package: "x" }] }],
267
267
  },
268
268
  });
@@ -66,7 +66,7 @@ const TMUX_MCP = {
66
66
  ],
67
67
  };
68
68
 
69
- const manifest = (bin: unknown[], schema = 1) => ({
69
+ const manifest = (bin: unknown[], schema = 2) => ({
70
70
  requires: { schema, bin },
71
71
  });
72
72
 
@@ -117,9 +117,9 @@ describe("collectDeps", () => {
117
117
  const collected = await collectDeps(
118
118
  root,
119
119
  sources({
120
- "new@x": manifest([BUN], 2),
121
- "old@x": manifest([BUN], 0),
122
- "bad@x": { requires: { schema: 1, bin: [{ name: "x" }] } },
120
+ "new@x": manifest([BUN], 3),
121
+ "old@x": manifest([BUN], 1),
122
+ "bad@x": { requires: { schema: 2, bin: [{ name: "x" }] } },
123
123
  }),
124
124
  );
125
125
  expect(collected.problems.map((p) => [p.plugin, p.kind])).toEqual([
@@ -132,7 +132,7 @@ describe("collectDeps", () => {
132
132
  });
133
133
 
134
134
  describe("depsReportJson", () => {
135
- test("names tmux-mcp missing with its release install, in the schema-1 shape", async () => {
135
+ test("names tmux-mcp missing with its release install, in the schema-2 shape", async () => {
136
136
  const state = await assessDeps(
137
137
  root,
138
138
  env,
@@ -36,7 +36,7 @@ beforeEach(() => {
36
36
  JSON.stringify({
37
37
  name: "fake",
38
38
  requires: {
39
- schema: 1,
39
+ schema: 2,
40
40
  bin: [
41
41
  {
42
42
  name: "faketool",
@@ -117,7 +117,7 @@ describe("magus doctor --json", () => {
117
117
  const r = doctor("--json");
118
118
  expect(r.code).toBe(1);
119
119
  const doc = JSON.parse(r.out);
120
- expect(doc.schema).toBe(1);
120
+ expect(doc.schema).toBe(2);
121
121
  expect(doc.plugins).toEqual(["fake@magus"]);
122
122
  expect(doc.deps).toEqual([
123
123
  {
@@ -60,7 +60,7 @@ function writePluginJson(dir: string, version: string, pin: string) {
60
60
  JSON.stringify({
61
61
  version,
62
62
  requires: {
63
- schema: 1,
63
+ schema: 2,
64
64
  bin: [{ name: "x", install: [{ via: "release", tag: pin }] }],
65
65
  },
66
66
  }),
@@ -23,7 +23,7 @@ const BUN: Requirement = {
23
23
  };
24
24
  const declares = (bin: Requirement[]) => ({
25
25
  name: "p",
26
- requires: { schema: 1, bin },
26
+ requires: { schema: 2, bin },
27
27
  });
28
28
 
29
29
  let dir: string;
@@ -60,7 +60,7 @@ describe("readPluginRequires", () => {
60
60
  test("an invalid declaration is reported, never dropped to empty", async () => {
61
61
  await fs.outputJson(join(dir, ".claude-plugin", "plugin.json"), {
62
62
  name: "p",
63
- requires: { schema: 1, bin: [{ name: "x", via: "brew" }] },
63
+ requires: { schema: 2, bin: [{ name: "x", via: "brew" }] },
64
64
  });
65
65
  const r = await readPluginRequires(dir);
66
66
  expect(r?.ok).toBe(false);
@@ -156,10 +156,10 @@ describe("parseRequires", () => {
156
156
  });
157
157
 
158
158
  test("schema: newer asks for a newer magus-cli, older for a newer plugin", () => {
159
- const newer = parseRequires({ requires: { schema: 2, bin: [] } });
159
+ const newer = parseRequires({ requires: { schema: 3, bin: [] } });
160
160
  expect(newer.ok ? null : newer.kind).toBe("newer-schema");
161
161
  expect(newer.ok ? "" : newer.errors[0]).toContain("update magus-cli");
162
- const older = parseRequires({ requires: { schema: 0, bin: [] } });
162
+ const older = parseRequires({ requires: { schema: 1, bin: [] } });
163
163
  expect(older.ok ? null : older.kind).toBe("older-schema");
164
164
  expect(older.ok ? "" : older.errors[0]).toContain("update the plugin");
165
165
  const none = parseRequires({ requires: { bin: [] } });
@@ -18,7 +18,7 @@ import {
18
18
  requiredByOf,
19
19
  } from "../services/deps-plan.js";
20
20
  import type { DepsState } from "../services/deps-report.js";
21
- import { installText } from "../services/doctor-bins.js";
21
+ import { installReason, installText } from "../services/doctor-bins.js";
22
22
  import {
23
23
  type Os,
24
24
  type Presence,
@@ -33,6 +33,8 @@ function presenceLine(name: string, p: Presence, by: string): string | null {
33
33
  return null;
34
34
  case "missing":
35
35
  return ` ✗ ${name} — missing${p.blockedBy ? ` (needs ${p.blockedBy} first)` : ""} (${by})`;
36
+ case "outdated":
37
+ return ` ✗ ${name} — outdated: ${p.found}, needs ${p.minVersion} or later (${by})`;
36
38
  case "off-path":
37
39
  return ` ⚠ ${name} — installed in ${p.prefix}, which is not on the PATH Claude Code starts with (${by})`;
38
40
  case "unknown":
@@ -57,8 +59,8 @@ export function depsSectionLines(
57
59
  const line = presenceLine(bin.name, p, requiredByOf(bin).join(", "));
58
60
  if (!line) return;
59
61
  lines.push(line);
60
- if (p.status === "missing")
61
- lines.push(` fix: ${installText(bin, env)}`);
62
+ if (p.status === "missing" || p.status === "outdated")
63
+ lines.push(` fix: ${installText(bin, env, installReason(p))}`);
62
64
  if (p.status === "off-path") unfixable++;
63
65
  });
64
66
  for (const problem of collected.problems) {
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * The one installer executor: plan what is missing, then install it.
3
3
  *
4
- * `planDeps` turns the requirements a probe found missing into ordered steps,
4
+ * `planDeps` turns the requirements a probe found missing or outdated into
5
+ * ordered steps,
5
6
  * each rendered by requires-probe's `renderInstall` (the only renderer).
6
7
  * `applyDepsPlan` runs them through `runArgv` and returns one `ApplyOutcome`
7
8
  * per step. Nothing here prints: `magus doctor` (text and `--json`),
@@ -42,6 +43,7 @@ import {
42
43
  BUN_PREFIX,
43
44
  type Bootstrap,
44
45
  type BootstrapTool,
46
+ type InstallReason,
45
47
  type InstallStep,
46
48
  LOCAL_PREFIX,
47
49
  type Os,
@@ -240,10 +242,11 @@ function fromMagusMarketplace(bin: ResolvedBin): boolean {
240
242
  }
241
243
 
242
244
  /**
243
- * The steps that install every `missing` requirement, in the order they must
244
- * run. `presence[i]` is the probe's answer for `bins[i]`. An `off-path`
245
- * requirement is not reinstalled: it needs a PATH line, which `pathAdvice`
246
- * carries. An `unknown` one (a check too slow to answer) is left alone.
245
+ * The steps that install every `missing` requirement and replace every
246
+ * `outdated` one, in the order they must run. `presence[i]` is the probe's
247
+ * answer for `bins[i]`. An `off-path` requirement is not reinstalled: it needs
248
+ * a PATH line, which `pathAdvice` carries. An `unknown` one (a check too slow
249
+ * to answer, or one that printed no version) is left alone.
247
250
  */
248
251
  export function planDeps(
249
252
  bins: ResolvedBin[],
@@ -253,10 +256,11 @@ export function planDeps(
253
256
  const unfixable: DepsPlan["unfixable"] = [];
254
257
  /** The prefixes off the PATH, in the order they came up. */
255
258
  const offPathPrefixes = new Set<UserPrefix>();
256
- const missing: ResolvedBin[] = [];
259
+ const missing: { bin: ResolvedBin; reason: InstallReason }[] = [];
257
260
  bins.forEach((bin, i) => {
258
261
  const p = presence[i];
259
- if (p?.status === "missing") missing.push(bin);
262
+ if (p?.status === "missing") missing.push({ bin, reason: "missing" });
263
+ if (p?.status === "outdated") missing.push({ bin, reason: "outdated" });
260
264
  if (p?.status === "off-path") offPathPrefixes.add(p.prefix);
261
265
  });
262
266
 
@@ -285,7 +289,7 @@ export function planDeps(
285
289
  pluginRoot: bin.root ?? "",
286
290
  });
287
291
 
288
- for (const bin of missing) {
292
+ for (const { bin, reason } of missing) {
289
293
  const option = bin.chosen;
290
294
  if (!option) {
291
295
  unfixable.push({
@@ -294,11 +298,12 @@ export function planDeps(
294
298
  });
295
299
  continue;
296
300
  }
297
- const step = renderInstall(bin, option, {
298
- os: env.os,
299
- arch: env.arch,
300
- pluginRoot: bin.root ?? "",
301
- });
301
+ const step = renderInstall(
302
+ bin,
303
+ option,
304
+ { os: env.os, arch: env.arch, pluginRoot: bin.root ?? "" },
305
+ reason,
306
+ );
302
307
  if (
303
308
  step.kind === "uv-module" &&
304
309
  step.escalation === "sudo" &&
@@ -895,7 +900,9 @@ async function landingDir(
895
900
  * After a step's installer exited 0, re-probe everything it installs with the
896
901
  * same probe doctor runs. Installed only when the probe now finds each one.
897
902
  * Found in a directory off the PATH Claude Code starts with: off-path, naming
898
- * the directory and the rc line. Not found at all: failed.
903
+ * the directory and the rc line. Not found at all: failed. Still below its
904
+ * `minVersion`: failed, naming the version found, the floor and the file the
905
+ * PATH resolves to — usually an older copy ahead of the one just installed.
899
906
  */
900
907
  async function verifyInstalled(
901
908
  p: PlannedStep,
@@ -904,6 +911,7 @@ async function verifyInstalled(
904
911
  const { env } = ctx;
905
912
  const offPath: Verified[] = [];
906
913
  const missing: string[] = [];
914
+ const outdated: string[] = [];
907
915
  for (const target of p.installs) {
908
916
  const presence = await ctx.io.verify(target, env);
909
917
  if (presence.status === "present") continue;
@@ -911,6 +919,18 @@ async function verifyInstalled(
911
919
  offPath.push({ status: "off-path", dir: presence.prefix });
912
920
  continue;
913
921
  }
922
+ if (presence.status === "outdated") {
923
+ const tool = target.requirement.check[0] ?? target.requirement.name;
924
+ const resolved = onPath(tool, env.path);
925
+ outdated.push(
926
+ `${target.requirement.name} is still ${presence.found}, below the ${presence.minVersion} it needs: ${
927
+ resolved
928
+ ? `the PATH Claude Code starts with runs ${resolved}`
929
+ : `${tool} is not on the PATH Claude Code starts with`
930
+ } — remove that older copy or put the new one ahead of it, then restart Claude Code from a new terminal`,
931
+ );
932
+ continue;
933
+ }
914
934
  const tool = target.requirement.check[0] ?? target.requirement.name;
915
935
  const landed = await landingDir(p.step, ctx);
916
936
  if (landed && onPath(tool, landed)) {
@@ -924,6 +944,10 @@ async function verifyInstalled(
924
944
  if (missing.length > 0) {
925
945
  return { status: "failed", why: missing.join("\n") };
926
946
  }
947
+ if (outdated.length > 0) {
948
+ const why = outdated.join("\n");
949
+ return { status: "failed", why, remedy: why };
950
+ }
927
951
  const first = offPath[0];
928
952
  if (first?.dir) {
929
953
  return {
@@ -33,13 +33,20 @@ import {
33
33
  prefixOnPath,
34
34
  requiredByOf,
35
35
  } from "./deps-plan.js";
36
- import { DOCTOR_CHECK_CAP_MS, probeBins } from "./doctor-bins.js";
36
+ import {
37
+ DOCTOR_CHECK_CAP_MS,
38
+ installReason,
39
+ probeBins,
40
+ } from "./doctor-bins.js";
37
41
  import { readManifest } from "./manifest.js";
38
42
  import { resolvePluginRequires, selectBins } from "./plugin-requires.js";
39
43
  import { binKey, cliToolBins, mergeBins } from "./resolver.js";
40
44
 
41
- /** The `--json` document's shape version. */
42
- export const DEPS_REPORT_SCHEMA = 1;
45
+ /**
46
+ * The `--json` document's shape version. 2 added the `outdated` status and a
47
+ * dep's `found` and `minVersion`.
48
+ */
49
+ export const DEPS_REPORT_SCHEMA = 2;
43
50
 
44
51
  export interface PluginParseProblem {
45
52
  plugin: string;
@@ -187,6 +194,10 @@ export interface DepJson {
187
194
  blockedBy?: string;
188
195
  prefix?: string;
189
196
  reason?: string;
197
+ /** Only when `status` is "outdated": the version the check reported. */
198
+ found?: string;
199
+ /** Only when `status` is "outdated": the lowest version that will do. */
200
+ minVersion?: string;
190
201
  fix?: {
191
202
  via: string;
192
203
  escalation: "none" | "sudo";
@@ -227,11 +238,17 @@ export function depsReportJson(
227
238
  if (p.status === "missing" && p.blockedBy) entry.blockedBy = p.blockedBy;
228
239
  if (p.status === "off-path") entry.prefix = p.prefix;
229
240
  if (p.status === "unknown") entry.reason = p.reason;
241
+ if (p.status === "outdated") {
242
+ entry.found = p.found;
243
+ entry.minVersion = p.minVersion;
244
+ }
230
245
  if (p.status !== "present" && bin.chosen) {
231
- const step = renderInstall(bin, bin.chosen, {
232
- ...env,
233
- pluginRoot: bin.root ?? "",
234
- });
246
+ const step = renderInstall(
247
+ bin,
248
+ bin.chosen,
249
+ { ...env, pluginRoot: bin.root ?? "" },
250
+ installReason(p),
251
+ );
235
252
  const prefix = prefixOf(step);
236
253
  entry.fix = {
237
254
  via: bin.chosen.via,