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,213 @@
1
+ /**
2
+ * The terminal rendering of plugin dependencies: doctor's report section, the
3
+ * consent list, live progress, and the result of an apply. `magus doctor` and
4
+ * `magus install` both render through here; `--json` and the TUI render the
5
+ * same `ApplyOutcome[]` their own way.
6
+ */
7
+
8
+ import {
9
+ type ApplyEvent,
10
+ type ApplyOutcome,
11
+ DepsLockBusy,
12
+ type DepsPlan,
13
+ type PathAdvice,
14
+ type PlanEnv,
15
+ applyDepsPlan,
16
+ defaultLockPath,
17
+ planLines,
18
+ requiredByOf,
19
+ } from "../services/deps-plan.js";
20
+ import type { DepsState } from "../services/deps-report.js";
21
+ import { installText } from "../services/doctor-bins.js";
22
+ import {
23
+ type Os,
24
+ type Presence,
25
+ doctorFixCommand,
26
+ } from "../shared/plugin/hooks/lib/requires-probe.js";
27
+ import { askYesNo } from "./prompt.js";
28
+ import { question } from "./update-view.js";
29
+
30
+ function presenceLine(name: string, p: Presence, by: string): string | null {
31
+ switch (p.status) {
32
+ case "present":
33
+ return null;
34
+ case "missing":
35
+ return ` ✗ ${name} — missing${p.blockedBy ? ` (needs ${p.blockedBy} first)` : ""} (${by})`;
36
+ case "off-path":
37
+ return ` ⚠ ${name} — installed in ${p.prefix}, which is not on the PATH Claude Code starts with (${by})`;
38
+ case "unknown":
39
+ return ` ⚠ ${name} — could not tell: ${p.reason} (${by})`;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * doctor's "Plugin dependencies" section. Counts what `--fix` can and cannot
45
+ * act on. `projectDir` is `--project`'s directory, named in the fix line.
46
+ */
47
+ export function depsSectionLines(
48
+ state: DepsState,
49
+ env: PlanEnv,
50
+ projectDir: string | null = null,
51
+ ): { lines: string[]; fixable: number; unfixable: number } {
52
+ const { collected, presences, plan } = state;
53
+ const lines: string[] = [];
54
+ let unfixable = 0;
55
+ collected.bins.forEach((bin, i) => {
56
+ const p = presences[i] as Presence;
57
+ const line = presenceLine(bin.name, p, requiredByOf(bin).join(", "));
58
+ if (!line) return;
59
+ lines.push(line);
60
+ if (p.status === "missing")
61
+ lines.push(` fix: ${installText(bin, env)}`);
62
+ if (p.status === "off-path") unfixable++;
63
+ });
64
+ for (const problem of collected.problems) {
65
+ lines.push(
66
+ problem.kind === "invalid"
67
+ ? ` ✗ ${problem.plugin} — its dependency declaration is invalid (${problem.errors.join("; ")}) — please report it to the plugin's author`
68
+ : ` ✗ ${problem.plugin} — ${problem.errors.join("; ")}`,
69
+ );
70
+ unfixable++;
71
+ }
72
+ for (const u of plan.unfixable) {
73
+ lines.push(` ✗ ${u.name} — ${u.why}`);
74
+ unfixable++;
75
+ }
76
+ if (plan.steps.length > 0) {
77
+ lines.push(` Fix: ${doctorFixCommand(projectDir)}`);
78
+ }
79
+ lines.push(...pathAdviceLines(plan.pathAdvice));
80
+ if (lines.length === 0 && collected.bins.length > 0) {
81
+ lines.push(` ✓ All ${collected.bins.length} plugin dependencies present.`);
82
+ }
83
+ return {
84
+ lines: lines.length > 0 ? ["Plugin dependencies:", ...lines] : [],
85
+ fixable: plan.steps.length,
86
+ unfixable,
87
+ };
88
+ }
89
+
90
+ /** A box with a title, for the two blocks a person must act on. */
91
+ function box(title: string, body: string[]): string[] {
92
+ return [` ┌─ ${title}`, ...body.map((l) => ` │ ${l}`), " └─"];
93
+ }
94
+
95
+ /** The PATH lines to add, ending with the restart. Empty when none. */
96
+ export function pathAdviceLines(advice: readonly PathAdvice[]): string[] {
97
+ if (advice.length === 0) return [];
98
+ return box("DO THIS NEXT", [
99
+ ...advice.map(
100
+ (a) =>
101
+ `${a.prefix} is not on your PATH: add ${a.rcLine} to ${a.rcFile}`,
102
+ ),
103
+ "then restart Claude Code from a new terminal",
104
+ ]);
105
+ }
106
+
107
+ const STATUS_TEXT: Record<ApplyOutcome["status"], string> = {
108
+ installed: "installed",
109
+ "off-path": "installed OFF the PATH Claude Code starts with",
110
+ failed: "failed",
111
+ "needs-password": "needs your sudo password",
112
+ "needs-root": "needs root",
113
+ blocked: "blocked",
114
+ };
115
+
116
+ /** The result of an apply: one row per step, then what needs the user. */
117
+ export function renderApplyText(outcomes: readonly ApplyOutcome[]): string[] {
118
+ if (outcomes.length === 0) return [];
119
+ const width = Math.max(...outcomes.map((o) => o.name.length));
120
+ const mark = (o: ApplyOutcome) =>
121
+ o.status === "installed" ? "✓" : o.status === "off-path" ? "⚠" : "✗";
122
+ const lines = outcomes.map(
123
+ (o) =>
124
+ ` ${mark(o)} ${o.name.padEnd(width)} ${STATUS_TEXT[o.status]}${o.dir ? ` (${o.dir})` : ""}${
125
+ o.durationMs > 0 ? ` ${(o.durationMs / 1000).toFixed(1)}s` : ""
126
+ }`,
127
+ );
128
+ const needs = outcomes.filter((o) => o.status !== "installed");
129
+ if (needs.length === 0) return lines;
130
+ const body: string[] = [];
131
+ for (const o of needs) {
132
+ body.push(
133
+ `${o.name}: ${STATUS_TEXT[o.status]}${o.dir ? ` — it is in ${o.dir}` : ""}`,
134
+ );
135
+ const last = o.outputTail.trim().split("\n").pop();
136
+ if (o.status === "failed" && last) body.push(` ${last}`);
137
+ if (o.remedy) body.push(` ${o.remedy}`);
138
+ }
139
+ return [...lines, ...box("NEEDS YOU", body)];
140
+ }
141
+
142
+ /** Live progress for a text apply, to stderr so stdout stays the report. */
143
+ export function stderrProgress(e: ApplyEvent): void {
144
+ if (e.type === "step-start") {
145
+ process.stderr.write(`→ ${e.name}: ${e.describe}\n`);
146
+ } else if (e.type === "output") {
147
+ for (const line of e.chunk.split("\n")) {
148
+ if (line.trim()) process.stderr.write(` ${line}\n`);
149
+ }
150
+ }
151
+ }
152
+
153
+ export interface FixDepsOptions {
154
+ yes: boolean;
155
+ /** Write progress and ask questions (text mode); false for `--json`. */
156
+ interactive: boolean;
157
+ /** For `--json` and tests: the lock path; defaults to the cache dir. */
158
+ lockPath?: string;
159
+ /** `--project`'s directory, named in a remedy's fix line. */
160
+ projectDir?: string | null;
161
+ }
162
+
163
+ export type FixDepsResult =
164
+ | { kind: "applied"; outcomes: ApplyOutcome[]; after: DepsState }
165
+ | { kind: "declined" }
166
+ | { kind: "busy"; message: string };
167
+
168
+ /** The consent question's body: every step, once. */
169
+ export function consentLines(plan: DepsPlan, os: Os): string[] {
170
+ return [
171
+ `Install ${plan.steps.length} plugin dependenc${plan.steps.length === 1 ? "y" : "ies"}:`,
172
+ ...planLines(plan, os).map((l) => ` ${l}`),
173
+ ];
174
+ }
175
+
176
+ /**
177
+ * The `--fix` flow, shared by doctor and install: list every step once, ask
178
+ * once (unless `yes`), apply, then re-assess from a fresh probe.
179
+ */
180
+ export async function fixDeps(
181
+ state: DepsState,
182
+ env: PlanEnv,
183
+ opts: FixDepsOptions,
184
+ reassess: () => Promise<DepsState>,
185
+ ): Promise<FixDepsResult> {
186
+ try {
187
+ const outcomes = await applyDepsPlan(state.plan, {
188
+ yes: opts.yes,
189
+ env,
190
+ projectDir: opts.projectDir ?? null,
191
+ lockPath:
192
+ opts.lockPath ?? defaultLockPath(env.home, process.env.XDG_CACHE_HOME),
193
+ confirm: async (plan) => {
194
+ for (const line of consentLines(plan, env.os)) console.log(line);
195
+ return askYesNo(question("Install them now?"));
196
+ },
197
+ ...(opts.interactive ? { onEvent: stderrProgress } : {}),
198
+ });
199
+ if (outcomes.length === 0 && state.plan.steps.length > 0) {
200
+ return { kind: "declined" };
201
+ }
202
+ return {
203
+ kind: "applied",
204
+ outcomes,
205
+ after: await reassess(),
206
+ };
207
+ } catch (error) {
208
+ if (error instanceof DepsLockBusy) {
209
+ return { kind: "busy", message: error.message };
210
+ }
211
+ throw error;
212
+ }
213
+ }
package/src/cli/doctor.ts CHANGED
@@ -1,10 +1,15 @@
1
1
  /**
2
- * `magus doctor [--fix]`
2
+ * `magus doctor [--json] [--fix] [--yes] [--project <dir>]`
3
3
  *
4
- * Diagnoses (and with --fix, repairs) project health:
4
+ * Diagnoses (and with --fix, repairs) the health of the project in the current
5
+ * directory, or in `--project`'s:
5
6
  * 0. Global settings — `~/.claude/settings.json` parses.
6
- * 1. Binary dependencies — every requires.bin / cliTools binary on PATH.
7
- * This is the check that would have caught a dead `tmux-mcp`.
7
+ * 1. Plugin dependencies — what every plugin Claude Code loads here declares
8
+ * in `requires` (plus the profiles' cliTools), probed with each
9
+ * declaration's own check. `--fix` lists every install once, asks once
10
+ * (`--yes` skips the question) and installs through the deps executor.
11
+ * `--json` prints only this section, as one document on stdout, and
12
+ * `--fix --json` repairs only this section: dependencies, nothing else.
8
13
  * 2. Profile state — which profile is active (strictly), what the next magus
9
14
  * command would record or regenerate (with `--fix`, it does it now and
10
15
  * says what it did), generated files git still tracks, and
@@ -21,6 +26,7 @@
21
26
  * them, because magus never rewrites a file it cannot parse.
22
27
  */
23
28
 
29
+ import os from "node:os";
24
30
  import path from "node:path";
25
31
  import fs from "fs-extra";
26
32
  import {
@@ -31,7 +37,13 @@ import {
31
37
  readLocalSettings,
32
38
  readSettings,
33
39
  } from "../services/claude-settings.js";
34
- import { checkBinaries, missingBinaries } from "../services/doctor-bins.js";
40
+ import type { PlanEnv } from "../services/deps-plan.js";
41
+ import {
42
+ type DepsState,
43
+ assessDeps,
44
+ depsReportJson,
45
+ } from "../services/deps-report.js";
46
+ import { machineEnv } from "../services/doctor-bins.js";
35
47
  import { checkProject, fixDoctorIssues } from "../services/doctor.js";
36
48
  import {
37
49
  isAgentModelHookRegistered,
@@ -77,10 +89,54 @@ import type {
77
89
  ProfileManifest,
78
90
  } from "../types/index.js";
79
91
  import { resolveExecutable } from "../utils/command-utils.js";
92
+ import { depsSectionLines, fixDeps, renderApplyText } from "./deps-view.js";
93
+
94
+ /**
95
+ * `--project <dir>`: the directory to check, or null for the current one. A
96
+ * string `error` is a usage error to print.
97
+ */
98
+ function projectOption(
99
+ args: string[],
100
+ ): { dir: string | null } | { error: string } {
101
+ const at = args.indexOf("--project");
102
+ if (at === -1) return { dir: null };
103
+ const value = args[at + 1];
104
+ if (value === undefined || value.startsWith("-")) {
105
+ return { error: "magus doctor --project needs a directory" };
106
+ }
107
+ const dir = path.resolve(value);
108
+ let isDir = false;
109
+ try {
110
+ isDir = fs.statSync(dir).isDirectory();
111
+ } catch {
112
+ // Absent: reported below like any other non-directory.
113
+ }
114
+ return isDir
115
+ ? { dir }
116
+ : { error: `magus doctor --project: ${value} is not a directory` };
117
+ }
80
118
 
81
119
  export async function runDoctorCommand(args: string[]): Promise<number> {
82
120
  const fix = args.includes("--fix");
121
+ const yes = args.includes("--yes") || args.includes("-y");
122
+ const project = projectOption(args);
123
+ if ("error" in project) {
124
+ console.error(project.error);
125
+ return 2;
126
+ }
127
+ // Doctor checks the directory it runs in: the plugins enabled there, its
128
+ // profiles, and each check runs there as Claude Code runs a plugin's servers.
129
+ // `--project` moves it there, so every one of those reads the same directory.
130
+ if (project.dir !== null) process.chdir(project.dir);
83
131
  const projectPath = process.cwd();
132
+ const env = machineEnv(
133
+ process.env.HOME || os.homedir(),
134
+ process.env.PATH ?? "",
135
+ process.env.SHELL ?? "",
136
+ );
137
+ if (args.includes("--json")) {
138
+ return runDepsJson(projectPath, env, { fix, yes, projectDir: project.dir });
139
+ }
84
140
  let problems = 0;
85
141
  // Findings `--fix` does not repair, counted apart so it cannot silence
86
142
  // them — see doctorExitCode.
@@ -93,7 +149,17 @@ export async function runDoctorCommand(args: string[]): Promise<number> {
93
149
  const globalSettingsError = await checkGlobalSettings();
94
150
  if (globalSettingsError) unfixable++;
95
151
 
96
- // 1. Binary dependencies, which needs the manifest to know what to check.
152
+ // 1. Plugin dependencies.
153
+ if (env) {
154
+ const deps = await reportDeps(projectPath, env, {
155
+ fix,
156
+ yes,
157
+ projectDir: project.dir,
158
+ });
159
+ problems += deps.fixable;
160
+ unfixable += deps.unfixable;
161
+ }
162
+
97
163
  let manifest: ProfileManifest | null = null;
98
164
  try {
99
165
  manifest = await readManifest(projectPath);
@@ -104,25 +170,11 @@ export async function runDoctorCommand(args: string[]): Promise<number> {
104
170
  }
105
171
  let pluginIds: string[] = [];
106
172
  if (manifest && Object.keys(manifest.profiles).length > 0) {
107
- const closure = await resolveAllProfiles(manifest);
173
+ // Plugin ids only: the dependency section above read every requirement.
174
+ const closure = await resolveAllProfiles(manifest, {
175
+ binResolver: async () => [],
176
+ });
108
177
  pluginIds = Object.keys(closure.plugins);
109
- if (closure.bins.length > 0) {
110
- const results = await checkBinaries(closure.bins);
111
- const missing = missingBinaries(results);
112
- if (missing.length > 0) {
113
- console.log("Binary dependencies:");
114
- for (const m of missing) {
115
- console.log(
116
- ` ✗ ${m.name} — run: ${m.installCommand} (needed by ${m.sources.join(", ")})`,
117
- );
118
- problems++;
119
- }
120
- } else {
121
- console.log(
122
- `✓ All ${closure.bins.length} binary dependencies present.`,
123
- );
124
- }
125
- }
126
178
  }
127
179
 
128
180
  // 2. Profile state.
@@ -183,6 +235,99 @@ export async function runDoctorCommand(args: string[]): Promise<number> {
183
235
  return doctorExitCode({ fixable: problems, unfixable, fix });
184
236
  }
185
237
 
238
+ interface DoctorDepsFlags {
239
+ fix: boolean;
240
+ yes: boolean;
241
+ /** `--project`'s directory, named in every fix line doctor prints; else null. */
242
+ projectDir: string | null;
243
+ }
244
+
245
+ /**
246
+ * Section 1 in text: the report, and with `--fix` the install. After an
247
+ * install the report is printed again from a fresh probe, and whatever is
248
+ * still missing counts as a problem `--fix` could not solve.
249
+ */
250
+ async function reportDeps(
251
+ projectPath: string,
252
+ env: PlanEnv,
253
+ flags: DoctorDepsFlags,
254
+ ): Promise<{ fixable: number; unfixable: number }> {
255
+ const state = await assessDeps(projectPath, env);
256
+ const before = depsSectionLines(state, env, flags.projectDir);
257
+ for (const line of before.lines) console.log(line);
258
+ if (!flags.fix || before.fixable === 0) return before;
259
+
260
+ console.log("");
261
+ const result = await fixDeps(
262
+ state,
263
+ env,
264
+ { yes: flags.yes, interactive: true, projectDir: flags.projectDir },
265
+ () => assessDeps(projectPath, env),
266
+ );
267
+ if (result.kind === "busy") {
268
+ console.log(` ✗ ${result.message}`);
269
+ return { fixable: 0, unfixable: before.fixable + before.unfixable };
270
+ }
271
+ if (result.kind === "declined") {
272
+ console.log(" Nothing installed.");
273
+ return { fixable: 0, unfixable: before.fixable + before.unfixable };
274
+ }
275
+ for (const line of renderApplyText(result.outcomes)) console.log(line);
276
+ const after = depsSectionLines(result.after, env, flags.projectDir);
277
+ console.log("");
278
+ for (const line of after.lines) console.log(line);
279
+ // Still missing after an install is not something a re-run of --fix fixes
280
+ // silently: it is a problem, counted so the exit code says so.
281
+ return { fixable: 0, unfixable: after.fixable + after.unfixable };
282
+ }
283
+
284
+ /**
285
+ * `--json`: only the dependency section, as one document on stdout and
286
+ * nothing else there. `--fix --json` needs `--yes`: with stdout carrying the
287
+ * document, there is nowhere to ask.
288
+ */
289
+ async function runDepsJson(
290
+ projectPath: string,
291
+ env: PlanEnv | null,
292
+ flags: DoctorDepsFlags,
293
+ ): Promise<number> {
294
+ if (!env) {
295
+ console.error("magus doctor --json: this platform is not supported");
296
+ return 1;
297
+ }
298
+ if (flags.fix && !flags.yes) {
299
+ console.error(
300
+ "magus doctor --fix --json needs --yes: nothing can be asked while stdout carries the report",
301
+ );
302
+ return 2;
303
+ }
304
+ let state: DepsState = await assessDeps(projectPath, env);
305
+ let applied: ReturnType<typeof depsReportJson>["applied"];
306
+ if (flags.fix && state.plan.steps.length > 0) {
307
+ const result = await fixDeps(
308
+ state,
309
+ env,
310
+ { yes: true, interactive: false, projectDir: flags.projectDir },
311
+ () => assessDeps(projectPath, env),
312
+ );
313
+ if (result.kind === "busy") {
314
+ console.error(result.message);
315
+ return 1;
316
+ }
317
+ if (result.kind === "applied") {
318
+ applied = result.outcomes;
319
+ state = result.after;
320
+ }
321
+ }
322
+ const report = depsReportJson(state, env, applied);
323
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
324
+ const healthy =
325
+ report.deps.every((d) => d.status === "present") &&
326
+ report.invalid.length === 0 &&
327
+ report.plugins_needing_update.length === 0;
328
+ return healthy ? 0 : 1;
329
+ }
330
+
186
331
  /**
187
332
  * A file that exists and does not parse, as a finding under `heading`.
188
333
  *
@@ -4,8 +4,9 @@
4
4
  * Put a committed team profile from .claude/profiles.json onto this machine:
5
5
  * register marketplaces, activate the profile (render its settings.json,
6
6
  * .mcp.json and models.json, install its skills), install its pinned plugins
7
- * and the binaries plugins declare (requires.bin) plus the profile's cliTools,
8
- * then collect required env.
7
+ * and what those plugins declare in `requires` plus the profile's cliTools
8
+ * (through the deps executor that `magus doctor --fix` uses), then collect
9
+ * required env.
9
10
  *
10
11
  * With no argument it installs the union of every profile's deps (so `profile
11
12
  * switch` needs no reinstall) and keeps the active profile active, asking only
@@ -13,6 +14,7 @@
13
14
  * the confirmation prompt.
14
15
  */
15
16
 
17
+ import { homedir } from "node:os";
16
18
  import { installPlugin, isClaudeAvailable } from "../services/claude-cli.js";
17
19
  import {
18
20
  getLocalSettingsPath,
@@ -20,6 +22,9 @@ import {
20
22
  readLocalSettings,
21
23
  writeLocalSettings,
22
24
  } from "../services/claude-settings.js";
25
+ import type { PlanEnv } from "../services/deps-plan.js";
26
+ import { assessBins } from "../services/deps-report.js";
27
+ import { machineEnv } from "../services/doctor-bins.js";
23
28
  import {
24
29
  computeEnvStatus,
25
30
  computeVersionDrift,
@@ -39,20 +44,14 @@ import {
39
44
  } from "../services/profile-state.js";
40
45
  import { resolveAllProfiles, resolveProfile } from "../services/resolver.js";
41
46
  import { SettingsParseError, writableMap } from "../services/settings-file.js";
42
- import {
43
- TOOLCHAIN_BOOTSTRAP,
44
- binInstallCommand,
45
- detectToolchains,
46
- } from "../services/toolchain.js";
47
47
  import {
48
48
  bringLoadForward,
49
49
  describeLoadNotForward,
50
50
  } from "../services/update-engine.js";
51
- import type { ResolvedBin, ResolvedClosure } from "../types/index.js";
52
- import { formatCommand, resolveExecutable } from "../utils/command-utils.js";
53
- import { runCommand, runShellScript } from "../utils/run.js";
51
+ import type { ResolvedClosure } from "../types/index.js";
54
52
  import { bold } from "./ansi.js";
55
53
  import { ensureManifest, printActivationNotes } from "./bootstrap.js";
54
+ import { depsSectionLines, fixDeps, renderApplyText } from "./deps-view.js";
56
55
  import { askYesNo, promptValue } from "./prompt.js";
57
56
  import { selectOption } from "./select.js";
58
57
  import {
@@ -108,48 +107,6 @@ async function runCheck(
108
107
 
109
108
  // ── install steps ───────────────────────────────────────────────────────────
110
109
 
111
- async function ensureToolchains(
112
- closure: ResolvedClosure,
113
- yes: boolean,
114
- ): Promise<void> {
115
- // Only the binaries actually missing can need a toolchain. Checking all of
116
- // them warns "pip isn't installed" on a machine whose pip-provided binaries
117
- // are already on PATH — an alarming message about work that will not happen.
118
- const missing: ResolvedBin[] = [];
119
- for (const bin of closure.bins) {
120
- if (!(await resolveExecutable(bin.name))) missing.push(bin);
121
- }
122
- if (missing.length === 0) return;
123
-
124
- const toolchains = await detectToolchains(missing);
125
- for (const tc of toolchains) {
126
- if (tc.present) continue;
127
- const bootstrap = TOOLCHAIN_BOOTSTRAP[tc.name];
128
- if (!bootstrap) {
129
- console.warn(
130
- `⚠ ${tc.name} is required by ${tc.requiredBy.join(", ")} but isn't installed and can't be auto-installed. Install it and re-run.`,
131
- );
132
- continue;
133
- }
134
- const ok =
135
- yes ||
136
- (await askYesNo(
137
- question(
138
- `${tc.name} is missing — ${tc.requiredBy.join(", ")} need${tc.requiredBy.length === 1 ? "s" : ""} it. Install it?`,
139
- ),
140
- ));
141
- if (ok) {
142
- console.log(`Installing ${tc.name}…`);
143
- // A bootstrap line IS shell text — bun's contains a pipe, Homebrew's a
144
- // command substitution — and it is a constant in this repo, not
145
- // profile data. This is the only legitimate caller.
146
- if (!(await runShellScript(bootstrap))) {
147
- console.warn(`⚠ Failed to install ${tc.name}. Continuing.`);
148
- }
149
- }
150
- }
151
- }
152
-
153
110
  /**
154
111
  * Install the closure's plugins at PROJECT scope.
155
112
  *
@@ -218,19 +175,42 @@ async function bringLoadsForward(
218
175
  }
219
176
  }
220
177
 
221
- async function installBins(bins: ResolvedBin[]): Promise<void> {
222
- for (const bin of bins) {
223
- if (await resolveExecutable(bin.name)) {
224
- continue; // already installed and runnable (not a dangling link)
225
- }
226
- const cmd = binInstallCommand(bin);
227
- // Printed rendered, executed as argv. The package name in `cmd.args` came
228
- // from a profile or a plugin manifest, so it must never reach a shell.
229
- console.log(`+ ${bin.name} (${formatCommand(cmd)})`);
230
- if (!(await runCommand(cmd))) {
231
- console.warn(`⚠ failed to install ${bin.name} via ${bin.via}`);
232
- }
178
+ /**
179
+ * Install what the closure's requirements say is missing here: probe, plan,
180
+ * list every step and ask once (unless `--yes`), apply, then report from a
181
+ * fresh probe — the same flow and rendering as `magus doctor --fix`.
182
+ */
183
+ async function installDeps(
184
+ closure: ResolvedClosure,
185
+ env: PlanEnv,
186
+ yes: boolean,
187
+ ): Promise<void> {
188
+ const collected = {
189
+ plugins: Object.keys(closure.plugins),
190
+ bins: closure.bins,
191
+ problems: [],
192
+ };
193
+ const state = await assessBins(collected, env);
194
+ if (state.plan.steps.length === 0) {
195
+ for (const line of depsSectionLines(state, env).lines) console.log(line);
196
+ return;
197
+ }
198
+ const result = await fixDeps(state, env, { yes, interactive: true }, () =>
199
+ assessBins(collected, env),
200
+ );
201
+ if (result.kind === "busy") {
202
+ console.warn(`⚠ ${result.message}`);
203
+ return;
204
+ }
205
+ if (result.kind === "declined") {
206
+ console.log(
207
+ "Plugin dependencies not installed — `magus doctor --fix` installs them later.",
208
+ );
209
+ return;
233
210
  }
211
+ for (const line of renderApplyText(result.outcomes)) console.log(line);
212
+ for (const line of depsSectionLines(result.after, env).lines)
213
+ console.log(line);
234
214
  }
235
215
 
236
216
  async function collectEnv(
@@ -373,7 +353,6 @@ export async function runInstallCommand(
373
353
  return 1;
374
354
  }
375
355
 
376
- await ensureToolchains(closure, flags.yes);
377
356
  await registerClosureMarketplaces(closure.marketplaces);
378
357
  // A marketplace added just now carries the plugin.json files that name
379
358
  // each plugin's dependencies; resolve again so those are installed too.
@@ -438,7 +417,13 @@ export async function runInstallCommand(
438
417
  );
439
418
  // Only what installed: a failed install already printed its own warning.
440
419
  await bringLoadsForward(installed, projectPath);
441
- await installBins(closure.bins);
420
+ // Resolved again: the requirements are read from the copies just installed.
421
+ const env = machineEnv(
422
+ process.env.HOME || homedir(),
423
+ process.env.PATH ?? "",
424
+ process.env.SHELL ?? "",
425
+ );
426
+ if (env) await installDeps(await resolveClosure(), env, flags.yes);
442
427
 
443
428
  if (activeId) {
444
429
  await collectEnv(
@@ -7,7 +7,12 @@
7
7
  * live config first goes into the profile being left.
8
8
  */
9
9
 
10
- import { checkBinaries, missingBinaries } from "../services/doctor-bins.js";
10
+ import { homedir } from "node:os";
11
+ import {
12
+ checkBinaries,
13
+ machineEnv,
14
+ missingBinaries,
15
+ } from "../services/doctor-bins.js";
11
16
  import { summarizeClosure } from "../services/install-plan.js";
12
17
  import { getManifestPath, readManifest } from "../services/manifest.js";
13
18
  import { reportDisplaced } from "../services/profile-activate.js";
@@ -173,7 +178,20 @@ export async function switchProfile(
173
178
  // succeeds (the config is in place), but warn so "switched" is not misleading.
174
179
  try {
175
180
  const closure = await resolveProfile(manifest, name);
176
- const missing = missingBinaries(await checkBinaries(closure.bins));
181
+ const env = machineEnv(
182
+ process.env.HOME || homedir(),
183
+ process.env.PATH ?? "",
184
+ process.env.SHELL ?? "",
185
+ );
186
+ const missing = env
187
+ ? missingBinaries(
188
+ await checkBinaries(closure.bins, {
189
+ env,
190
+ depth: "presence",
191
+ capMs: 3_000,
192
+ }),
193
+ )
194
+ : [];
177
195
  if (missing.length > 0) {
178
196
  console.log(
179
197
  `\n⚠ ${missing.length} binary dependenc${missing.length === 1 ? "y is" : "ies are"} missing:`,
package/src/cli/router.ts CHANGED
@@ -282,8 +282,12 @@ without one is offered adoption on first run. See docs/team-configuration.md.
282
282
  Changes Claude Code makes to .claude/settings.json or .mcp.json (a plugin
283
283
  installed, a setting changed) are changes to the active profile: every command
284
284
  above records them in .claude/profiles.json before it acts.
285
- doctor Check binary deps, the active profile, and conventions
286
- --fix Apply the repairs it can make itself
285
+ doctor Check plugin dependencies, the active profile, and conventions
286
+ --fix Apply the repairs it can make itself, including installing
287
+ missing plugin dependencies (asks once, listing each)
288
+ --yes Install without asking
289
+ --json Plugin dependencies only, as one JSON document
290
+ --project <dir> Check the project in <dir> instead of this directory
287
291
 
288
292
  Per-subagent model routing — give each subagent its own model and effort. The
289
293
  routing lives on the active profile (committed in .claude/profiles.json) and is