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.
- package/README.md +1 -1
- package/package.json +4 -4
- package/src/__tests__/blackbox-parity.test.ts +16 -11
- package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
- package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
- package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
- package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
- package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
- package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
- package/src/__tests__/blind-780-cli-tools.test.ts +36 -48
- package/src/__tests__/blind-780-review2.test.ts +0 -25
- package/src/__tests__/cli-apply-seams.test.ts +2 -67
- package/src/__tests__/cli-tool-commands.test.ts +131 -6
- package/src/__tests__/cli-tools-adapter.test.ts +52 -2
- package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
- package/src/__tests__/deps-adapter.test.ts +191 -0
- package/src/__tests__/deps-check-hook.test.ts +220 -0
- package/src/__tests__/deps-plan.test.ts +896 -0
- package/src/__tests__/deps-report.test.ts +197 -0
- package/src/__tests__/doctor-bins.test.ts +134 -36
- package/src/__tests__/doctor-json.test.ts +261 -0
- package/src/__tests__/failure-popup-review2.test.tsx +1 -25
- package/src/__tests__/final-fixes-780.test.tsx +25 -9
- package/src/__tests__/fixtures/bins.ts +55 -0
- package/src/__tests__/install-plan.test.ts +15 -1
- package/src/__tests__/marketplace-refresh.test.ts +9 -3
- package/src/__tests__/no-shell-spawn.test.ts +77 -0
- package/src/__tests__/npm-package-name.test.ts +6 -9
- package/src/__tests__/plugin-requires.test.ts +138 -153
- package/src/__tests__/requires-probe.test.ts +858 -0
- package/src/__tests__/resolver.test.ts +129 -52
- package/src/__tests__/run.test.ts +108 -0
- package/src/__tests__/tool-probe.test.ts +1 -1
- package/src/__tests__/toolchain.test.ts +123 -52
- package/src/__tests__/update-plan.test.ts +1 -1
- package/src/cli/ansi.ts +2 -7
- package/src/cli/deps-view.ts +213 -0
- package/src/cli/doctor.ts +169 -24
- package/src/cli/install.ts +51 -66
- package/src/cli/profile.ts +20 -2
- package/src/cli/router.ts +6 -2
- package/src/cli/update.ts +28 -7
- package/src/data/cli-tools.ts +24 -9
- package/src/services/active-closure.ts +3 -3
- package/src/services/claude-settings.ts +4 -348
- package/src/services/cli-tool-commands.ts +104 -3
- package/src/services/deps-plan.ts +962 -0
- package/src/services/deps-report.ts +265 -0
- package/src/services/doctor-bins.ts +115 -43
- package/src/services/install-plan.ts +11 -1
- package/src/services/local-marketplace.ts +12 -206
- package/src/services/marketplace-refresh.ts +4 -4
- package/src/services/plugin-manager.ts +4 -18
- package/src/services/plugin-requires.ts +93 -162
- package/src/services/resolver.ts +168 -58
- package/src/services/tool-probe.ts +18 -25
- package/src/services/toolchain.ts +117 -111
- package/src/services/update-engine.ts +19 -14
- package/src/services/update-plan.ts +3 -3
- package/src/shared/plugin/hooks/bun-missing.sh +39 -0
- package/src/shared/plugin/hooks/deps-check.ts +224 -0
- package/src/shared/plugin/hooks/lib/requires-probe.ts +1183 -0
- package/src/types/index.ts +53 -16
- package/src/ui/App.tsx +6 -40
- package/src/ui/adapters/cliToolsAdapter.ts +75 -37
- package/src/ui/adapters/depsAdapter.ts +129 -0
- package/src/ui/adapters/startupReports.ts +0 -22
- package/src/ui/renderers/cliToolRenderers.tsx +135 -2
- package/src/ui/screens/CliToolsScreen.tsx +141 -51
- package/src/ui/screens/PluginsScreen.tsx +12 -48
- package/src/utils/command-utils.ts +4 -5
- package/src/utils/run.ts +123 -52
- package/src/utils/string-utils.ts +9 -0
- package/src/__tests__/go-module.test.ts +0 -43
- package/src/__tests__/plugin-setup.test.ts +0 -1016
- package/src/__tests__/shell-script-callers.test.ts +0 -92
- package/src/services/plugin-setup.ts +0 -965
- package/src/utils/go-module.ts +0 -36
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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 {
|
|
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
|
-
*
|
|
206
|
-
*
|
|
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
|
|
209
|
-
|
|
210
|
-
)
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
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 `
|
|
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
|
|
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
|
|
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
|
|
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, `
|
|
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 {
|
|
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
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
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:
|
|
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
|
-
|
|
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> =
|
|
1205
|
+
run: (command: Command) => Promise<boolean> = runInstaller,
|
|
1199
1206
|
/** Read-back seam: re-probe ONE tool after its installer ran. */
|
|
1200
|
-
probe: (spec:
|
|
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:
|
|
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 {
|
|
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:
|
|
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,
|
|
1150
|
+
binSpecs: Map<string, ManagedBin>;
|
|
1151
1151
|
}
|
|
1152
1152
|
|
|
1153
1153
|
/**
|
|
@@ -0,0 +1,39 @@
|
|
|
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: seen/<label> = sh, then claims the
|
|
7
|
+
# Bun requirement's dedupe key, so a session prints one Bun line however many
|
|
8
|
+
# plugins carry this file. scripts/check-hook-copies.ts recomputes that key, the
|
|
9
|
+
# install line and the PATH lines from lib/requires-probe.ts and fails when this
|
|
10
|
+
# file disagrees. Exit 0 always.
|
|
11
|
+
|
|
12
|
+
label=$1
|
|
13
|
+
case $label in
|
|
14
|
+
'' | *[!a-z0-9._-]*) exit 0 ;;
|
|
15
|
+
esac
|
|
16
|
+
key=bun-3182ab42b0f1
|
|
17
|
+
|
|
18
|
+
sid=$(cat 2>/dev/null | tr -d '\n' | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([A-Za-z0-9_-]*\)".*/\1/p')
|
|
19
|
+
if [ -n "$sid" ]; then
|
|
20
|
+
base=${TMPDIR:-/tmp}
|
|
21
|
+
dir=$base/magus-deps-$sid
|
|
22
|
+
if mkdir -p "$dir/seen" "$dir/claimed" 2>/dev/null; then
|
|
23
|
+
printf sh >"$dir/seen/$label" 2>/dev/null
|
|
24
|
+
find "$base" -maxdepth 1 -type d -name 'magus-deps-*' -mtime +0 -exec rm -rf {} + 2>/dev/null
|
|
25
|
+
mkdir "$dir/claimed/$key" 2>/dev/null || exit 0
|
|
26
|
+
fi
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
if [ -x "$HOME/.bun/bin/bun" ]; then
|
|
30
|
+
case ${SHELL:-} in
|
|
31
|
+
*zsh) rc='~/.zshrc' line='export PATH=\"$HOME/.bun/bin:$PATH\"' ;;
|
|
32
|
+
*fish) rc='~/.config/fish/config.fish' line='fish_add_path $HOME/.bun/bin' ;;
|
|
33
|
+
*) rc='~/.bashrc' line='export PATH=\"$HOME/.bun/bin:$PATH\"' ;;
|
|
34
|
+
esac
|
|
35
|
+
printf '{"systemMessage":"magus-deps: Bun is installed at ~/.bun/bin 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' "$label" "$line" "$rc"
|
|
36
|
+
else
|
|
37
|
+
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"
|
|
38
|
+
fi
|
|
39
|
+
exit 0
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* SessionStart (matcher `startup`) — say loudly what this plugin needs and does
|
|
4
|
+
* not have. `argv[2]` is the plugin's label; the registration passes it.
|
|
5
|
+
*
|
|
6
|
+
* Reads the plugin's own `.claude-plugin/plugin.json` through
|
|
7
|
+
* lib/requires-probe.ts, probes each requirement at presence depth (a PATH
|
|
8
|
+
* lookup for a check that runs the requirement's own binary, the declared check
|
|
9
|
+
* for every other), and prints a `systemMessage` carrying the `magus-deps` marker when something is
|
|
10
|
+
* missing or off PATH. Prints nothing when healthy. Its "Fix all" line runs
|
|
11
|
+
* doctor with `--project <cwd>`, the session's directory from the hook input:
|
|
12
|
+
* doctor reads the plugins enabled in the project it checks, and the banner is
|
|
13
|
+
* read in a terminal that may be anywhere.
|
|
14
|
+
*
|
|
15
|
+
* THE LEDGER. Every copy writes `$TMPDIR/magus-deps-<session_id>/seen/<label>`
|
|
16
|
+
* (`ts` here, `sh` from bun-missing.sh), then claims each dep it reports with an
|
|
17
|
+
* atomic `mkdir claimed/<dedupeKey>`. A failed mkdir means another plugin's copy
|
|
18
|
+
* reports that dep this session, so one dep is one banner line however many
|
|
19
|
+
* plugins need it. An unreadable session id means no ledger and no claims:
|
|
20
|
+
* more banners, never fewer. Ledgers older than a day are pruned on write.
|
|
21
|
+
*
|
|
22
|
+
* Exit 0 always. A watchdog keeps the hook inside its registration timeout.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
mkdirSync,
|
|
27
|
+
readFileSync,
|
|
28
|
+
readdirSync,
|
|
29
|
+
rmSync,
|
|
30
|
+
statSync,
|
|
31
|
+
writeFileSync,
|
|
32
|
+
} from "node:fs";
|
|
33
|
+
import { homedir, tmpdir } from "node:os";
|
|
34
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
35
|
+
import { fileURLToPath } from "node:url";
|
|
36
|
+
import {
|
|
37
|
+
type Presence,
|
|
38
|
+
applicable,
|
|
39
|
+
currentPlatform,
|
|
40
|
+
dedupeKey,
|
|
41
|
+
onPath,
|
|
42
|
+
parseRequires,
|
|
43
|
+
probe,
|
|
44
|
+
renderFix,
|
|
45
|
+
} from "./lib/requires-probe.js";
|
|
46
|
+
|
|
47
|
+
const WATCHDOG_MS = 8_000;
|
|
48
|
+
const CHECK_CAP_MS = 3_000;
|
|
49
|
+
const STDIN_MS = 1_000;
|
|
50
|
+
const LEDGER_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
51
|
+
const SESSION_RE = /^[A-Za-z0-9_-]+$/;
|
|
52
|
+
|
|
53
|
+
const hasControlChar = (s: string): boolean =>
|
|
54
|
+
[...s].some((c) => {
|
|
55
|
+
const code = c.charCodeAt(0);
|
|
56
|
+
return code < 0x20 || code === 0x7f;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const quiet = (): never => process.exit(0);
|
|
60
|
+
setTimeout(quiet, WATCHDOG_MS).unref();
|
|
61
|
+
process.on("uncaughtException", quiet);
|
|
62
|
+
process.on("unhandledRejection", quiet);
|
|
63
|
+
|
|
64
|
+
interface HookInput {
|
|
65
|
+
/** Null when unreadable: no ledger, so more banners, never fewer. */
|
|
66
|
+
session: string | null;
|
|
67
|
+
/** The directory the session runs in; null leaves `--project` off the fix. */
|
|
68
|
+
cwd: string | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function readHookInput(): Promise<HookInput> {
|
|
72
|
+
const chunks: Buffer[] = [];
|
|
73
|
+
const read = (async () => {
|
|
74
|
+
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
|
|
75
|
+
})();
|
|
76
|
+
await Promise.race([read, new Promise((r) => setTimeout(r, STDIN_MS))]);
|
|
77
|
+
try {
|
|
78
|
+
const input = JSON.parse(Buffer.concat(chunks).toString("utf8")) as {
|
|
79
|
+
session_id?: unknown;
|
|
80
|
+
cwd?: unknown;
|
|
81
|
+
};
|
|
82
|
+
const id = input.session_id;
|
|
83
|
+
const cwd = input.cwd;
|
|
84
|
+
return {
|
|
85
|
+
session: typeof id === "string" && SESSION_RE.test(id) ? id : null,
|
|
86
|
+
// A control character would break the banner line it is printed in.
|
|
87
|
+
cwd:
|
|
88
|
+
typeof cwd === "string" && isAbsolute(cwd) && !hasControlChar(cwd)
|
|
89
|
+
? cwd
|
|
90
|
+
: null,
|
|
91
|
+
};
|
|
92
|
+
} catch {
|
|
93
|
+
return { session: null, cwd: null };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function pruneOldLedgers(base: string): void {
|
|
98
|
+
try {
|
|
99
|
+
for (const entry of readdirSync(base)) {
|
|
100
|
+
if (!entry.startsWith("magus-deps-")) continue;
|
|
101
|
+
const dir = join(base, entry);
|
|
102
|
+
if (Date.now() - statSync(dir).mtimeMs > LEDGER_MAX_AGE_MS) {
|
|
103
|
+
rmSync(dir, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// Pruning is housekeeping; a failure costs nothing now.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The ledger directory, with `seen/<label>` written, or null without a session. */
|
|
112
|
+
function openLedger(hookSession: string | null, label: string): string | null {
|
|
113
|
+
if (!hookSession) return null;
|
|
114
|
+
const base = process.env.TMPDIR || tmpdir();
|
|
115
|
+
const dir = join(base, `magus-deps-${hookSession}`);
|
|
116
|
+
try {
|
|
117
|
+
mkdirSync(join(dir, "seen"), { recursive: true });
|
|
118
|
+
mkdirSync(join(dir, "claimed"), { recursive: true });
|
|
119
|
+
writeFileSync(join(dir, "seen", label), "ts");
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
pruneOldLedgers(base);
|
|
124
|
+
return dir;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** True when this copy is the one to report `key` this session. */
|
|
128
|
+
function claim(ledger: string | null, key: string): boolean {
|
|
129
|
+
if (!ledger) return true;
|
|
130
|
+
try {
|
|
131
|
+
mkdirSync(join(ledger, "claimed", key));
|
|
132
|
+
return true;
|
|
133
|
+
} catch (error) {
|
|
134
|
+
return (error as NodeJS.ErrnoException).code !== "EEXIST";
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function emit(systemMessage: string, additionalContext: string): void {
|
|
139
|
+
process.stdout.write(
|
|
140
|
+
`${JSON.stringify({
|
|
141
|
+
systemMessage,
|
|
142
|
+
hookSpecificOutput: {
|
|
143
|
+
hookEventName: "SessionStart",
|
|
144
|
+
additionalContext,
|
|
145
|
+
},
|
|
146
|
+
})}\n`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function main(): Promise<void> {
|
|
151
|
+
const label = process.argv[2] ?? "";
|
|
152
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/.test(label)) return;
|
|
153
|
+
const pluginRoot =
|
|
154
|
+
process.env.CLAUDE_PLUGIN_ROOT ||
|
|
155
|
+
dirname(dirname(fileURLToPath(import.meta.url)));
|
|
156
|
+
const input = await readHookInput();
|
|
157
|
+
const ledger = openLedger(input.session, label);
|
|
158
|
+
|
|
159
|
+
const manifest = JSON.parse(
|
|
160
|
+
readFileSync(join(pluginRoot, ".claude-plugin", "plugin.json"), "utf8"),
|
|
161
|
+
) as unknown;
|
|
162
|
+
const parsed = parseRequires(manifest);
|
|
163
|
+
if (!parsed.ok) {
|
|
164
|
+
if (claim(ledger, `invalid-${label}`)) {
|
|
165
|
+
emit(
|
|
166
|
+
`magus-deps · ${label}: requires is invalid (${parsed.errors.join("; ")}) — please report`,
|
|
167
|
+
`${label}'s dependency declaration is invalid`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const platform = currentPlatform();
|
|
173
|
+
if (!platform) return;
|
|
174
|
+
const reqs = applicable(parsed.requirements, platform.os);
|
|
175
|
+
if (reqs.length === 0) return;
|
|
176
|
+
|
|
177
|
+
const env = {
|
|
178
|
+
pluginRoot,
|
|
179
|
+
...platform,
|
|
180
|
+
home: process.env.HOME || homedir(),
|
|
181
|
+
path: process.env.PATH ?? "",
|
|
182
|
+
capMs: CHECK_CAP_MS,
|
|
183
|
+
depth: "presence" as const,
|
|
184
|
+
userShell: process.env.SHELL ?? "",
|
|
185
|
+
};
|
|
186
|
+
const presences = await probe(reqs, env);
|
|
187
|
+
const reportable = (p: Presence) =>
|
|
188
|
+
p.status === "missing" || p.status === "off-path";
|
|
189
|
+
if (!presences.some(reportable)) return;
|
|
190
|
+
|
|
191
|
+
const byName = new Map(reqs.map((r) => [r.name, r]));
|
|
192
|
+
const claimed = (p: Presence) => {
|
|
193
|
+
const r = byName.get(p.name);
|
|
194
|
+
return r !== undefined && claim(ledger, dedupeKey(r));
|
|
195
|
+
};
|
|
196
|
+
const findings = presences.filter((p) => reportable(p) && claimed(p));
|
|
197
|
+
if (findings.length === 0) return;
|
|
198
|
+
const slow = presences.filter((p) => p.status === "unknown" && claimed(p));
|
|
199
|
+
const mine = [...findings, ...slow];
|
|
200
|
+
const message = renderFix(
|
|
201
|
+
label,
|
|
202
|
+
mine,
|
|
203
|
+
reqs,
|
|
204
|
+
env,
|
|
205
|
+
onPath("magus", env.path) !== null,
|
|
206
|
+
input.cwd,
|
|
207
|
+
);
|
|
208
|
+
if (message === null) return;
|
|
209
|
+
const failing = findings
|
|
210
|
+
.map((p) => p.name)
|
|
211
|
+
.sort()
|
|
212
|
+
.join(", ");
|
|
213
|
+
emit(
|
|
214
|
+
message,
|
|
215
|
+
`${label}'s MCP servers and hooks will fail until these are installed: ${failing}`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
await main();
|
|
221
|
+
} catch {
|
|
222
|
+
// A hook that cannot tell has nothing useful to print.
|
|
223
|
+
}
|
|
224
|
+
process.exit(0);
|