magus-cli 0.0.0-placeholder → 7.0.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 (331) 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 +684 -0
  19. package/src/__tests__/blackbox-plugin-state.test.ts +523 -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 +445 -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 +174 -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 +335 -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__/moved-marketplace.test.ts +86 -0
  96. package/src/__tests__/open-file.test.ts +59 -0
  97. package/src/__tests__/owned-marketplaces.test.ts +93 -0
  98. package/src/__tests__/plugin-checkout.test.ts +66 -0
  99. package/src/__tests__/plugin-cli-argv.test.ts +109 -0
  100. package/src/__tests__/plugin-contents.test.ts +371 -0
  101. package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
  102. package/src/__tests__/plugin-releases.test.ts +98 -0
  103. package/src/__tests__/plugin-requires.test.ts +187 -0
  104. package/src/__tests__/plugin-row-text.test.ts +336 -0
  105. package/src/__tests__/plugin-setup.test.ts +1016 -0
  106. package/src/__tests__/profile-adopt.test.ts +177 -0
  107. package/src/__tests__/profile-command.test.ts +110 -0
  108. package/src/__tests__/profile-materializer.test.ts +238 -0
  109. package/src/__tests__/profile-sync.test.ts +154 -0
  110. package/src/__tests__/registry-version-resolution.test.ts +88 -0
  111. package/src/__tests__/resolve-executable.test.ts +42 -0
  112. package/src/__tests__/resolver.test.ts +351 -0
  113. package/src/__tests__/scope-action.test.ts +134 -0
  114. package/src/__tests__/scope-squares.test.tsx +164 -0
  115. package/src/__tests__/selection-scope.test.ts +100 -0
  116. package/src/__tests__/settings-file.test.ts +179 -0
  117. package/src/__tests__/shell-script-callers.test.ts +92 -0
  118. package/src/__tests__/skill-install-files.test.ts +136 -0
  119. package/src/__tests__/skills-catalog-client.test.ts +48 -0
  120. package/src/__tests__/style-wrap.test.ts +220 -0
  121. package/src/__tests__/styles-manager.test.ts +1098 -0
  122. package/src/__tests__/styles-origins.test.ts +414 -0
  123. package/src/__tests__/styles-screen-state.test.ts +457 -0
  124. package/src/__tests__/styles-status-line.test.ts +72 -0
  125. package/src/__tests__/styles-sync.test.ts +452 -0
  126. package/src/__tests__/symlink-manager.test.ts +180 -0
  127. package/src/__tests__/tabbar-layout.test.ts +100 -0
  128. package/src/__tests__/terminology-filler.test.ts +214 -0
  129. package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
  130. package/src/__tests__/theme-env.test.ts +281 -0
  131. package/src/__tests__/theme-resolve.test.ts +380 -0
  132. package/src/__tests__/tool-probe.test.ts +1400 -0
  133. package/src/__tests__/toolchain.test.ts +232 -0
  134. package/src/__tests__/ui-version-writers.test.ts +88 -0
  135. package/src/__tests__/update-apply.test.ts +637 -0
  136. package/src/__tests__/update-plan.test.ts +1373 -0
  137. package/src/__tests__/uppercase-keybindings.test.ts +101 -0
  138. package/src/__tests__/useGitignoreModal.test.ts +79 -0
  139. package/src/__tests__/version-check-refresh.test.ts +45 -0
  140. package/src/__tests__/version-snapshot.test.ts +212 -0
  141. package/src/__tests__/version-unknown-state.test.ts +388 -0
  142. package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
  143. package/src/cli/ansi.ts +520 -0
  144. package/src/cli/bootstrap.ts +134 -0
  145. package/src/cli/doctor.ts +218 -0
  146. package/src/cli/hook.ts +129 -0
  147. package/src/cli/install.ts +382 -0
  148. package/src/cli/live.ts +374 -0
  149. package/src/cli/models.ts +297 -0
  150. package/src/cli/profile.ts +197 -0
  151. package/src/cli/prompt.ts +32 -0
  152. package/src/cli/router.ts +207 -0
  153. package/src/cli/update-view.ts +526 -0
  154. package/src/cli/update.ts +613 -0
  155. package/src/cli/upgrade.ts +97 -0
  156. package/src/data/alias-flags.ts +301 -0
  157. package/src/data/cli-tools.ts +146 -0
  158. package/src/data/community-styles.ts +531 -0
  159. package/src/data/gitignore-defaults.ts +33 -0
  160. package/src/data/gitignore-reasons.ts +93 -0
  161. package/src/data/gitignore-templates.ts +25 -0
  162. package/src/data/marketplaces.ts +278 -0
  163. package/src/data/mcp-servers.ts +51 -0
  164. package/src/data/models-presets.ts +329 -0
  165. package/src/data/predefined-profiles.ts +252 -0
  166. package/src/data/settings-catalog.ts +714 -0
  167. package/src/data/skill-repos.ts +91 -0
  168. package/src/data/styles/asd-ste100.md +38 -0
  169. package/src/data/styles/calibrated.md +24 -0
  170. package/src/data/styles/direct.md +23 -0
  171. package/src/data/styles/evidence-first.md +22 -0
  172. package/src/data/styles/explanatory.md +23 -0
  173. package/src/data/styles/index.ts +57 -0
  174. package/src/data/styles/no-slop.md +32 -0
  175. package/src/data/styles/plain-language.md +23 -0
  176. package/src/data/styles/structured.md +30 -0
  177. package/src/data/styles/terminology.md +28 -0
  178. package/src/data/styles/terse.md +20 -0
  179. package/src/main.tsx +110 -0
  180. package/src/markdown.d.ts +13 -0
  181. package/src/opentui.d.ts +203 -0
  182. package/src/services/active-closure.ts +117 -0
  183. package/src/services/alias-settings.ts +60 -0
  184. package/src/services/alias-shell-writer.ts +1244 -0
  185. package/src/services/alias-store.ts +172 -0
  186. package/src/services/binary-signing.ts +78 -0
  187. package/src/services/catalog-cache-store.ts +316 -0
  188. package/src/services/claude-cli.ts +304 -0
  189. package/src/services/claude-runner.ts +31 -0
  190. package/src/services/claude-settings.ts +1589 -0
  191. package/src/services/cli-tool-commands.ts +133 -0
  192. package/src/services/community-fetcher.ts +90 -0
  193. package/src/services/community-styles.ts +1194 -0
  194. package/src/services/content-drift.ts +208 -0
  195. package/src/services/conventions-manager.ts +945 -0
  196. package/src/services/doctor-bins.ts +63 -0
  197. package/src/services/doctor.ts +570 -0
  198. package/src/services/dotenv.ts +181 -0
  199. package/src/services/git-worktree.ts +129 -0
  200. package/src/services/github-budget.ts +274 -0
  201. package/src/services/gitignore-check.ts +63 -0
  202. package/src/services/gitignore-detector.ts +167 -0
  203. package/src/services/gitignore-fixer.ts +280 -0
  204. package/src/services/gitignore-resolver.ts +208 -0
  205. package/src/services/gitignore-service.ts +167 -0
  206. package/src/services/hook-registration.ts +218 -0
  207. package/src/services/install-plan.ts +107 -0
  208. package/src/services/local-marketplace.ts +587 -0
  209. package/src/services/manifest.ts +250 -0
  210. package/src/services/marketplace-catalog-git.ts +173 -0
  211. package/src/services/marketplace-catalog.ts +129 -0
  212. package/src/services/marketplace-fetcher.ts +400 -0
  213. package/src/services/marketplace-refresh.ts +253 -0
  214. package/src/services/marketplace-sync.ts +145 -0
  215. package/src/services/mate-availability.ts +133 -0
  216. package/src/services/mate-catalog.ts +265 -0
  217. package/src/services/mcp-registry.ts +171 -0
  218. package/src/services/models-core.ts +969 -0
  219. package/src/services/models-manager.ts +606 -0
  220. package/src/services/plugin-checkout.ts +186 -0
  221. package/src/services/plugin-manager.ts +878 -0
  222. package/src/services/plugin-mcp-config.ts +242 -0
  223. package/src/services/plugin-releases.ts +47 -0
  224. package/src/services/plugin-requires.ts +227 -0
  225. package/src/services/plugin-setup.ts +965 -0
  226. package/src/services/profile-adopt.ts +206 -0
  227. package/src/services/profile-materializer.ts +133 -0
  228. package/src/services/profile-sync.ts +158 -0
  229. package/src/services/profiles.ts +225 -0
  230. package/src/services/resolver.ts +344 -0
  231. package/src/services/settings-file.ts +69 -0
  232. package/src/services/settings-manager.ts +307 -0
  233. package/src/services/skills-manager.ts +1111 -0
  234. package/src/services/skillsmp-client.ts +193 -0
  235. package/src/services/styles-manager.ts +1294 -0
  236. package/src/services/symlink-manager.ts +189 -0
  237. package/src/services/terminology-filler.ts +266 -0
  238. package/src/services/tool-probe.ts +1637 -0
  239. package/src/services/toolchain.ts +431 -0
  240. package/src/services/update-engine.ts +691 -0
  241. package/src/services/update-plan.ts +863 -0
  242. package/src/services/version-check.ts +127 -0
  243. package/src/services/version-snapshot.ts +189 -0
  244. package/src/tui.tsx +112 -0
  245. package/src/types/bun.d.ts +27 -0
  246. package/src/types/gitignore.ts +68 -0
  247. package/src/types/index.ts +447 -0
  248. package/src/ui/App.tsx +538 -0
  249. package/src/ui/adapters/catalogNotice.ts +122 -0
  250. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  251. package/src/ui/adapters/modelsAdapter.ts +213 -0
  252. package/src/ui/adapters/pluginsAdapter.ts +413 -0
  253. package/src/ui/adapters/settingsAdapter.ts +168 -0
  254. package/src/ui/adapters/skillsAdapter.ts +229 -0
  255. package/src/ui/adapters/stylesAdapter.ts +403 -0
  256. package/src/ui/components/CategoryHeader.tsx +64 -0
  257. package/src/ui/components/EmptyFilterState.tsx +32 -0
  258. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  259. package/src/ui/components/ScopeIndicator.tsx +63 -0
  260. package/src/ui/components/ScrollableList.tsx +95 -0
  261. package/src/ui/components/SearchInput.tsx +57 -0
  262. package/src/ui/components/StyledText.tsx +75 -0
  263. package/src/ui/components/TabBar.tsx +101 -0
  264. package/src/ui/components/layout/FooterHints.tsx +78 -0
  265. package/src/ui/components/layout/Panel.tsx +63 -0
  266. package/src/ui/components/layout/ProgressBar.tsx +44 -0
  267. package/src/ui/components/layout/ScopeTabs.tsx +54 -0
  268. package/src/ui/components/layout/ScreenLayout.tsx +321 -0
  269. package/src/ui/components/layout/index.ts +9 -0
  270. package/src/ui/components/modals/ConfirmModal.tsx +62 -0
  271. package/src/ui/components/modals/InputModal.tsx +75 -0
  272. package/src/ui/components/modals/LoadingModal.tsx +51 -0
  273. package/src/ui/components/modals/MessageModal.tsx +66 -0
  274. package/src/ui/components/modals/ModalContainer.tsx +159 -0
  275. package/src/ui/components/modals/SelectModal.tsx +90 -0
  276. package/src/ui/components/modals/index.ts +6 -0
  277. package/src/ui/components/primitives/ActionHints.tsx +44 -0
  278. package/src/ui/components/primitives/DetailSection.tsx +23 -0
  279. package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
  280. package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
  281. package/src/ui/components/primitives/MetaText.tsx +40 -0
  282. package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
  283. package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
  284. package/src/ui/components/primitives/SelectableRow.tsx +30 -0
  285. package/src/ui/components/primitives/index.ts +9 -0
  286. package/src/ui/hooks/index.ts +2 -0
  287. package/src/ui/hooks/useGitignoreModal.ts +95 -0
  288. package/src/ui/hooks/useKeyboard.ts +24 -0
  289. package/src/ui/hooks/useKeyboardHandler.ts +63 -0
  290. package/src/ui/registry.ts +33 -0
  291. package/src/ui/renderers/cliToolRenderers.tsx +326 -0
  292. package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
  293. package/src/ui/renderers/markdownRenderer.tsx +302 -0
  294. package/src/ui/renderers/mcpRenderers.tsx +149 -0
  295. package/src/ui/renderers/modelRenderers.tsx +1309 -0
  296. package/src/ui/renderers/modelVisuals.tsx +1402 -0
  297. package/src/ui/renderers/pluginRenderers.tsx +1089 -0
  298. package/src/ui/renderers/pluginRowText.ts +70 -0
  299. package/src/ui/renderers/pluginStatusText.ts +56 -0
  300. package/src/ui/renderers/profileRenderers.tsx +406 -0
  301. package/src/ui/renderers/settingsRenderers.tsx +218 -0
  302. package/src/ui/renderers/skillRenderers.tsx +479 -0
  303. package/src/ui/renderers/styleRenderers.tsx +813 -0
  304. package/src/ui/screens/AliasScreen.tsx +1558 -0
  305. package/src/ui/screens/CliToolsScreen.tsx +367 -0
  306. package/src/ui/screens/EnvVarsScreen.tsx +221 -0
  307. package/src/ui/screens/GitignoreScreen.tsx +562 -0
  308. package/src/ui/screens/McpRegistryScreen.tsx +366 -0
  309. package/src/ui/screens/McpScreen.tsx +256 -0
  310. package/src/ui/screens/ModelsScreen.tsx +542 -0
  311. package/src/ui/screens/PluginsScreen.tsx +1464 -0
  312. package/src/ui/screens/ProfilesScreen.tsx +438 -0
  313. package/src/ui/screens/SkillsScreen.tsx +853 -0
  314. package/src/ui/screens/StylesScreen.tsx +1064 -0
  315. package/src/ui/screens/index.ts +11 -0
  316. package/src/ui/state/AppContext.tsx +233 -0
  317. package/src/ui/state/DimensionsContext.tsx +154 -0
  318. package/src/ui/state/reducer.ts +810 -0
  319. package/src/ui/state/types.ts +503 -0
  320. package/src/ui/theme-mode.ts +352 -0
  321. package/src/ui/theme-resolve.ts +147 -0
  322. package/src/ui/theme.ts +175 -0
  323. package/src/utils/clipboard.ts +58 -0
  324. package/src/utils/command-utils.ts +144 -0
  325. package/src/utils/config-dir.ts +45 -0
  326. package/src/utils/file-locking.ts +146 -0
  327. package/src/utils/fuzzy-search.ts +138 -0
  328. package/src/utils/go-module.ts +36 -0
  329. package/src/utils/open-file.ts +84 -0
  330. package/src/utils/run.ts +67 -0
  331. package/src/utils/string-utils.ts +88 -0
