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