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.
Files changed (88) 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/review-followups/bun-install-path.test.ts +57 -0
  11. package/src/__tests__/blind/review-followups/d1-empty-lock.test.ts +94 -0
  12. package/src/__tests__/blind/review-followups/d2-disappearing-lock.test.ts +90 -0
  13. package/src/__tests__/blind/review-followups/d3-release-temp-cleanup.test.ts +83 -0
  14. package/src/__tests__/blind/review-followups/d4-tool-stdout.test.ts +152 -0
  15. package/src/__tests__/blind/review-followups/installer-redirect.test.ts +74 -0
  16. package/src/__tests__/blind/review-followups/ledger-permissions.test.ts +78 -0
  17. package/src/__tests__/blind/review-followups/ledger-pruning.test.ts +79 -0
  18. package/src/__tests__/blind/review-followups/path-advice.test.ts +46 -0
  19. package/src/__tests__/blind/review-followups/stats-requires.test.ts +16 -0
  20. package/src/__tests__/blind-780-cli-tools.test.ts +36 -48
  21. package/src/__tests__/blind-780-review2.test.ts +0 -25
  22. package/src/__tests__/cli-apply-seams.test.ts +2 -67
  23. package/src/__tests__/cli-tool-commands.test.ts +131 -6
  24. package/src/__tests__/cli-tools-adapter.test.ts +52 -2
  25. package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
  26. package/src/__tests__/deps-adapter.test.ts +191 -0
  27. package/src/__tests__/deps-check-hook.test.ts +220 -0
  28. package/src/__tests__/deps-plan.test.ts +897 -0
  29. package/src/__tests__/deps-report.test.ts +197 -0
  30. package/src/__tests__/doctor-bins.test.ts +134 -36
  31. package/src/__tests__/doctor-json.test.ts +261 -0
  32. package/src/__tests__/failure-popup-review2.test.tsx +1 -25
  33. package/src/__tests__/final-fixes-780.test.tsx +25 -9
  34. package/src/__tests__/fixtures/bins.ts +55 -0
  35. package/src/__tests__/install-plan.test.ts +15 -1
  36. package/src/__tests__/marketplace-refresh.test.ts +9 -3
  37. package/src/__tests__/no-shell-spawn.test.ts +77 -0
  38. package/src/__tests__/npm-package-name.test.ts +6 -9
  39. package/src/__tests__/plugin-requires.test.ts +138 -153
  40. package/src/__tests__/requires-probe.test.ts +858 -0
  41. package/src/__tests__/resolver.test.ts +129 -52
  42. package/src/__tests__/run.test.ts +108 -0
  43. package/src/__tests__/tool-probe.test.ts +1 -1
  44. package/src/__tests__/toolchain.test.ts +123 -52
  45. package/src/__tests__/update-plan.test.ts +1 -1
  46. package/src/cli/ansi.ts +2 -7
  47. package/src/cli/deps-view.ts +213 -0
  48. package/src/cli/doctor.ts +169 -24
  49. package/src/cli/install.ts +51 -66
  50. package/src/cli/profile.ts +20 -2
  51. package/src/cli/router.ts +6 -2
  52. package/src/cli/update.ts +28 -7
  53. package/src/data/cli-tools.ts +24 -9
  54. package/src/services/active-closure.ts +3 -3
  55. package/src/services/claude-settings.ts +4 -348
  56. package/src/services/cli-tool-commands.ts +104 -3
  57. package/src/services/deps-plan.ts +1154 -0
  58. package/src/services/deps-report.ts +265 -0
  59. package/src/services/doctor-bins.ts +115 -43
  60. package/src/services/install-plan.ts +11 -1
  61. package/src/services/local-marketplace.ts +12 -206
  62. package/src/services/marketplace-refresh.ts +4 -4
  63. package/src/services/plugin-manager.ts +4 -18
  64. package/src/services/plugin-requires.ts +93 -162
  65. package/src/services/resolver.ts +168 -58
  66. package/src/services/tool-probe.ts +18 -25
  67. package/src/services/toolchain.ts +117 -111
  68. package/src/services/update-engine.ts +19 -14
  69. package/src/services/update-plan.ts +3 -3
  70. package/src/shared/plugin/hooks/bun-missing.sh +64 -0
  71. package/src/shared/plugin/hooks/deps-check.ts +240 -0
  72. package/src/shared/plugin/hooks/lib/requires-probe.ts +1279 -0
  73. package/src/types/index.ts +53 -16
  74. package/src/ui/App.tsx +6 -40
  75. package/src/ui/adapters/cliToolsAdapter.ts +75 -37
  76. package/src/ui/adapters/depsAdapter.ts +129 -0
  77. package/src/ui/adapters/startupReports.ts +0 -22
  78. package/src/ui/renderers/cliToolRenderers.tsx +135 -2
  79. package/src/ui/screens/CliToolsScreen.tsx +141 -51
  80. package/src/ui/screens/PluginsScreen.tsx +12 -48
  81. package/src/utils/command-utils.ts +4 -5
  82. package/src/utils/run.ts +143 -52
  83. package/src/utils/string-utils.ts +9 -0
  84. package/src/__tests__/go-module.test.ts +0 -43
  85. package/src/__tests__/plugin-setup.test.ts +0 -1016
  86. package/src/__tests__/shell-script-callers.test.ts +0 -92
  87. package/src/services/plugin-setup.ts +0 -965
  88. package/src/utils/go-module.ts +0 -36
