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,820 @@
1
+ /**
2
+ * The ONE place plugin state is decided, and the planning for `magus update`.
3
+ *
4
+ * `classifyPlugin` answers "what state is this plugin in, and why" for a plugin
5
+ * id, the version the profile pins it to, and the catalog. Every consumer reads
6
+ * its answer and nothing else: `magus update`'s plan, and in the TUI the
7
+ * Plugins row text, the detail panel, the header counts, the `U`/`a` actions
8
+ * and the scope picker. The TUI used to derive the same state from raw
9
+ * `PluginInfo` flags with its own rules, and the two front ends drifted — the
10
+ * same plugin, in the same project, could read green in one and red in the
11
+ * other, or carry a different reason in each.
12
+ *
13
+ * Tools follow the same rule: `planBinUpdates` plans from `classifyTool`
14
+ * (services/tool-probe.ts), the same classifier the TUI's CLI Tools screen
15
+ * renders, so a tool has one state in both front ends.
16
+ *
17
+ * `install` makes the machine match the manifest's pins. `update` advances it:
18
+ * anything the active profile declares but the machine lacks gets installed,
19
+ * and anything pinned "latest" moves to the newest version the marketplace
20
+ * catalog offers. Everything here is a pure function of a closure plus captured
21
+ * machine state, so the decisions are unit-testable and the callers only do IO.
22
+ *
23
+ * The one rule worth stating out loud: "cannot tell" must never be reported as
24
+ * "up to date". `PluginInfo.updateCheckFailed` exists because a rate-limited
25
+ * fetch once compared the installed version against a stale local clone, found
26
+ * them equal, and rendered the failure as a clean bill of health. That case, and
27
+ * every other one where the answer is not provable, gets its own action
28
+ * (`unknown`) rather than folding into `current`.
29
+ */
30
+
31
+ import type { ProfileSkillRef, ResolvedBin } from "../types/index.js";
32
+ import type { PluginScope } from "./claude-cli.js";
33
+ import {
34
+ type PluginInfo,
35
+ compareVersions,
36
+ isBehind,
37
+ } from "./plugin-manager.js";
38
+ import {
39
+ type ToolFacts,
40
+ type ToolReason,
41
+ type ToolState,
42
+ type ToolStatus,
43
+ classifyTool,
44
+ } from "./tool-probe.js";
45
+
46
+ // ── Plugins ──────────────────────────────────────────────────────────────────
47
+
48
+ export type PluginUpdateAction =
49
+ /** Declared by the profile, installed nowhere. */
50
+ | "install"
51
+ /** Installed at a version other than the one we should be on. */
52
+ | "update"
53
+ /** Same version, different files — only a reinstall delivers the content. */
54
+ | "repair"
55
+ /** Already on the right version. */
56
+ | "current"
57
+ /** The catalog check failed; we cannot claim either way. */
58
+ | "unknown";
59
+
60
+ /**
61
+ * WHY a plugin is in its state — a machine code, so tests and styling can key
62
+ * on it without parsing the human `note`.
63
+ */
64
+ export type PluginReason =
65
+ /** install: declared by the profile, installed nowhere. */
66
+ | "absent"
67
+ /** install: enabled in a settings file, but no registry row in any scope. */
68
+ | "enabled-not-installed"
69
+ /** unknown: a marketplace we READ does not list it. */
70
+ | "not-offered"
71
+ /** unknown: installed, unlisted, and its marketplace contributed no listed ids. */
72
+ | "marketplace-unread"
73
+ /** unknown: the catalog fetch failed ("latest" pins only). */
74
+ | "catalog-unverified"
75
+ /** current: listed, but its marketplace publishes no version to compare. */
76
+ | "no-published-version"
77
+ /** repair: files changed without a version bump. */
78
+ | "content-stale"
79
+ /** update: a scope is provably behind. */
80
+ | "behind"
81
+ /** current|update: an exact pin differs from what the marketplace offers. */
82
+ | "pin-undeliverable"
83
+ /** current: every installed scope is at (or ahead of) the target. */
84
+ | "up-to-date";
85
+
86
+ export interface PluginUpdateItem {
87
+ pluginId: string;
88
+ /** What the profile pins: an exact version, or "latest". */
89
+ pinned: string;
90
+ /** Highest version installed in any scope, or null when absent. */
91
+ installed: string | null;
92
+ /** Version the marketplace catalog offers, or null when unknown. */
93
+ available: string | null;
94
+ /** Version this item would move to. Null for `current`/`unknown`. */
95
+ target: string | null;
96
+ action: PluginUpdateAction;
97
+ /** Why it is in this state. */
98
+ reason: PluginReason;
99
+ /** Scopes holding an install this action must touch. Empty for a fresh install. */
100
+ scopes: PluginScope[];
101
+ /**
102
+ * The version installed in each scope BEFORE any work, so an apply can tell
103
+ * by reading back whether anything actually moved.
104
+ */
105
+ beforeVersions: Partial<Record<PluginScope, string>>;
106
+ /**
107
+ * The exact human reason. The CLI prints it and the TUI shows it verbatim, so
108
+ * it is the SAME string in both front ends.
109
+ */
110
+ note?: string;
111
+ }
112
+
113
+ /** Scope → installed version, for the three scopes a PluginInfo carries. */
114
+ function scopeVersions(
115
+ info: PluginInfo | undefined,
116
+ ): Array<[PluginScope, string]> {
117
+ if (!info) return [];
118
+ const out: Array<[PluginScope, string]> = [];
119
+ for (const [scope, status] of [
120
+ ["user", info.userScope],
121
+ ["project", info.projectScope],
122
+ ["local", info.localScope],
123
+ ] as const) {
124
+ if (status?.version) out.push([scope, status.version]);
125
+ }
126
+ return out;
127
+ }
128
+
129
+ /**
130
+ * The highest version installed across scopes, or null.
131
+ *
132
+ * `compareVersions` answers null for an unorderable pair, and null is not
133
+ * `>= 0`, so the reduce keeps the right-hand side when two versions cannot be
134
+ * ranked. "Highest" is not meaningful across commit ids; picking one
135
+ * deterministically is, and every caller only needs *an* installed version.
136
+ */
137
+ function highestInstalled(info: PluginInfo | undefined): string | null {
138
+ const versions = scopeVersions(info).map(([, v]) => v);
139
+ if (versions.length === 0) return null;
140
+ return versions.reduce((a, b) =>
141
+ (compareVersions(a, b) ?? -1) >= 0 ? a : b,
142
+ );
143
+ }
144
+
145
+ /**
146
+ * Scopes that are strictly BEHIND `target`.
147
+ *
148
+ * Every scope that holds an outdated install is returned, not just "user".
149
+ * Updating one hardcoded scope does not converge: drift is detected from the
150
+ * CURRENT PROJECT's resolved version, so "fixing" user scope reports success
151
+ * and leaves the project row untouched, and the same drift reappears on the
152
+ * next run.
153
+ *
154
+ * This only ever moves FORWARD, and there is deliberately no option to do
155
+ * otherwise. Nothing here can perform a downgrade: `claude plugin install`
156
+ * installs the newest published version and takes no version argument. So a
157
+ * scope ahead of the target is not a problem to fix — it is a state this tool
158
+ * cannot change, and planning against it produces a move that runs, appears to
159
+ * succeed, lands on the same version as before, and is replanned identically on
160
+ * the next run.
161
+ *
162
+ * A "not equal to target" comparison lived here briefly, on the theory that an
163
+ * explicit pin is absolute and moving down to it is as correct as moving up.
164
+ * Both halves were wrong: the pin is not deliverable, and the downgrade is not
165
+ * performable.
166
+ */
167
+ export function scopesNeeding(
168
+ info: PluginInfo | undefined,
169
+ target: string,
170
+ ): PluginScope[] {
171
+ // `isBehind`, not a raw compare: a scope holding a commit sha that differs
172
+ // from the published one IS behind, even though the two carry no ordering.
173
+ // A raw `compareVersions(...) > 0` reported false for that pair and the
174
+ // scope was silently left alone. An unknown (null) is still left alone —
175
+ // moving a scope we cannot rank would be a guess.
176
+ return scopeVersions(info)
177
+ .filter(([, version]) => isBehind(target, version) === true)
178
+ .map(([scope]) => scope);
179
+ }
180
+
181
+ /** The "@marketplace" suffix of an id, or null when it has none to reason about. */
182
+ function marketplaceSuffix(id: string): string | null {
183
+ const at = id.indexOf("@");
184
+ if (at < 0) return null;
185
+ const suffix = id.slice(at);
186
+ return suffix.length < 2 ? null : suffix;
187
+ }
188
+
189
+ /**
190
+ * The catalog, indexed for the questions the classifier asks of it.
191
+ *
192
+ * "Listed" is narrower than "present". `getAvailablePlugins` puts ORPHANS —
193
+ * installed or enabled, but published by no marketplace we read — into the same
194
+ * list with `isOrphaned: true`, so a plain `has(id)` is true for a plugin that
195
+ * nothing publishes. Treating an orphan as listed is what let an orphan plan as
196
+ * `current`: its `version` is filled in from its own installed version, so the
197
+ * compare found them equal.
198
+ */
199
+ export interface CatalogIndex {
200
+ get(pluginId: string): PluginInfo | undefined;
201
+ /** In the catalog AND not an orphan: a marketplace we read publishes it. */
202
+ isListed(pluginId: string): boolean;
203
+ /** Some OTHER listed id shares this id's "@marketplace" suffix. False for ids without "@". */
204
+ marketplaceIsKnown(pluginId: string): boolean;
205
+ }
206
+
207
+ export function indexCatalog(
208
+ catalog: ReadonlyMap<string, PluginInfo> | readonly PluginInfo[],
209
+ ): CatalogIndex {
210
+ const byId: ReadonlyMap<string, PluginInfo> =
211
+ catalog instanceof Map
212
+ ? catalog
213
+ : new Map((catalog as readonly PluginInfo[]).map((p) => [p.id, p]));
214
+
215
+ // Listed ids per marketplace suffix, counted once. The previous inline check
216
+ // scanned every catalog key for every plugin, which is quadratic in a catalog
217
+ // of ~300 official plugins.
218
+ const listedPerSuffix = new Map<string, number>();
219
+ for (const [id, info] of byId) {
220
+ if (info.isOrphaned) continue;
221
+ const suffix = marketplaceSuffix(id);
222
+ if (suffix)
223
+ listedPerSuffix.set(suffix, (listedPerSuffix.get(suffix) ?? 0) + 1);
224
+ }
225
+
226
+ const isListed = (id: string): boolean => {
227
+ const info = byId.get(id);
228
+ return !!info && !info.isOrphaned;
229
+ };
230
+
231
+ return {
232
+ get: (id) => byId.get(id),
233
+ isListed,
234
+ marketplaceIsKnown: (id) => {
235
+ const suffix = marketplaceSuffix(id);
236
+ if (!suffix) return false;
237
+ const others =
238
+ (listedPerSuffix.get(suffix) ?? 0) - (isListed(id) ? 1 : 0);
239
+ return others > 0;
240
+ },
241
+ };
242
+ }
243
+
244
+ /** True when a settings file enables the plugin in at least one scope. */
245
+ function enabledAnywhere(info: PluginInfo | undefined): boolean {
246
+ return (
247
+ !!info?.userScope?.enabled ||
248
+ !!info?.projectScope?.enabled ||
249
+ !!info?.localScope?.enabled
250
+ );
251
+ }
252
+
253
+ /**
254
+ * THE plugin classifier. Pure.
255
+ *
256
+ * The rules are evaluated in order and the first match wins. Each branch below
257
+ * returns, so reading top to bottom is reading the precedence.
258
+ *
259
+ * @param pluginId the plugin
260
+ * @param pin what it is judged against: an exact version, or "latest"
261
+ * @param index the catalog — see {@link indexCatalog}
262
+ */
263
+ export function classifyPlugin(
264
+ pluginId: string,
265
+ pin: string,
266
+ index: CatalogIndex,
267
+ ): PluginUpdateItem {
268
+ const info = index.get(pluginId);
269
+ const listed = index.isListed(pluginId);
270
+ const installed = highestInstalled(info);
271
+ // An orphan's `version` is its own installed version (or a stale local
272
+ // clone's), not something a marketplace we read offers — so it is not
273
+ // "available" from anywhere.
274
+ const available = listed ? (info?.version ?? null) : null;
275
+
276
+ const base = {
277
+ pluginId,
278
+ pinned: pin,
279
+ installed,
280
+ available,
281
+ beforeVersions: Object.fromEntries(scopeVersions(info)) as Partial<
282
+ Record<PluginScope, string>
283
+ >,
284
+ };
285
+
286
+ // Declared by the profile, installed nowhere, and its marketplace IS
287
+ // known to us but does not list it. That pin names a plugin that cannot
288
+ // be installed, and saying so is the whole point of this branch.
289
+ //
290
+ // Measured on the reporter's machine: mnemex's profile pinned
291
+ // `agentdev@magus` (retired) and `seo@magus` (moved to magus-marketing),
292
+ // and every run reported "3 install" while two of the three could never
293
+ // succeed — `claude plugin install` had nothing to fetch. Applied, failed,
294
+ // replanned identically, forever, with no diagnostic.
295
+ //
296
+ // "Not offered" is CATALOG MEMBERSHIP, not a missing version. Keying this
297
+ // on `available === null` was wrong twice over, and the second way was
298
+ // worse than the bug it replaced:
299
+ //
300
+ // - 278 of the 292 plugins in `claude-plugins-official` declare no
301
+ // version at all — `feature-dev` among them. They are listed, they are
302
+ // installable, and `available` is null for every one. Treating that as
303
+ // "not offered" made a profile pin for any of them plan `unknown` and
304
+ // never install. Measured on the reporting machine.
305
+ // - An INSTALLED plugin whose catalog version is unreadable is handled
306
+ // further down against what is on disk. Hence `!installed` here.
307
+ //
308
+ // Listing answers the real question directly. The remaining trap is that a
309
+ // marketplace this machine has not registered yet contributes NO entries, so
310
+ // its plugins are also absent — but installing one is exactly what registers
311
+ // the marketplace. `marketplaceIsKnown` separates the two: holding other
312
+ // LISTED ids from that marketplace proves we genuinely read its catalog, so
313
+ // absence is an answer; holding none proves nothing.
314
+ if (!listed && !installed && index.marketplaceIsKnown(pluginId)) {
315
+ return {
316
+ ...base,
317
+ target: null,
318
+ action: "unknown",
319
+ reason: "not-offered",
320
+ scopes: [],
321
+ note: "not offered by any configured marketplace",
322
+ };
323
+ }
324
+
325
+ // Not installed anywhere — the "checked but not installed" case.
326
+ //
327
+ // Nothing is installed in any scope here, so "enabled somewhere" is exactly
328
+ // "enabled but not installed". It is tested directly rather than through
329
+ // `isEnabledButNotInstalled`, which excludes orphans: an orphan that is only
330
+ // a settings key would otherwise read as `absent`, and the TUI renders
331
+ // `absent` as "the profile declares it", which an undeclared orphan does not.
332
+ if (!installed) {
333
+ const brokenEnable = enabledAnywhere(info);
334
+ return {
335
+ ...base,
336
+ target: pin === "latest" ? available : pin,
337
+ action: "install",
338
+ reason: brokenEnable ? "enabled-not-installed" : "absent",
339
+ scopes: [],
340
+ ...(brokenEnable
341
+ ? { note: "enabled in settings, but no files on disk" }
342
+ : {}),
343
+ };
344
+ }
345
+
346
+ // Installed, but no marketplace we read lists it — an orphan.
347
+ //
348
+ // Planning this against its own installed version (which is what an
349
+ // orphan's `version` holds) found the two equal and reported `current`,
350
+ // while the TUI rendered the same plugin red as "deprecated". Neither is
351
+ // the fact. What is known is one of three things, and each gets its own
352
+ // text: it moved to a marketplace that does publish it; a marketplace we
353
+ // did read no longer lists it; or its marketplace contributed nothing we
354
+ // could read, so its absence proves nothing either way.
355
+ if (!listed) {
356
+ const orphan = {
357
+ ...base,
358
+ target: null,
359
+ action: "unknown" as const,
360
+ scopes: [],
361
+ };
362
+ if (index.marketplaceIsKnown(pluginId)) {
363
+ return {
364
+ ...orphan,
365
+ reason: "not-offered",
366
+ note: "not offered by any configured marketplace",
367
+ };
368
+ }
369
+ const mp =
370
+ info?.marketplace || marketplaceSuffix(pluginId)?.slice(1) || pluginId;
371
+ return {
372
+ ...orphan,
373
+ reason: "marketplace-unread",
374
+ note: `its marketplace ${mp} was not read, so whether it still offers this plugin is unknown`,
375
+ };
376
+ }
377
+
378
+ // An exact pin is planned against what can ACTUALLY be delivered.
379
+ //
380
+ // This is dependency management, not a package manager: there is no
381
+ // resolver and no version negotiation. `claude plugin install` takes no
382
+ // version — it fetches whatever the marketplace publishes now — so a pin
383
+ // naming anything else is a request nothing can satisfy.
384
+ //
385
+ // Planning toward the pin regardless is an infinite loop with a progress
386
+ // bar: plan 9.9.9 → 4.6.1, install, get 9.9.9 back, replan the same thing
387
+ // next run, and fail `--check` every time in between. Targeting the
388
+ // deliverable version converges, and the note carries the shortfall so it
389
+ // is recorded rather than hidden. `install --check` + `strictVersions`
390
+ // remains the place that GATES on pin drift; this command reports it.
391
+ //
392
+ // `scopesNeeding` only ever moves forward, so a scope already AHEAD of the
393
+ // deliverable version is left alone rather than downgraded — the same rule
394
+ // as the "latest" path below, because in both cases the target is what the
395
+ // marketplace currently publishes.
396
+ //
397
+ // The decision comes from the PER-SCOPE versions, never from `installed`.
398
+ // `installed` is the highest across scopes, so comparing it to the target
399
+ // hides the case that matters most: user scope at the target and project
400
+ // scope behind it reads as "current" while the project — the scope that
401
+ // actually loads for this repo — stays stale.
402
+ if (pin !== "latest") {
403
+ const deliverable = available ?? pin;
404
+ const needing = scopesNeeding(info, deliverable);
405
+
406
+ // A scope that cannot be ordered against the deliverable contributes
407
+ // nothing to `needing` and lands on `current`. That is the decision: if we
408
+ // cannot compare, assume what is installed is the latest. See the "latest"
409
+ // path below for the same rule and what it costs.
410
+
411
+ const undeliverable = available !== null && available !== pin;
412
+ return {
413
+ ...base,
414
+ target: deliverable,
415
+ action: needing.length === 0 ? "current" : "update",
416
+ reason: undeliverable
417
+ ? "pin-undeliverable"
418
+ : needing.length === 0
419
+ ? "up-to-date"
420
+ : "behind",
421
+ scopes: needing,
422
+ ...(undeliverable
423
+ ? {
424
+ note: `manifest pins ${pin}; the marketplace offers only ${available}`,
425
+ }
426
+ : {}),
427
+ };
428
+ }
429
+
430
+ // "latest" — and the catalog is the only source for what that means.
431
+ if (info?.updateCheckFailed) {
432
+ return {
433
+ ...base,
434
+ target: null,
435
+ action: "unknown",
436
+ reason: "catalog-unverified",
437
+ scopes: [],
438
+ note:
439
+ info.updateCheckFailure?.detail ??
440
+ "catalog fetch failed — cannot tell whether a newer version exists",
441
+ };
442
+ }
443
+
444
+ // Same version, changed files. There is no version bump to detect, so a
445
+ // plain install is a no-op ("Plugin is already installed") and only
446
+ // uninstall+install refreshes the content.
447
+ if (info?.contentStale) {
448
+ return {
449
+ ...base,
450
+ target: installed,
451
+ action: "repair",
452
+ reason: "content-stale",
453
+ scopes: scopeVersions(info).map(([scope]) => scope),
454
+ note: "files changed without a version bump",
455
+ };
456
+ }
457
+
458
+ // Installed and listed, with nothing to compare against — still published,
459
+ // still installable, we simply have no number. 278 of the 292 plugins in
460
+ // `claude-plugins-official` are in this state.
461
+ //
462
+ // By decision this is CURRENT: if we cannot compare, assume what is installed
463
+ // is the latest. It was `unknown`, which put a red badge and a sentence on a
464
+ // quarter of the report for a fact nobody can act on. The `reason` is kept so
465
+ // a caller can still tell this apart from a verified match; the note is not,
466
+ // because there is nothing to say.
467
+ if (!available) {
468
+ return {
469
+ ...base,
470
+ target: installed,
471
+ action: "current",
472
+ reason: "no-published-version",
473
+ scopes: [],
474
+ };
475
+ }
476
+
477
+ // Again per-scope, and only ever forward.
478
+ const needing = scopesNeeding(info, available);
479
+
480
+ // A scope whose version cannot be ordered against the catalog contributes
481
+ // nothing to `needing`, so it lands on `current`. That is the decision, not
482
+ // an oversight: if we cannot compare, assume the installed one is the latest.
483
+ //
484
+ // What it costs, stated plainly: such a plugin can never report an update,
485
+ // and reads exactly like a verified-current one. What it buys: `--check` can
486
+ // pass at all. It used to fail forever on the 21 registry rows carrying the
487
+ // literal string "unknown" as their version, across 8
488
+ // `@claude-plugins-official` ids — a gate nothing could make green.
489
+
490
+ return {
491
+ ...base,
492
+ target: needing.length === 0 ? installed : available,
493
+ action: needing.length === 0 ? "current" : "update",
494
+ reason: needing.length === 0 ? "up-to-date" : "behind",
495
+ scopes: needing,
496
+ };
497
+ }
498
+
499
+ /**
500
+ * Decide what to do with every plugin the profile declares.
501
+ *
502
+ * @param pinned closure.plugins — pluginId -> exact version | "latest"
503
+ * @param catalog getAvailablePlugins() output, keyed by plugin id
504
+ */
505
+ export function planPluginUpdates(
506
+ pinned: Record<string, string>,
507
+ catalog: Map<string, PluginInfo>,
508
+ ): PluginUpdateItem[] {
509
+ const index = indexCatalog(catalog);
510
+ return Object.entries(pinned)
511
+ .map(([pluginId, pin]) => classifyPlugin(pluginId, pin, index))
512
+ .sort((a, b) => a.pluginId.localeCompare(b.pluginId));
513
+ }
514
+
515
+ /**
516
+ * The pin a TUI row is judged against.
517
+ *
518
+ * 1. The active profile's pin, when it declares the plugin — the SAME value
519
+ * `magus update` plans with, which is what makes the two agree.
520
+ * 2. Otherwise "latest", when the plugin is installed in any scope or enabled
521
+ * in one — what the TUI's own update actions have always planned with, so
522
+ * the row and the action agree.
523
+ * 3. Otherwise null: an undeclared catalog entry the user is only browsing. It
524
+ * gets no status at all. `install` means "the profile wants it", and an
525
+ * undeclared plugin carries no such claim.
526
+ *
527
+ * A consequence the renderer relies on: reason `absent` can only come from
528
+ * rule 1, because rule 2 never assigns a pin to a row that is neither installed
529
+ * nor enabled.
530
+ */
531
+ export function pinForRow(
532
+ info: PluginInfo,
533
+ pins: Readonly<Record<string, string>>,
534
+ ): string | null {
535
+ if (Object.hasOwn(pins, info.id)) return pins[info.id] ?? null;
536
+ if (scopeVersions(info).length > 0 || enabledAnywhere(info)) return "latest";
537
+ return null;
538
+ }
539
+
540
+ /**
541
+ * One status per row that has a pin. Rows whose {@link pinForRow} is null are
542
+ * absent from the map.
543
+ *
544
+ * Computed once per data/pins change, and the same map feeds every consumer —
545
+ * so the row, the detail panel, the header and the scope picker cannot disagree.
546
+ */
547
+ export function classifyPluginRows(
548
+ plugins: readonly PluginInfo[],
549
+ pins: Readonly<Record<string, string>>,
550
+ ): Map<string, PluginUpdateItem> {
551
+ const index = indexCatalog(plugins);
552
+ const out = new Map<string, PluginUpdateItem>();
553
+ for (const info of plugins) {
554
+ const pin = pinForRow(info, pins);
555
+ if (pin === null) continue;
556
+ out.set(info.id, classifyPlugin(info.id, pin, index));
557
+ }
558
+ return out;
559
+ }
560
+
561
+ export interface PluginStateCounts {
562
+ install: number;
563
+ update: number;
564
+ repair: number;
565
+ current: number;
566
+ unknown: number;
567
+ }
568
+
569
+ /** Counts per action. One implementation, so a header cannot recount differently. */
570
+ export function countPluginStates(
571
+ items: Iterable<PluginUpdateItem>,
572
+ ): PluginStateCounts {
573
+ const counts: PluginStateCounts = {
574
+ install: 0,
575
+ update: 0,
576
+ repair: 0,
577
+ current: 0,
578
+ unknown: 0,
579
+ };
580
+ for (const item of items) counts[item.action]++;
581
+ return counts;
582
+ }
583
+
584
+ /**
585
+ * The scope picker's one question: would advancing THIS scope be an update?
586
+ *
587
+ * Only a provable `update` that touches the scope says yes. A scope ahead of the
588
+ * catalog, one that cannot be ordered, and a `repair` all say no — `claude
589
+ * plugin install` on the same version is a no-op, so offering "update" there
590
+ * runs something that changes nothing.
591
+ */
592
+ export function scopeIsBehind(
593
+ status: PluginUpdateItem | null | undefined,
594
+ scope: PluginScope,
595
+ ): boolean {
596
+ return status?.action === "update" && status.scopes.includes(scope);
597
+ }
598
+
599
+ // ── Binaries ─────────────────────────────────────────────────────────────────
600
+
601
+ /**
602
+ * A tool's action is exactly its state from `classifyTool` — the same word the
603
+ * TUI's CLI Tools screen shows for the same tool.
604
+ */
605
+ export type BinUpdateAction = ToolState;
606
+
607
+ export interface BinUpdateItem {
608
+ name: string;
609
+ action: BinUpdateAction;
610
+ /** Why it is in this state. */
611
+ reason: ToolReason;
612
+ /** The exact human reason; the same string the TUI shows. */
613
+ note?: string;
614
+ /**
615
+ * Set when the upgrade will take the tool over from another installer —
616
+ * {@link ToolStatus.adopted}. The row must say so: it is an ordinary
617
+ * `upgrade` otherwise, and the owner changing is not something to discover
618
+ * afterwards.
619
+ */
620
+ adopted?: string;
621
+ /** The exact pin, when the profile declares one. */
622
+ version?: string;
623
+ /** The version PATH runs, or null when absent / unreadable. */
624
+ installed: string | null;
625
+ /** The newest version its installer offers, when known. */
626
+ latest: string | null;
627
+ /** The version an install/upgrade aims at, when known. */
628
+ target: string | null;
629
+ sources: string[];
630
+ }
631
+
632
+ /**
633
+ * Plan binary work from probe facts, through {@link classifyTool}.
634
+ *
635
+ * This used to plan from a presence check alone: present-and-unpinned was an
636
+ * UPGRADE, every run, because nothing could say whether the tool was behind.
637
+ * `brew upgrade tmux` on a current tmux, `uv tool install --force --upgrade`
638
+ * on a current browser-use — each "succeeded", changed nothing, and was
639
+ * planned identically the next time. Now an upgrade is planned only when the
640
+ * installer that owns the running binary says it is behind (or its registry
641
+ * does), and a tool whose state cannot be established is `unknown`, which is
642
+ * shown and never applied.
643
+ *
644
+ * Facts are matched by name. A bin with no facts entry was never probed, and
645
+ * that is `unknown` too — "not checked" is not "fine".
646
+ */
647
+ export function planBinUpdates(
648
+ bins: ResolvedBin[],
649
+ facts: readonly ToolFacts[],
650
+ ): BinUpdateItem[] {
651
+ const byName = new Map(facts.map((f) => [f.name, f]));
652
+ return bins.map((bin) => {
653
+ const f = byName.get(bin.name);
654
+ const status: ToolStatus = f
655
+ ? classifyTool(bin, f)
656
+ : {
657
+ name: bin.name,
658
+ state: "unknown",
659
+ reason: "not-probed",
660
+ note: "not probed",
661
+ installed: null,
662
+ latest: null,
663
+ target: null,
664
+ };
665
+ const item: BinUpdateItem = {
666
+ name: bin.name,
667
+ action: status.state,
668
+ reason: status.reason,
669
+ installed: status.installed,
670
+ latest: status.latest,
671
+ target: status.target,
672
+ sources: bin.sources,
673
+ };
674
+ if (status.note) item.note = status.note;
675
+ if (status.adopted) item.adopted = status.adopted;
676
+ if (bin.version) item.version = bin.version;
677
+ return item;
678
+ });
679
+ }
680
+
681
+ /**
682
+ * Whether a bin item is WORK: an install or an upgrade.
683
+ *
684
+ * The one predicate `applyBins`, `planWorkItems` and `planHasWork` share. The
685
+ * apply loop used to skip only `current` and run everything else, so the day an
686
+ * `unknown` existed it would have been applied — "cannot tell" executed as an
687
+ * upgrade. One predicate means the three cannot disagree about that.
688
+ */
689
+ export function binNeedsWork(item: BinUpdateItem): boolean {
690
+ return item.action === "install" || item.action === "upgrade";
691
+ }
692
+
693
+ // ── Skills ───────────────────────────────────────────────────────────────────
694
+
695
+ export interface SkillUpdateItem {
696
+ name: string;
697
+ ref: ProfileSkillRef;
698
+ action: "install" | "refresh";
699
+ }
700
+
701
+ /**
702
+ * Plan skill work.
703
+ *
704
+ * A profile's skill refs carry no content hash, so "is this one behind?" is not
705
+ * answerable without fetching every file. Skills are small and the fetch is the
706
+ * same call either way, so `update` re-fetches the installed ones instead of
707
+ * guessing. The distinction is kept in the REPORT — a user needs to see which
708
+ * skills were newly added — not in the work.
709
+ */
710
+ export function planSkillUpdates(
711
+ skills: ProfileSkillRef[],
712
+ installedNames: ReadonlySet<string>,
713
+ ): SkillUpdateItem[] {
714
+ return skills.map((ref) => ({
715
+ name: ref.name,
716
+ ref,
717
+ action: installedNames.has(ref.name) ? "refresh" : "install",
718
+ }));
719
+ }
720
+
721
+ // ── Summary ──────────────────────────────────────────────────────────────────
722
+
723
+ export interface UpdatePlan {
724
+ profileId: string;
725
+ plugins: PluginUpdateItem[];
726
+ bins: BinUpdateItem[];
727
+ skills: SkillUpdateItem[];
728
+ /**
729
+ * The full requirement behind each bin item, by name. The plan items carry
730
+ * only the decision; the installer command needs `via`/`package`/`formula`,
731
+ * and re-deriving those from a name would guess.
732
+ */
733
+ binSpecs: Map<string, ResolvedBin>;
734
+ }
735
+
736
+ /**
737
+ * True when applying the plan would do any work at all — including work that is
738
+ * only ever best-effort, like re-fetching an already-installed skill.
739
+ *
740
+ * Drives the "nothing to do" early exit. NOT the `--check` gate: see
741
+ * {@link planIsBehind}.
742
+ */
743
+ export function planHasWork(plan: UpdatePlan): boolean {
744
+ return (
745
+ plan.plugins.some((p) => p.action !== "current") ||
746
+ plan.bins.some(binNeedsWork) ||
747
+ plan.skills.length > 0
748
+ );
749
+ }
750
+
751
+ /**
752
+ * True when something is actually MISSING or KNOWN TO BE BEHIND. This is what
753
+ * `--check` gates on, and it is deliberately narrower than {@link planHasWork}.
754
+ *
755
+ * The distinction exists because a skill is re-run every time without that
756
+ * meaning anything is wrong: its manifest ref carries no content hash, so it is
757
+ * re-fetched rather than compared. Counting that as drift made `--check` exit 1
758
+ * forever for any profile that declared a single skill — a gate that can never
759
+ * pass tells you nothing, and would have been switched off in CI within a week.
760
+ *
761
+ * A bin `upgrade` DOES count now. It used to be excluded because an unpinned
762
+ * binary had no version probe and "upgrade" meant "re-run the installer"; it
763
+ * now means the owning installer says the tool is behind, which is drift.
764
+ *
765
+ * NEITHER an `unknown` plugin NOR an `unknown` tool counts. "We could not check
766
+ * this" is not evidence of drift, and most unknowns are structural and
767
+ * permanent rather than transient: a plugin whose marketplace publishes no
768
+ * version (278 of the official ones), a tool on PATH but installed by a
769
+ * different npm, `go` absent, a private registry. A gate that exits 1 on those
770
+ * can never pass, so it protects nothing and is switched off. The cost is that
771
+ * an item whose check merely FAILED this run — a rate-limited catalog fetch, a
772
+ * timed-out probe — also passes, so the exit code alone must never be read as
773
+ * "everything verified": the caller prints how many items went unchecked
774
+ * (`summarizePlan`'s `unknownPlugins`/`unknownTools`, rendered by
775
+ * `cli/update-view.ts`), and every unknown row carries its own reason above.
776
+ *
777
+ * Both unknowns are listed positively rather than as `!== "current"`, so a new
778
+ * action has to be classified here deliberately instead of defaulting into the
779
+ * gate.
780
+ */
781
+ export function planIsBehind(plan: UpdatePlan): boolean {
782
+ return (
783
+ plan.plugins.some(
784
+ (p) =>
785
+ p.action === "install" ||
786
+ p.action === "update" ||
787
+ p.action === "repair",
788
+ ) ||
789
+ plan.bins.some((b) => b.action === "install" || b.action === "upgrade") ||
790
+ plan.skills.some((s) => s.action === "install")
791
+ );
792
+ }
793
+
794
+ /** Counts for the closing summary line, in a stable order. */
795
+ export function summarizePlan(plan: UpdatePlan): {
796
+ installed: number;
797
+ updated: number;
798
+ repaired: number;
799
+ current: number;
800
+ /** unknownPlugins + unknownTools. */
801
+ unknown: number;
802
+ unknownPlugins: number;
803
+ unknownTools: number;
804
+ } {
805
+ const count = (a: PluginUpdateAction) =>
806
+ plan.plugins.filter((p) => p.action === a).length;
807
+ const bins = (a: BinUpdateAction) =>
808
+ plan.bins.filter((b) => b.action === a).length;
809
+ const unknownPlugins = count("unknown");
810
+ const unknownTools = bins("unknown");
811
+ return {
812
+ installed: count("install") + bins("install"),
813
+ updated: count("update") + bins("upgrade"),
814
+ repaired: count("repair"),
815
+ current: count("current") + bins("current"),
816
+ unknown: unknownPlugins + unknownTools,
817
+ unknownPlugins,
818
+ unknownTools,
819
+ };
820
+ }