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,98 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ MAX_RELEASES,
4
+ normalizeReleases,
5
+ } from "../services/plugin-releases.js";
6
+ import type { PluginRelease } from "../types/index.js";
7
+
8
+ /** Narrow to a populated result, failing the test rather than the assertion's type. */
9
+ function expectEntries(input: unknown): PluginRelease[] {
10
+ const out = normalizeReleases(input);
11
+ if (!out) throw new Error("expected releases, got undefined");
12
+ return out;
13
+ }
14
+
15
+ describe("normalizeReleases", () => {
16
+ test("passes through a well-formed entry", () => {
17
+ expect(
18
+ normalizeReleases([
19
+ {
20
+ version: "3.3.0",
21
+ date: "2026-07-29",
22
+ kinds: ["Changed", "Fixed"],
23
+ summary: "Model resolution is now live",
24
+ },
25
+ ]),
26
+ ).toEqual([
27
+ {
28
+ version: "3.3.0",
29
+ date: "2026-07-29",
30
+ kinds: ["Changed", "Fixed"],
31
+ summary: "Model resolution is now live",
32
+ },
33
+ ]);
34
+ });
35
+
36
+ test("omits optional fields rather than emitting undefined", () => {
37
+ const [entry] = expectEntries([
38
+ { version: "1.0.0", summary: "First release" },
39
+ ]);
40
+ expect(entry).toEqual({ version: "1.0.0", summary: "First release" });
41
+ expect("date" in entry).toBe(false);
42
+ expect("kinds" in entry).toBe(false);
43
+ });
44
+
45
+ test("returns undefined for anything that is not an array", () => {
46
+ for (const input of [undefined, null, "3.3.0", 7, {}, true]) {
47
+ expect(normalizeReleases(input)).toBeUndefined();
48
+ }
49
+ });
50
+
51
+ // The manifest is fetched over the network, so no field's type can be assumed.
52
+ test("drops entries missing or mistyping the required fields", () => {
53
+ expect(
54
+ normalizeReleases([
55
+ { version: "1.0.0", summary: "kept" },
56
+ { version: "1.0.1" }, // no summary
57
+ { summary: "no version" },
58
+ { version: 2, summary: "version is a number" },
59
+ { version: "1.0.2", summary: 42 },
60
+ { version: " ", summary: "blank version" },
61
+ { version: "1.0.3", summary: " " },
62
+ null,
63
+ "not an object",
64
+ ]),
65
+ ).toEqual([{ version: "1.0.0", summary: "kept" }]);
66
+ });
67
+
68
+ test("returns undefined when every entry is unusable", () => {
69
+ expect(normalizeReleases([{ nope: true }, null])).toBeUndefined();
70
+ expect(normalizeReleases([])).toBeUndefined();
71
+ });
72
+
73
+ test("caps the list so one manifest cannot flood the panel", () => {
74
+ const many = Array.from({ length: 500 }, (_, i) => ({
75
+ version: `1.0.${i}`,
76
+ summary: `entry ${i}`,
77
+ }));
78
+ const out = expectEntries(many);
79
+ expect(out).toHaveLength(MAX_RELEASES);
80
+ // Newest-first ordering is preserved — the cap takes the head, not the tail.
81
+ expect(out[0].version).toBe("1.0.0");
82
+ });
83
+
84
+ test("keeps only string members of kinds", () => {
85
+ const [entry] = expectEntries([
86
+ { version: "1.0.0", summary: "x", kinds: ["Added", 3, null, "Fixed"] },
87
+ ]);
88
+ expect(entry.kinds).toEqual(["Added", "Fixed"]);
89
+ });
90
+
91
+ test("trims surrounding whitespace", () => {
92
+ expect(
93
+ normalizeReleases([
94
+ { version: " 1.0.0 ", date: " 2026-07-29 ", summary: " padded " },
95
+ ]),
96
+ ).toEqual([{ version: "1.0.0", date: "2026-07-29", summary: "padded" }]);
97
+ });
98
+ });
@@ -0,0 +1,187 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import fs from "fs-extra";
6
+ import {
7
+ parsePluginRequires,
8
+ readPluginRequires,
9
+ } from "../services/plugin-requires.js";
10
+
11
+ describe("parsePluginRequires", () => {
12
+ test("parses a valid requires.bin and fills defaults", () => {
13
+ const requires = parsePluginRequires({
14
+ name: "terminal",
15
+ requires: {
16
+ bin: [
17
+ { name: "tmux", via: "brew", formula: "tmux", check: "tmux -V" },
18
+ { name: "tmux-mcp", via: "bun", package: "tmux-mcp" },
19
+ ],
20
+ },
21
+ });
22
+ expect(requires.bin).toHaveLength(2);
23
+ const tmux = requires.bin!.find((b) => b.name === "tmux")!;
24
+ expect(tmux.via).toBe("brew");
25
+ expect(tmux.check).toBe("tmux -V");
26
+ const mcp = requires.bin!.find((b) => b.name === "tmux-mcp")!;
27
+ // check defaulted from the name
28
+ expect(mcp.check).toBe("tmux-mcp --version");
29
+ });
30
+
31
+ test("drops entries with no name or an unknown installer", () => {
32
+ const requires = parsePluginRequires({
33
+ requires: {
34
+ bin: [
35
+ { via: "bun" }, // no name
36
+ { name: "x", via: "cargo" }, // unknown installer
37
+ { name: "ok", via: "npm" },
38
+ ],
39
+ },
40
+ });
41
+ expect(requires.bin).toHaveLength(1);
42
+ expect(requires.bin![0]!.name).toBe("ok");
43
+ });
44
+
45
+ test("no requires / non-array bin → empty object", () => {
46
+ expect(parsePluginRequires({ name: "x" })).toEqual({});
47
+ expect(parsePluginRequires({ requires: {} })).toEqual({});
48
+ expect(parsePluginRequires({ requires: { bin: "nope" } })).toEqual({});
49
+ expect(parsePluginRequires(null)).toEqual({});
50
+ });
51
+ });
52
+
53
+ describe("parsePluginRequires — setup block (the established convention)", () => {
54
+ test("terminal's setup: go module + required system tool", () => {
55
+ // The real plugins/terminal/plugin.json shape.
56
+ const r = parsePluginRequires({
57
+ name: "terminal",
58
+ setup: {
59
+ go: ["github.com/MadAppGang/tmux-mcp@latest"],
60
+ required: ["tmux"],
61
+ },
62
+ });
63
+ const mcp = r.bin!.find((b) => b.name === "tmux-mcp")!;
64
+ expect(mcp.via).toBe("go");
65
+ expect(mcp.module).toBe("github.com/MadAppGang/tmux-mcp");
66
+ expect(mcp.version).toBe("latest");
67
+ const tmux = r.bin!.find((b) => b.name === "tmux")!;
68
+ expect(tmux.via).toBe("brew");
69
+ expect(tmux.formula).toBe("tmux");
70
+ });
71
+
72
+ // Negative control for the /vN fix: basename(base) yields "v2" here, so
73
+ // without the go-module helper this test FAILS on `name`.
74
+ test("terminal's v2 pin: /v2 is the module's major version, not the binary", () => {
75
+ const r = parsePluginRequires({
76
+ name: "terminal",
77
+ setup: { go: ["github.com/MadAppGang/tmux-mcp/v2@v2.0.0"] },
78
+ });
79
+ expect(r.bin).toEqual([
80
+ {
81
+ name: "tmux-mcp",
82
+ via: "go",
83
+ module: "github.com/MadAppGang/tmux-mcp/v2",
84
+ version: "v2.0.0",
85
+ check: "tmux-mcp --version",
86
+ },
87
+ ]);
88
+ });
89
+
90
+ // One manifest per spec: entries are keyed by binary name, so a v1 and a v2
91
+ // pin of the same module in ONE setup would (correctly) collapse to one.
92
+ test.each([
93
+ [
94
+ "github.com/MadAppGang/tmux-mcp@v1.7.1",
95
+ {
96
+ name: "tmux-mcp",
97
+ module: "github.com/MadAppGang/tmux-mcp",
98
+ version: "v1.7.1",
99
+ },
100
+ ],
101
+ [
102
+ "github.com/MadAppGang/tmux-mcp/v2",
103
+ {
104
+ name: "tmux-mcp",
105
+ module: "github.com/MadAppGang/tmux-mcp/v2",
106
+ version: undefined,
107
+ },
108
+ ],
109
+ [
110
+ "github.com/x/v2tool@v1.0.0",
111
+ { name: "v2tool", module: "github.com/x/v2tool", version: "v1.0.0" },
112
+ ],
113
+ ])("go spec %s → name/module/version", (spec, expected) => {
114
+ const r = parsePluginRequires({ setup: { go: [spec] } });
115
+ expect(
116
+ r.bin!.map((b) => ({
117
+ name: b.name,
118
+ module: b.module,
119
+ version: b.version,
120
+ })),
121
+ ).toEqual([expected]);
122
+ });
123
+
124
+ test("browser-use's setup: pip packages", () => {
125
+ const r = parsePluginRequires({
126
+ name: "browser-use",
127
+ setup: { pip: ["browser-use", "mcp"] },
128
+ });
129
+ expect(r.bin!.map((b) => b.name).sort()).toEqual(["browser-use", "mcp"]);
130
+ expect(r.bin!.every((b) => b.via === "pip")).toBe(true);
131
+ });
132
+
133
+ test("bun/npm specs split name@version", () => {
134
+ const r = parsePluginRequires({
135
+ setup: { bun: ["claudish@1.2.0"], npm: ["@scope/tool@3.0.0"] },
136
+ });
137
+ const claudish = r.bin!.find((b) => b.name === "claudish")!;
138
+ expect(claudish.version).toBe("1.2.0");
139
+ const scoped = r.bin!.find((b) => b.name === "tool")!;
140
+ expect(scoped.package).toBe("@scope/tool");
141
+ expect(scoped.version).toBe("3.0.0");
142
+ });
143
+
144
+ test("explicit requires.bin overrides a setup entry of the same name", () => {
145
+ const r = parsePluginRequires({
146
+ setup: { required: ["tmux"] }, // → brew
147
+ requires: {
148
+ bin: [{ name: "tmux", via: "go", module: "example.com/tmux" }],
149
+ },
150
+ });
151
+ const tmux = r.bin!.find((b) => b.name === "tmux")!;
152
+ expect(tmux.via).toBe("go"); // requires.bin won
153
+ });
154
+ });
155
+
156
+ describe("readPluginRequires", () => {
157
+ let dir: string;
158
+ beforeEach(async () => {
159
+ dir = await mkdtemp(join(tmpdir(), "plugreq-"));
160
+ });
161
+ afterEach(async () => {
162
+ await rm(dir, { recursive: true, force: true });
163
+ });
164
+
165
+ test("reads plugin.json at the plugin root", async () => {
166
+ await fs.writeJson(join(dir, "plugin.json"), {
167
+ name: "p",
168
+ requires: { bin: [{ name: "tmux", via: "brew" }] },
169
+ });
170
+ const r = await readPluginRequires(dir);
171
+ expect(r.bin?.[0]?.name).toBe("tmux");
172
+ });
173
+
174
+ test("falls back to .claude-plugin/plugin.json", async () => {
175
+ await fs.ensureDir(join(dir, ".claude-plugin"));
176
+ await fs.writeJson(join(dir, ".claude-plugin", "plugin.json"), {
177
+ name: "p",
178
+ requires: { bin: [{ name: "mnemex", via: "bun" }] },
179
+ });
180
+ const r = await readPluginRequires(dir);
181
+ expect(r.bin?.[0]?.name).toBe("mnemex");
182
+ });
183
+
184
+ test("missing manifest → empty", async () => {
185
+ expect(await readPluginRequires(dir)).toEqual({});
186
+ });
187
+ });
@@ -0,0 +1,345 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { PluginInfo } from "../services/plugin-manager.js";
3
+ import {
4
+ type PluginUpdateItem,
5
+ classifyPluginRows,
6
+ planPluginUpdates,
7
+ } from "../services/update-plan.js";
8
+ import type { Marketplace } from "../types/index.js";
9
+ import {
10
+ type PluginPluginItem,
11
+ buildPluginBrowserItems,
12
+ } from "../ui/adapters/pluginsAdapter.js";
13
+ import { pluginRowText } from "../ui/renderers/pluginRowText.js";
14
+ import { pluginStatusText } from "../ui/renderers/pluginStatusText.js";
15
+
16
+ /**
17
+ * `pluginRowText` is exactly what a Plugins row prints after the name, in
18
+ * every branch of `pluginRow` — selected, unselected, orphan. So asserting on
19
+ * it asserts on the screen.
20
+ *
21
+ * Rows here are built the way the screen builds them: `classifyPluginRows` over
22
+ * the plugin list and the profile's pins, then `buildPluginBrowserItems`. The
23
+ * note each row must contain is taken from `planPluginUpdates` — the CLI's own
24
+ * answer — never retyped, so the two front ends are compared, not two copies.
25
+ */
26
+
27
+ const OFFICIAL: Marketplace = {
28
+ name: "claude-plugins-official",
29
+ displayName: "Anthropic Official",
30
+ source: { source: "github", repo: "anthropics/claude-plugins-official" },
31
+ description: "",
32
+ } as Marketplace;
33
+
34
+ const MAGUS: Marketplace = {
35
+ name: "magus",
36
+ displayName: "Magus",
37
+ source: { source: "github", repo: "MadAppGang/magus" },
38
+ description: "",
39
+ owned: true,
40
+ } as Marketplace;
41
+
42
+ function info(over: Partial<PluginInfo> & { id: string }): PluginInfo {
43
+ const [name, marketplace] = over.id.split("@");
44
+ return {
45
+ name,
46
+ version: null,
47
+ description: "",
48
+ marketplace,
49
+ marketplaceDisplay: "",
50
+ enabled: true,
51
+ author: { name: "Anthropic" },
52
+ ...over,
53
+ } as PluginInfo;
54
+ }
55
+
56
+ /** Real: `code-review@claude-plugins-official` in /Users/jack/mag/claudish. */
57
+ const CODE_REVIEW = info({
58
+ id: "code-review@claude-plugins-official",
59
+ version: null, // the official catalog publishes no version for it
60
+ installedVersion: "3ea32df27be7",
61
+ projectScope: { enabled: true, version: "3ea32df27be7" },
62
+ });
63
+
64
+ /** A registry "0.0.0" — the value the old row hid every status behind. */
65
+ const ZERO = info({
66
+ id: "zero-version@claude-plugins-official",
67
+ version: null,
68
+ installedVersion: "0.0.0",
69
+ projectScope: { enabled: true, version: "0.0.0" },
70
+ });
71
+
72
+ /** Other listed ids, so each marketplace demonstrably was read. */
73
+ const LISTED = [
74
+ info({ id: "dev@magus", version: "4.7.0", author: { name: "MadAppGang" } }),
75
+ info({ id: "github@claude-plugins-official", version: null }),
76
+ ];
77
+
78
+ function rows(
79
+ plugins: PluginInfo[],
80
+ pins: Record<string, string> = {},
81
+ ): Map<string, PluginPluginItem> {
82
+ const all = [...plugins, ...LISTED];
83
+ const items = buildPluginBrowserItems({
84
+ marketplaces: [MAGUS, OFFICIAL],
85
+ plugins: all,
86
+ collapsedMarketplaces: new Set(),
87
+ statuses: classifyPluginRows(all, pins),
88
+ });
89
+ return new Map(
90
+ items
91
+ .filter((i): i is PluginPluginItem => i.kind === "plugin")
92
+ .map((i) => [i.plugin.id, i]),
93
+ );
94
+ }
95
+
96
+ /** The note `magus update` prints for this plugin under this pin. */
97
+ function cliNote(
98
+ plugins: PluginInfo[],
99
+ id: string,
100
+ pin = "latest",
101
+ ): string | undefined {
102
+ const all = [...plugins, ...LISTED];
103
+ const [item] = planPluginUpdates(
104
+ { [id]: pin },
105
+ new Map(all.map((p) => [p.id, p])),
106
+ );
107
+ return item?.note;
108
+ }
109
+
110
+ describe("pluginRowText — a status never depends on a displayable version", () => {
111
+ // A sha install against a catalog that publishes no version is `current`
112
+ // now — we assume what is installed is the latest rather than warning about
113
+ // a comparison that can never be made. R2 still holds and is still the point
114
+ // of this file: whatever the CLI says, the row says, word for word.
115
+ test("a sha-versioned install says exactly what the CLI says", () => {
116
+ const row = rows([CODE_REVIEW]).get(CODE_REVIEW.id) as PluginPluginItem;
117
+ expect(row.status?.action).toBe("current");
118
+ // No note means no suffix. The row is the version and nothing else.
119
+ expect(cliNote([CODE_REVIEW], CODE_REVIEW.id)).toBeUndefined();
120
+ const { text } = pluginRowText(row);
121
+ expect(text).toBe(" v3ea32df27be7");
122
+ // The row never invents a vocabulary the CLI does not use.
123
+ expect(text).not.toContain("not comparable");
124
+ expect(text).not.toContain("?");
125
+ });
126
+
127
+ test('a "0.0.0" install is never printed as a version', () => {
128
+ // "0.0.0" means installed with an unknown version — Anthropic's official
129
+ // plugins record exactly that. It is not a version anyone can act on, so
130
+ // it is never shown, and no status clause may be gated on showing it.
131
+ const row = rows([ZERO]).get(ZERO.id) as PluginPluginItem;
132
+ expect(row.status?.action).toBe("current");
133
+ const { text } = pluginRowText(row);
134
+ expect(text).not.toContain("0.0.0");
135
+ expect(text).toBe(cliNote([ZERO], ZERO.id) ?? "");
136
+ });
137
+ });
138
+
139
+ describe("pluginRowText — orphans say what the CLI says (F9)", () => {
140
+ // The row NEVER guesses where an orphan went. That guess was a bare name
141
+ // match, and it cannot tell a move from a dual publication: claudish ships
142
+ // to two marketplaces with both copies installed, and the row announced
143
+ // "moved → magus" about a plugin that had not moved anywhere.
144
+ test("an orphan is 'not offered', and never names a destination", () => {
145
+ const seo = info({
146
+ id: "seo@magus",
147
+ version: "1.8.0",
148
+ installedVersion: "1.8.0",
149
+ isOrphaned: true,
150
+ projectScope: { enabled: true, version: "1.8.0" },
151
+ });
152
+ const row = rows([seo]).get(seo.id) as PluginPluginItem;
153
+ expect(cliNote([seo], seo.id)).toBe(
154
+ "not offered by any configured marketplace",
155
+ );
156
+ expect(pluginRowText(row).text).toBe(
157
+ " v1.8.0 ? not offered by any configured marketplace",
158
+ );
159
+ expect(pluginRowText(row).text).not.toMatch(/moved/i);
160
+ });
161
+
162
+ test("an orphan from a marketplace we DID read is 'not offered'", () => {
163
+ const gone = info({
164
+ id: "gone@magus",
165
+ version: "2.0.0",
166
+ installedVersion: "2.0.0",
167
+ isOrphaned: true,
168
+ projectScope: { enabled: true, version: "2.0.0" },
169
+ });
170
+ const row = rows([gone]).get(gone.id) as PluginPluginItem;
171
+ expect(pluginRowText(row).text).toBe(
172
+ ` v2.0.0 ? ${cliNote([gone], gone.id)}`,
173
+ );
174
+ expect(pluginRowText(row).text).toContain(
175
+ "not offered by any configured marketplace",
176
+ );
177
+ });
178
+
179
+ test("the old words are gone: no 'deprecated', no 'moved →'", () => {
180
+ const seo = info({
181
+ id: "seo@magus",
182
+ installedVersion: "1.8.0",
183
+ isOrphaned: true,
184
+ projectScope: { enabled: true, version: "1.8.0" },
185
+ });
186
+ const { text } = pluginRowText(rows([seo]).get(seo.id) as PluginPluginItem);
187
+ expect(text).not.toContain("deprecated");
188
+ expect(text).not.toContain("moved →");
189
+ });
190
+ });
191
+
192
+ describe("pluginRowText — history badges combine with the state", () => {
193
+ const updated = (over: Partial<PluginInfo>) =>
194
+ info({
195
+ id: "terminal@magus",
196
+ version: "3.3.1",
197
+ installedVersion: "3.0.1",
198
+ projectScope: { enabled: true, version: "3.0.1" },
199
+ author: { name: "MadAppGang" },
200
+ ...over,
201
+ });
202
+
203
+ test("'updated' and a pending update are both shown", () => {
204
+ // An external update moved 2.9.0 → 3.0.1 while 3.3.1 is already out.
205
+ const p = updated({ recentlyUpdatedFrom: "2.9.0" });
206
+ const row = rows([p]).get(p.id) as PluginPluginItem;
207
+ const { text, segments } = pluginRowText(row);
208
+ expect(text).toBe(" v2.9.0 → v3.0.1 updated → v3.3.1 available");
209
+ expect(segments.map((s) => s.tone)).toEqual(["success", "warning"]);
210
+ });
211
+
212
+ test("'new' and a pending update are both shown", () => {
213
+ const p = updated({ recentlyInstalled: true });
214
+ const row = rows([p]).get(p.id) as PluginPluginItem;
215
+ expect(pluginRowText(row).text).toBe(" v3.0.1 new → v3.3.1 available");
216
+ });
217
+
218
+ test("'new' with no displayable version still says new", () => {
219
+ const p = updated({
220
+ recentlyInstalled: true,
221
+ version: "0.0.0",
222
+ installedVersion: "0.0.0",
223
+ projectScope: { enabled: true, version: "0.0.0" },
224
+ });
225
+ const row = rows([p]).get(p.id) as PluginPluginItem;
226
+ expect(pluginRowText(row).text).toBe(" new");
227
+ });
228
+
229
+ test("a current row shows only its version", () => {
230
+ const p = updated({
231
+ version: "3.0.1",
232
+ });
233
+ const row = rows([p]).get(p.id) as PluginPluginItem;
234
+ expect(row.status?.action).toBe("current");
235
+ expect(pluginRowText(row).text).toBe(" v3.0.1");
236
+ });
237
+ });
238
+
239
+ describe("pluginRowText — rows with no state claim none", () => {
240
+ test("an undeclared, uninstalled catalog entry has no status and no text", () => {
241
+ const browse = info({
242
+ id: "kanban@magus",
243
+ version: "1.6.2",
244
+ author: { name: "MadAppGang" },
245
+ });
246
+ const row = rows([browse]).get(browse.id) as PluginPluginItem;
247
+ expect(row.status).toBeNull();
248
+ expect(pluginRowText(row).text).toBe("");
249
+ });
250
+
251
+ test("the same entry DECLARED by the profile says the profile wants it", () => {
252
+ const declared = info({
253
+ id: "kanban@magus",
254
+ version: "1.6.2",
255
+ author: { name: "MadAppGang" },
256
+ });
257
+ const row = rows([declared], { "kanban@magus": "latest" }).get(
258
+ declared.id,
259
+ ) as PluginPluginItem;
260
+ expect(pluginRowText(row).text).toBe(
261
+ " not installed — profile declares it",
262
+ );
263
+ });
264
+ });
265
+
266
+ describe("pluginStatusText — absent is not enabled-not-installed (LOW 6)", () => {
267
+ const status = (over: Partial<PluginUpdateItem>): PluginUpdateItem => ({
268
+ pluginId: "x@magus",
269
+ pinned: "latest",
270
+ installed: null,
271
+ available: "1.0.0",
272
+ target: "1.0.0",
273
+ action: "install",
274
+ reason: "absent",
275
+ scopes: [],
276
+ beforeVersions: {},
277
+ ...over,
278
+ });
279
+
280
+ test("absent — only ever from a profile pin — may say the profile declares it", () => {
281
+ expect(pluginStatusText(status({}))).toEqual({
282
+ suffix: " not installed — profile declares it",
283
+ tone: "warning",
284
+ });
285
+ });
286
+
287
+ test("enabled-not-installed shows its note and never claims a declaration", () => {
288
+ const got = pluginStatusText(
289
+ status({
290
+ reason: "enabled-not-installed",
291
+ note: "enabled in settings, but no files on disk",
292
+ }),
293
+ );
294
+ expect(got).toEqual({
295
+ suffix: " not installed (enabled in settings, but no files on disk)",
296
+ tone: "danger",
297
+ });
298
+ expect(got.suffix).not.toContain("profile");
299
+ });
300
+
301
+ test("an undeclared enabled-but-not-installed ROW gets the enabled text", () => {
302
+ const broken = info({
303
+ id: "kanban@magus",
304
+ version: "1.6.2",
305
+ author: { name: "MadAppGang" },
306
+ localScope: { enabled: true },
307
+ });
308
+ const row = rows([broken]).get(broken.id) as PluginPluginItem;
309
+ expect(row.status?.reason).toBe("enabled-not-installed");
310
+ expect(pluginRowText(row).text).toBe(
311
+ " not installed (enabled in settings, but no files on disk)",
312
+ );
313
+ });
314
+
315
+ test("null status says nothing", () => {
316
+ expect(pluginStatusText(null)).toEqual({ suffix: "", tone: "muted" });
317
+ });
318
+
319
+ test("update with a pin-undeliverable note carries it", () => {
320
+ expect(
321
+ pluginStatusText(
322
+ status({
323
+ action: "update",
324
+ reason: "pin-undeliverable",
325
+ target: "9.9.9",
326
+ note: "manifest pins 4.6.1; the marketplace offers only 9.9.9",
327
+ }),
328
+ ).suffix,
329
+ ).toBe(
330
+ " → v9.9.9 available (manifest pins 4.6.1; the marketplace offers only 9.9.9)",
331
+ );
332
+ });
333
+
334
+ test("repair carries its note", () => {
335
+ expect(
336
+ pluginStatusText(
337
+ status({
338
+ action: "repair",
339
+ reason: "content-stale",
340
+ note: "files changed without a version bump",
341
+ }),
342
+ ).suffix,
343
+ ).toBe(" stale — reinstall (files changed without a version bump)");
344
+ });
345
+ });