@@ -4,29 +4,38 @@
4
4
  * Turns a manifest entry into a fully-resolved {@link ResolvedClosure}: the
5
5
  * marketplaces, plugins, MCP servers, binary deps, skills, settings, and env a
6
6
  * machine must have to match the profile. Binary deps are the union of the
7
- * profile's own `cliTools` and every enabled plugin's declared `requires.bin`,
8
- * deduped by executable name with the project's pin winning. Plugins include
7
+ * profile's own `cliTools` and every enabled plugin's declared `requires`,
8
+ * merged by {@link mergeBins} with the project's pin winning. Plugins include
9
9
  * every plugin an enabled one declares as a dependency, transitively.
10
10
  */
11
11
 
12
12
  import semver from "semver";
13
- import { cliTools } from "../data/cli-tools.js";
13
+ import { cliTools, isOwnedEntry } from "../data/cli-tools.js";
14
14
  import { getMarketplaceByName } from "../data/marketplaces.js";
15
15
  import { PREDEFINED_PROFILES } from "../data/predefined-profiles.js";
16
+ import {
17
+ type InstallOption,
18
+ type Requirement,
19
+ dedupeKey,
20
+ } from "../shared/plugin/hooks/lib/requires-probe.js";
16
21
  import type {
17
- BinRequirement,
18
22
  DependencyReport,
19
23
  ProfileManifest,
20
24
  ProfileManifestEntry,
21
25
  ResolvedBin,
22
26
  ResolvedClosure,
27
+ SelectedBin,
23
28
  } from "../types/index.js";
24
29
  import { readManifest } from "./manifest.js";
25
30
  import {
26
31
  type PluginDependencyReader,
27
32
  readPluginDependencies,
28
33
  } from "./plugin-dependencies.js";
29
- import { resolvePluginBinRequirements } from "./plugin-requires.js";
34
+ import {
35
+ ownedRequirement,
36
+ resolvePluginBinRequirements,
37
+ selectBins,
38
+ } from "./plugin-requires.js";
30
39
  import { activeProfile } from "./profile-live.js";
31
40
 
