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.
Files changed (330) hide show
  1. package/README.md +309 -0
  2. package/bin/magus.js +170 -0
  3. package/package.json +67 -3
  4. package/scripts/build-binaries.ts +113 -0
  5. package/scripts/capture-builtin.ts +412 -0
  6. package/scripts/check-optional-deps.ts +49 -0
  7. package/scripts/test-isolated.ts +49 -0
  8. package/scripts/verify-binary.ts +95 -0
  9. package/scripts/verify-community-registry.ts +272 -0
  10. package/src/__tests__/active-closure.test.ts +184 -0
  11. package/src/__tests__/alias-adopt.test.ts +365 -0
  12. package/src/__tests__/alias-parser.test.ts +417 -0
  13. package/src/__tests__/alias-shell-writer.test.ts +681 -0
  14. package/src/__tests__/alias-store.test.ts +163 -0
  15. package/src/__tests__/binary-signing.test.ts +120 -0
  16. package/src/__tests__/blackbox-active-closure.test.ts +119 -0
  17. package/src/__tests__/blackbox-convergence.test.ts +334 -0
  18. package/src/__tests__/blackbox-parity.test.ts +725 -0
  19. package/src/__tests__/blackbox-plugin-state.test.ts +545 -0
  20. package/src/__tests__/blackbox-tool-state.test.ts +669 -0
  21. package/src/__tests__/cache-provenance.test.ts +84 -0
  22. package/src/__tests__/capture-builtin.test.ts +92 -0
  23. package/src/__tests__/catalog-cache-store.test.ts +309 -0
  24. package/src/__tests__/catalog-notice.test.ts +155 -0
  25. package/src/__tests__/cli-ansi.test.ts +297 -0
  26. package/src/__tests__/cli-apply-seams.test.ts +288 -0
  27. package/src/__tests__/cli-live.test.ts +391 -0
  28. package/src/__tests__/cli-router.test.ts +240 -0
  29. package/src/__tests__/cli-tool-commands.test.ts +239 -0
  30. package/src/__tests__/cli-tools-adapter.test.ts +294 -0
  31. package/src/__tests__/cli-update-view.test.ts +524 -0
  32. package/src/__tests__/community-fetch.test.ts +545 -0
  33. package/src/__tests__/community-registry.test.ts +269 -0
  34. package/src/__tests__/community-staleness.test.ts +720 -0
  35. package/src/__tests__/component-badge.test.ts +98 -0
  36. package/src/__tests__/component-icons.test.ts +94 -0
  37. package/src/__tests__/content-drift.test.ts +353 -0
  38. package/src/__tests__/conventions-integration.test.ts +830 -0
  39. package/src/__tests__/conventions-manager.test.ts +1236 -0
  40. package/src/__tests__/doctor-bins.test.ts +43 -0
  41. package/src/__tests__/doctor.test.ts +463 -0
  42. package/src/__tests__/dotenv.test.ts +195 -0
  43. package/src/__tests__/enabled-not-installed.test.ts +94 -0
  44. package/src/__tests__/file-locking.test.ts +208 -0
  45. package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
  46. package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
  47. package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
  48. package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
  49. package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
  50. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
  51. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
  52. package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
  53. package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
  54. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
  55. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
  56. package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
  57. package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
  58. package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
  59. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
  60. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
  61. package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
  62. package/src/__tests__/footer-hints.test.ts +40 -0
  63. package/src/__tests__/format-command.test.ts +78 -0
  64. package/src/__tests__/git-worktree.test.ts +108 -0
  65. package/src/__tests__/github-budget.test.ts +206 -0
  66. package/src/__tests__/gitignore-check.test.ts +71 -0
  67. package/src/__tests__/gitignore-detector.test.ts +176 -0
  68. package/src/__tests__/gitignore-fixer.test.ts +317 -0
  69. package/src/__tests__/gitignore-resolver.test.ts +193 -0
  70. package/src/__tests__/gitignore-service.test.ts +137 -0
  71. package/src/__tests__/go-module.test.ts +43 -0
  72. package/src/__tests__/hook-import-policy.test.ts +90 -0
  73. package/src/__tests__/hook-process.test.ts +256 -0
  74. package/src/__tests__/hook-registration.test.ts +224 -0
  75. package/src/__tests__/install-command.test.ts +81 -0
  76. package/src/__tests__/install-plan.test.ts +104 -0
  77. package/src/__tests__/launcher-signal.test.ts +308 -0
  78. package/src/__tests__/local-marketplace-scan.test.ts +248 -0
  79. package/src/__tests__/manifest.test.ts +281 -0
  80. package/src/__tests__/markdown-renderer.test.ts +142 -0
  81. package/src/__tests__/marketplace-badge.test.ts +140 -0
  82. package/src/__tests__/marketplace-refresh.test.ts +340 -0
  83. package/src/__tests__/marketplace-version.test.ts +84 -0
  84. package/src/__tests__/marketplaces.test.ts +128 -0
  85. package/src/__tests__/mate-availability.test.ts +156 -0
  86. package/src/__tests__/mate-catalog.test.ts +295 -0
  87. package/src/__tests__/mcp-registry.test.ts +158 -0
  88. package/src/__tests__/model-visuals.test.tsx +2466 -0
  89. package/src/__tests__/models-adapter.test.ts +332 -0
  90. package/src/__tests__/models-cli.test.ts +273 -0
  91. package/src/__tests__/models-core.test.ts +802 -0
  92. package/src/__tests__/models-manager.test.ts +500 -0
  93. package/src/__tests__/models-presets-marketplace.test.ts +168 -0
  94. package/src/__tests__/models-screen-state.test.ts +315 -0
  95. package/src/__tests__/open-file.test.ts +59 -0
  96. package/src/__tests__/owned-marketplaces.test.ts +93 -0
  97. package/src/__tests__/plugin-checkout.test.ts +66 -0
  98. package/src/__tests__/plugin-cli-argv.test.ts +109 -0
  99. package/src/__tests__/plugin-contents.test.ts +371 -0
  100. package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
  101. package/src/__tests__/plugin-releases.test.ts +98 -0
  102. package/src/__tests__/plugin-requires.test.ts +187 -0
  103. package/src/__tests__/plugin-row-text.test.ts +345 -0
  104. package/src/__tests__/plugin-setup.test.ts +1016 -0
  105. package/src/__tests__/profile-adopt.test.ts +177 -0
  106. package/src/__tests__/profile-command.test.ts +110 -0
  107. package/src/__tests__/profile-materializer.test.ts +238 -0
  108. package/src/__tests__/profile-sync.test.ts +154 -0
  109. package/src/__tests__/registry-version-resolution.test.ts +88 -0
  110. package/src/__tests__/resolve-executable.test.ts +42 -0
  111. package/src/__tests__/resolver.test.ts +351 -0
  112. package/src/__tests__/scope-action.test.ts +134 -0
  113. package/src/__tests__/scope-squares.test.tsx +164 -0
  114. package/src/__tests__/selection-scope.test.ts +100 -0
  115. package/src/__tests__/settings-file.test.ts +179 -0
  116. package/src/__tests__/shell-script-callers.test.ts +92 -0
  117. package/src/__tests__/skill-install-files.test.ts +136 -0
  118. package/src/__tests__/skills-catalog-client.test.ts +48 -0
  119. package/src/__tests__/style-wrap.test.ts +220 -0
  120. package/src/__tests__/styles-manager.test.ts +1098 -0
  121. package/src/__tests__/styles-origins.test.ts +414 -0
  122. package/src/__tests__/styles-screen-state.test.ts +457 -0
  123. package/src/__tests__/styles-status-line.test.ts +72 -0
  124. package/src/__tests__/styles-sync.test.ts +452 -0
  125. package/src/__tests__/symlink-manager.test.ts +180 -0
  126. package/src/__tests__/tabbar-layout.test.ts +100 -0
  127. package/src/__tests__/terminology-filler.test.ts +214 -0
  128. package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
  129. package/src/__tests__/theme-env.test.ts +310 -0
  130. package/src/__tests__/theme-resolve.test.ts +380 -0
  131. package/src/__tests__/tool-probe.test.ts +1400 -0
  132. package/src/__tests__/toolchain.test.ts +232 -0
  133. package/src/__tests__/ui-version-writers.test.ts +88 -0
  134. package/src/__tests__/update-apply.test.ts +637 -0
  135. package/src/__tests__/update-plan.test.ts +1396 -0
  136. package/src/__tests__/uppercase-keybindings.test.ts +101 -0
  137. package/src/__tests__/useGitignoreModal.test.ts +79 -0
  138. package/src/__tests__/version-check-refresh.test.ts +45 -0
  139. package/src/__tests__/version-snapshot.test.ts +212 -0
  140. package/src/__tests__/version-unknown-state.test.ts +403 -0
  141. package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
  142. package/src/cli/ansi.ts +528 -0
  143. package/src/cli/bootstrap.ts +134 -0
  144. package/src/cli/doctor.ts +218 -0
  145. package/src/cli/hook.ts +129 -0
  146. package/src/cli/install.ts +382 -0
  147. package/src/cli/live.ts +374 -0
  148. package/src/cli/models.ts +297 -0
  149. package/src/cli/profile.ts +197 -0
  150. package/src/cli/prompt.ts +32 -0
  151. package/src/cli/router.ts +264 -0
  152. package/src/cli/update-view.ts +717 -0
  153. package/src/cli/update.ts +600 -0
  154. package/src/cli/upgrade.ts +97 -0
  155. package/src/data/alias-flags.ts +301 -0
  156. package/src/data/cli-tools.ts +146 -0
  157. package/src/data/community-styles.ts +531 -0
  158. package/src/data/gitignore-defaults.ts +33 -0
  159. package/src/data/gitignore-reasons.ts +93 -0
  160. package/src/data/gitignore-templates.ts +25 -0
  161. package/src/data/marketplaces.ts +278 -0
  162. package/src/data/mcp-servers.ts +51 -0
  163. package/src/data/models-presets.ts +329 -0
  164. package/src/data/predefined-profiles.ts +252 -0
  165. package/src/data/settings-catalog.ts +714 -0
  166. package/src/data/skill-repos.ts +91 -0
  167. package/src/data/styles/asd-ste100.md +38 -0
  168. package/src/data/styles/calibrated.md +24 -0
  169. package/src/data/styles/direct.md +23 -0
  170. package/src/data/styles/evidence-first.md +22 -0
  171. package/src/data/styles/explanatory.md +23 -0
  172. package/src/data/styles/index.ts +57 -0
  173. package/src/data/styles/no-slop.md +32 -0
  174. package/src/data/styles/plain-language.md +23 -0
  175. package/src/data/styles/structured.md +30 -0
  176. package/src/data/styles/terminology.md +28 -0
  177. package/src/data/styles/terse.md +20 -0
  178. package/src/main.tsx +110 -0
  179. package/src/markdown.d.ts +13 -0
  180. package/src/opentui.d.ts +203 -0
  181. package/src/services/active-closure.ts +117 -0
  182. package/src/services/alias-settings.ts +60 -0
  183. package/src/services/alias-shell-writer.ts +1244 -0
  184. package/src/services/alias-store.ts +172 -0
  185. package/src/services/binary-signing.ts +78 -0
  186. package/src/services/catalog-cache-store.ts +316 -0
  187. package/src/services/claude-cli.ts +304 -0
  188. package/src/services/claude-runner.ts +31 -0
  189. package/src/services/claude-settings.ts +1564 -0
  190. package/src/services/cli-tool-commands.ts +133 -0
  191. package/src/services/community-fetcher.ts +90 -0
  192. package/src/services/community-styles.ts +1194 -0
  193. package/src/services/content-drift.ts +208 -0
  194. package/src/services/conventions-manager.ts +945 -0
  195. package/src/services/doctor-bins.ts +63 -0
  196. package/src/services/doctor.ts +570 -0
  197. package/src/services/dotenv.ts +181 -0
  198. package/src/services/git-worktree.ts +129 -0
  199. package/src/services/github-budget.ts +274 -0
  200. package/src/services/gitignore-check.ts +63 -0
  201. package/src/services/gitignore-detector.ts +167 -0
  202. package/src/services/gitignore-fixer.ts +280 -0
  203. package/src/services/gitignore-resolver.ts +208 -0
  204. package/src/services/gitignore-service.ts +167 -0
  205. package/src/services/hook-registration.ts +218 -0
  206. package/src/services/install-plan.ts +107 -0
  207. package/src/services/local-marketplace.ts +657 -0
  208. package/src/services/manifest.ts +250 -0
  209. package/src/services/marketplace-catalog-git.ts +173 -0
  210. package/src/services/marketplace-catalog.ts +129 -0
  211. package/src/services/marketplace-fetcher.ts +400 -0
  212. package/src/services/marketplace-refresh.ts +245 -0
  213. package/src/services/marketplace-sync.ts +145 -0
  214. package/src/services/mate-availability.ts +133 -0
  215. package/src/services/mate-catalog.ts +265 -0
  216. package/src/services/mcp-registry.ts +171 -0
  217. package/src/services/models-core.ts +969 -0
  218. package/src/services/models-manager.ts +606 -0
  219. package/src/services/plugin-checkout.ts +186 -0
  220. package/src/services/plugin-manager.ts +847 -0
  221. package/src/services/plugin-mcp-config.ts +242 -0
  222. package/src/services/plugin-releases.ts +47 -0
  223. package/src/services/plugin-requires.ts +227 -0
  224. package/src/services/plugin-setup.ts +965 -0
  225. package/src/services/profile-adopt.ts +206 -0
  226. package/src/services/profile-materializer.ts +133 -0
  227. package/src/services/profile-sync.ts +158 -0
  228. package/src/services/profiles.ts +225 -0
  229. package/src/services/resolver.ts +377 -0
  230. package/src/services/settings-file.ts +69 -0
  231. package/src/services/settings-manager.ts +307 -0
  232. package/src/services/skills-manager.ts +1111 -0
  233. package/src/services/skillsmp-client.ts +193 -0
  234. package/src/services/styles-manager.ts +1294 -0
  235. package/src/services/symlink-manager.ts +189 -0
  236. package/src/services/terminology-filler.ts +266 -0
  237. package/src/services/tool-probe.ts +1637 -0
  238. package/src/services/toolchain.ts +431 -0
  239. package/src/services/update-engine.ts +691 -0
  240. package/src/services/update-plan.ts +820 -0
  241. package/src/services/version-check.ts +127 -0
  242. package/src/services/version-snapshot.ts +189 -0
  243. package/src/tui.tsx +112 -0
  244. package/src/types/bun.d.ts +27 -0
  245. package/src/types/gitignore.ts +68 -0
  246. package/src/types/index.ts +447 -0
  247. package/src/ui/App.tsx +535 -0
  248. package/src/ui/adapters/catalogNotice.ts +122 -0
  249. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  250. package/src/ui/adapters/modelsAdapter.ts +213 -0
  251. package/src/ui/adapters/pluginsAdapter.ts +413 -0
  252. package/src/ui/adapters/settingsAdapter.ts +168 -0
  253. package/src/ui/adapters/skillsAdapter.ts +229 -0
  254. package/src/ui/adapters/stylesAdapter.ts +403 -0
  255. package/src/ui/components/CategoryHeader.tsx +64 -0
  256. package/src/ui/components/EmptyFilterState.tsx +32 -0
  257. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  258. package/src/ui/components/ScopeIndicator.tsx +63 -0
  259. package/src/ui/components/ScrollableList.tsx +95 -0
  260. package/src/ui/components/SearchInput.tsx +57 -0
  261. package/src/ui/components/StyledText.tsx +75 -0
  262. package/src/ui/components/TabBar.tsx +101 -0
  263. package/src/ui/components/layout/FooterHints.tsx +78 -0
  264. package/src/ui/components/layout/Panel.tsx +63 -0
  265. package/src/ui/components/layout/ProgressBar.tsx +44 -0
  266. package/src/ui/components/layout/ScopeTabs.tsx +54 -0
  267. package/src/ui/components/layout/ScreenLayout.tsx +321 -0
  268. package/src/ui/components/layout/index.ts +9 -0
  269. package/src/ui/components/modals/ConfirmModal.tsx +62 -0
  270. package/src/ui/components/modals/InputModal.tsx +75 -0
  271. package/src/ui/components/modals/LoadingModal.tsx +51 -0
  272. package/src/ui/components/modals/MessageModal.tsx +66 -0
  273. package/src/ui/components/modals/ModalContainer.tsx +159 -0
  274. package/src/ui/components/modals/SelectModal.tsx +90 -0
  275. package/src/ui/components/modals/index.ts +6 -0
  276. package/src/ui/components/primitives/ActionHints.tsx +44 -0
  277. package/src/ui/components/primitives/DetailSection.tsx +23 -0
  278. package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
  279. package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
  280. package/src/ui/components/primitives/MetaText.tsx +40 -0
  281. package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
  282. package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
  283. package/src/ui/components/primitives/SelectableRow.tsx +30 -0
  284. package/src/ui/components/primitives/index.ts +9 -0
  285. package/src/ui/hooks/index.ts +2 -0
  286. package/src/ui/hooks/useGitignoreModal.ts +95 -0
  287. package/src/ui/hooks/useKeyboard.ts +24 -0
  288. package/src/ui/hooks/useKeyboardHandler.ts +63 -0
  289. package/src/ui/registry.ts +33 -0
  290. package/src/ui/renderers/cliToolRenderers.tsx +326 -0
  291. package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
  292. package/src/ui/renderers/markdownRenderer.tsx +302 -0
  293. package/src/ui/renderers/mcpRenderers.tsx +149 -0
  294. package/src/ui/renderers/modelRenderers.tsx +1309 -0
  295. package/src/ui/renderers/modelVisuals.tsx +1402 -0
  296. package/src/ui/renderers/pluginRenderers.tsx +1089 -0
  297. package/src/ui/renderers/pluginRowText.ts +70 -0
  298. package/src/ui/renderers/pluginStatusText.ts +56 -0
  299. package/src/ui/renderers/profileRenderers.tsx +406 -0
  300. package/src/ui/renderers/settingsRenderers.tsx +218 -0
  301. package/src/ui/renderers/skillRenderers.tsx +479 -0
  302. package/src/ui/renderers/styleRenderers.tsx +813 -0
  303. package/src/ui/screens/AliasScreen.tsx +1558 -0
  304. package/src/ui/screens/CliToolsScreen.tsx +367 -0
  305. package/src/ui/screens/EnvVarsScreen.tsx +221 -0
  306. package/src/ui/screens/GitignoreScreen.tsx +562 -0
  307. package/src/ui/screens/McpRegistryScreen.tsx +366 -0
  308. package/src/ui/screens/McpScreen.tsx +256 -0
  309. package/src/ui/screens/ModelsScreen.tsx +542 -0
  310. package/src/ui/screens/PluginsScreen.tsx +1423 -0
  311. package/src/ui/screens/ProfilesScreen.tsx +438 -0
  312. package/src/ui/screens/SkillsScreen.tsx +853 -0
  313. package/src/ui/screens/StylesScreen.tsx +1064 -0
  314. package/src/ui/screens/index.ts +11 -0
  315. package/src/ui/state/AppContext.tsx +233 -0
  316. package/src/ui/state/DimensionsContext.tsx +154 -0
  317. package/src/ui/state/reducer.ts +810 -0
  318. package/src/ui/state/types.ts +503 -0
  319. package/src/ui/theme-mode.ts +440 -0
  320. package/src/ui/theme-resolve.ts +147 -0
  321. package/src/ui/theme.ts +175 -0
  322. package/src/utils/clipboard.ts +58 -0
  323. package/src/utils/command-utils.ts +149 -0
  324. package/src/utils/config-dir.ts +45 -0
  325. package/src/utils/file-locking.ts +146 -0
  326. package/src/utils/fuzzy-search.ts +138 -0
  327. package/src/utils/go-module.ts +36 -0
  328. package/src/utils/open-file.ts +84 -0
  329. package/src/utils/run.ts +67 -0
  330. package/src/utils/string-utils.ts +88 -0
