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
@@ -1,965 +0,0 @@
1
- /**
2
- * Plugin system dependency installer
3
- *
4
- * Auto-detects available package managers and installs plugin dependencies
5
- * declared in plugin.json's "setup" section.
6
- *
7
- * Supported managers:
8
- * - pip: Python packages (auto-detects uv > pip3 > pip)
9
- * - brew: Homebrew formulae (macOS/Linux)
10
- * - npm: Global npm packages
11
- * - cargo: Rust crates
12
- * - go: Go modules (via go install)
13
- *
14
- * Special keys:
15
- * - required: System binaries that must exist in PATH (not auto-installed)
16
- *
17
- * Example plugin.json:
18
- * {
19
- * "setup": {
20
- * "pip": ["browser-use", "mcp"],
21
- * "brew": ["memextech/tap/ht-mcp"],
22
- * "go": ["github.com/MadAppGang/tmux-mcp@latest"],
23
- * "required": ["tmux"]
24
- * }
25
- * }
26
- */
27
-
28
- import { execFile } from "node:child_process";
29
- import { constants as fsConstants } from "node:fs";
30
- import { access } from "node:fs/promises";
31
- import os from "node:os";
32
- import path from "node:path";
33
- import { promisify } from "node:util";
34
- import fs from "fs-extra";
35
- import { which } from "../utils/command-utils.js";
36
- import { goBinaryName } from "../utils/go-module.js";
37
-
38
- const execFileAsync = promisify(execFile);
39
-
40
- export interface SetupConfig {
41
- pip?: string[];
42
- brew?: string[];
43
- npm?: string[];
44
- cargo?: string[];
45
- go?: string[];
46
- required?: string[];
47
- }
48
-
49
- export interface SetupResult {
50
- installed: string[];
51
- skipped: string[];
52
- failed: Array<{ pkg: string; error: string }>;
53
- }
54
-
55
- /**
56
- * Run a command and return success/failure
57
- */
58
- async function run(
59
- cmd: string,
60
- args: string[],
61
- timeoutMs = 120000,
62
- ): Promise<{ ok: boolean; stdout: string; stderr: string }> {
63
- try {
64
- const { stdout, stderr } = await execFileAsync(cmd, args, {
65
- timeout: timeoutMs,
66
- });
67
- return { ok: true, stdout: stdout.trim(), stderr: stderr.trim() };
68
- } catch (error: unknown) {
69
- const e = error as { stdout?: string; stderr?: string; message?: string };
70
- return {
71
- ok: false,
72
- stdout: e.stdout?.trim() || "",
73
- stderr: e.stderr?.trim() || e.message || "unknown error",
74
- };
75
- }
76
- }
77
-
78
- /**
79
- * Detect the best available Python package installer
80
- * Prefers: uv > pip3 > pip
81
- */
82
- async function detectPipCommand(): Promise<{
83
- cmd: string;
84
- args: string[];
85
- } | null> {
86
- // Prefer uv (fast, modern)
87
- if (await which("uv")) {
88
- return {
89
- cmd: "uv",
90
- args: ["pip", "install", "--system", "--break-system-packages"],
91
- };
92
- }
93
- // Fall back to pip3
94
- if (await which("pip3")) {
95
- return {
96
- cmd: "pip3",
97
- args: ["install", "--break-system-packages"],
98
- };
99
- }
100
- // Fall back to pip
101
- if (await which("pip")) {
102
- return {
103
- cmd: "pip",
104
- args: ["install", "--break-system-packages"],
105
- };
106
- }
107
- return null;
108
- }
109
-
110
- /**
111
- * Check if a binary is available in PATH
112
- */
113
- async function isBinaryAvailable(name: string): Promise<boolean> {
114
- return (await which(name)) !== null;
115
- }
116
-
117
- /** Last path segment, e.g. "memextech/tap/ht-mcp" → "ht-mcp". */
118
- function lastSegment(s: string): string {
119
- return s.split("/").pop() || s;
120
- }
121
-
122
- /**
123
- * Extract binary name from a brew formula.
124
- *
125
- * Brew's `@` is part of the FORMULA NAME, not a version pin — `node@18` is a
126
- * distinct formula, not "node pinned to 18" — so the spec is installed verbatim
127
- * while the binary it provides drops the suffix.
128
- */
129
- export function extractBinaryName(pkg: string): string {
130
- return lastSegment(pkg).replace(/@[^@/]*$/, "") || lastSegment(pkg);
131
- }
132
-
133
- /** A dependency spec split into what to install and what to verify. */
134
- export interface ParsedSpec {
135
- /** The spec as declared, handed to the installer verbatim. */
136
- spec: string;
137
- /** Name used to locate the installed artifact. */
138
- name: string;
139
- /** Exact pinned version, or null when unpinned or a moving target. */
140
- version: string | null;
141
- }
142
-
143
- /**
144
- * Parse an `name@version` spec — the shared syntax of go, npm and cargo.
145
- *
146
- * A leading `@` is an npm SCOPE, not a version separator, so `@babel/cli` is
147
- * unpinned while `@babel/cli@7.0.0` pins 7.0.0.
148
- */
149
- export function parseAtSpec(spec: string): ParsedSpec {
150
- const at = spec.lastIndexOf("@");
151
- if (at <= 0) return { spec, name: lastSegment(spec), version: null };
152
-
153
- const version = spec.slice(at + 1).trim();
154
- const name = lastSegment(spec.slice(0, at));
155
- if (!version || version === "latest") return { spec, name, version: null };
156
- return { spec, name, version };
157
- }
158
-
159
- /**
160
- * Parse a pip requirement. Only `==` is an exact, verifiable pin; `>=`, `~=`
161
- * and friends describe a range, so there is no single version to check against.
162
- */
163
- export function parsePipSpec(spec: string): ParsedSpec {
164
- const exact = spec.match(/^\s*([A-Za-z0-9._-]+)\s*==\s*([^\s;]+)/);
165
- if (exact) return { spec, name: exact[1], version: exact[2] };
166
- const name = spec.split(/[<>=!~[;\s]/)[0].trim();
167
- return { spec, name: name || spec, version: null };
168
- }
169
-
170
- /**
171
- * The installed version of a pip distribution, or null when absent/unreadable.
172
- * Uses importlib.metadata rather than an import probe: it reports the VERSION,
173
- * and it works for distributions whose import name differs from their package
174
- * name (browser-use → browser_use).
175
- */
176
- async function getPipInstalledVersion(name: string): Promise<string | null> {
177
- const { ok, stdout } = await run("python3", [
178
- "-c",
179
- `import importlib.metadata as m; print(m.version(${JSON.stringify(name)}))`,
180
- ]);
181
- if (!ok || !stdout) return null;
182
- return normalizeVersion(stdout.split("\n")[0]);
183
- }
184
-
185
- /**
186
- * Does this pip requirement still need installing? Present-but-wrong-version
187
- * counts: without that, bumping a pin in plugin.json is inert.
188
- */
189
- async function pipDepNeedsInstall(pkg: string): Promise<boolean> {
190
- const { name, version } = parsePipSpec(pkg);
191
- const installed = await getPipInstalledVersion(name);
192
- if (installed === null) return true;
193
- if (!version) return false;
194
- return normalizeVersion(version) !== installed;
195
- }
196
-
197
- /**
198
- * Install pip packages
199
- */
200
- async function installPipPackages(
201
- packages: string[],
202
- result: SetupResult,
203
- ): Promise<void> {
204
- const pip = await detectPipCommand();
205
- if (!pip) {
206
- for (const pkg of packages) {
207
- result.failed.push({ pkg: `pip:${pkg}`, error: "No pip/uv found" });
208
- }
209
- return;
210
- }
211
-
212
- // Filter out those already at the pinned version
213
- const toInstall: string[] = [];
214
- for (const pkg of packages) {
215
- if (await pipDepNeedsInstall(pkg)) {
216
- toInstall.push(pkg);
217
- } else {
218
- result.skipped.push(`pip:${pkg}`);
219
- }
220
- }
221
-
222
- if (toInstall.length === 0) return;
223
-
224
- // Install all at once
225
- const { ok, stderr } = await run(pip.cmd, [...pip.args, ...toInstall]);
226
- if (ok) {
227
- for (const pkg of toInstall) {
228
- result.installed.push(`pip:${pkg}`);
229
- }
230
- } else {
231
- for (const pkg of toInstall) {
232
- result.failed.push({ pkg: `pip:${pkg}`, error: stderr });
233
- }
234
- }
235
- }
236
-
237
- /**
238
- * Install brew packages
239
- */
240
- async function installBrewPackages(
241
- packages: string[],
242
- result: SetupResult,
243
- ): Promise<void> {
244
- const brewPath = await which("brew");
245
- if (!brewPath) {
246
- for (const pkg of packages) {
247
- result.failed.push({
248
- pkg: `brew:${pkg}`,
249
- error: "Homebrew not installed",
250
- });
251
- }
252
- return;
253
- }
254
-
255
- for (const pkg of packages) {
256
- const decision = await brewDepDecision(pkg);
257
- if (decision.action === "skip") {
258
- result.skipped.push(`brew:${pkg}`);
259
- continue;
260
- }
261
- if (decision.action === "blocked") {
262
- result.failed.push({ pkg: `brew:${pkg}`, error: decision.detail });
263
- continue;
264
- }
265
-
266
- const { ok, stderr } = await run(brewPath, ["install", pkg]);
267
- if (ok) {
268
- result.installed.push(`brew:${pkg}`);
269
- } else {
270
- result.failed.push({ pkg: `brew:${pkg}`, error: stderr });
271
- }
272
- }
273
- }
274
-
275
- /**
276
- * Detect the best available Node.js package installer for globals
277
- * Prefers: bun > npm
278
- */
279
- async function detectNpmCommand(): Promise<{
280
- cmd: string;
281
- args: string[];
282
- label: string;
283
- } | null> {
284
- const bunPath = await which("bun");
285
- if (bunPath) {
286
- return { cmd: bunPath, args: ["install", "-g"], label: "bun" };
287
- }
288
- const npmPath = await which("npm");
289
- if (npmPath) {
290
- return { cmd: npmPath, args: ["install", "-g"], label: "npm" };
291
- }
292
- return null;
293
- }
294
-
295
- /**
296
- * Global bin dir for the detected Node installer. Best effort: bun and npm
297
- * report it differently, and null simply disables the reach check rather than
298
- * blocking an install we can't reason about.
299
- */
300
- async function getNpmInstallDir(installer: {
301
- cmd: string;
302
- label: string;
303
- }): Promise<string | null> {
304
- if (installer.label === "bun") {
305
- const { ok, stdout } = await run(installer.cmd, ["pm", "bin", "-g"], 10000);
306
- return ok && stdout ? stdout.split("\n")[0].trim() : null;
307
- }
308
- const { ok, stdout } = await run(installer.cmd, ["prefix", "-g"], 10000);
309
- if (!ok || !stdout) return null;
310
- return path.join(stdout.split("\n")[0].trim(), "bin");
311
- }
312
-
313
- /**
314
- * Install global Node.js packages (prefers bun > npm)
315
- */
316
- async function installNpmPackages(
317
- packages: string[],
318
- result: SetupResult,
319
- ): Promise<void> {
320
- const installer = await detectNpmCommand();
321
- if (!installer) {
322
- for (const pkg of packages) {
323
- result.failed.push({ pkg: `npm:${pkg}`, error: "No bun or npm found" });
324
- }
325
- return;
326
- }
327
-
328
- const installDir = () => getNpmInstallDir(installer);
329
-
330
- for (const pkg of packages) {
331
- // Parsed, not raw: `isBinaryAvailable("foo@1.2.3")` looks for a binary
332
- // literally named "foo@1.2.3", never finds one, and reinstalls forever.
333
- const decision = await binaryDepDecision(parseAtSpec(pkg), installDir);
334
- if (decision.action === "skip") {
335
- result.skipped.push(`npm:${pkg}`);
336
- continue;
337
- }
338
- if (decision.action === "blocked") {
339
- result.failed.push({
340
- pkg: `${installer.label}:${pkg}`,
341
- error: decision.detail,
342
- });
343
- continue;
344
- }
345
-
346
- const { ok, stderr } = await run(installer.cmd, [...installer.args, pkg]);
347
- if (ok) {
348
- result.installed.push(`${installer.label}:${pkg}`);
349
- } else {
350
- result.failed.push({ pkg: `${installer.label}:${pkg}`, error: stderr });
351
- }
352
- }
353
- }
354
-
355
- /**
356
- * Install cargo packages
357
- */
358
- async function installCargoPackages(
359
- packages: string[],
360
- result: SetupResult,
361
- ): Promise<void> {
362
- const cargoPath = await which("cargo");
363
- if (!cargoPath) {
364
- for (const pkg of packages) {
365
- result.failed.push({ pkg: `cargo:${pkg}`, error: "cargo not found" });
366
- }
367
- return;
368
- }
369
-
370
- for (const pkg of packages) {
371
- const parsed = parseAtSpec(pkg);
372
- const decision = await binaryDepDecision(parsed, getCargoInstallDir);
373
- if (decision.action === "skip") {
374
- result.skipped.push(`cargo:${pkg}`);
375
- continue;
376
- }
377
- if (decision.action === "blocked") {
378
- result.failed.push({ pkg: `cargo:${pkg}`, error: decision.detail });
379
- continue;
380
- }
381
-
382
- // `--version` rather than `crate@ver`: the explicit flag works on every
383
- // cargo, the @ form only on recent ones.
384
- const args = parsed.version
385
- ? ["install", parsed.name, "--version", parsed.version]
386
- : ["install", parsed.spec];
387
- const { ok, stderr } = await run(cargoPath, args, 300000);
388
- if (ok) {
389
- result.installed.push(`cargo:${pkg}`);
390
- } else {
391
- result.failed.push({ pkg: `cargo:${pkg}`, error: stderr });
392
- }
393
- }
394
- }
395
-
396
- /**
397
- * {@link parseAtSpec} for a Go module spec.
398
- *
399
- * Same split, but the name comes from {@link goBinaryName}: Go's `/vN`
400
- * major-version suffix is part of the module path, not the binary, so
401
- * "github.com/MadAppGang/tmux-mcp/v2@v2.0.0" installs `tmux-mcp`, not `v2`.
402
- * The generic parser cannot do this — an npm scope may legitimately be `v2`.
403
- */
404
- export function parseGoSpec(spec: string): ParsedSpec {
405
- const parsed = parseAtSpec(spec);
406
- // Same separator rule as parseAtSpec: a leading `@` is never a version.
407
- const at = spec.lastIndexOf("@");
408
- const modulePath = at > 0 ? spec.slice(0, at) : spec;
409
- return { ...parsed, name: goBinaryName(modulePath) };
410
- }
411
-
412
- /**
413
- * Extract binary name from a Go module path.
414
- * The binary name is the last path segment before @version, minus any /vN
415
- * major-version suffix.
416
- * e.g., "github.com/MadAppGang/tmux-mcp@latest" → "tmux-mcp"
417
- * "github.com/MadAppGang/tmux-mcp/v2@v2.0.0" → "tmux-mcp"
418
- * "github.com/user/tool" → "tool"
419
- */
420
- export function extractGoBinaryName(pkg: string): string {
421
- return parseGoSpec(pkg).name;
422
- }
423
-
424
- /**
425
- * Extract the pinned version from a Go module path, or null when unpinned.
426
- * e.g., "github.com/MadAppGang/tmux-mcp@v1.6.2" → "v1.6.2"
427
- * "github.com/MadAppGang/tmux-mcp@latest" → null (a moving target)
428
- * "github.com/user/tool" → null
429
- */
430
- export function extractGoVersion(pkg: string): string | null {
431
- return parseGoSpec(pkg).version;
432
- }
433
-
434
- /** Normalize a semver-ish string for comparison: trim and drop a leading "v". */
435
- function normalizeVersion(v: string): string {
436
- return v.trim().replace(/^v/, "");
437
- }
438
-
439
- /**
440
- * Read a binary's version via `<path> --version`, normalized, or null when it
441
- * produced no usable version string.
442
- *
443
- * Takes a RESOLVED path, never a bare name. Probing by name would re-run PATH
444
- * resolution and can read a different binary than the one whose presence was
445
- * checked — which is how a version check ends up passing judgement on one file
446
- * while `go install` fixes another.
447
- */
448
- async function getInstalledBinaryVersion(
449
- binaryPath: string,
450
- ): Promise<string | null> {
451
- const { ok, stdout } = await run(binaryPath, ["--version"], 10000);
452
- if (!ok || !stdout) return null;
453
- const match = stdout.split("\n")[0].match(/v?\d+\.\d+\.\d+[^\s]*/);
454
- return match ? normalizeVersion(match[0]) : null;
455
- }
456
-
457
- /** PATH split into ordered, non-empty entries. */
458
- function pathEntries(): string[] {
459
- return (process.env.PATH || "").split(path.delimiter).filter(Boolean);
460
- }
461
-
462
- /**
463
- * Parse `go env -json GOBIN GOPATH`.
464
- *
465
- * JSON rather than the line-oriented form deliberately: `go env GOBIN GOPATH`
466
- * prints an empty FIRST line when GOBIN is unset, so any trimming of the output
467
- * (which {@link run} does) silently promotes GOPATH into GOBIN's slot and points
468
- * the install dir one level too high.
469
- */
470
- export function parseGoEnv(stdout: string): { gobin: string; gopath: string } {
471
- try {
472
- const parsed = JSON.parse(stdout) as Record<string, unknown>;
473
- const str = (v: unknown) => (typeof v === "string" ? v.trim() : "");
474
- return { gobin: str(parsed.GOBIN), gopath: str(parsed.GOPATH) };
475
- } catch {
476
- return { gobin: "", gopath: "" };
477
- }
478
- }
479
-
480
- /**
481
- * The directory `go install` writes into: GOBIN when set, else GOPATH/bin.
482
- * Null when go can't be asked — callers must then draw no conclusions about
483
- * where an install would land.
484
- */
485
- async function getGoInstallDir(): Promise<string | null> {
486
- const goPath = await which("go");
487
- if (!goPath) return null;
488
- const { ok, stdout } = await run(
489
- goPath,
490
- ["env", "-json", "GOBIN", "GOPATH"],
491
- 10000,
492
- );
493
- if (!ok) return null;
494
- const { gobin, gopath } = parseGoEnv(stdout);
495
- if (gobin) return gobin;
496
- if (gopath) return path.join(gopath, "bin");
497
- return null;
498
- }
499
-
500
- /** Directories in `entries` (PATH order) holding an executable `binaryName`. */
501
- async function findOccurrenceDirs(
502
- binaryName: string,
503
- entries: string[],
504
- ): Promise<string[]> {
505
- const dirs: string[] = [];
506
- for (const entry of entries) {
507
- try {
508
- await access(path.join(entry, binaryName), fsConstants.X_OK);
509
- dirs.push(entry);
510
- } catch {
511
- // absent here, or not executable
512
- }
513
- }
514
- return dirs;
515
- }
516
-
517
- export type InstallReach =
518
- | { reach: "wins" }
519
- | { reach: "shadowed"; byDir: string }
520
- | { reach: "off-path" };
521
-
522
- /**
523
- * Whether writing a binary into `installDir` would change what PATH resolves —
524
- * the question `go install` on its own cannot answer.
525
- *
526
- * Walks PATH in order; the first entry that either IS the install dir or
527
- * already holds the binary decides. Running off the end without meeting the
528
- * install dir means a binary placed there can never be executed.
529
- *
530
- * Pure by design: the caller supplies `occurrenceDirs` and `installDir`, so the
531
- * ordering rules are testable without touching a filesystem or a PATH.
532
- */
533
- export function classifyInstallReach(
534
- entries: string[],
535
- occurrenceDirs: string[],
536
- installDir: string,
537
- ): InstallReach {
538
- const target = path.resolve(installDir);
539
- const occupied = new Set(occurrenceDirs.map((d) => path.resolve(d)));
540
-
541
- for (const entry of entries) {
542
- const dir = path.resolve(entry);
543
- if (dir === target) return { reach: "wins" };
544
- if (occupied.has(dir)) return { reach: "shadowed", byDir: entry };
545
- }
546
- return { reach: "off-path" };
547
- }
548
-
549
- export type GoDepDecision =
550
- | {
551
- action: "install";
552
- reason: "absent" | "version-mismatch" | "version-unreadable";
553
- }
554
- | { action: "skip"; reason: "up-to-date" | "unpinned-present" }
555
- | {
556
- action: "blocked";
557
- reason: "shadowed" | "install-dir-off-path";
558
- detail: string;
559
- };
560
-
561
- /**
562
- * Why an install into the go bin dir would not take effect, or null when it
563
- * would — or when we cannot tell, in which case we don't stand in the way.
564
- */
565
- async function findInstallBlocker(
566
- binaryName: string,
567
- installedVersion: string | null,
568
- getInstallDir: () => Promise<string | null>,
569
- ): Promise<GoDepDecision | null> {
570
- const installDir = await getInstallDir();
571
- if (!installDir) return null;
572
-
573
- const entries = pathEntries();
574
- const reach = classifyInstallReach(
575
- entries,
576
- await findOccurrenceDirs(binaryName, entries),
577
- installDir,
578
- );
579
-
580
- if (reach.reach === "wins") return null;
581
-
582
- if (reach.reach === "off-path") {
583
- return {
584
- action: "blocked",
585
- reason: "install-dir-off-path",
586
- detail: [
587
- `go install writes ${binaryName} to ${installDir}, which is not in`,
588
- "PATH, so the installed binary would never run.",
589
- `Add ${installDir} to PATH.`,
590
- ].join(" "),
591
- };
592
- }
593
-
594
- const shadowPath = path.join(reach.byDir, binaryName);
595
- const reports =
596
- installedVersion === null
597
- ? "which cannot report a version"
598
- : `which reports ${installedVersion}`;
599
- return {
600
- action: "blocked",
601
- reason: "shadowed",
602
- detail: [
603
- `go install would write ${binaryName} to ${installDir}, but PATH resolves`,
604
- `${binaryName} to ${shadowPath} first (${reports}), so installing cannot`,
605
- `change what runs. Remove ${shadowPath}, or move ${installDir} ahead of`,
606
- `${reach.byDir} in PATH.`,
607
- ].join(" "),
608
- };
609
- }
610
-
611
- /**
612
- * Decide what to do about a Go dependency. This is what makes a plugin *update*
613
- * actually update its binary: without a version check an already-present binary
614
- * is skipped forever, so a bumped plugin never pulls the new one.
615
- *
616
- * The binary is resolved ONCE and that exact path is probed. Resolving per step
617
- * is how a version check judges one file while `go install` fixes another, then
618
- * repeats forever — reporting success every time.
619
- *
620
- * - binary absent → install
621
- * - pinned version, resolved binary reports the same → skip
622
- * - pinned version, resolved binary reports another → install
623
- * - pinned version, version can't be read → install (can't
624
- * confirm the pin, so reinstall to be safe)
625
- * - unpinned (@latest) and present → skip (a moving target
626
- * can't be verified without a network round-trip)
627
- * - an install PATH would ignore → blocked, naming the
628
- * binary that shadows it
629
- */
630
- export async function binaryDepDecision(
631
- parsed: ParsedSpec,
632
- getInstallDir: () => Promise<string | null>,
633
- ): Promise<GoDepDecision> {
634
- const { name, version: pinned } = parsed;
635
- const resolved = await which(name);
636
-
637
- if (!resolved) {
638
- return (
639
- (await findInstallBlocker(name, null, getInstallDir)) ?? {
640
- action: "install",
641
- reason: "absent",
642
- }
643
- );
644
- }
645
-
646
- if (!pinned) return { action: "skip", reason: "unpinned-present" };
647
-
648
- const installed = await getInstalledBinaryVersion(resolved);
649
- if (installed !== null && installed === normalizeVersion(pinned)) {
650
- return { action: "skip", reason: "up-to-date" };
651
- }
652
-
653
- return (
654
- (await findInstallBlocker(name, installed, getInstallDir)) ?? {
655
- action: "install",
656
- reason: installed === null ? "version-unreadable" : "version-mismatch",
657
- }
658
- );
659
- }
660
-
661
- /** {@link binaryDepDecision} for a Go module path. */
662
- export async function goDepDecision(pkg: string): Promise<GoDepDecision> {
663
- return binaryDepDecision(parseGoSpec(pkg), getGoInstallDir);
664
- }
665
-
666
- /**
667
- * Where `cargo install` puts binaries: CARGO_INSTALL_ROOT, else CARGO_HOME/bin,
668
- * else ~/.cargo/bin.
669
- */
670
- async function getCargoInstallDir(): Promise<string | null> {
671
- const root = process.env.CARGO_INSTALL_ROOT;
672
- if (root) return path.join(root, "bin");
673
- const home = process.env.CARGO_HOME;
674
- if (home) return path.join(home, "bin");
675
- return path.join(os.homedir(), ".cargo", "bin");
676
- }
677
-
678
- /** Where brew links binaries: `brew --prefix`/bin. */
679
- async function getBrewInstallDir(): Promise<string | null> {
680
- const brewPath = await which("brew");
681
- if (!brewPath) return null;
682
- const { ok, stdout } = await run(brewPath, ["--prefix"], 10000);
683
- if (!ok || !stdout) return null;
684
- return path.join(stdout.split("\n")[0].trim(), "bin");
685
- }
686
-
687
- /** {@link binaryDepDecision} for a cargo crate. */
688
- export async function cargoDepDecision(pkg: string): Promise<GoDepDecision> {
689
- return binaryDepDecision(parseAtSpec(pkg), getCargoInstallDir);
690
- }
691
-
692
- /**
693
- * Decision for a brew formula. Brew has no exact-pin syntax, so `version` is
694
- * always null and this reduces to presence plus a reach check — which is the
695
- * case that actually bit us: a Homebrew copy sitting behind another PATH entry.
696
- */
697
- export async function brewDepDecision(pkg: string): Promise<GoDepDecision> {
698
- const name = extractBinaryName(pkg);
699
- return binaryDepDecision(
700
- { spec: pkg, name, version: null },
701
- getBrewInstallDir,
702
- );
703
- }
704
-
705
- /**
706
- * Boolean view of {@link goDepDecision}: does this dependency still need
707
- * attention? A blocked dep counts — it is not satisfied, and the install path
708
- * reports why rather than silently succeeding.
709
- */
710
- export async function goDepNeedsInstall(pkg: string): Promise<boolean> {
711
- return (await goDepDecision(pkg)).action !== "skip";
712
- }
713
-
714
- /**
715
- * Install Go packages via `go install`
716
- */
717
- async function installGoPackages(
718
- packages: string[],
719
- result: SetupResult,
720
- ): Promise<void> {
721
- const goPath = await which("go");
722
- if (!goPath) {
723
- for (const pkg of packages) {
724
- result.failed.push({ pkg: `go:${pkg}`, error: "go not found" });
725
- }
726
- return;
727
- }
728
-
729
- for (const pkg of packages) {
730
- const decision = await goDepDecision(pkg);
731
-
732
- if (decision.action === "skip") {
733
- result.skipped.push(`go:${pkg}`);
734
- continue;
735
- }
736
-
737
- // Installing would leave what actually runs untouched. Reporting success
738
- // here is what turned a broken PATH into an endless reinstall loop that
739
- // looked healthy every round, so say what's wrong instead.
740
- if (decision.action === "blocked") {
741
- result.failed.push({ pkg: `go:${pkg}`, error: decision.detail });
742
- continue;
743
- }
744
-
745
- const { ok, stderr } = await run(goPath, ["install", pkg], 300000);
746
- if (ok) {
747
- result.installed.push(`go:${pkg}`);
748
- } else {
749
- result.failed.push({ pkg: `go:${pkg}`, error: stderr });
750
- }
751
- }
752
- }
753
-
754
- /**
755
- * Check required system binaries and report missing ones.
756
- * These are NOT installed by magus — the user must install them manually.
757
- */
758
- async function checkRequiredBinaries(
759
- binaries: string[],
760
- result: SetupResult,
761
- ): Promise<void> {
762
- for (const name of binaries) {
763
- if (await isBinaryAvailable(name)) {
764
- result.skipped.push(`required:${name}`);
765
- } else {
766
- result.failed.push({
767
- pkg: `required:${name}`,
768
- error: `Required binary '${name}' not found in PATH. Please install it manually.`,
769
- });
770
- }
771
- }
772
- }
773
-
774
- /**
775
- * Read the setup config from a plugin's cached manifest
776
- */
777
- export async function getPluginSetupConfig(
778
- marketplace: string,
779
- pluginName: string,
780
- ): Promise<SetupConfig | null> {
781
- const cacheDir = path.join(
782
- os.homedir(),
783
- ".claude",
784
- "plugins",
785
- "cache",
786
- marketplace,
787
- );
788
-
789
- // Find the plugin directory (versioned)
790
- if (!(await fs.pathExists(cacheDir))) return null;
791
-
792
- const entries = await fs.readdir(cacheDir);
793
- // Look for the plugin name directory
794
- const pluginDir = entries.find((e) => e === pluginName);
795
- if (!pluginDir) return null;
796
-
797
- const pluginPath = path.join(cacheDir, pluginDir);
798
- const stat = await fs.stat(pluginPath);
799
- if (!stat.isDirectory()) return null;
800
-
801
- // Find the version directory
802
- const versions = await fs.readdir(pluginPath);
803
-
804
- // Use the latest version directory
805
- const latestVersion = versions.sort().pop();
806
- if (latestVersion === undefined) return null;
807
- const manifestPath = path.join(pluginPath, latestVersion, "plugin.json");
808
-
809
- if (!(await fs.pathExists(manifestPath))) return null;
810
-
811
- try {
812
- const manifest = await fs.readJson(manifestPath);
813
- return manifest.setup || null;
814
- } catch {
815
- return null;
816
- }
817
- }
818
-
819
- /**
820
- * Read setup config from a local plugin directory (marketplace source)
821
- */
822
- export async function getPluginSetupFromSource(
823
- marketplace: string,
824
- pluginName: string,
825
- ): Promise<SetupConfig | null> {
826
- const marketplaceDir = path.join(
827
- os.homedir(),
828
- ".claude",
829
- "plugins",
830
- "marketplaces",
831
- marketplace,
832
- );
833
-
834
- if (!(await fs.pathExists(marketplaceDir))) return null;
835
-
836
- // Try common plugin locations
837
- for (const subdir of ["plugins", ""]) {
838
- const pluginJson = subdir
839
- ? path.join(marketplaceDir, subdir, pluginName, "plugin.json")
840
- : path.join(marketplaceDir, pluginName, "plugin.json");
841
-
842
- if (await fs.pathExists(pluginJson)) {
843
- try {
844
- const manifest = await fs.readJson(pluginJson);
845
- return manifest.setup || null;
846
- } catch {}
847
- }
848
- }
849
-
850
- return null;
851
- }
852
-
853
- /**
854
- * Install all dependencies declared in a plugin's setup config.
855
- * Auto-detects available package managers.
856
- */
857
- export async function installPluginDeps(
858
- setup: SetupConfig,
859
- ): Promise<SetupResult> {
860
- const result: SetupResult = {
861
- installed: [],
862
- skipped: [],
863
- failed: [],
864
- };
865
-
866
- if (setup.pip?.length) {
867
- await installPipPackages(setup.pip, result);
868
- }
869
- if (setup.brew?.length) {
870
- await installBrewPackages(setup.brew, result);
871
- }
872
- if (setup.npm?.length) {
873
- await installNpmPackages(setup.npm, result);
874
- }
875
- if (setup.cargo?.length) {
876
- await installCargoPackages(setup.cargo, result);
877
- }
878
- if (setup.go?.length) {
879
- await installGoPackages(setup.go, result);
880
- }
881
- if (setup.required?.length) {
882
- await checkRequiredBinaries(setup.required, result);
883
- }
884
-
885
- return result;
886
- }
887
-
888
- /**
889
- * Check which dependencies from a setup config are missing.
890
- * Returns a filtered SetupConfig with only missing deps.
891
- */
892
- export async function checkMissingDeps(
893
- setup: SetupConfig,
894
- ): Promise<SetupConfig> {
895
- const missing: SetupConfig = {};
896
-
897
- // Each of these mirrors its installer's decision function. When the two
898
- // disagree the UI reports one thing and the install does another.
899
- if (setup.pip?.length) {
900
- const missingPip: string[] = [];
901
- for (const pkg of setup.pip) {
902
- if (await pipDepNeedsInstall(pkg)) {
903
- missingPip.push(pkg);
904
- }
905
- }
906
- if (missingPip.length > 0) missing.pip = missingPip;
907
- }
908
-
909
- if (setup.brew?.length) {
910
- const missingBrew: string[] = [];
911
- for (const pkg of setup.brew) {
912
- if ((await brewDepDecision(pkg)).action !== "skip") {
913
- missingBrew.push(pkg);
914
- }
915
- }
916
- if (missingBrew.length > 0) missing.brew = missingBrew;
917
- }
918
-
919
- if (setup.npm?.length) {
920
- const installer = await detectNpmCommand();
921
- const installDir = installer
922
- ? () => getNpmInstallDir(installer)
923
- : async () => null;
924
- const missingNpm: string[] = [];
925
- for (const pkg of setup.npm) {
926
- const decision = await binaryDepDecision(parseAtSpec(pkg), installDir);
927
- if (decision.action !== "skip") {
928
- missingNpm.push(pkg);
929
- }
930
- }
931
- if (missingNpm.length > 0) missing.npm = missingNpm;
932
- }
933
-
934
- if (setup.cargo?.length) {
935
- const missingCargo: string[] = [];
936
- for (const pkg of setup.cargo) {
937
- if ((await cargoDepDecision(pkg)).action !== "skip") {
938
- missingCargo.push(pkg);
939
- }
940
- }
941
- if (missingCargo.length > 0) missing.cargo = missingCargo;
942
- }
943
-
944
- if (setup.go?.length) {
945
- const missingGo: string[] = [];
946
- for (const pkg of setup.go) {
947
- if (await goDepNeedsInstall(pkg)) {
948
- missingGo.push(pkg);
949
- }
950
- }
951
- if (missingGo.length > 0) missing.go = missingGo;
952
- }
953
-
954
- if (setup.required?.length) {
955
- const missingRequired: string[] = [];
956
- for (const name of setup.required) {
957
- if (!(await isBinaryAvailable(name))) {
958
- missingRequired.push(name);
959
- }
960
- }
961
- if (missingRequired.length > 0) missing.required = missingRequired;
962
- }
963
-
964
- return missing;
965
- }