magus-cli 7.10.5 → 7.11.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.
Files changed (37) 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 +69 -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/__tests__/scope-action-deprecated.test.ts +105 -0
  25. package/src/cli/deps-view.ts +5 -3
  26. package/src/cli/router.ts +6 -1
  27. package/src/services/deps-plan.ts +38 -14
  28. package/src/services/deps-report.ts +24 -7
  29. package/src/services/doctor-bins.ts +40 -12
  30. package/src/services/plugin-manager.ts +5 -1
  31. package/src/services/resolver.ts +1 -0
  32. package/src/shared/plugin/hooks/deps-check.ts +7 -3
  33. package/src/shared/plugin/hooks/lib/requires-probe.ts +257 -33
  34. package/src/ui/adapters/cliToolsAdapter.ts +6 -1
  35. package/src/ui/renderers/cliToolRenderers.tsx +7 -1
  36. package/src/ui/screens/CliToolsScreen.tsx +8 -1
  37. package/src/ui/screens/PluginsScreen.tsx +4 -0
@@ -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: [] } });
@@ -0,0 +1,105 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ type ScopeStatus,
4
+ isInstalledInScope,
5
+ resolveScopeAction,
6
+ } from "../services/plugin-manager.js";
7
+
8
+ /**
9
+ * MTL-70: a deprecated plugin (no longer in any marketplace,
10
+ * `PluginInfo.isOrphaned === true`) can only be removed from a scope key.
11
+ * Installed in the scope → "uninstall"; not installed → "none". `behind` never
12
+ * matters for a deprecated plugin. Written blind from the spec.
13
+ */
14
+
15
+ const installedScopes: Array<[string, ScopeStatus]> = [
16
+ ["installed with a registry version", { installed: true, version: "3.0.2" }],
17
+ ["installed with no version (files missing)", { installed: true }],
18
+ ['installed at "0.0.0"', { installed: true, version: "0.0.0" }],
19
+ [
20
+ "installed with an unorderable sha",
21
+ { installed: true, version: "3ea32df27be7" },
22
+ ],
23
+ ];
24
+
25
+ const notInstalledScopes: Array<[string, ScopeStatus | undefined]> = [
26
+ ["an absent scope", undefined],
27
+ ["installed: false", { installed: false }],
28
+ [
29
+ "installed: false with a registry version",
30
+ { installed: false, version: "3.0.2" },
31
+ ],
32
+ ];
33
+
34
+ describe("resolveScopeAction — deprecated plugin installed in the scope (REQ-1)", () => {
35
+ for (const [label, scope] of installedScopes) {
36
+ test(`${label} is installed per isInstalledInScope (precondition)`, () => {
37
+ expect(isInstalledInScope(scope)).toBe(true);
38
+ });
39
+
40
+ for (const behind of [false, true]) {
41
+ test(`REQ-1: ${label}, behind=${behind} resolves to uninstall`, () => {
42
+ expect(resolveScopeAction(scope, behind, true)).toBe("uninstall");
43
+ });
44
+ }
45
+ }
46
+ });
47
+
48
+ describe("resolveScopeAction — deprecated plugin NOT installed in the scope (REQ-2)", () => {
49
+ for (const [label, scope] of notInstalledScopes) {
50
+ test(`${label} is not installed per isInstalledInScope (precondition)`, () => {
51
+ expect(isInstalledInScope(scope)).toBe(false);
52
+ });
53
+
54
+ for (const behind of [false, true]) {
55
+ test(`REQ-2: ${label}, behind=${behind} resolves to none — no install, no update`, () => {
56
+ const action = resolveScopeAction(scope, behind, true);
57
+ expect(action).toBe("none");
58
+ expect(action).not.toBe("install");
59
+ expect(action).not.toBe("update");
60
+ });
61
+ }
62
+ }
63
+ });
64
+
65
+ describe("resolveScopeAction — orphaned false or omitted is unchanged (REQ-3)", () => {
66
+ const cases: Array<[string, ScopeStatus | undefined, boolean, string]> = [
67
+ ["files missing", { installed: true }, false, "install"],
68
+ ["files missing, behind", { installed: true }, true, "install"],
69
+ ["never installed (absent)", undefined, false, "install"],
70
+ ["never installed (flag off)", { installed: false }, false, "install"],
71
+ [
72
+ "healthy at latest",
73
+ { installed: true, version: "3.0.2" },
74
+ false,
75
+ "uninstall",
76
+ ],
77
+ [
78
+ "healthy and behind",
79
+ { installed: true, version: "2.9.0" },
80
+ true,
81
+ "update",
82
+ ],
83
+ ];
84
+
85
+ for (const [label, scope, behind, expected] of cases) {
86
+ test(`REQ-3: ${label} with orphaned omitted resolves to ${expected}`, () => {
87
+ expect(resolveScopeAction(scope, behind)).toBe(expected as never);
88
+ });
89
+ test(`REQ-3: ${label} with orphaned=false resolves to ${expected}`, () => {
90
+ expect(resolveScopeAction(scope, behind, false)).toBe(expected as never);
91
+ });
92
+ }
93
+
94
+ test("REQ-3: a non-deprecated plugin never resolves to none", () => {
95
+ const all: Array<ScopeStatus | undefined> = [
96
+ ...installedScopes.map(([, s]) => s),
97
+ ...notInstalledScopes.map(([, s]) => s),
98
+ ];
99
+ for (const scope of all) {
100
+ for (const behind of [false, true]) {
101
+ expect(resolveScopeAction(scope, behind, false)).not.toBe("none");
102
+ }
103
+ }
104
+ });
105
+ });
@@ -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) {
package/src/cli/router.ts CHANGED
@@ -103,7 +103,12 @@ const SUBCOMMANDS = new Set([
103
103
  "doctor",
104
104
  ]);
105
105
 
106
- /** Subcommands that read or write profile state, and so pass the gate first. */
106
+ /**
107
+ * Subcommands that read or write profile state, and so pass the gate first.
108
+ * code-search setup runs `magus profile list` to regenerate settings.json after it
109
+ * edits profiles.json, so `profile` must keep running the gate
110
+ * (ai-docs/code-search-setup-profiles.md).
111
+ */
107
112
  const PROFILE_GATED = new Set(["install", "update", "profile", "models"]);
108
113
 
109
114
  export async function route(