@@ -0,0 +1,431 @@
1
+ /**
2
+ * Toolchain detection + bootstrap for the package managers a profile's binary
3
+ * deps need (bun, brew, npm, pip, go).
4
+ *
5
+ * `magus install` requires whatever installers the resolved closure uses. In
6
+ * source form bun is already guaranteed (bin/magus.js hard-exits without it),
7
+ * but once magus ships as a compiled binary an end user may lack bun — so bun
8
+ * is treated as a bootstrappable toolchain exactly like brew.
9
+ */
10
+
11
+ import type { BinInstaller, ResolvedBin } from "../types/index.js";
12
+ import type { Command } from "../utils/command-utils.js";
13
+ import { which, whichSync } from "../utils/command-utils.js";
14
+
15
+ // -- Python CLI applications -------------------------------------------------
16
+
17
+ /**
18
+ * How to install a Python COMMAND-LINE APPLICATION on this machine.
19
+ *
20
+ * `via: "pip"` used to emit the literal string `pip install <pkg>`, which is
21
+ * wrong on a modern macOS in two independent ways, both measured:
22
+ *
23
+ * 1. **`pip` frequently does not exist.** Homebrew installs `pip3`, and many
24
+ * people reach it through a shell alias — which lives only in interactive
25
+ * zsh. Commands were spawned through `/bin/sh`, which never sources a
26
+ * profile, so this died with `/bin/sh: pip: command not found` on a machine
27
+ * where typing `pip` at a prompt works perfectly. Commands are argv now
28
+ * (`utils/run.ts`), so `cmd` is looked up on PATH with no shell at all —
29
+ * which makes "resolve a real executable" the only option rather than the
30
+ * careful one.
31
+ * 2. **`pip install` into a Homebrew interpreter is refused outright.** PEP
32
+ * 668 marks it externally-managed; `python3 -m pip install --upgrade x`
33
+ * fails with `externally-managed-environment` no matter how it is spelled.
34
+ *
35
+ * So the fix is not "spell pip correctly". For a CLI application — which is
36
+ * what every `via: "pip"` requirement in this repo is, a package whose point is
37
+ * the executable it puts on PATH — installing into the system interpreter is
38
+ * the wrong mechanism. `uv tool` and `pipx` each give the package its own
39
+ * virtualenv and link the executable onto PATH, which is both what was wanted
40
+ * and immune to PEP 668.
41
+ *
42
+ * Ordered best-first. `python3 -m pip --user` is last because it is the one
43
+ * that PEP 668 can still reject; it stays because it is correct on Linux, in CI
44
+ * and under pyenv, and because emitting it names `python3` in the failure —
45
+ * which is a true and actionable error — rather than a `pip` that never existed.
46
+ */
47
+ export interface PythonCliInstaller {
48
+ /** Executable that must be on PATH for this option to be usable. */
49
+ probe: string;
50
+ install(pkg: string, version?: string): Command;
51
+ upgrade(pkg: string): Command;
52
+ /**
53
+ * Remove the tool again.
54
+ *
55
+ * Installer-specific for the same reason install is: a package installed
56
+ * with `uv tool install` lives in uv's own virtualenv and `pip uninstall`
57
+ * cannot see it. The one form that is always wrong is a bare `pip`, which
58
+ * frequently is not an executable at all.
59
+ */
60
+ uninstall(pkg: string): Command;
61
+ /**
62
+ * Move an ALREADY-INSTALLED package to exactly `version`.
63
+ *
64
+ * Distinct from `install(pkg, version)`, which each of these treats as a
65
+ * no-op once the package is present — `uv tool install pkg==v` answers
66
+ * "already installed" when any version is there. `--force` is what makes
67
+ * the pin land.
68
+ */
69
+ pin(pkg: string, version: string): Command;
70
+ }
71
+
72
+ export const PYTHON_CLI_INSTALLERS: readonly PythonCliInstaller[] = [
73
+ {
74
+ probe: "uv",
75
+ install: (pkg, version) => ({
76
+ cmd: "uv",
77
+ // `pkg==version` is ONE argv token. Splitting on the `==` would ask uv
78
+ // to install two packages, one of which is a version number.
79
+ args: ["tool", "install", version ? `${pkg}==${version}` : pkg],
80
+ }),
81
+ // `uv tool install --force --upgrade`, NOT `uv tool upgrade`. The latter
82
+ // only knows tools uv itself installed and errors otherwise —
83
+ // `browser-use is not installed; run uv tool install` on a machine where
84
+ // the executable was plainly on PATH, put there by a pip that predates
85
+ // uv. Since `magus update` reaches here for anything already present,
86
+ // that is the common case, not the edge case. `--force` adopts the
87
+ // existing shim; `--upgrade` moves it to the newest version.
88
+ upgrade: (pkg) => ({
89
+ cmd: "uv",
90
+ args: ["tool", "install", "--force", "--upgrade", pkg],
91
+ }),
92
+ uninstall: (pkg) => ({ cmd: "uv", args: ["tool", "uninstall", pkg] }),
93
+ pin: (pkg, version) => ({
94
+ cmd: "uv",
95
+ args: ["tool", "install", "--force", `${pkg}==${version}`],
96
+ }),
97
+ },
98
+ {
99
+ probe: "pipx",
100
+ install: (pkg, version) => ({
101
+ cmd: "pipx",
102
+ args: ["install", version ? `${pkg}==${version}` : pkg],
103
+ }),
104
+ // Same reasoning as uv: `pipx upgrade` fails when pipx does not already
105
+ // manage the package.
106
+ upgrade: (pkg) => ({ cmd: "pipx", args: ["install", "--force", pkg] }),
107
+ uninstall: (pkg) => ({ cmd: "pipx", args: ["uninstall", pkg] }),
108
+ pin: (pkg, version) => ({
109
+ cmd: "pipx",
110
+ args: ["install", "--force", `${pkg}==${version}`],
111
+ }),
112
+ },
113
+ {
114
+ probe: "python3",
115
+ install: (pkg, version) => ({
116
+ cmd: "python3",
117
+ args: [
118
+ "-m",
119
+ "pip",
120
+ "install",
121
+ "--user",
122
+ version ? `${pkg}==${version}` : pkg,
123
+ ],
124
+ }),
125
+ upgrade: (pkg) => ({
126
+ cmd: "python3",
127
+ args: ["-m", "pip", "install", "--user", "--upgrade", pkg],
128
+ }),
129
+ // `-y`: nothing here is attached to a TTY that could answer a prompt.
130
+ uninstall: (pkg) => ({
131
+ cmd: "python3",
132
+ args: ["-m", "pip", "uninstall", "-y", pkg],
133
+ }),
134
+ pin: (pkg, version) => ({
135
+ cmd: "python3",
136
+ args: ["-m", "pip", "install", "--user", `${pkg}==${version}`],
137
+ }),
138
+ },
139
+ ];
140
+
141
+ /** Memoised: PATH does not change within one magus run. */
142
+ let pythonCliMemo: PythonCliInstaller | undefined;
143
+
144
+ /**
145
+ * The best Python CLI installer available here. Never null — the `python3`
146
+ * entry is returned when nothing better is found, so callers always get a
147
+ * command whose failure names something real.
148
+ */
149
+ export function resolvePythonCliInstaller(): PythonCliInstaller {
150
+ if (pythonCliMemo) return pythonCliMemo;
151
+ const found = PYTHON_CLI_INSTALLERS.find((i) => whichSync(i.probe) !== null);
152
+ const fallback = PYTHON_CLI_INSTALLERS[PYTHON_CLI_INSTALLERS.length - 1];
153
+ pythonCliMemo = found ?? fallback;
154
+ return pythonCliMemo;
155
+ }
156
+
157
+ /** Test seam — PATH is stable within a run, so production never needs this. */
158
+ export function resetPythonCliInstallerMemo(): void {
159
+ pythonCliMemo = undefined;
160
+ }
161
+
162
+ // ----------------------------------------------------------------------------
163
+
164
+ /**
165
+ * The executable that proves a toolchain is installed.
166
+ *
167
+ * `pip` is deliberately absent: which executable proves it depends on the
168
+ * machine, so it is resolved by {@link resolvePythonCliInstaller} instead.
169
+ */
170
+ const TOOLCHAIN_PROBE: Record<Exclude<BinInstaller, "pip">, string> = {
171
+ bun: "bun",
172
+ brew: "brew",
173
+ npm: "npm",
174
+ go: "go",
175
+ };
176
+
177
+ /** The executable whose presence proves `name` is usable. */
178
+ function toolchainProbe(name: BinInstaller): string {
179
+ return name === "pip"
180
+ ? resolvePythonCliInstaller().probe
181
+ : TOOLCHAIN_PROBE[name];
182
+ }
183
+
184
+ /** Official one-line bootstrap for the toolchains magus will install. */
185
+ export const TOOLCHAIN_BOOTSTRAP: Partial<Record<BinInstaller, string>> = {
186
+ bun: "curl -fsSL https://bun.sh/install | bash",
187
+ brew: '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
188
+ };
189
+
190
+ export interface ToolchainStatus {
191
+ name: BinInstaller;
192
+ present: boolean;
193
+ /** Binary names in the closure that need this toolchain. */
194
+ requiredBy: string[];
195
+ /** Bootstrap command, when magus can install this toolchain itself. */
196
+ bootstrap?: string;
197
+ }
198
+
199
+ /** Is a given toolchain available on PATH? */
200
+ export async function isToolchainPresent(name: BinInstaller): Promise<boolean> {
201
+ return (await which(toolchainProbe(name))) !== null;
202
+ }
203
+
204
+ /**
205
+ * Determine which toolchains a resolved closure's binary deps require, and
206
+ * whether each is present. Only toolchains actually needed are returned.
207
+ */
208
+ export async function detectToolchains(
209
+ bins: ResolvedBin[],
210
+ ): Promise<ToolchainStatus[]> {
211
+ const requiredBy = new Map<BinInstaller, string[]>();
212
+ for (const bin of bins) {
213
+ const list = requiredBy.get(bin.via) ?? [];
214
+ list.push(bin.name);
215
+ requiredBy.set(bin.via, list);
216
+ }
217
+
218
+ const statuses: ToolchainStatus[] = [];
219
+ for (const [name, names] of requiredBy) {
220
+ statuses.push({
221
+ name,
222
+ present: await isToolchainPresent(name),
223
+ requiredBy: names,
224
+ bootstrap: TOOLCHAIN_BOOTSTRAP[name],
225
+ });
226
+ }
227
+ // Stable order: bun, brew, npm, pip, go.
228
+ const order: BinInstaller[] = ["bun", "brew", "npm", "pip", "go"];
229
+ statuses.sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name));
230
+ return statuses;
231
+ }
232
+
233
+ /**
234
+ * The install command for a single binary requirement.
235
+ *
236
+ * `python` is injectable so tests can pin one and stay machine-independent —
237
+ * the default depends on what is on PATH, so an assertion against a literal
238
+ * `uv tool …` would pass here and fail on a runner without uv.
239
+ *
240
+ * `pkg@version` and `pkg==version` are each ONE argv token. That join is
241
+ * precisely where a string was dangerous: `bin.package` is hand-authored
242
+ * profile/marketplace data, and under a shell a space or a `;` in it would have
243
+ * become extra words. Here it cannot.
244
+ */
245
+ export function binInstallCommand(
246
+ bin: {
247
+ name: string;
248
+ via: BinInstaller;
249
+ package?: string;
250
+ formula?: string;
251
+ module?: string;
252
+ version?: string;
253
+ },
254
+ python: PythonCliInstaller = resolvePythonCliInstaller(),
255
+ ): Command {
256
+ const pkg = bin.package ?? bin.name;
257
+ const at = bin.version ? `@${bin.version}` : "";
258
+ switch (bin.via) {
259
+ case "bun":
260
+ return { cmd: "bun", args: ["install", "-g", `${pkg}${at}`] };
261
+ case "npm":
262
+ return { cmd: "npm", args: ["install", "-g", `${pkg}${at}`] };
263
+ case "pip":
264
+ return python.install(pkg, bin.version);
265
+ case "brew":
266
+ return { cmd: "brew", args: ["install", bin.formula ?? bin.name] };
267
+ case "go":
268
+ return {
269
+ cmd: "go",
270
+ args: [
271
+ "install",
272
+ `${bin.module ?? bin.name}@${bin.version ?? "latest"}`,
273
+ ],
274
+ };
275
+ }
276
+ }
277
+
278
+ /**
279
+ * The command that advances an ALREADY-INSTALLED binary to the newest version.
280
+ *
281
+ * Distinct from {@link binInstallCommand} because two of the five installers
282
+ * treat "install" as a no-op once the package is present: `brew install` on an
283
+ * installed formula prints "already installed" and changes nothing, and `pip
284
+ * install` without `--upgrade` does the same. Reusing the install command for
285
+ * `magus update` would therefore report success while advancing nothing.
286
+ *
287
+ * A pinned version has nothing to advance to, so callers must only reach this
288
+ * for unpinned requirements; `@latest` is stated explicitly where the installer
289
+ * accepts it, so the intent survives in the printed command.
290
+ */
291
+ export function binUpgradeCommand(
292
+ bin: {
293
+ name: string;
294
+ via: BinInstaller;
295
+ package?: string;
296
+ formula?: string;
297
+ module?: string;
298
+ },
299
+ python: PythonCliInstaller = resolvePythonCliInstaller(),
300
+ ): Command {
301
+ const pkg = bin.package ?? bin.name;
302
+ switch (bin.via) {
303
+ case "bun":
304
+ return { cmd: "bun", args: ["install", "-g", `${pkg}@latest`] };
305
+ case "npm":
306
+ return { cmd: "npm", args: ["install", "-g", `${pkg}@latest`] };
307
+ case "pip":
308
+ return python.upgrade(pkg);
309
+ case "brew":
310
+ return { cmd: "brew", args: ["upgrade", bin.formula ?? bin.name] };
311
+ case "go":
312
+ return {
313
+ cmd: "go",
314
+ args: ["install", `${bin.module ?? bin.name}@latest`],
315
+ };
316
+ }
317
+ }
318
+
319
+ /**
320
+ * The command that moves an ALREADY-INSTALLED binary to exactly its pin.
321
+ *
322
+ * `magus update` plans a pinned tool that sits below its pin as an upgrade
323
+ * TO the pin, and neither of the other two builders delivers that:
324
+ * {@link binUpgradeCommand} goes to the newest version, past the pin, and a
325
+ * plain `uv tool install pkg==v` is a no-op once any version is installed.
326
+ *
327
+ * bun, npm and go take the version in the same token `binInstallCommand` builds
328
+ * (`pkg@v`), and each replaces an installed version with it. pip goes through
329
+ * the resolved installer's own `pin`. brew installs only its newest version, so
330
+ * there is nothing to return — the planner never asks for it (a brew pin below
331
+ * the installed version is reported, not planned).
332
+ *
333
+ * Null also when the requirement carries no pin.
334
+ */
335
+ export function binPinCommand(
336
+ bin: {
337
+ name: string;
338
+ via: BinInstaller;
339
+ package?: string;
340
+ formula?: string;
341
+ module?: string;
342
+ version?: string;
343
+ },
344
+ python: PythonCliInstaller = resolvePythonCliInstaller(),
345
+ ): Command | null {
346
+ if (!bin.version) return null;
347
+ switch (bin.via) {
348
+ case "bun":
349
+ case "npm":
350
+ case "go":
351
+ return binInstallCommand(bin, python);
352
+ case "pip":
353
+ return python.pin(bin.package ?? bin.name, bin.version);
354
+ case "brew":
355
+ return null;
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Which of the three builders a requirement in a given state needs.
361
+ *
362
+ * THE one rule that turns a state into a command, so a front end cannot show
363
+ * one target and run another. It was written twice before: `binCommandFor`
364
+ * (`update-engine.ts`) for `magus update`, and the CLI Tools screen's row,
365
+ * which always built `@latest` — so a row reading `→ v9.0.0` (the pin) ran
366
+ * `bun install -g pkg@latest` and left the tool permanently ahead of its pin,
367
+ * a state magus cannot leave. Both now come through here.
368
+ *
369
+ * `install` → {@link binInstallCommand} (which appends the pin, when there is
370
+ * one). An upgrade whose target IS the pin → {@link binPinCommand}, because
371
+ * {@link binUpgradeCommand} would overshoot it. Any other upgrade → the newest
372
+ * version.
373
+ *
374
+ * Null only when no sound command exists: a pinned brew formula, where brew
375
+ * installs its newest version and nothing else (the pin is advisory).
376
+ */
377
+ export function binCommandForState(
378
+ state: "install" | "upgrade",
379
+ target: string | null,
380
+ bin: {
381
+ name: string;
382
+ via: BinInstaller;
383
+ package?: string;
384
+ formula?: string;
385
+ module?: string;
386
+ version?: string;
387
+ },
388
+ python: PythonCliInstaller = resolvePythonCliInstaller(),
389
+ ): Command | null {
390
+ if (state === "install") return binInstallCommand(bin, python);
391
+ if (bin.version && target === bin.version) return binPinCommand(bin, python);
392
+ return binUpgradeCommand(bin, python);
393
+ }
394
+
395
+ /**
396
+ * The command that REMOVES an installed binary.
397
+ *
398
+ * Third of the trio, and it needs the resolver for exactly the reason the other
399
+ * two do: which executable can remove a Python CLI application depends on which
400
+ * one installed it, and a bare `pip uninstall` is wrong on the same machines
401
+ * where a bare `pip install` was. The CLI-tools screen carried that bare form in
402
+ * two separate copies before this existed.
403
+ *
404
+ * `brew` and `go` have no catalogue entries today, so those arms ship
405
+ * unexercised by real data — they are pinned by unit test instead. `go` has no
406
+ * uninstall at all (`go install` writes a binary into GOBIN and records
407
+ * nothing), so it returns null rather than inventing an `rm`.
408
+ */
409
+ export function binUninstallCommand(
410
+ bin: {
411
+ name: string;
412
+ via: BinInstaller;
413
+ package?: string;
414
+ formula?: string;
415
+ },
416
+ python: PythonCliInstaller = resolvePythonCliInstaller(),
417
+ ): Command | null {
418
+ const pkg = bin.package ?? bin.name;
419
+ switch (bin.via) {
420
+ case "bun":
421
+ return { cmd: "bun", args: ["remove", "-g", pkg] };
422
+ case "npm":
423
+ return { cmd: "npm", args: ["uninstall", "-g", pkg] };
424
+ case "pip":
425
+ return python.uninstall(pkg);
426
+ case "brew":
427
+ return { cmd: "brew", args: ["uninstall", bin.formula ?? bin.name] };
428
+ case "go":
429
+ return null;
430
+ }
431
+ }