magus-cli 7.7.0 → 7.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -4
  3. package/src/__tests__/blackbox-parity.test.ts +45 -26
  4. package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
  5. package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
  6. package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
  7. package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
  8. package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
  9. package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
  10. package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
  11. package/src/__tests__/blind-780-cli-tools.test.ts +796 -0
  12. package/src/__tests__/blind-780-deprecated.test.ts +631 -0
  13. package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
  14. package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
  15. package/src/__tests__/blind-780-review2.test.ts +1178 -0
  16. package/src/__tests__/cli-apply-seams.test.ts +2 -67
  17. package/src/__tests__/cli-tool-commands.test.ts +131 -6
  18. package/src/__tests__/cli-tools-adapter.test.ts +52 -2
  19. package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
  20. package/src/__tests__/cli-update-view.test.ts +3 -3
  21. package/src/__tests__/deprecated-remove.test.ts +827 -0
  22. package/src/__tests__/deps-adapter.test.ts +191 -0
  23. package/src/__tests__/deps-check-hook.test.ts +220 -0
  24. package/src/__tests__/deps-plan.test.ts +896 -0
  25. package/src/__tests__/deps-report.test.ts +197 -0
  26. package/src/__tests__/doctor-bins.test.ts +134 -36
  27. package/src/__tests__/doctor-json.test.ts +261 -0
  28. package/src/__tests__/doctor.test.ts +159 -1
  29. package/src/__tests__/failure-popup-review2.test.tsx +896 -0
  30. package/src/__tests__/failure-popup.test.tsx +419 -0
  31. package/src/__tests__/final-fixes-780.test.tsx +740 -0
  32. package/src/__tests__/fixtures/bins.ts +55 -0
  33. package/src/__tests__/format-update-outcome.test.ts +6 -32
  34. package/src/__tests__/gitignore-profile.test.ts +1 -9
  35. package/src/__tests__/install-plan.test.ts +15 -1
  36. package/src/__tests__/marketplace-refresh.test.ts +9 -3
  37. package/src/__tests__/models-screen-state.test.ts +18 -40
  38. package/src/__tests__/no-shell-spawn.test.ts +77 -0
  39. package/src/__tests__/npm-package-name.test.ts +6 -9
  40. package/src/__tests__/plugin-requires.test.ts +138 -153
  41. package/src/__tests__/plugin-resolution.test.ts +52 -13
  42. package/src/__tests__/plugin-row-text.test.ts +30 -37
  43. package/src/__tests__/profile-dependencies.test.ts +7 -0
  44. package/src/__tests__/profile-startup-old-format.test.ts +0 -3
  45. package/src/__tests__/profile-startup.test.ts +40 -12
  46. package/src/__tests__/requires-probe.test.ts +858 -0
  47. package/src/__tests__/resolver.test.ts +129 -52
  48. package/src/__tests__/run.test.ts +108 -0
  49. package/src/__tests__/stale-load-repair.test.ts +31 -23
  50. package/src/__tests__/tool-probe.test.ts +1 -1
  51. package/src/__tests__/toolchain.test.ts +123 -52
  52. package/src/__tests__/update-apply.test.ts +13 -29
  53. package/src/__tests__/update-plan.test.ts +24 -18
  54. package/src/__tests__/useStaleLoadModal.test.ts +1 -57
  55. package/src/__tests__/version-unknown-state.test.ts +15 -17
  56. package/src/cli/ansi.ts +2 -7
  57. package/src/cli/deps-view.ts +213 -0
  58. package/src/cli/doctor.ts +320 -54
  59. package/src/cli/install.ts +55 -71
  60. package/src/cli/profile.ts +20 -2
  61. package/src/cli/router.ts +6 -2
  62. package/src/cli/update-view.ts +58 -15
  63. package/src/cli/update.ts +55 -12
  64. package/src/data/cli-tools.ts +24 -9
  65. package/src/services/active-closure.ts +3 -3
  66. package/src/services/catalog-cache-store.ts +8 -6
  67. package/src/services/claude-cli.ts +106 -19
  68. package/src/services/claude-settings.ts +88 -384
  69. package/src/services/cli-tool-commands.ts +104 -3
  70. package/src/services/deps-plan.ts +962 -0
  71. package/src/services/deps-report.ts +265 -0
  72. package/src/services/doctor-bins.ts +115 -43
  73. package/src/services/install-plan.ts +11 -1
  74. package/src/services/local-marketplace.ts +12 -206
  75. package/src/services/marketplace-refresh.ts +4 -4
  76. package/src/services/plugin-env-needs.ts +69 -0
  77. package/src/services/plugin-manager.ts +35 -37
  78. package/src/services/plugin-requires.ts +93 -162
  79. package/src/services/plugin-resolution.ts +34 -42
  80. package/src/services/profile-activate.ts +1 -2
  81. package/src/services/profile-startup.ts +18 -43
  82. package/src/services/profile-state.ts +72 -2
  83. package/src/services/resolver.ts +263 -59
  84. package/src/services/tool-probe.ts +18 -25
  85. package/src/services/toolchain.ts +117 -111
  86. package/src/services/update-engine.ts +345 -45
  87. package/src/services/update-plan.ts +296 -36
  88. package/src/shared/plugin/hooks/bun-missing.sh +39 -0
  89. package/src/shared/plugin/hooks/deps-check.ts +224 -0
  90. package/src/shared/plugin/hooks/lib/requires-probe.ts +1183 -0
  91. package/src/types/index.ts +60 -16
  92. package/src/ui/App.tsx +81 -171
  93. package/src/ui/adapters/cliToolsAdapter.ts +252 -12
  94. package/src/ui/adapters/depsAdapter.ts +129 -0
  95. package/src/ui/adapters/failureReport.ts +137 -0
  96. package/src/ui/adapters/pluginsAdapter.ts +297 -45
  97. package/src/ui/adapters/startupReports.ts +260 -0
  98. package/src/ui/components/modals/FailureListModal.tsx +231 -0
  99. package/src/ui/components/modals/MessageModal.tsx +7 -12
  100. package/src/ui/components/modals/ModalContainer.tsx +10 -5
  101. package/src/ui/components/modals/index.ts +1 -0
  102. package/src/ui/hooks/startupQueue.ts +56 -0
  103. package/src/ui/hooks/useGitignoreModal.ts +30 -52
  104. package/src/ui/hooks/useProfileStartupModal.ts +119 -142
  105. package/src/ui/hooks/useStaleLoadModal.ts +35 -65
  106. package/src/ui/keymap.ts +14 -1
  107. package/src/ui/renderers/cliToolRenderers.tsx +135 -2
  108. package/src/ui/renderers/mcpRenderers.tsx +110 -3
  109. package/src/ui/renderers/pluginRenderers.tsx +77 -6
  110. package/src/ui/renderers/pluginRowText.ts +25 -25
  111. package/src/ui/renderers/pluginStatusText.ts +18 -1
  112. package/src/ui/screens/AliasScreen.tsx +38 -45
  113. package/src/ui/screens/CliToolsScreen.tsx +199 -98
  114. package/src/ui/screens/EnvVarsScreen.tsx +17 -5
  115. package/src/ui/screens/GitignoreScreen.tsx +48 -67
  116. package/src/ui/screens/McpRegistryScreen.tsx +67 -38
  117. package/src/ui/screens/McpScreen.tsx +46 -19
  118. package/src/ui/screens/ModelsScreen.tsx +46 -86
  119. package/src/ui/screens/PluginsScreen.tsx +315 -259
  120. package/src/ui/screens/ProfilesScreen.tsx +79 -40
  121. package/src/ui/screens/SkillsScreen.tsx +83 -80
  122. package/src/ui/screens/StylesScreen.tsx +211 -275
  123. package/src/ui/state/AppContext.tsx +66 -44
  124. package/src/ui/state/DimensionsContext.tsx +3 -22
  125. package/src/ui/state/reducer.ts +12 -100
  126. package/src/ui/state/types.ts +48 -69
  127. package/src/utils/command-utils.ts +4 -5
  128. package/src/utils/run.ts +123 -52
  129. package/src/utils/string-utils.ts +9 -0
  130. package/src/utils/user-path.ts +60 -0
  131. package/src/__tests__/go-module.test.ts +0 -43
  132. package/src/__tests__/notices-rules.test.ts +0 -476
  133. package/src/__tests__/notices.test.tsx +0 -604
  134. package/src/__tests__/plugin-setup.test.ts +0 -1016
  135. package/src/__tests__/shell-script-callers.test.ts +0 -92
  136. package/src/__tests__/styles-status-line.test.ts +0 -72
  137. package/src/__tests__/version-snapshot.test.ts +0 -212
  138. package/src/services/plugin-setup.ts +0 -965
  139. package/src/services/version-snapshot.ts +0 -189
  140. 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
