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,1564 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import fs from "fs-extra";
4
+ import semver from "semver";
5
+ import type {
6
+ ClaudeLocalSettings,
7
+ ClaudeSettings,
8
+ DiscoveredMarketplace,
9
+ InstalledPluginEntry,
10
+ InstalledPluginsRegistry,
11
+ MarketplaceSource,
12
+ McpServerConfig,
13
+ } from "../types/index.js";
14
+ import { parsePluginId } from "../utils/string-utils.js";
15
+ import { repoHeadSha } from "./content-drift.js";
16
+ import { inheritablePaths } from "./git-worktree.js";
17
+
18
+ const CLAUDE_DIR = ".claude";
19
+ const SETTINGS_FILE = "settings.json";
20
+ const LOCAL_SETTINGS_FILE = "settings.local.json";
21
+ const MCP_CONFIG_FILE = ".mcp.json";
22
+
23
+ // MCP config file types
24
+ interface McpConfigFile {
25
+ mcpServers?: Record<string, McpServerConfig>;
26
+ }
27
+
28
+ export function getClaudeDir(projectPath?: string): string {
29
+ const base = projectPath || process.cwd();
30
+ return path.join(base, CLAUDE_DIR);
31
+ }
32
+
33
+ export function getGlobalClaudeDir(): string {
34
+ return path.join(os.homedir(), CLAUDE_DIR);
35
+ }
36
+
37
+ export async function ensureClaudeDir(projectPath?: string): Promise<string> {
38
+ const claudeDir = getClaudeDir(projectPath);
39
+ await fs.ensureDir(claudeDir);
40
+ return claudeDir;
41
+ }
42
+
43
+ export async function readSettings(
44
+ projectPath?: string,
45
+ ): Promise<ClaudeSettings> {
46
+ const settingsPath = path.join(getClaudeDir(projectPath), SETTINGS_FILE);
47
+ try {
48
+ if (await fs.pathExists(settingsPath)) {
49
+ return await fs.readJson(settingsPath);
50
+ }
51
+ } catch {
52
+ // Return empty settings on error
53
+ }
54
+ return {};
55
+ }
56
+
57
+ export async function writeSettings(
58
+ settings: ClaudeSettings,
59
+ projectPath?: string,
60
+ ): Promise<void> {
61
+ const claudeDir = await ensureClaudeDir(projectPath);
62
+ const settingsPath = path.join(claudeDir, SETTINGS_FILE);
63
+ await fs.writeJson(settingsPath, settings, { spaces: 2 });
64
+ }
65
+
66
+ export async function readLocalSettings(
67
+ projectPath?: string,
68
+ ): Promise<ClaudeLocalSettings> {
69
+ const localPath = path.join(getClaudeDir(projectPath), LOCAL_SETTINGS_FILE);
70
+ try {
71
+ if (await fs.pathExists(localPath)) {
72
+ return await fs.readJson(localPath);
73
+ }
74
+ } catch {
75
+ // Return empty settings on error
76
+ }
77
+ return {};
78
+ }
79
+
80
+ export async function writeLocalSettings(
81
+ settings: ClaudeLocalSettings,
82
+ projectPath?: string,
83
+ ): Promise<void> {
84
+ const claudeDir = await ensureClaudeDir(projectPath);
85
+ const localPath = path.join(claudeDir, LOCAL_SETTINGS_FILE);
86
+ await fs.writeJson(localPath, settings, { spaces: 2 });
87
+ }
88
+
89
+ // MCP config file management (.mcp.json at project root)
90
+ export function getMcpConfigPath(projectPath?: string): string {
91
+ const base = projectPath || process.cwd();
92
+ return path.join(base, MCP_CONFIG_FILE);
93
+ }
94
+
95
+ export async function readMcpConfig(
96
+ projectPath?: string,
97
+ ): Promise<McpConfigFile> {
98
+ const mcpPath = getMcpConfigPath(projectPath);
99
+ try {
100
+ if (await fs.pathExists(mcpPath)) {
101
+ return await fs.readJson(mcpPath);
102
+ }
103
+ } catch {
104
+ // Return empty config on error
105
+ }
106
+ return {};
107
+ }
108
+
109
+ export async function writeMcpConfig(
110
+ config: McpConfigFile,
111
+ projectPath?: string,
112
+ ): Promise<void> {
113
+ const mcpPath = getMcpConfigPath(projectPath);
114
+ await fs.writeJson(mcpPath, config, { spaces: 2 });
115
+ }
116
+
117
+ export async function readGlobalSettings(): Promise<ClaudeSettings> {
118
+ const settingsPath = path.join(getGlobalClaudeDir(), SETTINGS_FILE);
119
+ try {
120
+ if (await fs.pathExists(settingsPath)) {
121
+ return await fs.readJson(settingsPath);
122
+ }
123
+ } catch {
124
+ // Return empty settings on error
125
+ }
126
+ return {};
127
+ }
128
+
129
+ export async function writeGlobalSettings(
130
+ settings: ClaudeSettings,
131
+ ): Promise<void> {
132
+ await fs.ensureDir(getGlobalClaudeDir());
133
+ const settingsPath = path.join(getGlobalClaudeDir(), SETTINGS_FILE);
134
+ await fs.writeJson(settingsPath, settings, { spaces: 2 });
135
+ }
136
+
137
+ // MCP Server management (writes to .mcp.json at project root)
138
+ export async function addMcpServer(
139
+ name: string,
140
+ config: McpServerConfig,
141
+ projectPath?: string,
142
+ ): Promise<void> {
143
+ // Extract env vars from config - they go to settings.local.json, not .mcp.json
144
+ const envVars = config.env || {};
145
+ const configWithoutEnv: McpServerConfig = { ...config };
146
+ // biome-ignore lint/performance/noDelete: this object is serialised into .mcp.json; the env key must be absent, not present as undefined
147
+ delete configWithoutEnv.env;
148
+
149
+ // Add to .mcp.json (without env vars)
150
+ const mcpConfig = await readMcpConfig(projectPath);
151
+ mcpConfig.mcpServers = mcpConfig.mcpServers || {};
152
+ mcpConfig.mcpServers[name] = configWithoutEnv;
153
+ await writeMcpConfig(mcpConfig, projectPath);
154
+
155
+ // Enable in settings.local.json and add env vars
156
+ const localSettings = await readLocalSettings(projectPath);
157
+ const enabledServers = localSettings.enabledMcpjsonServers || [];
158
+ if (!enabledServers.includes(name)) {
159
+ enabledServers.push(name);
160
+ }
161
+ localSettings.enabledMcpjsonServers = enabledServers;
162
+ localSettings.enableAllProjectMcpServers = true;
163
+
164
+ // Add env vars to settings.local.json
165
+ if (Object.keys(envVars).length > 0) {
166
+ localSettings.env = localSettings.env || {};
167
+ for (const [key, value] of Object.entries(envVars)) {
168
+ // Only add non-reference values (references like ${VAR} don't need to be stored)
169
+ if (!value.startsWith("${") || !value.endsWith("}")) {
170
+ localSettings.env[key] = value;
171
+ }
172
+ }
173
+ }
174
+
175
+ await writeLocalSettings(localSettings, projectPath);
176
+ }
177
+
178
+ export async function removeMcpServer(
179
+ name: string,
180
+ projectPath?: string,
181
+ ): Promise<void> {
182
+ // Remove from .mcp.json
183
+ const mcpConfig = await readMcpConfig(projectPath);
184
+ if (mcpConfig.mcpServers) {
185
+ delete mcpConfig.mcpServers[name];
186
+ }
187
+ await writeMcpConfig(mcpConfig, projectPath);
188
+
189
+ // Remove from settings.local.json
190
+ const localSettings = await readLocalSettings(projectPath);
191
+ if (localSettings.enabledMcpjsonServers) {
192
+ localSettings.enabledMcpjsonServers =
193
+ localSettings.enabledMcpjsonServers.filter((s) => s !== name);
194
+ await writeLocalSettings(localSettings, projectPath);
195
+ }
196
+ }
197
+
198
+ export async function toggleMcpServer(
199
+ name: string,
200
+ enabled: boolean,
201
+ projectPath?: string,
202
+ ): Promise<void> {
203
+ // Toggle is now a remove operation since .mcp.json doesn't have enabled/disabled state
204
+ // If disabled, remove from config; if enabled, the server should already be in config
205
+ if (!enabled) {
206
+ await removeMcpServer(name, projectPath);
207
+ }
208
+ // If enabling, the server should already exist in the config
209
+ }
210
+
211
+ export async function setAllowMcp(
212
+ _allow: boolean,
213
+ _projectPath?: string,
214
+ ): Promise<void> {
215
+ // .mcp.json doesn't have an allowMcp setting - servers are either in the file or not
216
+ // This function is kept for API compatibility but is now a no-op
217
+ }
218
+
219
+ // Marketplace management - READ ONLY
220
+ // Use Claude Code CLI commands to add/remove marketplaces:
221
+ // claude marketplace add owner/repo
222
+ // claude marketplace remove name
223
+
224
+ // Plugin management
225
+ export async function enablePlugin(
226
+ pluginId: string,
227
+ enabled: boolean,
228
+ projectPath?: string,
229
+ ): Promise<void> {
230
+ const settings = await readSettings(projectPath);
231
+ settings.enabledPlugins = settings.enabledPlugins || {};
232
+ if (enabled) {
233
+ settings.enabledPlugins[pluginId] = true;
234
+ } else {
235
+ delete settings.enabledPlugins[pluginId];
236
+ }
237
+ await writeSettings(settings, projectPath);
238
+ }
239
+
240
+ export async function getEnabledPlugins(
241
+ projectPath?: string,
242
+ ): Promise<Record<string, boolean>> {
243
+ const settings = await readSettings(projectPath);
244
+ return settings.enabledPlugins || {};
245
+ }
246
+
247
+ export async function getLocalEnabledPlugins(
248
+ projectPath?: string,
249
+ ): Promise<Record<string, boolean>> {
250
+ const settings = await readLocalSettings(projectPath);
251
+ return settings.enabledPlugins || {};
252
+ }
253
+
254
+ /**
255
+ * Overlay the installed_plugins.json registry onto a settings-derived version map.
256
+ *
257
+ * `settings.installedPluginVersions` is magus's own bookkeeping and is routinely
258
+ * incomplete: Claude Code's own `plugin install` writes the registry but not our
259
+ * settings key, so a plugin can be genuinely installed and still be absent here.
260
+ * Callers that only read settings then see `undefined` and — because the update
261
+ * check is `installedVersion && compare(...)` — silently treat the plugin as
262
+ * up to date instead of unknown. That hides real updates.
263
+ *
264
+ * The registry is what Claude Code actually loaded, so it wins on conflict.
265
+ *
266
+ * Scope resolution mirrors Claude Code: for a project scope, prefer an entry
267
+ * pinned to that exact projectPath (project or local), then any `fallbackPaths`
268
+ * that are the same repository (see git-worktree.ts), then the user-scope entry,
269
+ * which is what a project without its own install resolves to.
270
+ */
271
+ export function pickRegistryEntry(
272
+ entries: InstalledPluginEntry[] | undefined,
273
+ scope: "user" | "project" | "local",
274
+ projectPath?: string,
275
+ /**
276
+ * Same-repository directories whose rows may be inherited, nearest first —
277
+ * a linked worktree's main working tree, or the root of this checkout.
278
+ *
279
+ * A fresh worktree has no rows of its own, so without this every plugin read
280
+ * as not installed while Claude Code was loading all of them quite happily.
281
+ * These rank above the user-scope entry: "installed in this repository" is a
282
+ * closer answer than "installed globally somewhere".
283
+ */
284
+ fallbackPaths: string[] = [],
285
+ ): InstalledPluginEntry | undefined {
286
+ if (!Array.isArray(entries) || entries.length === 0) return undefined;
287
+
288
+ if (scope === "user") return entries.find((e) => e.scope === "user");
289
+
290
+ const pinnedTo = (target: string) =>
291
+ entries.find(
292
+ (e) =>
293
+ (e.scope === scope || e.scope === "local") &&
294
+ e.projectPath !== undefined &&
295
+ path.resolve(e.projectPath) === target,
296
+ );
297
+
298
+ if (projectPath) {
299
+ const own = pinnedTo(path.resolve(projectPath));
300
+ if (own) return own;
301
+ }
302
+ for (const fallback of fallbackPaths) {
303
+ const inherited = pinnedTo(path.resolve(fallback));
304
+ if (inherited) return inherited;
305
+ }
306
+ // A project with no install of its own resolves to the user-scope entry.
307
+ return entries.find((e) => e.scope === "user");
308
+ }
309
+
310
+ async function overlayRegistryVersions(
311
+ base: Record<string, string>,
312
+ scope: "user" | "project" | "local",
313
+ projectPath?: string,
314
+ ): Promise<Record<string, string>> {
315
+ let registry: InstalledPluginsRegistry;
316
+ try {
317
+ registry = await readInstalledPluginsRegistry();
318
+ } catch {
319
+ return base; // registry unreadable — settings-only is still better than nothing
320
+ }
321
+
322
+ // `readSettings(undefined)` already resolves to the current directory, so the
323
+ // registry lookup has to as well. While it did not, the base map described one
324
+ // project while the overlay refused to match any project's rows at all, and every
325
+ // version was deleted as unbacked — which is why the global
326
+ // plugin view, both of which pass no path, saw project-scope plugins as
327
+ // uninstalled everywhere, not only in a worktree.
328
+ const target = scope === "user" ? undefined : (projectPath ?? process.cwd());
329
+
330
+ // Resolved once, not per plugin: this shells out to git, and the loop runs
331
+ // across every key in the registry (76 on a well-used machine) for each scope.
332
+ const fallbackPaths = target ? await inheritablePaths(target) : [];
333
+
334
+ const merged = { ...base };
335
+ for (const [pluginId, entries] of Object.entries(registry.plugins ?? {})) {
336
+ const pick = pickRegistryEntry(entries, scope, target, fallbackPaths);
337
+ if (pick?.version) {
338
+ merged[pluginId] = pick.version;
339
+ continue;
340
+ }
341
+
342
+ // The registry knows this plugin but has no entry for this scope, so it
343
+ // is NOT installed here. Any leftover `installedPluginVersions` value is
344
+ // a claim about an install that does not exist.
345
+ //
346
+ // Claude Code never writes that field — it appears 0 times in the 2.1.223
347
+ // binary, and `claude plugin install` writes it at no scope — so only
348
+ // magus's own past writes can be in there, and they go stale silently.
349
+ // Measured: user settings claimed browser-use 1.1.2 / terminal 4.0.2 /
350
+ // code-search 5.1.0 while the registry said 1.4.0 / 4.1.4 / 5.3.1, and
351
+ // none of the claimed versions had a cache directory. Keeping the stale
352
+ // value made every one of them read as a permanent pending update.
353
+ if (entries && entries.length > 0) delete merged[pluginId];
354
+ }
355
+ return merged;
356
+ }
357
+
358
+ export async function getLocalInstalledPluginVersions(
359
+ projectPath?: string,
360
+ ): Promise<Record<string, string>> {
361
+ const settings = await readLocalSettings(projectPath);
362
+ return overlayRegistryVersions(
363
+ settings.installedPluginVersions || {},
364
+ "local",
365
+ projectPath,
366
+ );
367
+ }
368
+
369
+ /**
370
+ * Overlay the registry onto a project-scope settings map.
371
+ * Exported for plugin-manager, which reads project settings itself.
372
+ */
373
+ export async function getProjectInstalledPluginVersions(
374
+ base: Record<string, string>,
375
+ projectPath?: string,
376
+ ): Promise<Record<string, string>> {
377
+ return overlayRegistryVersions(base, "project", projectPath);
378
+ }
379
+
380
+ // Local plugin management (writes to settings.local.json)
381
+ export async function enableLocalPlugin(
382
+ pluginId: string,
383
+ enabled: boolean,
384
+ projectPath?: string,
385
+ ): Promise<void> {
386
+ const settings = await readLocalSettings(projectPath);
387
+ settings.enabledPlugins = settings.enabledPlugins || {};
388
+ if (enabled) {
389
+ settings.enabledPlugins[pluginId] = true;
390
+ } else {
391
+ delete settings.enabledPlugins[pluginId];
392
+ }
393
+ await writeLocalSettings(settings, projectPath);
394
+ }
395
+
396
+ export async function saveLocalInstalledPluginVersion(
397
+ pluginId: string,
398
+ version: string,
399
+ projectPath?: string,
400
+ ): Promise<void> {
401
+ const settings = await readLocalSettings(projectPath);
402
+ settings.installedPluginVersions = settings.installedPluginVersions || {};
403
+ settings.installedPluginVersions[pluginId] = version;
404
+ await writeLocalSettings(settings, projectPath);
405
+
406
+ // Update registry for local scope
407
+ await updateInstalledPluginsRegistry(
408
+ pluginId,
409
+ version,
410
+ "local",
411
+ projectPath ? path.resolve(projectPath) : undefined,
412
+ );
413
+ }
414
+
415
+ export async function removeLocalInstalledPluginVersion(
416
+ pluginId: string,
417
+ projectPath?: string,
418
+ ): Promise<void> {
419
+ const settings = await readLocalSettings(projectPath);
420
+ if (settings.installedPluginVersions) {
421
+ delete settings.installedPluginVersions[pluginId];
422
+ }
423
+ if (settings.enabledPlugins) {
424
+ delete settings.enabledPlugins[pluginId];
425
+ }
426
+ await writeLocalSettings(settings, projectPath);
427
+
428
+ // Remove from registry for local scope
429
+ await removeFromInstalledPluginsRegistry(
430
+ pluginId,
431
+ "local",
432
+ projectPath ? path.resolve(projectPath) : undefined,
433
+ );
434
+ }
435
+
436
+ // Status line management
437
+ // Claude Code expects statusLine as an object: { type: "template", template: "..." }
438
+ // or { type: "command", command: "..." }. Writing a bare string causes a validation error
439
+ // that skips the entire settings file.
440
+
441
+ function wrapStatusLine(
442
+ template: string,
443
+ ): { type: string; template: string } | undefined {
444
+ if (!template) return undefined;
445
+ return { type: "template", template };
446
+ }
447
+
448
+ function unwrapStatusLine(
449
+ value:
450
+ | string
451
+ | { type: string; template?: string; command?: string }
452
+ | undefined,
453
+ ): string | undefined {
454
+ if (!value) return undefined;
455
+ if (typeof value === "string") return value; // legacy format
456
+ return value.template ?? value.command;
457
+ }
458
+
459
+ export async function setStatusLine(
460
+ template: string,
461
+ projectPath?: string,
462
+ ): Promise<void> {
463
+ const settings = await readSettings(projectPath);
464
+ const wrapped = wrapStatusLine(template);
465
+ if (wrapped) {
466
+ settings.statusLine = wrapped;
467
+ } else {
468
+ // biome-ignore lint/performance/noDelete: settings is written back to settings.json; the key must be removed from the file, and readers check `Object.keys`/`in`
469
+ delete settings.statusLine;
470
+ }
471
+ await writeSettings(settings, projectPath);
472
+ }
473
+
474
+ export async function getStatusLine(
475
+ projectPath?: string,
476
+ ): Promise<string | undefined> {
477
+ const settings = await readSettings(projectPath);
478
+ return unwrapStatusLine(settings.statusLine);
479
+ }
480
+
481
+ // Global status line management
482
+ export async function setGlobalStatusLine(template: string): Promise<void> {
483
+ const settings = await readGlobalSettings();
484
+ const wrapped = wrapStatusLine(template);
485
+ if (wrapped) {
486
+ settings.statusLine = wrapped;
487
+ } else {
488
+ // biome-ignore lint/performance/noDelete: settings is written back to settings.json; the key must be removed from the file, and readers check `Object.keys`/`in`
489
+ delete settings.statusLine;
490
+ }
491
+ await writeGlobalSettings(settings);
492
+ }
493
+
494
+ export async function getGlobalStatusLine(): Promise<string | undefined> {
495
+ const settings = await readGlobalSettings();
496
+ return unwrapStatusLine(settings.statusLine);
497
+ }
498
+
499
+ // Get effective status line (project overrides global)
500
+ export async function getEffectiveStatusLine(projectPath?: string): Promise<{
501
+ template: string | undefined;
502
+ source: "project" | "global" | "default";
503
+ }> {
504
+ const projectStatusLine = await getStatusLine(projectPath);
505
+ if (projectStatusLine) {
506
+ return { template: projectStatusLine, source: "project" };
507
+ }
508
+
509
+ const globalStatusLine = await getGlobalStatusLine();
510
+ if (globalStatusLine) {
511
+ return { template: globalStatusLine, source: "global" };
512
+ }
513
+
514
+ return { template: undefined, source: "default" };
515
+ }
516
+
517
+ // Check if .claude directory exists
518
+ export async function hasClaudeDir(projectPath?: string): Promise<boolean> {
519
+ return fs.pathExists(getClaudeDir(projectPath));
520
+ }
521
+
522
+ // Get installed MCP servers (from .mcp.json)
523
+ export async function getInstalledMcpServers(
524
+ projectPath?: string,
525
+ ): Promise<Record<string, McpServerConfig>> {
526
+ const mcpConfig = await readMcpConfig(projectPath);
527
+ return mcpConfig.mcpServers || {};
528
+ }
529
+
530
+ // Get env vars for MCP servers (from settings.local.json)
531
+ export async function getMcpEnvVars(
532
+ projectPath?: string,
533
+ ): Promise<Record<string, string>> {
534
+ const localSettings = await readLocalSettings(projectPath);
535
+ return localSettings.env || {};
536
+ }
537
+
538
+ // Set an env var for MCP servers (in settings.local.json)
539
+ export async function setMcpEnvVar(
540
+ name: string,
541
+ value: string,
542
+ projectPath?: string,
543
+ ): Promise<void> {
544
+ const localSettings = await readLocalSettings(projectPath);
545
+ localSettings.env = localSettings.env || {};
546
+ localSettings.env[name] = value;
547
+ await writeLocalSettings(localSettings, projectPath);
548
+ }
549
+
550
+ // Remove an env var (from settings.local.json)
551
+ export async function removeMcpEnvVar(
552
+ name: string,
553
+ projectPath?: string,
554
+ ): Promise<void> {
555
+ const localSettings = await readLocalSettings(projectPath);
556
+ if (localSettings.env) {
557
+ delete localSettings.env[name];
558
+ await writeLocalSettings(localSettings, projectPath);
559
+ }
560
+ }
561
+
562
+ // Get enabled MCP servers (all servers in .mcp.json are considered enabled)
563
+ export async function getEnabledMcpServers(
564
+ projectPath?: string,
565
+ ): Promise<Record<string, boolean>> {
566
+ const mcpConfig = await readMcpConfig(projectPath);
567
+ const servers = mcpConfig.mcpServers || {};
568
+ const enabled: Record<string, boolean> = {};
569
+ for (const name of Object.keys(servers)) {
570
+ enabled[name] = true;
571
+ }
572
+ return enabled;
573
+ }
574
+
575
+ // Get all configured marketplaces — merges known_marketplaces.json (primary) with extraKnownMarketplaces (legacy fallback)
576
+ export async function getConfiguredMarketplaces(
577
+ projectPath?: string,
578
+ ): Promise<Record<string, MarketplaceSource>> {
579
+ const result: Record<string, MarketplaceSource> = {};
580
+
581
+ // Primary: known_marketplaces.json (official Claude Code registry)
582
+ const known = await readKnownMarketplaces();
583
+ for (const [name, entry] of Object.entries(known)) {
584
+ if (entry.source?.repo) {
585
+ result[name] = {
586
+ source: { source: "github" as const, repo: entry.source.repo },
587
+ };
588
+ }
589
+ }
590
+
591
+ // Fallback: extraKnownMarketplaces (legacy — only adds entries not already in known)
592
+ const settings = await readSettings(projectPath);
593
+ for (const [name, config] of Object.entries(
594
+ settings.extraKnownMarketplaces || {},
595
+ )) {
596
+ if (!result[name]) {
597
+ result[name] = config;
598
+ }
599
+ }
600
+
601
+ return result;
602
+ }
603
+
604
+ // Global marketplace management - READ ONLY
605
+ // Marketplaces are managed via Claude Code's native system
606
+
607
+ export async function getGlobalConfiguredMarketplaces(): Promise<
608
+ Record<string, MarketplaceSource>
609
+ > {
610
+ const result: Record<string, MarketplaceSource> = {};
611
+
612
+ // Primary: known_marketplaces.json
613
+ const known = await readKnownMarketplaces();
614
+ for (const [name, entry] of Object.entries(known)) {
615
+ if (entry.source?.repo) {
616
+ result[name] = {
617
+ source: { source: "github" as const, repo: entry.source.repo },
618
+ };
619
+ }
620
+ }
621
+
622
+ // Fallback: extraKnownMarketplaces
623
+ const settings = await readGlobalSettings();
624
+ for (const [name, config] of Object.entries(
625
+ settings.extraKnownMarketplaces || {},
626
+ )) {
627
+ if (!result[name]) {
628
+ result[name] = config;
629
+ }
630
+ }
631
+
632
+ return result;
633
+ }
634
+
635
+ // Global plugin management
636
+ export async function enableGlobalPlugin(
637
+ pluginId: string,
638
+ enabled: boolean,
639
+ ): Promise<void> {
640
+ const settings = await readGlobalSettings();
641
+ settings.enabledPlugins = settings.enabledPlugins || {};
642
+ if (enabled) {
643
+ settings.enabledPlugins[pluginId] = true;
644
+ } else {
645
+ delete settings.enabledPlugins[pluginId];
646
+ }
647
+ await writeGlobalSettings(settings);
648
+ }
649
+
650
+ export async function getGlobalEnabledPlugins(): Promise<
651
+ Record<string, boolean>
652
+ > {
653
+ const settings = await readGlobalSettings();
654
+ return settings.enabledPlugins || {};
655
+ }
656
+
657
+ export async function getGlobalInstalledPluginVersions(): Promise<
658
+ Record<string, string>
659
+ > {
660
+ const settings = await readGlobalSettings();
661
+ return overlayRegistryVersions(
662
+ settings.installedPluginVersions || {},
663
+ "user",
664
+ );
665
+ }
666
+
667
+ export async function saveGlobalInstalledPluginVersion(
668
+ pluginId: string,
669
+ version: string,
670
+ ): Promise<void> {
671
+ const settings = await readGlobalSettings();
672
+ settings.installedPluginVersions = settings.installedPluginVersions || {};
673
+ settings.installedPluginVersions[pluginId] = version;
674
+ await writeGlobalSettings(settings);
675
+
676
+ // Update registry for user scope
677
+ await updateInstalledPluginsRegistry(pluginId, version, "user");
678
+ }
679
+
680
+ export async function removeGlobalInstalledPluginVersion(
681
+ pluginId: string,
682
+ ): Promise<void> {
683
+ const settings = await readGlobalSettings();
684
+ if (settings.installedPluginVersions) {
685
+ delete settings.installedPluginVersions[pluginId];
686
+ }
687
+ if (settings.enabledPlugins) {
688
+ delete settings.enabledPlugins[pluginId];
689
+ }
690
+ await writeGlobalSettings(settings);
691
+
692
+ // Remove from registry for user scope
693
+ await removeFromInstalledPluginsRegistry(pluginId, "user");
694
+ }
695
+
696
+ // Shared logic for discovering marketplaces from settings
697
+ function discoverMarketplacesFromSettings(
698
+ settings: ClaudeSettings,
699
+ knownMarketplaces?: KnownMarketplaces,
700
+ ): DiscoveredMarketplace[] {
701
+ const discovered = new Map<string, DiscoveredMarketplace>();
702
+
703
+ // 1. From known_marketplaces.json (official Claude Code registry — primary source)
704
+ if (knownMarketplaces) {
705
+ for (const [name, entry] of Object.entries(knownMarketplaces)) {
706
+ if (entry.source?.repo) {
707
+ discovered.set(name, {
708
+ name,
709
+ source: "configured",
710
+ config: {
711
+ source: { source: "github" as const, repo: entry.source.repo },
712
+ },
713
+ });
714
+ }
715
+ }
716
+ }
717
+
718
+ // 2. From extraKnownMarketplaces (legacy/team fallback — only adds entries not already in known_marketplaces.json)
719
+ for (const [name, config] of Object.entries(
720
+ settings.extraKnownMarketplaces || {},
721
+ )) {
722
+ if (!discovered.has(name)) {
723
+ discovered.set(name, { name, source: "configured", config });
724
+ }
725
+ }
726
+
727
+ // 3. From enabledPlugins (infer marketplace from plugin ID format: pluginName@marketplaceName)
728
+ for (const pluginId of Object.keys(settings.enabledPlugins || {})) {
729
+ const parsed = parsePluginId(pluginId);
730
+ if (parsed && !discovered.has(parsed.marketplace)) {
731
+ discovered.set(parsed.marketplace, {
732
+ name: parsed.marketplace,
733
+ source: "inferred",
734
+ });
735
+ }
736
+ }
737
+
738
+ // 4. From installedPluginVersions (same format)
739
+ for (const pluginId of Object.keys(settings.installedPluginVersions || {})) {
740
+ const parsed = parsePluginId(pluginId);
741
+ if (parsed && !discovered.has(parsed.marketplace)) {
742
+ discovered.set(parsed.marketplace, {
743
+ name: parsed.marketplace,
744
+ source: "inferred",
745
+ });
746
+ }
747
+ }
748
+
749
+ return Array.from(discovered.values());
750
+ }
751
+
752
+ // Discover all marketplaces from settings (configured + inferred from plugins)
753
+ export async function discoverAllMarketplaces(
754
+ projectPath?: string,
755
+ ): Promise<DiscoveredMarketplace[]> {
756
+ try {
757
+ const settings = await readSettings(projectPath);
758
+ const known = await readKnownMarketplaces();
759
+ return discoverMarketplacesFromSettings(settings, known);
760
+ } catch (error) {
761
+ // Graceful degradation - return empty array instead of crashing
762
+ console.error(
763
+ "Failed to discover project marketplaces:",
764
+ error instanceof Error ? error.message : "Unknown error",
765
+ );
766
+ return [];
767
+ }
768
+ }
769
+
770
+ // Discover all marketplaces from global settings
771
+ export async function discoverAllGlobalMarketplaces(): Promise<
772
+ DiscoveredMarketplace[]
773
+ > {
774
+ try {
775
+ const settings = await readGlobalSettings();
776
+ const known = await readKnownMarketplaces();
777
+ return discoverMarketplacesFromSettings(settings, known);
778
+ } catch (error) {
779
+ // Graceful degradation - return empty array instead of crashing
780
+ console.error(
781
+ "Failed to discover global marketplaces:",
782
+ error instanceof Error ? error.message : "Unknown error",
783
+ );
784
+ return [];
785
+ }
786
+ }
787
+
788
+ // installed_plugins.json registry management.
789
+ // Resolved lazily (per-call) rather than cached at module load: caching
790
+ // os.homedir() in a module-level constant makes the path capture whatever
791
+ // HOME was set to when this module was first imported, which is fragile
792
+ // (import-order-dependent) and breaks any caller that changes HOME afterward.
793
+ const installedPluginsFile = (): string =>
794
+ path.join(os.homedir(), ".claude", "plugins", "installed_plugins.json");
795
+
796
+ const knownMarketplacesFile = (): string =>
797
+ path.join(os.homedir(), ".claude", "plugins", "known_marketplaces.json");
798
+
799
+ interface KnownMarketplaceEntry {
800
+ source: { source: string; path?: string; repo?: string };
801
+ installLocation: string;
802
+ lastUpdated: string;
803
+ autoUpdate?: boolean;
804
+ }
805
+
806
+ type KnownMarketplaces = Record<string, KnownMarketplaceEntry>;
807
+
808
+ /**
809
+ * Write known_marketplaces.json
810
+ */
811
+ async function writeKnownMarketplaces(
812
+ marketplaces: KnownMarketplaces,
813
+ ): Promise<void> {
814
+ await fs.ensureDir(path.dirname(knownMarketplacesFile()));
815
+ await fs.writeJson(knownMarketplacesFile(), marketplaces, { spaces: 2 });
816
+ }
817
+
818
+ /**
819
+ * Read a marketplace's `autoUpdate` flag — Claude Code's refresh policy.
820
+ *
821
+ * READ ONLY, and there is deliberately no setter. `autoUpdate` is Claude Code's
822
+ * field (50 occurrences in the 2.1.269 binary, with its own manifest-schema
823
+ * entry and a "Synced autoUpdate= from settings" path), so Claude Code decides
824
+ * its value. magus used to write `true` into every MadAppGang entry on TUI
825
+ * startup, which is a direct write to `known_marketplaces.json` — the file at
826
+ * the top of CLAUDE.md's never-write-directly list — and it fought Claude Code's
827
+ * own sync for ownership of the same byte.
828
+ */
829
+ export async function getMarketplaceAutoUpdate(
830
+ marketplaceName: string,
831
+ ): Promise<boolean | undefined> {
832
+ const known = await readKnownMarketplaces();
833
+ return known[marketplaceName]?.autoUpdate;
834
+ }
835
+
836
+ export interface MarketplaceRecoveryResult {
837
+ removed: string[];
838
+ reregistered: string[];
839
+ }
840
+
841
+ // =============================================================================
842
+ // MARKETPLACE RENAME MIGRATION: mag-claude-plugins / MadAppGang-claude-code → magus
843
+ // =============================================================================
844
+
845
+ const OLD_MARKETPLACE_NAMES = ["mag-claude-plugins", "MadAppGang-claude-code"];
846
+ const NEW_MARKETPLACE_NAME = "magus";
847
+ const NEW_MARKETPLACE_REPO = "MadAppGang/magus";
848
+ const OLD_MARKETPLACE_REPOS = ["MadAppGang/claude-code"];
849
+
850
+ /**
851
+ * Rename plugin keys in a Record from any old marketplace name to new.
852
+ * e.g., "frontend@mag-claude-plugins" → "frontend@magus"
853
+ * "dev@MadAppGang-claude-code" → "dev@magus"
854
+ * Returns [migratedRecord, count] — count=0 means no changes.
855
+ */
856
+ function migratePluginKeys<T>(
857
+ record: Record<string, T> | undefined,
858
+ ): [Record<string, T>, number] {
859
+ if (!record) return [{}, 0];
860
+ const migrated: Record<string, T> = {};
861
+ let count = 0;
862
+ for (const [key, value] of Object.entries(record)) {
863
+ const oldName = OLD_MARKETPLACE_NAMES.find((n) => key.endsWith(`@${n}`));
864
+ if (oldName) {
865
+ const pluginName = key.slice(0, key.lastIndexOf("@"));
866
+ const newKey = `${pluginName}@${NEW_MARKETPLACE_NAME}`;
867
+ // Don't overwrite if canonical key already exists
868
+ if (!record[newKey]) {
869
+ migrated[newKey] = value;
870
+ }
871
+ count++;
872
+ } else {
873
+ migrated[key] = value;
874
+ }
875
+ }
876
+ return [migrated, count];
877
+ }
878
+
879
+ /**
880
+ * Migrate a single settings object in-place.
881
+ * Returns number of keys renamed.
882
+ */
883
+ function migrateSettingsObject(settings: ClaudeSettings): number {
884
+ let total = 0;
885
+
886
+ const [ep, epCount] = migratePluginKeys(settings.enabledPlugins);
887
+ if (epCount > 0) {
888
+ settings.enabledPlugins = ep;
889
+ total += epCount;
890
+ }
891
+
892
+ const [iv, ivCount] = migratePluginKeys(settings.installedPluginVersions);
893
+ if (ivCount > 0) {
894
+ settings.installedPluginVersions = iv;
895
+ total += ivCount;
896
+ }
897
+
898
+ // Migrate extraKnownMarketplaces keys
899
+ for (const oldName of OLD_MARKETPLACE_NAMES) {
900
+ if (settings.extraKnownMarketplaces?.[oldName]) {
901
+ const entry = settings.extraKnownMarketplaces[oldName];
902
+ delete settings.extraKnownMarketplaces[oldName];
903
+ if (!settings.extraKnownMarketplaces[NEW_MARKETPLACE_NAME]) {
904
+ settings.extraKnownMarketplaces[NEW_MARKETPLACE_NAME] = {
905
+ ...entry,
906
+ source: {
907
+ ...entry.source,
908
+ repo: NEW_MARKETPLACE_REPO,
909
+ },
910
+ };
911
+ }
912
+ total++;
913
+ }
914
+ }
915
+
916
+ // Fix stale repo URL on existing magus entry (e.g. key is "magus" but repo is still "MadAppGang/claude-code")
917
+ const magusEntry = settings.extraKnownMarketplaces?.[NEW_MARKETPLACE_NAME];
918
+ if (
919
+ magusEntry?.source?.repo &&
920
+ OLD_MARKETPLACE_REPOS.includes(magusEntry.source.repo)
921
+ ) {
922
+ magusEntry.source.repo = NEW_MARKETPLACE_REPO;
923
+ total++;
924
+ }
925
+
926
+ return total;
927
+ }
928
+
929
+ export interface MigrationResult {
930
+ projectMigrated: number;
931
+ globalMigrated: number;
932
+ localMigrated: number;
933
+ knownMarketplacesMigrated: boolean;
934
+ registryMigrated: number;
935
+ }
936
+
937
+ /**
938
+ * Migrate all settings from mag-claude-plugins to magus.
939
+ * Safe to call multiple times — no-ops if already migrated.
940
+ * Runs across project settings, global settings, local settings,
941
+ * known_marketplaces.json, and installed_plugins.json.
942
+ */
943
+ /**
944
+ * Decode a ~/.claude/projects/ directory name back to a filesystem path.
945
+ * Claude Code encodes paths by replacing "/" with "-", which is lossy when
946
+ * directory names themselves contain dashes (e.g., "circl-infra" → "circl/infra").
947
+ * Strategy: split on "-", then greedily recombine segments by checking which
948
+ * combinations actually exist on disk.
949
+ * Returns null if no valid path can be resolved.
950
+ */
951
+ async function decodeProjectDirName(encoded: string): Promise<string | null> {
952
+ // Split into segments: "-Users-jack-dev-circl-infra" → ["Users","jack","dev","circl","infra"]
953
+ const segments = encoded.replace(/^-/, "").split("-");
954
+ if (segments.length === 0) return null;
955
+
956
+ // Build path greedily: at each step, try joining the next segment with a dash first
957
+ // (preserving directory names like "circl-infra"), fall back to slash (new path segment)
958
+ let current = `/${segments[0]}`;
959
+ for (let i = 1; i < segments.length; i++) {
960
+ const withDash = `${current}-${segments[i]}`;
961
+ const withSlash = `${current}/${segments[i]}`;
962
+ // Prefer dash (keeps compound names intact) if that path prefix exists
963
+ if (await fs.pathExists(withDash)) {
964
+ current = withDash;
965
+ } else {
966
+ current = withSlash;
967
+ }
968
+ }
969
+
970
+ return (await fs.pathExists(current)) ? current : null;
971
+ }
972
+
973
+ export async function migrateMarketplaceRename(
974
+ projectPath?: string,
975
+ ): Promise<MigrationResult> {
976
+ const result: MigrationResult = {
977
+ projectMigrated: 0,
978
+ globalMigrated: 0,
979
+ localMigrated: 0,
980
+ knownMarketplacesMigrated: false,
981
+ registryMigrated: 0,
982
+ };
983
+
984
+ // 1. Project settings
985
+ try {
986
+ const settings = await readSettings(projectPath);
987
+ const count = migrateSettingsObject(settings);
988
+ if (count > 0) {
989
+ await writeSettings(settings, projectPath);
990
+ result.projectMigrated = count;
991
+ }
992
+ } catch {
993
+ /* skip if unreadable */
994
+ }
995
+
996
+ // 2. Global settings
997
+ try {
998
+ const settings = await readGlobalSettings();
999
+ const count = migrateSettingsObject(settings);
1000
+ if (count > 0) {
1001
+ await writeGlobalSettings(settings);
1002
+ result.globalMigrated = count;
1003
+ }
1004
+ } catch {
1005
+ /* skip if unreadable */
1006
+ }
1007
+
1008
+ // 3. Local settings (settings.local.json)
1009
+ try {
1010
+ const local = await readLocalSettings(projectPath);
1011
+ let localCount = 0;
1012
+ const [ep, epCount] = migratePluginKeys(local.enabledPlugins);
1013
+ if (epCount > 0) {
1014
+ local.enabledPlugins = ep;
1015
+ localCount += epCount;
1016
+ }
1017
+ const [iv, ivCount] = migratePluginKeys(local.installedPluginVersions);
1018
+ if (ivCount > 0) {
1019
+ local.installedPluginVersions = iv;
1020
+ localCount += ivCount;
1021
+ }
1022
+ if (localCount > 0) {
1023
+ await writeLocalSettings(local, projectPath);
1024
+ result.localMigrated = localCount;
1025
+ }
1026
+ } catch {
1027
+ /* skip if unreadable */
1028
+ }
1029
+
1030
+ // 4. known_marketplaces.json — rename old keys + physical directory cleanup
1031
+ const pluginsDir = path.join(
1032
+ os.homedir(),
1033
+ ".claude",
1034
+ "plugins",
1035
+ "marketplaces",
1036
+ );
1037
+ const newDir = path.join(pluginsDir, NEW_MARKETPLACE_NAME);
1038
+
1039
+ try {
1040
+ const known = await readKnownMarketplaces();
1041
+ let knownModified = false;
1042
+
1043
+ for (const oldName of OLD_MARKETPLACE_NAMES) {
1044
+ if (known[oldName]) {
1045
+ const oldEntry = known[oldName];
1046
+ // If canonical entry doesn't exist yet, create it
1047
+ if (!known[NEW_MARKETPLACE_NAME]) {
1048
+ known[NEW_MARKETPLACE_NAME] = {
1049
+ ...oldEntry,
1050
+ source: {
1051
+ ...oldEntry.source,
1052
+ repo: "MadAppGang/magus",
1053
+ },
1054
+ };
1055
+ }
1056
+ delete known[oldName];
1057
+ knownModified = true;
1058
+ }
1059
+
1060
+ // Ensure installLocation doesn't reference old directory names
1061
+ if (known[NEW_MARKETPLACE_NAME]?.installLocation?.includes(oldName)) {
1062
+ known[NEW_MARKETPLACE_NAME].installLocation = known[
1063
+ NEW_MARKETPLACE_NAME
1064
+ ].installLocation.replace(oldName, NEW_MARKETPLACE_NAME);
1065
+ knownModified = true;
1066
+ }
1067
+ }
1068
+
1069
+ if (knownModified) {
1070
+ await writeKnownMarketplaces(known);
1071
+ result.knownMarketplacesMigrated = true;
1072
+ }
1073
+ } catch {
1074
+ /* skip if unreadable */
1075
+ }
1076
+
1077
+ // 4b. Rename/remove old physical directories (runs even if keys were already migrated)
1078
+ for (const oldName of OLD_MARKETPLACE_NAMES) {
1079
+ const oldDir = path.join(pluginsDir, oldName);
1080
+ try {
1081
+ if (await fs.pathExists(oldDir)) {
1082
+ if (!(await fs.pathExists(newDir))) {
1083
+ await fs.rename(oldDir, newDir);
1084
+ } else {
1085
+ // Both exist — remove the old one (magus dir is canonical)
1086
+ await fs.remove(oldDir);
1087
+ }
1088
+ }
1089
+ } catch {
1090
+ /* non-fatal: directory cleanup is best-effort */
1091
+ }
1092
+ }
1093
+
1094
+ // 4c. Update git remote URL in the marketplace clone (old → new repo)
1095
+ try {
1096
+ if (await fs.pathExists(path.join(newDir, ".git"))) {
1097
+ const { execSync } = await import("node:child_process");
1098
+ const remote = execSync("git remote get-url origin", {
1099
+ cwd: newDir,
1100
+ encoding: "utf-8",
1101
+ timeout: 5000,
1102
+ }).trim();
1103
+ if (remote.includes("claude-code") && remote.includes("MadAppGang")) {
1104
+ const newRemote = remote.replace("claude-code", NEW_MARKETPLACE_NAME);
1105
+ execSync(`git remote set-url origin "${newRemote}"`, {
1106
+ cwd: newDir,
1107
+ encoding: "utf-8",
1108
+ timeout: 5000,
1109
+ });
1110
+ }
1111
+ }
1112
+ } catch {
1113
+ /* non-fatal: git remote update is best-effort */
1114
+ }
1115
+
1116
+ // 5. installed_plugins.json — rename plugin ID keys
1117
+ try {
1118
+ const registry = await readInstalledPluginsRegistry();
1119
+ let regCount = 0;
1120
+ const newPlugins: typeof registry.plugins = {};
1121
+ for (const [pluginId, entries] of Object.entries(registry.plugins)) {
1122
+ const oldName = OLD_MARKETPLACE_NAMES.find((n) =>
1123
+ pluginId.endsWith(`@${n}`),
1124
+ );
1125
+ if (oldName) {
1126
+ const pluginName = pluginId.slice(0, pluginId.lastIndexOf("@"));
1127
+ const newKey = `${pluginName}@${NEW_MARKETPLACE_NAME}`;
1128
+ if (!newPlugins[newKey]) {
1129
+ newPlugins[newKey] = entries;
1130
+ }
1131
+ regCount++;
1132
+ } else {
1133
+ newPlugins[pluginId] = entries;
1134
+ }
1135
+ }
1136
+ if (regCount > 0) {
1137
+ registry.plugins = newPlugins;
1138
+ await writeInstalledPluginsRegistry(registry);
1139
+ result.registryMigrated = regCount;
1140
+ }
1141
+ } catch {
1142
+ /* skip if unreadable */
1143
+ }
1144
+
1145
+ // 6. Scan all known project settings (derived from ~/.claude/projects/ directory names)
1146
+ try {
1147
+ const projectsDir = path.join(os.homedir(), ".claude", "projects");
1148
+ if (await fs.pathExists(projectsDir)) {
1149
+ const entries = await fs.readdir(projectsDir);
1150
+ const seenPaths = new Set<string>();
1151
+ // Current project (from step 1) already handled — skip it
1152
+ const currentProject = projectPath || process.cwd();
1153
+ seenPaths.add(currentProject);
1154
+
1155
+ for (const entry of entries) {
1156
+ const decoded = await decodeProjectDirName(entry);
1157
+ if (!decoded || seenPaths.has(decoded)) continue;
1158
+ seenPaths.add(decoded);
1159
+
1160
+ const settingsFile = path.join(decoded, ".claude", "settings.json");
1161
+ try {
1162
+ if (await fs.pathExists(settingsFile)) {
1163
+ const raw = await fs.readJson(settingsFile);
1164
+ const count = migrateSettingsObject(raw);
1165
+ if (count > 0) {
1166
+ await fs.writeJson(settingsFile, raw, { spaces: 2 });
1167
+ result.projectMigrated += count;
1168
+ }
1169
+ }
1170
+ } catch {
1171
+ /* skip individual projects that fail */
1172
+ }
1173
+ }
1174
+ }
1175
+ } catch {
1176
+ /* non-fatal: cross-project scan is best-effort */
1177
+ }
1178
+
1179
+ return result;
1180
+ }
1181
+
1182
+ /**
1183
+ * Recover/sync marketplace settings:
1184
+ * - Remove entries for marketplaces whose installLocation no longer exists
1185
+ * - Fix stale "directory" source for known Magus marketplaces that should be "github"
1186
+ *
1187
+ * It does NOT touch `autoUpdate`. It used to set it to `true` on every
1188
+ * MadAppGang entry whose flag was unset, on every TUI startup — writing a
1189
+ * Claude Code-owned field that Claude Code also syncs from settings. See
1190
+ * {@link getMarketplaceAutoUpdate}.
1191
+ */
1192
+ export async function recoverMarketplaceSettings(): Promise<MarketplaceRecoveryResult> {
1193
+ const known = await readKnownMarketplaces();
1194
+ const result: MarketplaceRecoveryResult = {
1195
+ removed: [],
1196
+ reregistered: [],
1197
+ };
1198
+
1199
+ const updatedKnown: KnownMarketplaces = {};
1200
+
1201
+ // Import defaultMarketplaces lazily to get canonical repo URLs
1202
+ const { defaultMarketplaces } = await import("../data/marketplaces.js");
1203
+
1204
+ for (const [name, entry] of Object.entries(known)) {
1205
+ // Check if install location still exists
1206
+ if (
1207
+ entry.installLocation &&
1208
+ !(await fs.pathExists(entry.installLocation))
1209
+ ) {
1210
+ result.removed.push(name);
1211
+ continue;
1212
+ }
1213
+
1214
+ // Fix stale "directory" source for known marketplaces.
1215
+ // Some machines have magus registered as source: "directory" pointing to
1216
+ // a local dev checkout. This prevents Claude Code's auto-update from
1217
+ // refreshing the marketplace, causing "plugin not found" errors for
1218
+ // newly added plugins. Re-register as "github" with the canonical repo.
1219
+ if (entry.source?.source === "directory") {
1220
+ const defaultMp = defaultMarketplaces.find((m) => m.name === name);
1221
+ if (defaultMp?.source.repo) {
1222
+ const marketplacesDir = path.join(
1223
+ os.homedir(),
1224
+ ".claude",
1225
+ "plugins",
1226
+ "marketplaces",
1227
+ );
1228
+ entry.source = {
1229
+ source: "github",
1230
+ repo: defaultMp.source.repo,
1231
+ };
1232
+ entry.installLocation = path.join(marketplacesDir, name);
1233
+ result.reregistered.push(name);
1234
+ }
1235
+ }
1236
+
1237
+ updatedKnown[name] = entry;
1238
+ }
1239
+
1240
+ // Write back if any changes were made
1241
+ if (result.removed.length > 0 || result.reregistered.length > 0) {
1242
+ await writeKnownMarketplaces(updatedKnown);
1243
+ }
1244
+
1245
+ return result;
1246
+ }
1247
+
1248
+ /**
1249
+ * Read known_marketplaces.json to get marketplace source info
1250
+ */
1251
+ async function readKnownMarketplaces(): Promise<KnownMarketplaces> {
1252
+ try {
1253
+ if (await fs.pathExists(knownMarketplacesFile())) {
1254
+ return await fs.readJson(knownMarketplacesFile());
1255
+ }
1256
+ } catch {
1257
+ // Return empty if can't read
1258
+ }
1259
+ return {};
1260
+ }
1261
+
1262
+ /**
1263
+ * Check whether a marketplace is registered locally. A marketplace is
1264
+ * considered registered when it has an entry in known_marketplaces.json
1265
+ * AND its clone directory exists on disk. Either condition alone is not
1266
+ * enough: an entry with a missing directory means the clone was lost; a
1267
+ * directory without an entry means Claude Code doesn't know about it.
1268
+ */
1269
+ export async function isMarketplaceRegistered(name: string): Promise<boolean> {
1270
+ const known = await readKnownMarketplaces();
1271
+ if (!known[name]) return false;
1272
+ const marketplacesDir = path.join(
1273
+ os.homedir(),
1274
+ ".claude",
1275
+ "plugins",
1276
+ "marketplaces",
1277
+ );
1278
+ return fs.pathExists(path.join(marketplacesDir, name));
1279
+ }
1280
+
1281
+ /**
1282
+ * Get the source path for a plugin from its marketplace
1283
+ * For directory-based marketplaces, returns the local directory path
1284
+ * For GitHub marketplaces, returns the cloned repo path in ~/.claude/plugins/marketplaces/
1285
+ */
1286
+ async function getPluginSourcePath(
1287
+ pluginName: string,
1288
+ marketplace: string,
1289
+ ): Promise<string | null> {
1290
+ const known = await readKnownMarketplaces();
1291
+ const mpEntry = known[marketplace];
1292
+
1293
+ if (!mpEntry) {
1294
+ return null;
1295
+ }
1296
+
1297
+ let basePath: string;
1298
+
1299
+ if (mpEntry.source.source === "directory" && mpEntry.source.path) {
1300
+ // Directory-based marketplace - use the source path directly
1301
+ basePath = mpEntry.source.path;
1302
+ } else {
1303
+ // GitHub-based marketplace - use installLocation (cloned repo path)
1304
+ basePath = mpEntry.installLocation;
1305
+ }
1306
+
1307
+ // Look for plugin in standard locations
1308
+ const possiblePaths = [
1309
+ path.join(basePath, "plugins", pluginName),
1310
+ path.join(basePath, pluginName),
1311
+ ];
1312
+
1313
+ for (const pluginPath of possiblePaths) {
1314
+ if (await fs.pathExists(pluginPath)) {
1315
+ return pluginPath;
1316
+ }
1317
+ }
1318
+
1319
+ return null;
1320
+ }
1321
+
1322
+ /**
1323
+ * Copy plugin files from source to cache
1324
+ * This ensures the cache is populated with the latest plugin version
1325
+ *
1326
+ * Returns the directory the files were copied FROM, or null if nothing was
1327
+ * copied. The caller needs the source path, not just a success flag: that path
1328
+ * is what identifies the commit the cached files came from.
1329
+ */
1330
+ async function copyPluginToCache(
1331
+ pluginId: string,
1332
+ version: string,
1333
+ marketplace: string,
1334
+ ): Promise<string | null> {
1335
+ const { pluginName } = parsePluginId(pluginId) || {
1336
+ pluginName: pluginId.split("@")[0],
1337
+ };
1338
+
1339
+ const sourcePath = await getPluginSourcePath(pluginName, marketplace);
1340
+ if (!sourcePath) {
1341
+ return null;
1342
+ }
1343
+
1344
+ const cachePath = getPluginCachePath(pluginId, version, marketplace);
1345
+
1346
+ try {
1347
+ // Remove existing cache directory if it exists
1348
+ if (await fs.pathExists(cachePath)) {
1349
+ await fs.remove(cachePath);
1350
+ }
1351
+
1352
+ // Copy plugin files to cache
1353
+ await fs.copy(sourcePath, cachePath, {
1354
+ overwrite: true,
1355
+ errorOnExist: false,
1356
+ });
1357
+
1358
+ return sourcePath;
1359
+ } catch (error) {
1360
+ console.warn(
1361
+ `Failed to copy plugin ${pluginId} to cache:`,
1362
+ error instanceof Error ? error.message : "Unknown error",
1363
+ );
1364
+ return null;
1365
+ }
1366
+ }
1367
+
1368
+ /**
1369
+ * The commit a cache copy should be stamped with.
1370
+ *
1371
+ * `gitCommitSha` must describe the files sitting in the cache RIGHT NOW, which
1372
+ * is the single thing content-drift detection reads it for. Two rules:
1373
+ *
1374
+ * - Files were copied → the source repo's HEAD is their provenance. Undefined
1375
+ * is a legitimate answer here: a directory-type marketplace has no commit,
1376
+ * and content-drift stays silent rather than guessing.
1377
+ * - Nothing was copied → the cache is untouched, so whatever sha already
1378
+ * described it still does. Advancing it would claim the cache holds newer
1379
+ * files than it does, hiding real drift.
1380
+ *
1381
+ * The bug this replaces did neither: it preserved the pre-update sha even when
1382
+ * fresh files HAD been copied, so the recorded commit drifted further behind
1383
+ * with every release and drift detection reported a false positive on every
1384
+ * plugin at once.
1385
+ */
1386
+ export async function resolveCacheProvenance(
1387
+ copiedFrom: string | null,
1388
+ previousSha: string | undefined,
1389
+ ): Promise<string | undefined> {
1390
+ if (!copiedFrom) return previousSha;
1391
+ return await repoHeadSha(copiedFrom);
1392
+ }
1393
+
1394
+ /**
1395
+ * Read installed_plugins.json registry
1396
+ */
1397
+ export async function readInstalledPluginsRegistry(): Promise<InstalledPluginsRegistry> {
1398
+ try {
1399
+ if (await fs.pathExists(installedPluginsFile())) {
1400
+ const content = await fs.readJson(installedPluginsFile());
1401
+ // Validate structure
1402
+ if (!content.version || !content.plugins) {
1403
+ throw new Error("Invalid registry structure");
1404
+ }
1405
+ return content;
1406
+ }
1407
+ } catch (error) {
1408
+ // Backup corrupted file
1409
+ if (await fs.pathExists(installedPluginsFile())) {
1410
+ try {
1411
+ const backup = `${installedPluginsFile()}.backup.${Date.now()}`;
1412
+ await fs.copy(installedPluginsFile(), backup);
1413
+ console.warn(`Corrupted registry backed up to: ${backup}`);
1414
+ } catch {
1415
+ // Ignore backup errors
1416
+ }
1417
+ }
1418
+ }
1419
+ return { version: 2, plugins: {} };
1420
+ }
1421
+
1422
+ /**
1423
+ * Write installed_plugins.json registry
1424
+ */
1425
+ export async function writeInstalledPluginsRegistry(
1426
+ registry: InstalledPluginsRegistry,
1427
+ ): Promise<void> {
1428
+ await fs.ensureDir(path.dirname(installedPluginsFile()));
1429
+ await fs.writeJson(installedPluginsFile(), registry, { spaces: 2 });
1430
+ }
1431
+
1432
+ /**
1433
+ * Get install path for a plugin version in cache
1434
+ */
1435
+ function getPluginCachePath(
1436
+ pluginId: string,
1437
+ version: string,
1438
+ marketplace: string,
1439
+ ): string {
1440
+ const { pluginName } = parsePluginId(pluginId) || {
1441
+ pluginName: pluginId.split("@")[0],
1442
+ };
1443
+ return path.join(
1444
+ os.homedir(),
1445
+ ".claude",
1446
+ "plugins",
1447
+ "cache",
1448
+ marketplace,
1449
+ pluginName,
1450
+ version,
1451
+ );
1452
+ }
1453
+
1454
+ /**
1455
+ * Update installed_plugins.json when a plugin is installed/updated
1456
+ * Also copies plugin files from source to cache to ensure latest version is available
1457
+ */
1458
+ export async function updateInstalledPluginsRegistry(
1459
+ pluginId: string,
1460
+ version: string,
1461
+ scope: "user" | "project" | "local",
1462
+ projectPath?: string,
1463
+ ): Promise<void> {
1464
+ try {
1465
+ const registry = await readInstalledPluginsRegistry();
1466
+
1467
+ // Get marketplace from plugin ID
1468
+ const parsed = parsePluginId(pluginId);
1469
+ if (!parsed) {
1470
+ console.warn(`Invalid plugin ID: ${pluginId}, skipping registry update`);
1471
+ return;
1472
+ }
1473
+
1474
+ const { marketplace } = parsed;
1475
+
1476
+ // Copy plugin files from source to cache
1477
+ // This ensures the cache has the latest plugin version
1478
+ const copiedFrom = await copyPluginToCache(pluginId, version, marketplace);
1479
+
1480
+ const installPath = getPluginCachePath(pluginId, version, marketplace);
1481
+ const now = new Date().toISOString();
1482
+
1483
+ // Initialize plugin array if it doesn't exist
1484
+ if (!registry.plugins[pluginId]) {
1485
+ registry.plugins[pluginId] = [];
1486
+ }
1487
+
1488
+ // Find existing entry for this scope and project
1489
+ const existingIndex = registry.plugins[pluginId].findIndex((entry) => {
1490
+ if (entry.scope !== scope) return false;
1491
+ if (scope === "user") return true;
1492
+ return entry.projectPath === projectPath;
1493
+ });
1494
+
1495
+ const entry: InstalledPluginEntry = {
1496
+ scope,
1497
+ projectPath,
1498
+ installPath,
1499
+ version,
1500
+ installedAt:
1501
+ existingIndex >= 0
1502
+ ? registry.plugins[pluginId][existingIndex].installedAt
1503
+ : now,
1504
+ lastUpdated: now,
1505
+ gitCommitSha: await resolveCacheProvenance(
1506
+ copiedFrom,
1507
+ existingIndex >= 0
1508
+ ? registry.plugins[pluginId][existingIndex].gitCommitSha
1509
+ : undefined,
1510
+ ),
1511
+ };
1512
+
1513
+ if (existingIndex >= 0) {
1514
+ // Update existing entry
1515
+ registry.plugins[pluginId][existingIndex] = entry;
1516
+ } else {
1517
+ // Add new entry
1518
+ registry.plugins[pluginId].push(entry);
1519
+ }
1520
+
1521
+ await writeInstalledPluginsRegistry(registry);
1522
+ } catch (error) {
1523
+ // Log warning but don't block plugin operation
1524
+ console.warn(
1525
+ `Failed to update registry for ${pluginId}:`,
1526
+ error instanceof Error ? error.message : "Unknown error",
1527
+ );
1528
+ }
1529
+ }
1530
+
1531
+ /**
1532
+ * Remove plugin from installed_plugins.json registry
1533
+ */
1534
+ export async function removeFromInstalledPluginsRegistry(
1535
+ pluginId: string,
1536
+ scope: "user" | "project" | "local",
1537
+ projectPath?: string,
1538
+ ): Promise<void> {
1539
+ try {
1540
+ const registry = await readInstalledPluginsRegistry();
1541
+
1542
+ if (!registry.plugins[pluginId]) return;
1543
+
1544
+ // Remove entry matching scope and projectPath
1545
+ registry.plugins[pluginId] = registry.plugins[pluginId].filter((entry) => {
1546
+ if (entry.scope !== scope) return true;
1547
+ if (scope === "user") return false;
1548
+ return entry.projectPath !== projectPath;
1549
+ });
1550
+
1551
+ // Remove plugin key if no entries remain
1552
+ if (registry.plugins[pluginId].length === 0) {
1553
+ delete registry.plugins[pluginId];
1554
+ }
1555
+
1556
+ await writeInstalledPluginsRegistry(registry);
1557
+ } catch (error) {
1558
+ // Log warning but don't block plugin operation
1559
+ console.warn(
1560
+ `Failed to remove from registry for ${pluginId}:`,
1561
+ error instanceof Error ? error.message : "Unknown error",
1562
+ );
1563
+ }
1564
+ }