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,400 @@
1
+ /**
2
+ * Marketplace.json fetcher with offline fallback.
3
+ *
4
+ * Extracted from `plugin-manager.ts` so the fetch + fallback logic can be
5
+ * unit-tested without dragging in claude-settings.ts's module-level
6
+ * `os.homedir()` constants (which contaminate the test suite when
7
+ * imported via plugin-manager.ts).
8
+ */
9
+
10
+ import { isValidGitHubRepo } from "../utils/string-utils.js";
11
+ import {
12
+ clearCachedCatalogs,
13
+ readCachedCatalog,
14
+ writeCachedCatalog,
15
+ } from "./catalog-cache-store.js";
16
+ import {
17
+ type GitHubHost,
18
+ cooldownFor,
19
+ describeCooldown,
20
+ hydrateGitHubBudget,
21
+ recordRateLimit,
22
+ recordSuccess,
23
+ } from "./github-budget.js";
24
+ import type { LocalMarketplace } from "./local-marketplace.js";
25
+ import {
26
+ clearGitCatalogCache,
27
+ readCatalogFromRemote,
28
+ } from "./marketplace-catalog-git.js";
29
+ import {
30
+ type MarketplacePlugin,
31
+ clearMarketplaceVersionCache,
32
+ normalizeCatalogJson,
33
+ } from "./marketplace-catalog.js";
34
+
35
+ // Re-exported: these used to live here, and plugin-manager plus several screens
36
+ // import them from this module.
37
+ export {
38
+ getMarketplaceVersion,
39
+ normalizeCatalogJson,
40
+ type MarketplacePlugin,
41
+ } from "./marketplace-catalog.js";
42
+
43
+ // Session-level cache for fetched marketplace data (no TTL - persists until explicit refresh)
44
+ const marketplaceCache = new Map<string, MarketplacePlugin[]>();
45
+
46
+ /** Why a catalog fetch did not produce an authoritative plugin list. */
47
+ export type MarketplaceFetchFailureKind =
48
+ | "rate-limited"
49
+ | "http"
50
+ | "timeout"
51
+ | "network"
52
+ | "invalid-repo"
53
+ | "invalid-content";
54
+
55
+ export interface MarketplaceFetchFailure {
56
+ marketplace: string;
57
+ kind: MarketplaceFetchFailureKind;
58
+ httpStatus?: number;
59
+ /** Short, user-facing reason — rendered verbatim in the TUI. */
60
+ detail: string;
61
+ /**
62
+ * Wall-clock ms when a retry is due, for rate limits. Lets the UI count down
63
+ * and retry itself instead of telling the user to try again "later" and leaving
64
+ * them to guess how much later.
65
+ */
66
+ retryAt?: number;
67
+ }
68
+
69
+ export interface MarketplaceFetchResult {
70
+ plugins: MarketplacePlugin[];
71
+ /** Present exactly when `plugins` is not an authoritative remote answer. */
72
+ failure?: MarketplaceFetchFailure;
73
+ }
74
+
75
+ /**
76
+ * Where a resolved plugin list actually came from.
77
+ *
78
+ * `remote` raw.githubusercontent.com — authoritative.
79
+ * `remote-git` the clone's `origin/<branch>` ref, refreshed by `git fetch` —
80
+ * also authoritative: it IS upstream's file. Reached when HTTP is
81
+ * rate-limited, and it draws on neither GitHub HTTP budget.
82
+ * `local-clone` the checked-out working tree — usable but NOT authoritative. It
83
+ * is deliberately pinned (`autoUpdate: false`), so its versions can
84
+ * be arbitrarily old.
85
+ * `none` nothing could be read.
86
+ */
87
+ export type CatalogSource = "remote" | "remote-git" | "local-clone" | "none";
88
+
89
+ /** Sources whose versions may be reported to the user as current. */
90
+ export function isAuthoritative(source: CatalogSource): boolean {
91
+ return source === "remote" || source === "remote-git";
92
+ }
93
+
94
+ export interface MarketplaceResolution {
95
+ plugins: MarketplacePlugin[];
96
+ source: CatalogSource;
97
+ /**
98
+ * The remote failure, if any. Set even when `source` is `local-clone` — the
99
+ * fallback succeeded, but the versions in it are not known to be current.
100
+ */
101
+ failure?: MarketplaceFetchFailure;
102
+ }
103
+
104
+ /**
105
+ * Failures are remembered, not just returned.
106
+ *
107
+ * Two reasons. First, so the TUI can render "4 of 6 catalogs unreachable"
108
+ * without every caller threading a result type through. Second, so a failure is
109
+ * cached like a success: an un-cached failure was re-attempted on every screen
110
+ * mount, and with a 10s timeout per marketplace that alone cost 30-45s of
111
+ * "Loading..." each time the user switched tabs and came back.
112
+ *
113
+ * The TTL is short and an explicit refresh clears it, so a transient blip never
114
+ * freezes the catalog for a whole session — which would be the same
115
+ * silent-staleness trap this module exists to close.
116
+ */
117
+ const failureCache = new Map<
118
+ string,
119
+ { failure: MarketplaceFetchFailure; at: number }
120
+ >();
121
+ const NEGATIVE_TTL_MS = 60_000;
122
+
123
+ /** Monotonic clock, so a system clock change cannot expire the cache early. */
124
+ function now(): number {
125
+ return performance.now();
126
+ }
127
+
128
+ export function clearMarketplaceCache(): void {
129
+ marketplaceCache.clear();
130
+ clearMarketplaceVersionCache();
131
+ clearGitCatalogCache();
132
+ failureCache.clear();
133
+ }
134
+
135
+ /**
136
+ * Drop the on-disk catalog cache as well.
137
+ *
138
+ * Separate and async because it must be AWAITED before the refetch that follows
139
+ * it: fire-and-forget would race the next `resolveMarketplacePlugins`, which would
140
+ * then read the cache it was told to discard and make `r` a no-op.
141
+ *
142
+ * Deliberately does NOT clear the host cooldown. That records GitHub's state, not
143
+ * ours, and retrying inside a known window cannot succeed — the screen counts down
144
+ * and refetches by itself when the window opens.
145
+ */
146
+ export async function clearPersistedCatalogs(): Promise<void> {
147
+ await clearCachedCatalogs();
148
+ }
149
+
150
+ /**
151
+ * Every marketplace whose catalog could not be fetched, freshest answer first.
152
+ *
153
+ * A caller that renders plugin versions MUST consult this. An empty list is the
154
+ * only proof that the versions on screen were actually checked against GitHub.
155
+ */
156
+ export function getMarketplaceFetchFailures(): MarketplaceFetchFailure[] {
157
+ const out: MarketplaceFetchFailure[] = [];
158
+ for (const [, entry] of failureCache) {
159
+ if (now() - entry.at < NEGATIVE_TTL_MS) out.push(entry.failure);
160
+ }
161
+ return out;
162
+ }
163
+
164
+ /** Record a failure and return it as the result, so callers cannot ignore it. */
165
+ function fail(
166
+ marketplace: string,
167
+ kind: MarketplaceFetchFailureKind,
168
+ detail: string,
169
+ httpStatus?: number,
170
+ retryAt?: number,
171
+ ): MarketplaceFetchResult {
172
+ const failure: MarketplaceFetchFailure = {
173
+ marketplace,
174
+ kind,
175
+ detail,
176
+ httpStatus,
177
+ retryAt,
178
+ };
179
+ failureCache.set(marketplace, { failure, at: now() });
180
+ return { plugins: [], failure };
181
+ }
182
+
183
+ /**
184
+ * Turn a thrown fetch error into a classified failure.
185
+ *
186
+ * A timeout is distinguished from an outright network error because they mean
187
+ * different things to the user: the first is "GitHub is slow or unreachable
188
+ * right now", the second is usually "this machine has no route at all".
189
+ */
190
+ function classifyThrown(
191
+ marketplace: string,
192
+ error: unknown,
193
+ ): MarketplaceFetchResult {
194
+ const name = (error as { name?: string } | null)?.name;
195
+ if (name === "TimeoutError" || name === "AbortError") {
196
+ return fail(
197
+ marketplace,
198
+ "timeout",
199
+ `no response in ${FETCH_TIMEOUT_MS / 1000}s`,
200
+ );
201
+ }
202
+ const message = error instanceof Error ? error.message : String(error);
203
+ return fail(marketplace, "network", message);
204
+ }
205
+
206
+ const FETCH_TIMEOUT_MS = 10_000;
207
+
208
+ /**
209
+ * Fetch a marketplace catalog from GitHub.
210
+ *
211
+ * Returns a result, never a bare list, because "the fetch failed" and "this
212
+ * marketplace has no plugins" used to be the same value (`[]`) and the caller
213
+ * could not tell them apart. That single conflation is what let a rate-limited
214
+ * request render as "you are up to date": the empty list fell through to the
215
+ * on-disk clone, and a stale clone's version compared equal to the installed
216
+ * one. A failure must be a value the caller has to look at.
217
+ */
218
+ export async function fetchMarketplacePlugins(
219
+ marketplaceName: string,
220
+ repo: string,
221
+ ): Promise<MarketplaceFetchResult> {
222
+ // Check cache first - session-level, no TTL
223
+ const cached = marketplaceCache.get(marketplaceName);
224
+ if (cached) {
225
+ return { plugins: cached };
226
+ }
227
+
228
+ // A fresh failure is reused rather than re-attempted: see `failureCache`.
229
+ const priorFailure = failureCache.get(marketplaceName);
230
+ if (priorFailure && now() - priorFailure.at < NEGATIVE_TTL_MS) {
231
+ return { plugins: [], failure: priorFailure.failure };
232
+ }
233
+
234
+ // Validate repo format to prevent SSRF
235
+ if (!isValidGitHubRepo(repo)) {
236
+ return fail(
237
+ marketplaceName,
238
+ "invalid-repo",
239
+ `not a valid GitHub repo: "${repo}"`,
240
+ );
241
+ }
242
+
243
+ const host: GitHubHost = "raw.githubusercontent.com";
244
+
245
+ // Cooldowns recorded by a PREVIOUS launch count too. Without this the gate is
246
+ // per-process, and a TUI's process lives for one screen session.
247
+ await hydrateGitHubBudget();
248
+
249
+ // Already known to be rate-limited: do not spend an attempt to rediscover it.
250
+ // The budget is per-host and shared with every other service, so six
251
+ // marketplaces used to trip the same limit six times over — and on some
252
+ // endpoints a rejected request extends the penalty.
253
+ const cooling = cooldownFor(host);
254
+ if (cooling) {
255
+ return fail(
256
+ marketplaceName,
257
+ "rate-limited",
258
+ describeCooldown(cooling),
259
+ 429,
260
+ cooling.until,
261
+ );
262
+ }
263
+
264
+ try {
265
+ // Fetch marketplace.json from GitHub
266
+ const url = `https://raw.githubusercontent.com/${repo}/main/.claude-plugin/marketplace.json`;
267
+ const response = await fetch(url, {
268
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
269
+ });
270
+
271
+ if (!response.ok) {
272
+ // 429/403 are the rate-limit shapes GitHub returns for unauthenticated
273
+ // traffic. Called out separately because the fix is "wait, or
274
+ // authenticate", not "check your network".
275
+ const rateLimited = response.status === 429 || response.status === 403;
276
+ if (rateLimited) {
277
+ // Hand the response headers over: api.github.com states when the window
278
+ // reopens, raw.githubusercontent.com states nothing, and the budget
279
+ // module is what knows the difference.
280
+ const cooldown = recordRateLimit(host, response.headers);
281
+ return fail(
282
+ marketplaceName,
283
+ "rate-limited",
284
+ describeCooldown(cooldown),
285
+ response.status,
286
+ cooldown.until,
287
+ );
288
+ }
289
+ return fail(
290
+ marketplaceName,
291
+ "http",
292
+ `HTTP ${response.status} ${response.statusText}`.trim(),
293
+ response.status,
294
+ );
295
+ }
296
+
297
+ // Validate content-type
298
+ const contentType = response.headers.get("content-type");
299
+ if (
300
+ contentType &&
301
+ !contentType.includes("application/json") &&
302
+ !contentType.includes("text/plain")
303
+ ) {
304
+ return fail(
305
+ marketplaceName,
306
+ "invalid-content",
307
+ `unexpected content-type "${contentType}"`,
308
+ );
309
+ }
310
+
311
+ const plugins = normalizeCatalogJson(
312
+ marketplaceName,
313
+ await response.json(),
314
+ );
315
+
316
+ // Cache the result (session-level)
317
+ marketplaceCache.set(marketplaceName, plugins);
318
+ // A success retires any recorded failure, so the banner clears itself.
319
+ failureCache.delete(marketplaceName);
320
+ recordSuccess(host);
321
+
322
+ return { plugins };
323
+ } catch (error) {
324
+ return classifyThrown(marketplaceName, error);
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Resolve marketplace plugins with offline fallback.
330
+ *
331
+ * Calls `fetchMarketplacePlugins` (which hits GitHub) and, when it returns
332
+ * empty AND a local marketplace cache exists for the same name, falls back
333
+ * to the cached plugin manifest. This prevents every installed plugin from
334
+ * being flagged `isOrphaned: true` (and rendered "deprecated") whenever the
335
+ * machine can't reach `raw.githubusercontent.com` — the local cache already
336
+ * has the data, it just wasn't being consulted as a fallback.
337
+ *
338
+ * The two shapes are nearly identical; we drop a couple of LocalMarketplacePlugin
339
+ * fields (lspServers, agents, etc.) that MarketplacePlugin doesn't carry
340
+ * since the caller doesn't read them at this layer.
341
+ *
342
+ * The fallback stays — being offline should not make every plugin read as
343
+ * deprecated — but the return value now says WHICH source answered. The clone is
344
+ * a usable catalog, not an authoritative one: nothing refreshes it when a
345
+ * marketplace carries `autoUpdate: false`, so its versions can be arbitrarily
346
+ * old. A caller that reports "up to date" off a `local-clone` answer is
347
+ * reporting a guess as a fact.
348
+ */
349
+ export async function resolveMarketplacePlugins(
350
+ mpName: string,
351
+ repo: string,
352
+ localMarketplaces: Map<string, LocalMarketplace>,
353
+ ): Promise<MarketplaceResolution> {
354
+ // Disk cache first, so a relaunch costs no network at all. Measured before this
355
+ // existed: every process spent six requests and ~13s re-fetching catalogs it had
356
+ // already read minutes earlier.
357
+ const cached = await readCachedCatalog(mpName);
358
+ if (cached) {
359
+ return {
360
+ plugins: cached.plugins,
361
+ source: cached.source as CatalogSource,
362
+ };
363
+ }
364
+
365
+ const remote = await fetchMarketplacePlugins(mpName, repo);
366
+ if (remote.plugins.length > 0) {
367
+ void writeCachedCatalog(mpName, remote.plugins, "remote");
368
+ return { plugins: remote.plugins, source: "remote" };
369
+ }
370
+
371
+ // HTTP failed — most often rate-limited. Before falling back to the pinned
372
+ // working tree, ask git for upstream's catalog. `git fetch` moves only remote
373
+ // refs, so this respects `autoUpdate: false` (the install source stays pinned)
374
+ // while still answering "what version exists upstream". It is also on a
375
+ // different transport, so a raw.githubusercontent.com rate limit does not
376
+ // affect it.
377
+ const viaGit = await readCatalogFromRemote(mpName);
378
+ if (viaGit && viaGit.length > 0) {
379
+ void writeCachedCatalog(mpName, viaGit, "remote-git");
380
+ return { plugins: viaGit, source: "remote-git", failure: remote.failure };
381
+ }
382
+
383
+ const local = localMarketplaces.get(mpName);
384
+ if (!local || local.plugins.length === 0) {
385
+ return { plugins: [], source: "none", failure: remote.failure };
386
+ }
387
+
388
+ return {
389
+ plugins: local.plugins.map((p) => ({
390
+ name: p.name,
391
+ version: p.version,
392
+ description: p.description,
393
+ category: p.category,
394
+ author: p.author,
395
+ releases: p.releases,
396
+ })),
397
+ source: "local-clone",
398
+ failure: remote.failure,
399
+ };
400
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Keep on-disk marketplace clones current — safely.
3
+ *
4
+ * The plugin *list* magus shows is fetched fresh from GitHub, but a plugin's
5
+ * `setup` block and Claude Code's own installer read the on-disk marketplace
6
+ * clone (~/.claude/plugins/marketplaces/<name>). Nothing else keeps that clone
7
+ * current — recoverMarketplaceSettings only pulls entries it had to re-register —
8
+ * so a healthy marketplace's clone goes stale and its plugin metadata (e.g. a
9
+ * pinned dependency version in a plugin's setup) is never seen, even while the
10
+ * displayed version looks up to date.
11
+ *
12
+ * IMPORTANT: this refreshes the clone with a NON-DESTRUCTIVE `git pull --ff-only`,
13
+ * never `claude plugin marketplace update`. The latter (cacheMarketplaceFromGit)
14
+ * deletes the clone directory before re-cloning, so a failed clone leaves the
15
+ * marketplace permanently deleted — a documented hazard this codebase deliberately
16
+ * avoids (see marketplace-sync.ts and ai-docs/plugin-marketplace-bug-investigation.md).
17
+ * A failed `git pull --ff-only` leaves the clone exactly as it was: stale, but intact.
18
+ */
19
+
20
+ import { spawn } from "node:child_process";
21
+ import fs from "node:fs";
22
+ import path from "node:path";
23
+ import {
24
+ getConfiguredMarketplaces,
25
+ getGlobalClaudeDir,
26
+ getMarketplaceAutoUpdate,
27
+ } from "./claude-settings.js";
28
+
29
+ /** Per-git-invocation timeout. A hung pull must never block launching Claude. */
30
+ const GIT_TIMEOUT_MS = 30_000;
31
+
32
+ export interface RefreshResult {
33
+ /** Clones fast-forwarded to a newer commit (or already current). */
34
+ refreshed: string[];
35
+ /** Pull errored (network, diverged, timeout) — clone left intact and stale. */
36
+ failed: string[];
37
+ /** Nothing to do: no clone on disk, not a git repo, or dirty tree. */
38
+ skipped: string[];
39
+ }
40
+
41
+ function cloneDir(name: string): string {
42
+ return path.join(getGlobalClaudeDir(), "plugins", "marketplaces", name);
43
+ }
44
+
45
+ /** Run git in `cwd`, resolving stdout on exit 0. Killed after `timeoutMs`. */
46
+ function runGit(
47
+ cwd: string,
48
+ args: string[],
49
+ timeoutMs: number,
50
+ ): Promise<string> {
51
+ return new Promise((resolve, reject) => {
52
+ const proc = spawn("git", args, {
53
+ cwd,
54
+ timeout: timeoutMs,
55
+ killSignal: "SIGKILL",
56
+ });
57
+ let stdout = "";
58
+ let stderr = "";
59
+ proc.stdout?.on("data", (b) => {
60
+ stdout += b.toString();
61
+ });
62
+ proc.stderr?.on("data", (b) => {
63
+ stderr += b.toString();
64
+ });
65
+ proc.on("error", reject);
66
+ proc.on("close", (code, signal) => {
67
+ if (code === 0) {
68
+ resolve(stdout);
69
+ } else {
70
+ reject(
71
+ new Error(
72
+ `git ${args.join(" ")} ${
73
+ signal ? `killed (${signal})` : `exited ${code}`
74
+ }: ${stderr.trim()}`,
75
+ ),
76
+ );
77
+ }
78
+ });
79
+ });
80
+ }
81
+
82
+ /** True if the clone has uncommitted changes we must not clobber. */
83
+ async function isDirty(dir: string): Promise<boolean> {
84
+ const out = await runGit(dir, ["status", "--porcelain"], GIT_TIMEOUT_MS);
85
+ return out.trim().length > 0;
86
+ }
87
+
88
+ /**
89
+ * Outcome of fast-forwarding one clone:
90
+ * - `absent` no clone on disk (caller may create it via `marketplace add`)
91
+ * - `skipped` dirty tree — left untouched so local edits survive
92
+ * - `refreshed` fast-forwarded, or already current
93
+ * - `failed` pull errored (network, diverged, timeout) — clone left intact
94
+ */
95
+ export type CloneRefresh = "absent" | "skipped" | "refreshed" | "failed";
96
+
97
+ /**
98
+ * Non-destructively fast-forward ONE marketplace clone. Never throws, never
99
+ * deletes: the worst case is `failed` (stale clone left intact). Shared by the
100
+ * batch refresh below and by claude-cli's "plugin not found" recovery, so both
101
+ * paths get the same safe semantics instead of the old `marketplace update`.
102
+ */
103
+ export async function fastForwardCloneIfPresent(
104
+ name: string,
105
+ ): Promise<CloneRefresh> {
106
+ const dir = cloneDir(name);
107
+ // No clone on disk (claude never installed it) or not a git repo.
108
+ if (!fs.existsSync(path.join(dir, ".git"))) return "absent";
109
+ try {
110
+ // Never clobber a user's local edits to the clone.
111
+ if (await isDirty(dir)) return "skipped";
112
+ // --ff-only is the safety guarantee: it fetches and fast-forwards, and
113
+ // aborts without mutating the tree if it can't. It never deletes the dir,
114
+ // so a failure degrades to "stale clone", never "deleted marketplace".
115
+ await runGit(dir, ["pull", "--ff-only"], GIT_TIMEOUT_MS);
116
+ return "refreshed";
117
+ } catch {
118
+ return "failed";
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Progress taps, so a caller can show what is happening during the pulls.
124
+ *
125
+ * This is the slowest thing on the update path — a git fetch per marketplace,
126
+ * over the network, with a 30s timeout each — and it used to report only after
127
+ * every clone had settled.
128
+ *
129
+ * Both callbacks are purely observational and CANNOT affect the refresh: each
130
+ * is invoked inside a try/catch that discards whatever it throws. `onSettled`
131
+ * in particular runs inside the `Promise.all` fan-out, so an unguarded throw
132
+ * there would reject the batch and turn a display error into a failed refresh —
133
+ * exactly the coupling this function's "never throws" contract exists to
134
+ * prevent.
135
+ */
136
+ export interface RefreshProgress {
137
+ /** The eligible set, once selection is done and before any pull starts. */
138
+ onStart?: (names: string[]) => void;
139
+ /** One clone settled. Fires in completion order, not in `names` order. */
140
+ onSettled?: (name: string, status: CloneRefresh) => void;
141
+ }
142
+
143
+ /** Invoke an observer without letting it affect the caller. */
144
+ function tap(fn: (() => void) | undefined): void {
145
+ if (!fn) return;
146
+ try {
147
+ fn();
148
+ } catch {
149
+ // A progress display is never worth failing a refresh over.
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Non-destructively fast-forward the on-disk clone of every registered,
155
+ * auto-update-enabled, github-sourced marketplace.
156
+ *
157
+ * @param skip marketplaces already refreshed this run (e.g. re-registered ones),
158
+ * so they aren't pulled twice.
159
+ * @param progress optional taps for a live progress display.
160
+ * @returns which marketplaces were refreshed, which failed (clone left intact),
161
+ * and which were skipped. Failures and skips are collected rather than thrown —
162
+ * one unreachable marketplace must not block launching Claude. Pulls run
163
+ * concurrently, so a slow one doesn't serialize the rest onto the launch path.
164
+ */
165
+ /**
166
+ * Select and report, writing nothing.
167
+ *
168
+ * Everything above the pull loop is a read — the marketplace registry and each
169
+ * entry's `autoUpdate` flag — so a read-only caller can have the full selection
170
+ * verdict for free. `--dry-run` and `--check` promise to write nothing and
171
+ * therefore cannot pull, but they must still be able to say "your magus clone
172
+ * has autoUpdate off, so its catalog will never advance". Gating the whole call
173
+ * off silently dropped that warning, which is the same "no signal reads as no
174
+ * problem" failure the flag exists to prevent.
175
+ */
176
+ export async function reportRegisteredMarketplaces(
177
+ skip: Iterable<string> = [],
178
+ ): Promise<RefreshResult> {
179
+ return refreshRegisteredMarketplaces(skip, {}, { reportOnly: true });
180
+ }
181
+
182
+ export async function refreshRegisteredMarketplaces(
183
+ skip: Iterable<string> = [],
184
+ progress: RefreshProgress = {},
185
+ options: { reportOnly?: boolean } = {},
186
+ ): Promise<RefreshResult> {
187
+ const refreshed: string[] = [];
188
+ const failed: string[] = [];
189
+ const skipped: string[] = [];
190
+
191
+ const configured = await getConfiguredMarketplaces();
192
+ const skipSet = new Set(skip);
193
+
194
+ // Select eligible marketplaces first so the pulls can run concurrently.
195
+ const eligible: string[] = [];
196
+ for (const [name, cfg] of Object.entries(configured)) {
197
+ if (skipSet.has(name)) continue;
198
+ if (cfg.source?.source !== "github" || !cfg.source?.repo) {
199
+ skipped.push(name);
200
+ continue;
201
+ }
202
+ // Claude Code's own refresh policy. Honour it by not pulling, and say
203
+ // nothing: the version answer does not come from this clone.
204
+ // `readCatalogFromRemote` reads `origin/<branch>` with fetch + show, never
205
+ // the working tree — measured, a clone holding dev 7.4.0 with autoUpdate
206
+ // off still reported 7.4.1 available.
207
+ if ((await getMarketplaceAutoUpdate(name)) === false) {
208
+ skipped.push(name);
209
+ continue;
210
+ }
211
+ eligible.push(name);
212
+ }
213
+
214
+ // A read-only caller stops here, with the selection verdict intact and not
215
+ // one clone touched. `eligible` becomes `skipped`: nothing was refreshed, so
216
+ // claiming otherwise would be the false "all current" this module guards.
217
+ if (options.reportOnly) {
218
+ return {
219
+ refreshed: [],
220
+ failed: [],
221
+ skipped: [...skipped, ...eligible],
222
+ };
223
+ }
224
+
225
+ // Fast-forward every eligible clone in parallel so a slow one doesn't
226
+ // serialize the rest onto the launch path. fastForwardCloneIfPresent never
227
+ // throws, so the batch always resolves.
228
+ tap(() => progress.onStart?.([...eligible]));
229
+ const statuses = await Promise.all(
230
+ eligible.map(async (name) => {
231
+ const status = await fastForwardCloneIfPresent(name);
232
+ tap(() => progress.onSettled?.(name, status));
233
+ return status;
234
+ }),
235
+ );
236
+
237
+ statuses.forEach((status, i) => {
238
+ const name = eligible[i];
239
+ if (status === "refreshed") refreshed.push(name);
240
+ else if (status === "failed") failed.push(name);
241
+ else skipped.push(name); // absent | skipped
242
+ });
243
+
244
+ return { refreshed, failed, skipped };
245
+ }