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,320 @@
1
+ import {
2
+ afterAll,
3
+ beforeAll,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ it,
8
+ } from "bun:test";
9
+ import fs from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { resetGitHubBudget } from "../services/github-budget";
13
+ import type {
14
+ LocalMarketplace,
15
+ LocalMarketplacePlugin,
16
+ } from "../services/local-marketplace";
17
+ import {
18
+ clearMarketplaceCache,
19
+ fetchMarketplacePlugins,
20
+ getMarketplaceFetchFailures,
21
+ resolveMarketplacePlugins,
22
+ } from "../services/marketplace-fetcher";
23
+
24
+ /**
25
+ * Point the marketplace-clone lookup at an EMPTY temp directory.
26
+ *
27
+ * `resolveMarketplacePlugins` now consults a git remote ref before falling back
28
+ * to the checked-out clone. Without this, these tests found the operator's real
29
+ * `magus` clone, performed a live `git fetch`, and asserted against whatever
30
+ * happened to be published — 17 real plugins instead of the 2 in the fixture, and
31
+ * three seconds per test.
32
+ */
33
+ let prevConfigDir: string | undefined;
34
+ let emptyConfigDir: string;
35
+
36
+ beforeAll(() => {
37
+ emptyConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "mp-fallback-"));
38
+ prevConfigDir = process.env.CLAUDE_CONFIG_DIR;
39
+ process.env.CLAUDE_CONFIG_DIR = emptyConfigDir;
40
+ });
41
+
42
+ afterAll(() => {
43
+ // biome-ignore lint/performance/noDelete: assigning undefined to process.env stores the string "undefined"; only delete unsets the variable
44
+ if (prevConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
45
+ else process.env.CLAUDE_CONFIG_DIR = prevConfigDir;
46
+ fs.rmSync(emptyConfigDir, { recursive: true, force: true });
47
+ });
48
+
49
+ /**
50
+ * Regression coverage for the "deprecated everything" bug.
51
+ *
52
+ * When `fetchMarketplacePlugins` returns an empty list (network down,
53
+ * repo moved, GitHub blocked, etc.), the orphan-detection loop in
54
+ * `getAvailablePlugins` would flag every installed plugin as
55
+ * `isOrphaned: true` and the UI rendered them all as "deprecated".
56
+ *
57
+ * `resolveMarketplacePlugins` adds the local-marketplace cache as a
58
+ * fallback so installed plugins keep rendering with their real metadata
59
+ * even when the machine is offline.
60
+ */
61
+
62
+ function buildLocalMarketplace(
63
+ name: string,
64
+ plugins: LocalMarketplacePlugin[],
65
+ ): LocalMarketplace {
66
+ return { name, description: "", plugins };
67
+ }
68
+
69
+ describe("resolveMarketplacePlugins — offline fallback", () => {
70
+ beforeEach(() => {
71
+ // Session-level cache could otherwise carry over a previous test's
72
+ // successful fetch and short-circuit the fallback path.
73
+ clearMarketplaceCache();
74
+ // The per-host cooldown is deliberately NOT cleared by clearMarketplaceCache
75
+ // — it reflects GitHub's state, not ours — so a 429 in one test would
76
+ // otherwise gate every fetch in the next for the full backoff.
77
+ resetGitHubBudget();
78
+ });
79
+
80
+ it("falls back to the local cache when the remote fetch returns empty", async () => {
81
+ const local = new Map<string, LocalMarketplace>([
82
+ [
83
+ "magus",
84
+ buildLocalMarketplace("magus", [
85
+ {
86
+ name: "code-search",
87
+ version: "5.3.0",
88
+ description: "Codebase investigation tools",
89
+ category: "development",
90
+ author: { name: "Jack Rudenko" },
91
+ },
92
+ {
93
+ name: "dev",
94
+ version: "2.7.0",
95
+ description: "Dev workflow",
96
+ },
97
+ ]),
98
+ ],
99
+ ]);
100
+
101
+ // An invalid repo string short-circuits `fetchMarketplacePlugins` to
102
+ // `[]` without any network call, simulating the offline case.
103
+ const resolved = await resolveMarketplacePlugins(
104
+ "magus",
105
+ "not a valid repo string",
106
+ local,
107
+ );
108
+
109
+ expect(resolved.plugins).toHaveLength(2);
110
+ expect(resolved.plugins.map((p) => p.name).sort()).toEqual([
111
+ "code-search",
112
+ "dev",
113
+ ]);
114
+ const codeAnalysis = resolved.plugins.find((p) => p.name === "code-search");
115
+ expect(codeAnalysis?.version).toBe("5.3.0");
116
+ expect(codeAnalysis?.description).toBe("Codebase investigation tools");
117
+ expect(codeAnalysis?.category).toBe("development");
118
+ expect(codeAnalysis?.author?.name).toBe("Jack Rudenko");
119
+ });
120
+
121
+ it("labels the fallback answer as local-clone, not remote", async () => {
122
+ // The regression this guards: the fallback plugins were returned in the
123
+ // same shape as a successful remote fetch, so the caller compared installed
124
+ // versions against a clone nothing refreshes and reported "up to date".
125
+ // The source must travel with the data.
126
+ const local = new Map<string, LocalMarketplace>([
127
+ [
128
+ "magus",
129
+ buildLocalMarketplace("magus", [
130
+ { name: "terminal", version: "4.1.4", description: "" },
131
+ ]),
132
+ ],
133
+ ]);
134
+ const resolved = await resolveMarketplacePlugins(
135
+ "magus",
136
+ "not a valid repo string",
137
+ local,
138
+ );
139
+
140
+ expect(resolved.source).toBe("local-clone");
141
+ expect(resolved.failure).toBeDefined();
142
+ expect(resolved.failure?.kind).toBe("invalid-repo");
143
+ expect(resolved.failure?.marketplace).toBe("magus");
144
+ });
145
+
146
+ it("returns empty when both remote and cache are empty", async () => {
147
+ const local = new Map<string, LocalMarketplace>();
148
+ const resolved = await resolveMarketplacePlugins(
149
+ "magus",
150
+ "not a valid repo string",
151
+ local,
152
+ );
153
+ expect(resolved.plugins).toEqual([]);
154
+ expect(resolved.source).toBe("none");
155
+ expect(resolved.failure).toBeDefined();
156
+ });
157
+
158
+ it("returns empty when remote fails AND cache exists but has no plugins", async () => {
159
+ const local = new Map<string, LocalMarketplace>([
160
+ ["magus", buildLocalMarketplace("magus", [])],
161
+ ]);
162
+ const resolved = await resolveMarketplacePlugins(
163
+ "magus",
164
+ "not a valid repo string",
165
+ local,
166
+ );
167
+ expect(resolved.plugins).toEqual([]);
168
+ expect(resolved.source).toBe("none");
169
+ });
170
+
171
+ it("does not consult the cache when the remote returns plugins", async () => {
172
+ // We can't easily exercise the success branch without a live network,
173
+ // but we can verify that a cache entry for a DIFFERENT marketplace
174
+ // doesn't bleed into the requested one.
175
+ const local = new Map<string, LocalMarketplace>([
176
+ [
177
+ "other-marketplace",
178
+ buildLocalMarketplace("other-marketplace", [
179
+ { name: "should-not-leak", version: "0.0.1", description: "" },
180
+ ]),
181
+ ],
182
+ ]);
183
+ const resolved = await resolveMarketplacePlugins(
184
+ "magus",
185
+ "not a valid repo string",
186
+ local,
187
+ );
188
+ expect(resolved.plugins).toEqual([]);
189
+ expect(resolved.source).toBe("none");
190
+ });
191
+ });
192
+
193
+ /**
194
+ * The defect class these cover: a failed check that renders as a passed one.
195
+ *
196
+ * `fetchMarketplacePlugins` used to return `[]` for a rate limit, a timeout, a
197
+ * bad repo string and a genuinely empty marketplace alike. Every caller then had
198
+ * to guess, and all of them guessed "fine".
199
+ */
200
+ describe("fetchMarketplacePlugins — failures are values", () => {
201
+ beforeEach(() => {
202
+ clearMarketplaceCache();
203
+ resetGitHubBudget();
204
+ });
205
+
206
+ it("classifies a rate limit distinctly from a network problem", async () => {
207
+ const original = globalThis.fetch;
208
+ globalThis.fetch = (async () =>
209
+ new Response("rate limited", {
210
+ status: 429,
211
+ statusText: "Too Many Requests",
212
+ })) as typeof globalThis.fetch;
213
+ try {
214
+ const result = await fetchMarketplacePlugins("magus", "MadAppGang/magus");
215
+ expect(result.plugins).toEqual([]);
216
+ expect(result.failure?.kind).toBe("rate-limited");
217
+ expect(result.failure?.httpStatus).toBe(429);
218
+ expect(result.failure?.detail).toContain("rate limit");
219
+ } finally {
220
+ globalThis.fetch = original;
221
+ }
222
+ });
223
+
224
+ it("records the failure so the UI can report it without re-fetching", async () => {
225
+ const original = globalThis.fetch;
226
+ let calls = 0;
227
+ globalThis.fetch = (async () => {
228
+ calls++;
229
+ return new Response("nope", { status: 500, statusText: "Server Error" });
230
+ }) as typeof globalThis.fetch;
231
+ try {
232
+ await fetchMarketplacePlugins("magus", "MadAppGang/magus");
233
+
234
+ const failures = getMarketplaceFetchFailures();
235
+ expect(failures.map((f) => f.marketplace)).toContain("magus");
236
+ expect(failures.find((f) => f.marketplace === "magus")?.kind).toBe(
237
+ "http",
238
+ );
239
+
240
+ // Second call must be served from the negative cache. Re-attempting on
241
+ // every screen mount is what cost 30-45s of "Loading..." per tab switch.
242
+ await fetchMarketplacePlugins("magus", "MadAppGang/magus");
243
+ expect(calls).toBe(1);
244
+ } finally {
245
+ globalThis.fetch = original;
246
+ }
247
+ });
248
+
249
+ it("reports no failures when the fetch succeeds", async () => {
250
+ const original = globalThis.fetch;
251
+ globalThis.fetch = (async () =>
252
+ Response.json({
253
+ metadata: { version: "9.0.3" },
254
+ plugins: [{ name: "terminal", version: "4.1.6" }],
255
+ })) as typeof globalThis.fetch;
256
+ try {
257
+ const result = await fetchMarketplacePlugins("magus", "MadAppGang/magus");
258
+ expect(result.failure).toBeUndefined();
259
+ expect(result.plugins).toHaveLength(1);
260
+ expect(getMarketplaceFetchFailures()).toEqual([]);
261
+ } finally {
262
+ globalThis.fetch = original;
263
+ }
264
+ });
265
+
266
+ it("does not send a request while the rate-limit window is still open", async () => {
267
+ // Deliberate: an explicit refresh clears OUR caches but not GitHub's limit.
268
+ // Retrying inside a known window cannot succeed, and on some endpoints a
269
+ // rejected request extends the penalty — so the screen waits and counts down
270
+ // instead. `clearMarketplaceCache()` must not be a way around that.
271
+ const original = globalThis.fetch;
272
+ let calls = 0;
273
+ globalThis.fetch = (async () => {
274
+ calls++;
275
+ return new Response("nope", { status: 429 });
276
+ }) as typeof globalThis.fetch;
277
+ try {
278
+ await fetchMarketplacePlugins("magus", "MadAppGang/magus");
279
+ expect(calls).toBe(1);
280
+
281
+ clearMarketplaceCache();
282
+ const second = await fetchMarketplacePlugins("magus", "MadAppGang/magus");
283
+
284
+ expect(calls).toBe(1); // no second request went out
285
+ expect(second.failure?.kind).toBe("rate-limited");
286
+ expect(second.failure?.retryAt).toBeGreaterThan(Date.now());
287
+ } finally {
288
+ globalThis.fetch = original;
289
+ }
290
+ });
291
+
292
+ it("clears the recorded failure once the window passes and the fetch succeeds", async () => {
293
+ const original = globalThis.fetch;
294
+ globalThis.fetch = (async () =>
295
+ new Response("nope", { status: 429 })) as typeof globalThis.fetch;
296
+ try {
297
+ await fetchMarketplacePlugins("magus", "MadAppGang/magus");
298
+ expect(getMarketplaceFetchFailures()).toHaveLength(1);
299
+
300
+ // The cooldown expiring is what unblocks the retry — this stands in for the
301
+ // clock reaching `retryAt`, which is when the screen refetches by itself.
302
+ resetGitHubBudget();
303
+ clearMarketplaceCache();
304
+ globalThis.fetch = (async () =>
305
+ Response.json({
306
+ plugins: [{ name: "terminal", version: "4.2.0" }],
307
+ })) as typeof globalThis.fetch;
308
+
309
+ const recovered = await fetchMarketplacePlugins(
310
+ "magus",
311
+ "MadAppGang/magus",
312
+ );
313
+ expect(recovered.failure).toBeUndefined();
314
+ expect(recovered.plugins).toHaveLength(1);
315
+ expect(getMarketplaceFetchFailures()).toEqual([]);
316
+ } finally {
317
+ globalThis.fetch = original;
318
+ }
319
+ });
320
+ });
@@ -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
+ });