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,945 @@
1
+ /**
2
+ * Convention Manager Service
3
+ *
4
+ * All business logic for reading conventions from plugin.json,
5
+ * manipulating .gitignore files, and injecting/removing CLAUDE.md sections.
6
+ *
7
+ * Design principles:
8
+ * - Pure functions where possible (gitignore deduplication, marker parsing)
9
+ * - Side-effecting functions take explicit paths (no process.cwd())
10
+ * - File locking for all write operations
11
+ * - All operations are idempotent
12
+ * - Atomic writes: write-to-temp-and-rename under lock
13
+ * - Line-oriented state machine for CLAUDE.md marker parsing
14
+ */
15
+
16
+ import { randomUUID } from "node:crypto";
17
+ import { existsSync } from "node:fs";
18
+ import {
19
+ open,
20
+ readFile,
21
+ rename,
22
+ stat,
23
+ unlink,
24
+ writeFile,
25
+ } from "node:fs/promises";
26
+ import { homedir } from "node:os";
27
+ import { basename, dirname, join, resolve } from "node:path";
28
+ import type { PluginConventions } from "../types/index.js";
29
+ import { withFileLock } from "../utils/file-locking.js";
30
+
31
+ // ============================================================
32
+ // TYPES
33
+ // ============================================================
34
+
35
+ /** A successfully parsed managed section from CLAUDE.md */
36
+ export interface ParsedSection {
37
+ section: string;
38
+ version: string | null;
39
+ content: string;
40
+ startLine: number;
41
+ endLine: number;
42
+ insideCodeBlock: boolean;
43
+ }
44
+
45
+ /** Parse error types for CLAUDE.md markers */
46
+ export type ParseError =
47
+ | { type: "missing_end"; section: string; startLine: number }
48
+ | { type: "mismatched_end"; section: string; expected: string; line: number }
49
+ | { type: "duplicate_section"; section: string; lines: number[] }
50
+ | { type: "marker_in_code_block"; section: string; line: number };
51
+
52
+ /** Result from parseClaudeMdSections */
53
+ export interface ParseResult {
54
+ sections: ParsedSection[];
55
+ errors: ParseError[];
56
+ }
57
+
58
+ /** Logger for convention operations */
59
+ export type ConventionLogger = (
60
+ level: "info" | "warn" | "error",
61
+ message: string,
62
+ ) => void;
63
+
64
+ // ============================================================
65
+ // MARKER PATTERNS
66
+ // ============================================================
67
+
68
+ const BEGIN_MARKER_RE = /^<!--\s*BEGIN\s+magus:([\w-]+)\s+v([\w.+-]+)\s*-->$/;
69
+ const END_MARKER_RE = /^<!--\s*END\s+magus:([\w-]+)\s*-->$/;
70
+ const CODE_FENCE_RE = /^(`{3,}|~{3,})/;
71
+
72
+ // ============================================================
73
+ // ATOMIC FILE WRITE
74
+ // ============================================================
75
+
76
+ /**
77
+ * Write file atomically using write-to-temp-and-rename.
78
+ * Crash-safe: if the process crashes during write, the temp file is left
79
+ * behind (not the corrupted target). On next run, temp files are cleaned up.
80
+ *
81
+ * @param targetPath - Absolute path to the target file
82
+ * @param content - Content to write
83
+ */
84
+ async function atomicWrite(targetPath: string, content: string): Promise<void> {
85
+ const dir = dirname(targetPath);
86
+ const tmpPath = join(dir, `.${basename(targetPath)}.tmp.${randomUUID()}`);
87
+
88
+ try {
89
+ // Preserve original file permissions if file exists
90
+ let mode: number | undefined;
91
+ try {
92
+ const fileStat = await stat(targetPath);
93
+ mode = fileStat.mode;
94
+ } catch {
95
+ // File doesn't exist yet, use default permissions
96
+ }
97
+
98
+ // Write to temp file
99
+ await writeFile(tmpPath, content, { mode: mode ?? 0o644 });
100
+
101
+ // fsync to ensure content is on disk before rename
102
+ const fd = await open(tmpPath, "r");
103
+ await fd.sync();
104
+ await fd.close();
105
+
106
+ // Atomic rename (same filesystem guaranteed since same directory)
107
+ await rename(tmpPath, targetPath);
108
+ } catch (err) {
109
+ // Clean up temp file on failure
110
+ try {
111
+ await unlink(tmpPath);
112
+ } catch {
113
+ /* ignore */
114
+ }
115
+ throw err;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Read file contents, returning empty string if file does not exist.
121
+ */
122
+ async function readFileOrEmpty(filePath: string): Promise<string> {
123
+ try {
124
+ return await readFile(filePath, "utf-8");
125
+ } catch (err: unknown) {
126
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
127
+ return "";
128
+ }
129
+ throw err;
130
+ }
131
+ }
132
+
133
+ // ============================================================
134
+ // GITIGNORE OPERATIONS
135
+ // ============================================================
136
+
137
+ /**
138
+ * Ensure entries exist in a .gitignore file.
139
+ * Creates the file if it doesn't exist.
140
+ * Idempotent: skips entries that are already present.
141
+ * Uses atomic write-to-temp-and-rename under file lock.
142
+ *
143
+ * @param gitignorePath - Absolute path to .gitignore file
144
+ * @param entries - Array of gitignore patterns to add
145
+ * @param comment - Optional comment header (e.g., "# Code Analysis plugin")
146
+ * @returns Object with added entries and whether file was modified
147
+ */
148
+ export async function ensureGitignoreEntries(
149
+ gitignorePath: string,
150
+ entries: string[],
151
+ comment?: string,
152
+ ): Promise<{ added: string[]; modified: boolean }> {
153
+ if (entries.length === 0) {
154
+ return { added: [], modified: false };
155
+ }
156
+
157
+ return withFileLock(gitignorePath, async () => {
158
+ const content = await readFileOrEmpty(gitignorePath);
159
+ const lines = content.split("\n");
160
+
161
+ // Build a Set of existing patterns (trimmed, ignoring comments and blank lines)
162
+ const existingPatterns = new Set<string>();
163
+ for (const line of lines) {
164
+ const trimmed = line.trim();
165
+ if (trimmed && !trimmed.startsWith("#")) {
166
+ existingPatterns.add(trimmed);
167
+ }
168
+ }
169
+
170
+ // Find entries that need to be added
171
+ const toAdd = entries.filter(
172
+ (entry) => !existingPatterns.has(entry.trim()),
173
+ );
174
+
175
+ if (toAdd.length === 0) {
176
+ return { added: [], modified: false };
177
+ }
178
+
179
+ // Build the append block
180
+ const appendLines: string[] = [];
181
+
182
+ // Add comment if provided and not already in the file
183
+ if (comment) {
184
+ const commentExists = lines.some(
185
+ (line) => line.trim() === comment.trim(),
186
+ );
187
+ if (!commentExists) {
188
+ appendLines.push(comment);
189
+ }
190
+ }
191
+
192
+ for (const entry of toAdd) {
193
+ appendLines.push(entry);
194
+ }
195
+
196
+ // Ensure existing content ends with a newline before appending
197
+ let newContent = content;
198
+ if (newContent.length > 0 && !newContent.endsWith("\n")) {
199
+ newContent += "\n";
200
+ }
201
+
202
+ // Add blank line separator if file has content
203
+ if (newContent.trim().length > 0) {
204
+ newContent += "\n";
205
+ }
206
+
207
+ newContent += `${appendLines.join("\n")}\n`;
208
+
209
+ await atomicWrite(gitignorePath, newContent);
210
+
211
+ return { added: toAdd, modified: true };
212
+ });
213
+ }
214
+
215
+ /**
216
+ * Remove entries from a .gitignore file.
217
+ * No-op if file doesn't exist or entries are not present.
218
+ *
219
+ * @param gitignorePath - Absolute path to .gitignore file
220
+ * @param entries - Array of gitignore patterns to remove
221
+ * @returns Object with removed entries and whether file was modified
222
+ */
223
+ export async function removeGitignoreEntries(
224
+ gitignorePath: string,
225
+ entries: string[],
226
+ ): Promise<{ removed: string[]; modified: boolean }> {
227
+ if (entries.length === 0) {
228
+ return { removed: [], modified: false };
229
+ }
230
+
231
+ return withFileLock(gitignorePath, async () => {
232
+ const content = await readFileOrEmpty(gitignorePath);
233
+ if (!content) {
234
+ return { removed: [], modified: false };
235
+ }
236
+
237
+ const entriesToRemove = new Set(entries.map((e) => e.trim()));
238
+ const lines = content.split("\n");
239
+ const removed: string[] = [];
240
+ const newLines: string[] = [];
241
+
242
+ for (const line of lines) {
243
+ const trimmed = line.trim();
244
+ if (entriesToRemove.has(trimmed)) {
245
+ removed.push(trimmed);
246
+ } else {
247
+ newLines.push(line);
248
+ }
249
+ }
250
+
251
+ if (removed.length === 0) {
252
+ return { removed: [], modified: false };
253
+ }
254
+
255
+ await atomicWrite(gitignorePath, newLines.join("\n"));
256
+
257
+ return { removed, modified: true };
258
+ });
259
+ }
260
+
261
+ /**
262
+ * Check which expected entries are missing from a .gitignore file.
263
+ *
264
+ * @param gitignorePath - Absolute path to .gitignore file
265
+ * @param entries - Array of gitignore patterns to check
266
+ * @returns Object with present and missing entries
267
+ */
268
+ export async function checkGitignoreEntries(
269
+ gitignorePath: string,
270
+ entries: string[],
271
+ ): Promise<{ present: string[]; missing: string[] }> {
272
+ const content = await readFileOrEmpty(gitignorePath);
273
+ const lines = content.split("\n");
274
+
275
+ const existingPatterns = new Set<string>();
276
+ for (const line of lines) {
277
+ const trimmed = line.trim();
278
+ if (trimmed && !trimmed.startsWith("#")) {
279
+ existingPatterns.add(trimmed);
280
+ }
281
+ }
282
+
283
+ const present: string[] = [];
284
+ const missing: string[] = [];
285
+
286
+ for (const entry of entries) {
287
+ if (existingPatterns.has(entry.trim())) {
288
+ present.push(entry);
289
+ } else {
290
+ missing.push(entry);
291
+ }
292
+ }
293
+
294
+ return { present, missing };
295
+ }
296
+
297
+ // ============================================================
298
+ // CLAUDE.MD PARSING — LINE-ORIENTED STATE MACHINE
299
+ // ============================================================
300
+
301
+ type ParserState = "NORMAL" | "IN_SECTION";
302
+
303
+ /**
304
+ * Parse a CLAUDE.md file content using a line-oriented state machine.
305
+ * Returns all managed sections and any parse errors.
306
+ * Does NOT mutate the file.
307
+ *
308
+ * @param content - Raw CLAUDE.md file content
309
+ * @returns ParseResult with sections and errors
310
+ */
311
+ export function parseClaudeMdContent(content: string): ParseResult {
312
+ const lines = content.split("\n");
313
+ const sections: ParsedSection[] = [];
314
+ const errors: ParseError[] = [];
315
+
316
+ let state: ParserState = "NORMAL";
317
+ let inCodeBlock = false;
318
+ // Track active fence: delimiter char ('`' or '~') and minimum length to close
319
+ let activeFenceChar = "";
320
+ let activeFenceLen = 0;
321
+
322
+ // Current section being accumulated
323
+ let currentSection = "";
324
+ let currentVersion: string | null = null;
325
+ let currentStartLine = 0;
326
+ let currentContentLines: string[] = [];
327
+
328
+ // Track section names for duplicate detection
329
+ const sectionLineMap = new Map<string, number[]>();
330
+
331
+ for (let i = 0; i < lines.length; i++) {
332
+ const line = lines[i];
333
+ const lineNum = i + 1; // 1-based line numbers
334
+
335
+ // Check for code fence open/close (CommonMark: matching delimiter family + sufficient length)
336
+ const fenceMatch = line.trim().match(CODE_FENCE_RE);
337
+ if (fenceMatch) {
338
+ const fenceStr = fenceMatch[1]; // e.g. '```' or '~~~~'
339
+ const fenceChar = fenceStr[0]; // '`' or '~'
340
+ const fenceLen = fenceStr.length;
341
+
342
+ if (!inCodeBlock) {
343
+ // Opening a new code block
344
+ inCodeBlock = true;
345
+ activeFenceChar = fenceChar;
346
+ activeFenceLen = fenceLen;
347
+ } else if (fenceChar === activeFenceChar && fenceLen >= activeFenceLen) {
348
+ // Closing: must be same delimiter family with at least the same length
349
+ inCodeBlock = false;
350
+ activeFenceChar = "";
351
+ activeFenceLen = 0;
352
+ }
353
+ // Otherwise: different delimiter family or shorter length — ignore (stays in code block)
354
+ }
355
+
356
+ // Check for BEGIN marker
357
+ const beginMatch = line.trim().match(BEGIN_MARKER_RE);
358
+ if (beginMatch) {
359
+ const sectionName = beginMatch[1];
360
+ const version = beginMatch[2];
361
+
362
+ if (inCodeBlock) {
363
+ errors.push({
364
+ type: "marker_in_code_block",
365
+ section: sectionName,
366
+ line: lineNum,
367
+ });
368
+ continue;
369
+ }
370
+
371
+ // If we're already in a section, the previous one had no END marker
372
+ if (state === "IN_SECTION") {
373
+ errors.push({
374
+ type: "missing_end",
375
+ section: currentSection,
376
+ startLine: currentStartLine,
377
+ });
378
+ // Close the previous section with an error
379
+ sections.push({
380
+ section: currentSection,
381
+ version: currentVersion,
382
+ content: currentContentLines.join("\n"),
383
+ startLine: currentStartLine,
384
+ endLine: lineNum - 1,
385
+ insideCodeBlock: false,
386
+ });
387
+ }
388
+
389
+ // Track for duplicate detection
390
+ const seenLines = sectionLineMap.get(sectionName) ?? [];
391
+ seenLines.push(lineNum);
392
+ sectionLineMap.set(sectionName, seenLines);
393
+
394
+ // Start new section
395
+ state = "IN_SECTION";
396
+ currentSection = sectionName;
397
+ currentVersion = version;
398
+ currentStartLine = lineNum;
399
+ currentContentLines = [];
400
+ continue;
401
+ }
402
+
403
+ // Check for END marker
404
+ const endMatch = line.trim().match(END_MARKER_RE);
405
+ if (endMatch) {
406
+ const sectionName = endMatch[1];
407
+
408
+ if (inCodeBlock) {
409
+ // Inside code block, skip
410
+ continue;
411
+ }
412
+
413
+ if (state !== "IN_SECTION") {
414
+ // Orphan END marker, ignore silently
415
+ continue;
416
+ }
417
+
418
+ // Verify END marker matches the currently open section
419
+ if (sectionName !== currentSection) {
420
+ errors.push({
421
+ type: "mismatched_end",
422
+ section: sectionName,
423
+ expected: currentSection,
424
+ line: lineNum,
425
+ });
426
+ // Don't close the section — keep accumulating content
427
+ continue;
428
+ }
429
+
430
+ // Close section
431
+ sections.push({
432
+ section: currentSection,
433
+ version: currentVersion,
434
+ content: currentContentLines.join("\n"),
435
+ startLine: currentStartLine,
436
+ endLine: lineNum,
437
+ insideCodeBlock: false,
438
+ });
439
+
440
+ state = "NORMAL";
441
+ currentSection = "";
442
+ currentVersion = null;
443
+ currentContentLines = [];
444
+ continue;
445
+ }
446
+
447
+ // Accumulate content lines if inside a section
448
+ if (state === "IN_SECTION") {
449
+ currentContentLines.push(line);
450
+ }
451
+ }
452
+
453
+ // After all lines: if still in a section, it's unclosed
454
+ if (state === "IN_SECTION") {
455
+ errors.push({
456
+ type: "missing_end",
457
+ section: currentSection,
458
+ startLine: currentStartLine,
459
+ });
460
+ sections.push({
461
+ section: currentSection,
462
+ version: currentVersion,
463
+ content: currentContentLines.join("\n"),
464
+ startLine: currentStartLine,
465
+ endLine: lines.length,
466
+ insideCodeBlock: false,
467
+ });
468
+ }
469
+
470
+ // Check for duplicate sections
471
+ for (const [sectionName, lineNums] of sectionLineMap) {
472
+ if (lineNums.length > 1) {
473
+ errors.push({
474
+ type: "duplicate_section",
475
+ section: sectionName,
476
+ lines: lineNums,
477
+ });
478
+ }
479
+ }
480
+
481
+ return { sections, errors };
482
+ }
483
+
484
+ /**
485
+ * Parse a CLAUDE.md file using the line-oriented state machine.
486
+ * Reads file from disk. Returns all sections and any parse errors.
487
+ *
488
+ * @param claudeMdPath - Absolute path to CLAUDE.md file
489
+ * @returns ParseResult with sections and errors
490
+ */
491
+ export async function parseClaudeMdSections(
492
+ claudeMdPath: string,
493
+ ): Promise<ParseResult> {
494
+ const content = await readFileOrEmpty(claudeMdPath);
495
+ if (!content) {
496
+ return { sections: [], errors: [] };
497
+ }
498
+ return parseClaudeMdContent(content);
499
+ }
500
+
501
+ // ============================================================
502
+ // CLAUDE.MD MUTATION OPERATIONS
503
+ // ============================================================
504
+
505
+ /**
506
+ * Build a complete marker block for a section.
507
+ */
508
+ function buildMarkerBlock(
509
+ section: string,
510
+ version: string,
511
+ content: string,
512
+ ): string {
513
+ return `<!-- BEGIN magus:${section} v${version} -->\n${content}\n<!-- END magus:${section} -->`;
514
+ }
515
+
516
+ /**
517
+ * Inject or update a managed section in CLAUDE.md.
518
+ * Idempotent: if section exists with same version, no change.
519
+ * If section exists with different version, replaces content.
520
+ * If section doesn't exist, appends at end.
521
+ * REFUSES to mutate if parse errors are detected (returns error).
522
+ * Uses atomic write-to-temp-and-rename under file lock.
523
+ *
524
+ * @param claudeMdPath - Absolute path to CLAUDE.md file
525
+ * @param section - Section identifier (must match plugin name)
526
+ * @param version - Semantic version of the content
527
+ * @param content - Markdown content to inject (without markers)
528
+ * @returns Object indicating action taken, or error if file is malformed
529
+ */
530
+ export async function injectClaudeMdSection(
531
+ claudeMdPath: string,
532
+ section: string,
533
+ version: string,
534
+ content: string,
535
+ ): Promise<{
536
+ action: "inserted" | "updated" | "unchanged" | "error";
537
+ previousVersion?: string;
538
+ error?: string;
539
+ }> {
540
+ return withFileLock(claudeMdPath, async () => {
541
+ const fileContent = await readFileOrEmpty(claudeMdPath);
542
+ const parseResult = parseClaudeMdContent(fileContent);
543
+
544
+ // Refuse if parse errors detected
545
+ if (parseResult.errors.length > 0) {
546
+ const errorSummary = parseResult.errors
547
+ .map((e) => {
548
+ if (e.type === "missing_end")
549
+ return `missing END for "${e.section}" at line ${e.startLine}`;
550
+ if (e.type === "mismatched_end")
551
+ return `mismatched END for "${e.section}" (expected "${e.expected}") at line ${e.line}`;
552
+ if (e.type === "duplicate_section")
553
+ return `duplicate section "${e.section}" at lines ${e.lines.join(", ")}`;
554
+ if (e.type === "marker_in_code_block")
555
+ return `marker for "${e.section}" inside code block at line ${e.line}`;
556
+ return "unknown error";
557
+ })
558
+ .join("; ");
559
+ return {
560
+ action: "error" as const,
561
+ error: `File has malformed markers: ${errorSummary}. Use 'magus doctor --fix' to repair.`,
562
+ };
563
+ }
564
+
565
+ // Find existing section
566
+ const existing = parseResult.sections.find((s) => s.section === section);
567
+
568
+ if (existing) {
569
+ // Section exists -- check version
570
+ if (existing.version === version) {
571
+ return { action: "unchanged" as const };
572
+ }
573
+
574
+ // Version changed -- replace the section
575
+ const lines = fileContent.split("\n");
576
+ const before = lines.slice(0, existing.startLine - 1);
577
+ const after = lines.slice(existing.endLine);
578
+ const newBlock = buildMarkerBlock(section, version, content);
579
+
580
+ const newContent = [...before, newBlock, ...after].join("\n");
581
+ await atomicWrite(claudeMdPath, newContent);
582
+
583
+ return {
584
+ action: "updated" as const,
585
+ previousVersion: existing.version ?? undefined,
586
+ };
587
+ }
588
+
589
+ // Section doesn't exist -- append
590
+ let newContent = fileContent;
591
+ if (newContent.length > 0 && !newContent.endsWith("\n")) {
592
+ newContent += "\n";
593
+ }
594
+ if (newContent.length > 0) {
595
+ newContent += "\n";
596
+ }
597
+ newContent += `${buildMarkerBlock(section, version, content)}\n`;
598
+
599
+ await atomicWrite(claudeMdPath, newContent);
600
+
601
+ return { action: "inserted" as const };
602
+ });
603
+ }
604
+
605
+ /**
606
+ * Remove a managed section from CLAUDE.md.
607
+ * No-op if section doesn't exist or file doesn't exist.
608
+ * REFUSES to mutate if parse errors are detected (returns error).
609
+ * Uses atomic write under file lock.
610
+ *
611
+ * @param claudeMdPath - Absolute path to CLAUDE.md file
612
+ * @param section - Section identifier to remove
613
+ * @returns Whether a section was removed, or error if file is malformed
614
+ */
615
+ export async function removeClaudeMdSection(
616
+ claudeMdPath: string,
617
+ section: string,
618
+ options?: { force?: boolean },
619
+ ): Promise<{ removed: boolean; error?: string }> {
620
+ return withFileLock(claudeMdPath, async () => {
621
+ const fileContent = await readFileOrEmpty(claudeMdPath);
622
+ if (!fileContent) {
623
+ return { removed: false };
624
+ }
625
+
626
+ const parseResult = parseClaudeMdContent(fileContent);
627
+
628
+ // Refuse if parse errors detected (same guard as injectClaudeMdSection)
629
+ // Unless force=true (used by doctor repair-markers to fix broken files)
630
+ if (parseResult.errors.length > 0 && !options?.force) {
631
+ const errorSummary = parseResult.errors
632
+ .map((e) => {
633
+ if (e.type === "missing_end")
634
+ return `missing END for "${e.section}" at line ${e.startLine}`;
635
+ if (e.type === "mismatched_end")
636
+ return `mismatched END for "${e.section}" (expected "${e.expected}") at line ${e.line}`;
637
+ if (e.type === "duplicate_section")
638
+ return `duplicate section "${e.section}" at lines ${e.lines.join(", ")}`;
639
+ if (e.type === "marker_in_code_block")
640
+ return `marker for "${e.section}" inside code block at line ${e.line}`;
641
+ return "unknown error";
642
+ })
643
+ .join("; ");
644
+ return {
645
+ removed: false,
646
+ error: `File has malformed markers: ${errorSummary}. Use 'magus doctor --fix' to repair.`,
647
+ };
648
+ }
649
+
650
+ const existing = parseResult.sections.find((s) => s.section === section);
651
+
652
+ if (!existing) {
653
+ return { removed: false };
654
+ }
655
+
656
+ const lines = fileContent.split("\n");
657
+ const before = lines.slice(0, existing.startLine - 1);
658
+ const after = lines.slice(existing.endLine);
659
+
660
+ // Clean up blank lines at the join point
661
+ while (
662
+ before.length > 0 &&
663
+ before[before.length - 1].trim() === "" &&
664
+ after.length > 0 &&
665
+ after[0].trim() === ""
666
+ ) {
667
+ after.shift();
668
+ }
669
+
670
+ const newContent = [...before, ...after].join("\n");
671
+ await atomicWrite(claudeMdPath, newContent);
672
+
673
+ return { removed: true };
674
+ });
675
+ }
676
+
677
+ /**
678
+ * List all managed sections in a CLAUDE.md file.
679
+ *
680
+ * @param claudeMdPath - Absolute path to CLAUDE.md file
681
+ * @returns Array of section info (name, version, line range)
682
+ */
683
+ export async function listClaudeMdSections(claudeMdPath: string): Promise<
684
+ Array<{
685
+ section: string;
686
+ version: string | null;
687
+ startLine: number;
688
+ endLine: number;
689
+ }>
690
+ > {
691
+ const parseResult = await parseClaudeMdSections(claudeMdPath);
692
+ return parseResult.sections.map((s) => ({
693
+ section: s.section,
694
+ version: s.version,
695
+ startLine: s.startLine,
696
+ endLine: s.endLine,
697
+ }));
698
+ }
699
+
700
+ // ============================================================
701
+ // GLOBAL GITIGNORE RESOLUTION
702
+ // ============================================================
703
+
704
+ /**
705
+ * Resolve the path to the user's global gitignore file.
706
+ * Uses `git config --global core.excludesfile` first,
707
+ * falls back to $XDG_CONFIG_HOME/git/ignore or ~/.config/git/ignore.
708
+ *
709
+ * @returns Absolute path to global gitignore, or null if unresolvable
710
+ */
711
+ export async function resolveGlobalGitignorePath(): Promise<string | null> {
712
+ try {
713
+ const { execSync } = await import("node:child_process");
714
+ const configPath = execSync("git config --global core.excludesfile", {
715
+ encoding: "utf-8",
716
+ timeout: 5000,
717
+ }).trim();
718
+
719
+ if (configPath) {
720
+ // Expand ~ to home directory
721
+ const expanded = configPath.startsWith("~")
722
+ ? join(homedir(), configPath.slice(1))
723
+ : configPath;
724
+ return resolve(expanded);
725
+ }
726
+ } catch {
727
+ // git config returned empty or git not found
728
+ }
729
+
730
+ // Fallback: XDG_CONFIG_HOME/git/ignore or ~/.config/git/ignore
731
+ const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
732
+ return join(xdgConfig, "git", "ignore");
733
+ }
734
+
735
+ // ============================================================
736
+ // CONVENTION AGGREGATION
737
+ // ============================================================
738
+
739
+ /**
740
+ * Resolve conventions for a plugin by reading its plugin.json.
741
+ * Returns null if plugin has no conventions field.
742
+ *
743
+ * @param pluginPath - Absolute path to plugin directory (containing plugin.json)
744
+ * @returns Parsed conventions or null
745
+ */
746
+ export async function resolvePluginConventions(
747
+ pluginPath: string,
748
+ ): Promise<PluginConventions | null> {
749
+ // `.claude-plugin/plugin.json` is the location Claude Code reads; a root-level
750
+ // manifest is inert to the runtime but still worth reading for a third-party plugin
751
+ // whose author put it there.
752
+ const nested = join(pluginPath, ".claude-plugin", "plugin.json");
753
+ const manifestPath = existsSync(nested)
754
+ ? nested
755
+ : join(pluginPath, "plugin.json");
756
+
757
+ try {
758
+ const raw = await readFile(manifestPath, "utf-8");
759
+ const manifest = JSON.parse(raw);
760
+
761
+ if (!manifest.conventions || typeof manifest.conventions !== "object") {
762
+ return null;
763
+ }
764
+
765
+ return manifest.conventions as PluginConventions;
766
+ } catch {
767
+ return null;
768
+ }
769
+ }
770
+
771
+ /**
772
+ * Read template content for a plugin's CLAUDE.md conventions.
773
+ *
774
+ * @param pluginPath - Absolute path to plugin directory
775
+ * @param templateRelPath - Relative path to template from plugin root
776
+ * @returns Template content string
777
+ * @throws Error if template file cannot be read
778
+ */
779
+ export async function readConventionTemplate(
780
+ pluginPath: string,
781
+ templateRelPath: string,
782
+ ): Promise<string> {
783
+ const templatePath = resolve(join(pluginPath, templateRelPath));
784
+ const resolvedPluginPath = resolve(pluginPath);
785
+
786
+ // Prevent path traversal: template must stay within plugin directory
787
+ if (
788
+ !templatePath.startsWith(`${resolvedPluginPath}/`) &&
789
+ templatePath !== resolvedPluginPath
790
+ ) {
791
+ throw new Error(
792
+ `Template path escapes plugin directory: ${templateRelPath}`,
793
+ );
794
+ }
795
+
796
+ return readFile(templatePath, "utf-8");
797
+ }
798
+
799
+ // ============================================================
800
+ // END-TO-END CONVENTION APPLICATION
801
+ // ============================================================
802
+
803
+ /**
804
+ * Apply all conventions for a plugin to a project.
805
+ * Called from plugin-manager.ts during install/update lifecycle.
806
+ * Resolves conventions from plugin.json automatically.
807
+ *
808
+ * @param pluginPath - Absolute path to plugin source directory (containing plugin.json)
809
+ * @param projectPath - Absolute path to target project
810
+ * @param logger - Optional logger callback
811
+ * @returns Summary of changes made
812
+ */
813
+ export async function applyConventions(
814
+ pluginPath: string,
815
+ projectPath: string,
816
+ logger?: ConventionLogger,
817
+ ): Promise<{
818
+ gitignore: { added: string[]; modified: boolean };
819
+ claudemd: {
820
+ action: "inserted" | "updated" | "unchanged" | "skipped" | "error";
821
+ section?: string;
822
+ error?: string;
823
+ };
824
+ }> {
825
+ // Resolve conventions from plugin.json
826
+ const conventions = await resolvePluginConventions(pluginPath);
827
+
828
+ // Null conventions = no-op
829
+ if (!conventions) {
830
+ return {
831
+ gitignore: { added: [], modified: false },
832
+ claudemd: { action: "skipped" },
833
+ };
834
+ }
835
+
836
+ // Apply gitignore entries
837
+ let gitignoreResult = { added: [] as string[], modified: false };
838
+
839
+ if (
840
+ conventions.gitignore?.project &&
841
+ conventions.gitignore.project.length > 0
842
+ ) {
843
+ const gitignorePath = join(projectPath, ".gitignore");
844
+ gitignoreResult = await ensureGitignoreEntries(
845
+ gitignorePath,
846
+ conventions.gitignore.project,
847
+ "# Managed by magus plugin conventions",
848
+ );
849
+
850
+ if (gitignoreResult.modified) {
851
+ logger?.(
852
+ "info",
853
+ `Added ${gitignoreResult.added.length} entries to .gitignore`,
854
+ );
855
+ }
856
+ }
857
+
858
+ // Apply CLAUDE.md section
859
+ let claudemdResult: {
860
+ action: "inserted" | "updated" | "unchanged" | "skipped" | "error";
861
+ section?: string;
862
+ error?: string;
863
+ } = { action: "skipped" };
864
+
865
+ if (conventions.claudemd) {
866
+ const claudeMdPath = join(projectPath, "CLAUDE.md");
867
+ const { section, template, version } = conventions.claudemd;
868
+
869
+ try {
870
+ const templateContent = await readConventionTemplate(
871
+ pluginPath,
872
+ template,
873
+ );
874
+
875
+ const result = await injectClaudeMdSection(
876
+ claudeMdPath,
877
+ section,
878
+ version,
879
+ templateContent,
880
+ );
881
+
882
+ claudemdResult = {
883
+ action: result.action,
884
+ section,
885
+ error: result.error,
886
+ };
887
+
888
+ if (result.action === "inserted") {
889
+ logger?.("info", `Injected CLAUDE.md section "${section}" v${version}`);
890
+ } else if (result.action === "updated") {
891
+ logger?.(
892
+ "info",
893
+ `Updated CLAUDE.md section "${section}" from v${result.previousVersion} to v${version}`,
894
+ );
895
+ } else if (result.action === "error") {
896
+ logger?.("warn", `Could not update CLAUDE.md: ${result.error}`);
897
+ }
898
+ } catch (err: unknown) {
899
+ const message = err instanceof Error ? err.message : String(err);
900
+ claudemdResult = { action: "error", section, error: message };
901
+ logger?.("error", `Failed to apply CLAUDE.md conventions: ${message}`);
902
+ }
903
+ }
904
+
905
+ return {
906
+ gitignore: gitignoreResult,
907
+ claudemd: claudemdResult,
908
+ };
909
+ }
910
+
911
+ /**
912
+ * Remove all conventions for a plugin from a project.
913
+ * Called from plugin-manager.ts during uninstall lifecycle.
914
+ * Removes CLAUDE.md section only -- gitignore entries are kept.
915
+ *
916
+ * @param pluginName - Plugin name (used as section identifier)
917
+ * @param projectPath - Absolute path to target project
918
+ * @param logger - Optional logger callback
919
+ * @returns Summary of changes made
920
+ */
921
+ export async function removeConventions(
922
+ pluginName: string,
923
+ projectPath: string,
924
+ logger?: ConventionLogger,
925
+ ): Promise<{
926
+ claudemd: { removed: boolean };
927
+ }> {
928
+ // Even without conventions declaration, try to remove the section
929
+ // in case it was applied by a previous version
930
+ const claudeMdPath = join(projectPath, "CLAUDE.md");
931
+
932
+ try {
933
+ const result = await removeClaudeMdSection(claudeMdPath, pluginName);
934
+
935
+ if (result.removed) {
936
+ logger?.("info", `Removed CLAUDE.md section "${pluginName}"`);
937
+ }
938
+
939
+ return { claudemd: result };
940
+ } catch (err: unknown) {
941
+ const message = err instanceof Error ? err.message : String(err);
942
+ logger?.("warn", `Failed to remove CLAUDE.md section: ${message}`);
943
+ return { claudemd: { removed: false } };
944
+ }
945
+ }