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
@@ -1,16 +1,28 @@
1
1
  /**
2
- * Toolchain detection + bootstrap for the package managers a profile's binary
3
- * deps need (bun, brew, npm, pip, go).
2
+ * The commands that install, upgrade, pin and remove a version-managed tool
3
+ * (bun, brew, npm, pip, go), and the bridge between a requirement and that
4
+ * version-managed view.
4
5
  *
5
- * `magus install` requires whatever installers the resolved closure uses. In
6
- * source form bun is already guaranteed (bin/magus.js hard-exits without it),
7
- * but once magus ships as a compiled binary an end user may lack bun — so bun
8
- * is treated as a bootstrappable toolchain exactly like brew.
6
+ * Installing a requirement goes through requires-probe's `renderInstall` and
7
+ * the deps executor (`services/deps-plan.ts`); this module builds only what
8
+ * `magus update` and the CLI Tools screen run beyond a first install.
9
9
  */
10
10
 
11
- import type { BinInstaller, ResolvedBin } from "../types/index.js";
11
+ import {
12
+ type InstallOption,
13
+ assertNever,
14
+ currentPlatform,
15
+ renderInstall,
16
+ } from "../shared/plugin/hooks/lib/requires-probe.js";
17
+ import type {
18
+ BinInstaller,
19
+ ManagedBin,
20
+ ManagedTool,
21
+ ResolvedBin,
22
+ SelectedBin,
23
+ } from "../types/index.js";
12
24
  import type { Command } from "../utils/command-utils.js";
13
- import { which, whichSync } from "../utils/command-utils.js";
25
+ import { whichSync } from "../utils/command-utils.js";
14
26
 
15
27
  // -- Python CLI applications -------------------------------------------------
16
28
 
@@ -160,125 +172,119 @@ export function resetPythonCliInstallerMemo(): void {
160
172
  }
161
173
 
162
174
  // ----------------------------------------------------------------------------
175
+ //
176
+ // The version-managed view of a requirement.
177
+ //
178
+ // A requirement (plugin `requires`, or a profile cliTool mapped onto the
179
+ // profile-only kinds) is installed by requires-probe's `renderInstall` and the
180
+ // deps executor. `magus update` and the CLI Tools screen additionally track
181
+ // the installed VERSION of the kinds that have one — bun, brew, npm, pip, go —
182
+ // and they work in the {@link ManagedTool} shape. These functions are the one
183
+ // bridge between the two, in both directions.
163
184
 
164
- /**
165
- * The executable that proves a toolchain is installed.
166
- *
167
- * `pip` is deliberately absent: which executable proves it depends on the
168
- * machine, so it is resolved by {@link resolvePythonCliInstaller} instead.
169
- */
170
- const TOOLCHAIN_PROBE: Record<Exclude<BinInstaller, "pip">, string> = {
171
- bun: "bun",
172
- brew: "brew",
173
- npm: "npm",
174
- go: "go",
175
- };
176
-
177
- /** The executable whose presence proves `name` is usable. */
178
- function toolchainProbe(name: BinInstaller): string {
179
- return name === "pip"
180
- ? resolvePythonCliInstaller().probe
181
- : TOOLCHAIN_PROBE[name];
182
- }
183
-
184
- /** Official one-line bootstrap for the toolchains magus will install. */
185
- export const TOOLCHAIN_BOOTSTRAP: Partial<Record<BinInstaller, string>> = {
186
- bun: "curl -fsSL https://bun.sh/install | bash",
187
- brew: '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
188
- };
189
-
190
- export interface ToolchainStatus {
191
- name: BinInstaller;
192
- present: boolean;
193
- /** Binary names in the closure that need this toolchain. */
194
- requiredBy: string[];
195
- /** Bootstrap command, when magus can install this toolchain itself. */
196
- bootstrap?: string;
197
- }
198
-
199
- /** Is a given toolchain available on PATH? */
200
- export async function isToolchainPresent(name: BinInstaller): Promise<boolean> {
201
- return (await which(toolchainProbe(name))) !== null;
202
- }
185
+ const pinned = (version: string | undefined) => (version ? { version } : {});
203
186
 
