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,1098 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm, symlink } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import fs from "fs-extra";
6
+ import { EMBEDDED_PRESETS } from "../data/styles/index.js";
7
+ import {
8
+ INTEGRITY_BLOCK,
9
+ type ImportedStyle,
10
+ type StylePreset,
11
+ type StyleSource,
12
+ applyStyles,
13
+ clearStyle,
14
+ composeStyleFile,
15
+ generatedStyleName,
16
+ loadPresets,
17
+ orderSources,
18
+ readApplied,
19
+ splitFrontmatter,
20
+ validateSelection,
21
+ } from "../services/styles-manager.js";
22
+ import {
23
+ buildStyleBrowserItems,
24
+ firstSelectableIndex,
25
+ } from "../ui/adapters/stylesAdapter.js";
26
+
27
+ // ─── Fixtures ─────────────────────────────────────────────────────────────────
28
+
29
+ function preset(overrides: Partial<StylePreset> = {}): StylePreset {
30
+ const name = overrides.name ?? "direct";
31
+ return {
32
+ kind: "preset",
33
+ name,
34
+ displayName: name,
35
+ axis: "verbosity",
36
+ summary: "Answer first.",
37
+ conflicts: [],
38
+ template: false,
39
+ body: "### Direct\n\n- Lead with the answer.",
40
+ path: `/styles/${name}.md`,
41
+ ...overrides,
42
+ // Last, so a fixture that only sets `name` still gets a matching id.
43
+ id: overrides.id ?? name,
44
+ };
45
+ }
46
+
47
+ function imported(overrides: Partial<ImportedStyle> = {}): ImportedStyle {
48
+ const name = overrides.name ?? "my-voice";
49
+ const scope = overrides.scope ?? "user";
50
+ return {
51
+ kind: "imported",
52
+ name,
53
+ displayName: name,
54
+ scope,
55
+ origin: scope === "project" ? "team" : "personal",
56
+ managed: false,
57
+ description: "A personal voice.",
58
+ updatedAt: "2026-08-18",
59
+ capturedFrom: null,
60
+ community: null,
61
+ body: "Be brief.",
62
+ path: `/home/.claude/output-styles/${name}.md`,
63
+ ...overrides,
64
+ // After the spread so a fixture that only sets `name`/`scope` still gets
65
+ // a matching id.
66
+ id: overrides.id ?? `${scope}:${name}`,
67
+ };
68
+ }
69
+
70
+ let dir: string;
71
+
72
+ beforeEach(async () => {
73
+ dir = await mkdtemp(join(tmpdir(), "magus-styles-"));
74
+ });
75
+
76
+ afterEach(async () => {
77
+ await rm(dir, { recursive: true, force: true });
78
+ });
79
+
80
+ // ─── Frontmatter ──────────────────────────────────────────────────────────────
81
+
82
+ describe("splitFrontmatter", () => {
83
+ test("parses the shape the presets actually ship", () => {
84
+ const { frontmatter, body } = splitFrontmatter(
85
+ [
86
+ "---",
87
+ "name: direct",
88
+ "axis: verbosity",
89
+ "summary: Answer first, no preamble.",
90
+ "conflicts: explanatory, terse",
91
+ "---",
92
+ "",
93
+ "### Direct",
94
+ "",
95
+ "- Lead with the answer.",
96
+ ].join("\n"),
97
+ );
98
+ expect(frontmatter.name).toBe("direct");
99
+ expect(frontmatter.axis).toBe("verbosity");
100
+ expect(frontmatter.conflicts).toBe("explanatory, terse");
101
+ expect(body.startsWith("### Direct")).toBe(true);
102
+ });
103
+
104
+ test("unquotes and unescapes a quoted description", () => {
105
+ const { frontmatter } = splitFrontmatter(
106
+ ["---", 'description: "Composed: \\"x\\", y"', "---", "body"].join("\n"),
107
+ );
108
+ expect(frontmatter.description).toBe('Composed: "x", y');
109
+ });
110
+
111
+ test("a file with no frontmatter is all body", () => {
112
+ const { frontmatter, body } = splitFrontmatter("just text");
113
+ expect(frontmatter).toEqual({});
114
+ expect(body).toBe("just text");
115
+ });
116
+
117
+ test("an unterminated fence is not treated as frontmatter", () => {
118
+ const { frontmatter, body } = splitFrontmatter("---\nname: x\nno close");
119
+ expect(frontmatter).toEqual({});
120
+ expect(body).toContain("name: x");
121
+ });
122
+ });
123
+
124
+ // ─── Validation ───────────────────────────────────────────────────────────────
125
+
126
+ describe("validateSelection", () => {
127
+ test("accepts one verbosity plus any number of modifiers", () => {
128
+ expect(
129
+ validateSelection([
130
+ preset({ name: "direct", axis: "verbosity" }),
131
+ preset({ name: "no-slop", axis: "modifier" }),
132
+ preset({ name: "evidence-first", axis: "modifier" }),
133
+ ]),
134
+ ).toEqual([]);
135
+ });
136
+
137
+ test("rejects two verbosity presets — they cancel out", () => {
138
+ const errors = validateSelection([
139
+ preset({ name: "direct", axis: "verbosity" }),
140
+ preset({ name: "terse", axis: "verbosity" }),
141
+ ]);
142
+ expect(errors).toHaveLength(1);
143
+ expect(errors[0]).toContain("exactly one verbosity");
144
+ });
145
+
146
+ test("reports a conflicting pair once, not once from each side", () => {
147
+ const errors = validateSelection([
148
+ preset({ name: "a", axis: "modifier", conflicts: ["b"] }),
149
+ preset({ name: "b", axis: "modifier", conflicts: ["a"] }),
150
+ ]);
151
+ expect(errors).toHaveLength(1);
152
+ });
153
+
154
+ test("rejects a template preset with an actionable message", () => {
155
+ const errors = validateSelection([
156
+ preset({ name: "terminology", axis: "modifier", template: true }),
157
+ ]);
158
+ expect(errors).toHaveLength(1);
159
+ expect(errors[0]).toContain("template");
160
+ });
161
+
162
+ test("an empty selection has no validation errors of its own", () => {
163
+ expect(validateSelection([])).toEqual([]);
164
+ });
165
+ });
166
+
167
+ // ─── Ordering ─────────────────────────────────────────────────────────────────
168
+
169
+ describe("orderSources", () => {
170
+ test("imports first, then verbosity, then modifiers", () => {
171
+ const ordered = orderSources(
172
+ [
173
+ preset({ name: "no-slop", axis: "modifier" }),
174
+ preset({ name: "direct", axis: "verbosity" }),
175
+ ],
176
+ [imported({ name: "mine" })],
177
+ );
178
+ expect(ordered.map((s) => s.name)).toEqual(["mine", "direct", "no-slop"]);
179
+ });
180
+
181
+ test("keeps only the first verbosity preset", () => {
182
+ const ordered = orderSources(
183
+ [
184
+ preset({ name: "direct", axis: "verbosity" }),
185
+ preset({ name: "terse", axis: "verbosity" }),
186
+ ],
187
+ [],
188
+ );
189
+ expect(ordered.map((s) => s.name)).toEqual(["direct"]);
190
+ });
191
+ });
192
+
193
+ // ─── Composition ──────────────────────────────────────────────────────────────
194
+
195
+ describe("composeStyleFile", () => {
196
+ test("always keeps coding instructions on", () => {
197
+ // A style about how to COMMUNICATE must not switch off how to write code.
198
+ // Without this key Claude Code drops its coding-discipline block entirely.
199
+ const file = composeStyleFile("composed", [preset()]);
200
+ expect(file).toContain("keep-coding-instructions: true");
201
+ });
202
+
203
+ test("records provenance in frontmatter, not the body", () => {
204
+ const file = composeStyleFile("composed", [
205
+ preset({ name: "direct" }),
206
+ imported({ name: "mine", scope: "user" }),
207
+ ]);
208
+ const { frontmatter, body } = splitFrontmatter(file);
209
+ expect(frontmatter["style-presets"]).toBe("direct");
210
+ expect(frontmatter["style-imports"]).toBe("user:mine");
211
+ // The body is the prompt and is paid for on every request — provenance
212
+ // must not leak into it.
213
+ expect(body).not.toContain("style-presets");
214
+ expect(body).not.toContain("generated-by");
215
+ });
216
+
217
+ test("records 'none' when a category is empty", () => {
218
+ const { frontmatter } = splitFrontmatter(
219
+ composeStyleFile("composed", [preset()]),
220
+ );
221
+ expect(frontmatter["style-imports"]).toBe("none");
222
+ });
223
+
224
+ test("quotes the description so a colon cannot break the YAML", () => {
225
+ const file = composeStyleFile("composed", [preset({ name: "direct" })]);
226
+ const { frontmatter } = splitFrontmatter(file);
227
+ // Round-trips through the parser rather than appearing as a nested map.
228
+ expect(frontmatter.description).toContain("Composed communication style");
229
+ });
230
+
231
+ test("imported bodies come before preset bodies", () => {
232
+ const file = composeStyleFile("composed", [
233
+ imported({ name: "mine", body: "IMPORTED-MARKER" }),
234
+ preset({ name: "direct", body: "PRESET-MARKER" }),
235
+ ]);
236
+ expect(file.indexOf("IMPORTED-MARKER")).toBeLessThan(
237
+ file.indexOf("PRESET-MARKER"),
238
+ );
239
+ });
240
+
241
+ test("appends the integrity block to EVERY composition", () => {
242
+ // "Every" is the claim worth testing. The block is the semantic backstop
243
+ // for text we did not write — a third-party import cannot opt out of it,
244
+ // and neither can a selection that happens to contain no imports.
245
+ const selections: Array<{ label: string; sources: StyleSource[] }> = [
246
+ { label: "nothing selected", sources: [] },
247
+ { label: "one preset", sources: [preset()] },
248
+ { label: "one import", sources: [imported({ name: "mine" })] },
249
+ {
250
+ label: "both",
251
+ sources: [imported({ name: "mine" }), preset({ name: "direct" })],
252
+ },
253
+ ];
254
+ for (const { label, sources } of selections) {
255
+ const { body } = splitFrontmatter(composeStyleFile("composed", sources));
256
+ expect(body, `${label} carries the integrity block`).toContain(
257
+ INTEGRITY_BLOCK,
258
+ );
259
+ }
260
+ });
261
+
262
+ test("puts the integrity block LAST, so it refines what came before", () => {
263
+ // Same reasoning as orderSources: specific-after-broad. A limit stated
264
+ // before the rule it limits reads as something the rule may override.
265
+ const { body } = splitFrontmatter(
266
+ composeStyleFile("composed", [
267
+ imported({ name: "mine", body: "IMPORTED-MARKER" }),
268
+ preset({ name: "direct", body: "PRESET-MARKER" }),
269
+ ]),
270
+ );
271
+ expect(body.trimEnd().endsWith(INTEGRITY_BLOCK)).toBe(true);
272
+ });
273
+
274
+ test("the integrity block reaches the model, not just the file", () => {
275
+ // Frontmatter is split off before the prompt is built, so a block written
276
+ // there would be invisible to the model — the exact failure this test
277
+ // exists to catch.
278
+ const file = composeStyleFile("composed", [preset()]);
279
+ const { frontmatter, body } = splitFrontmatter(file);
280
+ expect(body).toContain("## Style limits");
281
+ expect(JSON.stringify(frontmatter)).not.toContain("Style limits");
282
+ });
283
+
284
+ test("the block states the rules it is there to state", () => {
285
+ // Checkable content, not just presence: these are the four commitments
286
+ // the block exists for, and a rewrite that drops one should fail here.
287
+ expect(INTEGRITY_BLOCK).toContain("error");
288
+ expect(INTEGRITY_BLOCK).toContain("security warning");
289
+ expect(INTEGRITY_BLOCK).toContain("destructive or irreversible action");
290
+ expect(INTEGRITY_BLOCK).toMatch(/never cut a flag from a command/);
291
+ });
292
+ });
293
+
294
+ describe("readApplied", () => {
295
+ test("round-trips the selection a composed file records", async () => {
296
+ const path = join(dir, "composed.md");
297
+ await fs.writeFile(
298
+ path,
299
+ composeStyleFile("composed", [
300
+ preset({ name: "direct" }),
301
+ preset({ name: "no-slop", axis: "modifier" }),
302
+ imported({ name: "mine", scope: "project" }),
303
+ ]),
304
+ );
305
+ const applied = await readApplied(path);
306
+ expect(applied?.presets).toEqual(["direct", "no-slop"]);
307
+ expect(applied?.imports).toEqual(["project:mine"]);
308
+ // composeStyleFile now records a fingerprint of the rules it was built
309
+ // from, so drift can be detected later.
310
+ expect(applied?.hash).toMatch(/^sha256:[0-9a-f]{32}$/);
311
+ });
312
+
313
+ test("returns null when nothing has been applied", async () => {
314
+ expect(await readApplied(join(dir, "missing.md"))).toBeNull();
315
+ });
316
+ });
317
+
318
+ // ─── Naming ───────────────────────────────────────────────────────────────────
319
+
320
+ describe("generatedStyleName", () => {
321
+ test("is plain when no profile is active", () => {
322
+ expect(generatedStyleName(null)).toBe("composed");
323
+ });
324
+
325
+ test("is per-profile so two profiles cannot clobber each other's file", () => {
326
+ expect(generatedStyleName("frontend")).toBe("composed-frontend");
327
+ expect(generatedStyleName("Team Alpha")).toBe("composed-team-alpha");
328
+ });
329
+
330
+ test("falls back to the plain name when a profile slugifies to nothing", () => {
331
+ expect(generatedStyleName("///")).toBe("composed");
332
+ });
333
+ });
334
+
335
+ // ─── Apply ────────────────────────────────────────────────────────────────────
336
+
337
+ describe("applyStyles", () => {
338
+ test("writes the style file and activates it", async () => {
339
+ const result = await applyStyles({
340
+ projectPath: dir,
341
+ presets: [preset({ name: "direct" })],
342
+ imports: [],
343
+ });
344
+
345
+ expect(result.styleName).toBe("composed");
346
+ expect(await fs.pathExists(result.stylePath)).toBe(true);
347
+ const settings = await fs.readJson(result.settingsPath);
348
+ expect(settings.outputStyle).toBe("composed");
349
+ });
350
+
351
+ test("preserves unrelated settings keys", async () => {
352
+ const settingsPath = join(dir, ".claude", "settings.json");
353
+ await fs.outputJson(settingsPath, {
354
+ enabledPlugins: { "dev@magus": true },
355
+ someOtherKey: 42,
356
+ });
357
+
358
+ await applyStyles({
359
+ projectPath: dir,
360
+ presets: [preset({ name: "direct" })],
361
+ imports: [],
362
+ });
363
+
364
+ const settings = await fs.readJson(settingsPath);
365
+ expect(settings.enabledPlugins).toEqual({ "dev@magus": true });
366
+ expect(settings.someOtherKey).toBe(42);
367
+ expect(settings.outputStyle).toBe("composed");
368
+ });
369
+
370
+ test("refuses to overwrite settings.json that is not valid JSON", async () => {
371
+ const settingsPath = join(dir, ".claude", "settings.json");
372
+ await fs.outputFile(settingsPath, "{ not json");
373
+
374
+ await expect(
375
+ applyStyles({
376
+ projectPath: dir,
377
+ presets: [preset({ name: "direct" })],
378
+ imports: [],
379
+ }),
380
+ ).rejects.toThrow(/not valid JSON/);
381
+
382
+ // The user's file is still there, untouched.
383
+ expect(await fs.readFile(settingsPath, "utf8")).toBe("{ not json");
384
+ });
385
+
386
+ test("rejects an invalid selection instead of writing a contradictory style", async () => {
387
+ await expect(
388
+ applyStyles({
389
+ projectPath: dir,
390
+ presets: [
391
+ preset({ name: "direct", axis: "verbosity" }),
392
+ preset({ name: "terse", axis: "verbosity" }),
393
+ ],
394
+ imports: [],
395
+ }),
396
+ ).rejects.toThrow(/exactly one verbosity/);
397
+ expect(await fs.pathExists(join(dir, ".claude", "output-styles"))).toBe(
398
+ false,
399
+ );
400
+ });
401
+
402
+ test("rejects an empty selection", async () => {
403
+ await expect(
404
+ applyStyles({ projectPath: dir, presets: [], imports: [] }),
405
+ ).rejects.toThrow(/Nothing selected/);
406
+ });
407
+
408
+ test("writes THROUGH an active profile's settings symlink without breaking it", async () => {
409
+ // The regression this guards: removing-then-recreating settings.json
410
+ // silently detaches the project from its profile, and every later profile
411
+ // switch then writes to a file nothing reads.
412
+ const profileSettings = join(
413
+ dir,
414
+ ".claude",
415
+ "_profiles",
416
+ "dev",
417
+ "settings.json",
418
+ );
419
+ await fs.outputJson(profileSettings, { enabledPlugins: {} });
420
+ const link = join(dir, ".claude", "settings.json");
421
+ await symlink("_profiles/dev/settings.json", link);
422
+
423
+ const result = await applyStyles({
424
+ projectPath: dir,
425
+ presets: [preset({ name: "direct" })],
426
+ imports: [],
427
+ });
428
+
429
+ expect(result.profile).toBe("dev");
430
+ expect(result.styleName).toBe("composed-dev");
431
+ expect((await fs.lstat(link)).isSymbolicLink()).toBe(true);
432
+ // The write landed in the profile, which is what "apply to the current
433
+ // profile" means.
434
+ expect((await fs.readJson(profileSettings)).outputStyle).toBe(
435
+ "composed-dev",
436
+ );
437
+ });
438
+
439
+ test("records the style in profiles.json so re-materializing cannot drop it", async () => {
440
+ // materializeProfile rewrites settings.json from the manifest, so a live
441
+ // write alone regresses on the next `magus install`.
442
+ const profileSettings = join(
443
+ dir,
444
+ ".claude",
445
+ "_profiles",
446
+ "dev",
447
+ "settings.json",
448
+ );
449
+ await fs.outputJson(profileSettings, { enabledPlugins: {} });
450
+ await symlink(
451
+ "_profiles/dev/settings.json",
452
+ join(dir, ".claude", "settings.json"),
453
+ );
454
+ await fs.outputJson(join(dir, ".claude", "profiles.json"), {
455
+ version: 2,
456
+ profiles: { dev: { name: "dev", plugins: {} } },
457
+ });
458
+
459
+ const result = await applyStyles({
460
+ projectPath: dir,
461
+ presets: [preset({ name: "direct" })],
462
+ imports: [],
463
+ });
464
+
465
+ expect(result.recordedInManifest).toBe(true);
466
+ const manifest = await fs.readJson(join(dir, ".claude", "profiles.json"));
467
+ expect(manifest.profiles.dev.settings.outputStyle).toBe("composed-dev");
468
+ });
469
+
470
+ test("reports honestly when there is a profile but no manifest entry", async () => {
471
+ const profileSettings = join(
472
+ dir,
473
+ ".claude",
474
+ "_profiles",
475
+ "dev",
476
+ "settings.json",
477
+ );
478
+ await fs.outputJson(profileSettings, {});
479
+ await symlink(
480
+ "_profiles/dev/settings.json",
481
+ join(dir, ".claude", "settings.json"),
482
+ );
483
+
484
+ const result = await applyStyles({
485
+ projectPath: dir,
486
+ presets: [preset({ name: "direct" })],
487
+ imports: [],
488
+ });
489
+ expect(result.profile).toBe("dev");
490
+ expect(result.recordedInManifest).toBe(false);
491
+ });
492
+ });
493
+
494
+ describe("clearStyle", () => {
495
+ test("removes the key rather than setting it undefined", async () => {
496
+ await applyStyles({
497
+ projectPath: dir,
498
+ presets: [preset({ name: "direct" })],
499
+ imports: [],
500
+ });
501
+ await clearStyle(dir);
502
+
503
+ const raw = await fs.readFile(
504
+ join(dir, ".claude", "settings.json"),
505
+ "utf8",
506
+ );
507
+ expect(raw).not.toContain("outputStyle");
508
+ expect(JSON.parse(raw)).toEqual({});
509
+ });
510
+
511
+ test("leaves the generated file on disk so a re-apply is one keypress away", async () => {
512
+ const result = await applyStyles({
513
+ projectPath: dir,
514
+ presets: [preset({ name: "direct" })],
515
+ imports: [],
516
+ });
517
+ await clearStyle(dir);
518
+ expect(await fs.pathExists(result.stylePath)).toBe(true);
519
+ });
520
+
521
+ test("is a no-op on a project that never had a style", async () => {
522
+ await clearStyle(dir);
523
+ expect(await fs.pathExists(join(dir, ".claude", "settings.json"))).toBe(
524
+ false,
525
+ );
526
+ });
527
+ });
528
+
529
+ // ─── The presets that actually ship ──────────────────────────────────────────
530
+
531
+ describe("embedded presets", () => {
532
+ // These read what `loadPresets()` really returns, so a preset whose
533
+ // frontmatter stops parsing shows up here rather than as an empty row in the
534
+ // Styles tab.
535
+ const shipped = loadPresets();
536
+
537
+ test("the directory and the index agree", async () => {
538
+ // `data/styles/index.ts` lists its imports one by one, because a glob
539
+ // cannot be resolved at build time. A file added to the directory without
540
+ // its two lines in the index does not ship, and nothing else notices.
541
+ const dir = join(import.meta.dir, "..", "data", "styles");
542
+ const onDisk = (await fs.readdir(dir)).filter((f) => f.endsWith(".md"));
543
+ expect(onDisk.length).toBeGreaterThan(0);
544
+ expect(EMBEDDED_PRESETS.map((p) => p.file).sort()).toEqual(onDisk.sort());
545
+ });
546
+
547
+ test("each entry carries the text of the file it names", () => {
548
+ // The name check above compares file names only, so two entries whose
549
+ // imports were swapped would still agree with the directory — and every
550
+ // preset would then be under the wrong name. Comparing each entry's own
551
+ // frontmatter against its file name is what catches that.
552
+ for (const { file, text } of EMBEDDED_PRESETS) {
553
+ const { frontmatter } = splitFrontmatter(text);
554
+ expect(frontmatter.name, `${file} carries its own frontmatter`).toBe(
555
+ file.replace(/\.md$/, ""),
556
+ );
557
+ }
558
+ });
559
+
560
+ test("every preset parses into a usable one", () => {
561
+ expect(shipped.length).toBe(EMBEDDED_PRESETS.length);
562
+ for (const p of shipped) {
563
+ expect(p.name, "declares a name").toBeTruthy();
564
+ expect(
565
+ ["verbosity", "modifier"],
566
+ `${p.name} declares a known axis`,
567
+ ).toContain(p.axis);
568
+ // The summary is the list-row text; an empty one renders a blank row.
569
+ expect(p.summary, `${p.name} declares a summary`).toBeTruthy();
570
+ expect(p.body.length, `${p.name} has a non-empty body`).toBeGreaterThan(
571
+ 0,
572
+ );
573
+ }
574
+ });
575
+
576
+ test("both axes are populated", () => {
577
+ // Either group empty renders a category with no rows, which reads as
578
+ // "the Styles tab is broken".
579
+ expect(
580
+ shipped.filter((p) => p.axis === "verbosity").length,
581
+ ).toBeGreaterThan(0);
582
+ expect(shipped.filter((p) => p.axis === "modifier").length).toBeGreaterThan(
583
+ 0,
584
+ );
585
+ });
586
+
587
+ test("declared conflicts all name a preset that ships", () => {
588
+ const names = new Set(shipped.map((p) => p.name));
589
+ for (const p of shipped) {
590
+ for (const conflict of p.conflicts) {
591
+ // A conflict naming a preset that no longer exists is dead config: it
592
+ // silently stops protecting the pair it was written for.
593
+ expect(names, `${p.name} conflicts with a real preset`).toContain(
594
+ conflict,
595
+ );
596
+ }
597
+ }
598
+ });
599
+
600
+ test("selection keys are unique", () => {
601
+ // `id` is what a declaration records and what the screen ticks. Two
602
+ // presets sharing one would make a selection ambiguous.
603
+ const ids = shipped.map((p) => p.id);
604
+ expect(new Set(ids).size).toBe(ids.length);
605
+ });
606
+
607
+ test("a template preset cannot be applied as-is", () => {
608
+ // `terminology` ships an empty table. Applying it unfilled would put a
609
+ // placeholder row into the system prompt.
610
+ for (const p of shipped.filter((s) => s.template)) {
611
+ expect(validateSelection([p]).length).toBeGreaterThan(0);
612
+ }
613
+ });
614
+ });
615
+
616
+ // ─── Adapter ──────────────────────────────────────────────────────────────────
617
+
618
+ describe("buildStyleBrowserItems", () => {
619
+ const presets = [
620
+ preset({ name: "direct", axis: "verbosity", conflicts: ["terse"] }),
621
+ preset({ name: "terse", axis: "verbosity" }),
622
+ preset({ name: "no-slop", axis: "modifier", conflicts: ["chatty"] }),
623
+ preset({ name: "chatty", axis: "modifier" }),
624
+ ];
625
+
626
+ test("leads with Anthropic official, then verbosity, modifiers, team, personal", () => {
627
+ const items = buildStyleBrowserItems({
628
+ presets,
629
+ imports: [
630
+ imported({ name: "mine", scope: "user", origin: "personal" }),
631
+ imported({
632
+ name: "builtin-explanatory",
633
+ displayName: "Explanatory",
634
+ scope: "user",
635
+ origin: "anthropic",
636
+ }),
637
+ imported({ name: "house", scope: "project", origin: "team" }),
638
+ ],
639
+ selected: new Set(),
640
+ applied: null,
641
+ query: "",
642
+ // This test is about the ORDER of the offline-authored sections, so the
643
+ // registry is emptied to keep it about that. Community's own placement
644
+ // is asserted separately, against the real registry.
645
+ registry: [],
646
+ });
647
+ const categories = items
648
+ .filter((i) => i.kind === "category")
649
+ .map((i) => (i.kind === "category" ? i.categoryKey : ""));
650
+ expect(categories).toEqual([
651
+ "anthropic",
652
+ "verbosity",
653
+ "modifier",
654
+ "team",
655
+ "personal",
656
+ ]);
657
+ });
658
+
659
+ test("shows Anthropic's own name, not the builtin- slug", () => {
660
+ const items = buildStyleBrowserItems({
661
+ presets: [],
662
+ imports: [
663
+ imported({
664
+ name: "builtin-explanatory",
665
+ displayName: "Explanatory",
666
+ origin: "anthropic",
667
+ }),
668
+ ],
669
+ selected: new Set(),
670
+ applied: null,
671
+ query: "",
672
+ });
673
+ const entry = items.find((i) => i.kind === "style");
674
+ expect(entry?.label).toBe("Explanatory");
675
+ // The id still carries the real on-disk name — that is what round-trips
676
+ // through the composed file's `style-imports`.
677
+ expect(entry?.id).toBe("user:builtin-explanatory");
678
+ });
679
+
680
+ test("filters on the displayed name as well as the file name", () => {
681
+ // Typing what you can see has to find it.
682
+ const items = buildStyleBrowserItems({
683
+ presets: [],
684
+ imports: [
685
+ imported({
686
+ name: "builtin-proactive",
687
+ displayName: "Proactive",
688
+ origin: "anthropic",
689
+ }),
690
+ ],
691
+ selected: new Set(),
692
+ applied: null,
693
+ query: "proact",
694
+ });
695
+ expect(items.filter((i) => i.kind === "style")).toHaveLength(1);
696
+ });
697
+
698
+ test("omits a section with no members rather than showing a zero count", () => {
699
+ const items = buildStyleBrowserItems({
700
+ presets,
701
+ imports: [],
702
+ selected: new Set(),
703
+ applied: null,
704
+ query: "",
705
+ // Community is omitted the same way, and needs an empty registry to be:
706
+ // with the shipped one it is never empty, which is the point of offers.
707
+ registry: [],
708
+ });
709
+ const categories = items
710
+ .filter((i) => i.kind === "category")
711
+ .map((i) => (i.kind === "category" ? i.categoryKey : ""));
712
+ expect(categories).toEqual(["verbosity", "modifier"]);
713
+ });
714
+
715
+ test("omits a category entirely when nothing in it matches the filter", () => {
716
+ const items = buildStyleBrowserItems({
717
+ presets,
718
+ imports: [],
719
+ selected: new Set(),
720
+ applied: null,
721
+ query: "no-slop",
722
+ });
723
+ expect(items.filter((i) => i.kind === "category")).toHaveLength(1);
724
+ expect(items.filter((i) => i.kind === "style")).toHaveLength(1);
725
+ });
726
+
727
+ test("filters on the summary as well as the name", () => {
728
+ const items = buildStyleBrowserItems({
729
+ presets: [preset({ name: "zzz", summary: "findable phrase" })],
730
+ imports: [],
731
+ selected: new Set(),
732
+ applied: null,
733
+ query: "findable",
734
+ });
735
+ expect(items.filter((i) => i.kind === "style")).toHaveLength(1);
736
+ });
737
+
738
+ test("distinguishes pending selection from what is live", () => {
739
+ const items = buildStyleBrowserItems({
740
+ presets,
741
+ imports: [],
742
+ selected: new Set(["no-slop"]),
743
+ applied: { presets: ["chatty"], imports: [] },
744
+ query: "",
745
+ });
746
+ const byName = new Map(
747
+ items
748
+ .filter((i) => i.kind === "style")
749
+ .map((i) => [i.label, i as { checked: boolean; applied: boolean }]),
750
+ );
751
+ expect(byName.get("no-slop")?.checked).toBe(true);
752
+ expect(byName.get("no-slop")?.applied).toBe(false);
753
+ expect(byName.get("chatty")?.checked).toBe(false);
754
+ expect(byName.get("chatty")?.applied).toBe(true);
755
+ });
756
+
757
+ test("flags a conflict from both directions, not only the declared one", () => {
758
+ // `conflicts` is declared on one side in the shipped presets, but the
759
+ // relationship is symmetric — otherwise the warning would depend on which
760
+ // of the pair the user happened to tick first.
761
+ const withChattySelected = buildStyleBrowserItems({
762
+ presets,
763
+ imports: [],
764
+ selected: new Set(["chatty"]),
765
+ applied: null,
766
+ query: "",
767
+ });
768
+ const noSlop = withChattySelected.find((i) => i.label === "no-slop");
769
+ expect(noSlop?.kind === "style" && noSlop.conflictsWith).toBe("chatty");
770
+
771
+ const withNoSlopSelected = buildStyleBrowserItems({
772
+ presets,
773
+ imports: [],
774
+ selected: new Set(["no-slop"]),
775
+ applied: null,
776
+ query: "",
777
+ });
778
+ const chatty = withNoSlopSelected.find((i) => i.label === "chatty");
779
+ expect(chatty?.kind === "style" && chatty.conflictsWith).toBe("no-slop");
780
+ });
781
+
782
+ test("never flags a conflict between verbosity presets — they are a radio group", () => {
783
+ const items = buildStyleBrowserItems({
784
+ presets,
785
+ imports: [],
786
+ selected: new Set(["direct"]),
787
+ applied: null,
788
+ query: "",
789
+ });
790
+ const terse = items.find((i) => i.label === "terse");
791
+ expect(terse?.kind === "style" && terse.conflictsWith).toBeNull();
792
+ });
793
+
794
+ test("flags a modifier that conflicts with the chosen verbosity preset", () => {
795
+ // This is the conflict that actually occurs in the shipped set:
796
+ // plain-language (modifier) declares a conflict with terse (verbosity).
797
+ // A rule that only compared modifiers against modifiers would never fire.
798
+ const cross = [
799
+ preset({ name: "terse", axis: "verbosity" }),
800
+ preset({
801
+ name: "plain-language",
802
+ axis: "modifier",
803
+ conflicts: ["terse"],
804
+ }),
805
+ ];
806
+ const items = buildStyleBrowserItems({
807
+ presets: cross,
808
+ imports: [],
809
+ selected: new Set(["terse"]),
810
+ applied: null,
811
+ query: "",
812
+ });
813
+ const modifier = items.find((i) => i.label === "plain-language");
814
+ expect(modifier?.kind === "style" && modifier.conflictsWith).toBe("terse");
815
+ expect(validateSelection(cross)).toHaveLength(1);
816
+ });
817
+
818
+ test("marks template presets disabled", () => {
819
+ const items = buildStyleBrowserItems({
820
+ presets: [preset({ name: "terminology", template: true })],
821
+ imports: [],
822
+ selected: new Set(),
823
+ applied: null,
824
+ query: "",
825
+ });
826
+ const entry = items.find((i) => i.kind === "style");
827
+ expect(entry?.kind === "style" && entry.disabled).toBe(true);
828
+ });
829
+ });
830
+
831
+ // ─── Community section ────────────────────────────────────────────────────────
832
+
833
+ describe("the Community section", () => {
834
+ const offer = (id: string, sourceId: string, name: string) => ({
835
+ id,
836
+ sourceId,
837
+ path: `output-styles/${id.split("--")[1]}.md`,
838
+ displayName: name,
839
+ summary: `Summary for ${name}.`,
840
+ });
841
+
842
+ const spartan = (name = "attention-span--spartan", displayName = "Spartan") =>
843
+ imported({
844
+ name,
845
+ displayName,
846
+ scope: "community",
847
+ origin: "community",
848
+ managed: true,
849
+ community: {
850
+ source: "alexgreensh/attention-span",
851
+ path: "output-styles/spartan.md",
852
+ ref: "HEAD",
853
+ commit: "b860c9f8f3c7",
854
+ sha256: "sha256:abc",
855
+ fetched: "2026-08-18",
856
+ licence: "AGPL-3.0",
857
+ author: "alexgreensh",
858
+ },
859
+ });
860
+
861
+ test("sorts last, after the sections nobody fetched from the network", () => {
862
+ const items = buildStyleBrowserItems({
863
+ presets: [preset({ name: "direct", axis: "verbosity" })],
864
+ imports: [
865
+ imported({ name: "mine", scope: "user", origin: "personal" }),
866
+ imported({ name: "house", scope: "project", origin: "team" }),
867
+ ],
868
+ selected: new Set(),
869
+ applied: null,
870
+ query: "",
871
+ });
872
+ const categories = items
873
+ .filter((i) => i.kind === "category")
874
+ .map((i) => (i.kind === "category" ? i.categoryKey : ""));
875
+ expect(categories.at(-1)).toBe("community");
876
+ });
877
+
878
+ test("lists the registry as offers even when nothing has been fetched", () => {
879
+ // The mitigation for placing the section last: it is non-empty from first
880
+ // launch, so it is discoverable by scrolling rather than only by already
881
+ // knowing it exists.
882
+ const items = buildStyleBrowserItems({
883
+ presets: [],
884
+ imports: [],
885
+ selected: new Set(),
886
+ applied: null,
887
+ query: "",
888
+ registry: [offer("attention-span--spartan", "attention-span", "Spartan")],
889
+ });
890
+ const offers = items.filter((i) => i.kind === "offer");
891
+ expect(offers).toHaveLength(1);
892
+ expect(offers[0]?.label).toBe("Spartan");
893
+ expect(offers[0]?.id).toBe("offer:attention-span--spartan");
894
+ });
895
+
896
+ test("is omitted entirely when the registry is empty and nothing is cached", () => {
897
+ const items = buildStyleBrowserItems({
898
+ presets: [],
899
+ imports: [],
900
+ selected: new Set(),
901
+ applied: null,
902
+ query: "",
903
+ registry: [],
904
+ });
905
+ expect(items).toHaveLength(0);
906
+ });
907
+
908
+ test("a fetched style REPLACES its offer rather than appearing twice", () => {
909
+ const items = buildStyleBrowserItems({
910
+ presets: [],
911
+ imports: [spartan()],
912
+ selected: new Set(),
913
+ applied: null,
914
+ query: "",
915
+ registry: [offer("attention-span--spartan", "attention-span", "Spartan")],
916
+ });
917
+ expect(items.filter((i) => i.kind === "offer")).toHaveLength(0);
918
+ const style = items.find((i) => i.kind === "style");
919
+ expect(style?.label).toBe("Spartan");
920
+ expect(style?.id).toBe("community:attention-span--spartan");
921
+ });
922
+
923
+ test("fetching a style does NOT move it — the row keeps its slot", () => {
924
+ // The cursor tracks an index. Grouping fetched styles above offers meant
925
+ // that fetching one moved it up, shifted everything below by a row, and
926
+ // left the cursor pointing at a different style than the one just acted
927
+ // on. Position is registry order now; state is the checkbox's job.
928
+ const reg = [
929
+ offer(
930
+ "attention-span--attention-kind",
931
+ "attention-span",
932
+ "Attention-kind",
933
+ ),
934
+ offer("attention-span--rundown", "attention-span", "Rundown"),
935
+ offer("attention-span--spartan", "attention-span", "Spartan"),
936
+ ];
937
+
938
+ const before = buildStyleBrowserItems({
939
+ presets: [],
940
+ imports: [],
941
+ selected: new Set(),
942
+ applied: null,
943
+ query: "",
944
+ registry: reg,
945
+ });
946
+
947
+ // Same registry, but the middle entry has now been fetched.
948
+ const after = buildStyleBrowserItems({
949
+ presets: [],
950
+ imports: [spartan("attention-span--rundown", "Rundown")],
951
+ selected: new Set(),
952
+ applied: null,
953
+ query: "",
954
+ registry: reg,
955
+ });
956
+
957
+ expect(before.map((i) => i.label)).toEqual(after.map((i) => i.label));
958
+ // Only the row's KIND changed, and only for the one that was fetched.
959
+ expect(before.map((i) => i.kind)).toEqual([
960
+ "category",
961
+ "offer",
962
+ "offer",
963
+ "offer",
964
+ ]);
965
+ expect(after.map((i) => i.kind)).toEqual([
966
+ "category",
967
+ "offer",
968
+ "style",
969
+ "offer",
970
+ ]);
971
+ });
972
+
973
+ test("offers are landable so the cursor can reach them", () => {
974
+ // `firstSelectableIndex` used to look for `kind === "style"`, which would
975
+ // make the whole section unreachable for a user who has fetched nothing —
976
+ // i.e. everyone, on their first launch.
977
+ const items = buildStyleBrowserItems({
978
+ presets: [],
979
+ imports: [],
980
+ selected: new Set(),
981
+ applied: null,
982
+ query: "",
983
+ registry: [offer("attention-span--spartan", "attention-span", "Spartan")],
984
+ });
985
+ expect(firstSelectableIndex(items)).toBe(1);
986
+ expect(items[1]?.kind).toBe("offer");
987
+ });
988
+
989
+ test("an offer is not tickable — it has no `checked` at all", () => {
990
+ const items = buildStyleBrowserItems({
991
+ presets: [],
992
+ imports: [],
993
+ selected: new Set(["offer:attention-span--spartan"]),
994
+ applied: null,
995
+ query: "",
996
+ registry: [offer("attention-span--spartan", "attention-span", "Spartan")],
997
+ });
998
+ const item = items.find((i) => i.kind === "offer");
999
+ expect(item).toBeDefined();
1000
+ expect("checked" in (item as object)).toBe(false);
1001
+ });
1002
+
1003
+ test("offers match the filter on name and summary", () => {
1004
+ const items = buildStyleBrowserItems({
1005
+ presets: [],
1006
+ imports: [],
1007
+ selected: new Set(),
1008
+ applied: null,
1009
+ query: "rundown",
1010
+ registry: [
1011
+ offer("attention-span--spartan", "attention-span", "Spartan"),
1012
+ offer("attention-span--rundown", "attention-span", "Rundown"),
1013
+ ],
1014
+ });
1015
+ const offers = items.filter((i) => i.kind === "offer");
1016
+ expect(offers.map((o) => o.label)).toEqual(["Rundown"]);
1017
+ });
1018
+
1019
+ test("a retired entry is no longer offered", () => {
1020
+ // Retired means "do not recommend this any more". It must still RESOLVE
1021
+ // for a committed declaration, which is asserted in styles-sync.test.ts —
1022
+ // deleting the entry is what breaks a teammate's checkout.
1023
+ const items = buildStyleBrowserItems({
1024
+ presets: [],
1025
+ imports: [],
1026
+ selected: new Set(),
1027
+ applied: null,
1028
+ query: "",
1029
+ registry: [
1030
+ {
1031
+ ...offer("attention-span--spartan", "attention-span", "Spartan"),
1032
+ retired: true as const,
1033
+ },
1034
+ ],
1035
+ });
1036
+ expect(items).toHaveLength(0);
1037
+ });
1038
+
1039
+ test("an offer the project's declaration asks for is flagged as needed", () => {
1040
+ const items = buildStyleBrowserItems({
1041
+ presets: [],
1042
+ imports: [],
1043
+ selected: new Set(),
1044
+ applied: null,
1045
+ query: "",
1046
+ registry: [
1047
+ offer("attention-span--spartan", "attention-span", "Spartan"),
1048
+ offer("attention-span--rundown", "attention-span", "Rundown"),
1049
+ ],
1050
+ fetchable: ["community:attention-span--spartan"],
1051
+ });
1052
+ const byLabel = new Map(
1053
+ items.filter((i) => i.kind === "offer").map((i) => [i.label, i]),
1054
+ );
1055
+ expect(byLabel.get("Spartan")?.declared).toBe(true);
1056
+ expect(byLabel.get("Rundown")?.declared).toBe(false);
1057
+ });
1058
+
1059
+ test("carries the upstream check state onto the row, keyed by coordinate", () => {
1060
+ const items = buildStyleBrowserItems({
1061
+ presets: [],
1062
+ imports: [spartan()],
1063
+ selected: new Set(),
1064
+ applied: null,
1065
+ query: "",
1066
+ registry: [],
1067
+ upstream: {
1068
+ "attention-span--spartan": {
1069
+ state: "update-available",
1070
+ detail: "newer version available — 12 lines changed",
1071
+ checkedAt: 1,
1072
+ changedLines: 12,
1073
+ },
1074
+ },
1075
+ });
1076
+ const style = items.find((i) => i.kind === "style");
1077
+ expect(style?.kind === "style" && style.upstream?.state).toBe(
1078
+ "update-available",
1079
+ );
1080
+ });
1081
+
1082
+ test("wears the same tone as Anthropic captures, never the danger tone", () => {
1083
+ // Both sections are "text captured from somewhere else onto this disk".
1084
+ // Red is the only unused tone and it means danger here — a normal, working
1085
+ // section must not wear it.
1086
+ const items = buildStyleBrowserItems({
1087
+ presets: [],
1088
+ imports: [],
1089
+ selected: new Set(),
1090
+ applied: null,
1091
+ query: "",
1092
+ registry: [offer("attention-span--spartan", "attention-span", "Spartan")],
1093
+ });
1094
+ const category = items.find((i) => i.kind === "category");
1095
+ expect(category?.kind === "category" && category.tone).toBe("yellow");
1096
+ expect(category?.kind === "category" && category.badge).toBe("from GitHub");
1097
+ });
1098
+ });