@@ -0,0 +1,239 @@
1
+ /**
2
+ * The CLI-tools catalogue can no longer emit a bare `pip`, or shell syntax.
3
+ *
4
+ * Two defects lived in this screen, and both were data problems dressed as code
5
+ * problems:
6
+ *
7
+ * 1. **Bare `pip`.** `getUpdateCommand`'s `pip` and `default` arms returned the
8
+ * catalogue's `installCommand` verbatim, and `getUninstallCommand` emitted
9
+ * `pip uninstall -y`. `pip` is frequently a zsh alias rather than an
10
+ * executable, and a machine that manages Python tools with uv or pipx may
11
+ * have no `pip` at all. `services/toolchain.ts` already knew how to answer
12
+ * "how does Python work here"; this screen was not asking it.
13
+ * 2. **Shell syntax in data.** `aider`'s entry was
14
+ * `pip install aider-install && aider-install` — a `&&` chain in a catalogue
15
+ * field that got handed to `/bin/bash`.
16
+ *
17
+ * Both are now unrepresentable: the field is gone and the command is derived.
18
+ * These tests assert that over the whole catalogue rather than over an example,
19
+ * so a new entry cannot reintroduce either.
20
+ *
21
+ * Pure — no React, no network, no PATH dependency. Every Python installer is
22
+ * pinned explicitly, per the convention `toolchain.test.ts` established: the
23
+ * production default is resolved from PATH, so an unpinned assertion would
24
+ * encode whatever happens to be installed on the machine running the suite.
25
+ */
26
+
27
+ import { describe, expect, test } from "bun:test";
28
+ import { cliTools } from "../data/cli-tools.js";
29
+ import {
30
+ type InstallMethod,
31
+ cliToolInstall,
32
+ cliToolUninstall,
33
+ cliToolUpdate,
34
+ } from "../services/cli-tool-commands.js";
35
+ import { versionFromPypiJson } from "../services/tool-probe.js";
36
+ import {
37
+ PYTHON_CLI_INSTALLERS,
38
+ type PythonCliInstaller,
39
+ resolvePythonCliInstaller,
40
+ } from "../services/toolchain.js";
41
+ import { type Command, formatCommand } from "../utils/command-utils.js";
42
+
43
+ const byProbe = (probe: string): PythonCliInstaller => {
44
+ const found = PYTHON_CLI_INSTALLERS.find((i) => i.probe === probe);
45
+ if (!found) throw new Error(`no python installer with probe ${probe}`);
46
+ return found;
47
+ };
48
+ const PYTHON_PROBES = PYTHON_CLI_INSTALLERS.map((i) => i.probe);
49
+
50
+ /** The methods the resolver models, i.e. the ones that reach toolchain.ts. */
51
+ const RESOLVED_METHODS: InstallMethod[] = ["npm", "bun", "brew", "pip"];
52
+
53
+ describe("the catalogue is well-formed", () => {
54
+ test("it has entries, and each names a package manager and a package", () => {
55
+ // Guard: every assertion below iterates the catalogue, so an empty or
56
+ // malformed one would make them all pass vacuously.
57
+ expect(cliTools.length).toBeGreaterThan(5);
58
+ for (const tool of cliTools) {
59
+ expect(typeof tool.packageName).toBe("string");
60
+ expect(tool.packageName.length).toBeGreaterThan(0);
61
+ expect(["bun", "npm", "pip", "brew", "go"]).toContain(
62
+ tool.packageManager,
63
+ );
64
+ }
65
+ });
66
+ });
67
+
68
+ describe("no builder ever emits a bare `pip`", () => {
69
+ test("install: cmd is the resolver's probe, never `pip`", () => {
70
+ for (const tool of cliTools) {
71
+ const cmd = cliToolInstall(tool);
72
+ expect(cmd.cmd).not.toBe("pip");
73
+ if (tool.packageManager === "pip") {
74
+ // Whatever the machine running this resolved to, it must be one of
75
+ // the three modelled installers — and `cmd` must BE that probe,
76
+ // since that is the executable the syscall looks up.
77
+ expect(PYTHON_PROBES).toContain(cmd.cmd);
78
+ expect(cmd.cmd).toBe(resolvePythonCliInstaller().probe);
79
+ }
80
+ }
81
+ });
82
+
83
+ test("update and uninstall: cmd is never `pip`, for any method", () => {
84
+ for (const tool of cliTools) {
85
+ for (const method of RESOLVED_METHODS) {
86
+ for (const cmd of [
87
+ cliToolUpdate(tool, method),
88
+ cliToolUninstall(tool, method),
89
+ ]) {
90
+ if (!cmd) continue;
91
+ expect(cmd.cmd).not.toBe("pip");
92
+ }
93
+ }
94
+ }
95
+ });
96
+
97
+ test("each pinned Python installer drives all three of its own commands", () => {
98
+ // Pinned explicitly so this holds on a runner with no uv. The `pip`
99
+ // method must route to the SAME executable for install, update and
100
+ // uninstall — installing with uv and uninstalling with python3 would look
101
+ // like it worked and leave the tool on disk.
102
+ for (const probe of PYTHON_PROBES) {
103
+ const python = byProbe(probe);
104
+ expect(python.install("x").cmd).toBe(probe);
105
+ expect(python.upgrade("x").cmd).toBe(probe);
106
+ expect(python.uninstall("x").cmd).toBe(probe);
107
+ }
108
+ });
109
+ });
110
+
111
+ describe("no builder can emit shell syntax", () => {
112
+ test("no argv element of any command contains &&, | or ;", () => {
113
+ // The catalogue's only `&&` chain was aider's installCommand. With the
114
+ // field gone there is nowhere left for shell syntax to enter, and this
115
+ // asserts that over every entry and every method rather than over that
116
+ // one case.
117
+ const all: Command[] = [];
118
+ for (const tool of cliTools) {
119
+ all.push(cliToolInstall(tool));
120
+ for (const method of [
121
+ ...RESOLVED_METHODS,
122
+ "pnpm" as const,
123
+ "yarn" as const,
124
+ ]) {
125
+ const u = cliToolUpdate(tool, method);
126
+ const r = cliToolUninstall(tool, method);
127
+ if (u) all.push(u);
128
+ if (r) all.push(r);
129
+ }
130
+ }
131
+ expect(all.length).toBeGreaterThan(0);
132
+
133
+ for (const cmd of all) {
134
+ for (const token of [cmd.cmd, ...cmd.args]) {
135
+ expect(token).not.toContain("&&");
136
+ expect(token).not.toContain("|");
137
+ expect(token).not.toContain(";");
138
+ }
139
+ }
140
+ });
141
+ });
142
+
143
+ describe("the detected method is followed, not the catalogue's", () => {
144
+ test("pnpm and yarn keep their own shapes", () => {
145
+ // A tool the catalogue calls npm can still have been installed with
146
+ // pnpm. Updating it as npm would install a second copy beside the first,
147
+ // which is the conflict this screen exists to report.
148
+ const tool = cliTools.find((t) => t.packageManager === "npm")!;
149
+ expect(cliToolUpdate(tool, "pnpm")).toEqual({
150
+ cmd: "pnpm",
151
+ args: ["install", "-g", tool.packageName],
152
+ });
153
+ expect(cliToolUpdate(tool, "yarn")).toEqual({
154
+ cmd: "yarn",
155
+ args: ["global", "add", tool.packageName],
156
+ });
157
+ expect(cliToolUninstall(tool, "pnpm")).toEqual({
158
+ cmd: "pnpm",
159
+ args: ["remove", "-g", tool.packageName],
160
+ });
161
+ expect(cliToolUninstall(tool, "yarn")).toEqual({
162
+ cmd: "yarn",
163
+ args: ["global", "remove", tool.packageName],
164
+ });
165
+ });
166
+
167
+ test("`unknown` returns null rather than guessing", () => {
168
+ // Preserves the observable behaviour: handleInstall reads
169
+ // `installed && updateCommand ? updateCommand : install…`, so null falls
170
+ // back to a plain install — which is what the old `default:` arm did by
171
+ // returning `tool.installCommand`.
172
+ const tool = cliTools[0]!;
173
+ expect(cliToolUpdate(tool, "unknown")).toBeNull();
174
+ expect(cliToolUninstall(tool, "unknown")).toBeNull();
175
+ });
176
+
177
+ test("brew and go arms are pinned, since no catalogue entry exercises them", () => {
178
+ // Every entry today is bun/npm/pip, so these two ship untouched by real
179
+ // data. `go` has no uninstaller — `go install` writes a binary and
180
+ // records nothing — so null is correct rather than an invented `rm`.
181
+ const tool = cliTools[0]!;
182
+ expect(cliToolUpdate(tool, "brew", "some-formula")).toEqual({
183
+ cmd: "brew",
184
+ args: ["upgrade", "some-formula"],
185
+ });
186
+ expect(cliToolUninstall(tool, "brew", "some-formula")).toEqual({
187
+ cmd: "brew",
188
+ args: ["uninstall", "some-formula"],
189
+ });
190
+ });
191
+ });
192
+
193
+ describe("derived commands still read as they did", () => {
194
+ test("claude renders exactly the literal the catalogue used to carry", () => {
195
+ // The entry whose command SHOULD not change. Its old
196
+ // `installCommand: "npm install -g @anthropic-ai/claude-code"` is now
197
+ // derived from packageManager + packageName, and must render identically.
198
+ const claude = cliTools.find((t) => t.name === "claude")!;
199
+ expect(formatCommand(cliToolInstall(claude))).toBe(
200
+ "npm install -g @anthropic-ai/claude-code",
201
+ );
202
+ });
203
+
204
+ test("aider now installs the package its own fields already named", () => {
205
+ // The one deliberate behaviour change. The old command installed
206
+ // `aider-install`, a bootstrapper whose version nothing here ever read,
207
+ // while `packageName`, `checkCommand` and the PyPI query all said
208
+ // `aider-chat`. The installCommand was the field that was out of step.
209
+ const aider = cliTools.find((t) => t.name === "aider")!;
210
+ expect(aider.packageName).toBe("aider-chat");
211
+ expect(cliToolInstall(aider).args).toContain("aider-chat");
212
+ expect(formatCommand(cliToolInstall(aider))).not.toContain("aider-install");
213
+ });
214
+ });
215
+
216
+ describe("versionFromPypiJson", () => {
217
+ // The shape of a real https://pypi.org/pypi/<pkg>/json body, trimmed to the
218
+ // two fields that matter. Captured rather than mocked so the parser is
219
+ // tested against what the endpoint actually returns.
220
+ const body = {
221
+ info: { name: "aider-chat", version: "0.86.1" },
222
+ releases: {},
223
+ };
224
+
225
+ test("reads info.version", () => {
226
+ expect(versionFromPypiJson(body)).toBe("0.86.1");
227
+ });
228
+
229
+ test("anything unexpected yields undefined, never a throw", () => {
230
+ // A version check is advisory: it must degrade to "no update badge",
231
+ // never take the screen down. PyPI returning an error object, a 404 body,
232
+ // or nothing at all all land here.
233
+ expect(versionFromPypiJson({})).toBeUndefined();
234
+ expect(versionFromPypiJson({ info: {} })).toBeUndefined();
235
+ expect(versionFromPypiJson({ info: { version: 42 } })).toBeUndefined();
236
+ expect(versionFromPypiJson(null)).toBeUndefined();
237
+ expect(versionFromPypiJson("not json at all")).toBeUndefined();
238
+ });
239
+ });
@@ -0,0 +1,294 @@
1
+ /**
2
+ * The TUI CLI Tools row is built from the shared probe facts and the shared
3
+ * classifier — the row can no longer disagree with `magus update`.
4
+ *
5
+ * Pure: `buildToolRow` over `specForCatalogTool`, with facts supplied the way
6
+ * `probeTools` would report them. No React, no IO.
7
+ */
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import { cliTools } from "../data/cli-tools.js";
11
+ import { cliToolInstall } from "../services/cli-tool-commands.js";
12
+ import {
13
+ type ToolFacts,
14
+ classifyTool,
15
+ specForCatalogTool,
16
+ } from "../services/tool-probe.js";
17
+ import { binCommandFor } from "../services/update-engine.js";
18
+ import { planBinUpdates } from "../services/update-plan.js";
19
+ import type { ResolvedBin } from "../types/index.js";
20
+ import {
21
+ buildToolRow,
22
+ cliToolStatusText,
23
+ countToolRows,
24
+ } from "../ui/adapters/cliToolsAdapter.js";
25
+
26
+ const catalogue = (name: string) => {
27
+ const tool = cliTools.find((t) => t.name === name);
28
+ if (!tool) throw new Error(`no catalogue tool ${name}`);
29
+ return tool;
30
+ };
31
+
32
+ function facts(name: string, over: Partial<ToolFacts> = {}): ToolFacts {
33
+ return {
34
+ name,
35
+ present: true,
36
+ resolvedPath: `/x/${name}`,
37
+ installed: "1.0.0",
38
+ latest: null,
39
+ behind: null,
40
+ managed: true,
41
+ sources: [],
42
+ ...over,
43
+ };
44
+ }
45
+
46
+ describe("buildToolRow — which installer a catalogue row is judged through", () => {
47
+ test("catalogue says npm, detected bun: the spec is bun and the state is bun's", () => {
48
+ // codex and opencode on the reporter's machine: catalogued as npm,
49
+ // installed by bun into ~/.bun/install/global.
50
+ const codex = catalogue("codex");
51
+ expect(codex.packageManager).toBe("npm");
52
+ const install = { primary: "bun" as const, all: ["bun" as const] };
53
+ const spec = specForCatalogTool(codex, null, install);
54
+ expect(spec.via).toBe("bun");
55
+
56
+ const row = buildToolRow(
57
+ codex,
58
+ spec,
59
+ facts("codex", { installed: "0.153.2", latest: "0.154.0" }),
60
+ install,
61
+ );
62
+ expect(row.status).toMatchObject({
63
+ state: "upgrade",
64
+ target: "0.154.0",
65
+ });
66
+ // Enter names the same installer the row was probed through.
67
+ expect(row.updateCommand).toEqual({
68
+ cmd: "bun",
69
+ args: ["install", "-g", "@openai/codex@latest"],
70
+ });
71
+ });
72
+
73
+ test("declared by the closure with via npm: the closure wins, and the row equals the CLI plan", () => {
74
+ const codex = catalogue("codex");
75
+ const declared: ResolvedBin = {
76
+ name: "codex",
77
+ via: "npm",
78
+ package: "@openai/codex",
79
+ sources: ["profile:default.cliTools"],
80
+ };
81
+ const install = { primary: "bun" as const, all: ["bun" as const] };
82
+ const spec = specForCatalogTool(codex, [declared], install);
83
+ expect(spec).toBe(declared);
84
+
85
+ // Through npm, the bun-installed file is not npm's: not-managed.
86
+ const f = facts("codex", {
87
+ managed: false,
88
+ resolvedPath:
89
+ "/Users/j/.bun/install/global/node_modules/@openai/codex/bin/codex.js",
90
+ owner: "npm (root /Users/j/.local/lib/node_modules)",
91
+ });
92
+ const row = buildToolRow(codex, spec, f, install);
93
+ const [cli] = planBinUpdates([declared], [f]);
94
+ expect(row.status).toMatchObject({
95
+ state: cli!.action,
96
+ reason: cli!.reason,
97
+ note: cli!.note,
98
+ target: cli!.target,
99
+ });
100
+ expect(row.status?.reason).toBe("not-managed");
101
+ });
102
+
103
+ test("pinned and behind: the row's key installs the PIN, exactly what the CLI runs", () => {
104
+ // The defect this pins: the row read `→ v0.150.0` (the pin) while its
105
+ // key ran `npm install -g @openai/codex@latest`, which installs past the
106
+ // pin and leaves the tool `unknown / ahead-of-pin` for good.
107
+ const codex = catalogue("codex");
108
+ const declared: ResolvedBin = {
109
+ name: "codex",
110
+ via: "npm",
111
+ package: "@openai/codex",
112
+ version: "0.150.0",
113
+ sources: ["profile:default.cliTools"],
114
+ };
115
+ // Detected as bun — the installer the OLD command builder followed.
116
+ const install = { primary: "bun" as const, all: ["bun" as const] };
117
+ const spec = specForCatalogTool(codex, [declared], install);
118
+ const f = facts("codex", { installed: "0.149.0", managed: true });
119
+
120
+ const row = buildToolRow(codex, spec, f, install);
121
+ expect(row.status).toMatchObject({
122
+ state: "upgrade",
123
+ reason: "behind-pin",
124
+ target: "0.150.0",
125
+ });
126
+ expect(row.updateCommand).toEqual({
127
+ cmd: "npm",
128
+ args: ["install", "-g", "@openai/codex@0.150.0"],
129
+ });
130
+ // And it is the same command `magus update` would run for this row.
131
+ const [item] = planBinUpdates([declared], [f]);
132
+ expect(row.updateCommand).toEqual(binCommandFor(item!, declared)!);
133
+ });
134
+
135
+ test("declared and absent: Enter installs at the pin, not the catalogue's @latest", () => {
136
+ const codex = catalogue("codex");
137
+ const declared: ResolvedBin = {
138
+ name: "codex",
139
+ via: "npm",
140
+ package: "@openai/codex",
141
+ version: "0.150.0",
142
+ sources: ["profile:default.cliTools"],
143
+ };
144
+ const row = buildToolRow(
145
+ codex,
146
+ specForCatalogTool(codex, [declared], undefined),
147
+ facts("codex", { present: false, installed: null }),
148
+ );
149
+ expect(row.status?.state).toBe("install");
150
+ expect(row.updateCommand).toBeUndefined();
151
+ expect(row.installCommand).toEqual({
152
+ cmd: "npm",
153
+ args: ["install", "-g", "@openai/codex@0.150.0"],
154
+ });
155
+ });
156
+
157
+ test("declared, unpinned and behind: the closure's package, at latest", () => {
158
+ const codex = catalogue("codex");
159
+ const declared: ResolvedBin = {
160
+ name: "codex",
161
+ via: "npm",
162
+ package: "@openai/codex",
163
+ sources: ["plugin:dev@magus"],
164
+ };
165
+ const install = { primary: "bun" as const, all: ["bun" as const] };
166
+ const row = buildToolRow(
167
+ codex,
168
+ specForCatalogTool(codex, [declared], install),
169
+ facts("codex", { installed: "0.153.2", latest: "0.154.0" }),
170
+ install,
171
+ );
172
+ expect(row.status?.state).toBe("upgrade");
173
+ // npm, the PROFILE's installer — not bun, the detected one — because the
174
+ // row's state was judged through npm too.
175
+ expect(row.updateCommand).toEqual({
176
+ cmd: "npm",
177
+ args: ["install", "-g", "@openai/codex@latest"],
178
+ });
179
+ });
180
+
181
+ test("undeclared: the screen's own rule survives — how it IS installed", () => {
182
+ const codex = catalogue("codex");
183
+ const install = { primary: "bun" as const, all: ["bun" as const] };
184
+ const row = buildToolRow(
185
+ codex,
186
+ specForCatalogTool(codex, null, install),
187
+ facts("codex", { installed: "0.153.2", latest: "0.154.0" }),
188
+ install,
189
+ );
190
+ expect(row.updateCommand).toEqual({
191
+ cmd: "bun",
192
+ args: ["install", "-g", "@openai/codex@latest"],
193
+ });
194
+ expect(row.installCommand).toEqual(cliToolInstall(codex));
195
+ });
196
+
197
+ test("detected pnpm → the catalogue's via, and a not-managed row naming the real path", () => {
198
+ const opencode = catalogue("opencode");
199
+ const install = { primary: "pnpm" as const, all: ["pnpm" as const] };
200
+ const spec = specForCatalogTool(opencode, null, install);
201
+ expect(spec.via).toBe(opencode.packageManager);
202
+
203
+ const f = facts("opencode", {
204
+ managed: false,
205
+ resolvedPath:
206
+ "/Users/j/.local/share/pnpm/global/5/node_modules/opencode-ai/bin/opencode",
207
+ owner: "npm (root /Users/j/.local/lib/node_modules)",
208
+ });
209
+ const row = buildToolRow(opencode, spec, f, install);
210
+ expect(row.status?.reason).toBe("not-managed");
211
+ expect(row.status?.note).toContain("/Users/j/.local/share/pnpm/");
212
+ // Enter still follows how it IS installed (pnpm).
213
+ expect(row.updateCommand?.cmd).toBe("pnpm");
214
+ });
215
+
216
+ test("no facts yet → checking, no status, no command", () => {
217
+ const tool = catalogue("claudish");
218
+ const row = buildToolRow(
219
+ tool,
220
+ specForCatalogTool(tool, null, undefined),
221
+ undefined,
222
+ );
223
+ expect(row).toMatchObject({ checking: true, status: null });
224
+ expect(row.updateCommand).toBeUndefined();
225
+ expect(cliToolStatusText(row).suffix).toBe(" ...");
226
+ });
227
+
228
+ test("absent → install, with no update command", () => {
229
+ const tool = catalogue("cline");
230
+ const spec = specForCatalogTool(tool, null, undefined);
231
+ const row = buildToolRow(
232
+ tool,
233
+ spec,
234
+ facts("cline", { present: false, installed: null }),
235
+ { primary: "unknown", all: [] },
236
+ );
237
+ expect(row.status?.state).toBe("install");
238
+ expect(row.updateCommand).toBeUndefined();
239
+ });
240
+ });
241
+
242
+ describe("cliToolStatusText — the row's words", () => {
243
+ test("unknown renders as ? <note>, the note verbatim", () => {
244
+ const tool = catalogue("crush");
245
+ const spec = specForCatalogTool(tool, null, {
246
+ primary: "npm",
247
+ all: ["npm"],
248
+ });
249
+ const f = facts("crush", { managed: false, owner: "npm (root /l)" });
250
+ const row = buildToolRow(tool, spec, f);
251
+ const text = cliToolStatusText(row);
252
+ expect(text.tone).toBe("warning");
253
+ expect(text.suffix).toBe(` ? ${classifyTool(spec, f).note}`);
254
+ });
255
+
256
+ test("upgrade names the target once, with one v", () => {
257
+ const tool = catalogue("mnemex");
258
+ const spec = specForCatalogTool(tool, null, {
259
+ primary: "bun",
260
+ all: ["bun"],
261
+ });
262
+ const row = buildToolRow(tool, spec, facts("mnemex", { latest: "v1.2.0" }));
263
+ expect(cliToolStatusText(row).suffix).toBe(" → v1.2.0");
264
+ });
265
+
266
+ test("current and install say nothing extra", () => {
267
+ const tool = catalogue("mnemex");
268
+ const spec = specForCatalogTool(tool, null, undefined);
269
+ expect(
270
+ cliToolStatusText(
271
+ buildToolRow(tool, spec, facts("mnemex", { behind: false })),
272
+ ).suffix,
273
+ ).toBe("");
274
+ });
275
+ });
276
+
277
+ describe("countToolRows — header counts from states", () => {
278
+ test("installed, updates and unverified", () => {
279
+ const tool = catalogue("mnemex");
280
+ const spec = specForCatalogTool(tool, null, undefined);
281
+ const rows = [
282
+ buildToolRow(tool, spec, facts("mnemex", { behind: false })),
283
+ buildToolRow(tool, spec, facts("mnemex", { behind: true })),
284
+ buildToolRow(tool, spec, facts("mnemex", { managed: false })),
285
+ buildToolRow(tool, spec, facts("mnemex", { present: false })),
286
+ buildToolRow(tool, spec, undefined),
287
+ ];
288
+ expect(countToolRows(rows)).toEqual({
289
+ installed: 3,
290
+ updates: 1,
291
+ unverified: 1,
292
+ });
293
+ });
294
+ });