magus-cli 7.8.0 → 7.9.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 (78) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -4
  3. package/src/__tests__/blackbox-parity.test.ts +16 -11
  4. package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
  5. package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
  6. package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
  7. package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
  8. package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
  9. package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
  10. package/src/__tests__/blind-780-cli-tools.test.ts +36 -48
  11. package/src/__tests__/blind-780-review2.test.ts +0 -25
  12. package/src/__tests__/cli-apply-seams.test.ts +2 -67
  13. package/src/__tests__/cli-tool-commands.test.ts +131 -6
  14. package/src/__tests__/cli-tools-adapter.test.ts +52 -2
  15. package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
  16. package/src/__tests__/deps-adapter.test.ts +191 -0
  17. package/src/__tests__/deps-check-hook.test.ts +220 -0
  18. package/src/__tests__/deps-plan.test.ts +896 -0
  19. package/src/__tests__/deps-report.test.ts +197 -0
  20. package/src/__tests__/doctor-bins.test.ts +134 -36
  21. package/src/__tests__/doctor-json.test.ts +261 -0
  22. package/src/__tests__/failure-popup-review2.test.tsx +1 -25
  23. package/src/__tests__/final-fixes-780.test.tsx +25 -9
  24. package/src/__tests__/fixtures/bins.ts +55 -0
  25. package/src/__tests__/install-plan.test.ts +15 -1
  26. package/src/__tests__/marketplace-refresh.test.ts +9 -3
  27. package/src/__tests__/no-shell-spawn.test.ts +77 -0
  28. package/src/__tests__/npm-package-name.test.ts +6 -9
  29. package/src/__tests__/plugin-requires.test.ts +138 -153
  30. package/src/__tests__/requires-probe.test.ts +858 -0
  31. package/src/__tests__/resolver.test.ts +129 -52
  32. package/src/__tests__/run.test.ts +108 -0
  33. package/src/__tests__/tool-probe.test.ts +1 -1
  34. package/src/__tests__/toolchain.test.ts +123 -52
  35. package/src/__tests__/update-plan.test.ts +1 -1
  36. package/src/cli/ansi.ts +2 -7
  37. package/src/cli/deps-view.ts +213 -0
  38. package/src/cli/doctor.ts +169 -24
  39. package/src/cli/install.ts +51 -66
  40. package/src/cli/profile.ts +20 -2
  41. package/src/cli/router.ts +6 -2
  42. package/src/cli/update.ts +28 -7
  43. package/src/data/cli-tools.ts +24 -9
  44. package/src/services/active-closure.ts +3 -3
  45. package/src/services/claude-settings.ts +4 -348
  46. package/src/services/cli-tool-commands.ts +104 -3
  47. package/src/services/deps-plan.ts +962 -0
  48. package/src/services/deps-report.ts +265 -0
  49. package/src/services/doctor-bins.ts +115 -43
  50. package/src/services/install-plan.ts +11 -1
  51. package/src/services/local-marketplace.ts +12 -206
  52. package/src/services/marketplace-refresh.ts +4 -4
  53. package/src/services/plugin-manager.ts +4 -18
  54. package/src/services/plugin-requires.ts +93 -162
  55. package/src/services/resolver.ts +168 -58
  56. package/src/services/tool-probe.ts +18 -25
  57. package/src/services/toolchain.ts +117 -111
  58. package/src/services/update-engine.ts +19 -14
  59. package/src/services/update-plan.ts +3 -3
  60. package/src/shared/plugin/hooks/bun-missing.sh +39 -0
  61. package/src/shared/plugin/hooks/deps-check.ts +224 -0
  62. package/src/shared/plugin/hooks/lib/requires-probe.ts +1183 -0
  63. package/src/types/index.ts +53 -16
  64. package/src/ui/App.tsx +6 -40
  65. package/src/ui/adapters/cliToolsAdapter.ts +75 -37
  66. package/src/ui/adapters/depsAdapter.ts +129 -0
  67. package/src/ui/adapters/startupReports.ts +0 -22
  68. package/src/ui/renderers/cliToolRenderers.tsx +135 -2
  69. package/src/ui/screens/CliToolsScreen.tsx +141 -51
  70. package/src/ui/screens/PluginsScreen.tsx +12 -48
  71. package/src/utils/command-utils.ts +4 -5
  72. package/src/utils/run.ts +123 -52
  73. package/src/utils/string-utils.ts +9 -0
  74. package/src/__tests__/go-module.test.ts +0 -43
  75. package/src/__tests__/plugin-setup.test.ts +0 -1016
  76. package/src/__tests__/shell-script-callers.test.ts +0 -92
  77. package/src/services/plugin-setup.ts +0 -965
  78. package/src/utils/go-module.ts +0 -36