@@ -60,6 +77,7 @@ export async function resolveDependencies(
60
77
  read: PluginDependencyReader,
61
78
  ): Promise<DependencyReport> {
62
79
  const derived: string[] = [];
80
+ const requiredBy: Record<string, string> = {};
63
81
  const unreadable: string[] = [];
64
82
  const blocked: DependencyReport["blocked"] = [];
65
83
  const queue = [...enabled];
@@ -75,13 +93,105 @@ export async function resolveDependencies(
75
93
  blocked.push({ plugin: id, dependency });
76
94
  continue;
77
95
  }
96
+ // Every edge, including to a plugin the profile names itself: "still
97
+ // required" is a fact about the plugin that needs it, not about how
98
+ // the profile came to enable it. Recording only derived ones let a
99
+ // no-longer-published dependency the profile ALSO listed be planned
100
+ // for removal from under the plugin that needs it.
101
+ if (dependency !== id && !Object.hasOwn(requiredBy, dependency)) {
102
+ requiredBy[dependency] = id;
103
+ }
78
104
  if (seen.has(dependency)) continue;
79
105
  seen.add(dependency);
80
106
  derived.push(dependency);
81
107
  queue.push(dependency);
82
108
  }
83
109
  }
84
- return { derived: derived.sort(), unreadable: unreadable.sort(), blocked };
110
+ return {
111
+ derived: derived.sort(),
112
+ requiredBy,
113
+ unreadable: unreadable.sort(),
114
+ blocked,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Which plugin requires each plugin, for the planner's STILL-REQUIRED rule:
120
+ * the profile's edges (`DependencyReport.requiredBy`) and, beside them, the
121
+ * edges every INSTALLED plugin's own plugin.json declares.
122
+ *
123
+ * The profile alone is not enough. A plugin installed at USER scope, or in a
124
+ * project with no profile at all, is planned too — and without its edges a
125
+ * dependency it needs that stopped being published was planned for removal
126
+ * from under it. One plugin.json read per installed plugin, the same read the
127
+ * profile's resolve makes. The profile's answer wins where both name one.
128
+ *
129
+ * ONE function for `magus update` and the Plugins screen, so the two plan the
130
+ * same removals.
131
+ */
132
+ export async function requiredByHere(
133
+ profileRequiredBy: Readonly<Record<string, string>> | undefined,
134
+ installedIds: readonly string[],
135
+ projectPath?: string,
136
+ read: PluginDependencyReader = readPluginDependencies,
137
+ ): Promise<Record<string, string>> {
138
+ const all = await requirersHere(
139
+ profileRequiredBy,
140
+ installedIds,
141
+ projectPath,
142
+ read,
143
+ );
144
+ const first: Record<string, string> = {};
145
+ for (const [dependency, requirers] of Object.entries(all)) {
146
+ const [requirer] = requirers;
147
+ if (requirer) first[dependency] = requirer;
148
+ }
149
+ return first;
150
+ }
151
+
152
+ /**
153
+ * {@link requiredByHere} with EVERY requirer of each plugin, not only the
154
+ * first — what the planner needs to remove deprecated plugins that require
155
+ * one another together, while keeping one that anything staying still needs.
156
+ * The profile's requirer comes first; the installed plugins' follow, sorted.
157
+ *
158
+ * This is what `magus update` and the Plugins screen plan with.
159
+ */
160
+ export async function requirersHere(
161
+ profileRequiredBy: Readonly<Record<string, string>> | undefined,
162
+ installedIds: readonly string[],
163
+ projectPath?: string,
164
+ read: PluginDependencyReader = readPluginDependencies,
165
+ ): Promise<Record<string, string[]>> {
166
+ const declared = await Promise.all(
167
+ [...installedIds].sort().map(async (id) => {
168
+ try {
169
+ return [id, await read(id, projectPath)] as const;
170
+ } catch {
171
+ return [id, null] as const;
172
+ }
173
+ }),
174
+ );
175
+ const edges: Record<string, string[]> = {};
176
+ const add = (dependency: string, requirer: string, front = false) => {
177
+ if (dependency === requirer) return;
178
+ const list = (
179
+ Object.hasOwn(edges, dependency) ? (edges[dependency] as string[]) : []
180
+ ).filter((r) => !front || r !== requirer);
181
+ if (list.includes(requirer)) return;
182
+ if (front) list.unshift(requirer);
183
+ else list.push(requirer);
184
+ edges[dependency] = list;
185
+ };
186
+ for (const [id, dependencies] of declared) {
187
+ for (const dependency of dependencies ?? []) add(dependency, id);
188
+ }
189
+ for (const [dependency, requirer] of Object.entries(
190
+ profileRequiredBy ?? {},
191
+ )) {
192
+ add(dependency, requirer, true);
193
+ }
194
+ return edges;
85
195
  }
86
196
 
87
197
  function hasDependencies(report: DependencyReport): boolean {
@@ -142,84 +252,163 @@ export function resolveExtends(
142
252
  };
143
253
  }
144
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
+
145
264
  /**
146
- * 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.
147
271
  *
148
272
  * Exported so the TUI's CLI Tools screen maps a catalogue tool the same way the
149
273
  * resolver does, rather than keeping a second copy of the mapping.
150
274
  */
151
- 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
+ }
152
295
  const tool = cliTools.find((t) => t.name === name);
