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