magus-cli 7.8.0 → 7.9.1
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/review-followups/bun-install-path.test.ts +57 -0
- package/src/__tests__/blind/review-followups/d1-empty-lock.test.ts +94 -0
- package/src/__tests__/blind/review-followups/d2-disappearing-lock.test.ts +90 -0
- package/src/__tests__/blind/review-followups/d3-release-temp-cleanup.test.ts +83 -0
- package/src/__tests__/blind/review-followups/d4-tool-stdout.test.ts +152 -0
- package/src/__tests__/blind/review-followups/installer-redirect.test.ts +74 -0
- package/src/__tests__/blind/review-followups/ledger-permissions.test.ts +78 -0
- package/src/__tests__/blind/review-followups/ledger-pruning.test.ts +79 -0
- package/src/__tests__/blind/review-followups/path-advice.test.ts +46 -0
- package/src/__tests__/blind/review-followups/stats-requires.test.ts +16 -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 +897 -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 +1154 -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 +64 -0
- package/src/shared/plugin/hooks/deps-check.ts +240 -0
- package/src/shared/plugin/hooks/lib/requires-probe.ts +1279 -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 +143 -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,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the plugins Claude Code loads here need on this machine, and what is
|
|
3
|
+
* missing: the dependency half of `magus doctor`.
|
|
4
|
+
*
|
|
5
|
+
* The plugin set is `enabledPlugins` across user, project and local scope, each
|
|
6
|
+
* read from the copy Claude Code loads (`resolvePluginRequires`), so it works
|
|
7
|
+
* with or without `.claude/profiles.json`. A project with profiles adds its
|
|
8
|
+
* profiles' own cliTools. Presence is requires-probe's `probe` at full depth,
|
|
9
|
+
* the plan is `planDeps`; this module only gathers and shapes, and prints
|
|
10
|
+
* nothing.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
type Os,
|
|
15
|
+
type Presence,
|
|
16
|
+
describeStep,
|
|
17
|
+
onPath,
|
|
18
|
+
renderInstall,
|
|
19
|
+
} from "../shared/plugin/hooks/lib/requires-probe.js";
|
|
20
|
+
import type { ResolvedBin, SelectedBin } from "../types/index.js";
|
|
21
|
+
import {
|
|
22
|
+
readGlobalSettings,
|
|
23
|
+
readLocalSettings,
|
|
24
|
+
readSettings,
|
|
25
|
+
} from "./claude-settings.js";
|
|
26
|
+
import {
|
|
27
|
+
type ApplyOutcome,
|
|
28
|
+
type DepsPlan,
|
|
29
|
+
type PathAdvice,
|
|
30
|
+
type PlanEnv,
|
|
31
|
+
planDeps,
|
|
32
|
+
prefixOf,
|
|
33
|
+
prefixOnPath,
|
|
34
|
+
requiredByOf,
|
|
35
|
+
} from "./deps-plan.js";
|
|
36
|
+
import { DOCTOR_CHECK_CAP_MS, probeBins } from "./doctor-bins.js";
|
|
37
|
+
import { readManifest } from "./manifest.js";
|
|
38
|
+
import { resolvePluginRequires, selectBins } from "./plugin-requires.js";
|
|
39
|
+
import { binKey, cliToolBins, mergeBins } from "./resolver.js";
|
|
40
|
+
|
|
41
|
+
/** The `--json` document's shape version. */
|
|
42
|
+
export const DEPS_REPORT_SCHEMA = 1;
|
|
43
|
+
|
|
44
|
+
export interface PluginParseProblem {
|
|
45
|
+
plugin: string;
|
|
46
|
+
kind: "invalid" | "newer-schema" | "older-schema";
|
|
47
|
+
errors: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CollectedDeps {
|
|
51
|
+
/** The enabled plugins whose declaration was read. */
|
|
52
|
+
plugins: string[];
|
|
53
|
+
bins: ResolvedBin[];
|
|
54
|
+
problems: PluginParseProblem[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Where {@link collectDeps} reads; replaced in tests. */
|
|
58
|
+
export interface CollectSources {
|
|
59
|
+
enabledPlugins(projectPath: string): Promise<string[]>;
|
|
60
|
+
pluginRequires: typeof resolvePluginRequires;
|
|
61
|
+
profileCliTools(
|
|
62
|
+
projectPath: string,
|
|
63
|
+
): Promise<Array<{ bin: SelectedBin; source: string }>>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Plugin ids turned on here, user then project then local — Claude Code's precedence. */
|
|
67
|
+
async function enabledHere(projectPath: string): Promise<string[]> {
|
|
68
|
+
const read = async (
|
|
69
|
+
f: () => Promise<{ enabledPlugins?: Record<string, boolean> }>,
|
|
70
|
+
) => {
|
|
71
|
+
try {
|
|
72
|
+
return (await f()).enabledPlugins ?? {};
|
|
73
|
+
} catch {
|
|
74
|
+
// An unparseable settings file is doctor's own finding elsewhere.
|
|
75
|
+
return {};
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
const merged = Object.assign(
|
|
79
|
+
{},
|
|
80
|
+
await read(readGlobalSettings),
|
|
81
|
+
await read(() => readSettings(projectPath)),
|
|
82
|
+
await read(() => readLocalSettings(projectPath)),
|
|
83
|
+
) as Record<string, boolean>;
|
|
84
|
+
return Object.keys(merged)
|
|
85
|
+
.filter((id) => merged[id] === true)
|
|
86
|
+
.sort();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Every profile's own cliTools, when the project has profiles. */
|
|
90
|
+
async function profilesCliTools(
|
|
91
|
+
projectPath: string,
|
|
92
|
+
): Promise<Array<{ bin: SelectedBin; source: string }>> {
|
|
93
|
+
let profiles: Record<string, { cliTools?: Record<string, string> }> = {};
|
|
94
|
+
try {
|
|
95
|
+
profiles = (await readManifest(projectPath)).profiles;
|
|
96
|
+
} catch {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
const out: Array<{ bin: SelectedBin; source: string }> = [];
|
|
100
|
+
for (const [name, entry] of Object.entries(profiles)) {
|
|
101
|
+
out.push(...(await cliToolBins({ name, cliTools: entry.cliTools })));
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const REAL_COLLECT_SOURCES: CollectSources = {
|
|
107
|
+
enabledPlugins: enabledHere,
|
|
108
|
+
pluginRequires: resolvePluginRequires,
|
|
109
|
+
profileCliTools: profilesCliTools,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** The requirements of everything loaded here, merged once. */
|
|
113
|
+
export async function collectDeps(
|
|
114
|
+
projectPath: string,
|
|
115
|
+
sources: CollectSources = REAL_COLLECT_SOURCES,
|
|
116
|
+
/** Selection sees the PATH and platform the probe and the plan see. */
|
|
117
|
+
env?: PlanEnv,
|
|
118
|
+
): Promise<CollectedDeps> {
|
|
119
|
+
const entries = await sources.profileCliTools(projectPath);
|
|
120
|
+
const plugins: string[] = [];
|
|
121
|
+
const problems: PluginParseProblem[] = [];
|
|
122
|
+
for (const id of await sources.enabledPlugins(projectPath)) {
|
|
123
|
+
const found = await sources.pluginRequires(id, projectPath);
|
|
124
|
+
// Not installed here: doctor's "Plugin loads" section says so.
|
|
125
|
+
if (!found) continue;
|
|
126
|
+
plugins.push(id);
|
|
127
|
+
if (!found.parse.ok) {
|
|
128
|
+
problems.push({
|
|
129
|
+
plugin: id,
|
|
130
|
+
kind: found.parse.kind,
|
|
131
|
+
errors: found.parse.errors,
|
|
132
|
+
});
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const selected = selectBins(
|
|
136
|
+
found.parse.requirements,
|
|
137
|
+
found.dir,
|
|
138
|
+
env?.path,
|
|
139
|
+
env,
|
|
140
|
+
);
|
|
141
|
+
for (const bin of selected) {
|
|
142
|
+
entries.push({ bin, source: `plugin:${id}` });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { plugins, bins: mergeBins(entries), problems };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface DepsState {
|
|
149
|
+
collected: CollectedDeps;
|
|
150
|
+
presences: Presence[];
|
|
151
|
+
plan: DepsPlan;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Probe collected requirements at full depth and plan — always a fresh probe. */
|
|
155
|
+
export async function assessBins(
|
|
156
|
+
collected: CollectedDeps,
|
|
157
|
+
env: PlanEnv,
|
|
158
|
+
): Promise<DepsState> {
|
|
159
|
+
const presences = await probeBins(collected.bins, {
|
|
160
|
+
env,
|
|
161
|
+
depth: "full",
|
|
162
|
+
capMs: DOCTOR_CHECK_CAP_MS,
|
|
163
|
+
});
|
|
164
|
+
return {
|
|
165
|
+
collected,
|
|
166
|
+
presences,
|
|
167
|
+
plan: planDeps(collected.bins, presences, env),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Collect what is loaded here, then {@link assessBins}. */
|
|
172
|
+
export async function assessDeps(
|
|
173
|
+
projectPath: string,
|
|
174
|
+
env: PlanEnv,
|
|
175
|
+
sources?: CollectSources,
|
|
176
|
+
): Promise<DepsState> {
|
|
177
|
+
return assessBins(await collectDeps(projectPath, sources, env), env);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─── the --json document ──────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
export interface DepJson {
|
|
183
|
+
name: string;
|
|
184
|
+
key: string;
|
|
185
|
+
status: Presence["status"];
|
|
186
|
+
requiredBy: string[];
|
|
187
|
+
blockedBy?: string;
|
|
188
|
+
prefix?: string;
|
|
189
|
+
reason?: string;
|
|
190
|
+
fix?: {
|
|
191
|
+
via: string;
|
|
192
|
+
escalation: "none" | "sudo";
|
|
193
|
+
text: string;
|
|
194
|
+
prefix?: string;
|
|
195
|
+
prefixOnPath?: boolean;
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface DepsReportJson {
|
|
200
|
+
schema: typeof DEPS_REPORT_SCHEMA;
|
|
201
|
+
platform: { os: Os; arch: string };
|
|
202
|
+
magusOnPath: boolean;
|
|
203
|
+
plugins: string[];
|
|
204
|
+
deps: DepJson[];
|
|
205
|
+
plugins_needing_update: { plugin: string; kind: string; message: string }[];
|
|
206
|
+
invalid: { plugin: string; errors: string[] }[];
|
|
207
|
+
unfixable: { name: string; why: string }[];
|
|
208
|
+
pathAdvice: PathAdvice[];
|
|
209
|
+
applied?: ApplyOutcome[];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The one JSON document `magus doctor --json` prints. */
|
|
213
|
+
export function depsReportJson(
|
|
214
|
+
state: DepsState,
|
|
215
|
+
env: PlanEnv,
|
|
216
|
+
applied?: ApplyOutcome[],
|
|
217
|
+
): DepsReportJson {
|
|
218
|
+
const { collected, presences, plan } = state;
|
|
219
|
+
const deps = collected.bins.map((bin, i): DepJson => {
|
|
220
|
+
const p = presences[i] as Presence;
|
|
221
|
+
const entry: DepJson = {
|
|
222
|
+
name: bin.name,
|
|
223
|
+
key: binKey(bin),
|
|
224
|
+
status: p.status,
|
|
225
|
+
requiredBy: requiredByOf(bin),
|
|
226
|
+
};
|
|
227
|
+
if (p.status === "missing" && p.blockedBy) entry.blockedBy = p.blockedBy;
|
|
228
|
+
if (p.status === "off-path") entry.prefix = p.prefix;
|
|
229
|
+
if (p.status === "unknown") entry.reason = p.reason;
|
|
230
|
+
if (p.status !== "present" && bin.chosen) {
|
|
231
|
+
const step = renderInstall(bin, bin.chosen, {
|
|
232
|
+
...env,
|
|
233
|
+
pluginRoot: bin.root ?? "",
|
|
234
|
+
});
|
|
235
|
+
const prefix = prefixOf(step);
|
|
236
|
+
entry.fix = {
|
|
237
|
+
via: bin.chosen.via,
|
|
238
|
+
escalation: "escalation" in step ? step.escalation : "none",
|
|
239
|
+
text: describeStep(step, env.os),
|
|
240
|
+
...(prefix ? { prefix, prefixOnPath: prefixOnPath(prefix, env) } : {}),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
return entry;
|
|
244
|
+
});
|
|
245
|
+
return {
|
|
246
|
+
schema: DEPS_REPORT_SCHEMA,
|
|
247
|
+
platform: { os: env.os, arch: env.arch },
|
|
248
|
+
magusOnPath: onPath("magus", env.path) !== null,
|
|
249
|
+
plugins: collected.plugins,
|
|
250
|
+
deps,
|
|
251
|
+
plugins_needing_update: collected.problems
|
|
252
|
+
.filter((p) => p.kind !== "invalid")
|
|
253
|
+
.map((p) => ({
|
|
254
|
+
plugin: p.plugin,
|
|
255
|
+
kind: p.kind,
|
|
256
|
+
message: p.errors.join("; "),
|
|
257
|
+
})),
|
|
258
|
+
invalid: collected.problems
|
|
259
|
+
.filter((p) => p.kind === "invalid")
|
|
260
|
+
.map((p) => ({ plugin: p.plugin, errors: p.errors })),
|
|
261
|
+
unfixable: plan.unfixable,
|
|
262
|
+
pathAdvice: plan.pathAdvice,
|
|
263
|
+
...(applied ? { applied } : {}),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
@@ -1,63 +1,135 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Presence of resolved requirements, for `magus doctor`, `magus install` and
|
|
3
|
+
* `profile switch`.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Presence is requires-probe's `probe` — the very function the SessionStart
|
|
6
|
+
* hook runs — so magus and the hook cannot disagree about whether a dependency
|
|
7
|
+
* is here. doctor probes at "full" depth (every declared check runs, with a
|
|
8
|
+
* long cap); the hook at "presence".
|
|
8
9
|
*/
|
|
9
10
|
|
|
11
|
+
import {
|
|
12
|
+
type Presence,
|
|
13
|
+
type ProbeDepth,
|
|
14
|
+
type Requirement,
|
|
15
|
+
currentPlatform,
|
|
16
|
+
describeStep,
|
|
17
|
+
expandCheck,
|
|
18
|
+
onPath,
|
|
19
|
+
probe,
|
|
20
|
+
renderInstall,
|
|
21
|
+
whyUnselectable,
|
|
22
|
+
} from "../shared/plugin/hooks/lib/requires-probe.js";
|
|
10
23
|
import type { ResolvedBin } from "../types/index.js";
|
|
11
|
-
import {
|
|
12
|
-
|
|
24
|
+
import type { PlanEnv } from "./deps-plan.js";
|
|
25
|
+
|
|
26
|
+
/** doctor's per-check cap: long enough that a slow check is still definitive. */
|
|
27
|
+
export const DOCTOR_CHECK_CAP_MS = 60_000;
|
|
28
|
+
|
|
29
|
+
export interface ProbeBinsOptions {
|
|
30
|
+
env: PlanEnv;
|
|
31
|
+
depth: ProbeDepth;
|
|
32
|
+
capMs: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** This machine, as planning and probing see it. Null on an unsupported platform. */
|
|
36
|
+
export function machineEnv(
|
|
37
|
+
home: string,
|
|
38
|
+
path: string,
|
|
39
|
+
userShell: string,
|
|
40
|
+
): PlanEnv | null {
|
|
41
|
+
const platform = currentPlatform();
|
|
42
|
+
return platform ? { ...platform, home, path, userShell } : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The probe's answer for each of `bins`, index-aligned. Each check's
|
|
47
|
+
* `${CLAUDE_PLUGIN_ROOT}` is expanded with its own plugin's directory, so one
|
|
48
|
+
* probe covers requirements from many plugins; requirements sharing a name are
|
|
49
|
+
* probed in separate passes, since a probe answers by name.
|
|
50
|
+
*/
|
|
51
|
+
export async function probeBins(
|
|
52
|
+
bins: readonly ResolvedBin[],
|
|
53
|
+
opts: ProbeBinsOptions,
|
|
54
|
+
): Promise<Presence[]> {
|
|
55
|
+
const out: Presence[] = new Array(bins.length);
|
|
56
|
+
let pending = bins.map((bin, index) => ({ bin, index }));
|
|
57
|
+
while (pending.length > 0) {
|
|
58
|
+
const seen = new Set<string>();
|
|
59
|
+
const batch: typeof pending = [];
|
|
60
|
+
const later: typeof pending = [];
|
|
61
|
+
for (const item of pending) {
|
|
62
|
+
if (seen.has(item.bin.name)) later.push(item);
|
|
63
|
+
else {
|
|
64
|
+
seen.add(item.bin.name);
|
|
65
|
+
batch.push(item);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const reqs: Requirement[] = batch.map(({ bin }) => ({
|
|
69
|
+
name: bin.name,
|
|
70
|
+
check: expandCheck(bin, bin.root ?? ""),
|
|
71
|
+
install: bin.install,
|
|
72
|
+
}));
|
|
73
|
+
const answers = await probe(reqs, {
|
|
74
|
+
pluginRoot: process.cwd(),
|
|
75
|
+
os: opts.env.os,
|
|
76
|
+
arch: opts.env.arch,
|
|
77
|
+
home: opts.env.home,
|
|
78
|
+
path: opts.env.path,
|
|
79
|
+
capMs: opts.capMs,
|
|
80
|
+
depth: opts.depth,
|
|
81
|
+
userShell: opts.env.userShell,
|
|
82
|
+
});
|
|
83
|
+
const byName = new Map(answers.map((a) => [a.name, a]));
|
|
84
|
+
for (const { bin, index } of batch) {
|
|
85
|
+
out[index] = byName.get(bin.name) ?? {
|
|
86
|
+
name: bin.name,
|
|
87
|
+
status: "unknown",
|
|
88
|
+
reason: "not probed",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
pending = later;
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The line a person runs to install `bin` here, or why nothing can. */
|
|
97
|
+
export function installText(bin: ResolvedBin, env: PlanEnv): string {
|
|
98
|
+
if (!bin.chosen) {
|
|
99
|
+
return whyUnselectable(bin, env, (t) => onPath(t, env.path) !== null);
|
|
100
|
+
}
|
|
101
|
+
return describeStep(
|
|
102
|
+
renderInstall(bin, bin.chosen, { ...env, pluginRoot: bin.root ?? "" }),
|
|
103
|
+
env.os,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
13
106
|
|
|
14
107
|
export interface BinCheckResult {
|
|
15
108
|
name: string;
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* The command that would install it, shown when missing.
|
|
19
|
-
*
|
|
20
|
-
* A rendered string, not a {@link Command}: this is advisory text printed
|
|
21
|
-
* after "run:" for a human to copy. Nothing executes it — doctor diagnoses
|
|
22
|
-
* and never installs — so there is nothing here for a shell to re-parse.
|
|
23
|
-
*/
|
|
109
|
+
presence: Presence;
|
|
110
|
+
/** Advisory text after "run:" for a human to copy. Never executed. */
|
|
24
111
|
installCommand: string;
|
|
25
112
|
/** Where the requirement came from (profile cliTools / plugin ids). */
|
|
26
113
|
sources: string[];
|
|
27
114
|
}
|
|
28
115
|
|
|
29
|
-
/**
|
|
30
|
-
* Check each resolved binary for presence on PATH. `isPresent` is injectable
|
|
31
|
-
* for tests; it defaults to a real PATH lookup.
|
|
32
|
-
*
|
|
33
|
-
* The probes are deliberately SERIAL — a PATH walk per binary, each resolving
|
|
34
|
-
* symlinks — so a profile declaring a dozen tools spends real time here. That
|
|
35
|
-
* is what `onProbe` is for: it fires with the name about to be checked, before
|
|
36
|
-
* the await, so a caller can name the binary it is currently blocked on rather
|
|
37
|
-
* than showing a bare spinner.
|
|
38
|
-
*/
|
|
116
|
+
/** Probe each bin and pair the answer with its install line. */
|
|
39
117
|
export async function checkBinaries(
|
|
40
118
|
bins: ResolvedBin[],
|
|
41
|
-
|
|
42
|
-
// (the tmux-mcp / npx-cache failure mode) reads as MISSING, not present.
|
|
43
|
-
isPresent: (name: string) => Promise<boolean> = async (name) =>
|
|
44
|
-
(await resolveExecutable(name)) !== null,
|
|
45
|
-
onProbe?: (name: string, index: number, total: number) => void,
|
|
119
|
+
opts: ProbeBinsOptions,
|
|
46
120
|
): Promise<BinCheckResult[]> {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
sources: bin.sources,
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
return results;
|
|
121
|
+
const presences = await probeBins(bins, opts);
|
|
122
|
+
return bins.map((bin, i) => ({
|
|
123
|
+
name: bin.name,
|
|
124
|
+
presence: presences[i] as Presence,
|
|
125
|
+
installCommand: installText(bin, opts.env),
|
|
126
|
+
sources: bin.sources,
|
|
127
|
+
}));
|
|
58
128
|
}
|
|
59
129
|
|
|
60
|
-
/**
|
|
130
|
+
/** The actionable subset: missing, or installed but not on PATH. */
|
|
61
131
|
export function missingBinaries(results: BinCheckResult[]): BinCheckResult[] {
|
|
62
|
-
return results.filter(
|
|
132
|
+
return results.filter(
|
|
133
|
+
(r) => r.presence.status === "missing" || r.presence.status === "off-path",
|
|
134
|
+
);
|
|
63
135
|
}
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
InstalledPluginsRegistry,
|
|
13
13
|
ResolvedClosure,
|
|
14
14
|
} from "../types/index.js";
|
|
15
|
+
import { pinOfBin } from "./resolver.js";
|
|
15
16
|
|
|
16
17
|
export interface VersionDrift {
|
|
17
18
|
pluginId: string;
|
|
@@ -94,7 +95,16 @@ export function summarizeClosure(closure: ResolvedClosure): string[] {
|
|
|
94
95
|
if (mcps.length) lines.push(`mcp servers ${mcps.join(", ")}`);
|
|
95
96
|
if (closure.bins.length) {
|
|
96
97
|
lines.push(
|
|
97
|
-
`cli tools ${closure.bins
|
|
98
|
+
`cli tools ${closure.bins
|
|
99
|
+
.map((b) => {
|
|
100
|
+
const pin = pinOfBin(b);
|
|
101
|
+
if (pin) return `${b.name}@${pin}`;
|
|
102
|
+
const refused = b.refusedPins ?? [];
|
|
103
|
+
return refused.length > 0
|
|
104
|
+
? `${b.name}@${refused.join("|")} (pin not applied)`
|
|
105
|
+
: b.name;
|
|
106
|
+
})
|
|
107
|
+
.join(", ")}`,
|
|
98
108
|
);
|
|
99
109
|
}
|
|
100
110
|
if (closure.skills.length) {
|