magus-cli 7.7.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 (140) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -4
  3. package/src/__tests__/blackbox-parity.test.ts +45 -26
  4. package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
  5. package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
  6. package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
  7. package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
  8. package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
  9. package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
  10. package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
  11. package/src/__tests__/blind-780-cli-tools.test.ts +796 -0
  12. package/src/__tests__/blind-780-deprecated.test.ts +631 -0
  13. package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
  14. package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
  15. package/src/__tests__/blind-780-review2.test.ts +1178 -0
  16. package/src/__tests__/cli-apply-seams.test.ts +2 -67
  17. package/src/__tests__/cli-tool-commands.test.ts +131 -6
  18. package/src/__tests__/cli-tools-adapter.test.ts +52 -2
  19. package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
  20. package/src/__tests__/cli-update-view.test.ts +3 -3
  21. package/src/__tests__/deprecated-remove.test.ts +827 -0
  22. package/src/__tests__/deps-adapter.test.ts +191 -0
  23. package/src/__tests__/deps-check-hook.test.ts +220 -0
  24. package/src/__tests__/deps-plan.test.ts +896 -0
  25. package/src/__tests__/deps-report.test.ts +197 -0
  26. package/src/__tests__/doctor-bins.test.ts +134 -36
  27. package/src/__tests__/doctor-json.test.ts +261 -0
  28. package/src/__tests__/doctor.test.ts +159 -1
  29. package/src/__tests__/failure-popup-review2.test.tsx +896 -0
  30. package/src/__tests__/failure-popup.test.tsx +419 -0
  31. package/src/__tests__/final-fixes-780.test.tsx +740 -0
  32. package/src/__tests__/fixtures/bins.ts +55 -0
  33. package/src/__tests__/format-update-outcome.test.ts +6 -32
  34. package/src/__tests__/gitignore-profile.test.ts +1 -9
  35. package/src/__tests__/install-plan.test.ts +15 -1
  36. package/src/__tests__/marketplace-refresh.test.ts +9 -3
  37. package/src/__tests__/models-screen-state.test.ts +18 -40
  38. package/src/__tests__/no-shell-spawn.test.ts +77 -0
  39. package/src/__tests__/npm-package-name.test.ts +6 -9
  40. package/src/__tests__/plugin-requires.test.ts +138 -153
  41. package/src/__tests__/plugin-resolution.test.ts +52 -13
  42. package/src/__tests__/plugin-row-text.test.ts +30 -37
  43. package/src/__tests__/profile-dependencies.test.ts +7 -0
  44. package/src/__tests__/profile-startup-old-format.test.ts +0 -3
  45. package/src/__tests__/profile-startup.test.ts +40 -12
  46. package/src/__tests__/requires-probe.test.ts +858 -0
  47. package/src/__tests__/resolver.test.ts +129 -52
  48. package/src/__tests__/run.test.ts +108 -0
  49. package/src/__tests__/stale-load-repair.test.ts +31 -23
  50. package/src/__tests__/tool-probe.test.ts +1 -1
  51. package/src/__tests__/toolchain.test.ts +123 -52
  52. package/src/__tests__/update-apply.test.ts +13 -29
  53. package/src/__tests__/update-plan.test.ts +24 -18
  54. package/src/__tests__/useStaleLoadModal.test.ts +1 -57
  55. package/src/__tests__/version-unknown-state.test.ts +15 -17
  56. package/src/cli/ansi.ts +2 -7
  57. package/src/cli/deps-view.ts +213 -0
  58. package/src/cli/doctor.ts +320 -54
  59. package/src/cli/install.ts +55 -71
  60. package/src/cli/profile.ts +20 -2
  61. package/src/cli/router.ts +6 -2
  62. package/src/cli/update-view.ts +58 -15
  63. package/src/cli/update.ts +55 -12
  64. package/src/data/cli-tools.ts +24 -9
  65. package/src/services/active-closure.ts +3 -3
  66. package/src/services/catalog-cache-store.ts +8 -6
  67. package/src/services/claude-cli.ts +106 -19
  68. package/src/services/claude-settings.ts +88 -384
  69. package/src/services/cli-tool-commands.ts +104 -3
  70. package/src/services/deps-plan.ts +962 -0
  71. package/src/services/deps-report.ts +265 -0
  72. package/src/services/doctor-bins.ts +115 -43
  73. package/src/services/install-plan.ts +11 -1
  74. package/src/services/local-marketplace.ts +12 -206
  75. package/src/services/marketplace-refresh.ts +4 -4
  76. package/src/services/plugin-env-needs.ts +69 -0
  77. package/src/services/plugin-manager.ts +35 -37
  78. package/src/services/plugin-requires.ts +93 -162
  79. package/src/services/plugin-resolution.ts +34 -42
  80. package/src/services/profile-activate.ts +1 -2
  81. package/src/services/profile-startup.ts +18 -43
  82. package/src/services/profile-state.ts +72 -2
  83. package/src/services/resolver.ts +263 -59
  84. package/src/services/tool-probe.ts +18 -25
  85. package/src/services/toolchain.ts +117 -111
  86. package/src/services/update-engine.ts +345 -45
  87. package/src/services/update-plan.ts +296 -36
  88. package/src/shared/plugin/hooks/bun-missing.sh +39 -0
  89. package/src/shared/plugin/hooks/deps-check.ts +224 -0
  90. package/src/shared/plugin/hooks/lib/requires-probe.ts +1183 -0
  91. package/src/types/index.ts +60 -16
  92. package/src/ui/App.tsx +81 -171
  93. package/src/ui/adapters/cliToolsAdapter.ts +252 -12
  94. package/src/ui/adapters/depsAdapter.ts +129 -0
  95. package/src/ui/adapters/failureReport.ts +137 -0
  96. package/src/ui/adapters/pluginsAdapter.ts +297 -45
  97. package/src/ui/adapters/startupReports.ts +260 -0
  98. package/src/ui/components/modals/FailureListModal.tsx +231 -0
  99. package/src/ui/components/modals/MessageModal.tsx +7 -12
  100. package/src/ui/components/modals/ModalContainer.tsx +10 -5
  101. package/src/ui/components/modals/index.ts +1 -0
  102. package/src/ui/hooks/startupQueue.ts +56 -0
  103. package/src/ui/hooks/useGitignoreModal.ts +30 -52
  104. package/src/ui/hooks/useProfileStartupModal.ts +119 -142
  105. package/src/ui/hooks/useStaleLoadModal.ts +35 -65
  106. package/src/ui/keymap.ts +14 -1
  107. package/src/ui/renderers/cliToolRenderers.tsx +135 -2
  108. package/src/ui/renderers/mcpRenderers.tsx +110 -3
  109. package/src/ui/renderers/pluginRenderers.tsx +77 -6
  110. package/src/ui/renderers/pluginRowText.ts +25 -25
  111. package/src/ui/renderers/pluginStatusText.ts +18 -1
  112. package/src/ui/screens/AliasScreen.tsx +38 -45
  113. package/src/ui/screens/CliToolsScreen.tsx +199 -98
  114. package/src/ui/screens/EnvVarsScreen.tsx +17 -5
  115. package/src/ui/screens/GitignoreScreen.tsx +48 -67
  116. package/src/ui/screens/McpRegistryScreen.tsx +67 -38
  117. package/src/ui/screens/McpScreen.tsx +46 -19
  118. package/src/ui/screens/ModelsScreen.tsx +46 -86
  119. package/src/ui/screens/PluginsScreen.tsx +315 -259
  120. package/src/ui/screens/ProfilesScreen.tsx +79 -40
  121. package/src/ui/screens/SkillsScreen.tsx +83 -80
  122. package/src/ui/screens/StylesScreen.tsx +211 -275
  123. package/src/ui/state/AppContext.tsx +66 -44
  124. package/src/ui/state/DimensionsContext.tsx +3 -22
  125. package/src/ui/state/reducer.ts +12 -100
  126. package/src/ui/state/types.ts +48 -69
  127. package/src/utils/command-utils.ts +4 -5
  128. package/src/utils/run.ts +123 -52
  129. package/src/utils/string-utils.ts +9 -0
  130. package/src/utils/user-path.ts +60 -0
  131. package/src/__tests__/go-module.test.ts +0 -43
  132. package/src/__tests__/notices-rules.test.ts +0 -476
  133. package/src/__tests__/notices.test.tsx +0 -604
  134. package/src/__tests__/plugin-setup.test.ts +0 -1016
  135. package/src/__tests__/shell-script-callers.test.ts +0 -92
  136. package/src/__tests__/styles-status-line.test.ts +0 -72
  137. package/src/__tests__/version-snapshot.test.ts +0 -212
  138. package/src/services/plugin-setup.ts +0 -965
  139. package/src/services/version-snapshot.ts +0 -189
  140. 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 {
@@ -28,9 +34,16 @@ import {
28
34
  getProjectInstalledPluginVersions,
29
35
  readGlobalSettings,
30
36
  readInstalledPluginsRegistry,
37
+ readLocalSettings,
31
38
  readSettings,
32
39
  } from "../services/claude-settings.js";
33
- 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";
34
47
  import { checkProject, fixDoctorIssues } from "../services/doctor.js";
35
48
  import {
36
49
  isAgentModelHookRegistered,
@@ -44,7 +57,9 @@ import {
44
57
  import { isBehind } from "../services/plugin-manager.js";
45
58
  import {
46
59
  type RepoRootCache,
60
+ isRelevantInstallation,
47
61
  pickLoadedEntry,
62
+ repoRootForPath,
48
63
  } from "../services/plugin-resolution.js";
49
64
  import { trackedGeneratedNotices } from "../services/profile-activate.js";
50
65
  import {
@@ -69,14 +84,59 @@ import {
69
84
  import { resolveAllProfiles } from "../services/resolver.js";
70
85
  import type {
71
86
  ClaudeSettings,
87
+ InstalledPluginEntry,
72
88
  InstalledPluginsRegistry,
73
89
  ProfileManifest,
74
90
  } from "../types/index.js";
75
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
+ }
76
118
 
77
119
  export async function runDoctorCommand(args: string[]): Promise<number> {
78
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);
79
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
+ }
80
140
  let problems = 0;
81
141
  // Findings `--fix` does not repair, counted apart so it cannot silence
82
142
  // them — see doctorExitCode.
@@ -89,7 +149,17 @@ export async function runDoctorCommand(args: string[]): Promise<number> {
89
149
  const globalSettingsError = await checkGlobalSettings();
90
150
  if (globalSettingsError) unfixable++;
91
151
 
92
- // 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
+
93
163
  let manifest: ProfileManifest | null = null;
94
164
  try {
95
165
  manifest = await readManifest(projectPath);
@@ -100,25 +170,11 @@ export async function runDoctorCommand(args: string[]): Promise<number> {
100
170
  }
101
171
  let pluginIds: string[] = [];
102
172
  if (manifest && Object.keys(manifest.profiles).length > 0) {
103
- 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
+ });
104
177
  pluginIds = Object.keys(closure.plugins);
105
- if (closure.bins.length > 0) {
106
- const results = await checkBinaries(closure.bins);
107
- const missing = missingBinaries(results);
108
- if (missing.length > 0) {
109
- console.log("Binary dependencies:");
110
- for (const m of missing) {
111
- console.log(
112
- ` ✗ ${m.name} — run: ${m.installCommand} (needed by ${m.sources.join(", ")})`,
113
- );
114
- problems++;
115
- }
116
- } else {
117
- console.log(
118
- `✓ All ${closure.bins.length} binary dependencies present.`,
119
- );
120
- }
121
- }
122
178
  }
123
179
 
124
180
  // 2. Profile state.
@@ -179,6 +235,99 @@ export async function runDoctorCommand(args: string[]): Promise<number> {
179
235
  return doctorExitCode({ fixable: problems, unfixable, fix });
180
236
  }
181
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
+
182
331
  /**
183
332
  * A file that exists and does not parse, as a finding under `heading`.
184
333
  *
@@ -271,37 +420,140 @@ async function checkPluginLoads(projectPath: string): Promise<number> {
271
420
  reportUnparseable("Plugin loads", error);
272
421
  return 1;
273
422
  }
423
+ let local: ClaudeSettings;
424
+ try {
425
+ local = await readLocalSettings(projectPath);
426
+ } catch (error) {
427
+ if (!(error instanceof SettingsParseError)) throw error;
428
+ reportUnparseable("Plugin loads", error);
429
+ return 1;
430
+ }
431
+ // The global file's parse failure is section 0's finding, reported there;
432
+ // here it only means the user scope turns nothing on that we can see.
433
+ let global: ClaudeSettings = {};
434
+ try {
435
+ global = await readGlobalSettings();
436
+ } catch (error) {
437
+ if (!(error instanceof SettingsParseError)) throw error;
438
+ }
439
+ const enabled = enabledHere(global, settings, local);
274
440
  const installed = await getProjectInstalledPluginVersions(
275
441
  settings.installedPluginVersions || {},
276
442
  projectPath,
277
443
  );
278
- const ids = Object.keys(installed);
444
+ // Counted as installed only when claimed HERE — the same rule
445
+ // `findPluginLoadProblems` applies to a plugin that loads nothing.
446
+ const cache: RepoRootCache = new Map();
447
+ const ids = Object.keys(installed).filter((id) =>
448
+ isClaimedHere(id, registry.plugins?.[id], projectPath, enabled, cache),
449
+ );
279
450
  if (ids.length === 0) return 0;
280
451
 
281
- // One cache across every plugin: the same handful of project paths repeat in
282
- // hundreds of rows, and each miss walks the tree to the filesystem root.
283
- const cache: RepoRootCache = new Map();
284
- const stale: Array<{
452
+ const findings = findPluginLoadProblems(
453
+ installed,
454
+ registry.plugins,
455
+ projectPath,
456
+ enabled,
457
+ );
458
+ for (const line of pluginLoadLines(findings, ids.length)) console.log(line);
459
+ return findings.stale.length + findings.loadsNothing.length;
460
+ }
461
+
462
+ /** What `magus doctor`'s "Plugin loads" section found — see {@link findPluginLoadProblems}. */
463
+ export interface PluginLoadFindings {
464
+ /** Installed here at one version, loading an older one. */
465
+ stale: Array<{
285
466
  pluginId: string;
286
467
  loaded: string;
287
468
  recorded: string;
288
469
  winner: string;
289
- }> = [];
470
+ }>;
471
+ /**
472
+ * Installed here, and Claude Code loads NO version of it in this folder.
473
+ * Its own finding: counting it among the plugins that "load at their
474
+ * installed version" reported a plugin that loads nothing as healthy.
475
+ */
476
+ loadsNothing: string[];
477
+ }
478
+
479
+ /**
480
+ * `enabledPlugins` as it applies to this folder: user, then project, then
481
+ * local, a later scope overriding an earlier one — Claude Code's precedence.
482
+ */
483
+ export function enabledHere(
484
+ ...scopes: ReadonlyArray<Pick<ClaudeSettings, "enabledPlugins">>
485
+ ): Record<string, boolean> {
486
+ return Object.assign({}, ...scopes.map((s) => s.enabledPlugins ?? {}));
487
+ }
488
+
489
+ /**
490
+ * Does anything that APPLIES TO THIS FOLDER say the plugin is installed?
491
+ *
492
+ * Either a scope that applies here turns it on, or the registry holds a row
493
+ * the loader would consider here (`isRelevantInstallation` — the machine-wide
494
+ * scopes, this folder's own rows, and rows from the same repository). A row
495
+ * for some other project is not a claim about this folder: counting it made a
496
+ * stale `installedPluginVersions` entry plus an unrelated repo's install read
497
+ * as "installed here, loads nothing".
498
+ */
499
+ export function isClaimedHere(
500
+ pluginId: string,
501
+ rows: readonly InstalledPluginEntry[] | undefined,
502
+ projectPath: string,
503
+ enabled: Readonly<Record<string, boolean>>,
504
+ cache: RepoRootCache = new Map(),
505
+ ): boolean {
506
+ if (enabled[pluginId] === true) return true;
507
+ if (!Array.isArray(rows) || rows.length === 0) return false;
508
+ const root = repoRootForPath(projectPath, cache);
509
+ return rows.some((row) =>
510
+ isRelevantInstallation(row, projectPath, root, cache),
511
+ );
512
+ }
290
513
 
291
- for (const pluginId of ids.sort()) {
514
+ /**
515
+ * Compare what is installed here against what Claude Code will load here.
516
+ * Pure over its inputs, so the decision is pinned by a test rather than by a
517
+ * command that needs a real `~/.claude` to run.
518
+ *
519
+ * A plugin is only reported as loading nothing when something says it is
520
+ * INSTALLED HERE — see {@link isClaimedHere}: a scope that applies to this
521
+ * folder turns it on, or the registry holds a row relevant to this folder. A
522
+ * version in `installedPluginVersions` alone is not that — Claude Code never
523
+ * writes the field, so a claim with neither behind it is a leftover of
524
+ * magus's own, not an install that fails to load.
525
+ *
526
+ * @param installed plugin id → the version recorded as installed here
527
+ * @param registryPlugins the registry's rows by plugin id
528
+ * @param enabled `enabledPlugins` in the scopes that apply to this
529
+ * folder — see {@link enabledHere}
530
+ */
531
+ export function findPluginLoadProblems(
532
+ installed: Readonly<Record<string, string>>,
533
+ registryPlugins: InstalledPluginsRegistry["plugins"] | undefined,
534
+ projectPath: string,
535
+ enabled: Readonly<Record<string, boolean>> = {},
536
+ ): PluginLoadFindings {
537
+ // One cache across every plugin: the same handful of project paths repeat in
538
+ // hundreds of rows, and each miss walks the tree to the filesystem root.
539
+ const cache: RepoRootCache = new Map();
540
+ const findings: PluginLoadFindings = { stale: [], loadsNothing: [] };
541
+
542
+ for (const pluginId of Object.keys(installed).sort()) {
292
543
  const recorded = installed[pluginId];
293
544
  if (!recorded) continue;
294
- const pick = pickLoadedEntry(
295
- registry.plugins?.[pluginId],
296
- projectPath,
297
- cache,
298
- );
299
- if (!pick?.version) continue;
545
+ const rows = registryPlugins?.[pluginId];
546
+ const pick = pickLoadedEntry(rows, projectPath, cache);
547
+ if (!pick?.version) {
548
+ if (isClaimedHere(pluginId, rows, projectPath, enabled, cache))
549
+ findings.loadsNothing.push(pluginId);
550
+ continue;
551
+ }
300
552
  // The same predicate the classifier uses, so doctor and the plugin screen
301
553
  // cannot disagree about which pairs count. Unorderable pairs answer null
302
554
  // and are left alone — "cannot tell" is not a finding.
303
555
  if (isBehind(recorded, pick.version) !== true) continue;
304
- stale.push({
556
+ findings.stale.push({
305
557
  pluginId,
306
558
  loaded: pick.version,
307
559
  recorded,
@@ -310,28 +562,42 @@ async function checkPluginLoads(projectPath: string): Promise<number> {
310
562
  (pick.scope === "user" ? "user scope (no project)" : "unknown project"),
311
563
  });
312
564
  }
565
+ return findings;
566
+ }
313
567
 
314
- console.log("\nPlugin loads:");
315
- if (stale.length === 0) {
316
- console.log(
317
- ` ✓ All ${ids.length} installed plugins load at their installed version.`,
568
+ /** The "Plugin loads" section's lines, for `total` plugins installed here. */
569
+ export function pluginLoadLines(
570
+ findings: PluginLoadFindings,
571
+ total: number,
572
+ ): string[] {
573
+ const lines = ["", "Plugin loads:"];
574
+ if (findings.stale.length === 0 && findings.loadsNothing.length === 0) {
575
+ lines.push(
576
+ ` ✓ All ${total} installed plugins load at their installed version.`,
318
577
  );
319
- return 0;
578
+ return lines;
320
579
  }
321
-
322
- for (const row of stale) {
323
- console.log(
580
+ for (const row of findings.stale) {
581
+ lines.push(
324
582
  ` ✗ ${row.pluginId} — loads v${row.loaded}, installed v${row.recorded}`,
583
+ ` the entry that wins belongs to ${row.winner}`,
325
584
  );
326
- console.log(` the entry that wins belongs to ${row.winner}`);
327
585
  }
328
- console.log(
329
- " Reinstalling does not fix this. Run `magus update` — it moves those",
330
- );
331
- console.log(
332
- " install records forward, backing up installed_plugins.json first.",
333
- );
334
- return stale.length;
586
+ if (findings.stale.length > 0) {
587
+ lines.push(
588
+ " Reinstalling does not fix this. Run `magus update` — it moves those",
589
+ " install records forward, backing up installed_plugins.json first.",
590
+ );
591
+ }
592
+ for (const pluginId of findings.loadsNothing) {
593
+ lines.push(
594
+ ` ✗ ${pluginId} is installed, but Claude Code loads no version of it in this folder`,
595
+ );
596
+ }
597
+ if (findings.loadsNothing.length > 0) {
598
+ lines.push(" Run `magus update` to fix it.");
599
+ }
600
+ return lines;
335
601
  }
336
602
 
337
603
  /**