magus-cli 0.0.0-placeholder → 7.1.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 +309 -0
- package/bin/magus.js +170 -0
- package/package.json +67 -3
- package/scripts/build-binaries.ts +113 -0
- package/scripts/capture-builtin.ts +412 -0
- package/scripts/check-optional-deps.ts +49 -0
- package/scripts/test-isolated.ts +49 -0
- package/scripts/verify-binary.ts +95 -0
- package/scripts/verify-community-registry.ts +272 -0
- package/src/__tests__/active-closure.test.ts +184 -0
- package/src/__tests__/alias-adopt.test.ts +365 -0
- package/src/__tests__/alias-parser.test.ts +417 -0
- package/src/__tests__/alias-shell-writer.test.ts +681 -0
- package/src/__tests__/alias-store.test.ts +163 -0
- package/src/__tests__/binary-signing.test.ts +120 -0
- package/src/__tests__/blackbox-active-closure.test.ts +119 -0
- package/src/__tests__/blackbox-convergence.test.ts +334 -0
- package/src/__tests__/blackbox-parity.test.ts +725 -0
- package/src/__tests__/blackbox-plugin-state.test.ts +545 -0
- package/src/__tests__/blackbox-tool-state.test.ts +669 -0
- package/src/__tests__/cache-provenance.test.ts +84 -0
- package/src/__tests__/capture-builtin.test.ts +92 -0
- package/src/__tests__/catalog-cache-store.test.ts +309 -0
- package/src/__tests__/catalog-notice.test.ts +155 -0
- package/src/__tests__/cli-ansi.test.ts +297 -0
- package/src/__tests__/cli-apply-seams.test.ts +288 -0
- package/src/__tests__/cli-live.test.ts +391 -0
- package/src/__tests__/cli-router.test.ts +240 -0
- package/src/__tests__/cli-tool-commands.test.ts +239 -0
- package/src/__tests__/cli-tools-adapter.test.ts +294 -0
- package/src/__tests__/cli-update-view.test.ts +524 -0
- package/src/__tests__/community-fetch.test.ts +545 -0
- package/src/__tests__/community-registry.test.ts +269 -0
- package/src/__tests__/community-staleness.test.ts +720 -0
- package/src/__tests__/component-badge.test.ts +98 -0
- package/src/__tests__/component-icons.test.ts +94 -0
- package/src/__tests__/content-drift.test.ts +353 -0
- package/src/__tests__/conventions-integration.test.ts +830 -0
- package/src/__tests__/conventions-manager.test.ts +1236 -0
- package/src/__tests__/doctor-bins.test.ts +43 -0
- package/src/__tests__/doctor.test.ts +463 -0
- package/src/__tests__/dotenv.test.ts +195 -0
- package/src/__tests__/enabled-not-installed.test.ts +94 -0
- package/src/__tests__/file-locking.test.ts +208 -0
- package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
- package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
- package/src/__tests__/footer-hints.test.ts +40 -0
- package/src/__tests__/format-command.test.ts +78 -0
- package/src/__tests__/git-worktree.test.ts +108 -0
- package/src/__tests__/github-budget.test.ts +206 -0
- package/src/__tests__/gitignore-check.test.ts +71 -0
- package/src/__tests__/gitignore-detector.test.ts +176 -0
- package/src/__tests__/gitignore-fixer.test.ts +317 -0
- package/src/__tests__/gitignore-resolver.test.ts +193 -0
- package/src/__tests__/gitignore-service.test.ts +137 -0
- package/src/__tests__/go-module.test.ts +43 -0
- package/src/__tests__/hook-import-policy.test.ts +90 -0
- package/src/__tests__/hook-process.test.ts +256 -0
- package/src/__tests__/hook-registration.test.ts +224 -0
- package/src/__tests__/install-command.test.ts +81 -0
- package/src/__tests__/install-plan.test.ts +104 -0
- package/src/__tests__/launcher-signal.test.ts +308 -0
- package/src/__tests__/local-marketplace-scan.test.ts +248 -0
- package/src/__tests__/manifest.test.ts +281 -0
- package/src/__tests__/markdown-renderer.test.ts +142 -0
- package/src/__tests__/marketplace-badge.test.ts +140 -0
- package/src/__tests__/marketplace-refresh.test.ts +340 -0
- package/src/__tests__/marketplace-version.test.ts +84 -0
- package/src/__tests__/marketplaces.test.ts +128 -0
- package/src/__tests__/mate-availability.test.ts +156 -0
- package/src/__tests__/mate-catalog.test.ts +295 -0
- package/src/__tests__/mcp-registry.test.ts +158 -0
- package/src/__tests__/model-visuals.test.tsx +2466 -0
- package/src/__tests__/models-adapter.test.ts +332 -0
- package/src/__tests__/models-cli.test.ts +273 -0
- package/src/__tests__/models-core.test.ts +802 -0
- package/src/__tests__/models-manager.test.ts +500 -0
- package/src/__tests__/models-presets-marketplace.test.ts +168 -0
- package/src/__tests__/models-screen-state.test.ts +315 -0
- package/src/__tests__/open-file.test.ts +59 -0
- package/src/__tests__/owned-marketplaces.test.ts +93 -0
- package/src/__tests__/plugin-checkout.test.ts +66 -0
- package/src/__tests__/plugin-cli-argv.test.ts +109 -0
- package/src/__tests__/plugin-contents.test.ts +371 -0
- package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
- package/src/__tests__/plugin-releases.test.ts +98 -0
- package/src/__tests__/plugin-requires.test.ts +187 -0
- package/src/__tests__/plugin-row-text.test.ts +345 -0
- package/src/__tests__/plugin-setup.test.ts +1016 -0
- package/src/__tests__/profile-adopt.test.ts +177 -0
- package/src/__tests__/profile-command.test.ts +110 -0
- package/src/__tests__/profile-materializer.test.ts +238 -0
- package/src/__tests__/profile-sync.test.ts +154 -0
- package/src/__tests__/registry-version-resolution.test.ts +88 -0
- package/src/__tests__/resolve-executable.test.ts +42 -0
- package/src/__tests__/resolver.test.ts +351 -0
- package/src/__tests__/scope-action.test.ts +134 -0
- package/src/__tests__/scope-squares.test.tsx +164 -0
- package/src/__tests__/selection-scope.test.ts +100 -0
- package/src/__tests__/settings-file.test.ts +179 -0
- package/src/__tests__/shell-script-callers.test.ts +92 -0
- package/src/__tests__/skill-install-files.test.ts +136 -0
- package/src/__tests__/skills-catalog-client.test.ts +48 -0
- package/src/__tests__/style-wrap.test.ts +220 -0
- package/src/__tests__/styles-manager.test.ts +1098 -0
- package/src/__tests__/styles-origins.test.ts +414 -0
- package/src/__tests__/styles-screen-state.test.ts +457 -0
- package/src/__tests__/styles-status-line.test.ts +72 -0
- package/src/__tests__/styles-sync.test.ts +452 -0
- package/src/__tests__/symlink-manager.test.ts +180 -0
- package/src/__tests__/tabbar-layout.test.ts +100 -0
- package/src/__tests__/terminology-filler.test.ts +214 -0
- package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
- package/src/__tests__/theme-env.test.ts +310 -0
- package/src/__tests__/theme-resolve.test.ts +380 -0
- package/src/__tests__/tool-probe.test.ts +1400 -0
- package/src/__tests__/toolchain.test.ts +232 -0
- package/src/__tests__/ui-version-writers.test.ts +88 -0
- package/src/__tests__/update-apply.test.ts +637 -0
- package/src/__tests__/update-plan.test.ts +1396 -0
- package/src/__tests__/uppercase-keybindings.test.ts +101 -0
- package/src/__tests__/useGitignoreModal.test.ts +79 -0
- package/src/__tests__/version-check-refresh.test.ts +45 -0
- package/src/__tests__/version-snapshot.test.ts +212 -0
- package/src/__tests__/version-unknown-state.test.ts +403 -0
- package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
- package/src/cli/ansi.ts +528 -0
- package/src/cli/bootstrap.ts +134 -0
- package/src/cli/doctor.ts +218 -0
- package/src/cli/hook.ts +129 -0
- package/src/cli/install.ts +382 -0
- package/src/cli/live.ts +374 -0
- package/src/cli/models.ts +297 -0
- package/src/cli/profile.ts +197 -0
- package/src/cli/prompt.ts +32 -0
- package/src/cli/router.ts +264 -0
- package/src/cli/update-view.ts +717 -0
- package/src/cli/update.ts +600 -0
- package/src/cli/upgrade.ts +97 -0
- package/src/data/alias-flags.ts +301 -0
- package/src/data/cli-tools.ts +146 -0
- package/src/data/community-styles.ts +531 -0
- package/src/data/gitignore-defaults.ts +33 -0
- package/src/data/gitignore-reasons.ts +93 -0
- package/src/data/gitignore-templates.ts +25 -0
- package/src/data/marketplaces.ts +278 -0
- package/src/data/mcp-servers.ts +51 -0
- package/src/data/models-presets.ts +329 -0
- package/src/data/predefined-profiles.ts +252 -0
- package/src/data/settings-catalog.ts +714 -0
- package/src/data/skill-repos.ts +91 -0
- package/src/data/styles/asd-ste100.md +38 -0
- package/src/data/styles/calibrated.md +24 -0
- package/src/data/styles/direct.md +23 -0
- package/src/data/styles/evidence-first.md +22 -0
- package/src/data/styles/explanatory.md +23 -0
- package/src/data/styles/index.ts +57 -0
- package/src/data/styles/no-slop.md +32 -0
- package/src/data/styles/plain-language.md +23 -0
- package/src/data/styles/structured.md +30 -0
- package/src/data/styles/terminology.md +28 -0
- package/src/data/styles/terse.md +20 -0
- package/src/main.tsx +110 -0
- package/src/markdown.d.ts +13 -0
- package/src/opentui.d.ts +203 -0
- package/src/services/active-closure.ts +117 -0
- package/src/services/alias-settings.ts +60 -0
- package/src/services/alias-shell-writer.ts +1244 -0
- package/src/services/alias-store.ts +172 -0
- package/src/services/binary-signing.ts +78 -0
- package/src/services/catalog-cache-store.ts +316 -0
- package/src/services/claude-cli.ts +304 -0
- package/src/services/claude-runner.ts +31 -0
- package/src/services/claude-settings.ts +1564 -0
- package/src/services/cli-tool-commands.ts +133 -0
- package/src/services/community-fetcher.ts +90 -0
- package/src/services/community-styles.ts +1194 -0
- package/src/services/content-drift.ts +208 -0
- package/src/services/conventions-manager.ts +945 -0
- package/src/services/doctor-bins.ts +63 -0
- package/src/services/doctor.ts +570 -0
- package/src/services/dotenv.ts +181 -0
- package/src/services/git-worktree.ts +129 -0
- package/src/services/github-budget.ts +274 -0
- package/src/services/gitignore-check.ts +63 -0
- package/src/services/gitignore-detector.ts +167 -0
- package/src/services/gitignore-fixer.ts +280 -0
- package/src/services/gitignore-resolver.ts +208 -0
- package/src/services/gitignore-service.ts +167 -0
- package/src/services/hook-registration.ts +218 -0
- package/src/services/install-plan.ts +107 -0
- package/src/services/local-marketplace.ts +657 -0
- package/src/services/manifest.ts +250 -0
- package/src/services/marketplace-catalog-git.ts +173 -0
- package/src/services/marketplace-catalog.ts +129 -0
- package/src/services/marketplace-fetcher.ts +400 -0
- package/src/services/marketplace-refresh.ts +245 -0
- package/src/services/marketplace-sync.ts +145 -0
- package/src/services/mate-availability.ts +133 -0
- package/src/services/mate-catalog.ts +265 -0
- package/src/services/mcp-registry.ts +171 -0
- package/src/services/models-core.ts +969 -0
- package/src/services/models-manager.ts +606 -0
- package/src/services/plugin-checkout.ts +186 -0
- package/src/services/plugin-manager.ts +847 -0
- package/src/services/plugin-mcp-config.ts +242 -0
- package/src/services/plugin-releases.ts +47 -0
- package/src/services/plugin-requires.ts +227 -0
- package/src/services/plugin-setup.ts +965 -0
- package/src/services/profile-adopt.ts +206 -0
- package/src/services/profile-materializer.ts +133 -0
- package/src/services/profile-sync.ts +158 -0
- package/src/services/profiles.ts +225 -0
- package/src/services/resolver.ts +377 -0
- package/src/services/settings-file.ts +69 -0
- package/src/services/settings-manager.ts +307 -0
- package/src/services/skills-manager.ts +1111 -0
- package/src/services/skillsmp-client.ts +193 -0
- package/src/services/styles-manager.ts +1294 -0
- package/src/services/symlink-manager.ts +189 -0
- package/src/services/terminology-filler.ts +266 -0
- package/src/services/tool-probe.ts +1637 -0
- package/src/services/toolchain.ts +431 -0
- package/src/services/update-engine.ts +691 -0
- package/src/services/update-plan.ts +820 -0
- package/src/services/version-check.ts +127 -0
- package/src/services/version-snapshot.ts +189 -0
- package/src/tui.tsx +112 -0
- package/src/types/bun.d.ts +27 -0
- package/src/types/gitignore.ts +68 -0
- package/src/types/index.ts +447 -0
- package/src/ui/App.tsx +535 -0
- package/src/ui/adapters/catalogNotice.ts +122 -0
- package/src/ui/adapters/cliToolsAdapter.ts +176 -0
- package/src/ui/adapters/modelsAdapter.ts +213 -0
- package/src/ui/adapters/pluginsAdapter.ts +413 -0
- package/src/ui/adapters/settingsAdapter.ts +168 -0
- package/src/ui/adapters/skillsAdapter.ts +229 -0
- package/src/ui/adapters/stylesAdapter.ts +403 -0
- package/src/ui/components/CategoryHeader.tsx +64 -0
- package/src/ui/components/EmptyFilterState.tsx +32 -0
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScopeIndicator.tsx +63 -0
- package/src/ui/components/ScrollableList.tsx +95 -0
- package/src/ui/components/SearchInput.tsx +57 -0
- package/src/ui/components/StyledText.tsx +75 -0
- package/src/ui/components/TabBar.tsx +101 -0
- package/src/ui/components/layout/FooterHints.tsx +78 -0
- package/src/ui/components/layout/Panel.tsx +63 -0
- package/src/ui/components/layout/ProgressBar.tsx +44 -0
- package/src/ui/components/layout/ScopeTabs.tsx +54 -0
- package/src/ui/components/layout/ScreenLayout.tsx +321 -0
- package/src/ui/components/layout/index.ts +9 -0
- package/src/ui/components/modals/ConfirmModal.tsx +62 -0
- package/src/ui/components/modals/InputModal.tsx +75 -0
- package/src/ui/components/modals/LoadingModal.tsx +51 -0
- package/src/ui/components/modals/MessageModal.tsx +66 -0
- package/src/ui/components/modals/ModalContainer.tsx +159 -0
- package/src/ui/components/modals/SelectModal.tsx +90 -0
- package/src/ui/components/modals/index.ts +6 -0
- package/src/ui/components/primitives/ActionHints.tsx +44 -0
- package/src/ui/components/primitives/DetailSection.tsx +23 -0
- package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
- package/src/ui/components/primitives/MetaText.tsx +40 -0
- package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
- package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
- package/src/ui/components/primitives/SelectableRow.tsx +30 -0
- package/src/ui/components/primitives/index.ts +9 -0
- package/src/ui/hooks/index.ts +2 -0
- package/src/ui/hooks/useGitignoreModal.ts +95 -0
- package/src/ui/hooks/useKeyboard.ts +24 -0
- package/src/ui/hooks/useKeyboardHandler.ts +63 -0
- package/src/ui/registry.ts +33 -0
- package/src/ui/renderers/cliToolRenderers.tsx +326 -0
- package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
- package/src/ui/renderers/markdownRenderer.tsx +302 -0
- package/src/ui/renderers/mcpRenderers.tsx +149 -0
- package/src/ui/renderers/modelRenderers.tsx +1309 -0
- package/src/ui/renderers/modelVisuals.tsx +1402 -0
- package/src/ui/renderers/pluginRenderers.tsx +1089 -0
- package/src/ui/renderers/pluginRowText.ts +70 -0
- package/src/ui/renderers/pluginStatusText.ts +56 -0
- package/src/ui/renderers/profileRenderers.tsx +406 -0
- package/src/ui/renderers/settingsRenderers.tsx +218 -0
- package/src/ui/renderers/skillRenderers.tsx +479 -0
- package/src/ui/renderers/styleRenderers.tsx +813 -0
- package/src/ui/screens/AliasScreen.tsx +1558 -0
- package/src/ui/screens/CliToolsScreen.tsx +367 -0
- package/src/ui/screens/EnvVarsScreen.tsx +221 -0
- package/src/ui/screens/GitignoreScreen.tsx +562 -0
- package/src/ui/screens/McpRegistryScreen.tsx +366 -0
- package/src/ui/screens/McpScreen.tsx +256 -0
- package/src/ui/screens/ModelsScreen.tsx +542 -0
- package/src/ui/screens/PluginsScreen.tsx +1423 -0
- package/src/ui/screens/ProfilesScreen.tsx +438 -0
- package/src/ui/screens/SkillsScreen.tsx +853 -0
- package/src/ui/screens/StylesScreen.tsx +1064 -0
- package/src/ui/screens/index.ts +11 -0
- package/src/ui/state/AppContext.tsx +233 -0
- package/src/ui/state/DimensionsContext.tsx +154 -0
- package/src/ui/state/reducer.ts +810 -0
- package/src/ui/state/types.ts +503 -0
- package/src/ui/theme-mode.ts +440 -0
- package/src/ui/theme-resolve.ts +147 -0
- package/src/ui/theme.ts +175 -0
- package/src/utils/clipboard.ts +58 -0
- package/src/utils/command-utils.ts +149 -0
- package/src/utils/config-dir.ts +45 -0
- package/src/utils/file-locking.ts +146 -0
- package/src/utils/fuzzy-search.ts +138 -0
- package/src/utils/go-module.ts +36 -0
- package/src/utils/open-file.ts +84 -0
- package/src/utils/run.ts +67 -0
- package/src/utils/string-utils.ts +88 -0
|
@@ -0,0 +1,965 @@
|
|
|
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
|
+
}
|