magus-cli 7.8.0 → 7.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -4
  3. package/src/__tests__/blackbox-parity.test.ts +16 -11
  4. package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
  5. package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
  6. package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
  7. package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
  8. package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
  9. package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
  10. package/src/__tests__/blind-780-cli-tools.test.ts +36 -48
  11. package/src/__tests__/blind-780-review2.test.ts +0 -25
  12. package/src/__tests__/cli-apply-seams.test.ts +2 -67
  13. package/src/__tests__/cli-tool-commands.test.ts +131 -6
  14. package/src/__tests__/cli-tools-adapter.test.ts +52 -2
  15. package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
  16. package/src/__tests__/deps-adapter.test.ts +191 -0
  17. package/src/__tests__/deps-check-hook.test.ts +220 -0
  18. package/src/__tests__/deps-plan.test.ts +896 -0
  19. package/src/__tests__/deps-report.test.ts +197 -0
  20. package/src/__tests__/doctor-bins.test.ts +134 -36
  21. package/src/__tests__/doctor-json.test.ts +261 -0
  22. package/src/__tests__/failure-popup-review2.test.tsx +1 -25
  23. package/src/__tests__/final-fixes-780.test.tsx +25 -9
  24. package/src/__tests__/fixtures/bins.ts +55 -0
  25. package/src/__tests__/install-plan.test.ts +15 -1
  26. package/src/__tests__/marketplace-refresh.test.ts +9 -3
  27. package/src/__tests__/no-shell-spawn.test.ts +77 -0
  28. package/src/__tests__/npm-package-name.test.ts +6 -9
  29. package/src/__tests__/plugin-requires.test.ts +138 -153
  30. package/src/__tests__/requires-probe.test.ts +858 -0
  31. package/src/__tests__/resolver.test.ts +129 -52
  32. package/src/__tests__/run.test.ts +108 -0
  33. package/src/__tests__/tool-probe.test.ts +1 -1
  34. package/src/__tests__/toolchain.test.ts +123 -52
  35. package/src/__tests__/update-plan.test.ts +1 -1
  36. package/src/cli/ansi.ts +2 -7
  37. package/src/cli/deps-view.ts +213 -0
  38. package/src/cli/doctor.ts +169 -24
  39. package/src/cli/install.ts +51 -66
  40. package/src/cli/profile.ts +20 -2
  41. package/src/cli/router.ts +6 -2
  42. package/src/cli/update.ts +28 -7
  43. package/src/data/cli-tools.ts +24 -9
  44. package/src/services/active-closure.ts +3 -3
  45. package/src/services/claude-settings.ts +4 -348
  46. package/src/services/cli-tool-commands.ts +104 -3
  47. package/src/services/deps-plan.ts +962 -0
  48. package/src/services/deps-report.ts +265 -0
  49. package/src/services/doctor-bins.ts +115 -43
  50. package/src/services/install-plan.ts +11 -1
  51. package/src/services/local-marketplace.ts +12 -206
  52. package/src/services/marketplace-refresh.ts +4 -4
  53. package/src/services/plugin-manager.ts +4 -18
  54. package/src/services/plugin-requires.ts +93 -162
  55. package/src/services/resolver.ts +168 -58
  56. package/src/services/tool-probe.ts +18 -25
  57. package/src/services/toolchain.ts +117 -111
  58. package/src/services/update-engine.ts +19 -14
  59. package/src/services/update-plan.ts +3 -3
  60. package/src/shared/plugin/hooks/bun-missing.sh +39 -0
  61. package/src/shared/plugin/hooks/deps-check.ts +224 -0
  62. package/src/shared/plugin/hooks/lib/requires-probe.ts +1183 -0
  63. package/src/types/index.ts +53 -16
  64. package/src/ui/App.tsx +6 -40
  65. package/src/ui/adapters/cliToolsAdapter.ts +75 -37
  66. package/src/ui/adapters/depsAdapter.ts +129 -0
  67. package/src/ui/adapters/startupReports.ts +0 -22
  68. package/src/ui/renderers/cliToolRenderers.tsx +135 -2
  69. package/src/ui/screens/CliToolsScreen.tsx +141 -51
  70. package/src/ui/screens/PluginsScreen.tsx +12 -48
  71. package/src/utils/command-utils.ts +4 -5
  72. package/src/utils/run.ts +123 -52
  73. package/src/utils/string-utils.ts +9 -0
  74. package/src/__tests__/go-module.test.ts +0 -43
  75. package/src/__tests__/plugin-setup.test.ts +0 -1016
  76. package/src/__tests__/shell-script-callers.test.ts +0 -92
  77. package/src/services/plugin-setup.ts +0 -965
  78. package/src/utils/go-module.ts +0 -36
@@ -0,0 +1,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
- * Binary-dependency check for `magus doctor`.
2
+ * Presence of resolved requirements, for `magus doctor`, `magus install` and
3
+ * `profile switch`.
3
4
  *
4
- * Verifies that every binary a profile's plugins declare (plugin.json
5
- * `requires.bin`) plus the profile's own cliTools is present on PATH. This is
6
- * the diagnostic that would have caught the dead `tmux-mcp` — a plugin's MCP
7
- * server silently failing because its binary was never installed.
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 { formatCommand, resolveExecutable } from "../utils/command-utils.js";
12
- import { binInstallCommand } from "./toolchain.js";
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
- present: boolean;
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
- // Default resolves symlinks + checks executability, so a dangling link
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 results: BinCheckResult[] = [];
48
- for (const [index, bin] of bins.entries()) {
49
- onProbe?.(bin.name, index, bins.length);
50
- results.push({
51
- name: bin.name,
52
- present: await isPresent(bin.name),
53
- installCommand: formatCommand(binInstallCommand(bin)),
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
- /** Just the missing ones — the actionable subset. */
130
+ /** The actionable subset: missing, or installed but not on PATH. */
61
131
  export function missingBinaries(results: BinCheckResult[]): BinCheckResult[] {
62
- return results.filter((r) => !r.present);
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.map((b) => (b.version ? `${b.name}@${b.version}` : b.name)).join(", ")}`,
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) {