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,335 @@
1
+ /**
2
+ * Tests for refreshRegisteredMarketplaces — the non-destructive marketplace clone
3
+ * refresh. These use REAL git repositories on purpose: the entire point of the
4
+ * rework is a real git property — `git pull --ff-only` advances a clone but NEVER
5
+ * deletes it on failure (unlike `claude plugin marketplace update`, which the repo
6
+ * deliberately bans; see marketplace-refresh.ts). Only a real pull can prove that,
7
+ * so the "clone survives a failed pull" cases here are the load-bearing ones.
8
+ */
9
+ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
10
+ import { execFileSync } from "node:child_process";
11
+ import fs from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+
15
+ // --- Mock the marketplace registry (the only magus dep of the refresher) ---
16
+ let configured: Record<string, { source: { source: string; repo?: string } }>;
17
+ let autoUpdate: Record<string, boolean | undefined>;
18
+
19
+ mock.module("../services/claude-settings.js", () => ({
20
+ getConfiguredMarketplaces: async () => configured,
21
+ getMarketplaceAutoUpdate: async (name: string) => autoUpdate[name],
22
+ // The refresher resolves clone paths via this helper; point it at our temp
23
+ // home so no real ~/.claude marketplace is ever touched. (Bun's os.homedir()
24
+ // ignores runtime $HOME changes, so mocking the helper is the reliable seam.)
25
+ getGlobalClaudeDir: () => path.join(tmp, ".claude"),
26
+ }));
27
+
28
+ const { refreshRegisteredMarketplaces, fastForwardCloneIfPresent } =
29
+ await import("../services/marketplace-refresh.js");
30
+
31
+ const gh = (repo = "MadAppGang/magus") => ({
32
+ source: { source: "github", repo },
33
+ });
34
+
35
+ // git with a deterministic identity, no signing, `main` as default branch — so the
36
+ // suite doesn't depend on the developer's global git config.
37
+ const GIT_CFG = [
38
+ "-c",
39
+ "user.email=test@example.com",
40
+ "-c",
41
+ "user.name=test",
42
+ "-c",
43
+ "commit.gpgsign=false",
44
+ "-c",
45
+ "init.defaultBranch=main",
46
+ ];
47
+ function git(cwd: string, ...args: string[]): string {
48
+ return execFileSync("git", [...GIT_CFG, ...args], { cwd, encoding: "utf8" });
49
+ }
50
+
51
+ let tmp: string;
52
+ let mpRoot: string; // <tmp>/.claude/plugins/marketplaces (via mocked getGlobalClaudeDir)
53
+ let origin: string; // bare origin repo standing in for the GitHub remote
54
+
55
+ function writePluginJson(dir: string, version: string, pin: string) {
56
+ const p = path.join(dir, "plugins", "terminal");
57
+ fs.mkdirSync(p, { recursive: true });
58
+ fs.writeFileSync(
59
+ path.join(p, "plugin.json"),
60
+ JSON.stringify({ version, setup: { go: [pin] } }),
61
+ );
62
+ }
63
+ function readPin(dir: string): { version: string; pin: string } {
64
+ const j = JSON.parse(
65
+ fs.readFileSync(
66
+ path.join(dir, "plugins", "terminal", "plugin.json"),
67
+ "utf8",
68
+ ),
69
+ );
70
+ return { version: j.version, pin: j.setup.go[0] };
71
+ }
72
+ /** Clone origin into the marketplace dir for `name`, at origin's current HEAD. */
73
+ function installClone(name: string): string {
74
+ const dir = path.join(mpRoot, name);
75
+ git(tmp, "clone", origin, dir);
76
+ return dir;
77
+ }
78
+ /** Advance origin to a v2 (pinned) release, so installed clones become stale. */
79
+ function publishV2() {
80
+ const pub = path.join(tmp, "pub");
81
+ git(tmp, "clone", origin, pub);
82
+ writePluginJson(pub, "4.1.2", "x@v1.6.2");
83
+ git(pub, "add", "-A");
84
+ git(pub, "commit", "-m", "v2 pinned");
85
+ git(pub, "push", "origin", "HEAD:main");
86
+ fs.rmSync(pub, { recursive: true, force: true });
87
+ }
88
+
89
+ beforeEach(() => {
90
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "mp-refresh-"));
91
+ mpRoot = path.join(tmp, ".claude", "plugins", "marketplaces");
92
+ fs.mkdirSync(mpRoot, { recursive: true });
93
+
94
+ // Bare origin seeded at v1 (unpinned).
95
+ origin = path.join(tmp, "origin.git");
96
+ const seed = path.join(tmp, "seed");
97
+ git(tmp, "init", "--bare", origin);
98
+ git(tmp, "clone", origin, seed);
99
+ writePluginJson(seed, "4.0.3", "x@latest");
100
+ git(seed, "add", "-A");
101
+ git(seed, "commit", "-m", "v1");
102
+ git(seed, "push", "origin", "HEAD:main");
103
+ fs.rmSync(seed, { recursive: true, force: true });
104
+
105
+ configured = {};
106
+ autoUpdate = {};
107
+ });
108
+
109
+ afterEach(() => {
110
+ fs.rmSync(tmp, { recursive: true, force: true });
111
+ });
112
+
113
+ describe("refreshRegisteredMarketplaces — the fix (CLAIM #1)", () => {
114
+ it("fast-forwards a stale clone so a newly pinned setup.go becomes visible", async () => {
115
+ const dir = installClone("magus");
116
+ expect(readPin(dir).pin).toBe("x@latest"); // stale on-disk clone
117
+ publishV2(); // a release lands upstream
118
+
119
+ configured = { magus: gh() };
120
+ const r = await refreshRegisteredMarketplaces();
121
+
122
+ expect(r.refreshed).toEqual(["magus"]);
123
+ expect(r.failed).toEqual([]);
124
+ // The whole point: the version-aware installer now sees the pin on disk.
125
+ expect(readPin(dir)).toEqual({ version: "4.1.2", pin: "x@v1.6.2" });
126
+ });
127
+
128
+ it("refreshes when autoUpdate is undefined (default on)", async () => {
129
+ installClone("magus");
130
+ publishV2();
131
+ configured = { magus: gh() };
132
+ const r = await refreshRegisteredMarketplaces();
133
+ expect(r.refreshed).toEqual(["magus"]);
134
+ });
135
+ });
136
+
137
+ describe("refreshRegisteredMarketplaces — non-destructive on failure (the rework's reason)", () => {
138
+ it("leaves the clone intact when the remote is unreachable (never deleted)", async () => {
139
+ const dir = installClone("magus");
140
+ git(
141
+ dir,
142
+ "remote",
143
+ "set-url",
144
+ "origin",
145
+ path.join(tmp, "does-not-exist.git"),
146
+ );
147
+ configured = { magus: gh() };
148
+
149
+ const r = await refreshRegisteredMarketplaces();
150
+
151
+ expect(r.failed).toEqual(["magus"]);
152
+ expect(fs.existsSync(dir)).toBe(true); // NOT deleted — the destructive mode is gone
153
+ expect(readPin(dir).pin).toBe("x@latest"); // content untouched
154
+ });
155
+
156
+ it("fails safely when history diverged (ff-only refuses to merge), clone intact", async () => {
157
+ const dir = installClone("magus");
158
+ // A local commit the upstream doesn't have → not fast-forwardable.
159
+ writePluginJson(dir, "4.0.3", "x@local");
160
+ git(dir, "add", "-A");
161
+ git(dir, "commit", "-m", "local divergence");
162
+ publishV2(); // upstream also moves → histories diverge
163
+
164
+ configured = { magus: gh() };
165
+ const r = await refreshRegisteredMarketplaces();
166
+
167
+ expect(r.failed).toEqual(["magus"]);
168
+ expect(fs.existsSync(dir)).toBe(true);
169
+ expect(readPin(dir).pin).toBe("x@local"); // local work preserved
170
+ });
171
+
172
+ it("refreshes healthy clones even when another fails (parallel, isolated)", async () => {
173
+ const good = installClone("good");
174
+ const bad = installClone("bad");
175
+ git(bad, "remote", "set-url", "origin", path.join(tmp, "nope.git"));
176
+ publishV2();
177
+
178
+ configured = { good: gh(), bad: gh("c/d") };
179
+ const r = await refreshRegisteredMarketplaces();
180
+
181
+ expect(r.refreshed).toEqual(["good"]);
182
+ expect(r.failed).toEqual(["bad"]);
183
+ expect(fs.existsSync(bad)).toBe(true);
184
+ expect(readPin(good).pin).toBe("x@v1.6.2");
185
+ });
186
+ });
187
+
188
+ describe("refreshRegisteredMarketplaces — selection & skips", () => {
189
+ it("skips a clone with uncommitted local changes (never clobbers them)", async () => {
190
+ const dir = installClone("magus");
191
+ publishV2();
192
+ // Uncommitted edit to a tracked file.
193
+ writePluginJson(dir, "4.0.3", "x@dirty");
194
+ configured = { magus: gh() };
195
+
196
+ const r = await refreshRegisteredMarketplaces();
197
+
198
+ expect(r.skipped).toEqual(["magus"]);
199
+ expect(r.refreshed).toEqual([]);
200
+ expect(readPin(dir).pin).toBe("x@dirty"); // preserved, not fast-forwarded
201
+ });
202
+
203
+ it("skips a registered marketplace whose clone is not on disk", async () => {
204
+ configured = { magus: gh() }; // never installed
205
+ const r = await refreshRegisteredMarketplaces();
206
+ expect(r.skipped).toEqual(["magus"]);
207
+ expect(r.failed).toEqual([]);
208
+ });
209
+
210
+ it("skips non-github sources", async () => {
211
+ configured = { local: { source: { source: "local" } } };
212
+ const r = await refreshRegisteredMarketplaces();
213
+ expect(r.refreshed).toEqual([]);
214
+ expect(r.skipped).toEqual(["local"]);
215
+ });
216
+
217
+ it("reports a marketplace with autoUpdate === false separately from ordinary skips", async () => {
218
+ // Not lumped into `skipped`: opting out here freezes the catalog, so the
219
+ // marketplace's plugins read as up to date forever. The caller has to be
220
+ // able to tell the user, which it cannot do if this looks like "no clone
221
+ // on disk". A real magus clone sat 11 days behind exactly this way.
222
+ installClone("magus");
223
+ publishV2();
224
+ configured = { magus: gh() };
225
+ autoUpdate = { magus: false };
226
+ const r = await refreshRegisteredMarketplaces();
227
+ expect(r.refreshed).toEqual([]);
228
+ expect(r.autoUpdateDisabled).toEqual(["magus"]);
229
+ expect(r.skipped).toEqual([]);
230
+ });
231
+
232
+ it("honors the skip set (already handled this run)", async () => {
233
+ installClone("magus");
234
+ installClone("other");
235
+ publishV2();
236
+ configured = { magus: gh(), other: gh("x/y") };
237
+
238
+ const r = await refreshRegisteredMarketplaces(["magus"]);
239
+
240
+ expect(r.refreshed).toEqual(["other"]);
241
+ expect(r.refreshed).not.toContain("magus");
242
+ expect(r.skipped).not.toContain("magus"); // skip = not touched at all
243
+ });
244
+ });
245
+
246
+ describe("refreshRegisteredMarketplaces — progress taps", () => {
247
+ it("reports the eligible set before pulling, then each clone as it settles", async () => {
248
+ // The whole reason these exist: this call is the slowest thing on the
249
+ // update path and used to report only once every clone had settled.
250
+ installClone("magus");
251
+ installClone("other");
252
+ publishV2();
253
+ configured = { magus: gh(), other: gh("x/y") };
254
+
255
+ const started: string[][] = [];
256
+ const settled: Array<[string, string]> = [];
257
+ const r = await refreshRegisteredMarketplaces([], {
258
+ onStart: (names) => started.push([...names]),
259
+ onSettled: (name, status) => settled.push([name, status]),
260
+ });
261
+
262
+ expect(started).toHaveLength(1);
263
+ expect(started[0]!.sort()).toEqual(["magus", "other"]);
264
+ // One event per eligible clone, and the statuses agree with the result.
265
+ expect(settled.map(([n]) => n).sort()).toEqual(["magus", "other"]);
266
+ expect(settled.every(([, s]) => s === "refreshed")).toBe(true);
267
+ expect(r.refreshed.sort()).toEqual(["magus", "other"]);
268
+ });
269
+
270
+ it("excludes an autoUpdate-disabled marketplace from the denominator", async () => {
271
+ // The progress meter's total comes from onStart. Counting a marketplace
272
+ // that is never pulled would leave the bar permanently short.
273
+ installClone("magus");
274
+ installClone("other");
275
+ publishV2();
276
+ configured = { magus: gh(), other: gh("x/y") };
277
+ autoUpdate = { magus: false };
278
+
279
+ const started: string[][] = [];
280
+ await refreshRegisteredMarketplaces([], {
281
+ onStart: (names) => started.push([...names]),
282
+ });
283
+
284
+ expect(started[0]).toEqual(["other"]);
285
+ });
286
+
287
+ it("a throwing observer cannot fail the refresh", async () => {
288
+ // onSettled fires inside the Promise.all fan-out, so an unguarded throw
289
+ // would reject the batch — turning a display bug into a failed refresh
290
+ // and breaking this function's documented "never throws" contract.
291
+ installClone("magus");
292
+ publishV2();
293
+ configured = { magus: gh() };
294
+
295
+ const r = await refreshRegisteredMarketplaces([], {
296
+ onStart: () => {
297
+ throw new Error("display exploded");
298
+ },
299
+ onSettled: () => {
300
+ throw new Error("display exploded again");
301
+ },
302
+ });
303
+
304
+ expect(r.refreshed).toEqual(["magus"]);
305
+ expect(r.failed).toEqual([]);
306
+ });
307
+ });
308
+
309
+ describe("fastForwardCloneIfPresent — single-clone helper (shared with claude-cli recovery)", () => {
310
+ it("returns 'absent' when no clone is on disk (caller then clones via add)", async () => {
311
+ expect(await fastForwardCloneIfPresent("magus")).toBe("absent");
312
+ });
313
+
314
+ it("returns 'refreshed' and advances a stale clone", async () => {
315
+ const dir = installClone("magus");
316
+ publishV2();
317
+ expect(await fastForwardCloneIfPresent("magus")).toBe("refreshed");
318
+ expect(readPin(dir).pin).toBe("x@v1.6.2");
319
+ });
320
+
321
+ it("returns 'failed' (never throws, clone intact) when the remote is unreachable", async () => {
322
+ const dir = installClone("magus");
323
+ git(dir, "remote", "set-url", "origin", path.join(tmp, "gone.git"));
324
+ expect(await fastForwardCloneIfPresent("magus")).toBe("failed");
325
+ expect(fs.existsSync(dir)).toBe(true);
326
+ });
327
+
328
+ it("returns 'skipped' for a dirty clone (never clobbers local edits)", async () => {
329
+ const dir = installClone("magus");
330
+ publishV2();
331
+ writePluginJson(dir, "4.0.3", "x@dirty");
332
+ expect(await fastForwardCloneIfPresent("magus")).toBe("skipped");
333
+ expect(readPin(dir).pin).toBe("x@dirty");
334
+ });
335
+ });
@@ -0,0 +1,84 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import {
3
+ clearMarketplaceCache,
4
+ fetchMarketplacePlugins,
5
+ getMarketplaceVersion,
6
+ } from "../services/marketplace-fetcher";
7
+
8
+ /**
9
+ * Coverage for capturing a marketplace's `metadata.version` during a fetch.
10
+ *
11
+ * The plugins TUI header renders "Name vX.Y.Z (count)" when a marketplace
12
+ * declares a version, so `fetchMarketplacePlugins` must stash the version and
13
+ * expose it via `getMarketplaceVersion`. Marketplaces without a version must
14
+ * yield `undefined` rather than crashing or leaking a stray "vundefined".
15
+ */
16
+
17
+ const realFetch = globalThis.fetch;
18
+
19
+ function mockFetchOnce(body: unknown): void {
20
+ globalThis.fetch = (async () =>
21
+ new Response(JSON.stringify(body), {
22
+ status: 200,
23
+ headers: { "content-type": "application/json" },
24
+ })) as typeof fetch;
25
+ }
26
+
27
+ describe("fetchMarketplacePlugins — metadata.version capture", () => {
28
+ beforeEach(() => {
29
+ clearMarketplaceCache();
30
+ });
31
+
32
+ afterEach(() => {
33
+ globalThis.fetch = realFetch;
34
+ clearMarketplaceCache();
35
+ });
36
+
37
+ it("captures metadata.version so getMarketplaceVersion returns it", async () => {
38
+ mockFetchOnce({
39
+ metadata: { version: "8.0.0" },
40
+ plugins: [{ name: "dev", description: "Dev workflow" }],
41
+ });
42
+
43
+ await fetchMarketplacePlugins("magus", "MadAppGang/magus");
44
+
45
+ expect(getMarketplaceVersion("magus")).toBe("8.0.0");
46
+ });
47
+
48
+ it("yields undefined when marketplace.json has no metadata.version", async () => {
49
+ mockFetchOnce({
50
+ plugins: [{ name: "core", description: "Anthropic core plugin" }],
51
+ });
52
+
53
+ await fetchMarketplacePlugins("official", "anthropics/official");
54
+
55
+ expect(getMarketplaceVersion("official")).toBeUndefined();
56
+ });
57
+
58
+ it("yields undefined for a marketplace with metadata but no version field", async () => {
59
+ mockFetchOnce({
60
+ metadata: { description: "no version here" },
61
+ plugins: [{ name: "core", description: "" }],
62
+ });
63
+
64
+ await fetchMarketplacePlugins("no-version", "owner/no-version");
65
+
66
+ expect(getMarketplaceVersion("no-version")).toBeUndefined();
67
+ });
68
+
69
+ it("clears captured versions on clearMarketplaceCache", async () => {
70
+ mockFetchOnce({
71
+ metadata: { version: "1.0.0" },
72
+ plugins: [],
73
+ });
74
+
75
+ await fetchMarketplacePlugins(
76
+ "magus-marketing",
77
+ "MadAppGang/magus-marketing",
78
+ );
79
+ expect(getMarketplaceVersion("magus-marketing")).toBe("1.0.0");
80
+
81
+ clearMarketplaceCache();
82
+ expect(getMarketplaceVersion("magus-marketing")).toBeUndefined();
83
+ });
84
+ });
@@ -0,0 +1,128 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ defaultMarketplaces,
4
+ deprecatedMarketplaces,
5
+ getAllMarketplaces,
6
+ } from "../data/marketplaces.js";
7
+ import type { LocalMarketplace } from "../services/local-marketplace.js";
8
+
9
+ describe("marketplaces", () => {
10
+ it("uses the canonical Superpowers marketplace by default", () => {
11
+ const superpowers = defaultMarketplaces.find(
12
+ (mp) => mp.name === "superpowers-marketplace",
13
+ );
14
+
15
+ expect(superpowers).toBeDefined();
16
+ expect(superpowers?.displayName).toBe("Superpowers");
17
+ expect(superpowers?.source.repo).toBe("obra/superpowers-marketplace");
18
+ expect(superpowers?.featured).toBe(true);
19
+ expect(deprecatedMarketplaces.superpowers).toBe("superpowers-marketplace");
20
+ });
21
+
22
+ it("hides stale local superpowers entries behind the canonical marketplace", () => {
23
+ const local = new Map<string, LocalMarketplace>([
24
+ [
25
+ "superpowers",
26
+ {
27
+ name: "superpowers-dev",
28
+ description: "Development marketplace",
29
+ gitRepo: "obra/superpowers",
30
+ plugins: [],
31
+ },
32
+ ],
33
+ ]);
34
+
35
+ const names = getAllMarketplaces(local).map((mp) => mp.name);
36
+
37
+ expect(names).toContain("superpowers-marketplace");
38
+ expect(names).not.toContain("superpowers");
39
+ });
40
+ });
41
+
42
+ /**
43
+ * The community marketplaces added alongside the Magus channels.
44
+ *
45
+ * `name` is not cosmetic: plugin ids are built as `${plugin.name}@${mpName}`
46
+ * (plugin-manager.ts), so it MUST equal the upstream
47
+ * .claude-plugin/marketplace.json "name" field. A mismatch fails at install
48
+ * time with "not found in marketplace", nowhere near the typo that caused it.
49
+ *
50
+ * Verify against upstream with:
51
+ * gh api repos/<repo>/contents/.claude-plugin/marketplace.json \
52
+ * -H "Accept: application/vnd.github.raw" | jq -r '.name'
53
+ */
54
+ const COMMUNITY_MARKETPLACES: ReadonlyArray<[name: string, repo: string]> = [
55
+ ["mattpocock", "mattpocock/skills"],
56
+ ["ecc", "affaan-m/everything-claude-code"],
57
+ ["ponytail", "DietrichGebert/ponytail"],
58
+ ["addy-agent-skills", "addyosmani/agent-skills"],
59
+ ["humanizer", "blader/humanizer"],
60
+ ["opendesign", "manalkaff/opendesign"],
61
+ // A fork, not the original: cursor/plugins keeps every manifest under
62
+ // `.cursor-plugin/` and carries no `.claude-plugin/marketplace.json`, so
63
+ // Claude Code cannot resolve it at all.
64
+ ["open-pstack", "ericlitman/open-pstack"],
65
+ ];
66
+
67
+ describe("community marketplaces", () => {
68
+ it.each(COMMUNITY_MARKETPLACES)(
69
+ "%s points at the repo whose manifest declares that name",
70
+ (name, repo) => {
71
+ const mp = defaultMarketplaces.find((m) => m.name === name);
72
+ expect(mp).toBeDefined();
73
+ expect(mp?.source.repo).toBe(repo);
74
+ },
75
+ );
76
+
77
+ it.each(COMMUNITY_MARKETPLACES)(
78
+ "%s is visible by default via the always-show gate",
79
+ (name) => {
80
+ const mp = defaultMarketplaces.find((m) => m.name === name);
81
+ // plugin-manager's gate: configured || official || featured || owned.
82
+ // Without one of these the entry is only a name-to-repo mapping and
83
+ // never appears, which is a silent no-op rather than a failure.
84
+ expect(mp?.official || mp?.featured || mp?.owned).toBe(true);
85
+ },
86
+ );
87
+
88
+ it.each(COMMUNITY_MARKETPLACES)(
89
+ "%s claims neither Anthropic nor MadAppGang ownership",
90
+ (name) => {
91
+ const mp = defaultMarketplaces.find((m) => m.name === name);
92
+ // `official` badges it as Anthropic-managed; `owned` sorts it above
93
+ // everything as a MadAppGang channel. Both would be a false claim.
94
+ expect(mp?.official).toBeUndefined();
95
+ expect(mp?.owned).toBeUndefined();
96
+ },
97
+ );
98
+
99
+ it("introduces no duplicate marketplace name or repo", () => {
100
+ const names = defaultMarketplaces.map((m) => m.name);
101
+ const repos = defaultMarketplaces.map((m) => m.source.repo.toLowerCase());
102
+
103
+ expect(new Set(names).size).toBe(names.length);
104
+ expect(new Set(repos).size).toBe(repos.length);
105
+ });
106
+
107
+ it("surfaces every community marketplace through getAllMarketplaces", () => {
108
+ const names = getAllMarketplaces().map((mp) => mp.name);
109
+
110
+ for (const [name] of COMMUNITY_MARKETPLACES) {
111
+ expect(names).toContain(name);
112
+ }
113
+ });
114
+
115
+ it("keeps the Magus channels sorted above the community ones", () => {
116
+ const order = getAllMarketplaces().map((mp) => mp.name);
117
+ const lastMagus = Math.max(
118
+ order.indexOf("magus"),
119
+ order.indexOf("magus-marketing"),
120
+ order.indexOf("magus-alpha"),
121
+ );
122
+ const firstCommunity = Math.min(
123
+ ...COMMUNITY_MARKETPLACES.map(([name]) => order.indexOf(name)),
124
+ );
125
+
126
+ expect(lastMagus).toBeLessThan(firstCommunity);
127
+ });
128
+ });