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,1373 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { PluginInfo } from "../services/plugin-manager.js";
3
+ import { type ToolFacts, classifyTool } from "../services/tool-probe.js";
4
+ import {
5
+ applyBins,
6
+ binCommandFor,
7
+ planWorkItems,
8
+ } from "../services/update-engine.js";
9
+ import {
10
+ type BinUpdateAction,
11
+ type BinUpdateItem,
12
+ type PluginUpdateItem,
13
+ type UpdatePlan,
14
+ binNeedsWork,
15
+ classifyPlugin,
16
+ classifyPluginRows,
17
+ countPluginStates,
18
+ indexCatalog,
19
+ pinForRow,
20
+ planBinUpdates,
21
+ planHasWork,
22
+ planIsBehind,
23
+ planPluginUpdates,
24
+ planSkillUpdates,
25
+ scopeIsBehind,
26
+ scopesNeeding,
27
+ summarizePlan,
28
+ } from "../services/update-plan.js";
29
+ import type { ResolvedBin } from "../types/index.js";
30
+
31
+ /** A PluginInfo with only the fields the planner reads. */
32
+ function info(overrides: Partial<PluginInfo> & { id: string }): PluginInfo {
33
+ return {
34
+ name: overrides.id.split("@")[0]!,
35
+ version: null,
36
+ description: "",
37
+ marketplace: overrides.id.split("@")[1] ?? "magus",
38
+ marketplaceDisplay: "",
39
+ enabled: true,
40
+ ...overrides,
41
+ } as PluginInfo;
42
+ }
43
+
44
+ function catalog(...infos: PluginInfo[]): Map<string, PluginInfo> {
45
+ return new Map(infos.map((i) => [i.id, i]));
46
+ }
47
+
48
+ /** Probe facts for a present, managed tool; override what the case is about. */
49
+ function toolFacts(name: string, over: Partial<ToolFacts> = {}): ToolFacts {
50
+ return {
51
+ name,
52
+ present: true,
53
+ resolvedPath: `/bin/${name}`,
54
+ installed: "1.0.0",
55
+ latest: null,
56
+ behind: null,
57
+ managed: true,
58
+ sources: [],
59
+ ...over,
60
+ };
61
+ }
62
+
63
+ function plan(overrides: Partial<UpdatePlan> = {}): UpdatePlan {
64
+ return {
65
+ profileId: "test",
66
+ plugins: [],
67
+ bins: [],
68
+ skills: [],
69
+ binSpecs: new Map(),
70
+ ...overrides,
71
+ };
72
+ }
73
+
74
+ describe("planPluginUpdates — the profile declares it, the machine lacks it", () => {
75
+ test("a plugin absent from every scope is an install", () => {
76
+ const [item] = planPluginUpdates(
77
+ { "dev@magus": "latest" },
78
+ catalog(info({ id: "dev@magus", version: "4.7.0" })),
79
+ );
80
+ expect(item).toMatchObject({
81
+ action: "install",
82
+ installed: null,
83
+ target: "4.7.0",
84
+ scopes: [],
85
+ });
86
+ expect(item.reason).toBe("absent");
87
+ });
88
+
89
+ test("a plugin missing from the catalog entirely is still an install", () => {
90
+ // The marketplace may not be registered yet — install registers it.
91
+ const [item] = planPluginUpdates({ "new@magus": "latest" }, catalog());
92
+ expect(item).toMatchObject({ action: "install", available: null });
93
+ expect(item.reason).toBe("absent");
94
+ });
95
+
96
+ test("an exact pin that is not installed installs that exact version", () => {
97
+ const [item] = planPluginUpdates(
98
+ { "dev@magus": "4.6.1" },
99
+ catalog(info({ id: "dev@magus", version: "4.7.0" })),
100
+ );
101
+ expect(item.target).toBe("4.6.1");
102
+ expect(item.reason).toBe("absent");
103
+ });
104
+ });
105
+
106
+ describe("planPluginUpdates — advancing a 'latest' pin", () => {
107
+ test("a newer catalog version is an update, targeting it", () => {
108
+ const [item] = planPluginUpdates(
109
+ { "dev@magus": "latest" },
110
+ catalog(
111
+ info({
112
+ id: "dev@magus",
113
+ version: "4.7.0",
114
+ projectScope: { enabled: true, version: "4.6.1" },
115
+ }),
116
+ ),
117
+ );
118
+ expect(item).toMatchObject({
119
+ action: "update",
120
+ installed: "4.6.1",
121
+ target: "4.7.0",
122
+ scopes: ["project"],
123
+ });
124
+ expect(item.reason).toBe("behind");
125
+ });
126
+
127
+ test("the same version is current, with no scopes to touch", () => {
128
+ const [item] = planPluginUpdates(
129
+ { "dev@magus": "latest" },
130
+ catalog(
131
+ info({
132
+ id: "dev@magus",
133
+ version: "4.7.0",
134
+ projectScope: { enabled: true, version: "4.7.0" },
135
+ }),
136
+ ),
137
+ );
138
+ expect(item).toMatchObject({ action: "current", scopes: [] });
139
+ expect(item.reason).toBe("up-to-date");
140
+ });
141
+
142
+ test("an older catalog version does not downgrade a 'latest' pin", () => {
143
+ const [item] = planPluginUpdates(
144
+ { "dev@magus": "latest" },
145
+ catalog(
146
+ info({
147
+ id: "dev@magus",
148
+ version: "4.5.0",
149
+ projectScope: { enabled: true, version: "4.7.0" },
150
+ }),
151
+ ),
152
+ );
153
+ expect(item.action).toBe("current");
154
+ expect(item.reason).toBe("up-to-date");
155
+ });
156
+ });
157
+
158
+ describe("planPluginUpdates — a failed check is never 'up to date'", () => {
159
+ test("updateCheckFailed yields 'unknown', not 'current'", () => {
160
+ const [item] = planPluginUpdates(
161
+ { "dev@magus": "latest" },
162
+ catalog(
163
+ info({
164
+ id: "dev@magus",
165
+ // The stale local clone reports the same version we have installed:
166
+ // without the flag this is indistinguishable from a clean check.
167
+ version: "4.6.1",
168
+ projectScope: { enabled: true, version: "4.6.1" },
169
+ updateCheckFailed: true,
170
+ updateCheckFailure: {
171
+ marketplace: "magus",
172
+ kind: "rate-limit",
173
+ detail: "GitHub rate limit",
174
+ },
175
+ }),
176
+ ),
177
+ );
178
+ expect(item.action).toBe("unknown");
179
+ expect(item.note).toBe("GitHub rate limit");
180
+ expect(item.target).toBeNull();
181
+ expect(item.reason).toBe("catalog-unverified");
182
+ });
183
+
184
+ test("an unknown plugin is still work to look at, but does not fail --check", () => {
185
+ // The two predicates split here on purpose: the run has something to
186
+ // report (planHasWork), yet nothing is KNOWN to be behind, so the CI gate
187
+ // passes. Nothing is applied for an unknown either way.
188
+ const p = plan({
189
+ plugins: planPluginUpdates(
190
+ { "dev@magus": "latest" },
191
+ catalog(
192
+ info({
193
+ id: "dev@magus",
194
+ version: "4.6.1",
195
+ projectScope: { enabled: true, version: "4.6.1" },
196
+ updateCheckFailed: true,
197
+ }),
198
+ ),
199
+ ),
200
+ });
201
+ expect(planHasWork(p)).toBe(true);
202
+ expect(planIsBehind(p)).toBe(false);
203
+ });
204
+ });
205
+
206
+ describe("planPluginUpdates — content drift", () => {
207
+ test("same version, changed files is a repair across every installed scope", () => {
208
+ const [item] = planPluginUpdates(
209
+ { "dev@magus": "latest" },
210
+ catalog(
211
+ info({
212
+ id: "dev@magus",
213
+ version: "4.6.1",
214
+ contentStale: true,
215
+ userScope: { enabled: true, version: "4.6.1" },
216
+ projectScope: { enabled: true, version: "4.6.1" },
217
+ }),
218
+ ),
219
+ );
220
+ expect(item.action).toBe("repair");
221
+ expect(item.scopes).toEqual(["user", "project"]);
222
+ expect(item.reason).toBe("content-stale");
223
+ });
224
+ });
225
+
226
+ describe("planPluginUpdates — an exact pin plans against what is deliverable", () => {
227
+ // This is dependency management, not a package manager: `claude plugin
228
+ // install` takes no version, so the only installable version is whatever the
229
+ // marketplace publishes now. A pin naming anything else cannot be satisfied,
230
+ // and planning toward it anyway never converges.
231
+
232
+ test("targets the marketplace version, not the unreachable pin", () => {
233
+ const [item] = planPluginUpdates(
234
+ { "dev@magus": "4.6.1" },
235
+ catalog(
236
+ info({
237
+ id: "dev@magus",
238
+ version: "9.9.9",
239
+ projectScope: { enabled: true, version: "4.0.0" },
240
+ }),
241
+ ),
242
+ );
243
+ expect(item).toMatchObject({ action: "update", target: "9.9.9" });
244
+ expect(item.reason).toBe("pin-undeliverable");
245
+ });
246
+
247
+ test("an unreachable pin is NOTED, never silently abandoned", () => {
248
+ const [item] = planPluginUpdates(
249
+ { "dev@magus": "4.6.1" },
250
+ catalog(
251
+ info({
252
+ id: "dev@magus",
253
+ version: "9.9.9",
254
+ projectScope: { enabled: true, version: "9.9.9" },
255
+ }),
256
+ ),
257
+ );
258
+ expect(item.note).toBe(
259
+ "manifest pins 4.6.1; the marketplace offers only 9.9.9",
260
+ );
261
+ expect(item.reason).toBe("pin-undeliverable");
262
+ });
263
+
264
+ test("already on the only installable version is CURRENT — no churn", () => {
265
+ // The bug this guards: planning 9.9.9 → 4.6.1 forever. Install returns
266
+ // 9.9.9, the next run replans the identical move, and --check fails in
267
+ // between every time.
268
+ const [item] = planPluginUpdates(
269
+ { "dev@magus": "4.6.1" },
270
+ catalog(
271
+ info({
272
+ id: "dev@magus",
273
+ version: "9.9.9",
274
+ projectScope: { enabled: true, version: "9.9.9" },
275
+ }),
276
+ ),
277
+ );
278
+ expect(item.action).toBe("current");
279
+ expect(item.scopes).toEqual([]);
280
+ expect(item.reason).toBe("pin-undeliverable");
281
+ });
282
+
283
+ test("an unsatisfiable pin does not fail --check forever", () => {
284
+ const p = plan({
285
+ plugins: planPluginUpdates(
286
+ { "dev@magus": "4.6.1" },
287
+ catalog(
288
+ info({
289
+ id: "dev@magus",
290
+ version: "9.9.9",
291
+ projectScope: { enabled: true, version: "9.9.9" },
292
+ }),
293
+ ),
294
+ ),
295
+ });
296
+ expect(planIsBehind(p)).toBe(false);
297
+ });
298
+
299
+ test("a pin the marketplace DOES offer is honoured exactly", () => {
300
+ const [item] = planPluginUpdates(
301
+ { "dev@magus": "4.6.1" },
302
+ catalog(
303
+ info({
304
+ id: "dev@magus",
305
+ version: "4.6.1",
306
+ projectScope: { enabled: true, version: "4.0.0" },
307
+ }),
308
+ ),
309
+ );
310
+ expect(item).toMatchObject({ action: "update", target: "4.6.1" });
311
+ expect(item.note).toBeUndefined();
312
+ expect(item.reason).toBe("behind");
313
+ });
314
+
315
+ test("falls back to the pin when the catalog offers nothing", () => {
316
+ const [item] = planPluginUpdates(
317
+ { "dev@magus": "4.6.1" },
318
+ catalog(
319
+ info({
320
+ id: "dev@magus",
321
+ version: null,
322
+ projectScope: { enabled: true, version: "4.0.0" },
323
+ }),
324
+ ),
325
+ );
326
+ expect(item.target).toBe("4.6.1");
327
+ expect(item.reason).toBe("behind");
328
+ });
329
+ });
330
+
331
+ describe("per-scope decisions — a stale scope is never masked", () => {
332
+ test("project behind while user is current is an UPDATE, not 'current'", async () => {
333
+ // `installed` is the highest version across scopes. Deciding from it hid
334
+ // exactly this: project scope — the one that actually loads for the repo —
335
+ // sits behind while user scope drags the comparison up to "current".
336
+ const [item] = planPluginUpdates(
337
+ { "dev@magus": "latest" },
338
+ catalog(
339
+ info({
340
+ id: "dev@magus",
341
+ version: "4.7.0",
342
+ userScope: { enabled: true, version: "4.7.0" },
343
+ projectScope: { enabled: true, version: "4.6.1" },
344
+ }),
345
+ ),
346
+ );
347
+ expect(item.action).toBe("update");
348
+ expect(item.scopes).toEqual(["project"]);
349
+ expect(item.reason).toBe("behind");
350
+ });
351
+
352
+ test("the same masking under an EXACT pin is also caught", () => {
353
+ // The marketplace offers the pinned version here, so the target IS the pin
354
+ // and only the behind scope should move. (When the marketplace offers
355
+ // something else, every scope moves to that instead — see the
356
+ // "deliverable" suite.)
357
+ const [item] = planPluginUpdates(
358
+ { "dev@magus": "4.6.1" },
359
+ catalog(
360
+ info({
361
+ id: "dev@magus",
362
+ version: "4.6.1",
363
+ userScope: { enabled: true, version: "4.6.1" },
364
+ projectScope: { enabled: true, version: "4.0.0" },
365
+ }),
366
+ ),
367
+ );
368
+ expect(item.action).toBe("update");
369
+ expect(item.scopes).toEqual(["project"]);
370
+ expect(item.reason).toBe("behind");
371
+ });
372
+
373
+ test("an unreachable pin moves EVERY scope to the installable version", () => {
374
+ const [item] = planPluginUpdates(
375
+ { "dev@magus": "4.6.1" },
376
+ catalog(
377
+ info({
378
+ id: "dev@magus",
379
+ version: "9.9.9",
380
+ userScope: { enabled: true, version: "4.6.1" },
381
+ projectScope: { enabled: true, version: "4.0.0" },
382
+ }),
383
+ ),
384
+ );
385
+ expect(item.target).toBe("9.9.9");
386
+ expect(item.scopes).toEqual(["user", "project"]);
387
+ expect(item.reason).toBe("pin-undeliverable");
388
+ });
389
+
390
+ test("all scopes at the target is genuinely current", () => {
391
+ const [item] = planPluginUpdates(
392
+ { "dev@magus": "latest" },
393
+ catalog(
394
+ info({
395
+ id: "dev@magus",
396
+ version: "4.7.0",
397
+ userScope: { enabled: true, version: "4.7.0" },
398
+ projectScope: { enabled: true, version: "4.7.0" },
399
+ }),
400
+ ),
401
+ );
402
+ expect(item.action).toBe("current");
403
+ expect(item.reason).toBe("up-to-date");
404
+ });
405
+
406
+ test("an installed plugin whose catalog declares no version is 'unknown'", () => {
407
+ // This fixture puts dev@magus IN the catalog with a null version, so it is
408
+ // the declares-no-version case, not the withdrawn one — the assertion used
409
+ // to say "not offered", which is what conflated them. 278 of the 292
410
+ // plugins in claude-plugins-official are in exactly this state.
411
+ const [item] = planPluginUpdates(
412
+ { "dev@magus": "latest" },
413
+ catalog(
414
+ info({
415
+ id: "dev@magus",
416
+ version: null,
417
+ projectScope: { enabled: true, version: "4.6.1" },
418
+ }),
419
+ ),
420
+ );
421
+ expect(item.action).toBe("unknown");
422
+ expect(item.note).toContain("publishes no version");
423
+ expect(item.reason).toBe("no-published-version");
424
+ });
425
+
426
+ test("an installed plugin ABSENT from every catalog is 'not offered'", () => {
427
+ // The genuinely withdrawn case: no catalog entry at all, the installed
428
+ // version known only from the registry.
429
+ const [item] = planPluginUpdates(
430
+ { "withdrawn@magus": "latest" },
431
+ catalog(info({ id: "dev@magus", version: "4.6.1" })),
432
+ );
433
+ expect(item.action).toBe("unknown");
434
+ expect(item.note).toContain("not offered by any configured marketplace");
435
+ expect(item.reason).toBe("not-offered");
436
+ });
437
+ });
438
+
439
+ describe("planIsBehind — the --check gate", () => {
440
+ const skillRef = {
441
+ name: "release",
442
+ repo: "MadAppGang/magus",
443
+ path: "skills/release",
444
+ };
445
+
446
+ test("an already-installed skill does NOT fail the gate", () => {
447
+ // A skill ref carries no content hash, so it is re-fetched every run.
448
+ // Counting that as drift made --check exit 1 forever for any profile
449
+ // declaring a single skill — a gate that can never pass is no gate.
450
+ const p = plan({
451
+ skills: planSkillUpdates([skillRef], new Set(["release"])),
452
+ });
453
+ expect(planHasWork(p)).toBe(true);
454
+ expect(planIsBehind(p)).toBe(false);
455
+ });
456
+
457
+ test("a MISSING skill does fail the gate", () => {
458
+ const p = plan({ skills: planSkillUpdates([skillRef], new Set()) });
459
+ expect(planIsBehind(p)).toBe(true);
460
+ });
461
+
462
+ test("a present tool its installer calls current does NOT fail the gate, and is no work", () => {
463
+ const p = plan({
464
+ bins: planBinUpdates(
465
+ [{ name: "tmux", via: "brew", sources: [] }],
466
+ [toolFacts("tmux", { behind: false })],
467
+ ),
468
+ });
469
+ expect(planHasWork(p)).toBe(false);
470
+ expect(planIsBehind(p)).toBe(false);
471
+ });
472
+
473
+ test("a tool its installer says is BEHIND does fail the gate", () => {
474
+ // Behaviour change, deliberate: "upgrade" used to mean "re-run the
475
+ // installer on a guess" and was excluded; it now means proven behind.
476
+ const p = plan({
477
+ bins: planBinUpdates(
478
+ [{ name: "claude-swap", via: "pip", sources: [] }],
479
+ [toolFacts("claude-swap", { behind: true, latest: "0.26.0" })],
480
+ ),
481
+ });
482
+ expect(planIsBehind(p)).toBe(true);
483
+ });
484
+
485
+ test("an UNKNOWN tool does not fail the gate, and is no work", () => {
486
+ // Tool unknowns are often structural and permanent (another npm owns the
487
+ // file, go is absent) — counting them makes a gate that never passes.
488
+ const p = plan({
489
+ bins: planBinUpdates(
490
+ [{ name: "crush", via: "npm", sources: [] }],
491
+ [toolFacts("crush", { managed: false })],
492
+ ),
493
+ });
494
+ expect(p.bins[0]!.action).toBe("unknown");
495
+ expect(planHasWork(p)).toBe(false);
496
+ expect(planIsBehind(p)).toBe(false);
497
+ });
498
+
499
+ test("a MISSING binary does fail the gate", () => {
500
+ const p = plan({
501
+ bins: planBinUpdates(
502
+ [{ name: "tmux-mcp", via: "bun", sources: [] }],
503
+ [toolFacts("tmux-mcp", { present: false, installed: null })],
504
+ ),
505
+ });
506
+ expect(planIsBehind(p)).toBe(true);
507
+ });
508
+
509
+ test("an unverifiable plugin does NOT fail the gate, and is not applied", () => {
510
+ // Same rule as an unknown tool. The user's own profile declares 5 plugins
511
+ // whose marketplace publishes no version, so the old behaviour exited 1 on
512
+ // every run for ever — a gate that can never pass protects nothing. The
513
+ // count of unchecked items is printed beside the verdict instead.
514
+ const p = plan({
515
+ plugins: planPluginUpdates(
516
+ { "dev@magus": "latest" },
517
+ catalog(
518
+ info({
519
+ id: "dev@magus",
520
+ version: "4.6.1",
521
+ projectScope: { enabled: true, version: "4.6.1" },
522
+ updateCheckFailed: true,
523
+ }),
524
+ ),
525
+ ),
526
+ });
527
+ expect(p.plugins[0]?.action).toBe("unknown");
528
+ expect(planIsBehind(p)).toBe(false);
529
+ expect(planWorkItems(p)).toEqual([]);
530
+ });
531
+
532
+ test("a plugin whose marketplace publishes no version does NOT fail the gate", () => {
533
+ // The real shape behind dry-run-1: listed, installed, no catalog version.
534
+ const p = plan({
535
+ plugins: planPluginUpdates(
536
+ { "code-review@claude-plugins-official": "latest" },
537
+ catalog(
538
+ info({
539
+ id: "code-review@claude-plugins-official",
540
+ version: null,
541
+ projectScope: { enabled: true, version: "3ea32df27be7" },
542
+ }),
543
+ ),
544
+ ),
545
+ });
546
+ expect(p.plugins[0]?.reason).toBe("no-published-version");
547
+ expect(planIsBehind(p)).toBe(false);
548
+ });
549
+
550
+ test("a plugin that is behind, absent or content-stale still fails the gate", () => {
551
+ const behind = plan({
552
+ plugins: planPluginUpdates(
553
+ { "dev@magus": "latest" },
554
+ catalog(
555
+ info({
556
+ id: "dev@magus",
557
+ version: "4.7.0",
558
+ projectScope: { enabled: true, version: "4.6.1" },
559
+ }),
560
+ ),
561
+ ),
562
+ });
563
+ expect(behind.plugins[0]?.action).toBe("update");
564
+ expect(planIsBehind(behind)).toBe(true);
565
+
566
+ const absent = plan({
567
+ plugins: planPluginUpdates(
568
+ { "dev@magus": "latest" },
569
+ catalog(info({ id: "dev@magus", version: "4.7.0" })),
570
+ ),
571
+ });
572
+ expect(absent.plugins[0]?.action).toBe("install");
573
+ expect(planIsBehind(absent)).toBe(true);
574
+
575
+ const stale = plan({
576
+ plugins: planPluginUpdates(
577
+ { "dev@magus": "latest" },
578
+ catalog(
579
+ info({
580
+ id: "dev@magus",
581
+ version: "4.6.1",
582
+ projectScope: { enabled: true, version: "4.6.1" },
583
+ contentStale: true,
584
+ }),
585
+ ),
586
+ ),
587
+ });
588
+ expect(stale.plugins[0]?.action).toBe("repair");
589
+ expect(planIsBehind(stale)).toBe(true);
590
+ });
591
+
592
+ test("a fully current profile with skills and bins passes the gate", () => {
593
+ const p = plan({
594
+ plugins: planPluginUpdates(
595
+ { "dev@magus": "latest" },
596
+ catalog(
597
+ info({
598
+ id: "dev@magus",
599
+ version: "4.7.0",
600
+ projectScope: { enabled: true, version: "4.7.0" },
601
+ }),
602
+ ),
603
+ ),
604
+ bins: planBinUpdates(
605
+ [{ name: "tmux-mcp", via: "bun", version: "1.0.0", sources: [] }],
606
+ [toolFacts("tmux-mcp", { installed: "1.0.0" })],
607
+ ),
608
+ skills: planSkillUpdates([skillRef], new Set(["release"])),
609
+ });
610
+ expect(planIsBehind(p)).toBe(false);
611
+ });
612
+ });
613
+
614
+ describe("nothing ever plans a downgrade", () => {
615
+ // `claude plugin install` installs the newest published version and takes no
616
+ // version argument, so a downgrade is not a move this tool can perform.
617
+ // Planning one produces a step that runs, reports success, lands on the same
618
+ // version as before, and is replanned identically on the next run.
619
+
620
+ test("a scope AHEAD of the marketplace is left alone under an exact pin", () => {
621
+ const [item] = planPluginUpdates(
622
+ { "dev@magus": "4.6.1" },
623
+ catalog(
624
+ info({
625
+ id: "dev@magus",
626
+ version: "9.9.9",
627
+ projectScope: { enabled: true, version: "10.0.0" },
628
+ }),
629
+ ),
630
+ );
631
+ expect(item.action).toBe("current");
632
+ expect(item.scopes).toEqual([]);
633
+ expect(item.reason).toBe("pin-undeliverable");
634
+ });
635
+
636
+ test("an older pin with no catalog entry does not drag an install backwards", () => {
637
+ // available === null, so the pin is the only target on offer — and it is
638
+ // BELOW what is installed. Moving to it is neither possible nor wanted.
639
+ const [item] = planPluginUpdates(
640
+ { "dev@magus": "4.6.1" },
641
+ catalog(
642
+ info({
643
+ id: "dev@magus",
644
+ version: null,
645
+ projectScope: { enabled: true, version: "5.0.0" },
646
+ }),
647
+ ),
648
+ );
649
+ expect(item.action).toBe("current");
650
+ expect(item.scopes).toEqual([]);
651
+ expect(item.reason).toBe("up-to-date");
652
+ });
653
+
654
+ test("only the behind scope moves when one is ahead and one behind", () => {
655
+ const [item] = planPluginUpdates(
656
+ { "dev@magus": "latest" },
657
+ catalog(
658
+ info({
659
+ id: "dev@magus",
660
+ version: "9.9.9",
661
+ userScope: { enabled: true, version: "10.0.0" },
662
+ projectScope: { enabled: true, version: "4.0.0" },
663
+ }),
664
+ ),
665
+ );
666
+ expect(item.scopes).toEqual(["project"]);
667
+ expect(item.reason).toBe("behind");
668
+ });
669
+
670
+ test("scopesNeeding never returns a scope ahead of the target", () => {
671
+ const i = info({
672
+ id: "dev@magus",
673
+ userScope: { enabled: true, version: "10.0.0" },
674
+ projectScope: { enabled: true, version: "9.9.9" },
675
+ localScope: { enabled: true, version: "1.0.0" },
676
+ });
677
+ expect(scopesNeeding(i, "9.9.9")).toEqual(["local"]);
678
+ });
679
+ });
680
+
681
+ describe("scopesNeeding", () => {
682
+ test("returns every outdated scope, not just user", () => {
683
+ // Fixing only 'user' leaves the project row behind, so the same drift
684
+ // reappears on every run.
685
+ const i = info({
686
+ id: "dev@magus",
687
+ userScope: { enabled: true, version: "4.6.1" },
688
+ projectScope: { enabled: true, version: "4.6.1" },
689
+ localScope: { enabled: true, version: "4.7.0" },
690
+ });
691
+ expect(scopesNeeding(i, "4.7.0")).toEqual(["user", "project"]);
692
+ });
693
+
694
+ test("a scope already on the target is left alone", () => {
695
+ const i = info({
696
+ id: "dev@magus",
697
+ projectScope: { enabled: true, version: "4.7.0" },
698
+ });
699
+ expect(scopesNeeding(i, "4.7.0")).toEqual([]);
700
+ });
701
+ });
702
+
703
+ describe("planBinUpdates", () => {
704
+ const bin = (over: Partial<ResolvedBin>): ResolvedBin => ({
705
+ name: "tmux-mcp",
706
+ via: "bun",
707
+ sources: ["profile:test.cliTools"],
708
+ ...over,
709
+ });
710
+
711
+ test("a missing binary is an install", () => {
712
+ const [item] = planBinUpdates(
713
+ [bin({})],
714
+ [toolFacts("tmux-mcp", { present: false, installed: null })],
715
+ );
716
+ expect(item).toMatchObject({ action: "install", reason: "absent" });
717
+ });
718
+
719
+ test("present, unpinned and CURRENT is current — the upgrade loop is gone", () => {
720
+ // This test used to read "present and unpinned is an upgrade — that is
721
+ // what 'update' means", and that was the defect: tmux, browser-use and
722
+ // mcp were current on the reporter's machine (brew / uv said so) and were
723
+ // "upgraded" on every run, forever.
724
+ const [item] = planBinUpdates(
725
+ [bin({ name: "tmux", via: "brew" })],
726
+ [toolFacts("tmux", { installed: "3.7c", behind: false })],
727
+ );
728
+ expect(item).toMatchObject({
729
+ action: "current",
730
+ reason: "up-to-date",
731
+ installed: "3.7c",
732
+ target: null,
733
+ });
734
+ expect(item.version).toBeUndefined();
735
+ });
736
+
737
+ test("present and unpinned is an upgrade ONLY when its installer says behind", () => {
738
+ const [item] = planBinUpdates(
739
+ [bin({ name: "claude-swap", via: "pip" })],
740
+ [toolFacts("claude-swap", { behind: true, latest: "0.26.0" })],
741
+ );
742
+ expect(item).toMatchObject({
743
+ action: "upgrade",
744
+ reason: "behind",
745
+ target: "0.26.0",
746
+ });
747
+ });
748
+
749
+ test("present and at its pin is current, keeping the pin", () => {
750
+ const [item] = planBinUpdates(
751
+ [bin({ version: "1.2.3" })],
752
+ [toolFacts("tmux-mcp", { installed: "1.2.3" })],
753
+ );
754
+ expect(item).toMatchObject({
755
+ action: "current",
756
+ reason: "at-pin",
757
+ version: "1.2.3",
758
+ });
759
+ });
760
+
761
+ test("a bin with no facts was never probed: unknown, with a note", () => {
762
+ const [item] = planBinUpdates([bin({})], []);
763
+ expect(item).toMatchObject({
764
+ action: "unknown",
765
+ reason: "not-probed",
766
+ note: "not probed",
767
+ });
768
+ });
769
+
770
+ test("the note is classifyTool's note, verbatim (the TUI shows the same string)", () => {
771
+ const f = toolFacts("crush", {
772
+ managed: false,
773
+ resolvedPath: "/n/crush.js",
774
+ owner: "npm (root /l)",
775
+ });
776
+ const [item] = planBinUpdates([bin({ name: "crush", via: "npm" })], [f]);
777
+ expect(item.note).toBe(
778
+ classifyTool(bin({ name: "crush", via: "npm" }), f).note,
779
+ );
780
+ });
781
+ });
782
+
783
+ describe("binNeedsWork — one predicate for apply, the meter and 'has work'", () => {
784
+ const actions: BinUpdateAction[] = [
785
+ "install",
786
+ "upgrade",
787
+ "current",
788
+ "unknown",
789
+ ];
790
+ const itemFor = (action: BinUpdateAction): BinUpdateItem => ({
791
+ name: `t-${action}`,
792
+ action,
793
+ reason: "not-probed",
794
+ installed: null,
795
+ latest: null,
796
+ target: null,
797
+ sources: [],
798
+ });
799
+
800
+ test("install and upgrade are work; current and unknown are not", () => {
801
+ expect(actions.map((a) => binNeedsWork(itemFor(a)))).toEqual([
802
+ true,
803
+ true,
804
+ false,
805
+ false,
806
+ ]);
807
+ });
808
+
809
+ test("applyBins, planWorkItems and planHasWork agree for every action", async () => {
810
+ for (const action of actions) {
811
+ const item = itemFor(action);
812
+ const p = plan({
813
+ bins: [item],
814
+ binSpecs: new Map([
815
+ [item.name, { name: item.name, via: "bun", sources: [] }],
816
+ ]),
817
+ });
818
+ const ran: string[] = [];
819
+ await applyBins(
820
+ p,
821
+ undefined,
822
+ async (c) => {
823
+ ran.push(c.cmd);
824
+ return true;
825
+ },
826
+ // Read-back seam: never the real PATH in a unit test.
827
+ async (s) => toolFacts(s.name, { installed: "2.0.0" }),
828
+ );
829
+ const expected = binNeedsWork(item);
830
+ expect(ran.length > 0).toBe(expected);
831
+ expect(planWorkItems(p).includes(item.name)).toBe(expected);
832
+ expect(planHasWork(p)).toBe(expected);
833
+ }
834
+ });
835
+
836
+ test("an upgrade TO the pin runs the pin command, not the upgrade command", () => {
837
+ const spec: ResolvedBin = {
838
+ name: "mnemex",
839
+ via: "bun",
840
+ version: "1.0.0",
841
+ sources: [],
842
+ };
843
+ expect(
844
+ binCommandFor(
845
+ { ...itemFor("upgrade"), name: "mnemex", target: "1.0.0" },
846
+ spec,
847
+ ),
848
+ ).toEqual({ cmd: "bun", args: ["install", "-g", "mnemex@1.0.0"] });
849
+ expect(
850
+ binCommandFor(
851
+ { ...itemFor("upgrade"), target: "2.0.0" },
852
+ {
853
+ ...spec,
854
+ version: undefined,
855
+ },
856
+ ),
857
+ ).toEqual({ cmd: "bun", args: ["install", "-g", "mnemex@latest"] });
858
+ expect(binCommandFor(itemFor("unknown"), spec)).toBeNull();
859
+ });
860
+ });
861
+
862
+ describe("planSkillUpdates", () => {
863
+ const ref = {
864
+ name: "release",
865
+ repo: "MadAppGang/magus",
866
+ path: "skills/release",
867
+ };
868
+
869
+ test("an uninstalled skill is an install", () => {
870
+ expect(planSkillUpdates([ref], new Set())[0]!.action).toBe("install");
871
+ });
872
+
873
+ test("an installed skill is refreshed, since refs carry no content hash", () => {
874
+ expect(planSkillUpdates([ref], new Set(["release"]))[0]!.action).toBe(
875
+ "refresh",
876
+ );
877
+ });
878
+ });
879
+
880
+ describe("planHasWork / summarizePlan", () => {
881
+ test("an all-current plan has no work", () => {
882
+ const p = plan({
883
+ plugins: planPluginUpdates(
884
+ { "dev@magus": "latest" },
885
+ catalog(
886
+ info({
887
+ id: "dev@magus",
888
+ version: "4.7.0",
889
+ projectScope: { enabled: true, version: "4.7.0" },
890
+ }),
891
+ ),
892
+ ),
893
+ });
894
+ expect(planHasWork(p)).toBe(false);
895
+ expect(summarizePlan(p).current).toBe(1);
896
+ });
897
+
898
+ test("counts split installs from updates across plugins and bins", () => {
899
+ const p = plan({
900
+ plugins: planPluginUpdates(
901
+ { "a@magus": "latest", "b@magus": "latest" },
902
+ catalog(
903
+ info({ id: "a@magus", version: "1.0.0" }),
904
+ info({
905
+ id: "b@magus",
906
+ version: "2.0.0",
907
+ projectScope: { enabled: true, version: "1.0.0" },
908
+ }),
909
+ ),
910
+ ),
911
+ bins: planBinUpdates(
912
+ [{ name: "tmux-mcp", via: "bun", sources: [] }],
913
+ [toolFacts("tmux-mcp", { installed: "1.0.0", latest: "1.1.0" })],
914
+ ),
915
+ });
916
+ expect(summarizePlan(p)).toMatchObject({ installed: 1, updated: 2 });
917
+ });
918
+
919
+ test("unknown splits into plugins and tools, and the total is their sum", () => {
920
+ const p = plan({
921
+ plugins: planPluginUpdates(
922
+ { "a@magus": "latest" },
923
+ catalog(
924
+ info({
925
+ id: "a@magus",
926
+ version: null,
927
+ projectScope: { enabled: true, version: "abc123" },
928
+ }),
929
+ ),
930
+ ),
931
+ bins: planBinUpdates(
932
+ [
933
+ { name: "crush", via: "npm", sources: [] },
934
+ { name: "claude", via: "npm", sources: [] },
935
+ { name: "tmux", via: "brew", sources: [] },
936
+ ],
937
+ [
938
+ toolFacts("crush", { managed: false }),
939
+ toolFacts("claude", { managed: false }),
940
+ toolFacts("tmux", { behind: false }),
941
+ ],
942
+ ),
943
+ });
944
+ expect(summarizePlan(p)).toMatchObject({
945
+ unknownPlugins: 1,
946
+ unknownTools: 2,
947
+ unknown: 3,
948
+ current: 1,
949
+ });
950
+ });
951
+ });
952
+
953
+ // ── The classifier: rules the TUI and CLI now share ─────────────────────────
954
+
955
+ /**
956
+ * An orphan as `getAvailablePlugins` builds one: installed, published by no
957
+ * marketplace we read, and — the trap — `version` filled in from its own
958
+ * installed version, so a naive compare finds them equal.
959
+ */
960
+ function orphan(
961
+ id: string,
962
+ installedVersion: string,
963
+ over: Partial<PluginInfo> = {},
964
+ ): PluginInfo {
965
+ return info({
966
+ id,
967
+ version: installedVersion,
968
+ installedVersion,
969
+ isOrphaned: true,
970
+ projectScope: { enabled: true, version: installedVersion },
971
+ ...over,
972
+ });
973
+ }
974
+
975
+ describe("classifyPlugin — an orphan is never 'current'", () => {
976
+ test("REGRESSION: an orphan used to plan current against its own version", () => {
977
+ // Before: `catalog.has(id)` was true for an orphan, its `version` equalled
978
+ // its installed version, and the plan said `current` while the TUI drew
979
+ // the same row red as "deprecated".
980
+ const [item] = planPluginUpdates(
981
+ { "seo@magus": "latest" },
982
+ catalog(
983
+ orphan("seo@magus", "1.8.0"),
984
+ info({ id: "dev@magus", version: "4.7.0" }),
985
+ ),
986
+ );
987
+ expect(item.action).toBe("unknown");
988
+ expect(item.action).not.toBe("current");
989
+ expect(item.available).toBeNull();
990
+ expect(item.target).toBeNull();
991
+ expect(item.scopes).toEqual([]);
992
+ });
993
+
994
+ test("an orphan another marketplace publishes is 'moved', naming it", () => {
995
+ const [item] = planPluginUpdates(
996
+ { "seo@magus": "latest" },
997
+ catalog(
998
+ orphan("seo@magus", "1.8.0", { movedTo: "magus-marketing" }),
999
+ info({ id: "dev@magus", version: "4.7.0" }),
1000
+ ),
1001
+ );
1002
+ expect(item).toMatchObject({
1003
+ action: "unknown",
1004
+ reason: "moved",
1005
+ note: "moved to magus-marketing",
1006
+ });
1007
+ });
1008
+
1009
+ test("an orphan whose marketplace we DID read is 'not offered'", () => {
1010
+ const [item] = planPluginUpdates(
1011
+ { "seo@magus": "latest" },
1012
+ catalog(
1013
+ orphan("seo@magus", "1.8.0"),
1014
+ info({ id: "dev@magus", version: "4.7.0" }),
1015
+ ),
1016
+ );
1017
+ expect(item).toMatchObject({
1018
+ action: "unknown",
1019
+ reason: "not-offered",
1020
+ note: "not offered by any configured marketplace",
1021
+ });
1022
+ });
1023
+
1024
+ test("an orphan whose marketplace contributed nothing is 'marketplace-unread'", () => {
1025
+ // No listed id from @gone — we never read that catalog, so its absence is
1026
+ // not evidence it was dropped. Claiming "not offered" would be a guess.
1027
+ const [item] = planPluginUpdates(
1028
+ { "thing@gone": "latest" },
1029
+ catalog(
1030
+ orphan("thing@gone", "2.0.0"),
1031
+ info({ id: "dev@magus", version: "4.7.0" }),
1032
+ ),
1033
+ );
1034
+ expect(item).toMatchObject({
1035
+ action: "unknown",
1036
+ reason: "marketplace-unread",
1037
+ note: "its marketplace gone was not read, so whether it still offers this plugin is unknown",
1038
+ });
1039
+ });
1040
+
1041
+ test("another ORPHAN from the same marketplace is not proof we read it", () => {
1042
+ // Two orphans under @gone used to make `marketplaceIsKnown` true for each
1043
+ // other. Neither is listed, so neither proves anything.
1044
+ const [item] = planPluginUpdates(
1045
+ { "thing@gone": "latest" },
1046
+ catalog(orphan("thing@gone", "2.0.0"), orphan("other@gone", "1.0.0")),
1047
+ );
1048
+ expect(item.reason).toBe("marketplace-unread");
1049
+ });
1050
+
1051
+ test("an orphan is unknown under an exact pin too", () => {
1052
+ const [item] = planPluginUpdates(
1053
+ { "seo@magus": "1.8.0" },
1054
+ catalog(
1055
+ orphan("seo@magus", "1.8.0"),
1056
+ info({ id: "dev@magus", version: "4.7.0" }),
1057
+ ),
1058
+ );
1059
+ expect(item.action).toBe("unknown");
1060
+ });
1061
+ });
1062
+
1063
+ describe("classifyPlugin — an exact pin that cannot be ordered is unknown (F3)", () => {
1064
+ test("REGRESSION: a pinned plugin at an unorderable version used to be current", () => {
1065
+ // The "latest" path already refused this; the exact-pin path did not.
1066
+ const [item] = planPluginUpdates(
1067
+ { "playground@official": "7.3.0" },
1068
+ catalog(
1069
+ info({
1070
+ id: "playground@official",
1071
+ version: "7.3.0",
1072
+ projectScope: { enabled: true, version: "unknown" },
1073
+ }),
1074
+ ),
1075
+ );
1076
+ expect(item).toMatchObject({
1077
+ action: "unknown",
1078
+ reason: "unorderable",
1079
+ target: null,
1080
+ scopes: [],
1081
+ note: "installed version cannot be compared with the published 7.3.0",
1082
+ });
1083
+ });
1084
+
1085
+ test("with no published version the note names the pin instead", () => {
1086
+ const [item] = planPluginUpdates(
1087
+ { "playground@official": "7.3.0" },
1088
+ catalog(
1089
+ info({
1090
+ id: "playground@official",
1091
+ version: null,
1092
+ projectScope: { enabled: true, version: "3ea32df27be7" },
1093
+ }),
1094
+ ),
1095
+ );
1096
+ expect(item).toMatchObject({
1097
+ action: "unknown",
1098
+ reason: "unorderable",
1099
+ note: "installed version cannot be compared with the pin 7.3.0",
1100
+ });
1101
+ });
1102
+
1103
+ test("a scope that IS behind still wins: it is planned, not hidden", () => {
1104
+ const [item] = planPluginUpdates(
1105
+ { "dev@magus": "4.7.0" },
1106
+ catalog(
1107
+ info({
1108
+ id: "dev@magus",
1109
+ version: "4.7.0",
1110
+ userScope: { enabled: true, version: "unknown" },
1111
+ projectScope: { enabled: true, version: "4.6.1" },
1112
+ }),
1113
+ ),
1114
+ );
1115
+ expect(item).toMatchObject({ action: "update", scopes: ["project"] });
1116
+ });
1117
+ });
1118
+
1119
+ describe("classifyPlugin — install reasons", () => {
1120
+ test("enabled in settings with no files anywhere says so", () => {
1121
+ const [item] = planPluginUpdates(
1122
+ { "dev@magus": "latest" },
1123
+ catalog(
1124
+ info({
1125
+ id: "dev@magus",
1126
+ version: "4.7.0",
1127
+ projectScope: { enabled: true },
1128
+ }),
1129
+ ),
1130
+ );
1131
+ expect(item).toMatchObject({
1132
+ action: "install",
1133
+ reason: "enabled-not-installed",
1134
+ note: "enabled in settings, but no files on disk",
1135
+ });
1136
+ });
1137
+
1138
+ test("a plain absence carries no note", () => {
1139
+ const [item] = planPluginUpdates(
1140
+ { "dev@magus": "latest" },
1141
+ catalog(info({ id: "dev@magus", version: "4.7.0" })),
1142
+ );
1143
+ expect(item.reason).toBe("absent");
1144
+ expect(item.note).toBeUndefined();
1145
+ });
1146
+ });
1147
+
1148
+ describe("classifyPlugin — beforeVersions records every installed scope", () => {
1149
+ test("each installed scope's version, and nothing for an absent one", () => {
1150
+ const [item] = planPluginUpdates(
1151
+ { "dev@magus": "latest" },
1152
+ catalog(
1153
+ info({
1154
+ id: "dev@magus",
1155
+ version: "4.7.0",
1156
+ userScope: { enabled: true, version: "4.6.0" },
1157
+ projectScope: { enabled: true, version: "4.6.1" },
1158
+ localScope: { enabled: true },
1159
+ }),
1160
+ ),
1161
+ );
1162
+ expect(item.beforeVersions).toEqual({ user: "4.6.0", project: "4.6.1" });
1163
+ });
1164
+
1165
+ test("an uninstalled plugin has none", () => {
1166
+ const [item] = planPluginUpdates(
1167
+ { "dev@magus": "latest" },
1168
+ catalog(info({ id: "dev@magus", version: "4.7.0" })),
1169
+ );
1170
+ expect(item.beforeVersions).toEqual({});
1171
+ });
1172
+ });
1173
+
1174
+ describe("indexCatalog", () => {
1175
+ const idx = indexCatalog([
1176
+ info({ id: "dev@magus", version: "4.7.0" }),
1177
+ orphan("seo@magus", "1.8.0"),
1178
+ orphan("thing@gone", "2.0.0"),
1179
+ ]);
1180
+
1181
+ test("isListed excludes orphans", () => {
1182
+ expect(idx.isListed("dev@magus")).toBe(true);
1183
+ expect(idx.isListed("seo@magus")).toBe(false);
1184
+ expect(idx.isListed("absent@magus")).toBe(false);
1185
+ // Still reachable by id — the classifier needs its scopes.
1186
+ expect(idx.get("seo@magus")?.isOrphaned).toBe(true);
1187
+ });
1188
+
1189
+ test("marketplaceIsKnown counts OTHER listed ids only", () => {
1190
+ expect(idx.marketplaceIsKnown("seo@magus")).toBe(true); // dev@magus is listed
1191
+ expect(idx.marketplaceIsKnown("absent@magus")).toBe(true);
1192
+ // dev@magus is the only listed @magus id — itself does not count.
1193
+ expect(idx.marketplaceIsKnown("dev@magus")).toBe(false);
1194
+ // Only an orphan there.
1195
+ expect(idx.marketplaceIsKnown("other@gone")).toBe(false);
1196
+ expect(idx.marketplaceIsKnown("no-marketplace")).toBe(false);
1197
+ expect(idx.marketplaceIsKnown("trailing@")).toBe(false);
1198
+ });
1199
+
1200
+ test("a Map and an array index identically", () => {
1201
+ const list = [
1202
+ info({ id: "a@m", version: "1.0.0" }),
1203
+ info({ id: "b@m", version: "1.0.0" }),
1204
+ ];
1205
+ const fromMap = indexCatalog(catalog(...list));
1206
+ const fromArray = indexCatalog(list);
1207
+ for (const id of ["a@m", "b@m", "c@m"]) {
1208
+ expect(fromMap.isListed(id)).toBe(fromArray.isListed(id));
1209
+ expect(fromMap.marketplaceIsKnown(id)).toBe(
1210
+ fromArray.marketplaceIsKnown(id),
1211
+ );
1212
+ }
1213
+ });
1214
+ });
1215
+
1216
+ describe("classifyPlugin and planPluginUpdates are the same function", () => {
1217
+ test("planPluginUpdates is classifyPlugin over each pin, sorted", () => {
1218
+ const cat = catalog(
1219
+ info({
1220
+ id: "b@magus",
1221
+ version: "2.0.0",
1222
+ projectScope: { enabled: true, version: "1.0.0" },
1223
+ }),
1224
+ info({ id: "a@magus", version: "1.0.0" }),
1225
+ );
1226
+ const pins = { "b@magus": "latest", "a@magus": "1.0.0" };
1227
+ const idx = indexCatalog(cat);
1228
+ expect(planPluginUpdates(pins, cat)).toEqual([
1229
+ classifyPlugin("a@magus", "1.0.0", idx),
1230
+ classifyPlugin("b@magus", "latest", idx),
1231
+ ]);
1232
+ });
1233
+ });
1234
+
1235
+ describe("pinForRow — which pin a TUI row is judged against", () => {
1236
+ test("the profile's pin wins, exact or latest", () => {
1237
+ const i = info({
1238
+ id: "dev@magus",
1239
+ projectScope: { enabled: true, version: "1.0.0" },
1240
+ });
1241
+ expect(pinForRow(i, { "dev@magus": "4.6.1" })).toBe("4.6.1");
1242
+ expect(pinForRow(i, { "dev@magus": "latest" })).toBe("latest");
1243
+ });
1244
+
1245
+ test("a declared plugin gets its pin even when it is installed nowhere", () => {
1246
+ expect(
1247
+ pinForRow(info({ id: "dev@magus" }), { "dev@magus": "latest" }),
1248
+ ).toBe("latest");
1249
+ });
1250
+
1251
+ test("undeclared but installed in any scope is judged against latest", () => {
1252
+ for (const scope of ["userScope", "projectScope", "localScope"] as const) {
1253
+ const i = info({
1254
+ id: "dev@magus",
1255
+ [scope]: { enabled: false, version: "1.0.0" },
1256
+ });
1257
+ expect(pinForRow(i, {})).toBe("latest");
1258
+ }
1259
+ });
1260
+
1261
+ test("undeclared, enabled but not installed is judged against latest", () => {
1262
+ expect(
1263
+ pinForRow(info({ id: "dev@magus", localScope: { enabled: true } }), {}),
1264
+ ).toBe("latest");
1265
+ });
1266
+
1267
+ test("undeclared and neither installed nor enabled gets NO status", () => {
1268
+ // A catalog entry the user is only browsing. `install` would claim the
1269
+ // profile wants it, which nothing says.
1270
+ expect(
1271
+ pinForRow(info({ id: "dev@magus", version: "1.0.0" }), {}),
1272
+ ).toBeNull();
1273
+ });
1274
+
1275
+ test("an inherited key from Object.prototype is not a pin", () => {
1276
+ expect(
1277
+ pinForRow(info({ id: "toString", version: "1.0.0" }), {}),
1278
+ ).toBeNull();
1279
+ });
1280
+ });
1281
+
1282
+ describe("classifyPluginRows", () => {
1283
+ test("one status per row with a pin; browse-only rows are absent", () => {
1284
+ const rows = [
1285
+ info({ id: "declared@magus", version: "1.0.0" }),
1286
+ info({
1287
+ id: "installed@magus",
1288
+ version: "2.0.0",
1289
+ projectScope: { enabled: true, version: "1.0.0" },
1290
+ }),
1291
+ info({ id: "browse@magus", version: "3.0.0" }),
1292
+ ];
1293
+ const statuses = classifyPluginRows(rows, { "declared@magus": "latest" });
1294
+ expect([...statuses.keys()].sort()).toEqual([
1295
+ "declared@magus",
1296
+ "installed@magus",
1297
+ ]);
1298
+ expect(statuses.get("declared@magus")).toMatchObject({
1299
+ action: "install",
1300
+ reason: "absent",
1301
+ });
1302
+ expect(statuses.get("installed@magus")).toMatchObject({
1303
+ action: "update",
1304
+ reason: "behind",
1305
+ });
1306
+ expect(statuses.has("browse@magus")).toBe(false);
1307
+ });
1308
+
1309
+ test("a declared row gets exactly what the CLI plans for it", () => {
1310
+ const rows = [
1311
+ info({
1312
+ id: "dev@magus",
1313
+ version: "4.7.0",
1314
+ projectScope: { enabled: true, version: "4.6.1" },
1315
+ }),
1316
+ info({ id: "terminal@magus", version: "5.0.1" }),
1317
+ ];
1318
+ const pins = { "dev@magus": "4.6.1", "terminal@magus": "latest" };
1319
+ const cli = planPluginUpdates(pins, catalog(...rows));
1320
+ const tui = classifyPluginRows(rows, pins);
1321
+ for (const item of cli) expect(tui.get(item.pluginId)).toEqual(item);
1322
+ });
1323
+ });
1324
+
1325
+ describe("countPluginStates", () => {
1326
+ test("counts every action, including zeros", () => {
1327
+ const items = [
1328
+ { action: "install" },
1329
+ { action: "update" },
1330
+ { action: "update" },
1331
+ { action: "repair" },
1332
+ { action: "unknown" },
1333
+ ] as PluginUpdateItem[];
1334
+ expect(countPluginStates(items)).toEqual({
1335
+ install: 1,
1336
+ update: 2,
1337
+ repair: 1,
1338
+ current: 0,
1339
+ unknown: 1,
1340
+ });
1341
+ });
1342
+
1343
+ test("accepts a Map's values directly", () => {
1344
+ const m = new Map([["a", { action: "current" } as PluginUpdateItem]]);
1345
+ expect(countPluginStates(m.values()).current).toBe(1);
1346
+ });
1347
+ });
1348
+
1349
+ describe("scopeIsBehind — the scope picker's one question", () => {
1350
+ const status = (over: Partial<PluginUpdateItem>) =>
1351
+ ({ action: "update", scopes: ["project"], ...over }) as PluginUpdateItem;
1352
+
1353
+ test("an update touching the scope says yes", () => {
1354
+ expect(scopeIsBehind(status({}), "project")).toBe(true);
1355
+ });
1356
+
1357
+ test("an update touching OTHER scopes says no for this one", () => {
1358
+ expect(scopeIsBehind(status({}), "user")).toBe(false);
1359
+ });
1360
+
1361
+ test("repair, unknown, current and install all say no", () => {
1362
+ // `claude plugin install` on the same version is a no-op, so a repair
1363
+ // scope must not be offered "update".
1364
+ for (const action of ["repair", "unknown", "current", "install"] as const) {
1365
+ expect(scopeIsBehind(status({ action }), "project")).toBe(false);
1366
+ }
1367
+ });
1368
+
1369
+ test("no status says no", () => {
1370
+ expect(scopeIsBehind(null, "project")).toBe(false);
1371
+ expect(scopeIsBehind(undefined, "project")).toBe(false);
1372
+ });
1373
+ });