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
@@ -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,
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import {
12
+ type InstallReason,
12
13
  type Presence,
13
14
  type ProbeDepth,
14
15
  type Requirement,
@@ -68,6 +69,7 @@ export async function probeBins(
68
69
  const reqs: Requirement[] = batch.map(({ bin }) => ({
69
70
  name: bin.name,
70
71
  check: expandCheck(bin, bin.root ?? ""),
72
+ ...(bin.minVersion !== undefined ? { minVersion: bin.minVersion } : {}),
71
73
  install: bin.install,
72
74
  }));
73
75
  const answers = await probe(reqs, {
@@ -93,13 +95,25 @@ export async function probeBins(
93
95
  return out;
94
96
  }
95
97
 
96
- /** The line a person runs to install `bin` here, or why nothing can. */
97
- export function installText(bin: ResolvedBin, env: PlanEnv): string {
98
+ /**
99
+ * The line a person runs to install `bin` here — or, `outdated`, to replace
100
+ * the copy that is too old — or why nothing can.
101
+ */
102
+ export function installText(
103
+ bin: ResolvedBin,
104
+ env: PlanEnv,
105
+ reason: InstallReason = "missing",
106
+ ): string {
98
107
  if (!bin.chosen) {
99
108
  return whyUnselectable(bin, env, (t) => onPath(t, env.path) !== null);
100
109
  }
101
110
  return describeStep(
102
- renderInstall(bin, bin.chosen, { ...env, pluginRoot: bin.root ?? "" }),
111
+ renderInstall(
112
+ bin,
113
+ bin.chosen,
114
+ { ...env, pluginRoot: bin.root ?? "" },
115
+ reason,
116
+ ),
103
117
  env.os,
104
118
  );
105
119
  }
@@ -107,7 +121,10 @@ export function installText(bin: ResolvedBin, env: PlanEnv): string {
107
121
  export interface BinCheckResult {
108
122
  name: string;
109
123
  presence: Presence;
110
- /** Advisory text after "run:" for a human to copy. Never executed. */
124
+ /**
125
+ * Advisory text after "run:" for a human to copy, an upgrade when the
126
+ * presence is `outdated`. Never executed.
127
+ */
111
128
  installCommand: string;
112
129
  /** Where the requirement came from (profile cliTools / plugin ids). */
113
130
  sources: string[];
@@ -119,17 +136,28 @@ export async function checkBinaries(
119
136
  opts: ProbeBinsOptions,
120
137
  ): Promise<BinCheckResult[]> {
121
138
  const presences = await probeBins(bins, opts);
122
- return bins.map((bin, i) => ({
123
- name: bin.name,
124
- presence: presences[i] as Presence,
125
- installCommand: installText(bin, opts.env),
126
- sources: bin.sources,
127
- }));
139
+ return bins.map((bin, i) => {
140
+ const presence = presences[i] as Presence;
141
+ return {
142
+ name: bin.name,
143
+ presence,
144
+ installCommand: installText(bin, opts.env, installReason(presence)),
145
+ sources: bin.sources,
146
+ };
147
+ });
148
+ }
149
+
150
+ /** Why a requirement with this presence would be installed. */
151
+ export function installReason(presence: Presence): InstallReason {
152
+ return presence.status === "outdated" ? "outdated" : "missing";
128
153
  }
129
154
 
130
- /** The actionable subset: missing, or installed but not on PATH. */
155
+ /** The actionable subset: missing, outdated, or installed but not on PATH. */
131
156
  export function missingBinaries(results: BinCheckResult[]): BinCheckResult[] {
132
157
  return results.filter(
133
- (r) => r.presence.status === "missing" || r.presence.status === "off-path",
158
+ (r) =>
159
+ r.presence.status === "missing" ||
160
+ r.presence.status === "outdated" ||
161
+ r.presence.status === "off-path",
134
162
  );
135
163
  }
@@ -371,7 +371,11 @@ export function buildScopeStatus(
371
371
  export function resolveScopeAction(
372
372
  scope: ScopeStatus | undefined,
373
373
  behind: boolean,
374
- ): "install" | "update" | "uninstall" {
374
+ orphaned = false,
375
+ ): "install" | "update" | "uninstall" | "none" {
376
+ // A deprecated plugin is no longer in any marketplace, so install and update
377
+ // can only fail with a raw CLI error. Removing it is the one action left.
378
+ if (orphaned) return isInstalledInScope(scope) ? "uninstall" : "none";
375
379
  if (!isInstalledInScope(scope) || scopeFilesMissing(scope)) return "install";
376
380
  return behind ? "update" : "uninstall";
377
381
  }
@@ -314,6 +314,7 @@ export function requirementOf(bin: Requirement): Requirement {
314
314
  name: bin.name,
315
315
  check: bin.check,
316
316
  ...(bin.os ? { os: bin.os } : {}),
317
+ ...(bin.minVersion !== undefined ? { minVersion: bin.minVersion } : {}),
317
318
  install: bin.install,
318
319
  };
319
320
  }
@@ -6,8 +6,9 @@
6
6
  * Reads the plugin's own `.claude-plugin/plugin.json` through
7
7
  * lib/requires-probe.ts, probes each requirement at presence depth (a PATH
8
8
  * lookup for a check that runs the requirement's own binary, the declared check
9
- * for every other), and prints a `systemMessage` carrying the `magus-deps` marker when something is
10
- * missing or off PATH. Prints nothing when healthy. Its "Fix all" line runs
9
+ * for every other, and for any requirement with a `minVersion`), and prints a
10
+ * `systemMessage` carrying the `magus-deps` marker when something is missing,
11
+ * outdated or off PATH. Prints nothing when healthy. Its "Fix all" line runs
11
12
  * doctor with `--project <cwd>`, the session's directory from the hook input:
12
13
  * doctor reads the plugins enabled in the project it checks, and the banner is
13
14
  * read in a terminal that may be anywhere.
@@ -200,8 +201,11 @@ async function main(): Promise<void> {
200
201
  userShell: process.env.SHELL ?? "",
201
202
  };
202
203
  const presences = await probe(reqs, env);
204
+ // Outdated is a finding like missing: claimed once, reported once.
203
205
  const reportable = (p: Presence) =>
204
- p.status === "missing" || p.status === "off-path";
206
+ p.status === "missing" ||
207
+ p.status === "outdated" ||
208
+ p.status === "off-path";
205
209
  if (!presences.some(reportable)) return;
206
210
 
207
211
  const byName = new Map(reqs.map((r) => [r.name, r]));