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,1294 @@
1
+ /**
2
+ * Styles manager — discovery and composition of communication style presets.
3
+ *
4
+ * ## What a "style" is
5
+ *
6
+ * Claude Code activates exactly ONE output style at a time. The preset model
7
+ * is compositional instead — one verbosity preset plus any number of
8
+ * modifiers — so the combination has to be flattened into a single generated
9
+ * style file before the harness ever sees it. That flattening is what
10
+ * `composeStyleFile` does here.
11
+ *
12
+ * ## Where the presets come from
13
+ *
14
+ * `data/styles/*.md`, embedded in the binary at build time. They used to ship
15
+ * in the `style@magus` plugin and be discovered on disk, which meant two
16
+ * implementations of one composer and an empty Styles tab for anyone without
17
+ * the plugin. The plugin was retired at Marketplace 10.0.0; this is now the
18
+ * only composer, and the presets travel with the binary that parses them.
19
+ *
20
+ * ## Profile awareness
21
+ *
22
+ * `.claude/settings.json` is a symlink into `.claude/_profiles/<name>/` when a
23
+ * profile is active, so writing `outputStyle` through it lands in that
24
+ * profile. But `materializeProfile` REWRITES that file from the manifest on
25
+ * every `magus install`, so the live write alone silently regresses. The
26
+ * selection is therefore also recorded in `.claude/profiles.json` under
27
+ * `profiles.<name>.settings.outputStyle`, which is the durable source.
28
+ *
29
+ * The generated style file is named per-profile (`composed-<name>`) so two
30
+ * profiles can hold different styles without clobbering each other's file
31
+ * while both point at the same name.
32
+ */
33
+
34
+ import { createHash } from "node:crypto";
35
+ import os from "node:os";
36
+ import path from "node:path";
37
+ import fs from "fs-extra";
38
+ import { findCommunityStyle } from "../data/community-styles.js";
39
+ import { EMBEDDED_PRESETS } from "../data/styles/index.js";
40
+ import {
41
+ getManifestPath,
42
+ mergeManifestSettings,
43
+ readManifest,
44
+ writeManifest,
45
+ } from "./manifest.js";
46
+ import { updateSettingsFile } from "./settings-file.js";
47
+ import { activeProfile } from "./symlink-manager.js";
48
+
49
+ // ─── Types ────────────────────────────────────────────────────────────────────
50
+
51
+ /**
52
+ * The id namespace fetched styles live in. `community:<sourceId>--<slug>`.
53
+ *
54
+ * Exported because `.claude/style.json` carries these ids verbatim and both the
55
+ * adapter and the screen need to split one back into a registry coordinate.
56
+ */
57
+ export const COMMUNITY_ID_PREFIX = "community:";
58
+
59
+ export type StyleAxis = "verbosity" | "modifier";
60
+
61
+ /** A preset embedded in this binary, from `data/styles/`. */
62
+ export interface StylePreset {
63
+ kind: "preset";
64
+ /** Selection key. Equals `name` — presets share one namespace. */
65
+ id: string;
66
+ name: string;
67
+ /**
68
+ * What lists show. From the preset's `title:` frontmatter, else the slug.
69
+ * Display only — the CLI, declarations, and hashes all use `name`, the
70
+ * same split the `builtin-` captures already have.
71
+ */
72
+ displayName: string;
73
+ axis: StyleAxis;
74
+ summary: string;
75
+ /** Preset names that cancel this one out when both are selected. */
76
+ conflicts: string[];
77
+ /** Template presets ship an empty table and cannot be applied directly. */
78
+ template: boolean;
79
+ body: string;
80
+ /**
81
+ * What the detail panel shows under "Source". A label rather than a real
82
+ * path: an embedded preset has no file on disk, and the open-in-editor
83
+ * action refuses presets before it ever reads this.
84
+ */
85
+ path: string;
86
+ }
87
+
88
+ /**
89
+ * Where an importable style came from, which is what decides how it is grouped
90
+ * and whether it travels with the repository.
91
+ *
92
+ * - `anthropic` — a Claude Code built-in captured by
93
+ * `scripts/capture-builtin.ts`. Its text ships inside the Claude Code
94
+ * binary, so the file is a snapshot that goes stale on the next upgrade.
95
+ * - `team` — project-scoped, so it lives in the repo and commits with it. This
96
+ * is the one everyone on the project gets.
97
+ * - `personal` — user-scoped, so it exists only on this machine.
98
+ * - `community` — someone else's style, fetched from their GitHub repo on an
99
+ * explicit user action and cached outside `~/.claude/output-styles/`. We
100
+ * store no copy of the text until the user asks for one, and the file carries
101
+ * the coordinates it came from.
102
+ */
103
+ export type StyleOrigin = "anthropic" | "team" | "personal" | "community";
104
+
105
+ /**
106
+ * Where a community style's words came from, as its own frontmatter records it.
107
+ *
108
+ * Read from text already on disk, so populating it costs no network call and
109
+ * `loadStyles` stays offline on every render. `source` is THE marker — its
110
+ * presence is what classifies the file as `community` in the first place.
111
+ *
112
+ * The two sentinels the fetcher writes are normalised back to null here, so the
113
+ * detail panel never has to know the words "unknown" and "unlicensed": a pin we
114
+ * could not resolve and a repo that grants no licence are both ABSENCES, and
115
+ * rendering them as values would let "unlicensed" read as an SPDX id.
116
+ */
117
+ export interface CommunityProvenance {
118
+ /** `owner/name` of the upstream repository. */
119
+ source: string;
120
+ /** Path within that repository, including its styles directory. */
121
+ path: string | null;
122
+ /** The git ref we asked for — usually `HEAD`. */
123
+ ref: string | null;
124
+ /** Short commit sha this copy is pinned at, or null if none was resolved. */
125
+ commit: string | null;
126
+ /** `sha256:<64 hex>` over the RAW upstream bytes at fetch time. */
127
+ sha256: string | null;
128
+ /** Local date of the fetch, YYYY-MM-DD. */
129
+ fetched: string | null;
130
+ /** SPDX id, or null when upstream grants no redistribution licence. */
131
+ licence: string | null;
132
+ author: string | null;
133
+ }
134
+
135
+ /** An output style already on this machine, importable into a composition. */
136
+ export interface ImportedStyle {
137
+ kind: "imported";
138
+ /** Selection key, `"user:name"` or `"project:name"` — scopes may collide. */
139
+ id: string;
140
+ /** The on-disk name. This is what `style-imports` records — never display-only. */
141
+ name: string;
142
+ /**
143
+ * What to show in the list. For a captured built-in this is Anthropic's own
144
+ * name ("Explanatory"), not the `builtin-explanatory` slug the file carries.
145
+ * Display only — composing still round-trips through `id`/`name`.
146
+ */
147
+ displayName: string;
148
+ /**
149
+ * Which directory it was read from. `community` is the magus cache, not a
150
+ * Claude Code styles directory — see `communityCacheDirOrNull`.
151
+ */
152
+ scope: "user" | "project" | "community";
153
+ origin: StyleOrigin;
154
+ /**
155
+ * True when magus owns this copy: it lives in the community cache, so it
156
+ * is re-fetchable and safe to delete. False for everything the user wrote or
157
+ * placed themselves, INCLUDING a community-marked file they copied into
158
+ * `~/.claude/output-styles/` — the marker says where the words came from, the
159
+ * directory says whose copy it is, and those are different questions.
160
+ */
161
+ managed: boolean;
162
+ description: string;
163
+ /** File mtime as YYYY-MM-DD — when this copy was last written. */
164
+ updatedAt: string | null;
165
+ /** For a captured built-in: the Claude Code version it was taken from. */
166
+ capturedFrom: string | null;
167
+ /** For a community style: where its words came from. Null for everything else. */
168
+ community: CommunityProvenance | null;
169
+ body: string;
170
+ path: string;
171
+ }
172
+
173
+ export type StyleSource = StylePreset | ImportedStyle;
174
+
175
+ /** Everything the Styles screen needs for one project, in one read. */
176
+ export interface StylesSnapshot {
177
+ presets: StylePreset[];
178
+ imports: ImportedStyle[];
179
+ /** Selection recorded in the generated style file; null if never applied. */
180
+ applied: { presets: string[]; imports: string[]; hash: string | null } | null;
181
+ /** The project's committed choice, `.claude/style.json`. */
182
+ declaration: StyleDeclaration | null;
183
+ /** Whether this machine's active style matches that declaration. */
184
+ status: StyleSyncStatus;
185
+ /** Where the generated style file is (or would be) written. */
186
+ stylePath: string;
187
+ /** The settings.json that carries `outputStyle`. */
188
+ settingsPath: string;
189
+ /** Name of the generated style, e.g. "composed" or "composed-dev". */
190
+ styleName: string;
191
+ /** Active profile name, when .claude/settings.json is a profile symlink. */
192
+ profile: string | null;
193
+ /** The value currently in settings.json, whatever set it. */
194
+ currentOutputStyle: string | null;
195
+ }
196
+
197
+ /**
198
+ * The project's committed style choice: `.claude/style.json`.
199
+ *
200
+ * The generated `composed.md` is an ARTIFACT — it is what Claude Code reads.
201
+ * This is the DECLARATION: the short, reviewable statement of what the project
202
+ * wants, which is the thing worth committing and arguing about in a pull
203
+ * request. A teammate who clones the repo gets the declaration, and magus
204
+ * can tell them their local artifact does not match it yet.
205
+ */
206
+ export interface StyleDeclaration {
207
+ version: 1;
208
+ presets: string[];
209
+ imports: string[];
210
+ /**
211
+ * Hash of the composition this declaration produced when it was written.
212
+ *
213
+ * Over the source BODIES, not just their names: the same preset names
214
+ * compose to different text after a plugin update, and "you have the right
215
+ * names" is not the same claim as "you have the right rules".
216
+ */
217
+ hash: string;
218
+ updatedAt: string;
219
+ }
220
+
221
+ /** How the local generated style relates to the committed declaration. */
222
+ export type StyleSyncState =
223
+ /** No `.claude/style.json` — the project has not declared a style. */
224
+ | "undeclared"
225
+ /** Declared, but this machine has not applied it (or applied something else). */
226
+ | "not-applied"
227
+ /** Applied, but the composition would differ now — usually a pulled change. */
228
+ | "stale"
229
+ /** The active style matches the declaration exactly. */
230
+ | "in-sync";
231
+
232
+ export interface StyleSyncStatus {
233
+ state: StyleSyncState;
234
+ /** One line explaining the state, safe to show in the status bar. */
235
+ detail: string;
236
+ /** Names the declaration asks for that no longer resolve, and cannot be got. */
237
+ missing: string[];
238
+ /**
239
+ * Missing `community:` ids the registry still knows how to fetch.
240
+ *
241
+ * DISJOINT from `missing`, because they are different situations wearing the
242
+ * same shape: a name in `missing` is a dead end the user can do nothing
243
+ * about, and one in `fetchable` is a single keypress from resolved. Reporting
244
+ * both as "missing" is what would turn this feature's best case — a team
245
+ * commits a style choice including third-party text and everyone gets it —
246
+ * into an unactionable error.
247
+ */
248
+ fetchable: string[];
249
+ }
250
+
251
+ export interface ApplyResult {
252
+ stylePath: string;
253
+ settingsPath: string;
254
+ styleName: string;
255
+ profile: string | null;
256
+ /** True when the selection was also recorded in .claude/profiles.json. */
257
+ recordedInManifest: boolean;
258
+ /** The committed declaration that was written — commit this. */
259
+ declarationPath: string;
260
+ hash: string;
261
+ presets: string[];
262
+ imports: string[];
263
+ bytes: number;
264
+ }
265
+
266
+ // ─── Frontmatter ──────────────────────────────────────────────────────────────
267
+
268
+ type Frontmatter = Record<string, string>;
269
+
270
+ /**
271
+ * Split `---` frontmatter from a markdown body.
272
+ *
273
+ * Deliberately not a YAML parser: the keys we read are flat scalars, and
274
+ * pulling in a YAML dependency to read five strings would be the tail wagging
275
+ * the dog. Quote-unescaping is included because a `description:` containing a
276
+ * colon has to be quoted to stay valid YAML for every other reader.
277
+ */
278
+ export function splitFrontmatter(text: string): {
279
+ frontmatter: Frontmatter;
280
+ body: string;
281
+ } {
282
+ const lines = text.replace(/^\uFEFF/, "").split("\n");
283
+ if (lines[0]?.trim() !== "---") {
284
+ return { frontmatter: {}, body: text.trim() };
285
+ }
286
+
287
+ let close = -1;
288
+ for (let i = 1; i < lines.length; i++) {
289
+ if (lines[i].trim() === "---") {
290
+ close = i;
291
+ break;
292
+ }
293
+ }
294
+ if (close === -1) return { frontmatter: {}, body: text.trim() };
295
+
296
+ const frontmatter: Frontmatter = {};
297
+ for (const line of lines.slice(1, close)) {
298
+ const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
299
+ if (!match) continue;
300
+ let value = match[2].trim();
301
+ const quoted =
302
+ (value.startsWith('"') && value.endsWith('"')) ||
303
+ (value.startsWith("'") && value.endsWith("'"));
304
+ if (quoted && value.length >= 2) {
305
+ value = value.slice(1, -1).replace(/\\(["\\])/g, "$1");
306
+ }
307
+ frontmatter[match[1].toLowerCase()] = value;
308
+ }
309
+
310
+ return {
311
+ frontmatter,
312
+ body: lines
313
+ .slice(close + 1)
314
+ .join("\n")
315
+ .trim(),
316
+ };
317
+ }
318
+
319
+ function asBool(value: string | undefined): boolean | null {
320
+ if (value === undefined) return null;
321
+ const normalized = value.trim().toLowerCase();
322
+ if (normalized === "true" || normalized === "yes") return true;
323
+ if (normalized === "false" || normalized === "no") return false;
324
+ return null;
325
+ }
326
+
327
+ function splitList(value: string | undefined): string[] {
328
+ if (!value) return [];
329
+ return value
330
+ .split(",")
331
+ .map((entry) => entry.trim())
332
+ .filter(Boolean);
333
+ }
334
+
335
+ async function markdownFiles(dir: string): Promise<string[]> {
336
+ try {
337
+ if (!(await fs.pathExists(dir))) return [];
338
+ if (!(await fs.stat(dir)).isDirectory()) return [];
339
+ const entries = await fs.readdir(dir);
340
+ const files: string[] = [];
341
+ for (const entry of entries) {
342
+ if (!entry.endsWith(".md")) continue;
343
+ const full = path.join(dir, entry);
344
+ try {
345
+ if ((await fs.stat(full)).isFile()) files.push(full);
346
+ } catch {
347
+ /* unreadable entry — skip */
348
+ }
349
+ }
350
+ return files.sort();
351
+ } catch {
352
+ return [];
353
+ }
354
+ }
355
+
356
+ // ─── Presets ────────────────────────────────────────────────────────────────
357
+
358
+ /**
359
+ * Parse the embedded preset files into usable presets.
360
+ *
361
+ * Synchronous and infallible by construction: the text is inlined at build
362
+ * time, so there is no directory to miss, no plugin to be uninstalled, and no
363
+ * version of the files other than the one this binary was built with. The
364
+ * frontmatter contract is `name`, `title`, `axis`, `summary`, `conflicts`,
365
+ * `template` — see `data/styles/index.ts`.
366
+ */
367
+ export function loadPresets(): StylePreset[] {
368
+ const presets: StylePreset[] = [];
369
+ for (const { file, text } of EMBEDDED_PRESETS) {
370
+ const { frontmatter, body } = splitFrontmatter(text);
371
+ const name = frontmatter.name || file.replace(/\.md$/, "");
372
+ presets.push({
373
+ kind: "preset",
374
+ id: name,
375
+ name,
376
+ displayName: frontmatter.title || name,
377
+ axis: frontmatter.axis === "verbosity" ? "verbosity" : "modifier",
378
+ summary: frontmatter.summary || "",
379
+ conflicts: splitList(frontmatter.conflicts),
380
+ template: asBool(frontmatter.template) === true,
381
+ body,
382
+ path: `bundled with magus · styles/${file}`,
383
+ });
384
+ }
385
+ return presets;
386
+ }
387
+
388
+ /**
389
+ * Where fetched community styles are cached, or null when a test has not
390
+ * isolated itself.
391
+ *
392
+ * Deliberately NOT `~/.claude/output-styles/`. A fetched style is a copy of
393
+ * someone else's text that magus can overwrite or delete without asking; the
394
+ * output-styles directories are the user's own, and putting a re-fetchable copy
395
+ * in them makes "delete this" and "delete my work" the same keypress.
396
+ *
397
+ * The null case mirrors `catalog-cache-store.ts`. Under `bun test`, a `home`
398
+ * that is still the operator's real one means the test never isolated itself —
399
+ * it gets an empty Community section rather than reading, and later possibly
400
+ * writing, the operator's live cache. A test that DOES pass a temporary home
401
+ * gets a working directory, which is what makes the classification tests
402
+ * possible at all.
403
+ */
404
+ export function communityCacheDirOrNull(home: string): string | null {
405
+ const configDir = process.env.CLAUDE_CONFIG_DIR;
406
+ if (configDir) return path.join(configDir, "magus", "community-styles");
407
+ if (process.env.NODE_ENV === "test" && home === os.homedir()) return null;
408
+ return path.join(home, ".claude", "magus", "community-styles");
409
+ }
410
+
411
+ async function readImportable(
412
+ projectPath: string,
413
+ generatedNames: string[],
414
+ home: string,
415
+ ): Promise<ImportedStyle[]> {
416
+ const communityDir = communityCacheDirOrNull(home);
417
+ const scopes: Array<{ scope: ImportedStyle["scope"]; dir: string }> = [
418
+ { scope: "user", dir: path.join(home, ".claude", "output-styles") },
419
+ {
420
+ scope: "project",
421
+ dir: path.join(projectPath, ".claude", "output-styles"),
422
+ },
423
+ ];
424
+ // A third entry in the SAME array, which is why the cache is flat: it reuses
425
+ // the non-recursive `markdownFiles` verbatim, and `.pending/` (downloaded but
426
+ // not accepted updates) is invisible to discovery by construction rather than
427
+ // by a filter someone could drop.
428
+ if (communityDir) scopes.push({ scope: "community", dir: communityDir });
429
+
430
+ const found: ImportedStyle[] = [];
431
+ for (const { scope, dir } of scopes) {
432
+ for (const file of await markdownFiles(dir)) {
433
+ const { frontmatter, body } = splitFrontmatter(
434
+ await fs.readFile(file, "utf8"),
435
+ );
436
+ const basename = path.basename(file, ".md");
437
+ const declared = frontmatter.name || basename;
438
+
439
+ // A comma in `name` corrupts the declaration round trip: `composeStyleFile`
440
+ // serialises `style-imports` comma-separated and `readApplied` splits it
441
+ // back on commas, so an id containing one returns as two bogus ids and
442
+ // `computeStyleStatus` reports `stale` forever with no way to converge.
443
+ // The basename cannot carry one, and keeping the declared value as
444
+ // `displayName` means the user still sees the name they wrote. Fixing the
445
+ // SERIALISATION instead would change the format every already-written
446
+ // composed.md carries, so every user's next load would report a spurious
447
+ // drift — a worse trade for a bug that is latent rather than live.
448
+ const hasComma = declared.includes(",");
449
+ const name = hasComma ? basename : declared;
450
+
451
+ // Never import a file we generate — it would compound on every apply.
452
+ if (generatedNames.includes(name)) continue;
453
+
454
+ // Classification is by MARKER, never by directory, and in this order.
455
+ // `captured-style` keeps precedence: capture-builtin.ts writes it with
456
+ // Anthropic's own name for the style, and a captured built-in is an
457
+ // Anthropic style wherever it sits. The `builtin-` name prefix is only a
458
+ // fallback, because a file the user happened to call `builtin-…` is
459
+ // theirs, not Anthropic's.
460
+ const capturedStyle = frontmatter["captured-style"];
461
+ const isAnthropic = Boolean(capturedStyle) || name.startsWith("builtin-");
462
+ // `community-source` names the repo the words came from. It travels with
463
+ // the file, so a copy the user moves into their own output-styles
464
+ // directory still reports its origin honestly — it is simply no longer
465
+ // `managed`, so nothing offers to update or delete it.
466
+ const isCommunity =
467
+ !isAnthropic && Boolean(frontmatter["community-source"]);
468
+
469
+ found.push({
470
+ kind: "imported",
471
+ id: `${scope}:${name}`,
472
+ name,
473
+ // Display only. The registry carries upstream's own name ("Spartan",
474
+ // "Zen Master") because the fetched file's `name` is the coordinate id,
475
+ // which is machinery. Same split as the `builtin-` handling above.
476
+ displayName: isAnthropic
477
+ ? capturedStyle || stripBuiltinPrefix(name)
478
+ : hasComma
479
+ ? declared
480
+ : isCommunity
481
+ ? (findCommunityStyle(name)?.displayName ?? name)
482
+ : name,
483
+ scope,
484
+ origin: isAnthropic
485
+ ? "anthropic"
486
+ : isCommunity
487
+ ? "community"
488
+ : scope === "project"
489
+ ? "team"
490
+ : "personal",
491
+ // Only what magus put in its own cache. A stray hand-written file
492
+ // in that directory is still not ours to overwrite.
493
+ managed: scope === "community" && isCommunity,
494
+ description: frontmatter.description || "",
495
+ updatedAt: await fileDate(file),
496
+ capturedFrom: frontmatter["captured-from"] || null,
497
+ community: isCommunity ? readProvenance(frontmatter) : null,
498
+ body,
499
+ path: file,
500
+ });
501
+ }
502
+ }
503
+ return found;
504
+ }
505
+
506
+ /**
507
+ * Lift the `community-*` keys out of a fetched file's frontmatter.
508
+ *
509
+ * The two sentinels the fetcher stamps — `unknown` for an unresolved pin and
510
+ * `unlicensed` for a repo that grants no licence — come back as null. They exist
511
+ * in the FILE because a blank value there would read as a truncated write; they
512
+ * must not survive into the model, where "unlicensed" would render in the
513
+ * licence slot as though it were an SPDX id.
514
+ */
515
+ function readProvenance(
516
+ frontmatter: Record<string, string>,
517
+ ): CommunityProvenance {
518
+ const value = (key: string, sentinel?: string): string | null => {
519
+ const raw = frontmatter[key]?.trim();
520
+ if (!raw || raw === sentinel) return null;
521
+ return raw;
522
+ };
523
+ return {
524
+ source: frontmatter["community-source"] ?? "",
525
+ path: value("community-path"),
526
+ ref: value("community-ref"),
527
+ commit: value("community-commit", "unknown"),
528
+ sha256: value("community-sha256"),
529
+ fetched: value("community-fetched"),
530
+ licence: value("community-licence", "unlicensed"),
531
+ author: value("community-author"),
532
+ };
533
+ }
534
+
535
+ /** `builtin-plain-text` -> `Plain text`. Only a fallback; see readImportable. */
536
+ export function stripBuiltinPrefix(name: string): string {
537
+ const bare = name.replace(/^builtin-/, "").replace(/-/g, " ");
538
+ return bare ? bare.charAt(0).toUpperCase() + bare.slice(1) : name;
539
+ }
540
+
541
+ /** Last-written date as YYYY-MM-DD, or null if the file cannot be stat'd. */
542
+ async function fileDate(file: string): Promise<string | null> {
543
+ try {
544
+ const { mtime } = await fs.stat(file);
545
+ // Local date, not toISOString(): the user reads this against their own
546
+ // calendar, and UTC would show yesterday for anything written after 5pm
547
+ // in a western timezone.
548
+ const pad = (n: number) => String(n).padStart(2, "0");
549
+ return `${mtime.getFullYear()}-${pad(mtime.getMonth() + 1)}-${pad(mtime.getDate())}`;
550
+ } catch {
551
+ return null;
552
+ }
553
+ }
554
+
555
+ // ─── Hashing and the committed declaration ────────────────────────────────────
556
+
557
+ /**
558
+ * Fingerprint a resolved composition.
559
+ *
560
+ * Covers each source's id AND body, in order, because all three of those change
561
+ * what Claude Code is actually told:
562
+ *
563
+ * - a different set of presets,
564
+ * - the same set in a different order (specific-after-broad matters),
565
+ * - the same set whose text changed under a plugin update.
566
+ *
567
+ * Hashing only the names would answer "did someone pick differently" and miss
568
+ * the third case entirely, which is the one nobody notices by eye.
569
+ *
570
+ * The integrity block is hashed too, even though it is not a source. It is text
571
+ * the model is given, it can change under a magus upgrade, and the third
572
+ * case above is exactly "the text changed while the names did not" — leaving it
573
+ * out would let `computeStyleStatus` report `in-sync` for a file that still
574
+ * carries the old block. Revising the block therefore costs every project one
575
+ * "project style changed — press a to re-apply", which is the honest signal.
576
+ */
577
+ export function styleHash(sources: StyleSource[]): string {
578
+ const hash = createHash("sha256");
579
+ for (const source of sources) {
580
+ hash.update(source.id);
581
+ hash.update("");
582
+ hash.update(source.body);
583
+ hash.update("");
584
+ }
585
+ hash.update(INTEGRITY_BLOCK);
586
+ return `sha256:${hash.digest("hex").slice(0, 32)}`;
587
+ }
588
+
589
+ export function declarationPath(projectPath: string): string {
590
+ return path.join(projectPath, ".claude", "style.json");
591
+ }
592
+
593
+ /** Read the committed declaration, or null when the project has none. */
594
+ export async function readStyleDeclaration(
595
+ projectPath: string,
596
+ ): Promise<StyleDeclaration | null> {
597
+ const file = declarationPath(projectPath);
598
+ try {
599
+ if (!(await fs.pathExists(file))) return null;
600
+ const parsed = (await fs.readJson(file)) as Partial<StyleDeclaration>;
601
+ if (!Array.isArray(parsed.presets) || !Array.isArray(parsed.imports)) {
602
+ return null;
603
+ }
604
+ return {
605
+ version: 1,
606
+ presets: parsed.presets.filter((v): v is string => typeof v === "string"),
607
+ imports: parsed.imports.filter((v): v is string => typeof v === "string"),
608
+ hash: typeof parsed.hash === "string" ? parsed.hash : "",
609
+ updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : "",
610
+ };
611
+ } catch {
612
+ // A corrupt declaration is reported as "none" rather than crashing the
613
+ // screen; re-applying rewrites it.
614
+ return null;
615
+ }
616
+ }
617
+
618
+ async function writeStyleDeclaration(
619
+ projectPath: string,
620
+ declaration: StyleDeclaration,
621
+ ): Promise<void> {
622
+ const file = declarationPath(projectPath);
623
+ await fs.ensureDir(path.dirname(file));
624
+ await fs.writeJson(file, declaration, { spaces: 2 });
625
+ }
626
+
627
+ /**
628
+ * Resolve the declaration against what is on this machine right now.
629
+ *
630
+ * Returns the sources in composition order plus anything that no longer
631
+ * resolves — a preset the plugin dropped, or an imported style the teammate
632
+ * who committed the declaration has but this machine does not.
633
+ */
634
+ export function resolveDeclaration(
635
+ declaration: StyleDeclaration,
636
+ presets: StylePreset[],
637
+ imports: ImportedStyle[],
638
+ ): { sources: StyleSource[]; missing: string[]; fetchable: string[] } {
639
+ const missing: string[] = [];
640
+ const fetchable: string[] = [];
641
+
642
+ const chosenPresets: StylePreset[] = [];
643
+ for (const name of declaration.presets) {
644
+ const match = presets.find((preset) => preset.name === name);
645
+ if (match) chosenPresets.push(match);
646
+ else missing.push(name);
647
+ }
648
+
649
+ const chosenImports: ImportedStyle[] = [];
650
+ for (const id of declaration.imports) {
651
+ const match = imports.find((style) => style.id === id);
652
+ if (match) {
653
+ chosenImports.push(match);
654
+ continue;
655
+ }
656
+ // The whole reason community ids are registry COORDINATES rather than the
657
+ // file's own `frontmatter.name`: this lookup happens on a machine that has
658
+ // fetched nothing, so the id has to resolve before any file exists.
659
+ if (
660
+ id.startsWith(COMMUNITY_ID_PREFIX) &&
661
+ findCommunityStyle(id.slice(COMMUNITY_ID_PREFIX.length))
662
+ ) {
663
+ fetchable.push(id);
664
+ } else {
665
+ missing.push(id);
666
+ }
667
+ }
668
+
669
+ return {
670
+ sources: orderSources(chosenPresets, chosenImports),
671
+ missing,
672
+ fetchable,
673
+ };
674
+ }
675
+
676
+ /**
677
+ * Compare the committed declaration with what is actually live here.
678
+ *
679
+ * The comparison recomputes the hash from CURRENT source bodies rather than
680
+ * trusting the one stored in the declaration. That is the whole point: it
681
+ * answers "would applying this right now produce what I already have", which
682
+ * catches a pulled change, a plugin update, and a machine that never applied.
683
+ */
684
+ export function computeStyleStatus(args: {
685
+ declaration: StyleDeclaration | null;
686
+ presets: StylePreset[];
687
+ imports: ImportedStyle[];
688
+ /** Hash recorded in the local generated style file, if it exists. */
689
+ localHash: string | null;
690
+ /** Whether settings.json actually points at our generated style. */
691
+ isActive: boolean;
692
+ }): StyleSyncStatus {
693
+ const { declaration, presets, imports, localHash, isActive } = args;
694
+
695
+ if (!declaration) {
696
+ return {
697
+ state: "undeclared",
698
+ detail: "no project style committed",
699
+ missing: [],
700
+ fetchable: [],
701
+ };
702
+ }
703
+
704
+ const { sources, missing, fetchable } = resolveDeclaration(
705
+ declaration,
706
+ presets,
707
+ imports,
708
+ );
709
+ const expected = styleHash(sources);
710
+
711
+ // Said in preference to every other detail below, because it is the only one
712
+ // naming an action that CONVERGES. "press a" on a declaration whose community
713
+ // style is not on this machine re-applies a smaller set and leaves the
714
+ // difference in place, so the message would still be true next time.
715
+ const needsFetch =
716
+ fetchable.length > 0
717
+ ? `project style needs ${fetchable.length} community style${
718
+ fetchable.length === 1 ? "" : "s"
719
+ } — press f`
720
+ : null;
721
+
722
+ if (!isActive || !localHash) {
723
+ return {
724
+ state: "not-applied",
725
+ detail: needsFetch ?? "project style not applied here — press a",
726
+ missing,
727
+ fetchable,
728
+ };
729
+ }
730
+ if (localHash !== expected || needsFetch) {
731
+ // A pending fetch never reports in-sync even when the hashes agree. They
732
+ // agree because the un-fetched style was left out of BOTH sides, which is
733
+ // the composition matching itself rather than matching the declaration.
734
+ return {
735
+ state: "stale",
736
+ detail: needsFetch ?? "project style changed — press a to re-apply",
737
+ missing,
738
+ fetchable,
739
+ };
740
+ }
741
+ return {
742
+ state: "in-sync",
743
+ detail: "matches the project style",
744
+ missing,
745
+ fetchable,
746
+ };
747
+ }
748
+
749
+ // ─── Paths ────────────────────────────────────────────────────────────────────
750
+
751
+ const DEFAULT_STYLE_NAME = "composed";
752
+
753
+ /**
754
+ * The generated style's name for this project.
755
+ *
756
+ * Per-profile when a profile is active. Without this, two profiles both write
757
+ * `composed.md` and both point `outputStyle` at "composed": the second apply
758
+ * silently rewrites the first profile's style while its settings still claim
759
+ * the old one. Naming the file after the profile keeps them independent.
760
+ */
761
+ export function generatedStyleName(profile: string | null): string {
762
+ if (!profile) return DEFAULT_STYLE_NAME;
763
+ const slug = profile
764
+ .toLowerCase()
765
+ .replace(/[^a-z0-9]+/g, "-")
766
+ .replace(/^-|-$/g, "");
767
+ return slug ? `${DEFAULT_STYLE_NAME}-${slug}` : DEFAULT_STYLE_NAME;
768
+ }
769
+
770
+ function stylePathFor(projectPath: string, styleName: string): string {
771
+ return path.join(projectPath, ".claude", "output-styles", `${styleName}.md`);
772
+ }
773
+
774
+ function settingsPathFor(projectPath: string): string {
775
+ return path.join(projectPath, ".claude", "settings.json");
776
+ }
777
+
778
+ // ─── Snapshot ─────────────────────────────────────────────────────────────────
779
+
780
+ /** Read the selection a previously generated style file recorded. */
781
+ export async function readApplied(stylePath: string): Promise<{
782
+ presets: string[];
783
+ imports: string[];
784
+ hash: string | null;
785
+ } | null> {
786
+ if (!(await fs.pathExists(stylePath))) return null;
787
+ const { frontmatter } = splitFrontmatter(
788
+ await fs.readFile(stylePath, "utf8"),
789
+ );
790
+ const clean = (value: string | undefined) =>
791
+ !value || value === "none" ? [] : splitList(value);
792
+ return {
793
+ presets: clean(frontmatter["style-presets"]),
794
+ imports: clean(frontmatter["style-imports"]),
795
+ // Absent on a file written before hashing existed; treated as "unknown",
796
+ // which reads as out-of-date and prompts a re-apply. That is the safe
797
+ // direction to be wrong in.
798
+ hash: frontmatter["style-hash"] || null,
799
+ };
800
+ }
801
+
802
+ async function readOutputStyle(settingsPath: string): Promise<string | null> {
803
+ try {
804
+ if (!(await fs.pathExists(settingsPath))) return null;
805
+ const raw = (await fs.readFile(settingsPath, "utf8")).trim();
806
+ if (!raw) return null;
807
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
808
+ return typeof parsed.outputStyle === "string" ? parsed.outputStyle : null;
809
+ } catch {
810
+ return null;
811
+ }
812
+ }
813
+
814
+ export interface LoadStylesOptions {
815
+ /**
816
+ * Home directory to read user-scoped styles from. Defaults to the real one.
817
+ * Overridable so a test does not depend on whatever the developer happens to
818
+ * have in ~/.claude/output-styles.
819
+ */
820
+ home?: string;
821
+ }
822
+
823
+ /** One read of everything the Styles screen renders. */
824
+ export async function loadStyles(
825
+ projectPath: string,
826
+ options: LoadStylesOptions = {},
827
+ ): Promise<StylesSnapshot> {
828
+ const home = options.home ?? os.homedir();
829
+ const profile = await activeProfile(projectPath);
830
+ const styleName = generatedStyleName(profile);
831
+ const stylePath = stylePathFor(projectPath, styleName);
832
+ const settingsPath = settingsPathFor(projectPath);
833
+
834
+ const presets = loadPresets();
835
+ // Exclude every name we might generate, not just the current one — a style
836
+ // left over from another profile is ours, not the user's, and importing it
837
+ // would nest a composition inside a composition.
838
+ const imports = await readImportable(
839
+ projectPath,
840
+ [DEFAULT_STYLE_NAME, styleName],
841
+ home,
842
+ );
843
+
844
+ // `applied` means "these rules are in force", which takes BOTH the generated
845
+ // file recording them and settings.json pointing at that file. Reading the
846
+ // file alone reports a cleared style as still live, because clearing removes
847
+ // the pointer and deliberately leaves the file on disk.
848
+ const currentOutputStyle = await readOutputStyle(settingsPath);
849
+ const applied =
850
+ currentOutputStyle === styleName ? await readApplied(stylePath) : null;
851
+
852
+ const declaration = await readStyleDeclaration(projectPath);
853
+ const status = computeStyleStatus({
854
+ declaration,
855
+ presets,
856
+ imports,
857
+ localHash: applied?.hash ?? null,
858
+ isActive: currentOutputStyle === styleName,
859
+ });
860
+
861
+ return {
862
+ presets,
863
+ imports,
864
+ applied,
865
+ declaration,
866
+ status,
867
+ stylePath,
868
+ settingsPath,
869
+ styleName,
870
+ profile,
871
+ currentOutputStyle,
872
+ };
873
+ }
874
+
875
+ // ─── Validation ───────────────────────────────────────────────────────────────
876
+
877
+ /**
878
+ * Why a selection cannot be applied. Empty array means it is valid.
879
+ *
880
+ * The rules are the plugin's, and they are not cosmetic: two verbosity presets
881
+ * are contradictory instructions that cancel out, producing a style that reads
882
+ * as if it were carefully configured and behaves as if it were not.
883
+ */
884
+ export function validateSelection(chosen: StylePreset[]): string[] {
885
+ const errors: string[] = [];
886
+
887
+ for (const preset of chosen) {
888
+ if (preset.template) {
889
+ errors.push(
890
+ `"${preset.name}" is a template preset — fill it in and save it to .claude/output-styles/ first, then import it.`,
891
+ );
892
+ }
893
+ }
894
+
895
+ const verbosity = chosen.filter((preset) => preset.axis === "verbosity");
896
+ if (verbosity.length > 1) {
897
+ errors.push(
898
+ `Pick exactly one verbosity preset. Got: ${verbosity
899
+ .map((preset) => preset.name)
900
+ .join(", ")}. They contradict each other and cancel out.`,
901
+ );
902
+ }
903
+
904
+ const seen = new Set<string>();
905
+ for (const preset of chosen) {
906
+ for (const conflict of preset.conflicts) {
907
+ if (!chosen.some((other) => other.name === conflict)) continue;
908
+ // Report a conflicting pair once, not once from each side.
909
+ const key = [preset.name, conflict].sort().join("|");
910
+ if (seen.has(key)) continue;
911
+ seen.add(key);
912
+ errors.push(`"${preset.name}" conflicts with "${conflict}".`);
913
+ }
914
+ }
915
+
916
+ return errors;
917
+ }
918
+
919
+ /**
920
+ * Order the selected sources for composition.
921
+ *
922
+ * Imports first, then verbosity, then modifiers — specific-after-broad, so a
923
+ * preset rule refines an imported personality rather than being buried by it.
924
+ */
925
+ export function orderSources(
926
+ presets: StylePreset[],
927
+ imports: ImportedStyle[],
928
+ ): StyleSource[] {
929
+ const verbosity = presets.filter((preset) => preset.axis === "verbosity");
930
+ const modifiers = presets.filter((preset) => preset.axis === "modifier");
931
+ return [...imports, ...verbosity.slice(0, 1), ...modifiers];
932
+ }
933
+
934
+ // ─── Composition ──────────────────────────────────────────────────────────────
935
+
936
+ /** Claude Code truncates long descriptions in the picker; stay well under. */
937
+ const MAX_DESCRIPTION = 200;
938
+
939
+ /**
940
+ * The shared integrity block. Appended to EVERY composition, unconditionally.
941
+ *
942
+ * A composed style is arbitrary instruction text assembled from several
943
+ * sources, and once the `community` origin lands one of those sources is
944
+ * third-party writing fetched from someone else's repository. We cannot detect
945
+ * prompt injection in it and will not pretend to. What we can do is state, at
946
+ * the end of the prompt, the things style is never allowed to change — so a
947
+ * rule that says "be brief" cannot be read as licence to trim a command, and a
948
+ * rule that says "no hedging" cannot be read as licence to drop a warning.
949
+ *
950
+ * It lives in the BODY, not the frontmatter, because only the body reaches the
951
+ * model. That is a real cost paid on every request, and it is the exception to
952
+ * the "provenance goes in frontmatter" rule directly above: provenance is for
953
+ * the human reading the file, this is for the model reading the prompt.
954
+ *
955
+ * It goes LAST for the same reason `orderSources` puts imports first —
956
+ * specific-after-broad, so later text refines earlier text rather than being
957
+ * buried by it.
958
+ *
959
+ * This is the only copy. It used to be duplicated in the retired style
960
+ * plugin's composer, with a test comparing the two; both are gone, so nothing
961
+ * can drift from it any more. It is still asserted to reach EVERY composition
962
+ * in `__tests__/styles-manager.test.ts` — that check is what matters, since
963
+ * the block is the security backstop on arbitrary assembled instruction text.
964
+ */
965
+ export const INTEGRITY_BLOCK = `## Style limits
966
+
967
+ These rules override everything above. Style decides how an answer is worded;
968
+ it never decides what is true.
969
+
970
+ - Never reword, shorten, or tidy code, commands, file paths, identifiers, error
971
+ text, log output, or numbers to fit a style rule. Reproduce them exactly,
972
+ including the parts that read badly.
973
+ - A brevity rule may cut prose. It may never cut a flag from a command, a
974
+ segment from a path, a digit from a figure, or the line of a stack trace that
975
+ names the failure.
976
+ - Quote real output rather than paraphrasing it. When it is too long to
977
+ include, quote the part that decides the answer and say what was left out.
978
+ - Never soften or drop a security warning, a data-loss risk, or a caveat that
979
+ would change what the reader does next. State it plainly, even under a rule
980
+ that bans hedging.
981
+ - Ask before any destructive or irreversible action and name exactly what would
982
+ be lost. No verbosity or brevity rule suppresses that confirmation.
983
+ - Say when something is unverified, failing, or unknown. A rule against filler
984
+ bans padding, not honesty.`;
985
+
986
+ function describe(sources: StyleSource[]): string {
987
+ const names = sources.map((source) => source.name).join(", ");
988
+ const text = `Composed communication style: ${names || "no rules selected"}`;
989
+ const clipped =
990
+ text.length > MAX_DESCRIPTION
991
+ ? `${text.slice(0, MAX_DESCRIPTION - 1)}\u2026`
992
+ : text;
993
+ // The value carries ": " from the label and preset names may carry anything.
994
+ // Unquoted, YAML reads that as a nested mapping and the frontmatter fails to
995
+ // parse — so always quote, and escape what would close the quote.
996
+ return `"${clipped.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
997
+ }
998
+
999
+ /** Render the single generated output-style file for a selection. */
1000
+ export function composeStyleFile(
1001
+ styleName: string,
1002
+ sources: StyleSource[],
1003
+ ): string {
1004
+ const presets = sources.filter(
1005
+ (source): source is StylePreset => source.kind === "preset",
1006
+ );
1007
+ const imports = sources.filter(
1008
+ (source): source is ImportedStyle => source.kind === "imported",
1009
+ );
1010
+
1011
+ // Provenance goes in FRONTMATTER, not the body: Claude Code splits the file
1012
+ // and only the body becomes the prompt, so a marker in the body is paid for
1013
+ // on every request forever.
1014
+ const lines: string[] = [
1015
+ "---",
1016
+ `name: ${styleName}`,
1017
+ `description: ${describe(sources)}`,
1018
+ // Without this, Claude Code drops its own coding-discipline block from the
1019
+ // system prompt. A style about how to COMMUNICATE has no business
1020
+ // switching off how to write code.
1021
+ "keep-coding-instructions: true",
1022
+ 'generated-by: "magus Styles tab. Hand edits are lost on the next apply."',
1023
+ `style-presets: ${presets.map((preset) => preset.name).join(", ") || "none"}`,
1024
+ `style-imports: ${imports.map((style) => style.id).join(", ") || "none"}`,
1025
+ // Fingerprint of the rules this file was built from. Recomputing it from
1026
+ // the declaration and comparing tells a teammate whether their local copy
1027
+ // is what the project currently asks for — see computeStyleStatus.
1028
+ `style-hash: ${styleHash(sources)}`,
1029
+ "---",
1030
+ "",
1031
+ ];
1032
+
1033
+ for (const style of imports) {
1034
+ lines.push(`## Imported: ${style.name}`, "", style.body, "");
1035
+ }
1036
+
1037
+ if (presets.length > 0) {
1038
+ lines.push("## Communication style", "");
1039
+ for (const preset of presets) {
1040
+ lines.push(preset.body, "");
1041
+ }
1042
+ }
1043
+
1044
+ // Unconditional, and last. A composition with no sources still gets it —
1045
+ // there is no selection for which "do not rewrite an error message" stops
1046
+ // applying.
1047
+ lines.push(INTEGRITY_BLOCK, "");
1048
+
1049
+ return `${lines
1050
+ .join("\n")
1051
+ .replace(/\n{3,}/g, "\n\n")
1052
+ .trimEnd()}\n`;
1053
+ }
1054
+
1055
+ // ─── Apply ────────────────────────────────────────────────────────────────────
1056
+
1057
+ /**
1058
+ * Set `outputStyle` in settings.json, preserving everything else.
1059
+ *
1060
+ * Writes in place, and refuses a file it cannot parse — both are
1061
+ * `updateSettingsFile`'s contract, and both are documented there.
1062
+ */
1063
+ async function setOutputStyle(
1064
+ settingsPath: string,
1065
+ styleName: string,
1066
+ ): Promise<void> {
1067
+ await updateSettingsFile(settingsPath, (settings) => {
1068
+ settings.outputStyle = styleName;
1069
+ });
1070
+ }
1071
+
1072
+ /**
1073
+ * Record the style in the committed manifest so it survives re-materialization.
1074
+ * Returns false when there is no active profile or no manifest entry for it —
1075
+ * both are normal, and neither is an error.
1076
+ *
1077
+ * `mergeManifestSettings` is shared with model routing, which writes the same
1078
+ * `profiles.<name>.settings` for the same reason. It throws on a manifest it
1079
+ * cannot parse rather than reporting a success it did not achieve.
1080
+ */
1081
+ async function recordInManifest(
1082
+ projectPath: string,
1083
+ profile: string | null,
1084
+ styleName: string,
1085
+ ): Promise<boolean> {
1086
+ return mergeManifestSettings(projectPath, profile, {
1087
+ outputStyle: styleName,
1088
+ });
1089
+ }
1090
+
1091
+ export interface ApplyStylesArgs {
1092
+ projectPath: string;
1093
+ presets: StylePreset[];
1094
+ imports: ImportedStyle[];
1095
+ }
1096
+
1097
+ /**
1098
+ * Compose the selection, write it, activate it, and verify by re-reading.
1099
+ *
1100
+ * Verification is deliberate: a write that reported success but produced a
1101
+ * style file with `keep-coding-instructions` missing would strip Claude Code's
1102
+ * coding rules, and nothing downstream would notice.
1103
+ */
1104
+ export async function applyStyles({
1105
+ projectPath,
1106
+ presets,
1107
+ imports,
1108
+ }: ApplyStylesArgs): Promise<ApplyResult> {
1109
+ const errors = validateSelection(presets);
1110
+ if (errors.length > 0) throw new Error(errors.join(" "));
1111
+ if (presets.length === 0 && imports.length === 0) {
1112
+ throw new Error("Nothing selected. Pick at least one style.");
1113
+ }
1114
+
1115
+ const profile = await activeProfile(projectPath);
1116
+ const styleName = generatedStyleName(profile);
1117
+ const stylePath = stylePathFor(projectPath, styleName);
1118
+ const settingsPath = settingsPathFor(projectPath);
1119
+
1120
+ const sources = orderSources(presets, imports);
1121
+ const contents = composeStyleFile(styleName, sources);
1122
+
1123
+ await fs.ensureDir(path.dirname(stylePath));
1124
+ await fs.writeFile(stylePath, contents, "utf8");
1125
+ await setOutputStyle(settingsPath, styleName);
1126
+
1127
+ // Verify by re-reading, not by trusting the write.
1128
+ const verify = splitFrontmatter(await fs.readFile(stylePath, "utf8"));
1129
+ const problems: string[] = [];
1130
+ if (verify.frontmatter.name !== styleName) {
1131
+ problems.push(
1132
+ `style file name is "${verify.frontmatter.name}", expected "${styleName}"`,
1133
+ );
1134
+ }
1135
+ if (asBool(verify.frontmatter["keep-coding-instructions"]) !== true) {
1136
+ problems.push(
1137
+ "keep-coding-instructions is not true — coding rules would be dropped",
1138
+ );
1139
+ }
1140
+ if ((await readOutputStyle(settingsPath)) !== styleName) {
1141
+ problems.push("settings outputStyle did not take");
1142
+ }
1143
+ if (problems.length > 0) {
1144
+ throw new Error(`Verification failed — ${problems.join("; ")}`);
1145
+ }
1146
+
1147
+ const recordedInManifest = await recordInManifest(
1148
+ projectPath,
1149
+ profile,
1150
+ styleName,
1151
+ );
1152
+
1153
+ // Write the declaration LAST, once the artifact is verified. It is the thing
1154
+ // that gets committed and that teammates are told to match, so it must never
1155
+ // claim a composition that failed to produce a valid file.
1156
+ const hash = styleHash(sources);
1157
+ const declaration: StyleDeclaration = {
1158
+ version: 1,
1159
+ presets: presets.map((preset) => preset.name),
1160
+ imports: imports.map((style) => style.id),
1161
+ hash,
1162
+ updatedAt: new Date().toISOString(),
1163
+ };
1164
+ await writeStyleDeclaration(projectPath, declaration);
1165
+
1166
+ return {
1167
+ stylePath,
1168
+ settingsPath,
1169
+ styleName,
1170
+ profile,
1171
+ recordedInManifest,
1172
+ declarationPath: declarationPath(projectPath),
1173
+ hash,
1174
+ presets: declaration.presets,
1175
+ imports: declaration.imports,
1176
+ bytes: Buffer.byteLength(contents, "utf8"),
1177
+ };
1178
+ }
1179
+
1180
+ // ─── Authoring a team style ───────────────────────────────────────────────────
1181
+
1182
+ /** Filename-safe slug. Rejects anything that would escape the styles dir. */
1183
+ export function styleSlug(input: string): string {
1184
+ return input
1185
+ .trim()
1186
+ .toLowerCase()
1187
+ .replace(/[^a-z0-9]+/g, "-")
1188
+ .replace(/^-+|-+$/g, "");
1189
+ }
1190
+
1191
+ export interface CreatedStyle {
1192
+ path: string;
1193
+ name: string;
1194
+ /** True when the file already existed and was left untouched. */
1195
+ existed: boolean;
1196
+ }
1197
+
1198
+ /**
1199
+ * Scaffold a project-scoped style the team can edit and commit.
1200
+ *
1201
+ * Project scope is the whole point: `<project>/.claude/output-styles/` lives in
1202
+ * the repository, so the style travels with the project instead of being one
1203
+ * person's machine-local preference.
1204
+ *
1205
+ * This writes a starting file rather than opening an editor — magus owns the
1206
+ * terminal, and suspending an OpenTUI app to hand the TTY to `$EDITOR` is a
1207
+ * reliable way to leave the terminal in a broken state. The caller shows the
1208
+ * path; the user edits it and presses `r`.
1209
+ *
1210
+ * An existing file is never overwritten: that would silently destroy work the
1211
+ * team had already committed.
1212
+ */
1213
+ export async function createTeamStyle(
1214
+ projectPath: string,
1215
+ rawName: string,
1216
+ ): Promise<CreatedStyle> {
1217
+ const name = styleSlug(rawName);
1218
+ if (!name) {
1219
+ throw new Error(
1220
+ `"${rawName}" has no letters or digits to make a filename from.`,
1221
+ );
1222
+ }
1223
+ if (
1224
+ name === DEFAULT_STYLE_NAME ||
1225
+ name.startsWith(`${DEFAULT_STYLE_NAME}-`)
1226
+ ) {
1227
+ throw new Error(
1228
+ `"${name}" is the name magus generates. Pick another, or it would be overwritten on the next apply.`,
1229
+ );
1230
+ }
1231
+
1232
+ const file = path.join(projectPath, ".claude", "output-styles", `${name}.md`);
1233
+ if (await fs.pathExists(file)) {
1234
+ return { path: file, name, existed: true };
1235
+ }
1236
+
1237
+ const scaffold = [
1238
+ "---",
1239
+ `name: ${name}`,
1240
+ `description: "Team communication style: ${name}"`,
1241
+ // Same reasoning as the composed file: a style about how to COMMUNICATE
1242
+ // must not switch off Claude Code's own coding rules.
1243
+ "keep-coding-instructions: true",
1244
+ "---",
1245
+ "",
1246
+ `## ${name}`,
1247
+ "",
1248
+ "Replace this with the rules for this project. Write them as short,",
1249
+ "checkable instructions — each one something a reader could tell was",
1250
+ "followed or not. For example:",
1251
+ "",
1252
+ "- Name the file and line when describing a change.",
1253
+ "- Report a failing test with its actual output, never a paraphrase.",
1254
+ "",
1255
+ "Commit this file with the project so everyone gets the same style.",
1256
+ "",
1257
+ ].join("\n");
1258
+
1259
+ await fs.ensureDir(path.dirname(file));
1260
+ await fs.writeFile(file, scaffold, "utf8");
1261
+ return { path: file, name, existed: false };
1262
+ }
1263
+
1264
+ /**
1265
+ * Deactivate: drop `outputStyle` from settings.json (and the manifest entry).
1266
+ * The generated file is left on disk — it is cheap, and keeping it means an
1267
+ * accidental clear is one re-apply away from being undone.
1268
+ */
1269
+ export async function clearStyle(projectPath: string): Promise<void> {
1270
+ const settingsPath = settingsPathFor(projectPath);
1271
+ // The guard stays: a project that never had a style must not gain a
1272
+ // settings.json just because the style was cleared.
1273
+ if (await fs.pathExists(settingsPath)) {
1274
+ await updateSettingsFile(settingsPath, (settings) => {
1275
+ // The key must be ABSENT, not present-and-undefined: Claude Code reads
1276
+ // presence, and an `= undefined` assignment still answers `in` checks.
1277
+ // biome-ignore lint/performance/noDelete: removal is the intent, not a shortcut
1278
+ delete settings.outputStyle;
1279
+ });
1280
+ }
1281
+
1282
+ const profile = await activeProfile(projectPath);
1283
+ if (!profile) return;
1284
+ if (!(await fs.pathExists(getManifestPath(projectPath)))) return;
1285
+ const manifest = await readManifest(projectPath);
1286
+ const entry = manifest.profiles[profile];
1287
+ if (!entry?.settings || !("outputStyle" in entry.settings)) return;
1288
+ // The guard above uses `in`, so assigning undefined would make a second call
1289
+ // a no-op that still leaves the key serialized into profiles.json.
1290
+ // biome-ignore lint/performance/noDelete: removal is the intent, not a shortcut
1291
+ delete entry.settings.outputStyle;
1292
+ entry.updatedAt = new Date().toISOString();
1293
+ await writeManifest(manifest, projectPath);
1294
+ }