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,176 @@
1
+ /**
2
+ * A CLI Tools row, built from the SAME probe facts and the SAME classifier
3
+ * `magus update` plans with.
4
+ *
5
+ * The screen used to carry its own probe — a shell `which`, `npm view` per
6
+ * tool, and a private comparator that read "1.2.3-beta" as "1.2.3" — so the
7
+ * row and the CLI could disagree about the same tool. Now the row's state is
8
+ * `classifyTool(spec, facts)`, and for a tool the active profile declares the
9
+ * spec IS the profile's (`specForCatalogTool`), so the two cannot differ.
10
+ *
11
+ * The same goes for the COMMANDS: a declared tool's row builds them with
12
+ * `binCommandForState`, the rule `magus update`'s apply path uses, so the
13
+ * row's target and the row's key can never aim at different versions.
14
+ *
15
+ * Pure: no IO, no React. The screen gathers facts; this only assembles.
16
+ */
17
+
18
+ import type { CliTool } from "../../data/cli-tools.js";
19
+ import {
20
+ cliToolInstall,
21
+ cliToolUpdate,
22
+ } from "../../services/cli-tool-commands.js";
23
+ import {
24
+ type InstallInfo,
25
+ type ToolFacts,
26
+ type ToolStatus,
27
+ classifyTool,
28
+ } from "../../services/tool-probe.js";
29
+ import { binCommandForState } from "../../services/toolchain.js";
30
+ import type { BinRequirement, ResolvedBin } from "../../types/index.js";
31
+ import type { Command } from "../../utils/command-utils.js";
32
+ import type { StatusTone } from "../renderers/pluginStatusText.js";
33
+
34
+ export interface CliToolRow {
35
+ tool: CliTool;
36
+ /** The requirement the tool was probed through (closure → detected → catalogue). */
37
+ spec: BinRequirement;
38
+ /** True until facts arrive. */
39
+ checking: boolean;
40
+ /** Null while checking. */
41
+ status: ToolStatus | null;
42
+ install?: InstallInfo;
43
+ /**
44
+ * What Enter runs for an installed tool. For a tool the active profile
45
+ * declares this is the SAME command `magus update` would run for the
46
+ * same row (pin included); otherwise the DETECTED method's updater, so it
47
+ * names the installer the row was probed through.
48
+ */
49
+ updateCommand?: Command;
50
+ /** What Enter runs for a tool that is not installed. */
51
+ installCommand: Command;
52
+ }
53
+
54
+ /**
55
+ * Did this spec come from the resolver's closure — i.e. does the active profile
56
+ * declare this tool?
57
+ *
58
+ * `ResolvedBin` is `BinRequirement` plus `sources`, and only the resolver adds
59
+ * them, so the provenance travels with the spec itself rather than being
60
+ * re-derived here (which would be a second copy of `specForCatalogTool`'s
61
+ * branch 1, free to disagree with it).
62
+ */
63
+ function declaredSpec(spec: BinRequirement): ResolvedBin | null {
64
+ const candidate = spec as ResolvedBin;
65
+ return Array.isArray(candidate.sources) ? candidate : null;
66
+ }
67
+
68
+ export function buildToolRow(
69
+ tool: CliTool,
70
+ spec: BinRequirement,
71
+ facts: ToolFacts | undefined,
72
+ install?: InstallInfo,
73
+ ): CliToolRow {
74
+ const declared = declaredSpec(spec);
75
+ // A declared tool installs at its pin, the way `magus update` does; the
76
+ // catalogue entry has no pin to honour.
77
+ const installCommand =
78
+ (declared && binCommandForState("install", null, declared)) ||
79
+ cliToolInstall(tool);
80
+ const base = { tool, spec, installCommand, ...(install ? { install } : {}) };
81
+ if (!facts) return { ...base, checking: true, status: null };
82
+ const status = classifyTool(spec, facts);
83
+ const updateCommand = updateCommandFor(tool, declared, status, install);
84
+ return {
85
+ ...base,
86
+ checking: false,
87
+ status,
88
+ ...(updateCommand ? { updateCommand } : {}),
89
+ };
90
+ }
91
+
92
+ /**
93
+ * The command the row's key runs, from the same rule as the row's state.
94
+ *
95
+ * A declared tool goes through `binCommandForState` — the CLI apply path's own
96
+ * builder over the PROFILE's requirement — so a row whose target is the pin
97
+ * installs the pin. Building `@latest` here (what this used to do, always) let
98
+ * the TUI overshoot a pin the CLI hits exactly, and the tool then classified
99
+ * `unknown / ahead-of-pin` for good: magus never downgrades.
100
+ *
101
+ * An undeclared catalogue tool keeps the screen's own rule, "follow how it IS
102
+ * installed": there is no pin to honour, and `pnpm`/`yarn` have no
103
+ * `BinInstaller` counterpart to hand the shared builder.
104
+ */
105
+ function updateCommandFor(
106
+ tool: CliTool,
107
+ declared: ResolvedBin | null,
108
+ status: ToolStatus,
109
+ install: InstallInfo | undefined,
110
+ ): Command | undefined {
111
+ if (!isInstalled(status)) return undefined;
112
+ if (declared) {
113
+ return binCommandForState("upgrade", status.target, declared) ?? undefined;
114
+ }
115
+ if (!install) return undefined;
116
+ return cliToolUpdate(tool, install.primary, install.brewFormula) ?? undefined;
117
+ }
118
+
119
+ /** On PATH, as far as the probe could tell. */
120
+ export function isInstalled(status: ToolStatus | null): boolean {
121
+ return status !== null && status.state !== "install";
122
+ }
123
+
124
+ /** A version for display, with exactly one leading "v". */
125
+ export function displayVersion(v: string): string {
126
+ return `v${v.replace(/^v/i, "")}`;
127
+ }
128
+
129
+ export interface CliToolStatusText {
130
+ /** Text after the version, with its leading space; "" for nothing to say. */
131
+ suffix: string;
132
+ tone: StatusTone;
133
+ }
134
+
135
+ /**
136
+ * The row's status words. An `unknown` shows its note verbatim — the same
137
+ * string `magus update` prints for that tool.
138
+ */
139
+ export function cliToolStatusText(row: CliToolRow): CliToolStatusText {
140
+ const { status } = row;
141
+ if (row.checking || !status) return { suffix: " ...", tone: "muted" };
142
+ switch (status.state) {
143
+ case "unknown":
144
+ return {
145
+ suffix: ` ? ${status.note ?? "cannot tell whether it is current"}`,
146
+ tone: "warning",
147
+ };
148
+ case "upgrade":
149
+ return {
150
+ suffix: status.target
151
+ ? ` → ${displayVersion(status.target)}`
152
+ : " update available",
153
+ tone: "warning",
154
+ };
155
+ case "install":
156
+ case "current":
157
+ return { suffix: "", tone: "muted" };
158
+ }
159
+ }
160
+
161
+ /** Header counts, from the rows' states. */
162
+ export function countToolRows(rows: readonly CliToolRow[]): {
163
+ installed: number;
164
+ updates: number;
165
+ unverified: number;
166
+ } {
167
+ let installed = 0;
168
+ let updates = 0;
169
+ let unverified = 0;
170
+ for (const row of rows) {
171
+ if (isInstalled(row.status)) installed++;
172
+ if (row.status?.state === "upgrade") updates++;
173
+ if (row.status?.state === "unknown") unverified++;
174
+ }
175
+ return { installed, updates, unverified };
176
+ }
@@ -0,0 +1,213 @@
1
+ import {
2
+ BUILT_IN_PRESETS,
3
+ DEFAULT_PRESET,
4
+ presetLabel,
5
+ } from "../../data/models-presets.js";
6
+ import {
7
+ GRADES,
8
+ type ModelsConfig,
9
+ type ModelsState,
10
+ type ModelsStatus,
11
+ sameRouting,
12
+ } from "../../services/models-core.js";
13
+
14
+ // ─── Item types ───────────────────────────────────────────────────────────────
15
+
16
+ /**
17
+ * The one header row: what is routed right now.
18
+ *
19
+ * Not selectable — the cursor skips it, the same way the Styles screen skips a
20
+ * category label. It carries only what the ROW draws; the drift lines and
21
+ * warnings behind the counts live on the `ModelsStatus` the detail renderer is
22
+ * handed, so they are not stored twice and cannot disagree.
23
+ */
24
+ /**
25
+ * What the row for a diverged config is called.
26
+ *
27
+ * A fixed word, never the file's `preset` string: that string names whatever was last
28
+ * applied, and a config edited afterwards would wear a built-in's name while not being it.
29
+ */
30
+ export const CUSTOM_LABEL = "Custom";
31
+
32
+ export interface ModelsStatusItem {
33
+ id: string;
34
+ kind: "status";
35
+ label: string;
36
+ state: ModelsState;
37
+ /** The preset the project's config names, or null when there is none. */
38
+ preset: string | null;
39
+ driftCount: number;
40
+ warningCount: number;
41
+ }
42
+
43
+ export interface ModelsPresetItem {
44
+ id: string;
45
+ kind: "preset";
46
+ label: string;
47
+ config: ModelsConfig;
48
+ /** The routing this project's `models.json` names. */
49
+ active: boolean;
50
+ /** What `magus models use` picks when nothing else is chosen. */
51
+ isDefault: boolean;
52
+ /** Not one of the built-ins — this project's own, hand-edited config. */
53
+ custom: boolean;
54
+ }
55
+
56
+ export type ModelsBrowserItem = ModelsStatusItem | ModelsPresetItem;
57
+
58
+ // ─── Adapter ──────────────────────────────────────────────────────────────────
59
+
60
+ export interface BuildModelsItemsArgs {
61
+ /** What `readModelsStatus` reported, or null before the first read lands. */
62
+ status: ModelsStatus | null;
63
+ /** The validated config, or null when there is none (or it failed validation). */
64
+ config: ModelsConfig | null;
65
+ query: string;
66
+ /**
67
+ * Presets to offer. Defaults to the shipped set, which is a compile-time
68
+ * constant — so building the list still costs no I/O whatsoever.
69
+ */
70
+ presets?: ModelsConfig[];
71
+ }
72
+
73
+ /**
74
+ * Does `config` answer to the filter?
75
+ *
76
+ * Matched over the preset name AND the models it names, because "sonnet" is the
77
+ * thing a user actually looks for — the preset names encode which model leads,
78
+ * not which models appear.
79
+ */
80
+ function matches(config: ModelsConfig, lowerQuery: string): boolean {
81
+ if (!lowerQuery) return true;
82
+ // Both names are searchable: the row SHOWS "Opus with Fable help", and a user who knows
83
+ // the command types "fable-advisor". Matching only one of them makes the other look like
84
+ // a missing preset.
85
+ //
86
+ // Bound models are searchable too: a project that routes `smart` to `mate1` → `grok-4.6`
87
+ // is findable by typing `grok`, which is the name its author thinks in. The slot names
88
+ // come along with the grade models above, so both halves of the binding match.
89
+ const haystack = [
90
+ config.preset,
91
+ presetLabel(config.preset),
92
+ config.main.model,
93
+ ...GRADES.map((grade) => config.grades[grade].model),
94
+ ...Object.values(config.mates ?? {}).map((binding) => binding.model),
95
+ ]
96
+ .join(" ")
97
+ .toLowerCase();
98
+ return haystack.includes(lowerQuery);
99
+ }
100
+
101
+ /**
102
+ * Build the flat list for the Models screen.
103
+ *
104
+ * One status header, then one row per preset in the order they ship. Nothing is
105
+ * grouped: there are four of them and a grade table each, so sections would cost
106
+ * rows and buy no navigation.
107
+ *
108
+ * The header is emitted only when at least one preset row survives the filter. A
109
+ * header with nothing under it reads as a result, and would leave the empty
110
+ * state — the one thing that says how to clear the filter — unreachable.
111
+ */
112
+ export function buildModelsItems({
113
+ status,
114
+ config,
115
+ query,
116
+ presets = BUILT_IN_PRESETS,
117
+ }: BuildModelsItemsArgs): ModelsBrowserItem[] {
118
+ if (!status) return [];
119
+
120
+ const lowerQuery = query.trim().toLowerCase();
121
+ const activeName = config?.preset ?? null;
122
+
123
+ // DOES THE PROJECT'S OWN CONFIG SAY SOMETHING NO BUILT-IN DOES?
124
+ //
125
+ // The test used to be "does it name a preset the built-ins do not", and that missed the
126
+ // ordinary case entirely. `magus models use opus-lead` writes `preset: "opus-lead"`,
127
+ // and hand-editing the file afterwards — adding a `mates` block, rebinding an agent —
128
+ // leaves that name untouched. So the config carrying the mate assignments matched a
129
+ // built-in by NAME, got no row of its own, and the list rendered the BUILT-IN's contents
130
+ // under the built-in's label.
131
+ //
132
+ // The consequence was not cosmetic: the only config on the machine that can name a mate
133
+ // was unreachable on screen, so neither the agent table nor the workflow bar could ever
134
+ // show one, no matter what the file said. `magus models list` printed it correctly the
135
+ // whole time, which is what made the gap look like a rendering bug rather than a missing
136
+ // row.
137
+ //
138
+ // Compared by CONTENT now, so a project that merely applied a preset still gets one row
139
+ // (its file and the built-in agree) and a project that changed anything gets two.
140
+ const builtInNamed = presets.find((preset) => preset.preset === activeName);
141
+ const projectOwn =
142
+ config && (!builtInNamed || !sameRouting(config, builtInNamed))
143
+ ? config
144
+ : null;
145
+
146
+ const rows: ModelsPresetItem[] = presets
147
+ .filter((preset) => matches(preset, lowerQuery))
148
+ .map((preset) => ({
149
+ id: `preset:${preset.preset}`,
150
+ kind: "preset" as const,
151
+ label: presetLabel(preset.preset),
152
+ config: preset,
153
+ // `●` marks WHAT IS RUNNING, and when the project has diverged the built-in of
154
+ // that name is not it. Leaving the mark on both rows would say two different
155
+ // routings are simultaneously in force.
156
+ active: preset.preset === activeName && projectOwn === null,
157
+ isDefault: preset.preset === DEFAULT_PRESET,
158
+ custom: false,
159
+ }));
160
+
161
+ // A project whose settings differ from every built-in gets ONE row, called `Custom`.
162
+ //
163
+ // Divergence is a THING YOU HAVE, not a fault to warn about. A project can legitimately
164
+ // run models or efforts no preset ships, and the list's job is to show that as one more
165
+ // option carrying those settings — selectable, inspectable, with its own tier table and
166
+ // chart — rather than as a badge saying the config and the settings disagree.
167
+ //
168
+ // It is called `Custom` and never the string in the file's `preset` field. That string is
169
+ // whatever was last applied — `models use opus-lead` writes `opus-lead` and hand-editing
170
+ // afterwards leaves it — so showing it puts a built-in's name on a row that is not that
171
+ // built-in.
172
+ if (projectOwn && matches(projectOwn, lowerQuery)) {
173
+ rows.push({
174
+ // `project:` rather than `preset:`, because the name it carries may be a built-in's
175
+ // and two rows may not share an id.
176
+ id: `project:${projectOwn.preset}`,
177
+ kind: "preset",
178
+ label: CUSTOM_LABEL,
179
+ config: projectOwn,
180
+ active: true,
181
+ isDefault: false,
182
+ custom: true,
183
+ });
184
+ }
185
+
186
+ if (rows.length === 0) return [];
187
+
188
+ const header: ModelsStatusItem = {
189
+ id: "status",
190
+ kind: "status",
191
+ label: status.preset
192
+ ? `Model tiers: ${status.state} · ${presetLabel(status.preset)}`
193
+ : `Model tiers: ${status.state}`,
194
+ state: status.state,
195
+ preset: status.preset,
196
+ driftCount: status.drift.length,
197
+ warningCount: status.warnings.length,
198
+ };
199
+
200
+ return [header, ...rows];
201
+ }
202
+
203
+ /**
204
+ * Index of the first row the user can actually land on, or 0 if none.
205
+ *
206
+ * The status row is a header, not a choice: every key this screen binds acts on
207
+ * a preset, so parking the cursor there would make `a` and Enter do nothing on
208
+ * the row the screen opens with.
209
+ */
210
+ export function firstSelectableIndex(items: ModelsBrowserItem[]): number {
211
+ const index = items.findIndex((item) => item.kind !== "status");
212
+ return index >= 0 ? index : 0;
213
+ }