153
- const pinned = version && version !== "latest" ? version : undefined;
154
- if (tool) {
155
- return {
156
- name,
157
- via: tool.packageManager,
158
- package: tool.packageName,
159
- check: tool.checkCommand,
160
- version: pinned,
161
- };
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 };
162
307
  }
163
- // 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 {
164
319
  return {
165
- name,
166
- via: "bun",
167
- package: name,
168
- check: `${name} --version`,
169
- version: pinned,
320
+ name: bin.name,
321
+ check: bin.check,
322
+ ...(bin.os ? { os: bin.os } : {}),
323
+ install: bin.install,
170
324
  };
171
325
  }
172
326
 
173
327
  /**
174
- * Dedupe binary requirements by executable name. Entries are processed in the
175
- * order given, so callers put higher-precedence sources first (project pins
176
- * before plugin-declared deps); the first entry's installer/version wins and
177
- * 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.
178
331
  */
179
- export function dedupeBins(
180
- entries: Array<{ bin: BinRequirement; source: string }>,
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.
346
+ */
347
+ export function mergeBins(
348
+ entries: Array<{ bin: SelectedBin; source: string }>,
181
349
  ): ResolvedBin[] {
182
- const byName = new Map<string, ResolvedBin>();
350
+ const out: ResolvedBin[] = [];
351
+ const byKey = new Map<string, ResolvedBin>();
352
+ const pinnedByName = new Map<string, ResolvedBin>();
183
353
  for (const { bin, source } of entries) {
184
- const existing = byName.get(bin.name);
354
+ const key = binKey(bin);
355
+ const existing = byKey.get(key) ?? pinnedByName.get(bin.name);
185
356
  if (existing) {
186
357
  if (!existing.sources.includes(source)) existing.sources.push(source);
187
- // A later entry only fills a version the winner left unset.
188
- if (existing.version === undefined && bin.version !== undefined) {
189
- existing.version = bin.version;
190
- }
191
358
  continue;
192
359
  }
193
- 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);
364
+ }
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
+ });
194
387
  }
