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,878 @@
1
+ import path from "node:path";
2
+ import { defaultMarketplaces } from "../data/marketplaces.js";
3
+ import type {
4
+ InstalledPluginsRegistry,
5
+ PluginRelease,
6
+ } from "../types/index.js";
7
+ import { formatMarketplaceName, parsePluginId } from "../utils/string-utils.js";
8
+ import type { PluginScope } from "./claude-cli.js";
9
+ import {
10
+ getConfiguredMarketplaces,
11
+ getEnabledPlugins,
12
+ getGlobalClaudeDir,
13
+ getGlobalConfiguredMarketplaces,
14
+ getGlobalEnabledPlugins,
15
+ getGlobalInstalledPluginVersions,
16
+ getLocalEnabledPlugins,
17
+ getLocalInstalledPluginVersions,
18
+ getProjectInstalledPluginVersions,
19
+ pickRegistryEntry,
20
+ readInstalledPluginsRegistry,
21
+ readSettings,
22
+ removeFromInstalledPluginsRegistry,
23
+ saveGlobalInstalledPluginVersion,
24
+ saveLocalInstalledPluginVersion,
25
+ updateInstalledPluginsRegistry,
26
+ writeSettings,
27
+ } from "./claude-settings.js";
28
+ import { hasContentDrift } from "./content-drift.js";
29
+ import { inheritablePaths } from "./git-worktree.js";
30
+ import {
31
+ type LocalMarketplace,
32
+ type ProgressCallback,
33
+ type RepairMarketplaceResult,
34
+ repairAllMarketplaces,
35
+ scanLocalMarketplaces,
36
+ } from "./local-marketplace.js";
37
+ import {
38
+ type CatalogSource,
39
+ type MarketplaceFetchFailure,
40
+ type MarketplacePlugin,
41
+ clearMarketplaceCache as clearFetcherCache,
42
+ clearPersistedCatalogs,
43
+ fetchMarketplacePlugins,
44
+ getMarketplaceFetchFailures,
45
+ isAuthoritative,
46
+ resolveMarketplacePlugins,
47
+ } from "./marketplace-fetcher.js";
48
+ export {
49
+ fetchMarketplacePlugins,
50
+ getMarketplaceFetchFailures,
51
+ isAuthoritative,
52
+ resolveMarketplacePlugins,
53
+ type CatalogSource,
54
+ type MarketplaceFetchFailure,
55
+ type MarketplacePlugin,
56
+ };
57
+ // Cache for local marketplaces (session-level) - Promise-based to prevent race conditions
58
+ let localMarketplacesPromise: Promise<Map<string, LocalMarketplace>> | null =
59
+ null;
60
+
61
+ export interface ScopeStatus {
62
+ enabled: boolean;
63
+ version?: string;
64
+ }
65
+
66
+ export interface PluginInfo {
67
+ id: string;
68
+ name: string;
69
+ version: string | null;
70
+ description: string;
71
+ marketplace: string;
72
+ marketplaceDisplay: string;
73
+ enabled: boolean;
74
+ installedVersion?: string;
75
+ /**
76
+ * Provably behind the catalog, on ONE version pair.
77
+ *
78
+ * @deprecated Derive plugin state from `classifyPlugin` (update-plan.ts),
79
+ * which answers per scope and distinguishes "cannot tell" from "current".
80
+ * Nothing in `src/` reads this any more — the content-drift gate was its
81
+ * last reader, and it now asks the version pair directly. It is still
82
+ * computed because `blackbox-parity.test.ts` reproduces the pre-classifier
83
+ * rule from it, which is what proves the fixture discriminates.
84
+ *
85
+ * Its sibling `updateUnknown` was written in six places and read in none,
86
+ * and is gone.
87
+ */
88
+ hasUpdate?: boolean;
89
+ // Per-scope installation status
90
+ userScope?: ScopeStatus;
91
+ projectScope?: ScopeStatus;
92
+ localScope?: ScopeStatus;
93
+ // Extended info
94
+ category?: string;
95
+ author?: { name: string; email?: string };
96
+ homepage?: string;
97
+ tags?: string[];
98
+ /** Recent release history, newest first. Absent when the manifest publishes none. */
99
+ releases?: PluginRelease[];
100
+ agents?: string[];
101
+ commands?: string[];
102
+ skills?: string[];
103
+ mcpServers?: string[];
104
+ lspServers?: Record<string, unknown>;
105
+ /**
106
+ * The plugin's directory within its marketplace repo, as the manifest
107
+ * declares it ("./" for a repo that is one plugin, "./plugins/dev" for a
108
+ * multi-plugin marketplace). Needed to scope a skill listing to this
109
+ * plugin's subtree rather than the whole repo.
110
+ */
111
+ source?: string;
112
+ /**
113
+ * Set only when the plugin's files live in a repo other than its
114
+ * marketplace's — an object `source` naming a different repo.
115
+ */
116
+ sourceRepo?: string;
117
+ isOrphaned?: boolean;
118
+ /**
119
+ * Set when the installed version equals the catalog version, but the
120
+ * plugin's files in the marketplace have changed since the commit it was
121
+ * installed from. The version compare says "up to date" and is wrong: only
122
+ * a reinstall delivers the current content. See content-drift.ts.
123
+ */
124
+ contentStale?: boolean;
125
+ /**
126
+ * For an orphaned plugin: another configured marketplace that publishes a
127
+ * plugin of the same name — i.e. it did not disappear, it moved.
128
+ *
129
+ * Splitting `magus` into `magus` + `magus-marketing` left video-editing and
130
+ * image-generate installed under a namespace that no longer lists them. Rendered as bare "deprecated", the only offered action
131
+ * was deletion, which silently drops a plugin that is still published.
132
+ */
133
+ movedTo?: string;
134
+ /**
135
+ * Set when this plugin's installed version changed since magus last
136
+ * rendered it — including updates made by Claude Code or a direct CLI call
137
+ * rather than by magus itself. Drives the "updated" badge.
138
+ */
139
+ recentlyUpdatedFrom?: string;
140
+ /**
141
+ * Set when magus is seeing this plugin installed for the first time.
142
+ * Never set on the very first run, when there is no baseline to compare to.
143
+ */
144
+ recentlyInstalled?: boolean;
145
+ /**
146
+ * Which catalog supplied `version` — i.e. the number this plugin's installed
147
+ * version was compared against.
148
+ *
149
+ * `remote` (HTTP) and `remote-git` (a `git fetch`ed remote ref) are both
150
+ * authoritative — each is upstream's own catalog file. `local-clone` is the
151
+ * checked-out working tree, which is deliberately pinned by
152
+ * `autoUpdate: false`, so its versions can be arbitrarily old.
153
+ */
154
+ catalogSource?: CatalogSource;
155
+ /**
156
+ * Set when `hasUpdate: false` could not actually be verified — the catalog
157
+ * fetch failed and the comparison ran against the pinned clone.
158
+ *
159
+ * This exists because the absence of an update was indistinguishable from a
160
+ * successful check. A rate-limited fetch returned `[]`, the stale clone filled
161
+ * in, its version compared equal to the installed one, and the UI reported
162
+ * "up to date" — the failure rendered as a clean bill of health. Anything that
163
+ * tells the user they are current must check this flag first.
164
+ */
165
+ updateCheckFailed?: boolean;
166
+ /** Why the check failed, for display. Set with `updateCheckFailed`. */
167
+ updateCheckFailure?: MarketplaceFetchFailure;
168
+ }
169
+
170
+ /**
171
+ * True when a plugin is enabled in some scope but no installed version resolves
172
+ * anywhere — it is listed in `enabledPlugins` but was never actually installed.
173
+ *
174
+ * This is a broken state, not a healthy one: Claude Code will not load the
175
+ * plugin, and because the update check is `installedVersion && compare(...)`,
176
+ * it is also invisible to every update path.
177
+ * It previously rendered identically to a working install.
178
+ *
179
+ * `isOrphaned` is a different failure — installed, but no longer offered by any
180
+ * marketplace — and is rendered as "deprecated" already.
181
+ *
182
+ * "0.0.0" means installed-with-unknown-version (Anthropic's official plugins
183
+ * record exactly that), so it must NOT count as missing.
184
+ */
185
+ export function isEnabledButNotInstalled(plugin: PluginInfo): boolean {
186
+ if (plugin.isOrphaned) return false;
187
+ const enabledSomewhere =
188
+ !!plugin.userScope?.enabled ||
189
+ !!plugin.projectScope?.enabled ||
190
+ !!plugin.localScope?.enabled;
191
+ return enabledSomewhere && !plugin.installedVersion;
192
+ }
193
+
194
+ /**
195
+ * True when a scope genuinely has the plugin installed.
196
+ *
197
+ * `enabled` on its own is not proof. It is the `enabledPlugins` flag out of a
198
+ * settings file, and only magus and the user maintain it; `version` comes
199
+ * from `installed_plugins.json` via `overlayRegistryVersions`, which is what
200
+ * Claude Code actually loaded. When the two disagree — flag set, no registry
201
+ * entry — the plugin is in the broken enabled-but-not-installed state that the
202
+ * list already renders as "not installed".
203
+ *
204
+ * Every action that touches the filesystem must use this, not the flag alone.
205
+ * Deciding on the flag is what made the scope keys uninstall a plugin the row
206
+ * had just labelled "not installed".
207
+ *
208
+ * A version of "0.0.0" still counts as installed — Anthropic's official plugins
209
+ * record exactly that — which is why this tests presence, not `isKnownVersion`.
210
+ */
211
+ export function isInstalledInScope(scope: ScopeStatus | undefined): boolean {
212
+ // Presence of files, NOT permission to load them. `enabled` was in this
213
+ // test until it was measured: a plugin with a real registry row that the
214
+ // user had simply switched off reported "not installed", so the row offered
215
+ // to install what was already on disk. Installation is a registry fact;
216
+ // enablement is a separate one, and `isEnabledButNotInstalled` is where the
217
+ // broken combination of the two is named.
218
+ return !!scope?.version;
219
+ }
220
+
221
+ /**
222
+ * One scope's status, from the two facts that define it.
223
+ *
224
+ * Exported and taken as arguments so this is testable and has exactly one
225
+ * implementation. It previously lived inline in `getAvailablePlugins` and its
226
+ * global twin — two copies, and both keyed existence off the settings flag:
227
+ *
228
+ * enabledPlugins[id] !== undefined ? {...} : undefined
229
+ *
230
+ * so a plugin with a registry row but no settings key produced NO scope at all
231
+ * and rendered exactly like one that was never installed. Existence is now
232
+ * "either fact is present", and `enabled` defaults to false rather than
233
+ * deciding whether the scope exists.
234
+ */
235
+ export function buildScopeStatus(
236
+ enabledPlugins: Record<string, boolean>,
237
+ installedVersions: Record<string, string>,
238
+ pluginId: string,
239
+ ): ScopeStatus | undefined {
240
+ const enabled = enabledPlugins[pluginId];
241
+ const version = installedVersions[pluginId];
242
+ if (enabled === undefined && version === undefined) return undefined;
243
+ return { enabled: enabled === true, version };
244
+ }
245
+
246
+ /**
247
+ * What a scope toggle (`u` / `p` / `l`, or the Enter scope picker) should do.
248
+ *
249
+ * `install` doubles as the repair for enabled-but-not-installed. Measured
250
+ * against Claude Code 2.1.223 in exactly that state — `dev@magus` flagged in
251
+ * `.claude/settings.json` with no registry entry for the project path —
252
+ * `claude plugin install dev@magus --scope project` installed it normally and
253
+ * wrote the registry entry. It does not report "already installed", so this
254
+ * needs no uninstall-first dance (unlike content drift, see repairPlugin).
255
+ *
256
+ * `behind` is the answer to "is this scope provably behind?", and it comes from
257
+ * the plugin classifier (`scopeIsBehind` in update-plan.ts), never from a
258
+ * comparison made here. This used to take the catalog version and compare raw
259
+ * strings — a fourth "is it behind?" rule, which offered `update` for a scope
260
+ * AHEAD of the catalog or one whose version cannot be ordered at all, while the
261
+ * row beside it said something else.
262
+ */
263
+ export function resolveScopeAction(
264
+ scope: ScopeStatus | undefined,
265
+ behind: boolean,
266
+ ): "install" | "update" | "uninstall" {
267
+ // `enabled` is deliberately part of THIS decision even though it is no longer
268
+ // part of `isInstalledInScope`. The two ask different questions:
269
+ // "is it on disk" (installation) versus "what should this key do next".
270
+ //
271
+ // Installed-but-disabled must NOT resolve to uninstall. The scope key runs
272
+ // `cliUninstallPlugin` with no confirmation, so that combination would delete
273
+ // a plugin the user had merely switched off — with the row beside it showing
274
+ // no scope square, which reads as "not installed" and invites the press.
275
+ // `install` re-installs and re-enables, which is what the key meant before
276
+ // installation and enablement were separated, and is the only non-destructive
277
+ // answer here.
278
+ if (!isInstalledInScope(scope) || !scope?.enabled) return "install";
279
+ return behind ? "update" : "uninstall";
280
+ }
281
+
282
+ export async function getAvailablePlugins(
283
+ projectPath?: string,
284
+ ): Promise<PluginInfo[]> {
285
+ const configuredMarketplaces = await getConfiguredMarketplaces(projectPath);
286
+ const enabledPlugins = await getEnabledPlugins(projectPath);
287
+ const installedVersions = await getInstalledPluginVersions(projectPath);
288
+
289
+ // Fetch all scopes for per-scope status
290
+ const userEnabledPlugins = await getGlobalEnabledPlugins();
291
+ const userInstalledVersions = await getGlobalInstalledPluginVersions();
292
+ const projectEnabledPlugins = await getEnabledPlugins(projectPath);
293
+ const projectInstalledVersions =
294
+ await getInstalledPluginVersions(projectPath);
295
+ const localEnabledPlugins = await getLocalEnabledPlugins(projectPath);
296
+ const localInstalledVersions =
297
+ await getLocalInstalledPluginVersions(projectPath);
298
+
299
+ const plugins: PluginInfo[] = [];
300
+ const seenPluginIds = new Set<string>();
301
+
302
+ // Helper to build scope status
303
+ // One implementation, shared with the global view and unit-tested. Both
304
+ // copies used to key scope existence off the settings flag, so an installed
305
+ // plugin with no settings key produced no scope at all.
306
+ const scopeStatusFor = (pluginId: string) => ({
307
+ userScope: buildScopeStatus(
308
+ userEnabledPlugins,
309
+ userInstalledVersions,
310
+ pluginId,
311
+ ),
312
+ projectScope: buildScopeStatus(
313
+ projectEnabledPlugins,
314
+ projectInstalledVersions,
315
+ pluginId,
316
+ ),
317
+ localScope: buildScopeStatus(
318
+ localEnabledPlugins,
319
+ localInstalledVersions,
320
+ pluginId,
321
+ ),
322
+ });
323
+
324
+ // Get all marketplace names (configured + official/featured defaults)
325
+ // Always include official and featured marketplaces so users can browse them
326
+ const marketplaceNames = new Set<string>();
327
+ for (const mp of defaultMarketplaces) {
328
+ if (
329
+ configuredMarketplaces[mp.name] ||
330
+ mp.official ||
331
+ mp.featured ||
332
+ mp.owned
333
+ ) {
334
+ marketplaceNames.add(mp.name);
335
+ }
336
+ }
337
+
338
+ // Fetch local marketplace caches up front so we can detect stale caches
339
+ let localMarketplaces = await getLocalMarketplaces();
340
+
341
+ // Resolve every marketplace concurrently. These are independent HTTP calls
342
+ // with a 10s timeout each; awaited in sequence, one unreachable marketplace
343
+ // added its full timeout to the "Loading..." the user stares at. Measured on
344
+ // six marketplaces with four unreachable: 44.9s sequential.
345
+ const resolutions = await Promise.all(
346
+ [...marketplaceNames].map(async (mpName) => {
347
+ const marketplace = defaultMarketplaces.find((m) => m.name === mpName);
348
+ if (!marketplace) return null;
349
+ return {
350
+ mpName,
351
+ marketplace,
352
+ resolution: await resolveMarketplacePlugins(
353
+ mpName,
354
+ marketplace.source.repo,
355
+ localMarketplaces,
356
+ ),
357
+ };
358
+ }),
359
+ );
360
+
361
+ for (const entry of resolutions) {
362
+ if (!entry) continue;
363
+ const { mpName, marketplace, resolution } = entry;
364
+ const marketplacePlugins = resolution.plugins;
365
+
366
+ // Auto-sync local cache if remote has plugins the local cache doesn't
367
+ localMarketplaces = await autoSyncIfStale(
368
+ mpName,
369
+ marketplacePlugins.map((p) => p.name),
370
+ localMarketplaces,
371
+ );
372
+
373
+ // The catalog that answered was not the authoritative one, so every
374
+ // version compare below is a guess, not a check.
375
+ // `remote-git` counts as verified: it is upstream's own catalog file, read
376
+ // through a git remote ref rather than over HTTP. Only the pinned working
377
+ // tree (`local-clone`) is untrustworthy for versions.
378
+ const unverified = !isAuthoritative(resolution.source);
379
+
380
+ for (const plugin of marketplacePlugins) {
381
+ const pluginId = `${plugin.name}@${mpName}`;
382
+ const installedVersion = installedVersions[pluginId];
383
+ const isEnabled = enabledPlugins[pluginId] === true;
384
+ const scopeStatus = scopeStatusFor(pluginId);
385
+
386
+ seenPluginIds.add(pluginId);
387
+ plugins.push({
388
+ id: pluginId,
389
+ name: plugin.name,
390
+ version: plugin.version,
391
+ description: plugin.description,
392
+ marketplace: mpName,
393
+ marketplaceDisplay: marketplace.displayName,
394
+ enabled: isEnabled,
395
+ installedVersion: installedVersion,
396
+ hasUpdate: isBehind(plugin.version, installedVersion) === true,
397
+ catalogSource: resolution.source,
398
+ updateCheckFailed: unverified,
399
+ updateCheckFailure: resolution.failure,
400
+ ...scopeStatus,
401
+ category: plugin.category,
402
+ author: plugin.author,
403
+ homepage: plugin.homepage,
404
+ tags: plugin.tags,
405
+ releases: plugin.releases,
406
+ source: plugin.source,
407
+ sourceRepo: plugin.sourceRepo,
408
+ });
409
+ }
410
+ }
411
+
412
+ for (const [mpName, localMp] of localMarketplaces) {
413
+ // Skip if already fetched from defaults
414
+ if (marketplaceNames.has(mpName)) continue;
415
+
416
+ // Add ALL plugins from this local marketplace cache
417
+ for (const localPlugin of localMp.plugins) {
418
+ const pluginId = `${localPlugin.name}@${mpName}`;
419
+ if (seenPluginIds.has(pluginId)) continue;
420
+
421
+ const installedVersion = installedVersions[pluginId];
422
+ const isEnabled = enabledPlugins[pluginId] === true;
423
+ const scopeStatus = scopeStatusFor(pluginId);
424
+
425
+ seenPluginIds.add(pluginId);
426
+ plugins.push({
427
+ id: pluginId,
428
+ name: localPlugin.name,
429
+ version: localPlugin.version,
430
+ description: localPlugin.description || "",
431
+ marketplace: mpName,
432
+ marketplaceDisplay: localMp.name || formatMarketplaceName(mpName),
433
+ enabled: isEnabled,
434
+ installedVersion: installedVersion,
435
+ hasUpdate: isBehind(localPlugin.version, installedVersion) === true,
436
+ ...scopeStatus,
437
+ category: localPlugin.category,
438
+ author: localPlugin.author,
439
+ agents: localPlugin.agents,
440
+ commands: localPlugin.commands,
441
+ skills: localPlugin.skills,
442
+ source: localPlugin.source,
443
+ mcpServers: localPlugin.mcpServers,
444
+ lspServers: localPlugin.lspServers,
445
+ releases: localPlugin.releases,
446
+ });
447
+ }
448
+ }
449
+
450
+ // Add orphaned plugins (enabled/installed but not in any cache)
451
+ const allPluginIds = new Set([
452
+ ...Object.keys(enabledPlugins),
453
+ ...Object.keys(installedVersions),
454
+ ]);
455
+
456
+ for (const pluginId of allPluginIds) {
457
+ if (seenPluginIds.has(pluginId)) continue;
458
+
459
+ const parsed = parsePluginId(pluginId);
460
+ if (!parsed) continue;
461
+
462
+ const { pluginName, marketplace: mpName } = parsed;
463
+ const installedVersion = installedVersions[pluginId];
464
+ const isEnabled = enabledPlugins[pluginId] === true;
465
+ const scopeStatus = scopeStatusFor(pluginId);
466
+
467
+ // Try to get plugin info from local marketplace cache (fallback)
468
+ const localMp = localMarketplaces.get(mpName);
469
+ const localPlugin = localMp?.plugins.find((p) => p.name === pluginName);
470
+ const movedTo = localPlugin
471
+ ? undefined
472
+ : findPluginInOtherMarketplace(pluginName, mpName, localMarketplaces);
473
+
474
+ const latestVersion = localPlugin?.version || installedVersion || "unknown";
475
+ const description = localPlugin?.description || "Installed plugin";
476
+ const hasUpdate = isBehind(localPlugin?.version, installedVersion) === true;
477
+
478
+ plugins.push({
479
+ id: pluginId,
480
+ name: pluginName,
481
+ version: latestVersion,
482
+ description,
483
+ marketplace: mpName,
484
+ marketplaceDisplay: localMp?.name || formatMarketplaceName(mpName),
485
+ enabled: isEnabled,
486
+ installedVersion: installedVersion,
487
+ hasUpdate,
488
+ isOrphaned: true,
489
+ movedTo,
490
+ ...scopeStatus,
491
+ });
492
+ }
493
+
494
+ await annotateContentDrift(plugins, "project", projectPath);
495
+ return plugins;
496
+ }
497
+
498
+ /**
499
+ * Find another configured marketplace that still publishes this plugin name.
500
+ *
501
+ * Answers "did it disappear, or did it move?" for an orphaned plugin, so the
502
+ * UI can offer migration rather than only deletion.
503
+ */
504
+ function findPluginInOtherMarketplace(
505
+ pluginName: string,
506
+ currentMarketplace: string,
507
+ localMarketplaces: Map<string, LocalMarketplace>,
508
+ ): string | undefined {
509
+ for (const [name, mp] of localMarketplaces) {
510
+ if (name === currentMarketplace) continue;
511
+ if (mp.plugins.some((p) => p.name === pluginName)) return name;
512
+ }
513
+ return undefined;
514
+ }
515
+
516
+ /**
517
+ * Is this plugin one whose files could have moved while its version did not?
518
+ *
519
+ * Drift is defined as "same version, different files", so it is only a
520
+ * question at all when the catalog version and the installed version are
521
+ * PROVABLY the same — `compareVersions` returning exactly 0, never null, which
522
+ * would be "cannot tell". A pending version bump is not drift: `hasContentDrift`
523
+ * compares the installed commit's subtree against the marketplace clone's HEAD,
524
+ * and for a plugin that is simply behind, that subtree differs BECAUSE of the
525
+ * newer version. Flagging it would turn an `update` into a `repair`, and a
526
+ * repair reinstalls whatever the clone holds — past a pin.
527
+ *
528
+ * This used to open with `if (plugin.hasUpdate || …)`, the pre-classifier
529
+ * boolean. It decided nothing the version compare below does not (`isBehind`
530
+ * cannot be true for a pair that compares 0), and gating the input the shared
531
+ * classifier reads on the rule it replaced is how the two drift apart again.
532
+ * An orphan is still excluded: it has no marketplace entry to diff against.
533
+ *
534
+ * Exported to be tested directly; `annotateContentDrift` itself needs the real
535
+ * registry.
536
+ */
537
+ export function contentDriftCandidate(plugin: PluginInfo): boolean {
538
+ if (plugin.isOrphaned) return false;
539
+ if (!plugin.installedVersion || !plugin.version) return false;
540
+ return compareVersions(plugin.version, plugin.installedVersion) === 0;
541
+ }
542
+
543
+ /**
544
+ * Flag plugins whose files moved without their version moving.
545
+ *
546
+ * Runs after the plugin list is assembled so every code path that builds a
547
+ * PluginInfo gets the same treatment. Which plugins are worth asking about is
548
+ * {@link contentDriftCandidate}.
549
+ *
550
+ * Best-effort throughout: a plugin whose drift cannot be determined is left
551
+ * exactly as it is today.
552
+ */
553
+ async function annotateContentDrift(
554
+ plugins: PluginInfo[],
555
+ scope: "user" | "project" | "local",
556
+ projectPath?: string,
557
+ ): Promise<void> {
558
+ let registry: InstalledPluginsRegistry;
559
+ try {
560
+ registry = await readInstalledPluginsRegistry();
561
+ } catch {
562
+ return;
563
+ }
564
+
565
+ // Must match how the version itself was resolved. Without this a worktree
566
+ // inherits an installed version but never its `gitCommitSha`, so drift
567
+ // detection silently switches off for exactly the plugins that just started
568
+ // reporting as installed.
569
+ const target = scope === "user" ? undefined : (projectPath ?? process.cwd());
570
+ const fallbackPaths = target ? await inheritablePaths(target) : [];
571
+
572
+ await Promise.all(
573
+ plugins.map(async (plugin) => {
574
+ if (!contentDriftCandidate(plugin)) return;
575
+
576
+ const entry = pickRegistryEntry(
577
+ registry.plugins[plugin.id],
578
+ scope,
579
+ target,
580
+ fallbackPaths,
581
+ );
582
+ if (!entry?.gitCommitSha) return;
583
+
584
+ try {
585
+ plugin.contentStale = await hasContentDrift({
586
+ marketplace: plugin.marketplace,
587
+ pluginName: plugin.name,
588
+ installedSha: entry.gitCommitSha,
589
+ installedVersion: plugin.installedVersion,
590
+ });
591
+ } catch {
592
+ /* leave unflagged */
593
+ }
594
+ }),
595
+ );
596
+ }
597
+
598
+ /** A dotted run of digits, optionally `v`-prefixed. Anything else is opaque. */
599
+ const SEMVER_ISH = /^\d+(\.\d+)*$/;
600
+
601
+ const normalize = (v: string) => v.replace(/^v/, "");
602
+
603
+ /**
604
+ * Version comparison: 1 if a > b, -1 if a < b, 0 if equal, **null if the pair
605
+ * carries no ordering at all**.
606
+ *
607
+ * `null` is the whole point. This used to return 0 for every unorderable pair,
608
+ * and 0 means "equal" to every caller — so "I cannot tell" was indistinguishable
609
+ * from "they match". Measured against real registry values before the fix:
610
+ *
611
+ * compareVersions("517b2fcd1b60", "b819188d2eea") = 0 ← two DIFFERENT commits
612
+ * compareVersions("7.2.0", "517b2fcd1b60") = 1 ← a fabricated ordering
613
+ *
614
+ * Anthropic's official plugins record a commit sha as their version, so the
615
+ * first line made every one of them permanently un-updatable, and the second
616
+ * invented an update whenever a sha met a semver.
617
+ *
618
+ * Three cases, and only the first has an answer:
619
+ * - both dotted-numeric → compare numerically, shorter side zero-padded
620
+ * - both opaque → equal strings are 0; different strings are `null`,
621
+ * because two commit ids carry no order between them
622
+ * - one of each → `null`; there is no shared scale
623
+ *
624
+ * A missing side is `null` too. It used to be 0, which claimed an absent
625
+ * version matched a present one.
626
+ *
627
+ * Callers asking "should this be updated?" want {@link isBehind}, not this.
628
+ */
629
+ export function compareVersions(
630
+ a: string | null | undefined,
631
+ b: string | null | undefined,
632
+ ): number | null {
633
+ if (!a || !b) return null;
634
+ const A = normalize(a);
635
+ const B = normalize(b);
636
+
637
+ const aNumeric = SEMVER_ISH.test(A);
638
+ const bNumeric = SEMVER_ISH.test(B);
639
+
640
+ if (aNumeric !== bNumeric) return null; // no shared scale
641
+ if (!aNumeric) return A === B ? 0 : null; // two opaque ids: equal, or unordered
642
+
643
+ const partsA = A.split(".").map(Number);
644
+ const partsB = B.split(".").map(Number);
645
+ for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
646
+ const numA = partsA[i] ?? 0;
647
+ const numB = partsB[i] ?? 0;
648
+ if (numA > numB) return 1;
649
+ if (numA < numB) return -1;
650
+ }
651
+ return 0;
652
+ }
653
+
654
+ /**
655
+ * Is `installed` out of date against what the catalog publishes as `latest`?
656
+ *
657
+ * `true` / `false` / `null`, where **null means unknown** — never a silent
658
+ * `false`. This is the question every update path actually asks, and keeping it
659
+ * separate from {@link compareVersions} is what lets the two opaque-id cases
660
+ * differ:
661
+ *
662
+ * - Ordering two commit shas is impossible, so `compareVersions` says `null`.
663
+ * - But the catalog is authoritative for what is published *now*, so an
664
+ * installed sha that differs from it is **not current** — `isBehind` says
665
+ * `true`. That is what makes a sha-versioned plugin updatable at all.
666
+ *
667
+ * Not installed is `null`, not `false`. The old inline check was
668
+ * `installedVersion && compare(...) > 0`, so a plugin with no install at all
669
+ * evaluated to `false` and was reported — and counted — as up to date.
670
+ */
671
+ export function isBehind(
672
+ latest: string | null | undefined,
673
+ installed: string | null | undefined,
674
+ ): boolean | null {
675
+ if (!latest || !installed) return null;
676
+ const cmp = compareVersions(latest, installed);
677
+ if (cmp !== null) return cmp > 0;
678
+
679
+ // Unorderable. Two opaque ids that differ still mean "not what is published".
680
+ const bothOpaque =
681
+ !SEMVER_ISH.test(normalize(latest)) &&
682
+ !SEMVER_ISH.test(normalize(installed));
683
+ return bothOpaque ? true : null;
684
+ }
685
+
686
+ // Get installed plugin versions from settings.json (shared, not secret)
687
+ async function getInstalledPluginVersions(
688
+ projectPath?: string,
689
+ ): Promise<Record<string, string>> {
690
+ const settings = await readSettings(projectPath);
691
+ // Settings alone under-report: Claude Code's own `plugin install` updates the
692
+ // registry without touching our settings key, so overlay the registry.
693
+ return getProjectInstalledPluginVersions(
694
+ settings.installedPluginVersions || {},
695
+ projectPath,
696
+ );
697
+ }
698
+
699
+ // Save installed plugin version to settings.json
700
+ export async function saveInstalledPluginVersion(
701
+ pluginId: string,
702
+ version: string,
703
+ projectPath?: string,
704
+ ): Promise<void> {
705
+ const settings = await readSettings(projectPath);
706
+ settings.installedPluginVersions = settings.installedPluginVersions || {};
707
+ settings.installedPluginVersions[pluginId] = version;
708
+ await writeSettings(settings, projectPath);
709
+
710
+ // Update installed_plugins.json registry
711
+ await updateInstalledPluginsRegistry(
712
+ pluginId,
713
+ version,
714
+ "project",
715
+ projectPath ? path.resolve(projectPath) : undefined,
716
+ );
717
+ }
718
+
719
+ /**
720
+ * The version Claude Code's own registry records for a plugin at a scope, read
721
+ * fresh from disk.
722
+ *
723
+ * Exists because `claude plugin install` takes NO version argument — it installs
724
+ * whatever the marketplace currently publishes. The version we asked for and the
725
+ * version we got are therefore different questions, and only this one can answer
726
+ * the second. Callers must record what this returns, never what they intended:
727
+ * writing the intent fabricates state that the drift gate then trusts.
728
+ */
729
+ export async function readInstalledVersionForScope(
730
+ pluginId: string,
731
+ scope: PluginScope,
732
+ projectPath: string = process.cwd(),
733
+ ): Promise<string | null> {
734
+ const registry = await readInstalledPluginsRegistry();
735
+ const entry = pickRegistryEntry(
736
+ registry.plugins?.[pluginId],
737
+ scope,
738
+ projectPath,
739
+ );
740
+ return entry?.version ?? null;
741
+ }
742
+
743
+ /**
744
+ * Record an installed version in the settings file that owns the given scope.
745
+ *
746
+ * Claude Code's CLI does not maintain `installedPluginVersions`, so magus
747
+ * keeps its own copy. Writing it at the WRONG scope is worse than not writing
748
+ * it: it makes a scope claim a version it does not have installed. Writing the
749
+ * wrong VERSION is worse still — see {@link readInstalledVersionForScope}.
750
+ */
751
+ export async function saveInstalledPluginVersionForScope(
752
+ pluginId: string,
753
+ version: string,
754
+ scope: PluginScope,
755
+ projectPath: string = process.cwd(),
756
+ ): Promise<void> {
757
+ if (scope === "user") {
758
+ await saveGlobalInstalledPluginVersion(pluginId, version);
759
+ } else if (scope === "local") {
760
+ await saveLocalInstalledPluginVersion(pluginId, version, projectPath);
761
+ } else {
762
+ await saveInstalledPluginVersion(pluginId, version, projectPath);
763
+ }
764
+ }
765
+
766
+ // Remove installed plugin version from settings.json
767
+ export async function removeInstalledPluginVersion(
768
+ pluginId: string,
769
+ projectPath?: string,
770
+ ): Promise<void> {
771
+ const settings = await readSettings(projectPath);
772
+ if (settings.installedPluginVersions) {
773
+ delete settings.installedPluginVersions[pluginId];
774
+ }
775
+ if (settings.enabledPlugins) {
776
+ delete settings.enabledPlugins[pluginId];
777
+ }
778
+ await writeSettings(settings, projectPath);
779
+
780
+ // Remove from installed_plugins.json registry
781
+ await removeFromInstalledPluginsRegistry(
782
+ pluginId,
783
+ "project",
784
+ projectPath ? path.resolve(projectPath) : undefined,
785
+ );
786
+ }
787
+
788
+ // Clear marketplace cache
789
+ export function clearMarketplaceCache(): void {
790
+ clearFetcherCache();
791
+ localMarketplacesPromise = null;
792
+ }
793
+
794
+ // Get local marketplaces (with Promise-based caching to prevent race conditions)
795
+ async function getLocalMarketplaces(): Promise<Map<string, LocalMarketplace>> {
796
+ if (localMarketplacesPromise === null) {
797
+ localMarketplacesPromise = scanLocalMarketplaces();
798
+ }
799
+ return localMarketplacesPromise;
800
+ }
801
+
802
+ // Export local marketplaces for use in other modules
803
+ export async function getLocalMarketplacesInfo(): Promise<
804
+ Map<string, LocalMarketplace>
805
+ > {
806
+ return getLocalMarketplaces();
807
+ }
808
+
809
+ // Track which marketplaces have already been auto-synced this session
810
+ // so we only attempt the update once per marketplace per magus run.
811
+ const autoSyncedMarketplaces = new Set<string>();
812
+
813
+ /**
814
+ * If the remote manifest lists plugins that aren't in the local cache,
815
+ * the local clone is stale. Log a warning but do NOT trigger a marketplace
816
+ * update from magus.
817
+ *
818
+ * Previously this called `updateMarketplace()` which invokes Claude Code's
819
+ * `cacheMarketplaceFromGit()` — a non-atomic delete-then-clone that can
820
+ * permanently delete the marketplace directory on clone failure.
821
+ * Claude Code's own background autoupdate handles marketplace refreshing
822
+ * after session start. See: ai-docs/plugin-marketplace-bug-investigation.md
823
+ */
824
+ async function autoSyncIfStale(
825
+ mpName: string,
826
+ remotePluginNames: string[],
827
+ localMarketplaces: Map<string, LocalMarketplace>,
828
+ ): Promise<Map<string, LocalMarketplace>> {
829
+ if (autoSyncedMarketplaces.has(mpName)) return localMarketplaces;
830
+
831
+ const localMp = localMarketplaces.get(mpName);
832
+ if (!localMp) return localMarketplaces;
833
+
834
+ const localNames = new Set(localMp.plugins.map((p) => p.name));
835
+ const missingPlugins = remotePluginNames.filter(
836
+ (name) => !localNames.has(name),
837
+ );
838
+ if (missingPlugins.length === 0) return localMarketplaces;
839
+
840
+ autoSyncedMarketplaces.add(mpName);
841
+ // Log stale state but don't trigger update — Claude Code handles refresh
842
+ console.log(
843
+ `ℹ Marketplace ${mpName} is stale (missing: ${missingPlugins.join(", ")}). Claude Code will auto-update on next session start.`,
844
+ );
845
+ return localMarketplaces;
846
+ }
847
+
848
+ export interface RefreshAndRepairResult {
849
+ refresh: never[];
850
+ repair: RepairMarketplaceResult[];
851
+ }
852
+
853
+ /**
854
+ * Refresh magus's internal cache
855
+ * Note: Marketplace updates should be done via Claude Code's /plugin marketplace update command
856
+ */
857
+ export async function refreshAllMarketplaces(
858
+ onProgress?: ProgressCallback,
859
+ ): Promise<RefreshAndRepairResult> {
860
+ onProgress?.({ current: 1, total: 1, name: "Clearing cache..." });
861
+
862
+ // Clear all caches to force fresh data. The on-disk catalog cache is awaited
863
+ // rather than fired and forgotten, so the refetch that follows cannot read the
864
+ // entries this is discarding.
865
+ clearMarketplaceCache();
866
+ await clearPersistedCatalogs();
867
+
868
+ // Auto-repair plugin.json files with missing agents/commands/skills
869
+ const repairResults = await repairAllMarketplaces();
870
+
871
+ return {
872
+ refresh: [],
873
+ repair: repairResults,
874
+ };
875
+ }
876
+
877
+ // Re-export types for consumers
878
+ export type { ProgressCallback };