magus-cli 0.0.0-placeholder → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (330) hide show
  1. package/README.md +309 -0
  2. package/bin/magus.js +170 -0
  3. package/package.json +67 -3
  4. package/scripts/build-binaries.ts +113 -0
  5. package/scripts/capture-builtin.ts +412 -0
  6. package/scripts/check-optional-deps.ts +49 -0
  7. package/scripts/test-isolated.ts +49 -0
  8. package/scripts/verify-binary.ts +95 -0
  9. package/scripts/verify-community-registry.ts +272 -0
  10. package/src/__tests__/active-closure.test.ts +184 -0
  11. package/src/__tests__/alias-adopt.test.ts +365 -0
  12. package/src/__tests__/alias-parser.test.ts +417 -0
  13. package/src/__tests__/alias-shell-writer.test.ts +681 -0
  14. package/src/__tests__/alias-store.test.ts +163 -0
  15. package/src/__tests__/binary-signing.test.ts +120 -0
  16. package/src/__tests__/blackbox-active-closure.test.ts +119 -0
  17. package/src/__tests__/blackbox-convergence.test.ts +334 -0
  18. package/src/__tests__/blackbox-parity.test.ts +725 -0
  19. package/src/__tests__/blackbox-plugin-state.test.ts +545 -0
  20. package/src/__tests__/blackbox-tool-state.test.ts +669 -0
  21. package/src/__tests__/cache-provenance.test.ts +84 -0
  22. package/src/__tests__/capture-builtin.test.ts +92 -0
  23. package/src/__tests__/catalog-cache-store.test.ts +309 -0
  24. package/src/__tests__/catalog-notice.test.ts +155 -0
  25. package/src/__tests__/cli-ansi.test.ts +297 -0
  26. package/src/__tests__/cli-apply-seams.test.ts +288 -0
  27. package/src/__tests__/cli-live.test.ts +391 -0
  28. package/src/__tests__/cli-router.test.ts +240 -0
  29. package/src/__tests__/cli-tool-commands.test.ts +239 -0
  30. package/src/__tests__/cli-tools-adapter.test.ts +294 -0
  31. package/src/__tests__/cli-update-view.test.ts +524 -0
  32. package/src/__tests__/community-fetch.test.ts +545 -0
  33. package/src/__tests__/community-registry.test.ts +269 -0
  34. package/src/__tests__/community-staleness.test.ts +720 -0
  35. package/src/__tests__/component-badge.test.ts +98 -0
  36. package/src/__tests__/component-icons.test.ts +94 -0
  37. package/src/__tests__/content-drift.test.ts +353 -0
  38. package/src/__tests__/conventions-integration.test.ts +830 -0
  39. package/src/__tests__/conventions-manager.test.ts +1236 -0
  40. package/src/__tests__/doctor-bins.test.ts +43 -0
  41. package/src/__tests__/doctor.test.ts +463 -0
  42. package/src/__tests__/dotenv.test.ts +195 -0
  43. package/src/__tests__/enabled-not-installed.test.ts +94 -0
  44. package/src/__tests__/file-locking.test.ts +208 -0
  45. package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
  46. package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
  47. package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
  48. package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
  49. package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
  50. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
  51. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
  52. package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
  53. package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
  54. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
  55. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
  56. package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
  57. package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
  58. package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
  59. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
  60. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
  61. package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
  62. package/src/__tests__/footer-hints.test.ts +40 -0
  63. package/src/__tests__/format-command.test.ts +78 -0
  64. package/src/__tests__/git-worktree.test.ts +108 -0
  65. package/src/__tests__/github-budget.test.ts +206 -0
  66. package/src/__tests__/gitignore-check.test.ts +71 -0
  67. package/src/__tests__/gitignore-detector.test.ts +176 -0
  68. package/src/__tests__/gitignore-fixer.test.ts +317 -0
  69. package/src/__tests__/gitignore-resolver.test.ts +193 -0
  70. package/src/__tests__/gitignore-service.test.ts +137 -0
  71. package/src/__tests__/go-module.test.ts +43 -0
  72. package/src/__tests__/hook-import-policy.test.ts +90 -0
  73. package/src/__tests__/hook-process.test.ts +256 -0
  74. package/src/__tests__/hook-registration.test.ts +224 -0
  75. package/src/__tests__/install-command.test.ts +81 -0
  76. package/src/__tests__/install-plan.test.ts +104 -0
  77. package/src/__tests__/launcher-signal.test.ts +308 -0
  78. package/src/__tests__/local-marketplace-scan.test.ts +248 -0
  79. package/src/__tests__/manifest.test.ts +281 -0
  80. package/src/__tests__/markdown-renderer.test.ts +142 -0
  81. package/src/__tests__/marketplace-badge.test.ts +140 -0
  82. package/src/__tests__/marketplace-refresh.test.ts +340 -0
  83. package/src/__tests__/marketplace-version.test.ts +84 -0
  84. package/src/__tests__/marketplaces.test.ts +128 -0
  85. package/src/__tests__/mate-availability.test.ts +156 -0
  86. package/src/__tests__/mate-catalog.test.ts +295 -0
  87. package/src/__tests__/mcp-registry.test.ts +158 -0
  88. package/src/__tests__/model-visuals.test.tsx +2466 -0
  89. package/src/__tests__/models-adapter.test.ts +332 -0
  90. package/src/__tests__/models-cli.test.ts +273 -0
  91. package/src/__tests__/models-core.test.ts +802 -0
  92. package/src/__tests__/models-manager.test.ts +500 -0
  93. package/src/__tests__/models-presets-marketplace.test.ts +168 -0
  94. package/src/__tests__/models-screen-state.test.ts +315 -0
  95. package/src/__tests__/open-file.test.ts +59 -0
  96. package/src/__tests__/owned-marketplaces.test.ts +93 -0
  97. package/src/__tests__/plugin-checkout.test.ts +66 -0
  98. package/src/__tests__/plugin-cli-argv.test.ts +109 -0
  99. package/src/__tests__/plugin-contents.test.ts +371 -0
  100. package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
  101. package/src/__tests__/plugin-releases.test.ts +98 -0
  102. package/src/__tests__/plugin-requires.test.ts +187 -0
  103. package/src/__tests__/plugin-row-text.test.ts +345 -0
  104. package/src/__tests__/plugin-setup.test.ts +1016 -0
  105. package/src/__tests__/profile-adopt.test.ts +177 -0
  106. package/src/__tests__/profile-command.test.ts +110 -0
  107. package/src/__tests__/profile-materializer.test.ts +238 -0
  108. package/src/__tests__/profile-sync.test.ts +154 -0
  109. package/src/__tests__/registry-version-resolution.test.ts +88 -0
  110. package/src/__tests__/resolve-executable.test.ts +42 -0
  111. package/src/__tests__/resolver.test.ts +351 -0
  112. package/src/__tests__/scope-action.test.ts +134 -0
  113. package/src/__tests__/scope-squares.test.tsx +164 -0
  114. package/src/__tests__/selection-scope.test.ts +100 -0
  115. package/src/__tests__/settings-file.test.ts +179 -0
  116. package/src/__tests__/shell-script-callers.test.ts +92 -0
  117. package/src/__tests__/skill-install-files.test.ts +136 -0
  118. package/src/__tests__/skills-catalog-client.test.ts +48 -0
  119. package/src/__tests__/style-wrap.test.ts +220 -0
  120. package/src/__tests__/styles-manager.test.ts +1098 -0
  121. package/src/__tests__/styles-origins.test.ts +414 -0
  122. package/src/__tests__/styles-screen-state.test.ts +457 -0
  123. package/src/__tests__/styles-status-line.test.ts +72 -0
  124. package/src/__tests__/styles-sync.test.ts +452 -0
  125. package/src/__tests__/symlink-manager.test.ts +180 -0
  126. package/src/__tests__/tabbar-layout.test.ts +100 -0
  127. package/src/__tests__/terminology-filler.test.ts +214 -0
  128. package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
  129. package/src/__tests__/theme-env.test.ts +310 -0
  130. package/src/__tests__/theme-resolve.test.ts +380 -0
  131. package/src/__tests__/tool-probe.test.ts +1400 -0
  132. package/src/__tests__/toolchain.test.ts +232 -0
  133. package/src/__tests__/ui-version-writers.test.ts +88 -0
  134. package/src/__tests__/update-apply.test.ts +637 -0
  135. package/src/__tests__/update-plan.test.ts +1396 -0
  136. package/src/__tests__/uppercase-keybindings.test.ts +101 -0
  137. package/src/__tests__/useGitignoreModal.test.ts +79 -0
  138. package/src/__tests__/version-check-refresh.test.ts +45 -0
  139. package/src/__tests__/version-snapshot.test.ts +212 -0
  140. package/src/__tests__/version-unknown-state.test.ts +403 -0
  141. package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
  142. package/src/cli/ansi.ts +528 -0
  143. package/src/cli/bootstrap.ts +134 -0
  144. package/src/cli/doctor.ts +218 -0
  145. package/src/cli/hook.ts +129 -0
  146. package/src/cli/install.ts +382 -0
  147. package/src/cli/live.ts +374 -0
  148. package/src/cli/models.ts +297 -0
  149. package/src/cli/profile.ts +197 -0
  150. package/src/cli/prompt.ts +32 -0
  151. package/src/cli/router.ts +264 -0
  152. package/src/cli/update-view.ts +717 -0
  153. package/src/cli/update.ts +600 -0
  154. package/src/cli/upgrade.ts +97 -0
  155. package/src/data/alias-flags.ts +301 -0
  156. package/src/data/cli-tools.ts +146 -0
  157. package/src/data/community-styles.ts +531 -0
  158. package/src/data/gitignore-defaults.ts +33 -0
  159. package/src/data/gitignore-reasons.ts +93 -0
  160. package/src/data/gitignore-templates.ts +25 -0
  161. package/src/data/marketplaces.ts +278 -0
  162. package/src/data/mcp-servers.ts +51 -0
  163. package/src/data/models-presets.ts +329 -0
  164. package/src/data/predefined-profiles.ts +252 -0
  165. package/src/data/settings-catalog.ts +714 -0
  166. package/src/data/skill-repos.ts +91 -0
  167. package/src/data/styles/asd-ste100.md +38 -0
  168. package/src/data/styles/calibrated.md +24 -0
  169. package/src/data/styles/direct.md +23 -0
  170. package/src/data/styles/evidence-first.md +22 -0
  171. package/src/data/styles/explanatory.md +23 -0
  172. package/src/data/styles/index.ts +57 -0
  173. package/src/data/styles/no-slop.md +32 -0
  174. package/src/data/styles/plain-language.md +23 -0
  175. package/src/data/styles/structured.md +30 -0
  176. package/src/data/styles/terminology.md +28 -0
  177. package/src/data/styles/terse.md +20 -0
  178. package/src/main.tsx +110 -0
  179. package/src/markdown.d.ts +13 -0
  180. package/src/opentui.d.ts +203 -0
  181. package/src/services/active-closure.ts +117 -0
  182. package/src/services/alias-settings.ts +60 -0
  183. package/src/services/alias-shell-writer.ts +1244 -0
  184. package/src/services/alias-store.ts +172 -0
  185. package/src/services/binary-signing.ts +78 -0
  186. package/src/services/catalog-cache-store.ts +316 -0
  187. package/src/services/claude-cli.ts +304 -0
  188. package/src/services/claude-runner.ts +31 -0
  189. package/src/services/claude-settings.ts +1564 -0
  190. package/src/services/cli-tool-commands.ts +133 -0
  191. package/src/services/community-fetcher.ts +90 -0
  192. package/src/services/community-styles.ts +1194 -0
  193. package/src/services/content-drift.ts +208 -0
  194. package/src/services/conventions-manager.ts +945 -0
  195. package/src/services/doctor-bins.ts +63 -0
  196. package/src/services/doctor.ts +570 -0
  197. package/src/services/dotenv.ts +181 -0
  198. package/src/services/git-worktree.ts +129 -0
  199. package/src/services/github-budget.ts +274 -0
  200. package/src/services/gitignore-check.ts +63 -0
  201. package/src/services/gitignore-detector.ts +167 -0
  202. package/src/services/gitignore-fixer.ts +280 -0
  203. package/src/services/gitignore-resolver.ts +208 -0
  204. package/src/services/gitignore-service.ts +167 -0
  205. package/src/services/hook-registration.ts +218 -0
  206. package/src/services/install-plan.ts +107 -0
  207. package/src/services/local-marketplace.ts +657 -0
  208. package/src/services/manifest.ts +250 -0
  209. package/src/services/marketplace-catalog-git.ts +173 -0
  210. package/src/services/marketplace-catalog.ts +129 -0
  211. package/src/services/marketplace-fetcher.ts +400 -0
  212. package/src/services/marketplace-refresh.ts +245 -0
  213. package/src/services/marketplace-sync.ts +145 -0
  214. package/src/services/mate-availability.ts +133 -0
  215. package/src/services/mate-catalog.ts +265 -0
  216. package/src/services/mcp-registry.ts +171 -0
  217. package/src/services/models-core.ts +969 -0
  218. package/src/services/models-manager.ts +606 -0
  219. package/src/services/plugin-checkout.ts +186 -0
  220. package/src/services/plugin-manager.ts +847 -0
  221. package/src/services/plugin-mcp-config.ts +242 -0
  222. package/src/services/plugin-releases.ts +47 -0
  223. package/src/services/plugin-requires.ts +227 -0
  224. package/src/services/plugin-setup.ts +965 -0
  225. package/src/services/profile-adopt.ts +206 -0
  226. package/src/services/profile-materializer.ts +133 -0
  227. package/src/services/profile-sync.ts +158 -0
  228. package/src/services/profiles.ts +225 -0
  229. package/src/services/resolver.ts +377 -0
  230. package/src/services/settings-file.ts +69 -0
  231. package/src/services/settings-manager.ts +307 -0
  232. package/src/services/skills-manager.ts +1111 -0
  233. package/src/services/skillsmp-client.ts +193 -0
  234. package/src/services/styles-manager.ts +1294 -0
  235. package/src/services/symlink-manager.ts +189 -0
  236. package/src/services/terminology-filler.ts +266 -0
  237. package/src/services/tool-probe.ts +1637 -0
  238. package/src/services/toolchain.ts +431 -0
  239. package/src/services/update-engine.ts +691 -0
  240. package/src/services/update-plan.ts +820 -0
  241. package/src/services/version-check.ts +127 -0
  242. package/src/services/version-snapshot.ts +189 -0
  243. package/src/tui.tsx +112 -0
  244. package/src/types/bun.d.ts +27 -0
  245. package/src/types/gitignore.ts +68 -0
  246. package/src/types/index.ts +447 -0
  247. package/src/ui/App.tsx +535 -0
  248. package/src/ui/adapters/catalogNotice.ts +122 -0
  249. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  250. package/src/ui/adapters/modelsAdapter.ts +213 -0
  251. package/src/ui/adapters/pluginsAdapter.ts +413 -0
  252. package/src/ui/adapters/settingsAdapter.ts +168 -0
  253. package/src/ui/adapters/skillsAdapter.ts +229 -0
  254. package/src/ui/adapters/stylesAdapter.ts +403 -0
  255. package/src/ui/components/CategoryHeader.tsx +64 -0
  256. package/src/ui/components/EmptyFilterState.tsx +32 -0
  257. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  258. package/src/ui/components/ScopeIndicator.tsx +63 -0
  259. package/src/ui/components/ScrollableList.tsx +95 -0
  260. package/src/ui/components/SearchInput.tsx +57 -0
  261. package/src/ui/components/StyledText.tsx +75 -0
  262. package/src/ui/components/TabBar.tsx +101 -0
  263. package/src/ui/components/layout/FooterHints.tsx +78 -0
  264. package/src/ui/components/layout/Panel.tsx +63 -0
  265. package/src/ui/components/layout/ProgressBar.tsx +44 -0
  266. package/src/ui/components/layout/ScopeTabs.tsx +54 -0
  267. package/src/ui/components/layout/ScreenLayout.tsx +321 -0
  268. package/src/ui/components/layout/index.ts +9 -0
  269. package/src/ui/components/modals/ConfirmModal.tsx +62 -0
  270. package/src/ui/components/modals/InputModal.tsx +75 -0
  271. package/src/ui/components/modals/LoadingModal.tsx +51 -0
  272. package/src/ui/components/modals/MessageModal.tsx +66 -0
  273. package/src/ui/components/modals/ModalContainer.tsx +159 -0
  274. package/src/ui/components/modals/SelectModal.tsx +90 -0
  275. package/src/ui/components/modals/index.ts +6 -0
  276. package/src/ui/components/primitives/ActionHints.tsx +44 -0
  277. package/src/ui/components/primitives/DetailSection.tsx +23 -0
  278. package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
  279. package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
  280. package/src/ui/components/primitives/MetaText.tsx +40 -0
  281. package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
  282. package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
  283. package/src/ui/components/primitives/SelectableRow.tsx +30 -0
  284. package/src/ui/components/primitives/index.ts +9 -0
  285. package/src/ui/hooks/index.ts +2 -0
  286. package/src/ui/hooks/useGitignoreModal.ts +95 -0
  287. package/src/ui/hooks/useKeyboard.ts +24 -0
  288. package/src/ui/hooks/useKeyboardHandler.ts +63 -0
  289. package/src/ui/registry.ts +33 -0
  290. package/src/ui/renderers/cliToolRenderers.tsx +326 -0
  291. package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
  292. package/src/ui/renderers/markdownRenderer.tsx +302 -0
  293. package/src/ui/renderers/mcpRenderers.tsx +149 -0
  294. package/src/ui/renderers/modelRenderers.tsx +1309 -0
  295. package/src/ui/renderers/modelVisuals.tsx +1402 -0
  296. package/src/ui/renderers/pluginRenderers.tsx +1089 -0
  297. package/src/ui/renderers/pluginRowText.ts +70 -0
  298. package/src/ui/renderers/pluginStatusText.ts +56 -0
  299. package/src/ui/renderers/profileRenderers.tsx +406 -0
  300. package/src/ui/renderers/settingsRenderers.tsx +218 -0
  301. package/src/ui/renderers/skillRenderers.tsx +479 -0
  302. package/src/ui/renderers/styleRenderers.tsx +813 -0
  303. package/src/ui/screens/AliasScreen.tsx +1558 -0
  304. package/src/ui/screens/CliToolsScreen.tsx +367 -0
  305. package/src/ui/screens/EnvVarsScreen.tsx +221 -0
  306. package/src/ui/screens/GitignoreScreen.tsx +562 -0
  307. package/src/ui/screens/McpRegistryScreen.tsx +366 -0
  308. package/src/ui/screens/McpScreen.tsx +256 -0
  309. package/src/ui/screens/ModelsScreen.tsx +542 -0
  310. package/src/ui/screens/PluginsScreen.tsx +1423 -0
  311. package/src/ui/screens/ProfilesScreen.tsx +438 -0
  312. package/src/ui/screens/SkillsScreen.tsx +853 -0
  313. package/src/ui/screens/StylesScreen.tsx +1064 -0
  314. package/src/ui/screens/index.ts +11 -0
  315. package/src/ui/state/AppContext.tsx +233 -0
  316. package/src/ui/state/DimensionsContext.tsx +154 -0
  317. package/src/ui/state/reducer.ts +810 -0
  318. package/src/ui/state/types.ts +503 -0
  319. package/src/ui/theme-mode.ts +440 -0
  320. package/src/ui/theme-resolve.ts +147 -0
  321. package/src/ui/theme.ts +175 -0
  322. package/src/utils/clipboard.ts +58 -0
  323. package/src/utils/command-utils.ts +149 -0
  324. package/src/utils/config-dir.ts +45 -0
  325. package/src/utils/file-locking.ts +146 -0
  326. package/src/utils/fuzzy-search.ts +138 -0
  327. package/src/utils/go-module.ts +36 -0
  328. package/src/utils/open-file.ts +84 -0
  329. package/src/utils/run.ts +67 -0
  330. package/src/utils/string-utils.ts +88 -0
