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,98 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import {
3
+ COMPONENT_BADGE,
4
+ CONTRAST_REFERENCE_KEYS,
5
+ componentBadge,
6
+ resetThemeMode,
7
+ setThemeMode,
8
+ } from "../ui/theme-mode.js";
9
+
10
+ afterEach(() => {
11
+ resetThemeMode();
12
+ });
13
+
14
+ /** WCAG relative luminance of a #rrggbb string. */
15
+ function luminance(hex: string): number {
16
+ const channel = (v: number) => {
17
+ const c = v / 255;
18
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
19
+ };
20
+ const n = Number.parseInt(hex.slice(1), 16);
21
+ return (
22
+ 0.2126 * channel((n >> 16) & 0xff) +
23
+ 0.7152 * channel((n >> 8) & 0xff) +
24
+ 0.0722 * channel(n & 0xff)
25
+ );
26
+ }
27
+
28
+ function contrast(a: string, b: string): number {
29
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
30
+ return (hi + 0.05) / (lo + 0.05);
31
+ }
32
+
33
+ const KINDS = ["skill", "command", "agent", "mcp"] as const;
34
+
35
+ describe("componentBadge", () => {
36
+ it("serves the dark pair before any resolution", () => {
37
+ // There is no unknown: dark is the stated default, so a chip is always a
38
+ // pair, never null, and the caller has no bare-glyph branch to keep.
39
+ for (const kind of KINDS) {
40
+ expect(componentBadge(kind)).toEqual(COMPONENT_BADGE[kind].dark);
41
+ }
42
+ });
43
+
44
+ it("serves the light pair on a light terminal", () => {
45
+ setThemeMode("light");
46
+
47
+ expect(componentBadge("skill")).toEqual(COMPONENT_BADGE.skill.light);
48
+ expect(componentBadge("mcp")).toEqual(COMPONENT_BADGE.mcp.light);
49
+ });
50
+
51
+ it("serves the dark pair on a dark terminal", () => {
52
+ setThemeMode("dark");
53
+
54
+ expect(componentBadge("skill")).toEqual(COMPONENT_BADGE.skill.dark);
55
+ expect(componentBadge("mcp")).toEqual(COMPONENT_BADGE.mcp.dark);
56
+ });
57
+
58
+ it("keeps every glyph legible on its own chip", () => {
59
+ // 3:1 is the floor the rest of the palette is measured to. A chip whose
60
+ // glyph disappears into its wash is worse than no chip at all.
61
+ for (const kind of KINDS) {
62
+ for (const mode of ["light", "dark"] as const) {
63
+ const { bg, fg } = COMPONENT_BADGE[kind][mode];
64
+ expect(contrast(bg, fg)).toBeGreaterThanOrEqual(3);
65
+ }
66
+ }
67
+ });
68
+
69
+ it("keeps every chip quiet against the page it is drawn on", () => {
70
+ // The wash must read as a tint of the page, not as a block on it. Above
71
+ // ~1.6 it stops being a chip and starts being a highlight bar.
72
+ for (const kind of KINDS) {
73
+ for (const mode of ["light", "dark"] as const) {
74
+ const { bg } = COMPONENT_BADGE[kind][mode];
75
+ const page = CONTRAST_REFERENCE_KEYS[mode];
76
+ expect(contrast(bg, page)).toBeLessThan(1.6);
77
+ }
78
+ }
79
+ });
80
+
81
+ it("mirrors the reference pages theme.ts measures accents against", async () => {
82
+ // The duplicate exists so this module stays free of the renderer import.
83
+ // If theme.ts ever restates its backgrounds, the chips must move with it.
84
+ const { CONTRAST_REFERENCE } = await import("../ui/theme.js");
85
+
86
+ expect(CONTRAST_REFERENCE_KEYS.light).toBe(CONTRAST_REFERENCE.light);
87
+ expect(CONTRAST_REFERENCE_KEYS.dark).toBe(CONTRAST_REFERENCE.dark);
88
+ });
89
+
90
+ it("gives each kind a distinguishable hue", () => {
91
+ // Single-character colour carries no readable hue *shift*, only a readable
92
+ // hue *difference* — the same reason the scope squares are far apart.
93
+ for (const mode of ["light", "dark"] as const) {
94
+ const fills = KINDS.map((k) => COMPONENT_BADGE[k][mode].bg);
95
+ expect(new Set(fills).size).toBe(KINDS.length);
96
+ }
97
+ });
98
+ });
@@ -0,0 +1,94 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { COMPONENT_ICON } from "../ui/renderers/pluginRenderers.js";
3
+
4
+ /**
5
+ * Every component badge is exactly one terminal cell wide.
6
+ *
7
+ * This is the constraint that rules out the icons the ecosystem actually uses:
8
+ * Cursor's skill badge defaults to a lightning bolt and the community VS Code
9
+ * Skills sidebar uses a sparkle, but `⚡` (U+26A1) and `✨` (U+2728) are both
10
+ * East-Asian-Width Wide — two cells — and a two-cell badge shifts every name on
11
+ * the row out of alignment with the plugin names above it.
12
+ *
13
+ * Unicode has no `east_asian_width` in the JS standard library, so the wide
14
+ * ranges are spelled out. They cover the emoji and CJK blocks a plausible icon
15
+ * would come from; anything outside them is single-width in practice.
16
+ */
17
+ const WIDE_RANGES: ReadonlyArray<readonly [number, number]> = [
18
+ [0x1100, 0x115f], // Hangul Jamo
19
+ [0x2329, 0x232a], // angle brackets
20
+ [0x2e80, 0x303e], // CJK radicals, Kangxi
21
+ [0x3041, 0x33ff], // Hiragana through CJK compatibility
22
+ [0x3400, 0x4dbf], // CJK Extension A
23
+ [0x4e00, 0x9fff], // CJK Unified Ideographs
24
+ [0xa000, 0xa4cf], // Yi
25
+ [0xac00, 0xd7a3], // Hangul syllables
26
+ [0xf900, 0xfaff], // CJK compatibility ideographs
27
+ [0xfe30, 0xfe6f], // CJK compatibility forms
28
+ [0xff00, 0xff60], // fullwidth forms
29
+ [0xffe0, 0xffe6], // fullwidth signs
30
+ [0x1f300, 0x1f64f], // misc symbols and pictographs, emoticons
31
+ [0x1f900, 0x1f9ff], // supplemental symbols and pictographs
32
+ [0x20000, 0x3fffd], // CJK Extension B and beyond
33
+ // Individually Wide despite sitting among narrow neighbours. These are the
34
+ // ones that actually bite: ⚡ and ✨ are exactly the icons a future editor
35
+ // would reach for.
36
+ [0x231a, 0x231b], // watch, hourglass
37
+ [0x23e9, 0x23ec],
38
+ [0x25fd, 0x25fe],
39
+ [0x2614, 0x2615],
40
+ [0x26a1, 0x26a1], // ⚡ HIGH VOLTAGE SIGN
41
+ [0x2728, 0x2728], // ✨ SPARKLES
42
+ [0x274c, 0x274c],
43
+ [0x2b1b, 0x2b1c],
44
+ [0x2b50, 0x2b50],
45
+ ];
46
+
47
+ function isWide(ch: string): boolean {
48
+ const cp = ch.codePointAt(0) ?? 0;
49
+ return WIDE_RANGES.some(([lo, hi]) => cp >= lo && cp <= hi);
50
+ }
51
+
52
+ describe("component icons", () => {
53
+ it("covers every component kind", () => {
54
+ expect(Object.keys(COMPONENT_ICON).sort()).toEqual([
55
+ "agent",
56
+ "command",
57
+ "mcp",
58
+ "skill",
59
+ ]);
60
+ });
61
+
62
+ it("uses exactly one code point per icon", () => {
63
+ for (const [kind, icon] of Object.entries(COMPONENT_ICON)) {
64
+ expect([...icon], `${kind} must be a single code point`).toHaveLength(1);
65
+ }
66
+ });
67
+
68
+ it("never uses a double-width glyph", () => {
69
+ for (const [kind, icon] of Object.entries(COMPONENT_ICON)) {
70
+ expect(isWide(icon), `${kind} icon ${icon} is double-width`).toBe(false);
71
+ }
72
+ });
73
+
74
+ it("rejects the two emoji a future editor would reach for", () => {
75
+ // Negative control: the guard above only means something if it fires.
76
+ expect(isWide("⚡")).toBe(true);
77
+ expect(isWide("✨")).toBe(true);
78
+ expect(isWide("📁")).toBe(true);
79
+ });
80
+
81
+ it("gives each kind a distinct glyph", () => {
82
+ const icons = Object.values(COMPONENT_ICON);
83
+ expect(new Set(icons).size).toBe(icons.length);
84
+ });
85
+
86
+ it("does not reuse a glyph this screen already means something by", () => {
87
+ // ★ is "★ Official" on a marketplace row, ● is "● Installed", ■ is a scope
88
+ // segment, and ▼ / ▶ are the expand arrows. Reusing one would make a badge
89
+ // read as a status.
90
+ for (const taken of ["★", "●", "■", "▼", "▶", "○", "✓", "⚠"]) {
91
+ expect(Object.values(COMPONENT_ICON)).not.toContain(taken);
92
+ }
93
+ });
94
+ });
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Content drift: a plugin whose files changed without its version changing.
3
+ *
4
+ * Hermetic — builds a real git marketplace under a temp CLAUDE_CONFIG_DIR.
5
+ * Real git is used deliberately: the detection IS a git question, and mocking
6
+ * it would only test the mock.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
10
+ import { spawnSync } from "node:child_process";
11
+ import { promises as fs } from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import {
15
+ clearContentDriftCache,
16
+ hasContentDrift,
17
+ repoHeadSha,
18
+ } from "../services/content-drift.js";
19
+ import {
20
+ type PluginInfo,
21
+ contentDriftCandidate,
22
+ } from "../services/plugin-manager.js";
23
+
24
+ let configDir: string;
25
+ let prevConfigDir: string | undefined;
26
+ let repo: string;
27
+
28
+ // commit.gpgsign=false isolates the developer's git config. With signing on
29
+ // (1Password's SSH signer, for instance) every commit here calls that signer,
30
+ // and because this helper ignores the exit status a failure is silent: no
31
+ // commit is created, so drift is never detected and the assertions fail with a
32
+ // bare `false` that points nowhere near the real cause. CI has no signing
33
+ // config, so this only ever broke locally.
34
+ const git = (args: string[]) =>
35
+ spawnSync("git", ["-c", "commit.gpgsign=false", ...args], {
36
+ cwd: repo,
37
+ encoding: "utf-8",
38
+ });
39
+
40
+ const head = () => git(["rev-parse", "HEAD"]).stdout.trim();
41
+
42
+ /** Rewrite a plugin's manifest version, mimicking a release. */
43
+ const setVersion = (plugin: string, version: string) =>
44
+ fs.writeFile(
45
+ path.join(repo, "plugins", plugin, "plugin.json"),
46
+ JSON.stringify({ name: plugin, version }, null, 2),
47
+ );
48
+
49
+ beforeEach(async () => {
50
+ prevConfigDir = process.env.CLAUDE_CONFIG_DIR;
51
+ configDir = await fs.mkdtemp(path.join(os.tmpdir(), "drift-"));
52
+ process.env.CLAUDE_CONFIG_DIR = configDir;
53
+ clearContentDriftCache();
54
+
55
+ repo = path.join(configDir, "plugins", "marketplaces", "probemp");
56
+ await fs.mkdir(path.join(repo, "plugins", "alpha", "skills"), {
57
+ recursive: true,
58
+ });
59
+ await fs.mkdir(path.join(repo, "plugins", "beta"), { recursive: true });
60
+ await fs.writeFile(
61
+ path.join(repo, "plugins", "alpha", "skills", "s.md"),
62
+ "v1",
63
+ );
64
+ await fs.writeFile(path.join(repo, "plugins", "beta", "readme.md"), "v1");
65
+ await setVersion("alpha", "1.0.0");
66
+ await setVersion("beta", "1.0.0");
67
+
68
+ git(["init", "-q", "-b", "main"]);
69
+ git(["config", "user.email", "probe@example.invalid"]);
70
+ git(["config", "user.name", "probe"]);
71
+ git(["add", "-A"]);
72
+ git(["commit", "-q", "-m", "release 1.0.0"]);
73
+ });
74
+
75
+ afterEach(async () => {
76
+ // biome-ignore lint/performance/noDelete: assigning undefined to process.env stores the string "undefined"; only delete unsets the variable
77
+ if (prevConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
78
+ else process.env.CLAUDE_CONFIG_DIR = prevConfigDir;
79
+ await fs.rm(configDir, { recursive: true, force: true });
80
+ });
81
+
82
+ describe("hasContentDrift", () => {
83
+ test("no drift when the plugin's files are untouched", async () => {
84
+ const sha = head();
85
+ expect(
86
+ await hasContentDrift({
87
+ marketplace: "probemp",
88
+ pluginName: "alpha",
89
+ installedSha: sha,
90
+ installedVersion: "1.0.0",
91
+ }),
92
+ ).toBe(false);
93
+ });
94
+
95
+ test("drift when the plugin's files change under an unchanged version", async () => {
96
+ const sha = head();
97
+ // Republish: same version, different content — what publish-dist.sh does.
98
+ await fs.writeFile(
99
+ path.join(repo, "plugins", "alpha", "skills", "s.md"),
100
+ "v2 — different content, same version",
101
+ );
102
+ git(["add", "-A"]);
103
+ git(["commit", "-q", "-m", "republish 1.0.0"]);
104
+
105
+ expect(
106
+ await hasContentDrift({
107
+ marketplace: "probemp",
108
+ pluginName: "alpha",
109
+ installedSha: sha,
110
+ installedVersion: "1.0.0",
111
+ }),
112
+ ).toBe(true);
113
+ });
114
+
115
+ test("a change to ANOTHER plugin does not flag this one", async () => {
116
+ // The reason this compares the plugin subtree and not the marketplace
117
+ // HEAD: any commit to any plugin would otherwise flag every plugin.
118
+ const sha = head();
119
+ await fs.writeFile(path.join(repo, "plugins", "beta", "readme.md"), "v2");
120
+ git(["add", "-A"]);
121
+ git(["commit", "-q", "-m", "unrelated change"]);
122
+
123
+ expect(
124
+ await hasContentDrift({
125
+ marketplace: "probemp",
126
+ pluginName: "alpha",
127
+ installedSha: sha,
128
+ installedVersion: "1.0.0",
129
+ }),
130
+ ).toBe(false);
131
+ expect(
132
+ await hasContentDrift({
133
+ marketplace: "probemp",
134
+ pluginName: "beta",
135
+ installedSha: sha,
136
+ installedVersion: "1.0.0",
137
+ }),
138
+ ).toBe(true);
139
+ });
140
+
141
+ test("deleting a skill counts as drift", async () => {
142
+ // The live case: dev@magus 3.0.1 lost tui-progress/SKILL.md while still
143
+ // declaring 3.0.1, so the version compare reported "up to date".
144
+ const sha = head();
145
+ await fs.rm(path.join(repo, "plugins", "alpha", "skills", "s.md"));
146
+ git(["add", "-A"]);
147
+ git(["commit", "-q", "-m", "drop a skill"]);
148
+
149
+ expect(
150
+ await hasContentDrift({
151
+ marketplace: "probemp",
152
+ pluginName: "alpha",
153
+ installedSha: sha,
154
+ installedVersion: "1.0.0",
155
+ }),
156
+ ).toBe(true);
157
+ });
158
+
159
+ test("stays silent when it cannot answer honestly", async () => {
160
+ // No recorded sha, unknown sha (force-push/shallow clone), and unknown
161
+ // marketplace must all report false rather than nag for a reinstall.
162
+ expect(
163
+ await hasContentDrift({
164
+ marketplace: "probemp",
165
+ pluginName: "alpha",
166
+ installedSha: undefined,
167
+ installedVersion: "1.0.0",
168
+ }),
169
+ ).toBe(false);
170
+ expect(
171
+ await hasContentDrift({
172
+ marketplace: "probemp",
173
+ pluginName: "alpha",
174
+ installedSha: "0".repeat(40),
175
+ installedVersion: "1.0.0",
176
+ }),
177
+ ).toBe(false);
178
+ expect(
179
+ await hasContentDrift({
180
+ marketplace: "does-not-exist",
181
+ pluginName: "alpha",
182
+ installedSha: head(),
183
+ installedVersion: "1.0.0",
184
+ }),
185
+ ).toBe(false);
186
+ });
187
+
188
+ // REGRESSION: every installed plugin rendered "stale — reinstall" because
189
+ // updateInstalledPluginsRegistry bumped `version` while carrying the
190
+ // pre-update `gitCommitSha` forward. The sha then named a commit several
191
+ // releases old, so the subtree diff was non-empty for essentially every
192
+ // plugin. Fixed in /dev:fix session dev-fix-20260822-125510-03db093b.
193
+ test("a sha recorded against a DIFFERENT version cannot answer the question", async () => {
194
+ const oldSha = head();
195
+
196
+ // Ship 2.0.0 — the plugin genuinely changed, as a release does.
197
+ await fs.writeFile(
198
+ path.join(repo, "plugins", "alpha", "skills", "s.md"),
199
+ "v2 content",
200
+ );
201
+ await setVersion("alpha", "2.0.0");
202
+ git(["add", "-A"]);
203
+ git(["commit", "-q", "-m", "release 2.0.0"]);
204
+
205
+ // 2.0.0 is installed, but the registry still carries the 1.0.0 sha.
206
+ // The subtree diff between them is non-empty, and says nothing about
207
+ // whether the installed 2.0.0 files match the marketplace's 2.0.0.
208
+ expect(
209
+ await hasContentDrift({
210
+ marketplace: "probemp",
211
+ pluginName: "alpha",
212
+ installedSha: oldSha,
213
+ installedVersion: "2.0.0",
214
+ }),
215
+ ).toBe(false);
216
+ });
217
+
218
+ test("a manifest that cannot be read at that sha reports no drift", async () => {
219
+ // Provenance unverifiable — the module's policy is silence, not a nag.
220
+ await fs.rm(path.join(repo, "plugins", "beta", "plugin.json"));
221
+ git(["add", "-A"]);
222
+ git(["commit", "-q", "-m", "beta loses its manifest"]);
223
+ const sha = head();
224
+
225
+ await fs.writeFile(path.join(repo, "plugins", "beta", "readme.md"), "v2");
226
+ git(["add", "-A"]);
227
+ git(["commit", "-q", "-m", "beta content changes"]);
228
+
229
+ expect(
230
+ await hasContentDrift({
231
+ marketplace: "probemp",
232
+ pluginName: "beta",
233
+ installedSha: sha,
234
+ installedVersion: "1.0.0",
235
+ }),
236
+ ).toBe(false);
237
+ });
238
+
239
+ test("reads a manifest at .claude-plugin/plugin.json too", async () => {
240
+ // magus lays plugins out as plugins/<name>/plugin.json, but Claude Code's
241
+ // documented location is .claude-plugin/plugin.json. Both must resolve or
242
+ // the guard silences drift detection for half the ecosystem.
243
+ await fs.mkdir(path.join(repo, "plugins", "beta", ".claude-plugin"), {
244
+ recursive: true,
245
+ });
246
+ await fs.rm(path.join(repo, "plugins", "beta", "plugin.json"));
247
+ await fs.writeFile(
248
+ path.join(repo, "plugins", "beta", ".claude-plugin", "plugin.json"),
249
+ JSON.stringify({ name: "beta", version: "1.0.0" }),
250
+ );
251
+ git(["add", "-A"]);
252
+ git(["commit", "-q", "-m", "beta uses .claude-plugin"]);
253
+ const sha = head();
254
+
255
+ await fs.writeFile(path.join(repo, "plugins", "beta", "readme.md"), "v2");
256
+ git(["add", "-A"]);
257
+ git(["commit", "-q", "-m", "republish 1.0.0"]);
258
+
259
+ expect(
260
+ await hasContentDrift({
261
+ marketplace: "probemp",
262
+ pluginName: "beta",
263
+ installedSha: sha,
264
+ installedVersion: "1.0.0",
265
+ }),
266
+ ).toBe(true);
267
+ });
268
+ });
269
+
270
+ describe("repoHeadSha", () => {
271
+ // The other half of the same bug: nothing in magus ever recorded a fresh
272
+ // sha, so the registry could only ever go stale. This is what
273
+ // updateInstalledPluginsRegistry now calls to stamp real provenance onto the
274
+ // files it just copied out of the marketplace clone.
275
+ test("resolves HEAD from a path inside the repo", async () => {
276
+ const sha = head();
277
+ expect(await repoHeadSha(path.join(repo, "plugins", "alpha"))).toBe(sha);
278
+ expect(await repoHeadSha(repo)).toBe(sha);
279
+ });
280
+
281
+ test("returns undefined for a directory that is not in a git repo", async () => {
282
+ // Directory-type marketplaces have no commit to name. Recording a sha
283
+ // from some unrelated clone would assert a provenance that is false.
284
+ const plain = await fs.mkdtemp(path.join(os.tmpdir(), "nogit-"));
285
+ try {
286
+ expect(await repoHeadSha(plain)).toBeUndefined();
287
+ } finally {
288
+ await fs.rm(plain, { recursive: true, force: true });
289
+ }
290
+ });
291
+
292
+ test("returns undefined for a path that does not exist", async () => {
293
+ expect(
294
+ await repoHeadSha(path.join(configDir, "no", "such", "dir")),
295
+ ).toBeUndefined();
296
+ });
297
+ });
298
+
299
+ describe("contentDriftCandidate — which plugins are even asked", () => {
300
+ const info = (over: Partial<PluginInfo> = {}): PluginInfo => ({
301
+ id: "dev@magus",
302
+ name: "dev",
303
+ version: "3.0.0",
304
+ description: "",
305
+ marketplace: "magus",
306
+ marketplaceDisplay: "magus",
307
+ enabled: true,
308
+ installedVersion: "3.0.0",
309
+ ...over,
310
+ });
311
+
312
+ test("same version, provably: asked", () => {
313
+ expect(contentDriftCandidate(info())).toBe(true);
314
+ // The comparator, not string equality: "3.0" IS 3.0.0.
315
+ expect(contentDriftCandidate(info({ installedVersion: "3.0" }))).toBe(true);
316
+ });
317
+
318
+ test("the pre-classifier `hasUpdate` no longer decides", () => {
319
+ // It was the gate's first clause. It could only ever be true for a pair
320
+ // the version compare already rejects, and reading it here made the
321
+ // input the shared classifier consumes depend on the rule it replaced.
322
+ // A stale flag must not suppress the question.
323
+ expect(contentDriftCandidate(info({ hasUpdate: true }))).toBe(true);
324
+ });
325
+
326
+ test("behind the catalog: NOT asked — that is an update, not drift", () => {
327
+ // hasContentDrift diffs the installed commit against the clone HEAD, so
328
+ // a plugin that is merely behind always "differs". Calling that drift
329
+ // would make it a repair, and a repair reinstalls whatever the clone
330
+ // holds — past a pin.
331
+ expect(contentDriftCandidate(info({ installedVersion: "2.9.0" }))).toBe(
332
+ false,
333
+ );
334
+ expect(
335
+ contentDriftCandidate(
336
+ info({ hasUpdate: false, installedVersion: "2.9.0" }),
337
+ ),
338
+ ).toBe(false);
339
+ });
340
+
341
+ test("unorderable, not installed, no catalog version, orphaned: not asked", () => {
342
+ expect(
343
+ contentDriftCandidate(
344
+ info({ version: "517b2fcd1b60", installedVersion: "b819188d2eea" }),
345
+ ),
346
+ ).toBe(false);
347
+ expect(contentDriftCandidate(info({ installedVersion: undefined }))).toBe(
348
+ false,
349
+ );
350
+ expect(contentDriftCandidate(info({ version: null }))).toBe(false);
351
+ expect(contentDriftCandidate(info({ isOrphaned: true }))).toBe(false);
352
+ });
353
+ });