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.
- package/README.md +1 -1
- package/package.json +4 -4
- package/src/__tests__/blackbox-parity.test.ts +16 -11
- 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 +36 -48
- package/src/__tests__/blind-780-review2.test.ts +0 -25
- 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__/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__/failure-popup-review2.test.tsx +1 -25
- package/src/__tests__/final-fixes-780.test.tsx +25 -9
- package/src/__tests__/fixtures/bins.ts +55 -0
- package/src/__tests__/install-plan.test.ts +15 -1
- package/src/__tests__/marketplace-refresh.test.ts +9 -3
- 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__/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__/tool-probe.test.ts +1 -1
- package/src/__tests__/toolchain.test.ts +123 -52
- package/src/__tests__/update-plan.test.ts +1 -1
- package/src/cli/ansi.ts +2 -7
- package/src/cli/deps-view.ts +213 -0
- package/src/cli/doctor.ts +169 -24
- package/src/cli/install.ts +51 -66
- package/src/cli/profile.ts +20 -2
- package/src/cli/router.ts +6 -2
- package/src/cli/update.ts +28 -7
- package/src/data/cli-tools.ts +24 -9
- package/src/services/active-closure.ts +3 -3
- package/src/services/claude-settings.ts +4 -348
- 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-manager.ts +4 -18
- package/src/services/plugin-requires.ts +93 -162
- package/src/services/resolver.ts +168 -58
- package/src/services/tool-probe.ts +18 -25
- package/src/services/toolchain.ts +117 -111
- package/src/services/update-engine.ts +19 -14
- package/src/services/update-plan.ts +3 -3
- 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 +53 -16
- package/src/ui/App.tsx +6 -40
- package/src/ui/adapters/cliToolsAdapter.ts +75 -37
- package/src/ui/adapters/depsAdapter.ts +129 -0
- package/src/ui/adapters/startupReports.ts +0 -22
- package/src/ui/renderers/cliToolRenderers.tsx +135 -2
- package/src/ui/screens/CliToolsScreen.tsx +141 -51
- package/src/ui/screens/PluginsScreen.tsx +12 -48
- 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/__tests__/go-module.test.ts +0 -43
- package/src/__tests__/plugin-setup.test.ts +0 -1016
- package/src/__tests__/shell-script-callers.test.ts +0 -92
- package/src/services/plugin-setup.ts +0 -965
- 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 {
|
|
@@ -31,7 +37,13 @@ import {
|
|
|
31
37
|
readLocalSettings,
|
|
32
38
|
readSettings,
|
|
33
39
|
} from "../services/claude-settings.js";
|
|
34
|
-
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";
|
|
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.
|
|
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
|
-
|
|
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
|
*
|
package/src/cli/install.ts
CHANGED
|
@@ -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
|
|
8
|
-
* then collect
|
|
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 {
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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(
|
package/src/cli/profile.ts
CHANGED
|
@@ -7,7 +7,12 @@
|
|
|
7
7
|
* live config first goes into the profile being left.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
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
|
|
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
|
|
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
|