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,1423 @@
1
+ import type React from "react";
2
+ import { useCallback, useEffect, useMemo, useState } from "react";
3
+ import {
4
+ defaultMarketplaces,
5
+ getAllMarketplaces,
6
+ } from "../../data/marketplaces.js";
7
+ import { loadActiveClosure } from "../../services/active-closure.js";
8
+ import {
9
+ type PluginScope,
10
+ addMarketplace as cliAddMarketplace,
11
+ repairPlugin as cliRepairPlugin,
12
+ uninstallPlugin as cliUninstallPlugin,
13
+ } from "../../services/claude-cli.js";
14
+ import {
15
+ getMcpEnvVars,
16
+ readSettings,
17
+ setMcpEnvVar,
18
+ } from "../../services/claude-settings.js";
19
+ import { clearContentDriftCache } from "../../services/content-drift.js";
20
+ import {
21
+ type MarketplaceFetchFailure,
22
+ type PluginInfo,
23
+ type ScopeStatus,
24
+ clearMarketplaceCache,
25
+ getAvailablePlugins,
26
+ getLocalMarketplacesInfo,
27
+ getMarketplaceFetchFailures,
28
+ isInstalledInScope,
29
+ refreshAllMarketplaces,
30
+ resolveScopeAction,
31
+ } from "../../services/plugin-manager.js";
32
+ import {
33
+ getPluginEnvRequirements,
34
+ getPluginSourcePath,
35
+ } from "../../services/plugin-mcp-config.js";
36
+ import {
37
+ checkMissingDeps,
38
+ getPluginSetupFromSource,
39
+ installPluginDeps,
40
+ } from "../../services/plugin-setup.js";
41
+ import { saveProfile } from "../../services/profiles.js";
42
+ import {
43
+ fetchPluginContents,
44
+ fetchPluginSkillDetail,
45
+ } from "../../services/skills-manager.js";
46
+ // The one update implementation, shared with `magus update`. See the header
47
+ // of update-engine.ts for what drifted while the TUI had its own loop.
48
+ import {
49
+ type ApplyReporter,
50
+ REAL_PLUGIN_DEPS,
51
+ applyPlugins,
52
+ installPluginInScope,
53
+ pluginNeedsWork,
54
+ updatePluginInScope,
55
+ } from "../../services/update-engine.js";
56
+ // The ONE plugin classifier, shared with `magus update`. Every state this
57
+ // screen shows or acts on — row text, detail panel, header counts, U / a, the
58
+ // scope picker — reads the same map it builds.
59
+ import {
60
+ type PluginUpdateItem,
61
+ classifyPluginRows,
62
+ countPluginStates,
63
+ scopeIsBehind,
64
+ } from "../../services/update-plan.js";
65
+ import {
66
+ diffVersions,
67
+ loadSeenVersions,
68
+ saveSeenVersions,
69
+ } from "../../services/version-snapshot.js";
70
+ import { fuzzyFilter } from "../../utils/fuzzy-search.js";
71
+ import { buildCatalogNoticeText } from "../adapters/catalogNotice.js";
72
+ import {
73
+ type PluginBrowserItem,
74
+ type PluginComponentItem,
75
+ type PluginPluginItem,
76
+ buildPluginBrowserItems,
77
+ noMoveReasons,
78
+ pluginContentsRepo,
79
+ } from "../adapters/pluginsAdapter.js";
80
+ import { EmptyFilterState } from "../components/EmptyFilterState.js";
81
+ import { ScrollableList } from "../components/ScrollableList.js";
82
+ import { ScreenLayout } from "../components/layout/index.js";
83
+ import { useKeyboard } from "../hooks/useKeyboard.js";
84
+ import {
85
+ renderPluginDetail,
86
+ renderPluginRow,
87
+ } from "../renderers/pluginRenderers.js";
88
+ import { useApp, useModal, useProgress } from "../state/AppContext.js";
89
+ import { useDimensions } from "../state/DimensionsContext.js";
90
+ import { asyncValue } from "../state/types.js";
91
+ import { theme } from "../theme.js";
92
+
93
+ /** Wraps the notice text in the warning-coloured row ScreenLayout renders. */
94
+ function buildCatalogNotice(
95
+ input: Parameters<typeof buildCatalogNoticeText>[0],
96
+ ): React.ReactNode {
97
+ const text = buildCatalogNoticeText(input);
98
+ if (!text) return null;
99
+ return <text fg={theme.colors.warning}>{text}</text>;
100
+ }
101
+
102
+ export function PluginsScreen() {
103
+ const { state, dispatch } = useApp();
104
+ const { plugins: pluginsState } = state;
105
+ const modal = useModal();
106
+ const progress = useProgress();
107
+ const dimensions = useDimensions();
108
+
109
+ const isSearchActive =
110
+ state.isSearching &&
111
+ state.currentRoute.screen === "plugins" &&
112
+ !state.modal;
113
+
114
+ /**
115
+ * Catalogs that could not be read from any authoritative source.
116
+ *
117
+ * Held in state rather than derived at render time because it is an answer
118
+ * from the last fetch and must survive until the next one — a failure the user
119
+ * cannot see is the bug this screen shipped with.
120
+ */
121
+ const [catalogFailures, setCatalogFailures] = useState<
122
+ MarketplaceFetchFailure[]
123
+ >([]);
124
+
125
+ /** Re-render tick so the retry countdown in the notice actually counts down. */
126
+ const [nowTick, setNowTick] = useState(() => Date.now());
127
+
128
+ /**
129
+ * The active profile's pins — the same values `magus update` plans with.
130
+ *
131
+ * `undefined` until the first fetch answers; `null` when no profile could be
132
+ * resolved (no manifest, an invalid one, or several with none active). In
133
+ * that case every row is judged against "latest" and the notice says so.
134
+ */
135
+ const [pins, setPins] = useState<
136
+ Readonly<Record<string, string>> | null | undefined
137
+ >(undefined);
138
+
139
+ // Fetch data (always fetches all scopes)
140
+ const fetchData = useCallback(async () => {
141
+ dispatch({ type: "PLUGINS_DATA_LOADING" });
142
+ try {
143
+ const localMarketplaces = await getLocalMarketplacesInfo();
144
+ const allMarketplaces = getAllMarketplaces(localMarketplaces);
145
+ // Read-only, never throws. `resolveBins: false`: this screen needs only
146
+ // the plugin pins, not every declared plugin's binary requirements.
147
+ const [pluginData, active] = await Promise.all([
148
+ getAvailablePlugins(state.projectPath),
149
+ loadActiveClosure(state.projectPath || process.cwd(), {
150
+ resolveBins: false,
151
+ }),
152
+ ]);
153
+ setPins(active?.closure.plugins ?? null);
154
+
155
+ // Which catalogs answered, and which only appeared to. Read straight
156
+ // after the fetch, while the record still describes this attempt.
157
+ //
158
+ // Only failures that survived every authoritative source are worth
159
+ // reporting: a rate-limited HTTP fetch that the git remote ref then
160
+ // answered is a non-event for the user, and reporting it would fire a
161
+ // warning about intended, working behaviour.
162
+ const unresolved = new Set(
163
+ pluginData.filter((p) => p.updateCheckFailed).map((p) => p.marketplace),
164
+ );
165
+ setCatalogFailures(
166
+ getMarketplaceFetchFailures().filter((f) =>
167
+ unresolved.has(f.marketplace),
168
+ ),
169
+ );
170
+
171
+ // Surface version changes made outside magus (Claude Code's own
172
+ // plugin update, a manual CLI call). Diffing against
173
+ // what we last rendered catches all of them; logging only what
174
+ // magus did would miss every one.
175
+ const current: Record<string, string> = {};
176
+ for (const p of pluginData) {
177
+ if (p.installedVersion) current[p.id] = p.installedVersion;
178
+ }
179
+ const projectPath = state.projectPath || process.cwd();
180
+ const previous = await loadSeenVersions(projectPath);
181
+ for (const change of diffVersions(previous, current)) {
182
+ const target = pluginData.find((p) => p.id === change.pluginId);
183
+ if (!target) continue;
184
+ if (change.kind === "updated") {
185
+ target.recentlyUpdatedFrom = change.from;
186
+ } else {
187
+ target.recentlyInstalled = true;
188
+ }
189
+ }
190
+ // Await the write. Fire-and-forget raced the next fetchData() — a
191
+ // refresh or a post-action refetch could load the baseline before the
192
+ // previous save landed and re-report the same change.
193
+ await saveSeenVersions(projectPath, current);
194
+
195
+ dispatch({
196
+ type: "PLUGINS_DATA_SUCCESS",
197
+ marketplaces: allMarketplaces,
198
+ plugins: pluginData,
199
+ });
200
+ } catch (error) {
201
+ dispatch({
202
+ type: "PLUGINS_DATA_ERROR",
203
+ error: error instanceof Error ? error : new Error(String(error)),
204
+ });
205
+ }
206
+ }, [dispatch, state.projectPath]);
207
+
208
+ // Load data on mount or when dataRefreshVersion changes
209
+ // biome-ignore lint/correctness/useExhaustiveDependencies: dataRefreshVersion is the external refresh trigger; bumping it must re-run the fetch even though the body never reads it
210
+ useEffect(() => {
211
+ fetchData();
212
+ }, [fetchData, state.dataRefreshVersion]);
213
+
214
+ /**
215
+ * Tick the countdown, and refetch by itself once the rate-limit window reopens.
216
+ *
217
+ * The user asked "when can I try again" — so the screen answers it and then
218
+ * acts on it. Leaving them to press `r` and guess is what makes a rate limit
219
+ * feel like a dead end, and a mistimed guess spends another request against the
220
+ * limit for nothing.
221
+ *
222
+ * Only armed while a retry is actually pending, so an untroubled screen runs no
223
+ * timer at all.
224
+ */
225
+ const nextRetryAt = useMemo(() => {
226
+ const times = catalogFailures
227
+ .map((f) => f.retryAt)
228
+ .filter((t): t is number => typeof t === "number");
229
+ return times.length > 0 ? Math.min(...times) : undefined;
230
+ }, [catalogFailures]);
231
+
232
+ useEffect(() => {
233
+ if (nextRetryAt === undefined) return;
234
+
235
+ const tick = setInterval(() => {
236
+ const now = Date.now();
237
+ setNowTick(now);
238
+ if (now >= nextRetryAt) {
239
+ clearInterval(tick);
240
+ // The cooldown has expired, so the fetcher will really try again
241
+ // rather than short-circuit on its own negative cache.
242
+ clearMarketplaceCache();
243
+ fetchData();
244
+ }
245
+ }, 1000);
246
+
247
+ return () => clearInterval(tick);
248
+ }, [nextRetryAt, fetchData]);
249
+
250
+ // The data to render: fresh when loaded, else the previous list while a
251
+ // reload is in flight. Reading `status === "success"` alone is what made a
252
+ // tab switch blank the screen — the router remounts this component, which
253
+ // refetches, and every intermediate frame had nothing to show.
254
+ const shownMarketplaces = asyncValue(pluginsState.marketplaces);
255
+ const shownPlugins = asyncValue(pluginsState.plugins);
256
+ const isReloading =
257
+ pluginsState.plugins.status === "loading" && shownPlugins !== undefined;
258
+
259
+ /**
260
+ * One status per plugin that has a pin, computed ONCE per data/pins change.
261
+ * The rows, the detail panel, the header counts, U / a and the scope picker
262
+ * all read this map, so none of them can disagree with another — or with
263
+ * `magus update`, which runs the same classifier over the same pins.
264
+ */
265
+ const statuses = useMemo(
266
+ (): Map<string, PluginUpdateItem> =>
267
+ shownPlugins ? classifyPluginRows(shownPlugins, pins ?? {}) : new Map(),
268
+ [shownPlugins, pins],
269
+ );
270
+
271
+ // Build list items (categories + plugins)
272
+ const allItems = useMemo((): PluginBrowserItem[] => {
273
+ if (!shownMarketplaces || !shownPlugins) return [];
274
+ return buildPluginBrowserItems({
275
+ marketplaces: shownMarketplaces,
276
+ plugins: shownPlugins,
277
+ collapsedMarketplaces: pluginsState.collapsedMarketplaces,
278
+ expandedPlugins: pluginsState.expandedPlugins,
279
+ pluginContents: pluginsState.pluginContents,
280
+ statuses,
281
+ });
282
+ }, [
283
+ shownMarketplaces,
284
+ shownPlugins,
285
+ pluginsState.collapsedMarketplaces,
286
+ pluginsState.expandedPlugins,
287
+ pluginsState.pluginContents,
288
+ statuses,
289
+ ]);
290
+
291
+ // Filter items by search query
292
+ const filteredItems = useMemo(() => {
293
+ const query = pluginsState.searchQuery.trim();
294
+ if (!query) return allItems;
295
+
296
+ // Only search plugins, not categories
297
+ const pluginItems = allItems.filter((item) => item.kind === "plugin");
298
+ const fuzzyResults = fuzzyFilter(pluginItems, query, (item) => item.label);
299
+
300
+ const matchedPluginIds = new Set<string>();
301
+ for (const result of fuzzyResults) {
302
+ matchedPluginIds.add(result.item.id);
303
+ }
304
+
305
+ // Walk allItems sequentially: include a category only if any plugin below matched
306
+ const categoryHasMatch = new Map<string, boolean>();
307
+ let currentCategoryId: string | null = null;
308
+ for (const item of allItems) {
309
+ if (item.kind === "category") {
310
+ currentCategoryId = item.id;
311
+ if (!categoryHasMatch.has(item.id)) {
312
+ categoryHasMatch.set(item.id, false);
313
+ }
314
+ } else if (item.kind === "plugin" && currentCategoryId) {
315
+ if (matchedPluginIds.has(item.id)) {
316
+ categoryHasMatch.set(currentCategoryId, true);
317
+ }
318
+ }
319
+ }
320
+
321
+ const result: PluginBrowserItem[] = [];
322
+ let currentCatIncluded = false;
323
+ currentCategoryId = null;
324
+
325
+ for (const item of allItems) {
326
+ if (item.kind === "category") {
327
+ currentCategoryId = item.id;
328
+ currentCatIncluded = categoryHasMatch.get(item.id) === true;
329
+ if (currentCatIncluded) result.push(item);
330
+ } else if (item.kind === "plugin" && currentCatIncluded) {
331
+ if (matchedPluginIds.has(item.id)) {
332
+ const matched = fuzzyResults.find((r) => r.item.id === item.id);
333
+ // Skill rows are not in the filtered result, so an expanded arrow
334
+ // here would point at nothing.
335
+ result.push({
336
+ ...item,
337
+ matches: matched?.matches,
338
+ isExpanded: false,
339
+ });
340
+ }
341
+ }
342
+ }
343
+
344
+ return result;
345
+ }, [allItems, pluginsState.searchQuery]);
346
+
347
+ const selectableItems = useMemo(() => filteredItems, [filteredItems]);
348
+
349
+ /**
350
+ * Read the file behind whichever component row the cursor has settled on.
351
+ *
352
+ * Debounced, and that is not cosmetic: each read is one GitHub API call, an
353
+ * unauthenticated client gets 60 an hour, and holding ↓ through the 286
354
+ * skills of a large plugin would otherwise fire a call per keypress and
355
+ * exhaust the budget for every other marketplace on screen. 200ms is below
356
+ * the point a deliberate move feels laggy and above key-repeat.
357
+ *
358
+ * MCP servers are skipped: their whole definition is the JSON already read
359
+ * during the contents listing, so there is no document to fetch.
360
+ */
361
+ const selectedRow = selectableItems[pluginsState.selectedIndex];
362
+ const selectedComponent =
363
+ selectedRow?.kind === "component" && selectedRow.component.kind !== "mcp"
364
+ ? (selectedRow as PluginComponentItem)
365
+ : undefined;
366
+ const detailKey = selectedComponent?.detailKey;
367
+ const detailRepo = selectedComponent?.repo;
368
+ const detailPath = selectedComponent?.component.repoPath;
369
+ const haveDetail = detailKey
370
+ ? pluginsState.skillDetails.has(detailKey)
371
+ : true;
372
+
373
+ useEffect(() => {
374
+ if (!detailKey || !detailRepo || !detailPath) return;
375
+ if (haveDetail) return;
376
+
377
+ const timer = setTimeout(() => {
378
+ dispatch({ type: "PLUGINS_SKILL_DETAIL_LOADING", key: detailKey });
379
+ fetchPluginSkillDetail(detailRepo, detailPath)
380
+ .then((detail) =>
381
+ dispatch({
382
+ type: "PLUGINS_SKILL_DETAIL_SUCCESS",
383
+ key: detailKey,
384
+ detail,
385
+ }),
386
+ )
387
+ .catch((error: unknown) =>
388
+ dispatch({
389
+ type: "PLUGINS_SKILL_DETAIL_ERROR",
390
+ key: detailKey,
391
+ error: error instanceof Error ? error.message : String(error),
392
+ }),
393
+ );
394
+ }, 200);
395
+
396
+ return () => clearTimeout(timer);
397
+ }, [dispatch, haveDetail, detailKey, detailPath, detailRepo]);
398
+
399
+ /**
400
+ * Open or close a plugin's contents, fetching them the first time.
401
+ *
402
+ * The listing is kept per plugin id for the life of the session rather than
403
+ * refetched on each open: it costs a GitHub Tree API call, and an
404
+ * unauthenticated client gets 60 an hour across every marketplace on screen.
405
+ */
406
+ const togglePluginContents = useCallback(
407
+ (item: PluginPluginItem) => {
408
+ const pluginId = item.plugin.id;
409
+ dispatch({ type: "PLUGINS_TOGGLE_PLUGIN_CONTENTS", pluginId });
410
+
411
+ if (item.isExpanded) return;
412
+ // A previous *success* is kept for the session. A previous failure is
413
+ // not: the usual cause is the GitHub hourly limit, which clears on its
414
+ // own, and caching the failure made a temporary condition look permanent
415
+ // with no way back except reloading the whole screen.
416
+ if (pluginsState.pluginContents.get(pluginId)?.status === "success") {
417
+ return;
418
+ }
419
+
420
+ const marketplace = shownMarketplaces?.find(
421
+ (m) => m.name === item.plugin.marketplace,
422
+ );
423
+ const repo = marketplace
424
+ ? pluginContentsRepo(item.plugin, marketplace)
425
+ : undefined;
426
+ if (!repo) return;
427
+
428
+ dispatch({ type: "PLUGINS_CONTENTS_LOADING", pluginId });
429
+ fetchPluginContents(repo, item.plugin.source, item.plugin.marketplace)
430
+ .then((components) =>
431
+ dispatch({ type: "PLUGINS_CONTENTS_SUCCESS", pluginId, components }),
432
+ )
433
+ .catch((error: unknown) =>
434
+ dispatch({
435
+ type: "PLUGINS_CONTENTS_ERROR",
436
+ pluginId,
437
+ error: error instanceof Error ? error.message : String(error),
438
+ }),
439
+ );
440
+ },
441
+ [dispatch, pluginsState.pluginContents, shownMarketplaces],
442
+ );
443
+
444
+ // Keyboard handling
445
+ useKeyboard((event) => {
446
+ if (state.modal) return;
447
+
448
+ const hasQuery = pluginsState.searchQuery.length > 0;
449
+
450
+ if (event.name === "escape") {
451
+ if (hasQuery || isSearchActive) {
452
+ dispatch({ type: "PLUGINS_SET_SEARCH", query: "" });
453
+ dispatch({ type: "SET_SEARCHING", isSearching: false });
454
+ dispatch({ type: "PLUGINS_SELECT", index: 0 });
455
+ }
456
+ return;
457
+ }
458
+
459
+ if (event.name === "backspace" || event.name === "delete") {
460
+ if (hasQuery) {
461
+ const newQuery = pluginsState.searchQuery.slice(0, -1);
462
+ dispatch({ type: "PLUGINS_SET_SEARCH", query: newQuery });
463
+ dispatch({ type: "PLUGINS_SELECT", index: 0 });
464
+ if (newQuery.length === 0) {
465
+ dispatch({ type: "SET_SEARCHING", isSearching: false });
466
+ }
467
+ }
468
+ return;
469
+ }
470
+
471
+ if (event.name === "up" || event.name === "k") {
472
+ if (isSearchActive)
473
+ dispatch({ type: "SET_SEARCHING", isSearching: false });
474
+ dispatch({
475
+ type: "PLUGINS_SELECT",
476
+ index: Math.max(0, pluginsState.selectedIndex - 1),
477
+ });
478
+ return;
479
+ }
480
+ if (event.name === "down" || event.name === "j") {
481
+ if (isSearchActive)
482
+ dispatch({ type: "SET_SEARCHING", isSearching: false });
483
+ dispatch({
484
+ type: "PLUGINS_SELECT",
485
+ index: Math.min(
486
+ selectableItems.length - 1,
487
+ pluginsState.selectedIndex + 1,
488
+ ),
489
+ });
490
+ return;
491
+ }
492
+
493
+ if (event.name === "enter" || event.name === "return") {
494
+ if (isSearchActive) {
495
+ dispatch({ type: "SET_SEARCHING", isSearching: false });
496
+ return;
497
+ }
498
+ handleSelect();
499
+ return;
500
+ }
501
+
502
+ if (
503
+ event.name === "left" ||
504
+ event.name === "right" ||
505
+ event.name === "<" ||
506
+ event.name === ">"
507
+ ) {
508
+ const item = selectableItems[pluginsState.selectedIndex];
509
+ if (item?.kind === "category") {
510
+ dispatch({
511
+ type: "PLUGINS_TOGGLE_MARKETPLACE",
512
+ name: item.marketplace.name,
513
+ });
514
+ return;
515
+ }
516
+ if (item?.kind === "plugin" && item.isExpandable) {
517
+ togglePluginContents(item);
518
+ return;
519
+ }
520
+ }
521
+
522
+ if (isSearchActive) {
523
+ if (
524
+ event.name.length === 1 &&
525
+ !event.ctrl &&
526
+ !event.meta &&
527
+ !/[0-9]/.test(event.name)
528
+ ) {
529
+ dispatch({
530
+ type: "PLUGINS_SET_SEARCH",
531
+ query: pluginsState.searchQuery + event.name,
532
+ });
533
+ dispatch({ type: "PLUGINS_SELECT", index: 0 });
534
+ }
535
+ return;
536
+ }
537
+
538
+ if (event.name === "/") {
539
+ dispatch({ type: "SET_SEARCHING", isSearching: true });
540
+ return;
541
+ }
542
+
543
+ if (event.name === "r") handleRefresh();
544
+ else if (event.name === "n") handleShowAddMarketplaceInstructions();
545
+ else if (event.name === "t") handleShowTeamConfigHelp();
546
+ // `name` is ALWAYS the unshifted key — Shift+U parses as
547
+ // {name:"u", shift:true, sequence:"U"}, so `name === "U"` can never be
548
+ // true. It sat below the plain "u" branch, so pressing Shift+U silently
549
+ // installed at user scope instead of updating.
550
+ else if (event.name === "u" && event.shift) handleUpdate();
551
+ else if (event.name === "u") handleScopeToggle("user");
552
+ else if (event.name === "p") handleScopeToggle("project");
553
+ else if (event.name === "l") handleScopeToggle("local");
554
+ else if (event.name === "d") handleRemoveDeprecated();
555
+ else if (event.name === "a") handleUpdateAll();
556
+ else if (event.name === "s") handleSaveAsProfile();
557
+ });
558
+
559
+ // ── Helpers ───────────────────────────────────────────────────────────────
560
+
561
+ // ── Action handlers ────────────────────────────────────────────────────────
562
+
563
+ const handleRefresh = async () => {
564
+ progress.show("Refreshing cache...");
565
+ try {
566
+ const result = await refreshAllMarketplaces((p) => {
567
+ progress.show(`${p.name}`, p.current, p.total);
568
+ });
569
+ clearMarketplaceCache();
570
+ // Drift is answered by diffing against the clone's HEAD, so a refresh
571
+ // that moves HEAD invalidates every cached answer.
572
+ clearContentDriftCache();
573
+ progress.hide();
574
+
575
+ let message =
576
+ "Cache refreshed.\n\n" +
577
+ "To update marketplaces from GitHub, run in Claude Code:\n" +
578
+ " /plugin marketplace update\n\n" +
579
+ "Then refresh magus again with 'r'.";
580
+
581
+ if (result.repair.length > 0) {
582
+ const totalRepaired = result.repair.reduce(
583
+ (sum, r) => sum + r.repaired.length,
584
+ 0,
585
+ );
586
+ message += `\n\nAuto-repaired ${totalRepaired} plugin(s) with missing agents/commands.`;
587
+ }
588
+
589
+ await modal.message("Refreshed", message, "success");
590
+ fetchData();
591
+ } catch (error) {
592
+ progress.hide();
593
+ await modal.message("Error", `Refresh failed: ${error}`, "error");
594
+ }
595
+ };
596
+
597
+ /**
598
+ * Add a marketplace.
599
+ *
600
+ * The known ones are offered as a menu and added here — that covers every
601
+ * marketplace magus ships with, including magus, so a first run does not
602
+ * have to leave the tool. An arbitrary owner/repo still needs the terminal:
603
+ * there is no text-input modal, only confirm/message/select.
604
+ */
605
+ const handleShowAddMarketplaceInstructions = async () => {
606
+ const installed = new Set(
607
+ pluginsState.marketplaces.status === "success"
608
+ ? pluginsState.marketplaces.data.map((m) => m.name)
609
+ : [],
610
+ );
611
+ const addable = defaultMarketplaces.filter(
612
+ (m) => !installed.has(m.name) && !m.deprecated && m.source.repo,
613
+ );
614
+
615
+ if (addable.length > 0) {
616
+ const choice = await modal.select(
617
+ "Add Marketplace",
618
+ "Pick one to add now, or choose Other for a repo not listed.",
619
+ [
620
+ ...addable.map((m) => ({
621
+ label: m.displayName || m.name,
622
+ value: m.source.repo as string,
623
+ description: m.description,
624
+ })),
625
+ { label: "Other…", value: "", description: "Any owner/repo" },
626
+ ],
627
+ );
628
+ if (choice === null) return;
629
+ if (choice) {
630
+ try {
631
+ await cliAddMarketplace(choice);
632
+ await handleRefresh();
633
+ } catch (e) {
634
+ await modal.message(
635
+ "Could not add marketplace",
636
+ `${(e as Error).message}\n\nRun it yourself if this persists:\n\n` +
637
+ ` claude plugin marketplace add ${choice}`,
638
+ "error",
639
+ );
640
+ }
641
+ return;
642
+ }
643
+ }
644
+
645
+ await modal.message(
646
+ "Add Marketplace",
647
+ "For a marketplace that is not listed, run this in your terminal:\n\n" +
648
+ " claude plugin marketplace add owner/repo\n\n" +
649
+ "Auto-update is enabled by default for new marketplaces.\n\n" +
650
+ "After adding, refresh magus with 'r' to see it.",
651
+ "info",
652
+ );
653
+ };
654
+
655
+ const handleShowTeamConfigHelp = async () => {
656
+ const helpText =
657
+ "TEAM CONFIGURATION FOR MARKETPLACES\n\n" +
658
+ "To configure marketplaces for your entire team:\n\n" +
659
+ "1. Add to .claude/settings.json (committed to git):\n\n" +
660
+ " {\n" +
661
+ ' "extraKnownMarketplaces": {\n' +
662
+ ' "company-tools": {\n' +
663
+ ' "source": {\n' +
664
+ ' "source": "github",\n' +
665
+ ' "repo": "your-org/claude-plugins"\n' +
666
+ " }\n" +
667
+ " }\n" +
668
+ " },\n" +
669
+ ' "enabledPlugins": {\n' +
670
+ ' "code-formatter@company-tools": true\n' +
671
+ " }\n" +
672
+ " }\n\n" +
673
+ "2. Team members get prompted to install when they trust the folder\n\n" +
674
+ "3. Marketplaces are GLOBAL ONLY (managed by Claude Code)\n" +
675
+ " - Not project-specific\n" +
676
+ " - All team members share the same marketplace cache\n\n" +
677
+ "NOTE: Individual plugin enablement is still project-specific.\n" +
678
+ 'Use "Project" scope in magus to enable for the team.';
679
+
680
+ await modal.message("Team Configuration", helpText, "info");
681
+ };
682
+
683
+ /**
684
+ * Collect environment variables required by a plugin's MCP servers.
685
+ */
686
+ const collectPluginEnvVars = async (
687
+ pluginName: string,
688
+ marketplace: string,
689
+ ): Promise<boolean> => {
690
+ try {
691
+ const pluginSource = await getPluginSourcePath(marketplace, pluginName);
692
+ if (!pluginSource) return true;
693
+
694
+ const requirements = await getPluginEnvRequirements(
695
+ marketplace,
696
+ pluginSource,
697
+ );
698
+ if (requirements.length === 0) return true;
699
+
700
+ const existingEnvVars = await getMcpEnvVars(state.projectPath);
701
+ const missingVars = requirements.filter(
702
+ (req) => !existingEnvVars[req.name] && !process.env[req.name],
703
+ );
704
+
705
+ if (missingVars.length === 0) return true;
706
+
707
+ const serverNames = [...new Set(missingVars.map((v) => v.serverName))];
708
+ const wantToConfigure = await modal.confirm(
709
+ "Configure MCP Servers?",
710
+ `This plugin includes MCP servers (${serverNames.join(", ")}) that need ${missingVars.length} environment variable(s).\n\nConfigure now?`,
711
+ );
712
+
713
+ if (!wantToConfigure) {
714
+ await modal.message(
715
+ "Skipped Configuration",
716
+ "You can configure these variables later in the Environment Variables screen (press 4).",
717
+ "info",
718
+ );
719
+ return true;
720
+ }
721
+
722
+ for (const req of missingVars) {
723
+ const existingProcessEnv = process.env[req.name];
724
+ if (existingProcessEnv) {
725
+ const useExisting = await modal.confirm(
726
+ `Use ${req.name}?`,
727
+ `${req.name} is already set in your environment.\n\nUse the existing value?`,
728
+ );
729
+ if (useExisting) {
730
+ await setMcpEnvVar(req.name, `\${${req.name}}`, state.projectPath);
731
+ continue;
732
+ }
733
+ }
734
+
735
+ const value = await modal.input(
736
+ `Configure ${req.serverName}`,
737
+ `${req.label} (required):`,
738
+ "",
739
+ );
740
+
741
+ if (value === null) {
742
+ await modal.message(
743
+ "Configuration Incomplete",
744
+ "Skipped remaining configuration.\nYou can configure these later in Environment Variables (press 4).",
745
+ "info",
746
+ );
747
+ return true;
748
+ }
749
+
750
+ if (value) {
751
+ await setMcpEnvVar(req.name, value, state.projectPath);
752
+ }
753
+ }
754
+
755
+ return true;
756
+ } catch (error) {
757
+ console.error("Error collecting plugin env vars:", error);
758
+ return true;
759
+ }
760
+ };
761
+
762
+ /**
763
+ * Install system dependencies required by a plugin's MCP servers.
764
+ */
765
+ const installPluginSystemDeps = async (
766
+ pluginName: string,
767
+ marketplace: string,
768
+ ): Promise<void> => {
769
+ try {
770
+ const setup = await getPluginSetupFromSource(marketplace, pluginName);
771
+ if (!setup) return;
772
+
773
+ const missing = await checkMissingDeps(setup);
774
+ const hasMissing =
775
+ (missing.pip?.length || 0) +
776
+ (missing.brew?.length || 0) +
777
+ (missing.npm?.length || 0) +
778
+ (missing.cargo?.length || 0) +
779
+ (missing.go?.length || 0) >
780
+ 0;
781
+
782
+ if (!hasMissing) return;
783
+
784
+ const parts: string[] = [];
785
+ if (missing.pip?.length) parts.push(`pip: ${missing.pip.join(", ")}`);
786
+ if (missing.brew?.length) parts.push(`brew: ${missing.brew.join(", ")}`);
787
+ if (missing.npm?.length) parts.push(`npm: ${missing.npm.join(", ")}`);
788
+ if (missing.cargo?.length)
789
+ parts.push(`cargo: ${missing.cargo.join(", ")}`);
790
+ if (missing.go?.length) parts.push(`go: ${missing.go.join(", ")}`);
791
+
792
+ const wantInstall = await modal.confirm(
793
+ "Install Dependencies?",
794
+ `This plugin needs system dependencies:\n\n${parts.join("\n")}\n\nInstall now?`,
795
+ );
796
+
797
+ if (!wantInstall) return;
798
+
799
+ modal.loading("Installing dependencies...");
800
+ const result = await installPluginDeps(missing);
801
+ modal.hideModal();
802
+
803
+ if (result.failed.length > 0) {
804
+ const failMsg = result.failed
805
+ .map((f) => `${f.pkg}: ${f.error}`)
806
+ .join("\n");
807
+ await modal.message(
808
+ "Partial Install",
809
+ `Installed: ${result.installed.length}\nFailed:\n${failMsg}`,
810
+ "error",
811
+ );
812
+ } else if (result.installed.length > 0) {
813
+ await modal.message(
814
+ "Dependencies Installed",
815
+ `Installed ${result.installed.length} package(s):\n${result.installed.join(", ")}`,
816
+ "success",
817
+ );
818
+ }
819
+ } catch (error) {
820
+ console.error("Error installing plugin deps:", error);
821
+ }
822
+ };
823
+
824
+ const handleSelect = async () => {
825
+ const item = selectableItems[pluginsState.selectedIndex];
826
+ if (!item) return;
827
+
828
+ if (item.kind === "category") {
829
+ const mp = item.marketplace;
830
+
831
+ if (item.marketplaceEnabled) {
832
+ const isCollapsed = pluginsState.collapsedMarketplaces.has(mp.name);
833
+ if (isCollapsed) {
834
+ dispatch({ type: "PLUGINS_TOGGLE_MARKETPLACE", name: mp.name });
835
+ } else if (item.pluginCount > 0) {
836
+ dispatch({ type: "PLUGINS_TOGGLE_MARKETPLACE", name: mp.name });
837
+ } else {
838
+ await modal.message(
839
+ `Remove ${mp.displayName}?`,
840
+ `To remove this marketplace, run in Claude Code:\n\n /plugin marketplace remove ${mp.name}\n\nAfter removing, refresh magus with 'r' to update the display.`,
841
+ "info",
842
+ );
843
+ }
844
+ } else {
845
+ // Add it here rather than printing the command for the user to run.
846
+ // Telling someone to leave the tool, paste a command, come back and
847
+ // press 'r' is a step magus can just take: it already knows the
848
+ // repo, and addMarketplace() is the same call they would make.
849
+ const repo = mp.source.repo || mp.name;
850
+ const wantAdd = await modal.confirm(
851
+ `Add ${mp.displayName}?`,
852
+ `Clone ${repo} and register it as a marketplace?\n\nAuto-update is enabled by default.`,
853
+ );
854
+ if (wantAdd) {
855
+ try {
856
+ await cliAddMarketplace(repo);
857
+ await handleRefresh();
858
+ } catch (e) {
859
+ await modal.message(
860
+ "Could not add marketplace",
861
+ `${(e as Error).message}\n\nRun it yourself if this persists:\n\n claude plugin marketplace add ${repo}`,
862
+ "error",
863
+ );
864
+ }
865
+ }
866
+ }
867
+ } else if (item.kind === "plugin") {
868
+ const plugin = item.plugin;
869
+ const status = statuses.get(plugin.id);
870
+
871
+ // The label's arrow and what the key then does are ONE rule —
872
+ // `scopeIsBehind` — so the picker can never show no arrow and then
873
+ // update, or show an arrow and then uninstall.
874
+ const buildScopeLabel = (
875
+ name: string,
876
+ scopeKey: PluginScope,
877
+ scope: ScopeStatus | undefined,
878
+ desc: string,
879
+ ) => {
880
+ const installed = isInstalledInScope(scope);
881
+ const ver = scope?.version;
882
+ let label = installed ? `● ${name}` : `○ ${name}`;
883
+ label += ` (${desc})`;
884
+ if (ver) label += ` v${ver}`;
885
+ if (scopeIsBehind(status, scopeKey) && status?.target)
886
+ label += ` → v${status.target}`;
887
+ // Enabled in settings with nothing installed. Without saying so the
888
+ // row reads exactly like a scope that was never enabled, and the
889
+ // user cannot tell that picking it repairs rather than adds.
890
+ if (!installed && scope?.enabled) label += " — not installed, repairs";
891
+ return label;
892
+ };
893
+
894
+ const scopeOptions = [
895
+ {
896
+ label: buildScopeLabel("User", "user", plugin.userScope, "global"),
897
+ value: "user",
898
+ },
899
+ {
900
+ label: buildScopeLabel(
901
+ "Project",
902
+ "project",
903
+ plugin.projectScope,
904
+ "team",
905
+ ),
906
+ value: "project",
907
+ },
908
+ {
909
+ label: buildScopeLabel(
910
+ "Local",
911
+ "local",
912
+ plugin.localScope,
913
+ "private",
914
+ ),
915
+ value: "local",
916
+ },
917
+ ];
918
+
919
+ const scopeValue = await modal.select(
920
+ plugin.name,
921
+ "Select scope to toggle:",
922
+ scopeOptions,
923
+ );
924
+ if (scopeValue === null) return;
925
+
926
+ const selectedScope =
927
+ scopeValue === "user"
928
+ ? plugin.userScope
929
+ : scopeValue === "project"
930
+ ? plugin.projectScope
931
+ : plugin.localScope;
932
+ const scopeLabel =
933
+ scopeValue === "user"
934
+ ? "User"
935
+ : scopeValue === "project"
936
+ ? "Project"
937
+ : "Local";
938
+
939
+ // Decided from the registry-backed version, not the `enabledPlugins`
940
+ // flag alone — see resolveScopeAction. Update-versus-uninstall is the
941
+ // same `scopeIsBehind` the label above used.
942
+ const action = resolveScopeAction(
943
+ selectedScope,
944
+ scopeIsBehind(status, scopeValue as PluginScope),
945
+ );
946
+
947
+ try {
948
+ const scope = scopeValue as PluginScope;
949
+ if (action === "uninstall") {
950
+ modal.loading(
951
+ `Uninstalling ${plugin.name} from ${scopeLabel}…\nclaude plugin uninstall ${plugin.id} --scope ${scope}`,
952
+ );
953
+ await cliUninstallPlugin(plugin.id, scope, state.projectPath);
954
+ } else if (action === "update") {
955
+ modal.loading(
956
+ `Updating ${plugin.name} in ${scopeLabel}…\nclaude plugin install ${plugin.id} --scope ${scope}`,
957
+ );
958
+ // Scope is the user's explicit choice here, so this does NOT go
959
+ // through applyPlugins (which advances every scope behind). It
960
+ // still records the version READ BACK, not the catalog version.
961
+ await updatePluginInScope(
962
+ plugin.id,
963
+ scope,
964
+ state.projectPath || process.cwd(),
965
+ );
966
+ } else {
967
+ modal.loading(
968
+ `Installing ${plugin.name} to ${scopeLabel}…\nclaude plugin install ${plugin.id} --scope ${scope}`,
969
+ );
970
+ // Same read-back as the update branch above. A fresh install is
971
+ // no more able to guarantee it got the catalog version than an
972
+ // update is — the marketplace serves whatever it publishes at
973
+ // the moment of the call — so recording the version we asked
974
+ // for made `install --check` compare that number against a copy
975
+ // of itself and report clean forever.
976
+ await installPluginInScope(
977
+ plugin.id,
978
+ scope,
979
+ state.projectPath || process.cwd(),
980
+ );
981
+ modal.hideModal();
982
+ await collectPluginEnvVars(plugin.name, plugin.marketplace);
983
+ await installPluginSystemDeps(plugin.name, plugin.marketplace);
984
+ }
985
+ if (action !== "install") {
986
+ modal.hideModal();
987
+ }
988
+ fetchData();
989
+ } catch (error) {
990
+ modal.hideModal();
991
+ await modal.message("Error", `Failed: ${error}`, "error");
992
+ }
993
+ }
994
+ };
995
+
996
+ const handleUpdate = async () => {
997
+ const item = selectableItems[pluginsState.selectedIndex];
998
+ if (!item || item.kind !== "plugin") return;
999
+ // The row said "update" or "stale — reinstall"; nothing else is work.
1000
+ const status = statuses.get(item.plugin.id);
1001
+ if (status?.action !== "update" && status?.action !== "repair") return;
1002
+
1003
+ const plugin = item.plugin;
1004
+ const scope: PluginScope =
1005
+ pluginsState.scope === "global" ? "user" : "project";
1006
+
1007
+ // No version bump to install over — the files changed underneath an
1008
+ // unchanged version, and `plugin install` no-ops on "already installed".
1009
+ // Only uninstall+install re-copies. See claude-cli.ts repairPlugin.
1010
+ const repairing = status.action === "repair";
1011
+
1012
+ modal.loading(
1013
+ repairing
1014
+ ? `Repairing ${plugin.name}…\nclaude plugin uninstall ${plugin.id} --scope ${scope} && claude plugin install ${plugin.id} --scope ${scope}`
1015
+ : `Updating ${plugin.name}…\nclaude plugin install ${plugin.id} --scope ${scope}`,
1016
+ );
1017
+ const reasons = noMoveReasons();
1018
+ try {
1019
+ // One plugin, but the SAME engine — so a single update records the
1020
+ // version that landed and touches every scope that is behind, exactly
1021
+ // as `magus update` does. The plan item IS the row's status: the
1022
+ // classifier's answer for this plugin under its real pin (the
1023
+ // profile's, or "latest" for an undeclared install — see pinForRow),
1024
+ // so what runs is exactly what the row claimed.
1025
+ const result = await applyPlugins(
1026
+ [status],
1027
+ state.projectPath || process.cwd(),
1028
+ REAL_PLUGIN_DEPS,
1029
+ reasons.reporter,
1030
+ );
1031
+ modal.hideModal();
1032
+ if (result.failed.length > 0) {
1033
+ await modal.message(
1034
+ "Error",
1035
+ `Failed to update ${plugin.name}`,
1036
+ "error",
1037
+ );
1038
+ return;
1039
+ }
1040
+ // Ran, exited 0, moved nothing — and the row will not have moved
1041
+ // either. Saying nothing here is what made "press U, press U,
1042
+ // forever": the CLI names the cause, so the screen does too.
1043
+ if (result.unchanged.length > 0) {
1044
+ await modal.message(
1045
+ "No change",
1046
+ reasons.lines(result.unchanged),
1047
+ "info",
1048
+ );
1049
+ }
1050
+ fetchData();
1051
+ } catch (error) {
1052
+ modal.hideModal();
1053
+ await modal.message("Error", `Failed to update: ${error}`, "error");
1054
+ }
1055
+ };
1056
+
1057
+ /**
1058
+ * "Update all" — the SAME planner and the SAME apply engine `magus
1059
+ * update` runs. Only the selection differs, and only because a screen and a
1060
+ * profile genuinely mean different sets: here it is "everything listed with
1061
+ * an update", there it is "what the profile declares".
1062
+ *
1063
+ * This used to be a hand-rolled loop, and it had drifted from the CLI in
1064
+ * three ways that were bugs, not choices — it touched one scope derived from
1065
+ * the open tab, it recorded the version it EXPECTED rather than the one that
1066
+ * landed, and it never reported a pin the installer could not honour. All
1067
+ * three are fixed by not having a second implementation.
1068
+ */
1069
+ const handleUpdateAll = async () => {
1070
+ if (pluginsState.plugins.status !== "success") return;
1071
+
1072
+ // Exactly the rows that say "update" or "stale — reinstall", each planned
1073
+ // under its real pin. Content-stale plugins are included: they need
1074
+ // repairing, and excluding them meant "update all" left the machine with
1075
+ // silently outdated files.
1076
+ const items = [...statuses.values()]
1077
+ .filter((s) => s.action === "update" || s.action === "repair")
1078
+ .sort((a, b) => a.pluginId.localeCompare(b.pluginId));
1079
+ if (items.length === 0) return;
1080
+
1081
+ const projectPath = state.projectPath || process.cwd();
1082
+ const total = items.filter(pluginNeedsWork).length;
1083
+
1084
+ let done = 0;
1085
+ const reasons = noMoveReasons();
1086
+ const reporter: ApplyReporter = {
1087
+ begin: (name) => {
1088
+ done++;
1089
+ modal.loading(`Updating ${name} (${done}/${total})…`);
1090
+ },
1091
+ finish: reasons.reporter.finish,
1092
+ detach: (work) => work(),
1093
+ };
1094
+
1095
+ try {
1096
+ const result = await applyPlugins(
1097
+ items,
1098
+ projectPath,
1099
+ REAL_PLUGIN_DEPS,
1100
+ reporter,
1101
+ );
1102
+ modal.hideModal();
1103
+ if (result.failed.length > 0) {
1104
+ await modal.message(
1105
+ "Some updates failed",
1106
+ result.failed.join(", "),
1107
+ "error",
1108
+ );
1109
+ }
1110
+ if (result.unchanged.length > 0) {
1111
+ await modal.message(
1112
+ "No change",
1113
+ reasons.lines(result.unchanged),
1114
+ "info",
1115
+ );
1116
+ }
1117
+ fetchData();
1118
+ } catch (error) {
1119
+ modal.hideModal();
1120
+ await modal.message("Error", `Failed to update: ${error}`, "error");
1121
+ }
1122
+ };
1123
+
1124
+ const handleScopeToggle = async (scope: "user" | "project" | "local") => {
1125
+ const item = selectableItems[pluginsState.selectedIndex];
1126
+ if (!item || item.kind !== "plugin") return;
1127
+
1128
+ const plugin = item.plugin;
1129
+ const scopeLabel =
1130
+ scope === "user" ? "User" : scope === "project" ? "Project" : "Local";
1131
+
1132
+ const scopeData =
1133
+ scope === "user"
1134
+ ? plugin.userScope
1135
+ : scope === "project"
1136
+ ? plugin.projectScope
1137
+ : plugin.localScope;
1138
+
1139
+ // This used to key off `scopeData.enabled` alone, so a plugin the list
1140
+ // had just labelled "not installed" answered its own scope key by
1141
+ // uninstalling itself — the `enabledPlugins` flag was set, the registry
1142
+ // entry was missing, and only the flag was consulted. Update-versus-
1143
+ // uninstall is the classifier's `scopeIsBehind`, the same rule the Enter
1144
+ // scope picker labels with.
1145
+ const action = resolveScopeAction(
1146
+ scopeData,
1147
+ scopeIsBehind(statuses.get(plugin.id), scope),
1148
+ );
1149
+
1150
+ try {
1151
+ if (action === "uninstall") {
1152
+ modal.loading(
1153
+ `Uninstalling ${plugin.name} from ${scopeLabel}…\nclaude plugin uninstall ${plugin.id} --scope ${scope}`,
1154
+ );
1155
+ await cliUninstallPlugin(plugin.id, scope, state.projectPath);
1156
+ } else if (action === "update") {
1157
+ modal.loading(
1158
+ `Updating ${plugin.name} in ${scopeLabel}…\nclaude plugin install ${plugin.id} --scope ${scope}`,
1159
+ );
1160
+ // Explicit scope, read-back version — see the note above.
1161
+ await updatePluginInScope(
1162
+ plugin.id,
1163
+ scope,
1164
+ state.projectPath || process.cwd(),
1165
+ );
1166
+ } else {
1167
+ modal.loading(
1168
+ `Installing ${plugin.name} to ${scopeLabel}…\nclaude plugin install ${plugin.id} --scope ${scope}`,
1169
+ );
1170
+ // Read-back on install too — see the note on the same branch above.
1171
+ await installPluginInScope(
1172
+ plugin.id,
1173
+ scope,
1174
+ state.projectPath || process.cwd(),
1175
+ );
1176
+ modal.hideModal();
1177
+ await collectPluginEnvVars(plugin.name, plugin.marketplace);
1178
+ await installPluginSystemDeps(plugin.name, plugin.marketplace);
1179
+ }
1180
+ if (action !== "install") {
1181
+ modal.hideModal();
1182
+ }
1183
+ fetchData();
1184
+ } catch (error) {
1185
+ modal.hideModal();
1186
+ await modal.message("Error", `Failed: ${error}`, "error");
1187
+ }
1188
+ };
1189
+
1190
+ const handleRemoveDeprecated = async () => {
1191
+ const item = selectableItems[pluginsState.selectedIndex];
1192
+ if (!item || item.kind !== "plugin" || !item.plugin.isOrphaned) return;
1193
+
1194
+ const plugin = item.plugin;
1195
+
1196
+ // No "it moved, migrate there?" branch. It offered to uninstall this
1197
+ // plugin and install the same NAME from another marketplace, on the
1198
+ // strength of a name match with no history behind it — and for
1199
+ // `claudish`, dual-published to two marketplaces on purpose, the target
1200
+ // was already installed. An orphan is removed; where else a name happens
1201
+ // to appear is not this screen's guess to make.
1202
+
1203
+ try {
1204
+ // Remove from all scopes — try all to clean up stale references
1205
+ const scopes: PluginScope[] = ["user", "project", "local"];
1206
+ // Nothing tallies which scopes actually held it. The migrate dialog
1207
+ // that consumed that tally is gone with the `moved` inference, and
1208
+ // collecting it for no reader is how a value goes stale unnoticed.
1209
+ for (const scope of scopes) {
1210
+ try {
1211
+ modal.loading(
1212
+ `Removing ${plugin.name} from ${scope}…\nclaude plugin uninstall ${plugin.id} --scope ${scope}`,
1213
+ );
1214
+ await cliUninstallPlugin(plugin.id, scope, state.projectPath);
1215
+ } catch {
1216
+ // Ignore errors for scopes where it doesn't exist
1217
+ }
1218
+ }
1219
+
1220
+ modal.hideModal();
1221
+ fetchData();
1222
+ } catch (error) {
1223
+ modal.hideModal();
1224
+ await modal.message("Error", `Failed to remove: ${error}`, "error");
1225
+ }
1226
+ };
1227
+
1228
+ const handleSaveAsProfile = async () => {
1229
+ const settings = await readSettings(state.projectPath);
1230
+ const enabledPlugins = settings.enabledPlugins ?? {};
1231
+
1232
+ const name = await modal.input("Save Profile", "Profile name:");
1233
+ if (name === null || !name.trim()) return;
1234
+
1235
+ const scopeChoice = await modal.select(
1236
+ "Save to scope",
1237
+ "Where should this profile be saved?",
1238
+ [
1239
+ {
1240
+ label: "User — ~/.claude/profiles.json (available everywhere)",
1241
+ value: "user",
1242
+ },
1243
+ {
1244
+ label: "Project — .claude/profiles.json (shared with team via git)",
1245
+ value: "project",
1246
+ },
1247
+ ],
1248
+ );
1249
+ if (scopeChoice === null) return;
1250
+
1251
+ const scope = scopeChoice as "user" | "project";
1252
+
1253
+ modal.loading("Saving profile...");
1254
+ try {
1255
+ await saveProfile(name.trim(), enabledPlugins, scope, state.projectPath);
1256
+ modal.hideModal();
1257
+ await modal.message(
1258
+ "Saved",
1259
+ `Profile "${name.trim()}" saved.\nPress 6 to manage profiles.`,
1260
+ "success",
1261
+ );
1262
+ } catch (error) {
1263
+ modal.hideModal();
1264
+ await modal.message("Error", `Failed to save profile: ${error}`, "error");
1265
+ }
1266
+ };
1267
+
1268
+ // ── Render ─────────────────────────────────────────────────────────────────
1269
+
1270
+ // Only a genuine first load takes over the screen. A reload keeps the list on
1271
+ // screen and says so in the notice line instead.
1272
+ if (!shownMarketplaces || !shownPlugins) {
1273
+ if (
1274
+ pluginsState.marketplaces.status === "loading" ||
1275
+ pluginsState.plugins.status === "loading"
1276
+ ) {
1277
+ return (
1278
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
1279
+ <text fg={theme.colors.accent}>
1280
+ <strong>magus Plugins</strong>
1281
+ </text>
1282
+ <text fg={theme.colors.muted}>
1283
+ Loading plugin catalogs from GitHub…
1284
+ </text>
1285
+ </box>
1286
+ );
1287
+ }
1288
+ }
1289
+
1290
+ if (
1291
+ pluginsState.marketplaces.status === "error" ||
1292
+ pluginsState.plugins.status === "error"
1293
+ ) {
1294
+ // Name the error. "Error loading data" told the user nothing and sent them
1295
+ // to the debug pane to find out what magus already knew.
1296
+ const failure =
1297
+ pluginsState.plugins.status === "error"
1298
+ ? pluginsState.plugins.error
1299
+ : pluginsState.marketplaces.status === "error"
1300
+ ? pluginsState.marketplaces.error
1301
+ : undefined;
1302
+ return (
1303
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
1304
+ <text fg={theme.colors.accent}>
1305
+ <strong>magus Plugins</strong>
1306
+ </text>
1307
+ <text fg={theme.colors.danger}>
1308
+ Could not load plugins: {failure?.message ?? "unknown error"}
1309
+ </text>
1310
+ <text fg={theme.colors.muted}>Press r to retry.</text>
1311
+ </box>
1312
+ );
1313
+ }
1314
+
1315
+ const selectedItem = selectableItems[pluginsState.selectedIndex];
1316
+
1317
+ const footerHints = isSearchActive
1318
+ ? [
1319
+ { keys: ["type"], label: "filter" },
1320
+ { keys: ["Enter"], label: "done" },
1321
+ { keys: ["Esc"], label: "clear" },
1322
+ ]
1323
+ : [
1324
+ { keys: ["u", "/", "p", "/", "l"], label: "toggle" },
1325
+ { keys: ["U"], label: "update" },
1326
+ { keys: ["a"], label: "all" },
1327
+ { keys: ["s"], label: "profile" },
1328
+ { keys: ["/"], label: "search" },
1329
+ ];
1330
+
1331
+ const scopeLabel = pluginsState.scope === "global" ? "Global" : "Project";
1332
+ const plugins: PluginInfo[] = shownPlugins ?? [];
1333
+ // "installed" must mean the same thing here as in the rows beside it. This
1334
+ // counted `p.enabled` — the settings flag — so the header read "11 installed"
1335
+ // on a screen whose every row said "not installed". Same predicate as the
1336
+ // row and the detail panel now.
1337
+ const isInstalledAnywhere = (p: PluginInfo) =>
1338
+ isInstalledInScope(p.userScope) ||
1339
+ isInstalledInScope(p.projectScope) ||
1340
+ isInstalledInScope(p.localScope);
1341
+ const installedCount = plugins.filter(isInstalledAnywhere).length;
1342
+ // Every state count is the classifier's, over the same map the rows read,
1343
+ // so a header can never count a row the list beside it does not show.
1344
+ const counts = countPluginStates(statuses.values());
1345
+ const updateCount = counts.update + counts.repair;
1346
+ // Cannot tell whether it is current. Counted and shown separately: folding
1347
+ // it into `updateCount` would overstate, and folding it into silence is the
1348
+ // "cannot tell rendered as fine" bug.
1349
+ const unknownCount = counts.unknown;
1350
+ const installCount = counts.install;
1351
+ const subtitle = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} updates` : ""}${installCount > 0 ? ` │ ${installCount} to install` : ""}${unknownCount > 0 ? ` │ ${unknownCount} unverified` : ""}`;
1352
+ const searchPlaceholder = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} ⬆` : ""}${installCount > 0 ? ` │ ${installCount} +` : ""}${unknownCount > 0 ? ` │ ${unknownCount} ?` : ""} │ / to search`;
1353
+
1354
+ const catalogNotice = buildCatalogNotice({
1355
+ failures: catalogFailures,
1356
+ unverifiedPlugins: [...statuses.values()].filter(
1357
+ (s) => s.reason === "catalog-unverified",
1358
+ ).length,
1359
+ width: dimensions.terminalWidth,
1360
+ now: nowTick,
1361
+ });
1362
+ // Without a resolved profile, exact pins are unknown here and every row is
1363
+ // judged against "latest". Saying so is what keeps a row that disagrees with
1364
+ // `magus update <profile>` from reading as a contradiction.
1365
+ const notice = isReloading ? (
1366
+ <text fg={theme.colors.muted}>⟳ Refreshing catalogs from GitHub…</text>
1367
+ ) : (
1368
+ (catalogNotice ??
1369
+ (pins === null ? (
1370
+ <text fg={theme.colors.muted}>
1371
+ profile not resolved — states judged against latest
1372
+ </text>
1373
+ ) : null))
1374
+ );
1375
+
1376
+ return (
1377
+ <ScreenLayout
1378
+ title="magus Plugins"
1379
+ subtitle={subtitle}
1380
+ notice={notice}
1381
+ currentScreen="plugins"
1382
+ search={{
1383
+ isActive: isSearchActive,
1384
+ query: pluginsState.searchQuery,
1385
+ placeholder: searchPlaceholder,
1386
+ }}
1387
+ footerHints={footerHints}
1388
+ listPanel={
1389
+ <box flexDirection="column">
1390
+ <ScrollableList
1391
+ items={selectableItems}
1392
+ selectedIndex={pluginsState.selectedIndex}
1393
+ renderItem={renderPluginRow}
1394
+ maxHeight={dimensions.listPanelHeight}
1395
+ getKey={(item) => item.id}
1396
+ />
1397
+ {pluginsState.searchQuery && selectableItems.length === 0 && (
1398
+ <EmptyFilterState
1399
+ query={pluginsState.searchQuery}
1400
+ entityName="plugins"
1401
+ />
1402
+ )}
1403
+ </box>
1404
+ }
1405
+ // A skill's panel now holds its whole SKILL.md, which is long enough to
1406
+ // scroll. Without this the pane kept the previous row's scroll offset,
1407
+ // so moving to the next skill landed you in the middle of a document
1408
+ // you had not started reading.
1409
+ detailKey={selectedItem?.id}
1410
+ detailPanel={renderPluginDetail(
1411
+ selectedItem,
1412
+ pluginsState.collapsedMarketplaces,
1413
+ pluginsState.pluginContents,
1414
+ pluginsState.skillDetails,
1415
+ // ScreenLayout gives the detail pane 50% of the terminal, then takes
1416
+ // a column for its left padding and one for the scrollbar.
1417
+ Math.max(24, Math.floor(dimensions.terminalWidth / 2) - 3),
1418
+ )}
1419
+ />
1420
+ );
1421
+ }
1422
+
1423
+ export default PluginsScreen;