magus-cli 0.0.0-placeholder → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (331) hide show
  1. package/README.md +309 -0
  2. package/bin/magus.js +170 -0
  3. package/package.json +67 -3
  4. package/scripts/build-binaries.ts +113 -0
  5. package/scripts/capture-builtin.ts +412 -0
  6. package/scripts/check-optional-deps.ts +49 -0
  7. package/scripts/test-isolated.ts +49 -0
  8. package/scripts/verify-binary.ts +95 -0
  9. package/scripts/verify-community-registry.ts +272 -0
  10. package/src/__tests__/active-closure.test.ts +184 -0
  11. package/src/__tests__/alias-adopt.test.ts +365 -0
  12. package/src/__tests__/alias-parser.test.ts +417 -0
  13. package/src/__tests__/alias-shell-writer.test.ts +681 -0
  14. package/src/__tests__/alias-store.test.ts +163 -0
  15. package/src/__tests__/binary-signing.test.ts +120 -0
  16. package/src/__tests__/blackbox-active-closure.test.ts +119 -0
  17. package/src/__tests__/blackbox-convergence.test.ts +334 -0
  18. package/src/__tests__/blackbox-parity.test.ts +684 -0
  19. package/src/__tests__/blackbox-plugin-state.test.ts +523 -0
  20. package/src/__tests__/blackbox-tool-state.test.ts +669 -0
  21. package/src/__tests__/cache-provenance.test.ts +84 -0
  22. package/src/__tests__/capture-builtin.test.ts +92 -0
  23. package/src/__tests__/catalog-cache-store.test.ts +309 -0
  24. package/src/__tests__/catalog-notice.test.ts +155 -0
  25. package/src/__tests__/cli-ansi.test.ts +297 -0
  26. package/src/__tests__/cli-apply-seams.test.ts +288 -0
  27. package/src/__tests__/cli-live.test.ts +391 -0
  28. package/src/__tests__/cli-router.test.ts +240 -0
  29. package/src/__tests__/cli-tool-commands.test.ts +239 -0
  30. package/src/__tests__/cli-tools-adapter.test.ts +294 -0
  31. package/src/__tests__/cli-update-view.test.ts +445 -0
  32. package/src/__tests__/community-fetch.test.ts +545 -0
  33. package/src/__tests__/community-registry.test.ts +269 -0
  34. package/src/__tests__/community-staleness.test.ts +720 -0
  35. package/src/__tests__/component-badge.test.ts +98 -0
  36. package/src/__tests__/component-icons.test.ts +94 -0
  37. package/src/__tests__/content-drift.test.ts +353 -0
  38. package/src/__tests__/conventions-integration.test.ts +830 -0
  39. package/src/__tests__/conventions-manager.test.ts +1236 -0
  40. package/src/__tests__/doctor-bins.test.ts +43 -0
  41. package/src/__tests__/doctor.test.ts +463 -0
  42. package/src/__tests__/dotenv.test.ts +195 -0
  43. package/src/__tests__/enabled-not-installed.test.ts +94 -0
  44. package/src/__tests__/file-locking.test.ts +208 -0
  45. package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
  46. package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
  47. package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
  48. package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
  49. package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
  50. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
  51. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
  52. package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
  53. package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
  54. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
  55. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
  56. package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
  57. package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
  58. package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
  59. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
  60. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
  61. package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
  62. package/src/__tests__/footer-hints.test.ts +40 -0
  63. package/src/__tests__/format-command.test.ts +78 -0
  64. package/src/__tests__/git-worktree.test.ts +108 -0
  65. package/src/__tests__/github-budget.test.ts +206 -0
  66. package/src/__tests__/gitignore-check.test.ts +71 -0
  67. package/src/__tests__/gitignore-detector.test.ts +176 -0
  68. package/src/__tests__/gitignore-fixer.test.ts +317 -0
  69. package/src/__tests__/gitignore-resolver.test.ts +193 -0
  70. package/src/__tests__/gitignore-service.test.ts +137 -0
  71. package/src/__tests__/go-module.test.ts +43 -0
  72. package/src/__tests__/hook-import-policy.test.ts +90 -0
  73. package/src/__tests__/hook-process.test.ts +256 -0
  74. package/src/__tests__/hook-registration.test.ts +224 -0
  75. package/src/__tests__/install-command.test.ts +81 -0
  76. package/src/__tests__/install-plan.test.ts +104 -0
  77. package/src/__tests__/launcher-signal.test.ts +308 -0
  78. package/src/__tests__/local-marketplace-scan.test.ts +174 -0
  79. package/src/__tests__/manifest.test.ts +281 -0
  80. package/src/__tests__/markdown-renderer.test.ts +142 -0
  81. package/src/__tests__/marketplace-badge.test.ts +140 -0
  82. package/src/__tests__/marketplace-refresh.test.ts +335 -0
  83. package/src/__tests__/marketplace-version.test.ts +84 -0
  84. package/src/__tests__/marketplaces.test.ts +128 -0
  85. package/src/__tests__/mate-availability.test.ts +156 -0
  86. package/src/__tests__/mate-catalog.test.ts +295 -0
  87. package/src/__tests__/mcp-registry.test.ts +158 -0
  88. package/src/__tests__/model-visuals.test.tsx +2466 -0
  89. package/src/__tests__/models-adapter.test.ts +332 -0
  90. package/src/__tests__/models-cli.test.ts +273 -0
  91. package/src/__tests__/models-core.test.ts +802 -0
  92. package/src/__tests__/models-manager.test.ts +500 -0
  93. package/src/__tests__/models-presets-marketplace.test.ts +168 -0
  94. package/src/__tests__/models-screen-state.test.ts +315 -0
  95. package/src/__tests__/moved-marketplace.test.ts +86 -0
  96. package/src/__tests__/open-file.test.ts +59 -0
  97. package/src/__tests__/owned-marketplaces.test.ts +93 -0
  98. package/src/__tests__/plugin-checkout.test.ts +66 -0
  99. package/src/__tests__/plugin-cli-argv.test.ts +109 -0
  100. package/src/__tests__/plugin-contents.test.ts +371 -0
  101. package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
  102. package/src/__tests__/plugin-releases.test.ts +98 -0
  103. package/src/__tests__/plugin-requires.test.ts +187 -0
  104. package/src/__tests__/plugin-row-text.test.ts +336 -0
  105. package/src/__tests__/plugin-setup.test.ts +1016 -0
  106. package/src/__tests__/profile-adopt.test.ts +177 -0
  107. package/src/__tests__/profile-command.test.ts +110 -0
  108. package/src/__tests__/profile-materializer.test.ts +238 -0
  109. package/src/__tests__/profile-sync.test.ts +154 -0
  110. package/src/__tests__/registry-version-resolution.test.ts +88 -0
  111. package/src/__tests__/resolve-executable.test.ts +42 -0
  112. package/src/__tests__/resolver.test.ts +351 -0
  113. package/src/__tests__/scope-action.test.ts +134 -0
  114. package/src/__tests__/scope-squares.test.tsx +164 -0
  115. package/src/__tests__/selection-scope.test.ts +100 -0
  116. package/src/__tests__/settings-file.test.ts +179 -0
  117. package/src/__tests__/shell-script-callers.test.ts +92 -0
  118. package/src/__tests__/skill-install-files.test.ts +136 -0
  119. package/src/__tests__/skills-catalog-client.test.ts +48 -0
  120. package/src/__tests__/style-wrap.test.ts +220 -0
  121. package/src/__tests__/styles-manager.test.ts +1098 -0
  122. package/src/__tests__/styles-origins.test.ts +414 -0
  123. package/src/__tests__/styles-screen-state.test.ts +457 -0
  124. package/src/__tests__/styles-status-line.test.ts +72 -0
  125. package/src/__tests__/styles-sync.test.ts +452 -0
  126. package/src/__tests__/symlink-manager.test.ts +180 -0
  127. package/src/__tests__/tabbar-layout.test.ts +100 -0
  128. package/src/__tests__/terminology-filler.test.ts +214 -0
  129. package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
  130. package/src/__tests__/theme-env.test.ts +281 -0
  131. package/src/__tests__/theme-resolve.test.ts +380 -0
  132. package/src/__tests__/tool-probe.test.ts +1400 -0
  133. package/src/__tests__/toolchain.test.ts +232 -0
  134. package/src/__tests__/ui-version-writers.test.ts +88 -0
  135. package/src/__tests__/update-apply.test.ts +637 -0
  136. package/src/__tests__/update-plan.test.ts +1373 -0
  137. package/src/__tests__/uppercase-keybindings.test.ts +101 -0
  138. package/src/__tests__/useGitignoreModal.test.ts +79 -0
  139. package/src/__tests__/version-check-refresh.test.ts +45 -0
  140. package/src/__tests__/version-snapshot.test.ts +212 -0
  141. package/src/__tests__/version-unknown-state.test.ts +388 -0
  142. package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
  143. package/src/cli/ansi.ts +520 -0
  144. package/src/cli/bootstrap.ts +134 -0
  145. package/src/cli/doctor.ts +218 -0
  146. package/src/cli/hook.ts +129 -0
  147. package/src/cli/install.ts +382 -0
  148. package/src/cli/live.ts +374 -0
  149. package/src/cli/models.ts +297 -0
  150. package/src/cli/profile.ts +197 -0
  151. package/src/cli/prompt.ts +32 -0
  152. package/src/cli/router.ts +207 -0
  153. package/src/cli/update-view.ts +526 -0
  154. package/src/cli/update.ts +613 -0
  155. package/src/cli/upgrade.ts +97 -0
  156. package/src/data/alias-flags.ts +301 -0
  157. package/src/data/cli-tools.ts +146 -0
  158. package/src/data/community-styles.ts +531 -0
  159. package/src/data/gitignore-defaults.ts +33 -0
  160. package/src/data/gitignore-reasons.ts +93 -0
  161. package/src/data/gitignore-templates.ts +25 -0
  162. package/src/data/marketplaces.ts +278 -0
  163. package/src/data/mcp-servers.ts +51 -0
  164. package/src/data/models-presets.ts +329 -0
  165. package/src/data/predefined-profiles.ts +252 -0
  166. package/src/data/settings-catalog.ts +714 -0
  167. package/src/data/skill-repos.ts +91 -0
  168. package/src/data/styles/asd-ste100.md +38 -0
  169. package/src/data/styles/calibrated.md +24 -0
  170. package/src/data/styles/direct.md +23 -0
  171. package/src/data/styles/evidence-first.md +22 -0
  172. package/src/data/styles/explanatory.md +23 -0
  173. package/src/data/styles/index.ts +57 -0
  174. package/src/data/styles/no-slop.md +32 -0
  175. package/src/data/styles/plain-language.md +23 -0
  176. package/src/data/styles/structured.md +30 -0
  177. package/src/data/styles/terminology.md +28 -0
  178. package/src/data/styles/terse.md +20 -0
  179. package/src/main.tsx +110 -0
  180. package/src/markdown.d.ts +13 -0
  181. package/src/opentui.d.ts +203 -0
  182. package/src/services/active-closure.ts +117 -0
  183. package/src/services/alias-settings.ts +60 -0
  184. package/src/services/alias-shell-writer.ts +1244 -0
  185. package/src/services/alias-store.ts +172 -0
  186. package/src/services/binary-signing.ts +78 -0
  187. package/src/services/catalog-cache-store.ts +316 -0
  188. package/src/services/claude-cli.ts +304 -0
  189. package/src/services/claude-runner.ts +31 -0
  190. package/src/services/claude-settings.ts +1589 -0
  191. package/src/services/cli-tool-commands.ts +133 -0
  192. package/src/services/community-fetcher.ts +90 -0
  193. package/src/services/community-styles.ts +1194 -0
  194. package/src/services/content-drift.ts +208 -0
  195. package/src/services/conventions-manager.ts +945 -0
  196. package/src/services/doctor-bins.ts +63 -0
  197. package/src/services/doctor.ts +570 -0
  198. package/src/services/dotenv.ts +181 -0
  199. package/src/services/git-worktree.ts +129 -0
  200. package/src/services/github-budget.ts +274 -0
  201. package/src/services/gitignore-check.ts +63 -0
  202. package/src/services/gitignore-detector.ts +167 -0
  203. package/src/services/gitignore-fixer.ts +280 -0
  204. package/src/services/gitignore-resolver.ts +208 -0
  205. package/src/services/gitignore-service.ts +167 -0
  206. package/src/services/hook-registration.ts +218 -0
  207. package/src/services/install-plan.ts +107 -0
  208. package/src/services/local-marketplace.ts +587 -0
  209. package/src/services/manifest.ts +250 -0
  210. package/src/services/marketplace-catalog-git.ts +173 -0
  211. package/src/services/marketplace-catalog.ts +129 -0
  212. package/src/services/marketplace-fetcher.ts +400 -0
  213. package/src/services/marketplace-refresh.ts +253 -0
  214. package/src/services/marketplace-sync.ts +145 -0
  215. package/src/services/mate-availability.ts +133 -0
  216. package/src/services/mate-catalog.ts +265 -0
  217. package/src/services/mcp-registry.ts +171 -0
  218. package/src/services/models-core.ts +969 -0
  219. package/src/services/models-manager.ts +606 -0
  220. package/src/services/plugin-checkout.ts +186 -0
  221. package/src/services/plugin-manager.ts +878 -0
  222. package/src/services/plugin-mcp-config.ts +242 -0
  223. package/src/services/plugin-releases.ts +47 -0
  224. package/src/services/plugin-requires.ts +227 -0
  225. package/src/services/plugin-setup.ts +965 -0
  226. package/src/services/profile-adopt.ts +206 -0
  227. package/src/services/profile-materializer.ts +133 -0
  228. package/src/services/profile-sync.ts +158 -0
  229. package/src/services/profiles.ts +225 -0
  230. package/src/services/resolver.ts +344 -0
  231. package/src/services/settings-file.ts +69 -0
  232. package/src/services/settings-manager.ts +307 -0
  233. package/src/services/skills-manager.ts +1111 -0
  234. package/src/services/skillsmp-client.ts +193 -0
  235. package/src/services/styles-manager.ts +1294 -0
  236. package/src/services/symlink-manager.ts +189 -0
  237. package/src/services/terminology-filler.ts +266 -0
  238. package/src/services/tool-probe.ts +1637 -0
  239. package/src/services/toolchain.ts +431 -0
  240. package/src/services/update-engine.ts +691 -0
  241. package/src/services/update-plan.ts +863 -0
  242. package/src/services/version-check.ts +127 -0
  243. package/src/services/version-snapshot.ts +189 -0
  244. package/src/tui.tsx +112 -0
  245. package/src/types/bun.d.ts +27 -0
  246. package/src/types/gitignore.ts +68 -0
  247. package/src/types/index.ts +447 -0
  248. package/src/ui/App.tsx +538 -0
  249. package/src/ui/adapters/catalogNotice.ts +122 -0
  250. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  251. package/src/ui/adapters/modelsAdapter.ts +213 -0
  252. package/src/ui/adapters/pluginsAdapter.ts +413 -0
  253. package/src/ui/adapters/settingsAdapter.ts +168 -0
  254. package/src/ui/adapters/skillsAdapter.ts +229 -0
  255. package/src/ui/adapters/stylesAdapter.ts +403 -0
  256. package/src/ui/components/CategoryHeader.tsx +64 -0
  257. package/src/ui/components/EmptyFilterState.tsx +32 -0
  258. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  259. package/src/ui/components/ScopeIndicator.tsx +63 -0
  260. package/src/ui/components/ScrollableList.tsx +95 -0
  261. package/src/ui/components/SearchInput.tsx +57 -0
  262. package/src/ui/components/StyledText.tsx +75 -0
  263. package/src/ui/components/TabBar.tsx +101 -0
  264. package/src/ui/components/layout/FooterHints.tsx +78 -0
  265. package/src/ui/components/layout/Panel.tsx +63 -0
  266. package/src/ui/components/layout/ProgressBar.tsx +44 -0
  267. package/src/ui/components/layout/ScopeTabs.tsx +54 -0
  268. package/src/ui/components/layout/ScreenLayout.tsx +321 -0
  269. package/src/ui/components/layout/index.ts +9 -0
  270. package/src/ui/components/modals/ConfirmModal.tsx +62 -0
  271. package/src/ui/components/modals/InputModal.tsx +75 -0
  272. package/src/ui/components/modals/LoadingModal.tsx +51 -0
  273. package/src/ui/components/modals/MessageModal.tsx +66 -0
  274. package/src/ui/components/modals/ModalContainer.tsx +159 -0
  275. package/src/ui/components/modals/SelectModal.tsx +90 -0
  276. package/src/ui/components/modals/index.ts +6 -0
  277. package/src/ui/components/primitives/ActionHints.tsx +44 -0
  278. package/src/ui/components/primitives/DetailSection.tsx +23 -0
  279. package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
  280. package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
  281. package/src/ui/components/primitives/MetaText.tsx +40 -0
  282. package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
  283. package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
  284. package/src/ui/components/primitives/SelectableRow.tsx +30 -0
  285. package/src/ui/components/primitives/index.ts +9 -0
  286. package/src/ui/hooks/index.ts +2 -0
  287. package/src/ui/hooks/useGitignoreModal.ts +95 -0
  288. package/src/ui/hooks/useKeyboard.ts +24 -0
  289. package/src/ui/hooks/useKeyboardHandler.ts +63 -0
  290. package/src/ui/registry.ts +33 -0
  291. package/src/ui/renderers/cliToolRenderers.tsx +326 -0
  292. package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
  293. package/src/ui/renderers/markdownRenderer.tsx +302 -0
  294. package/src/ui/renderers/mcpRenderers.tsx +149 -0
  295. package/src/ui/renderers/modelRenderers.tsx +1309 -0
  296. package/src/ui/renderers/modelVisuals.tsx +1402 -0
  297. package/src/ui/renderers/pluginRenderers.tsx +1089 -0
  298. package/src/ui/renderers/pluginRowText.ts +70 -0
  299. package/src/ui/renderers/pluginStatusText.ts +56 -0
  300. package/src/ui/renderers/profileRenderers.tsx +406 -0
  301. package/src/ui/renderers/settingsRenderers.tsx +218 -0
  302. package/src/ui/renderers/skillRenderers.tsx +479 -0
  303. package/src/ui/renderers/styleRenderers.tsx +813 -0
  304. package/src/ui/screens/AliasScreen.tsx +1558 -0
  305. package/src/ui/screens/CliToolsScreen.tsx +367 -0
  306. package/src/ui/screens/EnvVarsScreen.tsx +221 -0
  307. package/src/ui/screens/GitignoreScreen.tsx +562 -0
  308. package/src/ui/screens/McpRegistryScreen.tsx +366 -0
  309. package/src/ui/screens/McpScreen.tsx +256 -0
  310. package/src/ui/screens/ModelsScreen.tsx +542 -0
  311. package/src/ui/screens/PluginsScreen.tsx +1464 -0
  312. package/src/ui/screens/ProfilesScreen.tsx +438 -0
  313. package/src/ui/screens/SkillsScreen.tsx +853 -0
  314. package/src/ui/screens/StylesScreen.tsx +1064 -0
  315. package/src/ui/screens/index.ts +11 -0
  316. package/src/ui/state/AppContext.tsx +233 -0
  317. package/src/ui/state/DimensionsContext.tsx +154 -0
  318. package/src/ui/state/reducer.ts +810 -0
  319. package/src/ui/state/types.ts +503 -0
  320. package/src/ui/theme-mode.ts +352 -0
  321. package/src/ui/theme-resolve.ts +147 -0
  322. package/src/ui/theme.ts +175 -0
  323. package/src/utils/clipboard.ts +58 -0
  324. package/src/utils/command-utils.ts +144 -0
  325. package/src/utils/config-dir.ts +45 -0
  326. package/src/utils/file-locking.ts +146 -0
  327. package/src/utils/fuzzy-search.ts +138 -0
  328. package/src/utils/go-module.ts +36 -0
  329. package/src/utils/open-file.ts +84 -0
  330. package/src/utils/run.ts +67 -0
  331. package/src/utils/string-utils.ts +88 -0