32
41
  export interface ResolveOptions {
@@ -34,7 +43,15 @@ export interface ResolveOptions {
34
43
  * Resolve a plugin id to its declared binary requirements. Injectable for
35
44
  * tests; defaults to reading plugin.json from the marketplace cache.
36
45
  */
37
- binResolver?: (pluginId: string) => Promise<BinRequirement[]>;
46
+ binResolver?: (pluginId: string) => Promise<SelectedBin[]>;
47
+ /**
48
+ * The requirement a plugin declares for a catalogue tool it owns
49
+ * (`ownedBy`). Defaults to reading that plugin's declaration here.
50
+ */
51
+ ownedResolver?: (
52
+ pluginId: string,
53
+ name: string,
54
+ ) => Promise<SelectedBin | null>;
38
55
  /**
39
56
  * Resolve a plugin id to the plugin ids its plugin.json declares as
40
57
  * dependencies, or null when it cannot be read. Defaults to reading the
@@ -235,84 +252,163 @@ export function resolveExtends(
235
252
  };
236
253
  }
237
254
 
255
+ /** The pin a selected requirement installs at, if its chosen option carries one. */
256
+ export function pinOfBin(bin: SelectedBin): string | undefined {
257
+ const option = bin.chosen;
258
+ return option && "version" in option ? option.version : undefined;
259
+ }
260
+
261
+ /** The catalogue's check line as argv: split once, here, never by a shell. */
262
+ const splitCheck = (line: string): string[] => line.trim().split(/\s+/);
263
+
238
264
  /**
239
- * Map a manifest cliTools entry (name -> version) to a BinRequirement.
265
+ * Map a manifest cliTools entry (name -> version) onto a requirement: the
266
+ * catalogue's installer as one option (`npm`/`pip`/`go` are the profile-only
267
+ * kinds), its check as argv, the pin carried in the option. A tool a plugin
268
+ * owns (`ownedBy`) is that plugin's declaration, passed in as `owned`, with
269
+ * the pin applied to its chosen option. An unknown tool installs with bun
270
+ * under its own name.
240
271
  *
241
272
  * Exported so the TUI's CLI Tools screen maps a catalogue tool the same way the
242
273
  * resolver does, rather than keeping a second copy of the mapping.
243
274
  */
244
- export function cliToolToBin(name: string, version: string): BinRequirement {
275
+ export function cliToolToBin(
276
+ name: string,
277
+ version: string,
278
+ owned?: SelectedBin | null,
279
+ ): SelectedBin {
280
+ const pin = version && version !== "latest" ? version : undefined;
281
+ const carriesPin = (
282
+ o: InstallOption,
283
+ ): o is Extract<InstallOption, { via: "bun" | "npm" | "pip" | "go" }> =>
284
+ o.via === "bun" || o.via === "npm" || o.via === "pip" || o.via === "go";
285
+ const withPin = (o: InstallOption): InstallOption =>
286
+ pin && carriesPin(o) ? { ...o, version: pin } : o;
287
+ if (owned) {
288
+ if (!owned.chosen) return owned;
289
+ // The plugin's option decides the version: a pin it cannot carry is
290
+ // kept as refused, so it is reported rather than lost.
291
+ return pin && !carriesPin(owned.chosen)
292
+ ? { ...owned, refusedPins: [pin] }
293
+ : { ...owned, chosen: withPin(owned.chosen) };
294
+ }
245
295
  const tool = cliTools.find((t) => t.name === name);
246
- const pinned = version && version !== "latest" ? version : undefined;
247
- if (tool) {
248
- return {
249
- name,
250
- via: tool.packageManager,
251
- package: tool.packageName,
252
- check: tool.checkCommand,
253
- version: pinned,
254
- };
296
+ let option: InstallOption;
297
+ let check: string[];
298
+ if (tool && !isOwnedEntry(tool)) {
299
+ check = splitCheck(tool.checkCommand);
300
+ option =
301
+ tool.packageManager === "brew"
302
+ ? { via: "brew", formula: tool.packageName }
303
+ : { via: tool.packageManager, package: tool.packageName };
304
+ } else {
305
+ check = [name, "--version"];
306
+ option = { via: "bun", package: name };
255
307
  }
256
- // Unknown tool: default to bun (magus's toolchain) with name as package.
308
+ const requirement: Requirement = { name, check, install: [withPin(option)] };
309
+ return (
310
+ selectBins([requirement], undefined)[0] ?? {
311
+ ...requirement,
312
+ chosen: null,
313
+ }
314
+ );
315
+ }
316
+
317
+ /** The declaration of a requirement, without what selection and provenance add. */
318
+ export function requirementOf(bin: Requirement): Requirement {
257
319
  return {
258
- name,
259
- via: "bun",
260
- package: name,
261
- check: `${name} --version`,
262
- version: pinned,
320
+ name: bin.name,
321
+ check: bin.check,
322
+ ...(bin.os ? { os: bin.os } : {}),
323
+ install: bin.install,
263
324
  };
264
325
  }
265
326
 
266
327
  /**
267
- * Dedupe binary requirements by executable name. Entries are processed in the
268
- * order given, so callers put higher-precedence sources first (project pins
269
- * before plugin-declared deps); the first entry's installer/version wins and
270
- * later ones only contribute provenance.
328
+ * The identity two requirements merge by: requires-probe's `dedupeKey` of the
329
+ * declaration, plus the plugin directory when the check reads
330
+ * `${CLAUDE_PLUGIN_ROOT}` — such a check is per plugin by nature.
331
+ */
332
+ export function binKey(bin: SelectedBin): string {
333
+ const key = dedupeKey(requirementOf(bin));
334
+ return bin.check.some((t) => t.includes("${CLAUDE_PLUGIN_ROOT}"))
335
+ ? `${key}@${bin.root ?? ""}`
336
+ : key;
337
+ }
338
+
339
+ /**
340
+ * Merge selected requirements, in the order given — callers put higher
341
+ * precedence first (project pins before plugin declarations). Identical
342
+ * declarations merge (one entry, every source). A profile cliTools entry
343
+ * absorbs any later requirement of the same name: the project's pin is the
344
+ * decision. Two different plugin declarations of one name stay two entries,
345
+ * so neither hides the other.
271
346
  */
272
- export function dedupeBins(
273
- entries: Array<{ bin: BinRequirement; source: string }>,
347
+ export function mergeBins(
348
+ entries: Array<{ bin: SelectedBin; source: string }>,
274
349
  ): ResolvedBin[] {
275
- const byName = new Map<string, ResolvedBin>();
350
+ const out: ResolvedBin[] = [];
351
+ const byKey = new Map<string, ResolvedBin>();
352
+ const pinnedByName = new Map<string, ResolvedBin>();
276
353
  for (const { bin, source } of entries) {
277
- const existing = byName.get(bin.name);
354
+ const key = binKey(bin);
355
+ const existing = byKey.get(key) ?? pinnedByName.get(bin.name);
278
356
  if (existing) {
279
357
  if (!existing.sources.includes(source)) existing.sources.push(source);
280
- // A later entry only fills a version the winner left unset.
281
- if (existing.version === undefined && bin.version !== undefined) {
282
- existing.version = bin.version;
283
- }
284
358
  continue;
285
359
  }
286
- byName.set(bin.name, { ...bin, sources: [source] });
360
+ const resolved: ResolvedBin = { ...bin, sources: [source] };
361
+ byKey.set(key, resolved);
362
+ if (source.startsWith("profile:")) pinnedByName.set(bin.name, resolved);
363
+ out.push(resolved);
287
364
  }
288
- return [...byName.values()];
365
+ return out;
366
+ }
367
+
368
+ /** A profile's own cliTools as selected requirements, with their source. */
369
+ export async function cliToolBins(
370
+ entry: Pick<ProfileManifestEntry, "name" | "cliTools">,
371
+ ownedResolver: (
372
+ pluginId: string,
373
+ name: string,
374
+ ) => Promise<SelectedBin | null> = ownedRequirement,
375
+ ): Promise<Array<{ bin: SelectedBin; source: string }>> {
376
+ const out: Array<{ bin: SelectedBin; source: string }> = [];
377
+ for (const [name, version] of Object.entries(entry.cliTools ?? {})) {
378
+ const tool = cliTools.find((t) => t.name === name);
379
+ const owned =
380
+ tool && isOwnedEntry(tool)
381
+ ? await ownedResolver(tool.ownedBy, name)
382
+ : null;
383
+ out.push({
384
+ bin: cliToolToBin(name, version, owned),
385
+ source: `profile:${entry.name}.cliTools`,
386
+ });
387
+ }
388
+ return out;
289
389
  }
290
390
 
291
391
  /** Collect the binary requirements for a resolved plugin set + profile cliTools. */
292
392
  async function resolveBins(
293
393
  entry: ProfileManifestEntry,
294
394
  pluginIds: string[],
295
- binResolver: (pluginId: string) => Promise<BinRequirement[]>,
395
+ binResolver: (pluginId: string) => Promise<SelectedBin[]>,
396
+ ownedResolver: (
397
+ pluginId: string,
398
+ name: string,
399
+ ) => Promise<SelectedBin | null>,
296
400
  ): Promise<ResolvedBin[]> {
297
- const collected: Array<{ bin: BinRequirement; source: string }> = [];
298
-
299
401
  // Project pins first (highest precedence).
300
- for (const [name, version] of Object.entries(entry.cliTools ?? {})) {
301
- collected.push({
302
- bin: cliToolToBin(name, version),
303
- source: `profile:${entry.name}.cliTools`,
304
- });
305
- }
402
+ const collected = await cliToolBins(entry, ownedResolver);
306
403
 
307
- // Then each plugin's declared binary deps.
404
+ // Then each plugin's declared requirements.
308
405
  for (const pluginId of pluginIds) {
309
- const bins = await binResolver(pluginId);
310
- for (const bin of bins) {
406
+ for (const bin of await binResolver(pluginId)) {
311
407
  collected.push({ bin, source: `plugin:${pluginId}` });
312
408
  }
313
409
  }
314
410
 
315
- return dedupeBins(collected);
411
+ return mergeBins(collected);
316
412
  }
317
413
 
318
414
  /** Resolve a single profile (applying `extends`) into an install closure. */
@@ -386,6 +482,7 @@ export async function resolveProfile(
386
482
  if (!raw) throw new Error(`Profile "${profileId}" not found in manifest`);
387
483
  const entry = resolveExtends(raw);
388
484
  const binResolver = opts.binResolver ?? resolvePluginBinRequirements;
485
+ const ownedResolver = opts.ownedResolver ?? ownedRequirement;
389
486
 
390
487
  const plugins = normalizePlugins(entry.plugins);
391
488
  const disabledPlugins = Object.entries(entry.plugins ?? {})
@@ -400,7 +497,12 @@ export async function resolveProfile(
400
497
  );
401
498
  for (const id of dependencies.derived) plugins[id] = "latest";
402
499
 
403
- const bins = await resolveBins(entry, Object.keys(plugins), binResolver);
500
+ const bins = await resolveBins(
501
+ entry,
502
+ Object.keys(plugins),
503
+ binResolver,
504
+ ownedResolver,
505
+ );
404
506
 
405
507
  return {
406
508
  marketplaces: withDerivedMarketplaces(entry.marketplaces, plugins),
@@ -480,18 +582,26 @@ function mergeClosures(
480
582
  continue;
481
583
  }
482
584
  const mergedSources = [...new Set([...existing.sources, ...bin.sources])];
483
- const va = existing.version;
484
- const vb = bin.version;
585
+ // Every profile's refused pin survives the union, whichever entry wins.
586
+ const refused = [
587
+ ...new Set([...(existing.refusedPins ?? []), ...(bin.refusedPins ?? [])]),
588
+ ];
589
+ const merged = (winner: ResolvedBin): ResolvedBin => ({
590
+ ...winner,
591
+ sources: mergedSources,
592
+ ...(refused.length > 0 ? { refusedPins: refused } : {}),
593
+ });
594
+ const va = pinOfBin(existing);
595
+ const vb = pinOfBin(bin);
485
596
  if (va === undefined && vb === undefined) {
486
- existing.sources = mergedSources;
597
+ binByName.set(bin.name, merged(existing));
487
598
  } else if (va === undefined) {
488
- binByName.set(bin.name, { ...bin, sources: mergedSources });
599
+ binByName.set(bin.name, merged(bin));
489
600
  } else if (vb === undefined) {
490
- existing.sources = mergedSources;
601
+ binByName.set(bin.name, merged(existing));
491
602
  } else {
492
603
  const { chosen, conflict } = pickVersion(bin.name, va, vb);
493
- const winner = chosen === va ? existing : bin;
494
- binByName.set(bin.name, { ...winner, sources: mergedSources });
604
+ binByName.set(bin.name, merged(chosen === va ? existing : bin));
495
605
  if (conflict) conflicts.push(conflict);
496
606
  }
497
607
  }
@@ -43,11 +43,7 @@ import os from "node:os";
43
43
  import path from "node:path";
44
44
  import semver from "semver";
45
45
  import { CLAUDE_CODE_NPM_PACKAGE, type CliTool } from "../data/cli-tools.js";
46
- import type {
47
- BinInstaller,
48
- BinRequirement,
49
- ResolvedBin,
50
- } from "../types/index.js";
46
+ import type { BinInstaller, ManagedBin, ManagedTool } from "../types/index.js";
51
47
  import type { Command } from "../utils/command-utils.js";
52
48
  import type { InstallMethod } from "./cli-tool-commands.js";
53
49
  import {
@@ -210,7 +206,7 @@ export function compareToolVersions(a: string, b: string): -1 | 0 | 1 | null {
210
206
  // ── The classifier ───────────────────────────────────────────────────────────
211
207
 
212
208
  /** The exact pin, or undefined for an unpinned (or "latest") requirement. */
213
- function pinOf(spec: BinRequirement): string | undefined {
209
+ function pinOf(spec: ManagedTool): string | undefined {
214
210
  const v = spec.version?.trim();
215
211
  return v && v !== "latest" ? v : undefined;
216
212
  }
@@ -220,7 +216,7 @@ function withFailure(text: string, failure: string | undefined): string {
220
216
  }
221
217
 
222
218
  /** The `not-managed` sentence. Names the file and its owner, never the version. */
223
- function notManagedNote(spec: BinRequirement, facts: ToolFacts): string {
219
+ function notManagedNote(spec: ManagedTool, facts: ToolFacts): string {
224
220
  return facts.managed === false
225
221
  ? `on PATH at ${facts.resolvedPath ?? "?"}, which ${facts.owner ?? spec.via} does not own — upgrading through ${spec.via} would install a second copy`
226
222
  : withFailure(
@@ -247,10 +243,7 @@ function notManagedNote(spec: BinRequirement, facts: ToolFacts): string {
247
243
  * rules are not displaced by it — they need a version to compare, and there is
248
244
  * none.
249
245
  */
250
- export function classifyTool(
251
- spec: BinRequirement,
252
- facts: ToolFacts,
253
- ): ToolStatus {
246
+ export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
254
247
  const pin = pinOf(spec);
255
248
  const base = {
256
249
  name: spec.name,
@@ -1027,7 +1020,7 @@ const SHELL_META = /[|&;<>()$`\\"'*?{}[\]~!#\n\r]/;
1027
1020
  * Refuse a name that could be read as an option, carries a NUL, or (for a
1028
1021
  * value joined into a path) climbs out of its directory. Null when fine.
1029
1022
  */
1030
- function refusal(spec: BinRequirement): string | null {
1023
+ function refusal(spec: ManagedTool): string | null {
1031
1024
  const bad = (v: string | undefined, isPath: boolean): boolean =>
1032
1025
  v !== undefined &&
1033
1026
  (v.startsWith("-") ||
@@ -1102,7 +1095,7 @@ async function packageJsonVersion(
1102
1095
  */
1103
1096
  async function versionFromCheck(
1104
1097
  s: ProbeSession,
1105
- spec: BinRequirement,
1098
+ spec: ManagedTool,
1106
1099
  f: ToolFacts,
1107
1100
  onPath: string,
1108
1101
  ): Promise<void> {
@@ -1191,7 +1184,7 @@ async function containedIn(
1191
1184
 
1192
1185
  async function probeBrew(
1193
1186
  s: ProbeSession,
1194
- spec: BinRequirement,
1187
+ spec: ManagedTool,
1195
1188
  f: ToolFacts,
1196
1189
  resolvedPath: string,
1197
1190
  ): Promise<void> {
@@ -1230,7 +1223,7 @@ async function probeBrew(
1230
1223
 
1231
1224
  async function probePip(
1232
1225
  s: ProbeSession,
1233
- spec: BinRequirement,
1226
+ spec: ManagedTool,
1234
1227
  f: ToolFacts,
1235
1228
  onPath: string,
1236
1229
  resolvedPath: string,
@@ -1361,7 +1354,7 @@ async function readNodePackage(
1361
1354
 
1362
1355
  async function probeNodeGlobal(
1363
1356
  s: ProbeSession,
1364
- spec: BinRequirement,
1357
+ spec: ManagedTool,
1365
1358
  f: ToolFacts,
1366
1359
  resolvedPath: string,
1367
1360
  ): Promise<void> {
@@ -1495,7 +1488,7 @@ async function isNativeLauncher(
1495
1488
  */
1496
1489
  async function probeClaudeNative(
1497
1490
  s: ProbeSession,
1498
- spec: BinRequirement,
1491
+ spec: ManagedTool,
1499
1492
  f: ToolFacts,
1500
1493
  onPath: string,
1501
1494
  resolvedPath: string,
@@ -1532,7 +1525,7 @@ async function probeClaudeNative(
1532
1525
 
1533
1526
  async function probeGo(
1534
1527
  s: ProbeSession,
1535
- spec: BinRequirement,
1528
+ spec: ManagedTool,
1536
1529
  f: ToolFacts,
1537
1530
  resolvedPath: string,
1538
1531
  pinned: boolean,
@@ -1585,7 +1578,7 @@ async function probeGo(
1585
1578
  /** Probe one tool into `f`, mutating it as facts arrive. Never throws. */
1586
1579
  async function probeInto(
1587
1580
  s: ProbeSession,
1588
- spec: BinRequirement,
1581
+ spec: ManagedTool,
1589
1582
  f: ToolFacts,
1590
1583
  ): Promise<void> {
1591
1584
  const refused = refusal(spec);
@@ -1683,7 +1676,7 @@ function timedOut(f: ToolFacts, deadlineMs: number): ToolFacts {
1683
1676
 
1684
1677
  async function probeWithDeadline(
1685
1678
  s: ProbeSession,
1686
- spec: BinRequirement,
1679
+ spec: ManagedTool,
1687
1680
  deadlineAt: number,
1688
1681
  deadlineMs: number,
1689
1682
  ): Promise<ToolFacts> {
@@ -1721,7 +1714,7 @@ async function probeWithDeadline(
1721
1714
  * facts with `failure` set, which classify as `unknown`, never `upgrade`.
1722
1715
  */
1723
1716
  export async function probeTools(
1724
- specs: readonly BinRequirement[],
1717
+ specs: readonly ManagedTool[],
1725
1718
  io: ToolProbeIO = REAL_TOOL_PROBE_IO,
1726
1719
  opts: ProbeOptions = {},
1727
1720
  ): Promise<ToolFacts[]> {
@@ -1774,7 +1767,7 @@ export async function probeTools(
1774
1767
 
1775
1768
  /** One tool, with its own probe session. */
1776
1769
  export async function probeTool(
1777
- spec: BinRequirement,
1770
+ spec: ManagedTool,
1778
1771
  io: ToolProbeIO = REAL_TOOL_PROBE_IO,
1779
1772
  opts: Omit<ProbeOptions, "concurrency" | "onResult"> = {},
1780
1773
  ): Promise<ToolFacts> {
@@ -1804,7 +1797,7 @@ function isBinInstaller(m: InstallMethod): m is BinInstaller & InstallMethod {
1804
1797
  /**
1805
1798
  * The spec a catalogue tool is probed through, in order:
1806
1799
  *
1807
- * 1. the active profile's `ResolvedBin` of the same name — the same pin and
1800
+ * 1. the active profile's `ManagedBin` of the same name — the same pin and
1808
1801
  * the same installer `magus update` uses, so the two agree;
1809
1802
  * 2. the DETECTED installer, when it is one magus models — "how it IS
1810
1803
  * installed", the rule `cliToolUpdate` follows, so the row's state and the
@@ -1814,9 +1807,9 @@ function isBinInstaller(m: InstallMethod): m is BinInstaller & InstallMethod {
1814
1807
  */
1815
1808
  export function specForCatalogTool(
1816
1809
  tool: CliTool,
1817
- closureBins: readonly ResolvedBin[] | null,
1810
+ closureBins: readonly ManagedBin[] | null,
1818
1811
  install: InstallInfo | undefined,
1819
- ): BinRequirement {
1812
+ ): ManagedTool {
1820
1813
  const declared = closureBins?.find((b) => b.name === tool.name);
1821
1814
  if (declared) return declared;
1822
1815
  if (install && isBinInstaller(install.primary)) {