195
- return [...byName.values()];
388
+ return out;
196
389
  }
197
390
 
198
391
  /** Collect the binary requirements for a resolved plugin set + profile cliTools. */
199
392
  async function resolveBins(
200
393
  entry: ProfileManifestEntry,
201
394
  pluginIds: string[],
202
- binResolver: (pluginId: string) => Promise<BinRequirement[]>,
395
+ binResolver: (pluginId: string) => Promise<SelectedBin[]>,
396
+ ownedResolver: (
397
+ pluginId: string,
398
+ name: string,
399
+ ) => Promise<SelectedBin | null>,
203
400
  ): Promise<ResolvedBin[]> {
204
- const collected: Array<{ bin: BinRequirement; source: string }> = [];
205
-
206
401
  // Project pins first (highest precedence).
207
- for (const [name, version] of Object.entries(entry.cliTools ?? {})) {
208
- collected.push({
209
- bin: cliToolToBin(name, version),
210
- source: `profile:${entry.name}.cliTools`,
211
- });
212
- }
402
+ const collected = await cliToolBins(entry, ownedResolver);
213
403
 
214
- // Then each plugin's declared binary deps.
404
+ // Then each plugin's declared requirements.
215
405
  for (const pluginId of pluginIds) {
216
- const bins = await binResolver(pluginId);
217
- for (const bin of bins) {
406
+ for (const bin of await binResolver(pluginId)) {
218
407
  collected.push({ bin, source: `plugin:${pluginId}` });
219
408
  }
220
409
  }
221
410
 
222
- return dedupeBins(collected);
411
+ return mergeBins(collected);
223
412
  }
224
413
 
225
414
  /** Resolve a single profile (applying `extends`) into an install closure. */
@@ -293,6 +482,7 @@ export async function resolveProfile(
293
482
  if (!raw) throw new Error(`Profile "${profileId}" not found in manifest`);
294
483
  const entry = resolveExtends(raw);
295
484
  const binResolver = opts.binResolver ?? resolvePluginBinRequirements;
485
+ const ownedResolver = opts.ownedResolver ?? ownedRequirement;
296
486
 
297
487
  const plugins = normalizePlugins(entry.plugins);
298
488
  const disabledPlugins = Object.entries(entry.plugins ?? {})
@@ -307,7 +497,12 @@ export async function resolveProfile(
307
497
  );
308
498
  for (const id of dependencies.derived) plugins[id] = "latest";
309
499
 
310
- 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
+ );
311
506
 
312
507
  return {
313
508
  marketplaces: withDerivedMarketplaces(entry.marketplaces, plugins),
@@ -387,18 +582,26 @@ function mergeClosures(
387
582
  continue;
388
583
  }
389
584
  const mergedSources = [...new Set([...existing.sources, ...bin.sources])];
390
- const va = existing.version;
391
- 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);
392
596
  if (va === undefined && vb === undefined) {
393
- existing.sources = mergedSources;
597
+ binByName.set(bin.name, merged(existing));
394
598
  } else if (va === undefined) {
395
- binByName.set(bin.name, { ...bin, sources: mergedSources });
599
+ binByName.set(bin.name, merged(bin));
396
600
  } else if (vb === undefined) {
397
- existing.sources = mergedSources;
601
+ binByName.set(bin.name, merged(existing));
398
602
  } else {
399
603
  const { chosen, conflict } = pickVersion(bin.name, va, vb);
400
- const winner = chosen === va ? existing : bin;
401
- binByName.set(bin.name, { ...winner, sources: mergedSources });
604
+ binByName.set(bin.name, merged(chosen === va ? existing : bin));
402
605
  if (conflict) conflicts.push(conflict);
403
606
  }
404
607
  }
@@ -444,6 +647,7 @@ function mergeDependencyReports(
444
647
  );
445
648
  return {
446
649
  derived: [...new Set([...a.derived, ...b.derived])].sort(),
650
+ requiredBy: { ...b.requiredBy, ...a.requiredBy },
447
651
  unreadable: [...new Set([...a.unreadable, ...b.unreadable])].sort(),
448
652
  blocked: [...blocked.values()],
449
653
  };
@@ -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)) {