@@ -0,0 +1,725 @@
1
+ /**
2
+ * BLACK-BOX parity test (R7 / R2 / R3). Written from requirements.md and the
3
+ * architecture §7/§8 contracts only — no implementation bodies were read.
4
+ *
5
+ * ONE fixture (catalog + registry-derived installed versions + profile pins +
6
+ * tool facts) is fed through the CLI planning path (`planPluginUpdates`,
7
+ * `planBinUpdates`) and the TUI state path (`classifyPluginRows` →
8
+ * `buildPluginBrowserItems` → `pluginRowText`; `specForCatalogTool` →
9
+ * `buildToolRow`). For every shared id the state and the reason string must be
10
+ * identical.
11
+ *
12
+ * Real values: the 5 `claude-plugins-official` rows carry the registry versions
13
+ * read (read-only) from ~/.claude/plugins/installed_plugins.json for project
14
+ * /Users/jack/mag/claudish on 2026-09-12; the 17 declared plugins are the real
15
+ * /Users/jack/mag/claudish/.claude/profiles.json `default` profile; tmux,
16
+ * browser-use and mcp facts are validation/baseline-tools.md. Rows marked
17
+ * _synthetic exist only to reach states the real data lacks.
18
+ */
19
+ import { describe, expect, test } from "bun:test";
20
+ import { cliTools } from "../data/cli-tools.js";
21
+ import { selectProfileId } from "../services/active-closure.js";
22
+ import type { PluginInfo } from "../services/plugin-manager.js";
23
+ import { resolveProfile } from "../services/resolver.js";
24
+ import { type ToolFacts, specForCatalogTool } from "../services/tool-probe.js";
25
+ import {
26
+ type BinUpdateItem,
27
+ type PluginUpdateItem,
28
+ classifyPluginRows,
29
+ countPluginStates,
30
+ planBinUpdates,
31
+ planPluginUpdates,
32
+ } from "../services/update-plan.js";
33
+ import type {
34
+ BinRequirement,
35
+ Marketplace,
36
+ ProfileManifest,
37
+ ResolvedClosure,
38
+ } from "../types/index.js";
39
+ import { buildToolRow } from "../ui/adapters/cliToolsAdapter.js";
40
+ import {
41
+ type PluginPluginItem,
42
+ buildPluginBrowserItems,
43
+ } from "../ui/adapters/pluginsAdapter.js";
44
+ import { pluginRowText } from "../ui/renderers/pluginRowText.js";
45
+
46
+ // ── fixture ────────────────────────────────────────────────────────────────
47
+
48
+ const OFFICIAL = "claude-plugins-official";
49
+
50
+ /** Real registry versions (project scope, /Users/jack/mag/claudish). Catalog publishes none. */
51
+ const REAL_OFFICIAL: Record<string, string> = {
52
+ [`code-review@${OFFICIAL}`]: "3ea32df27be7",
53
+ [`code-simplifier@${OFFICIAL}`]: "1.0.0",
54
+ [`explanatory-output-style@${OFFICIAL}`]: "1.0.0",
55
+ [`playground@${OFFICIAL}`]: "3b600518a637",
56
+ [`skill-creator@${OFFICIAL}`]: "517b2fcd1b60",
57
+ };
58
+
59
+ const NO_PUBLISHED_VERSION_NOTE =
60
+ "its marketplace publishes no version for this plugin, so there is nothing to compare";
61
+
62
+ function marketplace(
63
+ name: string,
64
+ extra: Partial<Marketplace> = {},
65
+ ): Marketplace {
66
+ return {
67
+ name,
68
+ displayName: name,
69
+ source: { source: "github", repo: `example/${name}` },
70
+ description: "",
71
+ ...extra,
72
+ };
73
+ }
74
+
75
+ function makeMarketplaces(): Marketplace[] {
76
+ return [
77
+ marketplace(OFFICIAL, { official: true }),
78
+ marketplace("magus", { owned: true }),
79
+ marketplace("magus-marketing", { owned: true }),
80
+ ];
81
+ }
82
+
83
+ function row(
84
+ id: string,
85
+ catalogVersion: string | null,
86
+ installed: string | null,
87
+ extra: Partial<PluginInfo> = {},
88
+ ): PluginInfo {
89
+ const at = id.indexOf("@");
90
+ const name = id.slice(0, at);
91
+ const mp = id.slice(at + 1);
92
+ return {
93
+ id,
94
+ name,
95
+ version: catalogVersion,
96
+ description: "",
97
+ marketplace: mp,
98
+ marketplaceDisplay: mp,
99
+ enabled: installed !== null,
100
+ ...(installed !== null
101
+ ? {
102
+ installedVersion: installed,
103
+ projectScope: { enabled: true, version: installed },
104
+ }
105
+ : {}),
106
+ ...extra,
107
+ };
108
+ }
109
+
110
+ function makeCatalog(): PluginInfo[] {
111
+ return [
112
+ // the 5 real unversioned official plugins
113
+ ...Object.entries(REAL_OFFICIAL).map(([id, v]) => row(id, null, v)),
114
+ row(`typescript-lsp@${OFFICIAL}`, "1.0.0", "1.0.0"),
115
+ // _synthetic: the real "0.0.0" registry value, unversioned in the catalog
116
+ row(`zero-version@${OFFICIAL}`, null, "0.0.0"),
117
+ // real magus ids from the profile
118
+ row("browser-use@magus", "1.4.0", "1.4.0"),
119
+ row("designer@magus", "2.0.0", "1.5.0"),
120
+ row("code-analysis@magus", "3.1.0", "3.1.0"),
121
+ row("multimodel@magus", "2.2.0", "2.2.0"),
122
+ row("dev@magus", "7.4.0", "7.4.0"),
123
+ row("terminal@magus", "1.3.0", "1.3.0"),
124
+ row("madbench@magus", "1.0.0", "1.0.0", { contentStale: true }),
125
+ row("claudish@magus", "2.0.0", "2.0.0", {
126
+ updateCheckFailed: true,
127
+ catalogSource: "local-clone",
128
+ }),
129
+ row("mnemex@magus", "1.2.0", "1.2.0"),
130
+ row("bunjs@magus", "0.4.4", null),
131
+ row("setup@magus", "1.0.0", "1.0.0"),
132
+ // _synthetic orphans
133
+ row("seo@magus", null, "1.0.0", {
134
+ isOrphaned: true,
135
+ }),
136
+ row("legacy-tool@magus", null, "0.5.0", { isOrphaned: true }),
137
+ row("seo@magus-marketing", "1.1.0", null),
138
+ // _synthetic exact pins
139
+ row("pinned-behind@magus", "2.1.0", "2.0.0"),
140
+ row("pinned-unorderable@magus", "1.0.0", "3ea32df27be7"),
141
+ row("pinned-undeliverable@magus", "3.0.0", "1.0.0"),
142
+ // _synthetic TUI-only rows
143
+ row("installed-undeclared@magus", "1.0.0", "1.0.0"),
144
+ row("enabled-only@magus", "1.0.0", null, {
145
+ enabled: true,
146
+ userScope: { enabled: true },
147
+ }),
148
+ row("browse-only@magus", "1.0.0", null),
149
+ ];
150
+ }
151
+
152
+ const REAL_PROFILE_PLUGINS = [
153
+ `code-review@${OFFICIAL}`,
154
+ `code-simplifier@${OFFICIAL}`,
155
+ `explanatory-output-style@${OFFICIAL}`,
156
+ `playground@${OFFICIAL}`,
157
+ `typescript-lsp@${OFFICIAL}`,
158
+ `skill-creator@${OFFICIAL}`,
159
+ "browser-use@magus",
160
+ "designer@magus",
161
+ "code-analysis@magus",
162
+ "multimodel@magus",
163
+ "dev@magus",
164
+ "terminal@magus",
165
+ "madbench@magus",
166
+ "claudish@magus",
167
+ "mnemex@magus",
168
+ "bunjs@magus",
169
+ "setup@magus",
170
+ ];
171
+
172
+ function makeManifest(): ProfileManifest {
173
+ const plugins: Record<string, string> = {};
174
+ for (const id of REAL_PROFILE_PLUGINS) plugins[id] = "latest";
175
+ Object.assign(plugins, {
176
+ [`zero-version@${OFFICIAL}`]: "latest",
177
+ "seo@magus": "latest",
178
+ "legacy-tool@magus": "latest",
179
+ "gone@magus": "latest", // CLI-only: not in the catalog at all
180
+ "pinned-behind@magus": "2.1.0",
181
+ "pinned-unorderable@magus": "1.0.0",
182
+ "pinned-undeliverable@magus": "1.0.0",
183
+ });
184
+ return {
185
+ version: 2,
186
+ profiles: {
187
+ default: {
188
+ name: "Default",
189
+ plugins,
190
+ cliTools: {
191
+ claudish: "latest",
192
+ mnemex: "1.0.0",
193
+ crush: "latest",
194
+ codex: "latest",
195
+ gemini: "latest",
196
+ qwen: "latest",
197
+ },
198
+ },
199
+ other: { name: "Other", plugins: { "dev@magus": "latest" } },
200
+ },
201
+ };
202
+ }
203
+
204
+ const PLUGIN_BINS: Record<string, BinRequirement[]> = {
205
+ "terminal@magus": [
206
+ { name: "tmux", via: "brew", check: "tmux -V" },
207
+ {
208
+ name: "tmux-mcp",
209
+ via: "go",
210
+ module: "github.com/MadAppGang/tmux-mcp/v2",
211
+ version: "v2.0.0",
212
+ },
213
+ ],
214
+ "browser-use@magus": [
215
+ { name: "browser-use", via: "pip" },
216
+ { name: "mcp", via: "pip" },
217
+ ],
218
+ };
219
+
220
+ async function fixtureBins(pluginId: string): Promise<BinRequirement[]> {
221
+ return (PLUGIN_BINS[pluginId] ?? []).map((b) => ({ ...b }));
222
+ }
223
+
224
+ function facts(name: string, over: Partial<ToolFacts>): ToolFacts {
225
+ return {
226
+ name,
227
+ present: true,
228
+ resolvedPath: `/usr/local/bin/${name}`,
229
+ installed: null,
230
+ latest: null,
231
+ behind: null,
232
+ managed: true,
233
+ sources: ["path"],
234
+ ...over,
235
+ };
236
+ }
237
+
238
+ function makeFacts(): ToolFacts[] {
239
+ return [
240
+ // baseline-tools.md: brew outdated tmux → [], tmux 3.7c
241
+ facts("tmux", {
242
+ resolvedPath: "/opt/homebrew/Cellar/tmux/3.7c/bin/tmux",
243
+ installed: "3.7c",
244
+ behind: false,
245
+ sources: ["path", "brew"],
246
+ }),
247
+ // baseline-tools.md: absent from `uv tool list --outdated`
248
+ facts("browser-use", {
249
+ resolvedPath:
250
+ "/Users/jack/.local/share/uv/tools/browser-use/bin/browser-use",
251
+ installed: "0.13.10",
252
+ behind: false,
253
+ sources: ["path", "uv"],
254
+ }),
255
+ facts("mcp", {
256
+ resolvedPath: "/Users/jack/.local/share/uv/tools/mcp/bin/mcp",
257
+ installed: "2.2.0",
258
+ behind: false,
259
+ sources: ["path", "uv"],
260
+ }),
261
+ facts("tmux-mcp", {
262
+ resolvedPath: "/Users/jack/go/bin/tmux-mcp",
263
+ installed: "v2.0.0",
264
+ sources: ["path", "go-buildinfo"],
265
+ }),
266
+ facts("claudish", {
267
+ resolvedPath:
268
+ "/Users/jack/.bun/install/global/node_modules/claudish/dist/index.js",
269
+ installed: "9.3.0",
270
+ latest: "9.3.0",
271
+ sources: ["path", "bun-global", "npm-registry"],
272
+ }),
273
+ facts("mnemex", {
274
+ resolvedPath:
275
+ "/Users/jack/.bun/install/global/node_modules/mnemex/dist/index.js",
276
+ installed: "0.9.0",
277
+ latest: "1.2.0",
278
+ sources: ["path", "bun-global", "npm-registry"],
279
+ }),
280
+ facts("crush", {
281
+ resolvedPath:
282
+ "/Users/jack/.nvm/versions/node/v22.14.0/lib/node_modules/@charmland/crush/bin/crush",
283
+ installed: "0.7.0",
284
+ latest: "0.8.0",
285
+ managed: false,
286
+ sources: ["path", "npm-registry"],
287
+ }),
288
+ facts("codex", {
289
+ installed: "0.40.0",
290
+ latest: "0.41.0",
291
+ sources: ["path", "npm-global", "npm-registry"],
292
+ }),
293
+ facts("gemini", {
294
+ installed: "0.5.0",
295
+ latest: null,
296
+ behind: null,
297
+ failure: "timed out after 8s",
298
+ sources: ["path", "npm-global"],
299
+ }),
300
+ facts("qwen", {
301
+ present: false,
302
+ resolvedPath: null,
303
+ installed: null,
304
+ latest: "0.1.0",
305
+ managed: null,
306
+ sources: ["npm-registry"],
307
+ }),
308
+ ];
309
+ }
310
+
311
+ // ── the two paths ──────────────────────────────────────────────────────────
312
+
313
+ interface Paths {
314
+ closure: ResolvedClosure;
315
+ cliPlugins: Map<string, PluginUpdateItem>;
316
+ tuiRows: Map<string, PluginPluginItem>;
317
+ sharedPluginIds: string[];
318
+ cliBins: Map<string, BinUpdateItem>;
319
+ tuiTools: Map<string, ReturnType<typeof buildToolRow>>;
320
+ sharedToolNames: string[];
321
+ catalog: PluginInfo[];
322
+ }
323
+
324
+ async function runBothPaths(): Promise<Paths> {
325
+ const manifest = makeManifest();
326
+ const catalog = makeCatalog();
327
+ const profileId = selectProfileId(
328
+ Object.keys(manifest.profiles),
329
+ undefined,
330
+ "default",
331
+ );
332
+ if (profileId === null) throw new Error("fixture: no profile selected");
333
+ const closure = await resolveProfile(manifest, profileId, {
334
+ binResolver: fixtureBins,
335
+ });
336
+
337
+ // CLI path
338
+ const cliPluginList = planPluginUpdates(
339
+ closure.plugins,
340
+ new Map(catalog.map((p) => [p.id, p])),
341
+ );
342
+ const cliPlugins = new Map(cliPluginList.map((i) => [i.pluginId, i]));
343
+
344
+ // TUI path
345
+ const statuses = classifyPluginRows(catalog, closure.plugins);
346
+ const items = buildPluginBrowserItems({
347
+ marketplaces: makeMarketplaces(),
348
+ plugins: catalog,
349
+ collapsedMarketplaces: new Set(),
350
+ statuses,
351
+ });
352
+ const tuiRows = new Map<string, PluginPluginItem>();
353
+ for (const it of items) {
354
+ if (it.kind === "plugin") tuiRows.set(it.plugin.id, it);
355
+ }
356
+
357
+ const sharedPluginIds = [...cliPlugins.keys()]
358
+ .filter((id) => tuiRows.has(id))
359
+ .sort();
360
+
361
+ // tools
362
+ const factList = makeFacts();
363
+ const cliBins = new Map(
364
+ planBinUpdates(closure.bins, factList).map((b) => [b.name, b]),
365
+ );
366
+ const factsByName = new Map(factList.map((f) => [f.name, f]));
367
+ const declared = new Set(closure.bins.map((b) => b.name));
368
+ const tuiTools = new Map<string, ReturnType<typeof buildToolRow>>();
369
+ for (const tool of cliTools) {
370
+ if (!declared.has(tool.name)) continue;
371
+ tuiTools.set(
372
+ tool.name,
373
+ buildToolRow(
374
+ tool,
375
+ specForCatalogTool(tool, closure.bins, undefined),
376
+ factsByName.get(tool.name),
377
+ ),
378
+ );
379
+ }
380
+ const sharedToolNames = [...tuiTools.keys()]
381
+ .filter((n) => cliBins.has(n))
382
+ .sort();
383
+
384
+ return {
385
+ closure,
386
+ cliPlugins,
387
+ tuiRows,
388
+ sharedPluginIds,
389
+ cliBins,
390
+ tuiTools,
391
+ sharedToolNames,
392
+ catalog,
393
+ };
394
+ }
395
+
396
+ function pickPlugin(i: PluginUpdateItem | null | undefined) {
397
+ if (!i) return i;
398
+ return { action: i.action, reason: i.reason, note: i.note, target: i.target };
399
+ }
400
+
401
+ // ── assertions ─────────────────────────────────────────────────────────────
402
+
403
+ describe("R7 parity: one fixture through the CLI path and the TUI path", () => {
404
+ test("the intersections are non-vacuous and include every reported official plugin", async () => {
405
+ const p = await runBothPaths();
406
+
407
+ expect(p.sharedPluginIds.length).toBeGreaterThanOrEqual(8);
408
+ for (const id of Object.keys(REAL_OFFICIAL)) {
409
+ expect(p.sharedPluginIds).toContain(id);
410
+ }
411
+ expect(p.sharedPluginIds).toContain(`zero-version@${OFFICIAL}`);
412
+ expect(p.sharedToolNames.length).toBeGreaterThanOrEqual(3);
413
+ });
414
+
415
+ test("every shared plugin has the same action, reason, note and target in both front ends", async () => {
416
+ const p = await runBothPaths();
417
+
418
+ for (const id of p.sharedPluginIds) {
419
+ const cli = p.cliPlugins.get(id);
420
+ const tui = p.tuiRows.get(id)?.status;
421
+ expect({ id, s: pickPlugin(tui) }).toEqual({ id, s: pickPlugin(cli) });
422
+ }
423
+ });
424
+
425
+ test("every non-current shared plugin row renders non-empty text containing the CLI note verbatim", async () => {
426
+ const p = await runBothPaths();
427
+
428
+ for (const id of p.sharedPluginIds) {
429
+ const cli = p.cliPlugins.get(id);
430
+ if (!cli || cli.action === "current") continue;
431
+ const row = p.tuiRows.get(id);
432
+ if (!row) throw new Error(`no TUI row for ${id}`);
433
+ const { text } = pluginRowText(row);
434
+ expect({ id, empty: text.trim() === "" }).toEqual({ id, empty: false });
435
+ if (cli.note) {
436
+ expect({ id, containsNote: text.includes(cli.note) }).toEqual({
437
+ id,
438
+ containsNote: true,
439
+ });
440
+ }
441
+ }
442
+ });
443
+
444
+ // These 5 are CURRENT now, not unknown: their marketplace publishes no
445
+ // version, so there is nothing newer to be behind. R7 is unchanged and is
446
+ // still what this asserts — the two front ends must give the same answer,
447
+ // whatever that answer is. The reason code survives so the WHY is still
448
+ // recorded; the note does not, because nothing here is actionable.
449
+ test("the 5 official plugins get the same verdict on both paths", async () => {
450
+ const p = await runBothPaths();
451
+
452
+ for (const id of Object.keys(REAL_OFFICIAL)) {
453
+ const cli = p.cliPlugins.get(id);
454
+ const tui = p.tuiRows.get(id)?.status;
455
+ expect({ id, action: cli?.action, reason: cli?.reason }).toEqual({
456
+ id,
457
+ action: "current",
458
+ reason: "no-published-version",
459
+ });
460
+ // Parity, the point of the file: same note (none), same rendered row.
461
+ expect(tui?.note).toBe(cli?.note);
462
+ expect(cli?.note).toBeUndefined();
463
+ const row = p.tuiRows.get(id);
464
+ if (!row) throw new Error(`no TUI row for ${id}`);
465
+ expect(pluginRowText(row).text).not.toContain(NO_PUBLISHED_VERSION_NOTE);
466
+ }
467
+ });
468
+
469
+ test("each fixture row has its expected state on both paths", async () => {
470
+ const p = await runBothPaths();
471
+ const expected: Record<
472
+ string,
473
+ { action: string; reason?: string; target?: string | null }
474
+ > = {
475
+ // No published version to be behind → current. The reason still records
476
+ // that the verdict came from an absent catalog version, not a match.
477
+ [`zero-version@${OFFICIAL}`]: {
478
+ action: "current",
479
+ reason: "no-published-version",
480
+ },
481
+ [`typescript-lsp@${OFFICIAL}`]: {
482
+ action: "current",
483
+ reason: "up-to-date",
484
+ },
485
+ "browser-use@magus": { action: "current", reason: "up-to-date" },
486
+ "dev@magus": { action: "current", reason: "up-to-date" },
487
+ "designer@magus": { action: "update", reason: "behind", target: "2.0.0" },
488
+ "bunjs@magus": { action: "install", reason: "absent" },
489
+ "madbench@magus": { action: "repair", reason: "content-stale" },
490
+ "claudish@magus": {
491
+ action: "unknown",
492
+ reason: "catalog-unverified",
493
+ target: null,
494
+ },
495
+ // Was `moved`, which guessed a destination from a bare name match and
496
+ // could not tell a move from a dual publication. An orphan is simply
497
+ // not offered.
498
+ "seo@magus": { action: "unknown", reason: "not-offered", target: null },
499
+ "legacy-tool@magus": {
500
+ action: "unknown",
501
+ reason: "not-offered",
502
+ target: null,
503
+ },
504
+ "pinned-behind@magus": {
505
+ action: "update",
506
+ reason: "behind",
507
+ target: "2.1.0",
508
+ },
509
+ // `unorderable` is gone: when two versions cannot be ordered we assume
510
+ // the install is the latest rather than raising a warning no action
511
+ // can clear.
512
+ "pinned-unorderable@magus": { action: "current" },
513
+ };
514
+
515
+ for (const [id, want] of Object.entries(expected)) {
516
+ for (const [side, item] of [
517
+ ["cli", p.cliPlugins.get(id)],
518
+ ["tui", p.tuiRows.get(id)?.status],
519
+ ] as const) {
520
+ const got: Record<string, unknown> = { id, side, action: item?.action };
521
+ const exp: Record<string, unknown> = { id, side, action: want.action };
522
+ if (want.reason !== undefined) {
523
+ got.reason = item?.reason;
524
+ exp.reason = want.reason;
525
+ }
526
+ if (want.target !== undefined) {
527
+ got.target = item?.target;
528
+ exp.target = want.target;
529
+ }
530
+ expect(got).toEqual(exp);
531
+ }
532
+ }
533
+
534
+ const undeliverable = p.cliPlugins.get("pinned-undeliverable@magus");
535
+ expect(undeliverable?.reason).toBe("pin-undeliverable");
536
+ expect(["current", "update"]).toContain(undeliverable?.action ?? "");
537
+ expect(undeliverable?.note).toBeTruthy();
538
+
539
+ // declared but absent from the catalog: a CLI item, never a TUI row
540
+ expect(p.cliPlugins.get("gone@magus")?.action).toBe("unknown");
541
+ expect(p.tuiRows.has("gone@magus")).toBe(false);
542
+ });
543
+
544
+ test("TUI-only rows: installed or enabled rows get a status, browse-only rows get none", async () => {
545
+ const p = await runBothPaths();
546
+
547
+ expect(p.tuiRows.get("installed-undeclared@magus")?.status?.action).toBe(
548
+ "current",
549
+ );
550
+ const enabledOnly = p.tuiRows.get("enabled-only@magus")?.status;
551
+ expect({
552
+ action: enabledOnly?.action,
553
+ reason: enabledOnly?.reason,
554
+ }).toEqual({
555
+ action: "install",
556
+ reason: "enabled-not-installed",
557
+ });
558
+ const enabledRow = p.tuiRows.get("enabled-only@magus");
559
+ if (!enabledRow) throw new Error("no enabled-only row");
560
+ expect(pluginRowText(enabledRow).text).not.toContain("profile declares it");
561
+ expect(p.tuiRows.get("browse-only@magus")?.status).toBeNull();
562
+ expect(p.tuiRows.get("seo@magus-marketing")?.status).toBeNull();
563
+ });
564
+
565
+ test("per-state counts over the shared set are equal on both paths and match the fixture", async () => {
566
+ const p = await runBothPaths();
567
+ const cliItems = p.sharedPluginIds.map(
568
+ (id) => p.cliPlugins.get(id) as PluginUpdateItem,
569
+ );
570
+ const tuiItems = p.sharedPluginIds.map(
571
+ (id) => p.tuiRows.get(id)?.status as PluginUpdateItem,
572
+ );
573
+
574
+ const cliCounts = countPluginStates(cliItems);
575
+ expect(countPluginStates(tuiItems)).toEqual(cliCounts);
576
+ expect(cliCounts.install).toBe(1); // bunjs
577
+ expect(cliCounts.repair).toBe(1); // madbench
578
+ // Was 10. Seven of those rows are `current` now, and each for the same
579
+ // reason: nothing could be compared, so nothing is known to be behind.
580
+ // − 5 official (marketplace publishes no version)
581
+ // − zero-version (installed "0.0.0", no published version)
582
+ // − pinned-unorderable (sha install against a semver pin)
583
+ // What is left is unknown because something is genuinely wrong or unread:
584
+ // claudish catalog-unverified — the check itself failed
585
+ // seo@magus not-offered — an orphan
586
+ // legacy-tool not-offered — an orphan
587
+ expect(cliCounts.unknown).toBe(3);
588
+ expect(cliCounts.update).toBeGreaterThanOrEqual(2); // designer, pinned-behind
589
+ });
590
+
591
+ test("every shared tool has the same state, reason, note and target in both front ends", async () => {
592
+ const p = await runBothPaths();
593
+
594
+ for (const name of p.sharedToolNames) {
595
+ const cli = p.cliBins.get(name);
596
+ const tui = p.tuiTools.get(name);
597
+ expect({ name, checking: tui?.checking }).toEqual({
598
+ name,
599
+ checking: false,
600
+ });
601
+ expect({
602
+ name,
603
+ state: tui?.status?.state,
604
+ reason: tui?.status?.reason,
605
+ note: tui?.status?.note,
606
+ target: tui?.status?.target,
607
+ }).toEqual({
608
+ name,
609
+ state: cli?.action,
610
+ reason: cli?.reason,
611
+ note: cli?.note,
612
+ target: cli?.target,
613
+ });
614
+ // the TUI judges a declared tool against the closure's own spec
615
+ const declared = p.closure.bins.find((b) => b.name === name);
616
+ expect({ name, via: tui?.spec.via, version: tui?.spec.version }).toEqual({
617
+ name,
618
+ via: declared?.via,
619
+ version: declared?.version,
620
+ });
621
+ }
622
+ });
623
+
624
+ test("each fixture tool has its expected state; the user's looping trio is current", async () => {
625
+ const p = await runBothPaths();
626
+ const expected: Record<
627
+ string,
628
+ { action: string; reason?: string; target?: string }
629
+ > = {
630
+ tmux: { action: "current" },
631
+ "browser-use": { action: "current" },
632
+ mcp: { action: "current" },
633
+ "tmux-mcp": { action: "current", reason: "at-pin" },
634
+ claudish: { action: "current" },
635
+ mnemex: { action: "upgrade", reason: "behind-pin", target: "1.0.0" },
636
+ codex: { action: "upgrade", reason: "behind", target: "0.41.0" },
637
+ gemini: { action: "unknown" },
638
+ qwen: { action: "install", reason: "absent" },
639
+ };
640
+
641
+ for (const [name, want] of Object.entries(expected)) {
642
+ const item = p.cliBins.get(name);
643
+ const got: Record<string, unknown> = { name, action: item?.action };
644
+ const exp: Record<string, unknown> = { name, action: want.action };
645
+ if (want.reason) {
646
+ got.reason = item?.reason;
647
+ exp.reason = want.reason;
648
+ }
649
+ if (want.target) {
650
+ got.target = item?.target;
651
+ exp.target = want.target;
652
+ }
653
+ expect(got).toEqual(exp);
654
+ }
655
+
656
+ const crush = p.cliBins.get("crush");
657
+ expect(crush?.reason).toBe("not-managed");
658
+ expect(crush?.action).not.toBe("upgrade");
659
+ expect(p.cliBins.get("gemini")?.note).toBeTruthy();
660
+ });
661
+
662
+ test("planning the same fixture twice yields identical plans", async () => {
663
+ const a = await runBothPaths();
664
+ const b = await runBothPaths();
665
+
666
+ expect([...b.cliPlugins.values()]).toEqual([...a.cliPlugins.values()]);
667
+ expect([...b.cliBins.values()]).toEqual([...a.cliBins.values()]);
668
+ });
669
+ });
670
+
671
+ describe("fixture discriminates the pre-change logic", () => {
672
+ /** requirements.md, root causes: `present ? (bin.version ? "current" : "upgrade") : "install"`. */
673
+ function legacyPlanBin(present: boolean, pinned: boolean): string {
674
+ return present ? (pinned ? "current" : "upgrade") : "install";
675
+ }
676
+
677
+ /** Pre-change TUI row state from the PluginInfo flags alone (requirements: "reads hasUpdate / updateCheckFailed"). */
678
+ function legacyFlagState(info: PluginInfo): string {
679
+ if (info.updateCheckFailed) return "unknown";
680
+ if (info.hasUpdate) return "update";
681
+ return "healthy";
682
+ }
683
+
684
+ test("legacy bin rule upgrades tmux, browser-use and mcp on every run; the new plan says current", async () => {
685
+ const p = await runBothPaths();
686
+
687
+ for (const name of ["tmux", "browser-use", "mcp"]) {
688
+ const spec = p.closure.bins.find((b) => b.name === name);
689
+ expect(legacyPlanBin(true, Boolean(spec?.version))).toBe("upgrade");
690
+ expect(p.cliBins.get(name)?.action).toBe("current");
691
+ }
692
+ });
693
+
694
+ // The fixture must still tell the two designs apart, or every test above it
695
+ // could pass against the old flag-derived logic and prove nothing.
696
+ //
697
+ // It no longer discriminates on the UNVERSIONED rows: both call them
698
+ // healthy/current. That is a real narrowing and it is stated rather than
699
+ // hidden — the surviving difference there is that the classifier records WHY
700
+ // (`no-published-version`) while the flags carry no reason at all.
701
+ test("flag-derived state cannot reach the states the classifier does", async () => {
702
+ const p = await runBothPaths();
703
+
704
+ // Same verdict, different information: the flags have no reason field.
705
+ for (const id of [
706
+ ...Object.keys(REAL_OFFICIAL),
707
+ `zero-version@${OFFICIAL}`,
708
+ ]) {
709
+ const info = p.catalog.find((c) => c.id === id) as PluginInfo;
710
+ expect(legacyFlagState(info)).toBe("healthy");
711
+ expect(p.tuiRows.get(id)?.status?.action).toBe("current");
712
+ expect(p.tuiRows.get(id)?.status?.reason).toBe("no-published-version");
713
+ }
714
+
715
+ // Where they still disagree outright: an orphan carries neither
716
+ // `hasUpdate` nor `updateCheckFailed`, so the flags call it healthy while
717
+ // the classifier says no marketplace offers it at all.
718
+ for (const id of ["seo@magus", "legacy-tool@magus"]) {
719
+ const info = p.catalog.find((c) => c.id === id) as PluginInfo;
720
+ expect(legacyFlagState(info)).toBe("healthy");
721
+ expect(p.tuiRows.get(id)?.status?.action).toBe("unknown");
722
+ expect(p.tuiRows.get(id)?.status?.reason).toBe("not-offered");
723
+ }
724
+ });
725
+ });