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.
- package/README.md +1 -1
- package/package.json +4 -4
- package/src/__tests__/blackbox-parity.test.ts +45 -26
- package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
- package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
- package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
- package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
- package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
- package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
- package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
- package/src/__tests__/blind-780-cli-tools.test.ts +796 -0
- package/src/__tests__/blind-780-deprecated.test.ts +631 -0
- package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
- package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
- package/src/__tests__/blind-780-review2.test.ts +1178 -0
- package/src/__tests__/cli-apply-seams.test.ts +2 -67
- package/src/__tests__/cli-tool-commands.test.ts +131 -6
- package/src/__tests__/cli-tools-adapter.test.ts +52 -2
- package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
- package/src/__tests__/cli-update-view.test.ts +3 -3
- package/src/__tests__/deprecated-remove.test.ts +827 -0
- package/src/__tests__/deps-adapter.test.ts +191 -0
- package/src/__tests__/deps-check-hook.test.ts +220 -0
- package/src/__tests__/deps-plan.test.ts +896 -0
- package/src/__tests__/deps-report.test.ts +197 -0
- package/src/__tests__/doctor-bins.test.ts +134 -36
- package/src/__tests__/doctor-json.test.ts +261 -0
- package/src/__tests__/doctor.test.ts +159 -1
- package/src/__tests__/failure-popup-review2.test.tsx +896 -0
- package/src/__tests__/failure-popup.test.tsx +419 -0
- package/src/__tests__/final-fixes-780.test.tsx +740 -0
- package/src/__tests__/fixtures/bins.ts +55 -0
- package/src/__tests__/format-update-outcome.test.ts +6 -32
- package/src/__tests__/gitignore-profile.test.ts +1 -9
- package/src/__tests__/install-plan.test.ts +15 -1
- package/src/__tests__/marketplace-refresh.test.ts +9 -3
- package/src/__tests__/models-screen-state.test.ts +18 -40
- package/src/__tests__/no-shell-spawn.test.ts +77 -0
- package/src/__tests__/npm-package-name.test.ts +6 -9
- package/src/__tests__/plugin-requires.test.ts +138 -153
- package/src/__tests__/plugin-resolution.test.ts +52 -13
- package/src/__tests__/plugin-row-text.test.ts +30 -37
- package/src/__tests__/profile-dependencies.test.ts +7 -0
- package/src/__tests__/profile-startup-old-format.test.ts +0 -3
- package/src/__tests__/profile-startup.test.ts +40 -12
- package/src/__tests__/requires-probe.test.ts +858 -0
- package/src/__tests__/resolver.test.ts +129 -52
- package/src/__tests__/run.test.ts +108 -0
- package/src/__tests__/stale-load-repair.test.ts +31 -23
- package/src/__tests__/tool-probe.test.ts +1 -1
- package/src/__tests__/toolchain.test.ts +123 -52
- package/src/__tests__/update-apply.test.ts +13 -29
- package/src/__tests__/update-plan.test.ts +24 -18
- package/src/__tests__/useStaleLoadModal.test.ts +1 -57
- package/src/__tests__/version-unknown-state.test.ts +15 -17
- package/src/cli/ansi.ts +2 -7
- package/src/cli/deps-view.ts +213 -0
- package/src/cli/doctor.ts +320 -54
- package/src/cli/install.ts +55 -71
- package/src/cli/profile.ts +20 -2
- package/src/cli/router.ts +6 -2
- package/src/cli/update-view.ts +58 -15
- package/src/cli/update.ts +55 -12
- package/src/data/cli-tools.ts +24 -9
- package/src/services/active-closure.ts +3 -3
- package/src/services/catalog-cache-store.ts +8 -6
- package/src/services/claude-cli.ts +106 -19
- package/src/services/claude-settings.ts +88 -384
- package/src/services/cli-tool-commands.ts +104 -3
- package/src/services/deps-plan.ts +962 -0
- package/src/services/deps-report.ts +265 -0
- package/src/services/doctor-bins.ts +115 -43
- package/src/services/install-plan.ts +11 -1
- package/src/services/local-marketplace.ts +12 -206
- package/src/services/marketplace-refresh.ts +4 -4
- package/src/services/plugin-env-needs.ts +69 -0
- package/src/services/plugin-manager.ts +35 -37
- package/src/services/plugin-requires.ts +93 -162
- package/src/services/plugin-resolution.ts +34 -42
- package/src/services/profile-activate.ts +1 -2
- package/src/services/profile-startup.ts +18 -43
- package/src/services/profile-state.ts +72 -2
- package/src/services/resolver.ts +263 -59
- package/src/services/tool-probe.ts +18 -25
- package/src/services/toolchain.ts +117 -111
- package/src/services/update-engine.ts +345 -45
- package/src/services/update-plan.ts +296 -36
- package/src/shared/plugin/hooks/bun-missing.sh +39 -0
- package/src/shared/plugin/hooks/deps-check.ts +224 -0
- package/src/shared/plugin/hooks/lib/requires-probe.ts +1183 -0
- package/src/types/index.ts +60 -16
- package/src/ui/App.tsx +81 -171
- package/src/ui/adapters/cliToolsAdapter.ts +252 -12
- package/src/ui/adapters/depsAdapter.ts +129 -0
- package/src/ui/adapters/failureReport.ts +137 -0
- package/src/ui/adapters/pluginsAdapter.ts +297 -45
- package/src/ui/adapters/startupReports.ts +260 -0
- package/src/ui/components/modals/FailureListModal.tsx +231 -0
- package/src/ui/components/modals/MessageModal.tsx +7 -12
- package/src/ui/components/modals/ModalContainer.tsx +10 -5
- package/src/ui/components/modals/index.ts +1 -0
- package/src/ui/hooks/startupQueue.ts +56 -0
- package/src/ui/hooks/useGitignoreModal.ts +30 -52
- package/src/ui/hooks/useProfileStartupModal.ts +119 -142
- package/src/ui/hooks/useStaleLoadModal.ts +35 -65
- package/src/ui/keymap.ts +14 -1
- package/src/ui/renderers/cliToolRenderers.tsx +135 -2
- package/src/ui/renderers/mcpRenderers.tsx +110 -3
- package/src/ui/renderers/pluginRenderers.tsx +77 -6
- package/src/ui/renderers/pluginRowText.ts +25 -25
- package/src/ui/renderers/pluginStatusText.ts +18 -1
- package/src/ui/screens/AliasScreen.tsx +38 -45
- package/src/ui/screens/CliToolsScreen.tsx +199 -98
- package/src/ui/screens/EnvVarsScreen.tsx +17 -5
- package/src/ui/screens/GitignoreScreen.tsx +48 -67
- package/src/ui/screens/McpRegistryScreen.tsx +67 -38
- package/src/ui/screens/McpScreen.tsx +46 -19
- package/src/ui/screens/ModelsScreen.tsx +46 -86
- package/src/ui/screens/PluginsScreen.tsx +315 -259
- package/src/ui/screens/ProfilesScreen.tsx +79 -40
- package/src/ui/screens/SkillsScreen.tsx +83 -80
- package/src/ui/screens/StylesScreen.tsx +211 -275
- package/src/ui/state/AppContext.tsx +66 -44
- package/src/ui/state/DimensionsContext.tsx +3 -22
- package/src/ui/state/reducer.ts +12 -100
- package/src/ui/state/types.ts +48 -69
- package/src/utils/command-utils.ts +4 -5
- package/src/utils/run.ts +123 -52
- package/src/utils/string-utils.ts +9 -0
- package/src/utils/user-path.ts +60 -0
- package/src/__tests__/go-module.test.ts +0 -43
- package/src/__tests__/notices-rules.test.ts +0 -476
- package/src/__tests__/notices.test.tsx +0 -604
- package/src/__tests__/plugin-setup.test.ts +0 -1016
- package/src/__tests__/shell-script-callers.test.ts +0 -92
- package/src/__tests__/styles-status-line.test.ts +0 -72
- package/src/__tests__/version-snapshot.test.ts +0 -212
- package/src/services/plugin-setup.ts +0 -965
- package/src/services/version-snapshot.ts +0 -189
- 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
|
|
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.
|
|
7
|
-
*
|
|
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 {
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
cache
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
|
578
|
+
return lines;
|
|
320
579
|
}
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
/**
|