@@ -0,0 +1,104 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ computeEnvStatus,
4
+ computeVersionDrift,
5
+ summarizeClosure,
6
+ } from "../services/install-plan.js";
7
+ import type {
8
+ InstalledPluginsRegistry,
9
+ ResolvedClosure,
10
+ } from "../types/index.js";
11
+
12
+ function closure(overrides: Partial<ResolvedClosure> = {}): ResolvedClosure {
13
+ return {
14
+ marketplaces: {},
15
+ plugins: {},
16
+ mcpServers: {},
17
+ bins: [],
18
+ skills: [],
19
+ settings: {},
20
+ env: { required: [], optional: [] },
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ function registry(plugins: Record<string, string[]>): InstalledPluginsRegistry {
26
+ const out: InstalledPluginsRegistry = { version: 2, plugins: {} };
27
+ for (const [id, versions] of Object.entries(plugins)) {
28
+ out.plugins[id] = versions.map((v) => ({
29
+ scope: "user",
30
+ installPath: `/cache/${id}/${v}`,
31
+ version: v,
32
+ installedAt: "t",
33
+ lastUpdated: "t",
34
+ }));
35
+ }
36
+ return out;
37
+ }
38
+
39
+ describe("computeVersionDrift", () => {
40
+ test("no drift when installed matches the pin", () => {
41
+ const c = closure({ plugins: { "dev@magus": "2.9.0" } });
42
+ expect(
43
+ computeVersionDrift(c, registry({ "dev@magus": ["2.9.0"] })),
44
+ ).toEqual([]);
45
+ });
46
+
47
+ test("'latest' pins are never drift-checked", () => {
48
+ const c = closure({ plugins: { "dev@magus": "latest" } });
49
+ expect(
50
+ computeVersionDrift(c, registry({ "dev@magus": ["1.0.0"] })),
51
+ ).toEqual([]);
52
+ });
53
+
54
+ test("flags a mismatch, reporting the highest installed version", () => {
55
+ const c = closure({ plugins: { "dev@magus": "2.9.0" } });
56
+ const drift = computeVersionDrift(
57
+ c,
58
+ registry({ "dev@magus": ["2.8.0", "2.10.0"] }),
59
+ );
60
+ expect(drift).toEqual([
61
+ { pluginId: "dev@magus", pinned: "2.9.0", installed: "2.10.0" },
62
+ ]);
63
+ });
64
+
65
+ test("flags a pinned plugin that isn't installed at all", () => {
66
+ const c = closure({ plugins: { "terminal@magus": "4.1.0" } });
67
+ expect(computeVersionDrift(c, registry({}))).toEqual([
68
+ { pluginId: "terminal@magus", pinned: "4.1.0", installed: null },
69
+ ]);
70
+ });
71
+ });
72
+
73
+ describe("computeEnvStatus", () => {
74
+ test("splits present/missing required and optional", () => {
75
+ const c = closure({
76
+ env: { required: ["FIGMA_TOKEN", "SET_VAR"], optional: ["OPT"] },
77
+ });
78
+ const status = computeEnvStatus(c, { SET_VAR: "x", OPT: "" });
79
+ expect(status.missingRequired).toEqual(["FIGMA_TOKEN"]);
80
+ expect(status.required.find((e) => e.name === "SET_VAR")!.present).toBe(
81
+ true,
82
+ );
83
+ // empty string counts as missing
84
+ expect(status.optional[0]!.present).toBe(false);
85
+ });
86
+ });
87
+
88
+ describe("summarizeClosure", () => {
89
+ test("emits one labeled line per non-empty category", () => {
90
+ const c = closure({
91
+ marketplaces: { magus: { source: "github", repo: "MadAppGang/magus" } },
92
+ plugins: { "dev@magus": "latest", "terminal@magus": "4.1.0" },
93
+ bins: [
94
+ { name: "claudish", via: "bun", version: "1.2.0", sources: ["x"] },
95
+ ],
96
+ env: { required: ["FIGMA_TOKEN"], optional: [] },
97
+ });
98
+ const lines = summarizeClosure(c);
99
+ expect(lines.some((l) => l.startsWith("marketplaces"))).toBe(true);
100
+ expect(lines.some((l) => l.includes("terminal@magus@4.1.0"))).toBe(true);
101
+ expect(lines.some((l) => l.includes("claudish@1.2.0"))).toBe(true);
102
+ expect(lines.some((l) => l.startsWith("env"))).toBe(true);
103
+ });
104
+ });
@@ -0,0 +1,308 @@
1
+ /**
2
+ * `bin/magus.js` — what the launcher does when the prebuilt binary dies.
3
+ *
4
+ * The defect these tests pin: a process killed by a signal leaves
5
+ * `{status: null, signal: "SIGKILL", error: undefined}`. The launcher's guard
6
+ * read `error` only, so `status ?? 0` turned every signal death into exit 0.
7
+ * On macOS 27 an invalid code signature kills the binary at exec, so magus
8
+ * 4.40.0 through 6.8.0 printed nothing and reported success.
9
+ *
10
+ * The tests drive the real launcher inside a throwaway tree that mirrors the
11
+ * npm install layout:
12
+ *
13
+ * <tmp>/package.json ({"type":"module"})
14
+ * <tmp>/bin/magus.js (a copy of the real file)
15
+ * <tmp>/src/main.tsx (stub source path)
16
+ * <tmp>/node_modules/magus-cli-<plat>-<arch>/bin/magus (stub binary)
17
+ *
18
+ * `createRequire` inside the copy walks up from <tmp>/bin, so it can never
19
+ * reach this repo's own installed magus-cli-darwin-arm64. That is why the tree
20
+ * is built rather than NODE_PATH set: NODE_PATH is consulted only AFTER the
21
+ * node_modules walk fails, so a dev with the real optionalDependency installed
22
+ * would silently test the shipped binary instead of the stub.
23
+ *
24
+ * Each stub appends one line to $MAGUS_TEST_LEDGER. A ledger file gives
25
+ * ordering and argv forwarding, which an inherited stdio stream cannot. The
26
+ * source stub's exit code 7 is a value the masked path can never produce, so
27
+ * `status === 7` alone separates "fell back" from "pretended to succeed".
28
+ */
29
+
30
+ import { afterEach, describe, expect, test } from "bun:test";
31
+ import { spawnSync } from "node:child_process";
32
+ import {
33
+ chmodSync,
34
+ cpSync,
35
+ existsSync,
36
+ mkdirSync,
37
+ mkdtempSync,
38
+ readFileSync,
39
+ realpathSync,
40
+ rmSync,
41
+ writeFileSync,
42
+ } from "node:fs";
43
+ import os from "node:os";
44
+ import path from "node:path";
45
+
46
+ const REAL_LAUNCHER = path.join(import.meta.dir, "..", "..", "bin", "magus.js");
47
+ const PKG_SUFFIX = `${process.platform}-${process.arch}`;
48
+ const PKG_NAME = `magus-cli-${PKG_SUFFIX}`;
49
+
50
+ /**
51
+ * Absolute path to node, resolved once. The "Bun is not installed" tests hand
52
+ * the launcher a PATH with neither node nor bun on it, so the interpreter
53
+ * cannot be found by name.
54
+ */
55
+ const NODE_BIN = spawnSync("sh", ["-c", "command -v node"], {
56
+ encoding: "utf8",
57
+ }).stdout.trim();
58
+
59
+ const dirs: string[] = [];
60
+
61
+ afterEach(() => {
62
+ for (const dir of dirs.splice(0))
63
+ rmSync(dir, { recursive: true, force: true });
64
+ });
65
+
66
+ function sourceStub(action: string): string {
67
+ return `import { appendFileSync } from "node:fs";
68
+ appendFileSync(
69
+ process.env.MAGUS_TEST_LEDGER as string,
70
+ \`SOURCE \${process.argv.slice(2).join(" ")}\\n\`,
71
+ );
72
+ ${action}
73
+ `;
74
+ }
75
+
76
+ function makeTree(
77
+ opts: {
78
+ /** Shell the binary stub runs after logging. Omit = package absent. */
79
+ binaryAction?: string;
80
+ binaryMode?: number;
81
+ /** Statement the source stub runs after logging. */
82
+ sourceAction?: string;
83
+ } = {},
84
+ ): string {
85
+ const dir = realpathSync(
86
+ mkdtempSync(path.join(os.tmpdir(), "magus-launcher-")),
87
+ );
88
+ dirs.push(dir);
89
+
90
+ // The launcher is ESM. Without a `type: module` package.json above it, node
91
+ // parses the copy as CJS and dies on the first `import`.
92
+ writeFileSync(
93
+ path.join(dir, "package.json"),
94
+ JSON.stringify({ name: "magus", version: "0.0.0", type: "module" }),
95
+ );
96
+
97
+ mkdirSync(path.join(dir, "bin"));
98
+ cpSync(REAL_LAUNCHER, path.join(dir, "bin", "magus.js"));
99
+
100
+ mkdirSync(path.join(dir, "src"));
101
+ writeFileSync(
102
+ path.join(dir, "src", "main.tsx"),
103
+ sourceStub(opts.sourceAction ?? "process.exit(7);"),
104
+ );
105
+
106
+ if (opts.binaryAction !== undefined) {
107
+ const pkgDir = path.join(dir, "node_modules", PKG_NAME);
108
+ mkdirSync(path.join(pkgDir, "bin"), { recursive: true });
109
+ // No "exports" field: the launcher resolves `<pkg>/package.json`, which
110
+ // an exports map would refuse.
111
+ writeFileSync(
112
+ path.join(pkgDir, "package.json"),
113
+ JSON.stringify({ name: PKG_NAME, version: "0.0.0" }),
114
+ );
115
+ const bin = path.join(pkgDir, "bin", "magus");
116
+ writeFileSync(
117
+ bin,
118
+ `#!/bin/sh\necho "BINARY $*" >> "$MAGUS_TEST_LEDGER"\n${opts.binaryAction}\n`,
119
+ );
120
+ chmodSync(bin, opts.binaryMode ?? 0o755);
121
+ }
122
+
123
+ return dir;
124
+ }
125
+
126
+ function binaryPathIn(dir: string): string {
127
+ return path.join(dir, "node_modules", PKG_NAME, "bin", "magus");
128
+ }
129
+
130
+ interface Launch {
131
+ status: number | null;
132
+ signal: NodeJS.Signals | null;
133
+ stderr: string;
134
+ ledger: string[];
135
+ }
136
+
137
+ function launch(
138
+ dir: string,
139
+ args: string[] = [],
140
+ env: Record<string, string> = {},
141
+ ): Launch {
142
+ const ledgerPath = path.join(dir, "ledger.txt");
143
+ const result = spawnSync(
144
+ NODE_BIN,
145
+ [path.join(dir, "bin", "magus.js"), ...args],
146
+ {
147
+ encoding: "utf8",
148
+ // Nothing may reach a TUI renderer: hand the child an empty stdin.
149
+ input: "",
150
+ env: {
151
+ ...process.env,
152
+ MAGUS_NO_BINARY: "",
153
+ MAGUS_TEST_LEDGER: ledgerPath,
154
+ ...env,
155
+ },
156
+ },
157
+ );
158
+ const ledger = existsSync(ledgerPath)
159
+ ? readFileSync(ledgerPath, "utf8")
160
+ .split("\n")
161
+ .map((line) => line.trim())
162
+ .filter(Boolean)
163
+ : [];
164
+ return {
165
+ status: result.status,
166
+ signal: result.signal,
167
+ stderr: result.stderr ?? "",
168
+ ledger,
169
+ };
170
+ }
171
+
172
+ describe("launcher: binary killed by a signal", () => {
173
+ test("T-sig-1: SIGKILL at exec falls back to source and explains why", () => {
174
+ const dir = makeTree({ binaryAction: "kill -s KILL $$" });
175
+ const r = launch(dir, ["--version"]);
176
+
177
+ // The shipped bug in one line: this was 0.
178
+ expect(r.status).not.toBe(0);
179
+ // 7 is the stub source path's code — nothing else can produce it.
180
+ expect(r.status).toBe(7);
181
+ expect(r.ledger).toEqual(["BINARY --version", "SOURCE --version"]);
182
+
183
+ // A stranded user needs the signal, the file, and a way out.
184
+ expect(r.stderr).toContain("SIGKILL");
185
+ expect(r.stderr).toContain(binaryPathIn(dir));
186
+ expect(r.stderr).toContain("signature");
187
+ expect(r.stderr).toContain("codesign --verify");
188
+ expect(r.stderr).toContain("MAGUS_NO_BINARY=1");
189
+ });
190
+
191
+ test("T-sig-2: SIGINT is the user's own Ctrl-C — exit 130, no fallback", () => {
192
+ const dir = makeTree({ binaryAction: "kill -s INT $$" });
193
+ const r = launch(dir);
194
+
195
+ expect(r.status).toBe(130);
196
+ expect(r.ledger.some((line) => line.startsWith("SOURCE"))).toBe(false);
197
+ expect(r.stderr).toBe("");
198
+ });
199
+
200
+ test("T-sig-3: SIGTERM is exit 143, no fallback", () => {
201
+ const dir = makeTree({ binaryAction: "kill -s TERM $$" });
202
+ const r = launch(dir);
203
+
204
+ expect(r.status).toBe(143);
205
+ expect(r.ledger.some((line) => line.startsWith("SOURCE"))).toBe(false);
206
+ });
207
+
208
+ test("T-sig-4: SIGABRT is a crash, not a user signal — it must fall back", () => {
209
+ // The hole an allowlist of crash signals leaves: a Bun/JSC panic aborts,
210
+ // which is precisely the "binary is broken, use source" case.
211
+ const dir = makeTree({ binaryAction: "kill -s ABRT $$" });
212
+ const r = launch(dir);
213
+
214
+ expect(r.status).toBe(7);
215
+ expect(r.ledger.some((line) => line.startsWith("SOURCE"))).toBe(true);
216
+ });
217
+
218
+ test("T-sig-5: a kill seconds in is deliberate — report it, do not relaunch", () => {
219
+ // A signature kill lands in single-digit milliseconds at exec. A user
220
+ // running `kill -9` on a live TUI, or an OOM kill, lands much later.
221
+ // Without the time gate the fix trades a silent exit 0 for a surprise
222
+ // relaunch of an app the user deliberately killed.
223
+ const dir = makeTree({ binaryAction: "sleep 3\nkill -s KILL $$" });
224
+ const r = launch(dir);
225
+
226
+ expect(r.status).toBe(137);
227
+ expect(r.ledger.some((line) => line.startsWith("SOURCE"))).toBe(false);
228
+ }, 20000);
229
+
230
+ test("T-sig-6: an ordinary non-zero exit passes straight through", () => {
231
+ const dir = makeTree({ binaryAction: "exit 3" });
232
+ const r = launch(dir);
233
+
234
+ expect(r.status).toBe(3);
235
+ expect(r.ledger.some((line) => line.startsWith("SOURCE"))).toBe(false);
236
+ });
237
+
238
+ test("T-sig-7: no platform package — straight to source, silently", () => {
239
+ const dir = makeTree();
240
+ const r = launch(dir);
241
+
242
+ expect(r.status).toBe(7);
243
+ expect(r.ledger).toEqual(["SOURCE"]);
244
+ expect(r.stderr).not.toContain("could not start");
245
+ });
246
+
247
+ test("T-sig-8: an unexecutable binary still falls back and says so", () => {
248
+ const dir = makeTree({ binaryAction: "exit 0", binaryMode: 0o644 });
249
+ const r = launch(dir);
250
+
251
+ expect(r.status).toBe(7);
252
+ expect(r.stderr).toContain("could not start");
253
+ });
254
+
255
+ test("T-sig-9: MAGUS_NO_BINARY=1 skips the binary entirely", () => {
256
+ // The escape hatch the SIGKILL diagnostic advertises has to work.
257
+ const dir = makeTree({ binaryAction: "kill -s KILL $$" });
258
+ const r = launch(dir, [], { MAGUS_NO_BINARY: "1" });
259
+
260
+ expect(r.ledger.some((line) => line.startsWith("BINARY"))).toBe(false);
261
+ expect(r.status).toBe(7);
262
+ });
263
+
264
+ test("T-sig-10: the source path has the same bug, and nowhere to fall back to", () => {
265
+ const dir = makeTree({
266
+ sourceAction: 'process.kill(process.pid, "SIGKILL");',
267
+ });
268
+ const r = launch(dir);
269
+
270
+ expect(r.status).toBe(137);
271
+ // No binary was involved, so a codesign hint here would be nonsense.
272
+ expect(r.stderr).not.toContain("codesign");
273
+ });
274
+
275
+ test("T-sig-11: argv reaches both the binary and the fallback unchanged", () => {
276
+ const dir = makeTree({ binaryAction: "kill -s KILL $$" });
277
+ const r = launch(dir, ["--version", "--x"]);
278
+
279
+ expect(r.ledger).toContain("BINARY --version --x");
280
+ expect(r.ledger).toContain("SOURCE --version --x");
281
+ });
282
+
283
+ // An empty PATH removes bun, so the source fallback cannot start either.
284
+ // This is the terminal state for the population the 6.8.1 fix exists for:
285
+ // magus installed from npm on macOS 27, no Bun on the machine.
286
+ const NO_BUN = { PATH: "" };
287
+
288
+ test("T-sig-12: binary crashed AND no Bun — the message must not deny the binary", () => {
289
+ const dir = makeTree({ binaryAction: "kill -s KILL $$" });
290
+ const r = launch(dir, ["--version"], NO_BUN);
291
+
292
+ expect(r.status).toBe(1);
293
+ // The contradiction: a binary was found, run, and reported killed
294
+ // moments earlier. Saying none exists sends the user down a dead end.
295
+ expect(r.stderr).not.toContain(`no prebuilt binary for ${PKG_SUFFIX}`);
296
+ expect(r.stderr).toContain("unusable on this machine");
297
+ expect(r.stderr).toContain("npm install -g magus-cli@latest");
298
+ });
299
+
300
+ test("T-sig-13: no binary AND no Bun — that message is still correct, and kept", () => {
301
+ const dir = makeTree();
302
+ const r = launch(dir, ["--version"], NO_BUN);
303
+
304
+ expect(r.status).toBe(1);
305
+ expect(r.stderr).toContain(`no prebuilt binary for ${PKG_SUFFIX}`);
306
+ expect(r.stderr).not.toContain("unusable on this machine");
307
+ });
308
+ });
@@ -0,0 +1,174 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import fs from "fs-extra";
5
+ import {
6
+ scanMcpServers,
7
+ scanSkillDirs,
8
+ } from "../services/local-marketplace.js";
9
+ import { walkMarkdown, walkSkillDirs } from "../services/skills-manager.js";
10
+
11
+ /**
12
+ * The skill count in the plugin detail panel comes from a scan of the cloned
13
+ * marketplace. It used to read only the top level of `skills/`, which counts
14
+ * the *grouping* directories rather than the skills: dev reported 9 against 43
15
+ * on disk, mattpocock 5 against 37.
16
+ */
17
+ describe("scanSkillDirs", () => {
18
+ let root: string;
19
+
20
+ beforeEach(async () => {
21
+ root = await fs.mkdtemp(path.join(os.tmpdir(), "magus-skills-"));
22
+ });
23
+
24
+ afterEach(async () => {
25
+ await fs.remove(root);
26
+ });
27
+
28
+ async function writeSkill(...segments: string[]): Promise<void> {
29
+ const dir = path.join(root, ...segments);
30
+ await fs.ensureDir(dir);
31
+ await fs.writeFile(path.join(dir, "SKILL.md"), "---\nname: x\n---\n");
32
+ }
33
+
34
+ it("counts skills nested under grouping directories", async () => {
35
+ await writeSkill("skills", "frontend", "design-system");
36
+ await writeSkill("skills", "frontend", "forms");
37
+ await writeSkill("skills", "backend", "db-branching");
38
+ await writeSkill("skills", "flat-skill");
39
+
40
+ const found = await scanSkillDirs(path.join(root, "skills"));
41
+
42
+ expect(found.sort()).toEqual([
43
+ "db-branching",
44
+ "design-system",
45
+ "flat-skill",
46
+ "forms",
47
+ ]);
48
+ });
49
+
50
+ it("never counts a grouping directory as a skill", async () => {
51
+ await writeSkill("skills", "engineering", "code-review");
52
+ await writeSkill("skills", "engineering", "tdd");
53
+
54
+ const found = await scanSkillDirs(path.join(root, "skills"));
55
+
56
+ expect(found.sort()).toEqual(["code-review", "tdd"]);
57
+ expect(found).not.toContain("engineering");
58
+ });
59
+
60
+ it("returns nothing when the directory is absent", async () => {
61
+ expect(await scanSkillDirs(path.join(root, "nope"))).toEqual([]);
62
+ });
63
+
64
+ it("stops descending past four levels rather than walking the whole tree", async () => {
65
+ await writeSkill("skills", "a", "b", "c", "d", "too-deep");
66
+
67
+ expect(await scanSkillDirs(path.join(root, "skills"))).toEqual([]);
68
+ });
69
+ });
70
+
71
+ /**
72
+ * mnemex ships one MCP server and nothing else. The scan only looked in an
73
+ * `mcp-servers/` directory, so it reported zero of every component kind and the
74
+ * plugin read as shipping nothing at all.
75
+ */
76
+ describe("scanMcpServers", () => {
77
+ let root: string;
78
+
79
+ beforeEach(async () => {
80
+ root = await fs.mkdtemp(path.join(os.tmpdir(), "magus-mcp-"));
81
+ });
82
+
83
+ afterEach(async () => {
84
+ await fs.remove(root);
85
+ });
86
+
87
+ it("reads server names from a root .mcp.json", async () => {
88
+ await fs.writeJson(path.join(root, ".mcp.json"), {
89
+ mnemex: { command: "mnemex", args: ["--mcp"] },
90
+ });
91
+
92
+ expect(await scanMcpServers(root)).toEqual(["mnemex"]);
93
+ });
94
+
95
+ it("reads the mcpServers wrapper shape too", async () => {
96
+ await fs.writeJson(path.join(root, ".mcp.json"), {
97
+ mcpServers: { alpha: { command: "a" }, beta: { command: "b" } },
98
+ });
99
+
100
+ expect((await scanMcpServers(root)).sort()).toEqual(["alpha", "beta"]);
101
+ });
102
+
103
+ it("still reads an mcp-servers/ directory", async () => {
104
+ await fs.ensureDir(path.join(root, "mcp-servers"));
105
+ await fs.writeJson(path.join(root, "mcp-servers", "tmux.json"), {});
106
+
107
+ expect(await scanMcpServers(root)).toEqual(["tmux"]);
108
+ });
109
+
110
+ it("reports a server rather than zero when .mcp.json will not parse", async () => {
111
+ await fs.writeFile(path.join(root, ".mcp.json"), "{ not json");
112
+
113
+ expect(await scanMcpServers(root)).toEqual(["mcp"]);
114
+ });
115
+
116
+ it("reports none when the plugin declares no server", async () => {
117
+ expect(await scanMcpServers(root)).toEqual([]);
118
+ });
119
+ });
120
+
121
+ /**
122
+ * The clone walkers behind the local-first read. A marketplace the user has
123
+ * added is on disk, so listing its plugins must cost no GitHub request — the
124
+ * hourly cap of 60 unauthenticated requests is shared across every repo on
125
+ * screen, and once it ran out every expand answered "Could not read the plugin",
126
+ * including for marketplaces sitting complete in the cache.
127
+ */
128
+ describe("clone walkers", () => {
129
+ let root: string;
130
+
131
+ beforeEach(async () => {
132
+ root = await fs.mkdtemp(path.join(os.tmpdir(), "magus-walk-"));
133
+ });
134
+
135
+ afterEach(async () => {
136
+ await fs.remove(root);
137
+ });
138
+
139
+ async function write(rel: string, body = "x"): Promise<void> {
140
+ const full = path.join(root, rel);
141
+ await fs.ensureDir(path.dirname(full));
142
+ await fs.writeFile(full, body);
143
+ }
144
+
145
+ it("carries the grouping path so nested skills match the network shape", async () => {
146
+ await write("skills/frontend/design-system/SKILL.md");
147
+ await write("skills/backend/db/SKILL.md");
148
+ await write("skills/flat/SKILL.md");
149
+
150
+ const found = await walkSkillDirs(path.join(root, "skills"));
151
+ const byName = Object.fromEntries(found.map((f) => [f.name, f.group]));
152
+
153
+ expect(Object.keys(byName).sort()).toEqual(["db", "design-system", "flat"]);
154
+ expect(byName["design-system"]).toBe("frontend");
155
+ expect(byName.db).toBe("backend");
156
+ expect(byName.flat).toBeUndefined();
157
+ });
158
+
159
+ it("lists markdown commands and skips README", async () => {
160
+ await write("commands/go.md");
161
+ await write("commands/README.md");
162
+ await write("commands/deep/trace.md");
163
+
164
+ const found = await walkMarkdown(path.join(root, "commands"));
165
+
166
+ expect(found.map((f) => f.name).sort()).toEqual(["go", "trace"]);
167
+ expect(found.find((f) => f.name === "trace")?.group).toBe("deep");
168
+ });
169
+
170
+ it("returns nothing for a directory that is not there", async () => {
171
+ expect(await walkSkillDirs(path.join(root, "nope"))).toEqual([]);
172
+ expect(await walkMarkdown(path.join(root, "nope"))).toEqual([]);
173
+ });
174
+ });