204
187
  /**
205
- * Determine which toolchains a resolved closure's binary deps require, and
206
- * whether each is present. Only toolchains actually needed are returned.
188
+ * The version-managed view of a selected requirement, or null when its chosen
189
+ * option is not one of the {@link BinInstaller} kinds (apt, release,
190
+ * bootstrap, uv-script, uv-module: their version is the declaration's, and
191
+ * `magus doctor --fix` installs them).
207
192
  */
208
- export async function detectToolchains(
209
- bins: ResolvedBin[],
210
- ): Promise<ToolchainStatus[]> {
211
- const requiredBy = new Map<BinInstaller, string[]>();
212
- for (const bin of bins) {
213
- const list = requiredBy.get(bin.via) ?? [];
214
- list.push(bin.name);
215
- requiredBy.set(bin.via, list);
193
+ export function managedTool(bin: SelectedBin): ManagedTool | null {
194
+ const option = bin.chosen;
195
+ if (!option) return null;
196
+ const check = bin.check.join(" ");
197
+ switch (option.via) {
198
+ case "bun":
199
+ case "npm":
200
+ case "pip":
201
+ return {
202
+ name: bin.name,
203
+ via: option.via,
204
+ package: option.package,
205
+ check,
206
+ ...pinned(option.version),
207
+ };
208
+ case "go":
209
+ return {
210
+ name: bin.name,
211
+ via: "go",
212
+ module: option.package,
213
+ check,
214
+ ...pinned(option.version),
215
+ };
216
+ case "brew":
217
+ // A cask is installed, upgraded and removed with `--cask`, which the
218
+ // version-managed view does not speak: its declaration manages it.
219
+ return option.cask !== undefined
220
+ ? null
221
+ : { name: bin.name, via: "brew", formula: option.formula, check };
222
+ case "apt":
223
+ case "release":
224
+ case "bootstrap":
225
+ case "uv-script":
226
+ case "uv-module":
227
+ return null;
228
+ default:
229
+ return assertNever(option);
216
230
  }
231
+ }
217
232
 
218
- const statuses: ToolchainStatus[] = [];
219
- for (const [name, names] of requiredBy) {
220
- statuses.push({
221
- name,
222
- present: await isToolchainPresent(name),
223
- requiredBy: names,
224
- bootstrap: TOOLCHAIN_BOOTSTRAP[name],
225
- });
233
+ /** {@link managedTool} over a closure's bins, keeping each one's provenance. */
234
+ export function managedBins(bins: readonly ResolvedBin[]): ManagedBin[] {
235
+ const out: ManagedBin[] = [];
236
+ for (const bin of bins) {
237
+ const tool = managedTool(bin);
238
+ if (tool) out.push({ ...tool, sources: [...bin.sources] });
226
239
  }
227
- // Stable order: bun, brew, npm, pip, go.
228
- const order: BinInstaller[] = ["bun", "brew", "npm", "pip", "go"];
229
- statuses.sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name));
230
- return statuses;
240
+ return out;
231
241
  }
232
242
 
233
- /**
234
- * The install command for a single binary requirement.
235
- *
236
- * `python` is injectable so tests can pin one and stay machine-independent —
237
- * the default depends on what is on PATH, so an assertion against a literal
238
- * `uv tool …` would pass here and fail on a runner without uv.
239
- *
240
- * `pkg@version` and `pkg==version` are each ONE argv token. That join is
241
- * precisely where a string was dangerous: `bin.package` is hand-authored
242
- * profile/marketplace data, and under a shell a space or a `;` in it would have
243
- * become extra words. Here it cannot.
244
- */
245
- export function binInstallCommand(
246
- bin: {
247
- name: string;
248
- via: BinInstaller;
249
- package?: string;
250
- formula?: string;
251
- module?: string;
252
- version?: string;
253
- },
254
- python: PythonCliInstaller = resolvePythonCliInstaller(),
255
- ): Command {
256
- const pkg = bin.package ?? bin.name;
257
- const at = bin.version ? `@${bin.version}` : "";
258
- switch (bin.via) {
243
+ /** The install option a {@link ManagedTool} stands for. */
244
+ export function managedOption(t: ManagedTool): InstallOption {
245
+ switch (t.via) {
246
+ case "brew":
247
+ return { via: "brew", formula: t.formula ?? t.name };
248
+ case "go":
249
+ return { via: "go", package: t.module ?? t.name, ...pinned(t.version) };
259
250
  case "bun":
260
- return { cmd: "bun", args: ["install", "-g", `${pkg}${at}`] };
261
251
  case "npm":
262
- return { cmd: "npm", args: ["install", "-g", `${pkg}${at}`] };
263
252
  case "pip":
264
- return python.install(pkg, bin.version);
265
- case "brew":
266
- return { cmd: "brew", args: ["install", bin.formula ?? bin.name] };
267
- case "go":
268
253
  return {
269
- cmd: "go",
270
- args: [
271
- "install",
272
- `${bin.module ?? bin.name}@${bin.version ?? "latest"}`,
273
- ],
254
+ via: t.via,
255
+ package: t.package ?? t.name,
256
+ ...pinned(t.version),
274
257
  };
258
+ default:
259
+ return assertNever(t.via);
260
+ }
261
+ }
262
+
263
+ /**
264
+ * The command that installs a {@link ManagedTool}, rendered by requires-probe's
265
+ * `renderInstall` — the one renderer every install goes through — so the pin
266
+ * travels in the same token (`pkg@version`, `pkg==version`) it does there.
267
+ */
268
+ export function managedInstallCommand(t: ManagedTool): Command {
269
+ const step = renderInstall(
270
+ { name: t.name, check: [], install: [] },
271
+ managedOption(t),
272
+ {
273
+ ...(currentPlatform() ?? { os: "linux", arch: "amd64" }),
274
+ pluginRoot: "",
275
+ },
276
+ );
277
+ if (step.kind !== "argv") {
278
+ throw new Error(`${t.via} renders a ${step.kind} step, not a command`);
275
279
  }
280
+ const [cmd = "", ...args] = step.argv;
281
+ return { cmd, args };
276
282
  }
277
283
 
278
284
  /**
279
285
  * The command that advances an ALREADY-INSTALLED binary to the newest version.
280
286
  *
281
- * Distinct from {@link binInstallCommand} because two of the five installers
287
+ * Distinct from {@link managedInstallCommand} because two of the five installers
282
288
  * treat "install" as a no-op once the package is present: `brew install` on an
283
289
  * installed formula prints "already installed" and changes nothing, and `pip
284
290
  * install` without `--upgrade` does the same. Reusing the install command for
@@ -363,7 +369,7 @@ export function claudeNativeUpgradeCommand(pin?: string): Command {
363
369
  * {@link binUpgradeCommand} goes to the newest version, past the pin, and a
364
370
  * plain `uv tool install pkg==v` is a no-op once any version is installed.
365
371
  *
366
- * bun, npm and go take the version in the same token `binInstallCommand` builds
372
+ * bun, npm and go take the version in the same token `managedInstallCommand` builds
367
373
  * (`pkg@v`), and each replaces an installed version with it. pip goes through
368
374
  * the resolved installer's own `pin`. brew installs only its newest version, so
369
375
  * there is nothing to return — the planner never asks for it (a brew pin below
@@ -387,7 +393,7 @@ export function binPinCommand(
387
393
  case "bun":
388
394
  case "npm":
389
395
  case "go":
390
- return binInstallCommand(bin, python);
396
+ return managedInstallCommand(bin);
391
397
  case "pip":
392
398
  return python.pin(bin.package ?? bin.name, bin.version);
393
399
  case "brew":
@@ -405,7 +411,7 @@ export function binPinCommand(
405
411
  * `bun install -g pkg@latest` and left the tool permanently ahead of its pin,
406
412
  * a state magus cannot leave. Both now come through here.
407
413
  *
408
- * `install` → {@link binInstallCommand} (which appends the pin, when there is
414
+ * `install` → {@link managedInstallCommand} (which appends the pin, when there is
409
415
  * one). An upgrade whose target IS the pin → {@link binPinCommand}, because
410
416
  * {@link binUpgradeCommand} would overshoot it. Any other upgrade → the newest
411
417
  * version.
@@ -426,7 +432,7 @@ export function binCommandForState(
426
432
  },
427
433
  python: PythonCliInstaller = resolvePythonCliInstaller(),
428
434
  ): Command | null {
429
- if (state === "install") return binInstallCommand(bin, python);
435
+ if (state === "install") return managedInstallCommand(bin);
430
436
  if (bin.version && target === bin.version) return binPinCommand(bin, python);
431
437
  return binUpgradeCommand(bin, python);
432
438
  }
@@ -20,7 +20,7 @@
20
20
  * So the engine lives here, in `services/`, and both front ends drive it.
21
21
  * `cli/` and `ui/` are sibling presentation layers: they own how progress looks
22
22
  * and nothing else. Everything IO-shaped is injected — `PluginApplyDeps` for
23
- * the five Claude Code calls, `ApplyReporter` for progress, `runCommand` for
23
+ * the five Claude Code calls, `ApplyReporter` for progress, `runArgv` for
24
24
  * installers — so the same code drives an animated terminal meter, a TUI modal,
25
25
  * and a test that wants silence.
26
26
  *
@@ -32,13 +32,13 @@
32
32
  import path from "node:path";
33
33
  import type {
34
34
  InstalledPluginEntry,
35
+ ManagedBin,
35
36
  ProfileSkillRef,
36
- ResolvedBin,
37
37
  SkillInfo,
38
38
  } from "../types/index.js";
39
39
  import type { Command } from "../utils/command-utils.js";
40
40
  import { formatCommand } from "../utils/command-utils.js";
41
- import { runCommand } from "../utils/run.js";
41
+ import { runArgv, succeeded } from "../utils/run.js";
42
42
  import type { PluginScope } from "./claude-cli.js";
43
43
  import {
44
44
  installPlugin,
@@ -158,10 +158,10 @@ export interface ApplyReporter {
158
158
  /**
159
159
  * Run `work` with the terminal released.
160
160
  *
161
- * A binary upgrade spawns `brew`/`uv` with inherited stdio: it writes and
162
- * moves the cursor itself, so any animated block must be erased first or the
163
- * two interleave into garbage. A TUI implementation suspends its renderer;
164
- * a silent one just awaits.
161
+ * The installer's output is captured (`runArgv`), so nothing a child
162
+ * prints reaches the terminal; an implementation pauses its animation for
163
+ * the duration only so the meter does not tick over a step that has no
164
+ * progress of its own to show. A silent one just awaits.
165
165
  */
166
166
  detach<T>(work: () => Promise<T>): Promise<T>;
167
167
  }
@@ -1128,7 +1128,7 @@ export async function updatePluginInScope(
1128
1128
  */
1129
1129
  export function binCommandFor(
1130
1130
  item: BinUpdateItem,
1131
- spec: ResolvedBin,
1131
+ spec: ManagedBin,
1132
1132
  ): Command | null {
1133
1133
  if (!binNeedsWork(item)) return null;
1134
1134
  if (item.action === "upgrade" && item.upgradeCommand)
@@ -1185,7 +1185,14 @@ function binNoMove(item: BinUpdateItem, after: ToolFacts): string | null {
1185
1185
  * which R6 counts as changed — the safe answer, but never "no change — still
1186
1186
  * 3.7c", the one the user needs.
1187
1187
  */
1188
- const REAL_READ_BACK = (spec: ResolvedBin): Promise<ToolFacts> =>
1188
+ /** How long one `magus update` installer may run before it is stopped. */
1189
+ const INSTALLER_TIMEOUT_MS = 15 * 60_000;
1190
+
1191
+ /** The default installer runner: argv, output captured, true on success. */
1192
+ const runInstaller = async (command: Command): Promise<boolean> =>
1193
+ succeeded(await runArgv(command, { timeoutMs: INSTALLER_TIMEOUT_MS }));
1194
+
1195
+ const REAL_READ_BACK = (spec: ManagedBin): Promise<ToolFacts> =>
1189
1196
  probeTool(spec, undefined, { allowCheckCommand: true });
1190
1197
 
1191
1198
  export async function applyBins(
@@ -1195,9 +1202,9 @@ export async function applyBins(
1195
1202
  // profile-supplied package name reach `/bin/sh`: a seam called "shell" reads
1196
1203
  // as an invitation to hand it a string. It takes argv, and nothing here
1197
1204
  // builds a shell line.
1198
- run: (command: Command) => Promise<boolean> = runCommand,
1205
+ run: (command: Command) => Promise<boolean> = runInstaller,
1199
1206
  /** Read-back seam: re-probe ONE tool after its installer ran. */
1200
- probe: (spec: ResolvedBin) => Promise<ToolFacts> = REAL_READ_BACK,
1207
+ probe: (spec: ManagedBin) => Promise<ToolFacts> = REAL_READ_BACK,
1201
1208
  ): Promise<ApplyResult> {
1202
1209
  const result: ApplyResult = { ok: 0, failed: [], unchanged: [] };
1203
1210
  for (const item of plan.bins) {
@@ -1222,8 +1229,6 @@ export async function applyBins(
1222
1229
  result.failed.push(item.name);
1223
1230
  continue;
1224
1231
  }
1225
- // The installer owns the terminal for the duration: it streams its own
1226
- // output through inherited stdio.
1227
1232
  const succeeded = await report.detach(() => run(cmd));
1228
1233
  if (!succeeded) {
1229
1234
  report.finish(
@@ -1315,7 +1320,7 @@ export interface ApplyPlanOptions {
1315
1320
  /** Installer execution seam — see the note on {@link applyBins}. */
1316
1321
  run?: (command: Command) => Promise<boolean>;
1317
1322
  /** Read-back seam — see {@link applyBins}. Default: `probeTool(spec)`. */
1318
- probe?: (spec: ResolvedBin) => Promise<ToolFacts>;
1323
+ probe?: (spec: ManagedBin) => Promise<ToolFacts>;
1319
1324
  }
1320
1325
 
1321
1326
  /**
@@ -28,7 +28,7 @@
28
28
  * (`unknown`) rather than folding into `current`.
29
29
  */
30
30
 
31
- import type { ProfileSkillRef, ResolvedBin } from "../types/index.js";
31
+ import type { ManagedBin, ProfileSkillRef } from "../types/index.js";
32
32
  import type { Command } from "../utils/command-utils.js";
33
33
  import type { PluginScope } from "./claude-cli.js";
34
34
  import {
@@ -1061,7 +1061,7 @@ export interface BinUpdateItem {
1061
1061
  * that is `unknown` too — "not checked" is not "fine".
1062
1062
  */
1063
1063
  export function planBinUpdates(
1064
- bins: ResolvedBin[],
1064
+ bins: ManagedBin[],
1065
1065
  facts: readonly ToolFacts[],
1066
1066
  ): BinUpdateItem[] {
1067
1067
  const byName = new Map(facts.map((f) => [f.name, f]));
@@ -1147,7 +1147,7 @@ export interface UpdatePlan {
1147
1147
  * only the decision; the installer command needs `via`/`package`/`formula`,
1148
1148
  * and re-deriving those from a name would guess.
1149
1149
  */
1150
- binSpecs: Map<string, ResolvedBin>;
1150
+ binSpecs: Map<string, ManagedBin>;
1151
1151
  }
1152
1152
 
1153
1153
  /**
@@ -0,0 +1,64 @@
1
+ #!/bin/sh
2
+ # SessionStart (matcher `startup`) fallback for deps-check.ts. The registration
3
+ # runs this only when Bun is not on the PATH Claude Code started with, which is
4
+ # the one dependency the TypeScript check cannot report itself. $1 is the label.
5
+ #
6
+ # Writes the same ledger as deps-check.ts, 0700: seen/<label> = sh, then claims
7
+ # the Bun requirement's dedupe key, so a session prints one Bun line however
8
+ # many plugins carry this file. A bun in a place an installer or Homebrew puts
9
+ # one is reported as off the PATH, naming that place; only when there is none
10
+ # is Bun called missing. scripts/check-hook-copies.ts recomputes the key, the
11
+ # install line, the Bun prefix and the PATH line formats from
12
+ # lib/requires-probe.ts and fails when this file disagrees. Exit 0 always.
13
+
14
+ label=$1
15
+ case $label in
16
+ '' | *[!a-z0-9._-]*) exit 0 ;;
17
+ esac
18
+ key=bun-3182ab42b0f1
19
+
20
+ sid=$(cat 2>/dev/null | tr -d '\n' | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([A-Za-z0-9_-]*\)".*/\1/p')
21
+ if [ -n "$sid" ]; then
22
+ umask 077
23
+ base=${TMPDIR:-/tmp}
24
+ dir=$base/magus-deps-$sid
25
+ if mkdir -p "$dir/seen" "$dir/claimed" 2>/dev/null; then
26
+ printf sh >"$dir/seen/$label" 2>/dev/null
27
+ find "$base" -maxdepth 1 -type d -name 'magus-deps-*' -mtime +0 -exec rm -rf {} + 2>/dev/null
28
+ mkdir "$dir/claimed/$key" 2>/dev/null || exit 0
29
+ fi
30
+ fi
31
+
32
+ # The first bun where an installer or Homebrew leaves one. A path that would
33
+ # break the JSON line is skipped.
34
+ found=
35
+ for d in ${BUN_INSTALL:+"$BUN_INSTALL/bin"} "$HOME/.bun/bin" /opt/homebrew/bin /usr/local/bin /home/linuxbrew/.linuxbrew/bin; do
36
+ case $d in
37
+ *\"* | *\\* | *[[:cntrl:]]*) continue ;;
38
+ esac
39
+ if [ -f "$d/bun" ] && [ -x "$d/bun" ]; then
40
+ found=$d
41
+ break
42
+ fi
43
+ done
44
+
45
+ if [ -n "$found" ]; then
46
+ # Under $HOME: shown as ~/…, and $HOME in the PATH line.
47
+ rel=${found#"$HOME"/}
48
+ if [ -n "$HOME" ] && [ "$HOME" != / ] && [ "$rel" != "$found" ]; then
49
+ shown="~/$rel" pathdir="\$HOME/$rel"
50
+ else
51
+ shown=$found pathdir=$found
52
+ fi
53
+ case ${SHELL:-} in
54
+ *zsh) rc='~/.zshrc' fmt='export PATH="%s:$PATH"' ;;
55
+ *fish) rc='~/.config/fish/config.fish' fmt='fish_add_path %s' ;;
56
+ *) rc='~/.bashrc' fmt='export PATH="%s:$PATH"' ;;
57
+ esac
58
+ # shellcheck disable=SC2059 # fmt is one of the three fixed formats above
59
+ line=$(printf "$fmt" "$pathdir" | sed 's/["\\]/\\&/g')
60
+ printf '{"systemMessage":"magus-deps: Bun is installed at %s but is not on the PATH Claude Code started with, so magus hooks and MCP servers cannot run (noticed by %s).\\n Add %s to %s, then restart Claude Code from a new terminal."}\n' "$shown" "$label" "$line" "$rc"
61
+ else
62
+ printf '{"systemMessage":"magus-deps: Bun is missing — magus hooks and MCP servers need it (noticed by %s).\\n Install: curl -fsSL https://bun.sh/install | bash, then restart Claude Code from a new terminal."}\n' "$label"
63
+ fi
64
+ exit 0