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,88 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { pickRegistryEntry } from "../services/claude-settings.js";
3
+ import type { InstalledPluginEntry } from "../types/index.js";
4
+
5
+ /**
6
+ * Regression cover for the "no outdated plugins shown" bug.
7
+ *
8
+ * magus used to read installed versions from `settings.installedPluginVersions`
9
+ * only. Claude Code's own `plugin install` writes installed_plugins.json but not
10
+ * that settings key, so a genuinely installed plugin could be missing from it.
11
+ * The update check is `installedVersion && compare(latest, installedVersion) > 0`,
12
+ * so a missing version resolved to `hasUpdate: false` — an available update was
13
+ * reported as "up to date" rather than "unknown".
14
+ */
15
+
16
+ const entry = (
17
+ scope: InstalledPluginEntry["scope"],
18
+ version: string,
19
+ projectPath?: string,
20
+ ): InstalledPluginEntry => ({
21
+ scope,
22
+ version,
23
+ projectPath,
24
+ installPath: `/cache/${version}`,
25
+ installedAt: "2026-01-01T00:00:00.000Z",
26
+ lastUpdated: "2026-01-01T00:00:00.000Z",
27
+ });
28
+
29
+ describe("pickRegistryEntry — scope resolution", () => {
30
+ test("user scope takes the user entry, ignoring project installs", () => {
31
+ const entries = [
32
+ entry("project", "1.0.0", "/proj/a"),
33
+ entry("user", "2.0.0"),
34
+ entry("project", "3.0.0", "/proj/b"),
35
+ ];
36
+ expect(pickRegistryEntry(entries, "user")?.version).toBe("2.0.0");
37
+ });
38
+
39
+ test("project scope prefers the entry pinned to that exact project", () => {
40
+ const entries = [
41
+ entry("user", "2.0.0"),
42
+ entry("project", "1.0.0", "/proj/a"),
43
+ entry("project", "3.0.0", "/proj/b"),
44
+ ];
45
+ expect(pickRegistryEntry(entries, "project", "/proj/b")?.version).toBe(
46
+ "3.0.0",
47
+ );
48
+ });
49
+
50
+ test("project scope falls back to the user entry when the project has no install", () => {
51
+ // This is the statusline case: installed at user scope, absent from project
52
+ // settings, previously surfaced as "no version" and skipped by update checks.
53
+ const entries = [
54
+ entry("user", "2.1.2"),
55
+ entry("project", "1.0.0", "/proj/other"),
56
+ ];
57
+ expect(pickRegistryEntry(entries, "project", "/proj/mine")?.version).toBe(
58
+ "2.1.2",
59
+ );
60
+ });
61
+
62
+ test("project paths are compared resolved, not as raw strings", () => {
63
+ const entries = [entry("project", "4.2.0", "/proj/a/../a")];
64
+ expect(pickRegistryEntry(entries, "project", "/proj/a")?.version).toBe(
65
+ "4.2.0",
66
+ );
67
+ });
68
+
69
+ test("a local-scope entry satisfies a project lookup for the same path", () => {
70
+ const entries = [
71
+ entry("local", "5.0.0", "/proj/a"),
72
+ entry("user", "1.0.0"),
73
+ ];
74
+ expect(pickRegistryEntry(entries, "project", "/proj/a")?.version).toBe(
75
+ "5.0.0",
76
+ );
77
+ });
78
+
79
+ test("no project path and no user entry yields undefined, not a wrong project's version", () => {
80
+ const entries = [entry("project", "9.9.9", "/proj/somewhere-else")];
81
+ expect(pickRegistryEntry(entries, "project")).toBeUndefined();
82
+ });
83
+
84
+ test("empty and missing entry lists are handled", () => {
85
+ expect(pickRegistryEntry([], "user")).toBeUndefined();
86
+ expect(pickRegistryEntry(undefined, "user")).toBeUndefined();
87
+ });
88
+ });
@@ -0,0 +1,42 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { chmod, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { resolveExecutable } from "../utils/command-utils.js";
6
+
7
+ /**
8
+ * resolveExecutable must treat a dangling symlink as NOT available — the exact
9
+ * failure mode behind a plugin's binary looking present (the symlink exists)
10
+ * while failing at runtime (its target is gone).
11
+ */
12
+ describe("resolveExecutable — dangling symlink detection", () => {
13
+ let dir: string;
14
+ let prevPath: string | undefined;
15
+
16
+ beforeEach(async () => {
17
+ dir = await mkdtemp(join(tmpdir(), "resolveexe-"));
18
+ prevPath = process.env.PATH;
19
+ process.env.PATH = `${dir}:${prevPath}`;
20
+ });
21
+ afterEach(async () => {
22
+ process.env.PATH = prevPath;
23
+ await rm(dir, { recursive: true, force: true });
24
+ });
25
+
26
+ test("a live executable resolves", async () => {
27
+ const bin = join(dir, "livetool");
28
+ await writeFile(bin, "#!/bin/sh\necho hi\n");
29
+ await chmod(bin, 0o755);
30
+ expect(await resolveExecutable("livetool")).not.toBeNull();
31
+ });
32
+
33
+ test("a dangling symlink resolves to null", async () => {
34
+ const link = join(dir, "ghosttool");
35
+ await symlink(join(dir, "does-not-exist"), link);
36
+ expect(await resolveExecutable("ghosttool")).toBeNull();
37
+ });
38
+
39
+ test("a missing command resolves to null", async () => {
40
+ expect(await resolveExecutable("definitely-not-a-real-cmd-xyz")).toBeNull();
41
+ });
42
+ });
@@ -0,0 +1,351 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { PREDEFINED_PROFILES } from "../data/predefined-profiles.js";
3
+ import {
4
+ dedupeBins,
5
+ resolveAllProfiles,
6
+ resolveExtends,
7
+ resolveProfile,
8
+ } from "../services/resolver.js";
9
+ import type {
10
+ BinRequirement,
11
+ ProfileManifest,
12
+ ProfileManifestEntry,
13
+ } from "../types/index.js";
14
+
15
+ // A binResolver that returns canned requires.bin per plugin — keeps the
16
+ // resolver tests hermetic (no marketplace cache / filesystem).
17
+ function fakeBinResolver(
18
+ table: Record<string, BinRequirement[]>,
19
+ ): (pluginId: string) => Promise<BinRequirement[]> {
20
+ return async (pluginId) => table[pluginId] ?? [];
21
+ }
22
+
23
+ function manifestOf(
24
+ profiles: Record<string, ProfileManifestEntry>,
25
+ ): ProfileManifest {
26
+ return { version: 2, profiles };
27
+ }
28
+
29
+ describe("dedupeBins — precedence by order", () => {
30
+ test("first-seen wins installer/version; later contributes only provenance", () => {
31
+ const claudishPin: BinRequirement = {
32
+ name: "claudish",
33
+ via: "bun",
34
+ package: "claudish",
35
+ version: "1.2.0",
36
+ };
37
+ const claudishLoose: BinRequirement = {
38
+ name: "claudish",
39
+ via: "npm",
40
+ package: "claudish",
41
+ };
42
+ const result = dedupeBins([
43
+ { bin: claudishPin, source: "profile:x.cliTools" },
44
+ { bin: claudishLoose, source: "plugin:foo@magus" },
45
+ ]);
46
+ expect(result).toHaveLength(1);
47
+ expect(result[0]!.via).toBe("bun");
48
+ expect(result[0]!.version).toBe("1.2.0");
49
+ expect(result[0]!.sources).toEqual([
50
+ "profile:x.cliTools",
51
+ "plugin:foo@magus",
52
+ ]);
53
+ });
54
+
55
+ test("a later entry fills a version the winner left unset", () => {
56
+ const loose: BinRequirement = { name: "tmux", via: "brew" };
57
+ const pinned: BinRequirement = {
58
+ name: "tmux",
59
+ via: "brew",
60
+ version: "3.4",
61
+ };
62
+ const result = dedupeBins([
63
+ { bin: loose, source: "plugin:a" },
64
+ { bin: pinned, source: "plugin:b" },
65
+ ]);
66
+ expect(result[0]!.version).toBe("3.4");
67
+ });
68
+ });
69
+
70
+ describe("resolveExtends", () => {
71
+ test("inherits predefined plugins/settings under the entry's own", () => {
72
+ const entry: ProfileManifestEntry = {
73
+ name: "Custom",
74
+ extends: "must-have",
75
+ plugins: { "dev@magus": "2.9.0" },
76
+ settings: { effortLevel: "max" },
77
+ };
78
+ const merged = resolveExtends(entry);
79
+ // must-have contributes setup@magus + multimodel@magus at "latest".
80
+ expect(merged.plugins!["setup@magus"]).toBe("latest");
81
+ expect(merged.plugins!["multimodel@magus"]).toBe("latest");
82
+ // Entry's own plugin pin is preserved.
83
+ expect(merged.plugins!["dev@magus"]).toBe("2.9.0");
84
+ // Entry setting wins over the inherited one.
85
+ expect(merged.settings!.effortLevel).toBe("max");
86
+ });
87
+
88
+ test("honors an explicit @marketplace on a predefined plugin name", () => {
89
+ const entry: ProfileManifestEntry = {
90
+ name: "Growth",
91
+ extends: "growth-marketer",
92
+ };
93
+ const merged = resolveExtends(entry);
94
+ // image/video-editing ship on the magus-marketing channel and
95
+ // pin it explicitly — they must NOT be rewritten to @magus.
96
+ expect(merged.plugins!["image@magus-marketing"]).toBe("latest");
97
+ expect(merged.plugins!["video-editing@magus-marketing"]).toBe("latest");
98
+ expect(merged.plugins!["image-generate@magus"]).toBeUndefined();
99
+ // Bare names still default to @magus.
100
+ expect(merged.plugins!["browser-use@magus"]).toBe("latest");
101
+ });
102
+
103
+ test("no extends is a no-op", () => {
104
+ const entry: ProfileManifestEntry = { name: "Plain", plugins: {} };
105
+ expect(resolveExtends(entry)).toBe(entry);
106
+ });
107
+
108
+ // `models` inherits like `settings` — later wins — but by WHOLE object: a
109
+ // ModelsConfig is validated as a unit (every grade present, one effort per
110
+ // model), so a key-by-key merge could produce a config neither side wrote
111
+ // and neither side validated.
112
+ test("the entry's own models win over an inherited one, whole-object", () => {
113
+ const base = {
114
+ version: 1,
115
+ preset: "inherited",
116
+ main: { model: "sonnet" },
117
+ grades: {
118
+ smart: { model: "opus" },
119
+ normal: { model: "sonnet" },
120
+ cheap: { model: "haiku" },
121
+ },
122
+ agents: {},
123
+ fallback: "normal",
124
+ } as ProfileManifestEntry["models"];
125
+ const own = { ...(base as object), preset: "mine" } as typeof base;
126
+
127
+ const withBase = PREDEFINED_PROFILES.find((p) => p.id === "must-have");
128
+ if (!withBase) throw new Error("must-have preset went missing");
129
+ withBase.models = base;
130
+ try {
131
+ expect(
132
+ resolveExtends({ name: "X", extends: "must-have" }).models?.preset,
133
+ ).toBe("inherited");
134
+ expect(
135
+ resolveExtends({ name: "X", extends: "must-have", models: own }).models
136
+ ?.preset,
137
+ ).toBe("mine");
138
+ } finally {
139
+ withBase.models = undefined;
140
+ }
141
+ });
142
+
143
+ test("unknown extends id is ignored (returns entry unchanged)", () => {
144
+ const entry: ProfileManifestEntry = { name: "X", extends: "nope" };
145
+ expect(resolveExtends(entry)).toBe(entry);
146
+ });
147
+ });
148
+
149
+ describe("resolveProfile", () => {
150
+ test("normalizes plugins, unions cliTools with plugin bins (project pin wins)", async () => {
151
+ const manifest = manifestOf({
152
+ frontend: {
153
+ name: "Frontend",
154
+ plugins: {
155
+ "terminal@magus": "4.1.0",
156
+ "dev@magus": true, // → latest
157
+ "old@magus": false, // → omitted
158
+ },
159
+ cliTools: { claudish: "1.2.0" },
160
+ env: { required: ["FIGMA_ACCESS_TOKEN"] },
161
+ },
162
+ });
163
+
164
+ const closure = await resolveProfile(manifest, "frontend", {
165
+ binResolver: fakeBinResolver({
166
+ "terminal@magus": [
167
+ { name: "tmux", via: "brew", formula: "tmux" },
168
+ // terminal ALSO wants claudish, but the profile pinned it →
169
+ // profile pin must win.
170
+ { name: "claudish", via: "npm", package: "claudish" },
171
+ ],
172
+ }),
173
+ });
174
+
175
+ expect(closure.plugins).toEqual({
176
+ "terminal@magus": "4.1.0",
177
+ "dev@magus": "latest",
178
+ });
179
+ expect(closure.env.required).toEqual(["FIGMA_ACCESS_TOKEN"]);
180
+
181
+ const claudish = closure.bins.find((b) => b.name === "claudish")!;
182
+ expect(claudish.via).toBe("bun"); // from the cli-tools catalog, not npm
183
+ expect(claudish.version).toBe("1.2.0");
184
+ expect(claudish.sources[0]).toContain("cliTools");
185
+
186
+ const tmux = closure.bins.find((b) => b.name === "tmux")!;
187
+ expect(tmux.via).toBe("brew");
188
+ expect(tmux.sources).toContain("plugin:terminal@magus");
189
+ });
190
+
191
+ test("throws for an unknown profile id", async () => {
192
+ await expect(resolveProfile(manifestOf({}), "ghost")).rejects.toThrow(
193
+ 'Profile "ghost" not found',
194
+ );
195
+ });
196
+ });
197
+
198
+ describe("resolveAllProfiles — union", () => {
199
+ test("unions plugins/bins/skills/env; a pin beats latest across profiles", async () => {
200
+ const manifest = manifestOf({
201
+ frontend: {
202
+ name: "Frontend",
203
+ plugins: { "dev@magus": "latest", "terminal@magus": "4.1.0" },
204
+ skills: [{ name: "A", repo: "o/r", path: "skills/a" }],
205
+ env: { required: ["FIGMA_ACCESS_TOKEN"] },
206
+ },
207
+ backend: {
208
+ name: "Backend",
209
+ plugins: { "dev@magus": "2.9.0" }, // pin should win over "latest"
210
+ cliTools: { mnemex: "latest" },
211
+ skills: [{ name: "A", repo: "o/r", path: "skills/a" }], // dupe
212
+ env: { required: ["DATABASE_URL"] },
213
+ },
214
+ });
215
+
216
+ const union = await resolveAllProfiles(manifest, {
217
+ binResolver: fakeBinResolver({}),
218
+ });
219
+
220
+ expect(union.plugins["dev@magus"]).toBe("2.9.0"); // pin beat latest
221
+ expect(union.plugins["terminal@magus"]).toBe("4.1.0");
222
+ expect(union.skills).toHaveLength(1); // deduped by repo#path
223
+ expect(union.env.required.sort()).toEqual([
224
+ "DATABASE_URL",
225
+ "FIGMA_ACCESS_TOKEN",
226
+ ]);
227
+ expect(union.bins.find((b) => b.name === "mnemex")?.via).toBe("bun");
228
+ expect(union.conflicts).toBeUndefined(); // latest-vs-pin isn't a conflict
229
+ });
230
+
231
+ test("conflicting explicit pins resolve to the higher semver, order-independent, and are reported", async () => {
232
+ const mk = (order: "ab" | "ba"): ProfileManifest => {
233
+ const a: ProfileManifestEntry = {
234
+ name: "A",
235
+ plugins: { "dev@magus": "2.9.0" },
236
+ };
237
+ const b: ProfileManifestEntry = {
238
+ name: "B",
239
+ plugins: { "dev@magus": "2.11.0" },
240
+ };
241
+ return manifestOf(order === "ab" ? { a, b } : { b, a });
242
+ };
243
+ const ab = await resolveAllProfiles(mk("ab"), {
244
+ binResolver: fakeBinResolver({}),
245
+ });
246
+ const ba = await resolveAllProfiles(mk("ba"), {
247
+ binResolver: fakeBinResolver({}),
248
+ });
249
+
250
+ // Deterministic: higher pin wins regardless of profile order.
251
+ expect(ab.plugins["dev@magus"]).toBe("2.11.0");
252
+ expect(ba.plugins["dev@magus"]).toBe("2.11.0");
253
+ // And the conflict is surfaced, not silent.
254
+ expect(ab.conflicts?.some((c) => c.includes("dev@magus"))).toBe(true);
255
+ expect(ba.conflicts?.some((c) => c.includes("dev@magus"))).toBe(true);
256
+ });
257
+
258
+ test("conflicting bin pins across profiles resolve deterministically + report", async () => {
259
+ const manifest = manifestOf({
260
+ a: { name: "A", cliTools: { claudish: "1.2.0" } },
261
+ b: { name: "B", cliTools: { claudish: "1.5.0" } },
262
+ });
263
+ const union = await resolveAllProfiles(manifest, {
264
+ binResolver: fakeBinResolver({}),
265
+ });
266
+ expect(union.bins.find((x) => x.name === "claudish")?.version).toBe(
267
+ "1.5.0",
268
+ );
269
+ expect(union.conflicts?.some((c) => c.includes("claudish"))).toBe(true);
270
+ });
271
+ });
272
+
273
+ describe("marketplaces derived from plugin ids", () => {
274
+ const noBins = { binResolver: fakeBinResolver({}) };
275
+
276
+ test("a plugin id registers the marketplace it names", async () => {
277
+ const manifest = manifestOf({
278
+ solo: { name: "Solo", plugins: { "dev@magus": "latest" } },
279
+ });
280
+ const c = await resolveProfile(manifest, "solo", noBins);
281
+ expect(c.marketplaces.magus).toEqual({
282
+ source: "github",
283
+ repo: "MadAppGang/magus",
284
+ });
285
+ });
286
+
287
+ test("extends alone is enough — the failure this fixes", async () => {
288
+ // A profile that only extends a predefined one inherits plugins like
289
+ // dev@magus and previously NO marketplaces, so install resolved every
290
+ // plugin against a marketplace that was never registered.
291
+ const manifest = manifestOf({
292
+ team: { name: "Team", extends: "developer-essentials" },
293
+ });
294
+ const c = await resolveProfile(manifest, "team", noBins);
295
+ expect(Object.keys(c.plugins).length).toBeGreaterThan(0);
296
+ expect(c.marketplaces.magus?.repo).toBe("MadAppGang/magus");
297
+ });
298
+
299
+ test("each distinct channel is derived, not just the first", async () => {
300
+ const manifest = manifestOf({
301
+ mixed: {
302
+ name: "Mixed",
303
+ plugins: {
304
+ "dev@magus": "latest",
305
+ "image@magus-marketing": "latest",
306
+ "feature-dev@claude-plugins-official": "latest",
307
+ },
308
+ },
309
+ });
310
+ const c = await resolveProfile(manifest, "mixed", noBins);
311
+ expect(c.marketplaces["magus-marketing"]?.repo).toBe(
312
+ "MadAppGang/magus-marketing",
313
+ );
314
+ expect(c.marketplaces["claude-plugins-official"]?.repo).toBe(
315
+ "anthropics/claude-plugins-official",
316
+ );
317
+ });
318
+
319
+ test("an explicit declaration is never overridden", async () => {
320
+ const manifest = manifestOf({
321
+ pinned: {
322
+ name: "Pinned",
323
+ marketplaces: {
324
+ magus: { source: "github", repo: "MadAppGang/magus-fork" },
325
+ },
326
+ plugins: { "dev@magus": "latest" },
327
+ },
328
+ });
329
+ const c = await resolveProfile(manifest, "pinned", noBins);
330
+ expect(c.marketplaces.magus?.repo).toBe("MadAppGang/magus-fork");
331
+ });
332
+
333
+ test("an unknown marketplace is left for the manifest to declare", async () => {
334
+ const manifest = manifestOf({
335
+ third: {
336
+ name: "Third",
337
+ plugins: { "thing@someones-marketplace": "latest" },
338
+ },
339
+ });
340
+ const c = await resolveProfile(manifest, "third", noBins);
341
+ expect(c.marketplaces["someones-marketplace"]).toBeUndefined();
342
+ });
343
+
344
+ test("a bare plugin id contributes no marketplace", async () => {
345
+ const manifest = manifestOf({
346
+ bare: { name: "Bare", plugins: { local: "latest" } },
347
+ });
348
+ const c = await resolveProfile(manifest, "bare", noBins);
349
+ expect(Object.keys(c.marketplaces)).toHaveLength(0);
350
+ });
351
+ });
@@ -0,0 +1,134 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ type PluginInfo,
4
+ isInstalledInScope,
5
+ resolveScopeAction,
6
+ } from "../services/plugin-manager.js";
7
+ import { planPluginUpdates, scopeIsBehind } from "../services/update-plan.js";
8
+
9
+ /**
10
+ * The scope keys (u / p / l) and the Enter scope picker both decide between
11
+ * install, update and uninstall. That decision used to read `scope.enabled` —
12
+ * the `enabledPlugins` flag from a settings file — and ignore `scope.version`,
13
+ * which is the registry-backed answer to "did Claude Code actually install
14
+ * this".
15
+ *
16
+ * Those two disagree in a real, common state: a plugin listed in
17
+ * `enabledPlugins` with no entry in `installed_plugins.json` for the current
18
+ * project path. The list row renders it "not installed" (isEnabledButNotInstalled),
19
+ * and pressing its scope key ran `claude plugin uninstall`. The user asked to
20
+ * install the thing the UI told them was missing, and it was removed instead.
21
+ */
22
+
23
+ describe("isInstalledInScope", () => {
24
+ test("enabled with a registry version is installed", () => {
25
+ expect(isInstalledInScope({ enabled: true, version: "3.0.2" })).toBe(true);
26
+ });
27
+
28
+ test("enabled with NO version is not installed — the broken state", () => {
29
+ // dev@magus, measured: `.claude/settings.json` had "dev@magus": true while
30
+ // installed_plugins.json had no entry resolving for the project path.
31
+ expect(isInstalledInScope({ enabled: true })).toBe(false);
32
+ });
33
+
34
+ test("a registry version with the flag off IS installed", () => {
35
+ // Installation and enablement are separate facts. This asserted `false`
36
+ // until it was measured against the other direction: a plugin with a real
37
+ // registry row that the user had simply switched off reported "not
38
+ // installed", so its row offered to install what was already on disk, and
39
+ // the header counted it out.
40
+ //
41
+ // The hazard the sibling test above guards — pressing a scope key on
42
+ // enabled-with-no-version and having it run `uninstall` — is untouched:
43
+ // that case has no version, so it is still not installed, and still
44
+ // resolves to `install`.
45
+ expect(isInstalledInScope({ enabled: false, version: "3.0.2" })).toBe(true);
46
+ });
47
+
48
+ test("an absent scope is not installed", () => {
49
+ expect(isInstalledInScope(undefined)).toBe(false);
50
+ });
51
+
52
+ test('"0.0.0" counts as installed — Anthropic official plugins record it', () => {
53
+ expect(isInstalledInScope({ enabled: true, version: "0.0.0" })).toBe(true);
54
+ });
55
+ });
56
+
57
+ describe("resolveScopeAction", () => {
58
+ // The second argument is "is this scope provably behind?", which the screen
59
+ // takes from the classifier (`scopeIsBehind`). It used to be the catalog
60
+ // version, compared here with raw string inequality — a fourth rule.
61
+
62
+ test("REGRESSION: enabled but not installed resolves to install, never uninstall", () => {
63
+ // This is the reported bug. Before the fix this returned "uninstall".
64
+ expect(resolveScopeAction({ enabled: true }, false)).toBe("install");
65
+ // Whatever the classifier says: the safety branch comes first.
66
+ expect(resolveScopeAction({ enabled: true }, true)).toBe("install");
67
+ });
68
+
69
+ test("a scope that was never enabled resolves to install", () => {
70
+ expect(resolveScopeAction(undefined, false)).toBe("install");
71
+ expect(resolveScopeAction({ enabled: false }, false)).toBe("install");
72
+ });
73
+
74
+ test("a healthy install at the latest version resolves to uninstall (toggle off)", () => {
75
+ expect(resolveScopeAction({ enabled: true, version: "3.0.2" }, false)).toBe(
76
+ "uninstall",
77
+ );
78
+ });
79
+
80
+ test("a healthy install behind the catalog resolves to update", () => {
81
+ expect(resolveScopeAction({ enabled: true, version: "2.9.0" }, true)).toBe(
82
+ "update",
83
+ );
84
+ });
85
+ });
86
+
87
+ /** The screen's path: classify, then ask `scopeIsBehind` for the pressed scope. */
88
+ function keyFor(installed: string, catalogVersion: string | null) {
89
+ const scope = { enabled: true, version: installed };
90
+ const [status] = planPluginUpdates(
91
+ { "x@magus": "latest" },
92
+ new Map([
93
+ [
94
+ "x@magus",
95
+ {
96
+ id: "x@magus",
97
+ name: "x",
98
+ version: catalogVersion,
99
+ description: "",
100
+ marketplace: "magus",
101
+ marketplaceDisplay: "Magus",
102
+ enabled: true,
103
+ projectScope: scope,
104
+ } as PluginInfo,
105
+ ],
106
+ ]),
107
+ );
108
+ return resolveScopeAction(scope, scopeIsBehind(status, "project"));
109
+ }
110
+
111
+ describe("resolveScopeAction through the classifier — the screen's real path", () => {
112
+ test('an unknown catalog version ("0.0.0") never fabricates an update', () => {
113
+ // There is nothing to update *to*, so toggling must still mean uninstall.
114
+ expect(keyFor("3.0.2", "0.0.0")).toBe("uninstall");
115
+ });
116
+
117
+ test("installed with unknown version resolves to update when a real version is published", () => {
118
+ // "0.0.0" installed against a real catalog version is a genuine upgrade
119
+ // path, not the broken state — it must not be treated as uninstall.
120
+ expect(keyFor("0.0.0", "3.0.2")).toBe("update");
121
+ });
122
+
123
+ test("REGRESSION: a scope AHEAD of the catalog no longer resolves to update", () => {
124
+ // Raw string inequality called 3.1.0 vs 3.0.2 "behind" and offered an
125
+ // update that could only reinstall the older version or no-op.
126
+ expect(keyFor("3.1.0", "3.0.2")).toBe("uninstall");
127
+ });
128
+
129
+ test("REGRESSION: an unorderable scope no longer resolves to update", () => {
130
+ // A sha against a semver cannot be ordered. The row says "?", so the key
131
+ // must not act as though it knew the scope was behind.
132
+ expect(keyFor("3ea32df27be7", "3.0.2")).toBe("uninstall");
133
+ });
134
+ });