@@ -0,0 +1,1183 @@
1
+ /**
2
+ * requires-probe — the one reader of a plugin.json `requires` block.
3
+ *
4
+ * Everything that asks "what does this plugin need, is it here, and how is it
5
+ * installed" goes through this file: the SessionStart hook every declaring
6
+ * plugin carries (`hooks/deps-check.ts`), magus-cli, and the repo gate
7
+ * `scripts/check-plugin-requires.ts`. Parsing, presence, option selection and
8
+ * the human rendering of an install step exist here once.
9
+ *
10
+ * WHY IT IS COPIED. A plugin root must be self-contained: plugins install and
11
+ * update separately, so a hook cannot import out of magus-cli or another
12
+ * plugin's tree. The reference lives in tools/magus/src/shared/plugin/; each
13
+ * declaring plugin carries a byte-identical copy under `hooks/lib/`, and
14
+ * `scripts/check-hook-copies.ts` makes drift a failed check. A hook copy always
15
+ * matches its own plugin.json, because the two ship in one plugin version.
16
+ *
17
+ * Dependency-free (`node:*` only) so the copy runs from a bare plugin root.
18
+ * Nothing here spawns a shell: a check is an argv, and the only expansion is
19
+ * `${CLAUDE_PLUGIN_ROOT}`.
20
+ */
21
+
22
+ import { spawn } from "node:child_process";
23
+ import { createHash } from "node:crypto";
24
+ import { constants, accessSync, statSync } from "node:fs";
25
+ import { delimiter, join } from "node:path";
26
+
27
+ /** The one `requires` shape this reader knows. Any change to it bumps this. */
28
+ export const REQUIRES_SCHEMA = 1;
29
+
30
+ export type Os = "darwin" | "linux";
31
+ export type Arch = "amd64" | "arm64";
32
+ export type Platform = `${Os}_${Arch}`;
33
+
34
+ const OSES: readonly Os[] = ["darwin", "linux"];
35
+ const ARCHES: readonly Arch[] = ["amd64", "arm64"];
36
+ const PLATFORMS: readonly Platform[] = [
37
+ "darwin_amd64",
38
+ "darwin_arm64",
39
+ "linux_amd64",
40
+ "linux_arm64",
41
+ ];
42
+
43
+ /** The user prefixes the toolchains install into. Never on PATH by default. */
44
+ export const BUN_PREFIX = "~/.bun/bin";
45
+ export const LOCAL_PREFIX = "~/.local/bin";
46
+ export type UserPrefix = typeof BUN_PREFIX | typeof LOCAL_PREFIX;
47
+
48
+ export interface Bootstrap {
49
+ url: string;
50
+ interpreter: "sh" | "bash";
51
+ prefix: UserPrefix;
52
+ /** Tools the upstream installer script itself runs. */
53
+ needs: readonly string[];
54
+ }
55
+
56
+ /**
57
+ * The only toolchain bootstraps. A manifest names an entry by key; it never
58
+ * carries a URL or shell text. Homebrew is deliberately absent: its installer
59
+ * needs an interactive password and the Command Line Tools.
60
+ */
61
+ export const TOOLCHAIN_BOOTSTRAP = {
62
+ bun: {
63
+ url: "https://bun.sh/install",
64
+ interpreter: "bash",
65
+ prefix: BUN_PREFIX,
66
+ needs: ["curl", "unzip"],
67
+ },
68
+ uv: {
69
+ url: "https://astral.sh/uv/install.sh",
70
+ interpreter: "sh",
71
+ prefix: LOCAL_PREFIX,
72
+ needs: ["curl"],
73
+ },
74
+ } as const satisfies Record<string, Bootstrap>;
75
+
76
+ export type BootstrapTool = keyof typeof TOOLCHAIN_BOOTSTRAP;
77
+
78
+ type Base = { os?: Os[] };
79
+ export type InstallOption =
80
+ | (Base & { via: "bun"; package: string; version?: string })
81
+ // Exactly one of `formula` and `cask`: a cask installs with `--cask`.
82
+ | (Base & { via: "brew"; formula: string; cask?: never })
83
+ | (Base & { via: "brew"; cask: string; formula?: never })
84
+ | (Base & { via: "apt"; packages: string[] })
85
+ | (Base & {
86
+ via: "release";
87
+ repo: string;
88
+ tag: string;
89
+ /**
90
+ * The asset's file name, with `{os}` and `{arch}` substituted. One
91
+ * ending in `.tar.gz` or `.tgz` is an archive holding `binary`;
92
+ * any other name is the binary itself.
93
+ */
94
+ asset: string;
95
+ /** The installed file's name (and, for an archive, its entry). */
96
+ binary: string;
97
+ /** How this release spells each arch in `{arch}`, when not `amd64`/`arm64`. */
98
+ arch?: Partial<Record<Arch, string>>;
99
+ sha256: Partial<Record<Platform, string>>;
100
+ })
101
+ | (Base & { via: "bootstrap"; tool: BootstrapTool })
102
+ | (Base & { via: "uv-script"; script: string; args: string[] })
103
+ | (Base & {
104
+ via: "uv-module";
105
+ script: string;
106
+ module: string;
107
+ args: string[];
108
+ root?: true;
109
+ })
110
+ // Profile-only: a profile's cliTools use these; a plugin.json may not.
111
+ | (Base & { via: "npm" | "pip" | "go"; package: string; version?: string });
112
+
113
+ export type Via = InstallOption["via"];
114
+
115
+ export interface Requirement {
116
+ name: string;
117
+ check: string[];
118
+ os?: Os[];
119
+ install: InstallOption[];
120
+ }
121
+
122
+ export type ParseResult =
123
+ | { ok: true; requirements: Requirement[] }
124
+ | {
125
+ ok: false;
126
+ kind: "invalid" | "newer-schema" | "older-schema";
127
+ errors: string[];
128
+ };
129
+
130
+ // ─── parsing ──────────────────────────────────────────────────────────────────
131
+
132
+ const NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
133
+ const NPM_NAME_RE = /^(@[a-z0-9~-][a-z0-9._~-]*\/)?[a-z0-9~-][a-z0-9._~-]*$/;
134
+ const VERSION_RE = /^[A-Za-z0-9_.+~^<>=*-]*[A-Za-z0-9*]$/;
135
+ const FORMULA_RE = /^[a-z0-9][a-z0-9+._@/-]*$/;
136
+ const APT_RE = /^[a-z0-9][a-z0-9+.-]+$/;
137
+ const REPO_RE = /^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/;
138
+ const TAG_RE = /^[A-Za-z0-9._][A-Za-z0-9._-]*$/;
139
+ const ASSET_RE = /^[A-Za-z0-9._-]+$/;
140
+ const SHA256_RE = /^[0-9a-f]{64}$/;
141
+ const MODULE_RE = /^[A-Za-z_][A-Za-z0-9_.]*$/;
142
+ const SCRIPT_RE = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
143
+ const ROOT_TOKEN = "${CLAUDE_PLUGIN_ROOT}";
144
+
145
+ const PROFILE_ONLY: readonly Via[] = ["npm", "pip", "go"];
146
+
147
+ /** Archive names a release asset may not carry: only a tar.gz is extracted. */
148
+ const UNSUPPORTED_ARCHIVE_RE =
149
+ /\.(zip|tar|tar\.xz|txz|tar\.bz2|tbz2?|gz|xz|bz2|7z)$/;
150
+
151
+ /**
152
+ * What a release asset is: a `.tar.gz`/`.tgz` archive holding the binary, or
153
+ * the binary itself. Read from the asset's name, so a declaration states it once.
154
+ */
155
+ export function releaseFormat(asset: string): "tar.gz" | "binary" {
156
+ return /\.(tar\.gz|tgz)$/.test(asset) ? "tar.gz" : "binary";
157
+ }
158
+
159
+ /**
160
+ * The release asset's file name on one platform: `{os}` and `{arch}`
161
+ * substituted, `{arch}` in the release's own spelling (`arch`). The one
162
+ * substitution — the installer, the by-hand line and `scripts/pin-release.ts`
163
+ * all call it.
164
+ */
165
+ export function releaseAsset(
166
+ option: { asset: string; arch?: Partial<Record<Arch, string>> },
167
+ os: Os,
168
+ arch: Arch,
169
+ ): string {
170
+ return option.asset
171
+ .replaceAll("{os}", os)
172
+ .replaceAll("{arch}", option.arch?.[arch] ?? arch);
173
+ }
174
+
175
+ /** The keys each `via` allows, besides `via` and `os`. */
176
+ const OPTION_KEYS: Record<Via, readonly string[]> = {
177
+ bun: ["package", "version"],
178
+ brew: ["formula", "cask"],
179
+ apt: ["packages"],
180
+ release: ["repo", "tag", "asset", "binary", "arch", "sha256"],
181
+ bootstrap: ["tool"],
182
+ "uv-script": ["script", "args"],
183
+ "uv-module": ["script", "module", "args", "root"],
184
+ npm: ["package", "version"],
185
+ pip: ["package", "version"],
186
+ go: ["package", "version"],
187
+ };
188
+
189
+ /** Toolchains an option kind needs declared beside it in the same plugin. */
190
+ const OPTION_PREREQ: Partial<Record<Via, string>> = {
191
+ bun: "bun",
192
+ "uv-script": "uv",
193
+ "uv-module": "uv",
194
+ };
195
+
196
+ type Obj = Record<string, unknown>;
197
+ const isObj = (v: unknown): v is Obj =>
198
+ typeof v === "object" && v !== null && !Array.isArray(v);
199
+ const isStrArray = (v: unknown): v is string[] =>
200
+ Array.isArray(v) && v.every((s) => typeof s === "string");
201
+
202
+ function unknownKeys(
203
+ at: string,
204
+ o: Obj,
205
+ allowed: readonly string[],
206
+ errors: string[],
207
+ ): void {
208
+ for (const key of Object.keys(o)) {
209
+ if (!allowed.includes(key)) errors.push(`${at}: unknown key "${key}"`);
210
+ }
211
+ }
212
+
213
+ function parseOs(at: string, v: unknown, errors: string[]): Os[] | undefined {
214
+ if (v === undefined) return undefined;
215
+ if (
216
+ !isStrArray(v) ||
217
+ v.length === 0 ||
218
+ !v.every((s) => (OSES as readonly string[]).includes(s))
219
+ ) {
220
+ errors.push(`${at}.os: must be a non-empty list of ${OSES.join(" | ")}`);
221
+ return undefined;
222
+ }
223
+ return v as Os[];
224
+ }
225
+
226
+ function checkScript(at: string, v: unknown, errors: string[]): string {
227
+ if (typeof v !== "string" || !SCRIPT_RE.test(v)) {
228
+ errors.push(`${at}.script: must be a relative path inside the plugin root`);
229
+ return "";
230
+ }
231
+ if (v.split("/").some((part) => part === "." || part === "..")) {
232
+ errors.push(`${at}.script: must not contain "." or ".." segments`);
233
+ }
234
+ return v;
235
+ }
236
+
237
+ function checkArgs(at: string, v: unknown, errors: string[]): string[] {
238
+ if (!isStrArray(v)) {
239
+ errors.push(`${at}.args: must be a list of strings`);
240
+ return [];
241
+ }
242
+ return v;
243
+ }
244
+
245
+ function req(
246
+ at: string,
247
+ o: Obj,
248
+ key: string,
249
+ re: RegExp,
250
+ errors: string[],
251
+ ): string {
252
+ const v = o[key];
253
+ if (typeof v !== "string" || !re.test(v)) {
254
+ errors.push(`${at}.${key}: missing or malformed`);
255
+ return "";
256
+ }
257
+ return v;
258
+ }
259
+
260
+ function parseOption(
261
+ at: string,
262
+ raw: unknown,
263
+ reqName: string,
264
+ errors: string[],
265
+ ): InstallOption | null {
266
+ if (!isObj(raw)) {
267
+ errors.push(`${at}: must be an object`);
268
+ return null;
269
+ }
270
+ const via = raw.via;
271
+ if (typeof via !== "string" || !(via in OPTION_KEYS)) {
272
+ errors.push(`${at}: unknown via "${String(via)}"`);
273
+ return null;
274
+ }
275
+ const kind = via as Via;
276
+ if (PROFILE_ONLY.includes(kind)) {
277
+ errors.push(`${at}: via ${kind} is not allowed in plugin.json`);
278
+ return null;
279
+ }
280
+ unknownKeys(at, raw, ["via", "os", ...OPTION_KEYS[kind]], errors);
281
+ const os = parseOs(at, raw.os, errors);
282
+ const base = os ? { os } : {};
283
+ switch (kind) {
284
+ case "bun": {
285
+ const option: InstallOption = {
286
+ ...base,
287
+ via: "bun",
288
+ package: req(at, raw, "package", NPM_NAME_RE, errors),
289
+ };
290
+ if (raw.version !== undefined) {
291
+ option.version = req(at, raw, "version", VERSION_RE, errors);
292
+ }
293
+ return option;
294
+ }
295
+ case "brew":
296
+ if ((raw.formula === undefined) === (raw.cask === undefined)) {
297
+ errors.push(`${at}: brew takes exactly one of formula and cask`);
298
+ return null;
299
+ }
300
+ return raw.cask !== undefined
301
+ ? {
302
+ ...base,
303
+ via: "brew",
304
+ cask: req(at, raw, "cask", FORMULA_RE, errors),
305
+ }
306
+ : {
307
+ ...base,
308
+ via: "brew",
309
+ formula: req(at, raw, "formula", FORMULA_RE, errors),
310
+ };
311
+ case "apt": {
312
+ const packages = raw.packages;
313
+ if (
314
+ !isStrArray(packages) ||
315
+ packages.length === 0 ||
316
+ !packages.every((p) => APT_RE.test(p))
317
+ ) {
318
+ errors.push(
319
+ `${at}.packages: must be a non-empty list of apt package names`,
320
+ );
321
+ return null;
322
+ }
323
+ return { ...base, via: "apt", packages };
324
+ }
325
+ case "release": {
326
+ const asset = req(at, raw, "asset", /^.+$/, errors);
327
+ const stripped = asset.replaceAll("{os}", "").replaceAll("{arch}", "");
328
+ if (asset && !ASSET_RE.test(stripped)) {
329
+ errors.push(
330
+ `${at}.asset: only {os} and {arch} may be substituted into a file name`,
331
+ );
332
+ } else if (
333
+ asset &&
334
+ releaseFormat(asset) === "binary" &&
335
+ UNSUPPORTED_ARCHIVE_RE.test(asset)
336
+ ) {
337
+ errors.push(
338
+ `${at}.asset: an archive must be .tar.gz or .tgz; any other name is installed as the binary itself`,
339
+ );
340
+ }
341
+ let arch: Partial<Record<Arch, string>> | undefined;
342
+ if (raw.arch !== undefined) {
343
+ arch = {};
344
+ if (!isObj(raw.arch)) {
345
+ errors.push(
346
+ `${at}.arch: must map amd64/arm64 to the release's spelling`,
347
+ );
348
+ } else {
349
+ for (const [from, to] of Object.entries(raw.arch)) {
350
+ if (!(ARCHES as readonly string[]).includes(from)) {
351
+ errors.push(`${at}.arch: unknown arch "${from}"`);
352
+ } else if (typeof to !== "string" || !ASSET_RE.test(to)) {
353
+ errors.push(`${at}.arch.${from}: missing or malformed`);
354
+ } else {
355
+ arch[from as Arch] = to;
356
+ }
357
+ }
358
+ }
359
+ }
360
+ const sha256: Partial<Record<Platform, string>> = {};
361
+ if (!isObj(raw.sha256)) {
362
+ errors.push(`${at}.sha256: must map {os}_{arch} to a sha256 digest`);
363
+ } else {
364
+ for (const [platform, digest] of Object.entries(raw.sha256)) {
365
+ if (!(PLATFORMS as readonly string[]).includes(platform)) {
366
+ errors.push(`${at}.sha256: unknown platform "${platform}"`);
367
+ } else if (typeof digest !== "string" || !SHA256_RE.test(digest)) {
368
+ errors.push(`${at}.sha256.${platform}: must be 64 lowercase hex`);
369
+ } else {
370
+ sha256[platform as Platform] = digest;
371
+ }
372
+ }
373
+ }
374
+ return {
375
+ ...base,
376
+ via: "release",
377
+ repo: req(at, raw, "repo", REPO_RE, errors),
378
+ tag: req(at, raw, "tag", TAG_RE, errors),
379
+ asset,
380
+ binary: req(at, raw, "binary", TAG_RE, errors),
381
+ ...(arch ? { arch } : {}),
382
+ sha256,
383
+ };
384
+ }
385
+ case "bootstrap": {
386
+ const tool = raw.tool;
387
+ if (typeof tool !== "string" || !(tool in TOOLCHAIN_BOOTSTRAP)) {
388
+ errors.push(
389
+ `${at}.tool: must be one of ${Object.keys(TOOLCHAIN_BOOTSTRAP).join(", ")}`,
390
+ );
391
+ return null;
392
+ }
393
+ if (tool !== reqName) {
394
+ errors.push(
395
+ `${at}.tool: bootstraps "${tool}" for a requirement named "${reqName}"`,
396
+ );
397
+ }
398
+ return { ...base, via: "bootstrap", tool: tool as BootstrapTool };
399
+ }
400
+ case "uv-script":
401
+ return {
402
+ ...base,
403
+ via: "uv-script",
404
+ script: checkScript(at, raw.script, errors),
405
+ args: checkArgs(at, raw.args, errors),
406
+ };
407
+ case "uv-module": {
408
+ const option: InstallOption = {
409
+ ...base,
410
+ via: "uv-module",
411
+ script: checkScript(at, raw.script, errors),
412
+ module: req(at, raw, "module", MODULE_RE, errors),
413
+ args: checkArgs(at, raw.args, errors),
414
+ };
415
+ if (raw.root !== undefined) {
416
+ if (raw.root !== true) errors.push(`${at}.root: may only be true`);
417
+ else option.root = true;
418
+ }
419
+ return option;
420
+ }
421
+ case "npm":
422
+ case "pip":
423
+ case "go":
424
+ return null;
425
+ default:
426
+ return assertNever(kind);
427
+ }
428
+ }
429
+
430
+ function parseRequirement(
431
+ at: string,
432
+ raw: unknown,
433
+ errors: string[],
434
+ ): Requirement | null {
435
+ if (!isObj(raw)) {
436
+ errors.push(`${at}: must be an object`);
437
+ return null;
438
+ }
439
+ unknownKeys(at, raw, ["name", "check", "os", "install"], errors);
440
+ const name = raw.name;
441
+ if (typeof name !== "string" || !NAME_RE.test(name)) {
442
+ errors.push(`${at}.name: missing or malformed`);
443
+ return null;
444
+ }
445
+ const named = `${at} (${name})`;
446
+ const check = raw.check;
447
+ if (!isStrArray(check) || check.length === 0 || check.some((s) => !s)) {
448
+ errors.push(`${named}.check: missing check (a non-empty argv)`);
449
+ } else {
450
+ for (const token of check) {
451
+ if (token.replaceAll(ROOT_TOKEN, "").includes("${")) {
452
+ errors.push(
453
+ `${named}.check: only ${ROOT_TOKEN} is expanded, found "${token}"`,
454
+ );
455
+ }
456
+ }
457
+ }
458
+ const os = parseOs(named, raw.os, errors);
459
+ if (!Array.isArray(raw.install) || raw.install.length === 0) {
460
+ errors.push(`${named}.install: must be a non-empty list of options`);
461
+ return null;
462
+ }
463
+ const install = raw.install
464
+ .map((o, i) => parseOption(`${named}.install[${i}]`, o, name, errors))
465
+ .filter((o): o is InstallOption => o !== null);
466
+ const requirement: Requirement = {
467
+ name,
468
+ check: isStrArray(check) ? check : [],
469
+ install,
470
+ };
471
+ if (os) requirement.os = os;
472
+ return requirement;
473
+ }
474
+
475
+ /**
476
+ * Parse the `requires` key of a plugin.json. Nothing else in the manifest is
477
+ * read. A manifest without `requires` declares nothing and parses as empty.
478
+ */
479
+ export function parseRequires(pluginJson: unknown): ParseResult {
480
+ if (!isObj(pluginJson)) {
481
+ return {
482
+ ok: false,
483
+ kind: "invalid",
484
+ errors: ["plugin.json: not an object"],
485
+ };
486
+ }
487
+ const raw = pluginJson.requires;
488
+ if (raw === undefined) return { ok: true, requirements: [] };
489
+ if (!isObj(raw)) {
490
+ return { ok: false, kind: "invalid", errors: ["requires: not an object"] };
491
+ }
492
+ const schema = raw.schema;
493
+ if (typeof schema !== "number" || !Number.isInteger(schema)) {
494
+ return {
495
+ ok: false,
496
+ kind: "invalid",
497
+ errors: ["requires.schema: missing (this reader reads schema 1)"],
498
+ };
499
+ }
500
+ if (schema > REQUIRES_SCHEMA) {
501
+ return {
502
+ ok: false,
503
+ kind: "newer-schema",
504
+ errors: [
505
+ `declares requires schema ${schema}; this magus-cli reads ${REQUIRES_SCHEMA} — update magus-cli`,
506
+ ],
507
+ };
508
+ }
509
+ if (schema < REQUIRES_SCHEMA) {
510
+ return {
511
+ ok: false,
512
+ kind: "older-schema",
513
+ errors: [
514
+ `declares requires schema ${schema}; this magus-cli reads ${REQUIRES_SCHEMA} — update the plugin`,
515
+ ],
516
+ };
517
+ }
518
+ const errors: string[] = [];
519
+ unknownKeys("requires", raw, ["schema", "bin"], errors);
520
+ if (!Array.isArray(raw.bin)) {
521
+ errors.push("requires.bin: must be a list");
522
+ return { ok: false, kind: "invalid", errors };
523
+ }
524
+ const requirements = raw.bin
525
+ .map((r, i) => parseRequirement(`requires.bin[${i}]`, r, errors))
526
+ .filter((r): r is Requirement => r !== null);
527
+
528
+ const names = new Set<string>();
529
+ for (const r of requirements) {
530
+ if (names.has(r.name)) errors.push(`requires.bin: "${r.name}" twice`);
531
+ names.add(r.name);
532
+ }
533
+ for (const r of requirements) {
534
+ for (const option of r.install) {
535
+ const needs = OPTION_PREREQ[option.via];
536
+ if (needs && !names.has(needs)) {
537
+ errors.push(
538
+ `requires.bin (${r.name}): via ${option.via} needs "${needs}" declared in the same plugin`,
539
+ );
540
+ }
541
+ }
542
+ }
543
+ if (errors.length > 0) return { ok: false, kind: "invalid", errors };
544
+ return { ok: true, requirements };
545
+ }
546
+
547
+ // ─── identity ─────────────────────────────────────────────────────────────────
548
+
549
+ function canonical(value: unknown): string {
550
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
551
+ if (isObj(value)) {
552
+ return `{${Object.keys(value)
553
+ .sort()
554
+ .map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`)
555
+ .join(",")}}`;
556
+ }
557
+ return JSON.stringify(value);
558
+ }
559
+
560
+ /**
561
+ * `<name>-<first 12 hex of sha256(canonical declaration)>`. Identical
562
+ * declarations in two plugins share a key, so a dep is reported once per
563
+ * session however many plugins need it; different ones never hide each other.
564
+ */
565
+ export function dedupeKey(r: Requirement): string {
566
+ const digest = createHash("sha256").update(canonical(r)).digest("hex");
567
+ return `${r.name}-${digest.slice(0, 12)}`;
568
+ }
569
+
570
+ // ─── platform and PATH ────────────────────────────────────────────────────────
571
+
572
+ /**
573
+ * How much a probe runs.
574
+ *
575
+ * "presence" (the SessionStart hook): a requirement whose check runs its own
576
+ * binary (`check[0]` is the requirement's name) is decided by the PATH lookup
577
+ * alone, so a session start never waits on a slow `--version`; a dangling
578
+ * symlink still reads as missing. Every other check runs, because it answers
579
+ * something a lookup cannot (a script env, a marker file).
580
+ *
581
+ * "full" (`magus doctor`): every check runs.
582
+ */
583
+ export type ProbeDepth = "presence" | "full";
584
+
585
+ export interface ProbeEnv {
586
+ pluginRoot: string;
587
+ os: Os;
588
+ arch: Arch;
589
+ home: string;
590
+ /** The PATH the plugin's MCP servers and hooks inherit. */
591
+ path: string;
592
+ /** Per-check cap: the hook uses 3000, doctor 60000. */
593
+ capMs: number;
594
+ /** How much of each check to run — see {@link ProbeDepth}. */
595
+ depth: ProbeDepth;
596
+ /** `$SHELL`, to name the rc file for a PATH line. */
597
+ userShell?: string;
598
+ }
599
+
600
+ /** This process's platform, or null on one the schema does not cover. */
601
+ export function currentPlatform(): { os: Os; arch: Arch } | null {
602
+ const os =
603
+ process.platform === "darwin" || process.platform === "linux"
604
+ ? process.platform
605
+ : null;
606
+ const arch =
607
+ process.arch === "x64"
608
+ ? "amd64"
609
+ : process.arch === "arm64"
610
+ ? "arm64"
611
+ : null;
612
+ return os && arch ? { os, arch } : null;
613
+ }
614
+
615
+ const appliesTo = (os: Os[] | undefined, current: Os): boolean =>
616
+ os === undefined || os.includes(current);
617
+
618
+ /** The requirements that apply on `os`. */
619
+ export function applicable(reqs: Requirement[], os: Os): Requirement[] {
620
+ return reqs.filter((r) => appliesTo(r.os, os));
621
+ }
622
+
623
+ function isExecutableFile(file: string): boolean {
624
+ try {
625
+ if (!statSync(file).isFile()) return false;
626
+ accessSync(file, constants.X_OK);
627
+ return true;
628
+ } catch {
629
+ return false;
630
+ }
631
+ }
632
+
633
+ /** The executable `tool` resolves to on `path`, or null. No shell involved. */
634
+ export function onPath(tool: string, path: string): string | null {
635
+ if (tool.includes("/")) return isExecutableFile(tool) ? tool : null;
636
+ for (const dir of path.split(delimiter)) {
637
+ if (!dir) continue;
638
+ const candidate = join(dir, tool);
639
+ if (isExecutableFile(candidate)) return candidate;
640
+ }
641
+ return null;
642
+ }
643
+
644
+ /** `~/x` → `<home>/x`. */
645
+ export function expandHome(dir: string, home: string): string {
646
+ return dir.startsWith("~/") ? join(home, dir.slice(2)) : dir;
647
+ }
648
+
649
+ export interface UserPrefixInfo {
650
+ dir: UserPrefix;
651
+ rcLine(shell: string): string;
652
+ }
653
+
654
+ /**
655
+ * The rc-file line that puts `dir` on PATH for `shell`: the one rendering of
656
+ * it. `dir` is absolute or `~/…`; `~` becomes `$HOME`.
657
+ */
658
+ export function pathRcLine(dir: string, shell: string): string {
659
+ const shellDir = dir.replace(/^~/, "$HOME");
660
+ return shell.endsWith("fish")
661
+ ? `fish_add_path ${shellDir}`
662
+ : `export PATH="${shellDir}:$PATH"`;
663
+ }
664
+
665
+ function prefixRcLine(dir: UserPrefix): (shell: string) => string {
666
+ return (shell) => pathRcLine(dir, shell);
667
+ }
668
+
669
+ /** Where bun, uv and released binaries land, and the line that puts each on PATH. */
670
+ export const USER_PREFIXES: readonly UserPrefixInfo[] = [
671
+ { dir: BUN_PREFIX, rcLine: prefixRcLine(BUN_PREFIX) },
672
+ { dir: LOCAL_PREFIX, rcLine: prefixRcLine(LOCAL_PREFIX) },
673
+ ];
674
+
675
+ /** The rc file a PATH line belongs in, from `$SHELL`. */
676
+ export function rcFile(shell: string): string {
677
+ if (shell.endsWith("zsh")) return "~/.zshrc";
678
+ if (shell.endsWith("fish")) return "~/.config/fish/config.fish";
679
+ return "~/.bashrc";
680
+ }
681
+
682
+ // ─── presence ─────────────────────────────────────────────────────────────────
683
+
684
+ export type Presence =
685
+ | { name: string; status: "present" }
686
+ | { name: string; status: "missing"; blockedBy?: string }
687
+ | { name: string; status: "off-path"; prefix: UserPrefix }
688
+ | { name: string; status: "unknown"; reason: string };
689
+
690
+ /** The check argv with `${CLAUDE_PLUGIN_ROOT}` expanded, and nothing else. */
691
+ export function expandCheck(r: Requirement, pluginRoot: string): string[] {
692
+ return r.check.map((token) => token.replaceAll(ROOT_TOKEN, pluginRoot));
693
+ }
694
+
695
+ function offPathPrefix(tool: string, home: string): UserPrefix | null {
696
+ if (tool.includes("/")) return null;
697
+ for (const prefix of USER_PREFIXES) {
698
+ if (isExecutableFile(join(expandHome(prefix.dir, home), tool))) {
699
+ return prefix.dir;
700
+ }
701
+ }
702
+ return null;
703
+ }
704
+
705
+ function runCheck(
706
+ argv: string[],
707
+ env: ProbeEnv,
708
+ ): Promise<"pass" | "fail" | "timeout"> {
709
+ return new Promise((resolve) => {
710
+ const [cmd = "", ...args] = argv;
711
+ const exe = onPath(cmd, env.path);
712
+ if (!exe) {
713
+ resolve("fail");
714
+ return;
715
+ }
716
+ let settled = false;
717
+ const settle = (outcome: "pass" | "fail" | "timeout"): void => {
718
+ if (settled) return;
719
+ settled = true;
720
+ clearTimeout(timer);
721
+ resolve(outcome);
722
+ };
723
+ const child = spawn(exe, args, {
724
+ cwd: env.pluginRoot,
725
+ env: { ...process.env, PATH: env.path, HOME: env.home },
726
+ stdio: "ignore",
727
+ });
728
+ const timer = setTimeout(() => {
729
+ child.kill("SIGKILL");
730
+ settle("timeout");
731
+ }, env.capMs);
732
+ child.on("error", () => settle("fail"));
733
+ child.on("exit", (code) => settle(code === 0 ? "pass" : "fail"));
734
+ });
735
+ }
736
+
737
+ /** A check that did not pass: off PATH when a user prefix holds `tool`. */
738
+ function notFound(r: Requirement, tool: string, env: ProbeEnv): Presence {
739
+ if (!onPath(tool, env.path)) {
740
+ const prefix = offPathPrefix(tool, env.home);
741
+ if (prefix) return { name: r.name, status: "off-path", prefix };
742
+ }
743
+ return { name: r.name, status: "missing" };
744
+ }
745
+
746
+ async function probeOne(r: Requirement, env: ProbeEnv): Promise<Presence> {
747
+ const argv = expandCheck(r, env.pluginRoot);
748
+ const tool = argv[0] ?? "";
749
+ if (env.depth === "presence" && tool === r.name) {
750
+ return onPath(tool, env.path)
751
+ ? { name: r.name, status: "present" }
752
+ : notFound(r, tool, env);
753
+ }
754
+ const outcome = await runCheck(argv, env);
755
+ if (outcome === "pass") return { name: r.name, status: "present" };
756
+ if (outcome === "timeout") {
757
+ return {
758
+ name: r.name,
759
+ status: "unknown",
760
+ reason: `slow: no answer within ${Math.round(env.capMs / 1000)}s`,
761
+ };
762
+ }
763
+ return notFound(r, tool, env);
764
+ }
765
+
766
+ /**
767
+ * Probe every requirement that applies on `env.os`, in parallel. A check whose
768
+ * argv[0] is another declared requirement waits for it, and is `missing` with
769
+ * `blockedBy` when that one is not present — its own check could not run.
770
+ */
771
+ export async function probe(
772
+ reqs: Requirement[],
773
+ env: ProbeEnv,
774
+ ): Promise<Presence[]> {
775
+ const live = applicable(reqs, env.os);
776
+ const names = new Set(live.map((r) => r.name));
777
+ const dependsOn = (r: Requirement): string | null => {
778
+ const head = r.check[0];
779
+ return head && head !== r.name && names.has(head) ? head : null;
780
+ };
781
+ const first = new Map<string, Presence>();
782
+ await Promise.all(
783
+ live
784
+ .filter((r) => dependsOn(r) === null)
785
+ .map(async (r) => first.set(r.name, await probeOne(r, env))),
786
+ );
787
+ await Promise.all(
788
+ live
789
+ .filter((r) => dependsOn(r) !== null)
790
+ .map(async (r) => {
791
+ const dep = dependsOn(r) as string;
792
+ const depStatus = first.get(dep)?.status;
793
+ first.set(
794
+ r.name,
795
+ depStatus === "present"
796
+ ? await probeOne(r, env)
797
+ : { name: r.name, status: "missing", blockedBy: dep },
798
+ );
799
+ }),
800
+ );
801
+ return live.map((r) => first.get(r.name) as Presence);
802
+ }
803
+
804
+ // ─── installing ───────────────────────────────────────────────────────────────
805
+
806
+ export type InstallStep =
807
+ | {
808
+ kind: "argv";
809
+ name: string;
810
+ escalation: "none" | "sudo";
811
+ argv: string[];
812
+ }
813
+ | {
814
+ kind: "uv-module";
815
+ name: string;
816
+ escalation: "none" | "sudo";
817
+ script: string;
818
+ module: string;
819
+ args: string[];
820
+ }
821
+ | {
822
+ kind: "bootstrap";
823
+ name: string;
824
+ url: string;
825
+ interpreter: "sh" | "bash";
826
+ prefix: UserPrefix;
827
+ }
828
+ | {
829
+ kind: "release";
830
+ name: string;
831
+ url: string;
832
+ sha256: string;
833
+ /** An archive to extract `binary` from, or the binary itself. */
834
+ format: "tar.gz" | "binary";
835
+ binary: string;
836
+ dest: typeof LOCAL_PREFIX;
837
+ };
838
+
839
+ export function assertNever(value: never): never {
840
+ throw new Error(`unhandled case: ${JSON.stringify(value)}`);
841
+ }
842
+
843
+ const platformOf = (env: Pick<ProbeEnv, "os" | "arch">): Platform =>
844
+ `${env.os}_${env.arch}`;
845
+
846
+ /**
847
+ * Why `option` cannot be used here, or null when it can. `has` answers whether
848
+ * a tool is present or will be installed earlier in the same plan.
849
+ */
850
+ export function optionBlocker(
851
+ option: InstallOption,
852
+ env: Pick<ProbeEnv, "os" | "arch">,
853
+ has: (tool: string) => boolean,
854
+ ): string | null {
855
+ if (!appliesTo(option.os, env.os)) return `not for ${env.os}`;
856
+ switch (option.via) {
857
+ case "bun":
858
+ return has("bun") ? null : "needs Bun";
859
+ case "brew":
860
+ return has("brew")
861
+ ? null
862
+ : "Homebrew is missing — install it from https://brew.sh first";
863
+ case "apt":
864
+ return has("apt-get") ? null : "apt-get is not on this machine";
865
+ case "release":
866
+ return option.sha256[platformOf(env)]
867
+ ? null
868
+ : `no pinned build for ${platformOf(env)}`;
869
+ case "bootstrap": {
870
+ const missing = TOOLCHAIN_BOOTSTRAP[option.tool].needs.filter(
871
+ (tool) => !has(tool),
872
+ );
873
+ if (missing.length === 0) return null;
874
+ const line =
875
+ env.os === "darwin"
876
+ ? `brew install ${missing.join(" ")}`
877
+ : `sudo apt-get install -y ${missing.join(" ")}`;
878
+ return `its installer needs ${missing.join(", ")}: ${line}`;
879
+ }
880
+ case "uv-script":
881
+ case "uv-module":
882
+ return has("uv") ? null : "needs uv";
883
+ case "npm":
884
+ return has("npm") ? null : "needs npm";
885
+ case "pip":
886
+ return has("uv") ? null : "needs uv";
887
+ case "go":
888
+ return has("go") ? null : "needs Go";
889
+ default:
890
+ return assertNever(option);
891
+ }
892
+ }
893
+
894
+ /** The first option usable here, in declaration order, or null. */
895
+ export function selectOption(
896
+ r: Requirement,
897
+ env: Pick<ProbeEnv, "os" | "arch">,
898
+ has: (tool: string) => boolean,
899
+ ): InstallOption | null {
900
+ return r.install.find((o) => optionBlocker(o, env, has) === null) ?? null;
901
+ }
902
+
903
+ /** Why no option of `r` is usable here: each applicable option's blocker. */
904
+ export function whyUnselectable(
905
+ r: Requirement,
906
+ env: Pick<ProbeEnv, "os" | "arch">,
907
+ has: (tool: string) => boolean,
908
+ ): string {
909
+ const reasons = r.install
910
+ .filter((o) => appliesTo(o.os, env.os))
911
+ .map((o) => optionBlocker(o, env, has))
912
+ .filter((why): why is string => why !== null);
913
+ return reasons.length > 0
914
+ ? [...new Set(reasons)].join("; ")
915
+ : `no install option for ${env.os}`;
916
+ }
917
+
918
+ const pinned = (pkg: string, version?: string): string =>
919
+ version ? `${pkg}@${version}` : pkg;
920
+
921
+ /**
922
+ * `uv <sub…> --no-config --script <script>`: the one argv for a uv call that
923
+ * builds or reads a plugin's script env. uv otherwise reads `uv.toml` and
924
+ * `pyproject.toml` from the directory it runs in, which is the user's
925
+ * project, so that project's index, Python preference or `offline` setting
926
+ * would decide what the plugin imports, and every project reuses the env built
927
+ * there. `uv` is the executable: the executor passes the one it resolved.
928
+ */
929
+ export function uvScriptArgv(
930
+ sub: string[],
931
+ script: string,
932
+ uv = "uv",
933
+ ): string[] {
934
+ return [uv, ...sub, "--no-config", "--script", script];
935
+ }
936
+
937
+ /** The one rendering of an option into the step that installs it. */
938
+ export function renderInstall(
939
+ r: Requirement,
940
+ option: InstallOption,
941
+ env: Pick<ProbeEnv, "os" | "arch" | "pluginRoot">,
942
+ ): InstallStep {
943
+ const name = r.name;
944
+ const argv = (args: string[], escalation: "none" | "sudo" = "none") =>
945
+ ({ kind: "argv", name, escalation, argv: args }) as const;
946
+ switch (option.via) {
947
+ case "bun":
948
+ return argv(["bun", "add", "-g", pinned(option.package, option.version)]);
949
+ case "brew":
950
+ return argv(
951
+ option.cask !== undefined
952
+ ? ["brew", "install", "--cask", option.cask]
953
+ : ["brew", "install", option.formula],
954
+ );
955
+ case "apt":
956
+ return argv(
957
+ [
958
+ "apt-get",
959
+ "install",
960
+ "-y",
961
+ "--no-install-recommends",
962
+ ...option.packages,
963
+ ],
964
+ "sudo",
965
+ );
966
+ case "release": {
967
+ const platform = platformOf(env);
968
+ const asset = releaseAsset(option, env.os, env.arch);
969
+ const url = new URL(
970
+ `https://github.com/${option.repo}/releases/download/${option.tag}/${asset}`,
971
+ );
972
+ return {
973
+ kind: "release",
974
+ name,
975
+ url: url.href,
976
+ sha256: option.sha256[platform] ?? "",
977
+ format: releaseFormat(asset),
978
+ binary: option.binary,
979
+ dest: LOCAL_PREFIX,
980
+ };
981
+ }
982
+ case "bootstrap": {
983
+ const b = TOOLCHAIN_BOOTSTRAP[option.tool];
984
+ return {
985
+ kind: "bootstrap",
986
+ name,
987
+ url: b.url,
988
+ interpreter: b.interpreter,
989
+ prefix: b.prefix,
990
+ };
991
+ }
992
+ case "uv-script":
993
+ return argv([
994
+ ...uvScriptArgv(["run"], join(env.pluginRoot, option.script)),
995
+ ...option.args,
996
+ ]);
997
+ case "uv-module":
998
+ return {
999
+ kind: "uv-module",
1000
+ name,
1001
+ escalation: option.root ? "sudo" : "none",
1002
+ script: join(env.pluginRoot, option.script),
1003
+ module: option.module,
1004
+ args: option.args,
1005
+ };
1006
+ case "npm":
1007
+ return argv([
1008
+ "npm",
1009
+ "install",
1010
+ "-g",
1011
+ pinned(option.package, option.version),
1012
+ ]);
1013
+ case "pip":
1014
+ return argv([
1015
+ "uv",
1016
+ "tool",
1017
+ "install",
1018
+ option.version
1019
+ ? `${option.package}==${option.version}`
1020
+ : option.package,
1021
+ ]);
1022
+ case "go":
1023
+ return argv([
1024
+ "go",
1025
+ "install",
1026
+ `${option.package}@${option.version ?? "latest"}`,
1027
+ ]);
1028
+ default:
1029
+ return assertNever(option);
1030
+ }
1031
+ }
1032
+
1033
+ /** Quote one word for display in a POSIX shell line. Display only. */
1034
+ export function shellWord(word: string): string {
1035
+ return /^[A-Za-z0-9_@%+=:,./~-]+$/.test(word)
1036
+ ? word
1037
+ : `'${word.replaceAll("'", `'\\''`)}'`;
1038
+ }
1039
+
1040
+ const shellLine = (argv: string[]): string => argv.map(shellWord).join(" ");
1041
+
1042
+ /**
1043
+ * The one human rendering of a step: a line a person can paste. Shown in the
1044
+ * hook banner, doctor, the TUI and /setup:project — never executed.
1045
+ */
1046
+ export function describeStep(step: InstallStep, os: Os): string {
1047
+ switch (step.kind) {
1048
+ case "argv":
1049
+ return `${step.escalation === "sudo" ? "sudo " : ""}${shellLine(step.argv)}`;
1050
+ case "uv-module": {
1051
+ const sync = shellLine(uvScriptArgv(["sync"], step.script));
1052
+ const find = shellLine(uvScriptArgv(["python", "find"], step.script));
1053
+ const run = shellLine(["-B", "-m", step.module, ...step.args]);
1054
+ const sudo = step.escalation === "sudo" ? "sudo " : "";
1055
+ return `${sync} && ${sudo}"$(${find})" ${run}`;
1056
+ }
1057
+ case "bootstrap":
1058
+ return `curl -fsSL ${step.url} | ${step.interpreter}`;
1059
+ case "release": {
1060
+ const asset = step.url.slice(step.url.lastIndexOf("/") + 1);
1061
+ const verify = os === "darwin" ? "shasum -a 256 -c" : "sha256sum -c";
1062
+ const archive = step.format === "tar.gz";
1063
+ return [
1064
+ "cd $(mktemp -d)",
1065
+ `curl -fsSLO ${step.url}`,
1066
+ `echo '${step.sha256} ${asset}' | ${verify}`,
1067
+ ...(archive ? [`tar -xzf ${asset} ${step.binary}`] : []),
1068
+ `mkdir -p ${step.dest}`,
1069
+ `install -m 755 ${archive ? step.binary : asset} ${step.dest}/${step.binary}`,
1070
+ ].join(" && ");
1071
+ }
1072
+ default:
1073
+ return assertNever(step);
1074
+ }
1075
+ }
1076
+
1077
+ // ─── the report ───────────────────────────────────────────────────────────────
1078
+
1079
+ /** The install line magus-cli is installed with. */
1080
+ export const MAGUS_CLI_INSTALL = "bun add -g magus-cli";
1081
+
1082
+ /**
1083
+ * The line that installs every plugin dependency, the one rendering of it.
1084
+ * Doctor reads the plugin set from the project it checks, so a line read
1085
+ * outside that project — the session banner, a remedy to run in a new
1086
+ * terminal — names it with `--project`. Null means "the directory you are in".
1087
+ */
1088
+ export function doctorFixCommand(projectDir: string | null): string {
1089
+ return projectDir === null
1090
+ ? "magus doctor --fix"
1091
+ : `magus doctor --fix --project ${shellWord(projectDir)}`;
1092
+ }
1093
+
1094
+ /**
1095
+ * The banner text for one plugin, or null when nothing is missing or off PATH:
1096
+ * a healthy machine prints nothing. An `unknown` (a check that was too slow to
1097
+ * answer) is mentioned only beside a real finding, and never called missing.
1098
+ * `projectDir` is the directory the session runs in, when the hook input says.
1099
+ */
1100
+ export function renderFix(
1101
+ plugin: string,
1102
+ presences: Presence[],
1103
+ reqs: Requirement[],
1104
+ env: ProbeEnv,
1105
+ magusOnPath: boolean,
1106
+ projectDir: string | null = null,
1107
+ ): string | null {
1108
+ const missing = presences.filter((p) => p.status === "missing");
1109
+ const offPath = presences.filter(
1110
+ (p): p is Extract<Presence, { status: "off-path" }> =>
1111
+ p.status === "off-path",
1112
+ );
1113
+ if (missing.length === 0 && offPath.length === 0) return null;
1114
+ const unknown = presences.filter(
1115
+ (p): p is Extract<Presence, { status: "unknown" }> =>
1116
+ p.status === "unknown",
1117
+ );
1118
+ const byName = new Map(reqs.map((r) => [r.name, r]));
1119
+ const shell = env.userShell ?? "";
1120
+
1121
+ const names = (ps: Presence[]) =>
1122
+ ps
1123
+ .map((p) => p.name)
1124
+ .sort()
1125
+ .join(", ");
1126
+ const head: string[] = [];
1127
+ if (missing.length > 0) head.push(`missing ${names(missing)}`);
1128
+ if (offPath.length > 0) head.push(`not on PATH ${names(offPath)}`);
1129
+ if (unknown.length > 0) head.push(`unknown (slow) ${names(unknown)}`);
1130
+ const lines = [`magus-deps · ${plugin}: ${head.join("; ")}.`];
1131
+ lines.push(
1132
+ ` Fix all: ${magusOnPath ? "" : `${MAGUS_CLI_INSTALL}, then `}${doctorFixCommand(projectDir)}`,
1133
+ );
1134
+
1135
+ // "present or installed earlier in the same fix": a declared dep counts.
1136
+ const has = (tool: string) =>
1137
+ byName.has(tool) || onPath(tool, env.path) !== null;
1138
+ const apt: string[] = [];
1139
+ const aptNames: string[] = [];
1140
+ const byHand: string[] = [];
1141
+ for (const p of [...missing].sort((a, b) => a.name.localeCompare(b.name))) {
1142
+ const r = byName.get(p.name);
1143
+ if (!r) continue;
1144
+ const option = selectOption(r, env, has);
1145
+ if (!option) {
1146
+ byHand.push(`${p.name}: ${whyUnselectable(r, env, has)}`);
1147
+ continue;
1148
+ }
1149
+ if (option.via === "apt") {
1150
+ apt.push(...option.packages);
1151
+ aptNames.push(p.name);
1152
+ continue;
1153
+ }
1154
+ const blocked =
1155
+ p.status === "missing" && p.blockedBy ? ` (after ${p.blockedBy})` : "";
1156
+ byHand.push(
1157
+ `${p.name}${blocked}: ${describeStep(renderInstall(r, option, env), env.os)}`,
1158
+ );
1159
+ }
1160
+ if (apt.length > 0) {
1161
+ // One apt line for every apt dep, rendered like any other step.
1162
+ const label = aptNames.join(", ");
1163
+ const merged = renderInstall(
1164
+ { name: label, check: [], install: [] },
1165
+ { via: "apt", packages: [...new Set(apt)] },
1166
+ env,
1167
+ );
1168
+ byHand.unshift(`${label}: ${describeStep(merged, env.os)}`);
1169
+ }
1170
+ if (byHand.length > 0) {
1171
+ lines.push(" Or by hand:");
1172
+ for (const line of byHand) lines.push(` ${line}`);
1173
+ }
1174
+ for (const p of offPath) {
1175
+ const prefix = USER_PREFIXES.find((u) => u.dir === p.prefix);
1176
+ const rc = prefix?.rcLine(shell) ?? "";
1177
+ lines.push(
1178
+ ` ${p.name} is in ${p.prefix}, which is not on the PATH Claude Code started with: add ${rc} to ${rcFile(shell)}`,
1179
+ );
1180
+ }
1181
+ lines.push(" Then restart Claude Code from a new terminal.");
1182
+ return lines.join("\n");
1183
+ }