@amsterdamdatalabs/enact-extensions 0.1.37 → 0.1.47

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 (577) hide show
  1. package/README.md +67 -108
  2. package/bundled-extensions.json +6 -0
  3. package/dist/create/enact.d.ts.map +1 -1
  4. package/dist/create/enact.js +2 -0
  5. package/dist/create/enact.js.map +1 -1
  6. package/dist/create/index.d.ts +3 -0
  7. package/dist/create/index.d.ts.map +1 -1
  8. package/dist/create/index.js +6 -0
  9. package/dist/create/index.js.map +1 -1
  10. package/dist/create/kimi.d.ts +16 -0
  11. package/dist/create/kimi.d.ts.map +1 -0
  12. package/dist/create/kimi.js +87 -0
  13. package/dist/create/kimi.js.map +1 -0
  14. package/dist/create/opencode.d.ts +129 -0
  15. package/dist/create/opencode.d.ts.map +1 -0
  16. package/dist/create/opencode.js +696 -0
  17. package/dist/create/opencode.js.map +1 -0
  18. package/dist/index.d.ts +12 -16
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +10 -10
  21. package/dist/index.js.map +1 -1
  22. package/dist/internal/agents.d.ts +12 -2
  23. package/dist/internal/agents.d.ts.map +1 -1
  24. package/dist/internal/agents.js +119 -3
  25. package/dist/internal/agents.js.map +1 -1
  26. package/dist/internal/extensions-table.d.ts +58 -0
  27. package/dist/internal/extensions-table.d.ts.map +1 -0
  28. package/dist/internal/extensions-table.js +134 -0
  29. package/dist/internal/extensions-table.js.map +1 -0
  30. package/dist/internal/file-ops.d.ts +36 -0
  31. package/dist/internal/file-ops.d.ts.map +1 -0
  32. package/dist/internal/file-ops.js +89 -0
  33. package/dist/internal/file-ops.js.map +1 -0
  34. package/dist/internal/hooks.d.ts +56 -0
  35. package/dist/internal/hooks.d.ts.map +1 -0
  36. package/dist/internal/hooks.js +128 -0
  37. package/dist/internal/hooks.js.map +1 -0
  38. package/dist/internal/json-merge.d.ts +63 -0
  39. package/dist/internal/json-merge.d.ts.map +1 -0
  40. package/dist/internal/json-merge.js +122 -0
  41. package/dist/internal/json-merge.js.map +1 -0
  42. package/dist/internal/lock-ownership.d.ts +41 -0
  43. package/dist/internal/lock-ownership.d.ts.map +1 -0
  44. package/dist/internal/lock-ownership.js +24 -0
  45. package/dist/internal/lock-ownership.js.map +1 -0
  46. package/dist/internal/platform.d.ts.map +1 -1
  47. package/dist/internal/platform.js +90 -4
  48. package/dist/internal/platform.js.map +1 -1
  49. package/dist/internal/schema.js.map +1 -1
  50. package/dist/internal/toml-block.d.ts +59 -0
  51. package/dist/internal/toml-block.d.ts.map +1 -0
  52. package/dist/internal/toml-block.js +113 -0
  53. package/dist/internal/toml-block.js.map +1 -0
  54. package/dist/internal/types.d.ts +2 -1
  55. package/dist/internal/types.d.ts.map +1 -1
  56. package/dist/projection.d.ts +47 -0
  57. package/dist/projection.d.ts.map +1 -0
  58. package/dist/projection.js +180 -0
  59. package/dist/projection.js.map +1 -0
  60. package/dist/validate/index.d.ts +9 -4
  61. package/dist/validate/index.d.ts.map +1 -1
  62. package/dist/validate/index.js +56 -7
  63. package/dist/validate/index.js.map +1 -1
  64. package/dist/validate/kimi.d.ts +3 -0
  65. package/dist/validate/kimi.d.ts.map +1 -0
  66. package/dist/validate/kimi.js +11 -0
  67. package/dist/validate/kimi.js.map +1 -0
  68. package/dist/validate/opencode.d.ts +3 -0
  69. package/dist/validate/opencode.d.ts.map +1 -0
  70. package/dist/validate/opencode.js +11 -0
  71. package/dist/validate/opencode.js.map +1 -0
  72. package/dist/validate/run.d.ts +39 -0
  73. package/dist/validate/run.d.ts.map +1 -0
  74. package/dist/validate/run.js +63 -0
  75. package/dist/validate/run.js.map +1 -0
  76. package/extensions/enact-loop/.agents/plugin.json +12 -8
  77. package/extensions/enact-loop/scripts/validate.mjs +113 -2
  78. package/extensions/enact-loop/skills/enact-loop/SKILL.md +36 -466
  79. package/extensions/enact-loop/skills/enact-loop/references/loop-contract.md +114 -0
  80. package/extensions/enact-loop/skills/loop-budget/SKILL.md +57 -0
  81. package/extensions/enact-loop/skills/loop-intake/SKILL.md +59 -0
  82. package/extensions/enact-repo/.agents/plugin.json +55 -0
  83. package/extensions/enact-repo/.mcp.json +20 -0
  84. package/extensions/enact-repo/README.md +90 -0
  85. package/extensions/enact-repo/hooks/hooks.json +98 -0
  86. package/extensions/enact-repo/lean-ctx/config.toml +35 -0
  87. package/extensions/enact-repo/lean-ctx/layout.toml +5 -0
  88. package/extensions/enact-repo/skills/aca-sandboxes/SKILL.md +53 -0
  89. package/extensions/enact-repo/skills/aca-sandboxes/references/capabilities.md +52 -0
  90. package/extensions/enact-repo/skills/aca-sandboxes/references/install.md +87 -0
  91. package/extensions/enact-repo/skills/aca-sandboxes/references/prerequisites.md +52 -0
  92. package/extensions/enact-repo/skills/aca-sandboxes/references/quickstart.md +164 -0
  93. package/extensions/enact-repo/skills/aca-sandboxes/references/reference-advanced.md +251 -0
  94. package/extensions/enact-repo/skills/aca-sandboxes/references/reference.md +287 -0
  95. package/extensions/enact-repo/skills/aca-sandboxes/references/response-cues.md +71 -0
  96. package/extensions/enact-repo/skills/aca-sandboxes/references/scenarios.md +219 -0
  97. package/extensions/enact-repo/skills/aca-sandboxes/version.json +4 -0
  98. package/extensions/enact-repo/skills/backend-remove-deadcode/SKILL.md +96 -0
  99. package/extensions/enact-repo/skills/cmux-leader/SKILL.md +61 -0
  100. package/extensions/enact-repo/skills/cmux-send/SKILL.md +61 -0
  101. package/extensions/enact-repo/skills/code-review/SKILL.md +100 -0
  102. package/extensions/enact-repo/skills/codebase-design/SKILL.md +98 -0
  103. package/extensions/enact-repo/skills/codebase-design/references/DEEPENING.md +37 -0
  104. package/extensions/enact-repo/skills/codebase-design/references/DESIGN-IT-TWICE.md +44 -0
  105. package/extensions/enact-repo/skills/codebase-design/references/TESTABILITY.md +29 -0
  106. package/extensions/enact-repo/skills/computer-use/SKILL.md +76 -0
  107. package/extensions/enact-repo/skills/debug-issue/SKILL.md +40 -0
  108. package/extensions/enact-repo/skills/diagnosing-bugs/SKILL.md +100 -0
  109. package/extensions/enact-repo/skills/diagnosing-bugs/references/FEEDBACK-LOOP-TACTICS.md +34 -0
  110. package/extensions/enact-repo/skills/diagnosing-bugs/scripts/hitl-loop.template.sh +41 -0
  111. package/extensions/enact-repo/skills/domain-modeling/SKILL.md +86 -0
  112. package/extensions/enact-repo/skills/domain-modeling/references/ADR-FORMAT.md +47 -0
  113. package/extensions/enact-repo/skills/domain-modeling/references/CONTEXT-FORMAT.md +60 -0
  114. package/extensions/enact-repo/skills/explore-codebase/SKILL.md +41 -0
  115. package/extensions/enact-repo/skills/frontend-design/LICENSE.txt +177 -0
  116. package/extensions/enact-repo/skills/frontend-design/SKILL.md +56 -0
  117. package/extensions/enact-repo/skills/grill-me/SKILL.md +20 -0
  118. package/extensions/enact-repo/skills/grill-with-docs/SKILL.md +20 -0
  119. package/extensions/enact-repo/skills/handoff/SKILL.md +28 -0
  120. package/extensions/enact-repo/skills/http-contract-migration/SKILL.md +74 -0
  121. package/extensions/enact-repo/skills/http-contract-migration/references/compatibility-and-gates.md +26 -0
  122. package/extensions/enact-repo/skills/http-contract-migration/references/hexagonal-boundary.md +16 -0
  123. package/extensions/enact-repo/skills/http-contract-migration/references/publication-and-adoption.md +31 -0
  124. package/extensions/enact-repo/skills/http-contract-migration/references/wire-fidelity-and-dto.md +31 -0
  125. package/extensions/enact-repo/skills/implement/SKILL.md +28 -0
  126. package/extensions/enact-repo/skills/improve-codebase-architecture/SKILL.md +80 -0
  127. package/extensions/enact-repo/skills/improve-codebase-architecture/references/HTML-REPORT.md +123 -0
  128. package/extensions/enact-repo/skills/interaction-design/SKILL.md +30 -0
  129. package/extensions/enact-repo/skills/interaction-design/references/Workflow.md +146 -0
  130. package/extensions/enact-repo/skills/interaction-design/references/animation-libraries-gsap.md +220 -0
  131. package/extensions/enact-repo/skills/interaction-design/references/animation-libraries-native.md +110 -0
  132. package/extensions/enact-repo/skills/interaction-design/references/animation-libraries.md +178 -0
  133. package/extensions/enact-repo/skills/interaction-design/references/interaction-patterns.md +166 -0
  134. package/extensions/enact-repo/skills/interaction-design/references/microinteraction-patterns-advanced.md +285 -0
  135. package/extensions/enact-repo/skills/interaction-design/references/microinteraction-patterns.md +254 -0
  136. package/extensions/enact-repo/skills/interaction-design/references/scroll-animations-reveals.md +213 -0
  137. package/extensions/enact-repo/skills/interaction-design/references/scroll-animations.md +209 -0
  138. package/extensions/enact-repo/skills/lean-ctx/SKILL.md +81 -0
  139. package/extensions/enact-repo/skills/orca-cli/SKILL.md +88 -0
  140. package/extensions/enact-repo/skills/orchestration/SKILL.md +80 -0
  141. package/extensions/enact-repo/skills/prototype/SKILL.md +37 -0
  142. package/extensions/enact-repo/skills/prototype/references/LOGIC.md +79 -0
  143. package/extensions/enact-repo/skills/prototype/references/UI.md +112 -0
  144. package/extensions/enact-repo/skills/refactor-safely/SKILL.md +44 -0
  145. package/extensions/enact-repo/skills/remove-deadcode/SKILL.md +54 -0
  146. package/extensions/enact-repo/skills/research/SKILL.md +27 -0
  147. package/extensions/enact-repo/skills/resolving-merge-conflicts/SKILL.md +28 -0
  148. package/extensions/enact-repo/skills/review-changes/SKILL.md +45 -0
  149. package/extensions/enact-repo/skills/tailwind-4-docs/SKILL.md +88 -0
  150. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/accent-color.mdx +149 -0
  151. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/adding-custom-styles.mdx +635 -0
  152. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/align-content.mdx +300 -0
  153. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/align-items.mdx +241 -0
  154. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/align-self.mdx +294 -0
  155. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/animation.mdx +286 -0
  156. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/appearance.mdx +180 -0
  157. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/aspect-ratio.mdx +98 -0
  158. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/backdrop-filter-blur.mdx +109 -0
  159. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/backdrop-filter-brightness.mdx +82 -0
  160. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/backdrop-filter-contrast.mdx +82 -0
  161. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/backdrop-filter-grayscale.mdx +99 -0
  162. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/backdrop-filter-hue-rotate.mdx +173 -0
  163. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/backdrop-filter-invert.mdx +99 -0
  164. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/backdrop-filter-opacity.mdx +103 -0
  165. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/backdrop-filter-saturate.mdx +98 -0
  166. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/backdrop-filter-sepia.mdx +95 -0
  167. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/backdrop-filter.mdx +113 -0
  168. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/backface-visibility.mdx +110 -0
  169. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/background-attachment.mdx +126 -0
  170. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/background-blend-mode.mdx +79 -0
  171. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/background-clip.mdx +85 -0
  172. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/background-color.mdx +176 -0
  173. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/background-image.mdx +349 -0
  174. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/background-origin.mdx +64 -0
  175. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/background-position.mdx +171 -0
  176. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/background-repeat.mdx +127 -0
  177. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/background-size.mdx +100 -0
  178. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/block-size.mdx +190 -0
  179. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/border-collapse.mdx +181 -0
  180. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/border-color.mdx +316 -0
  181. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/border-radius.mdx +347 -0
  182. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/border-spacing.mdx +117 -0
  183. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/border-style.mdx +129 -0
  184. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/border-width.mdx +344 -0
  185. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/box-decoration-break.mdx +71 -0
  186. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/box-shadow.mdx +497 -0
  187. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/box-sizing.mdx +175 -0
  188. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/break-after.mdx +38 -0
  189. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/break-before.mdx +38 -0
  190. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/break-inside.mdx +34 -0
  191. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/caption-side.mdx +190 -0
  192. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/caret-color.mdx +66 -0
  193. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/clear.mdx +224 -0
  194. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/color-scheme.mdx +81 -0
  195. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/color.mdx +125 -0
  196. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/colors.mdx +927 -0
  197. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/columns.mdx +310 -0
  198. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/compatibility.mdx +193 -0
  199. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/content.mdx +129 -0
  200. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/cursor.mdx +104 -0
  201. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/dark-mode.mdx +191 -0
  202. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/detecting-classes-in-source-files.mdx +315 -0
  203. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/display.mdx +523 -0
  204. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/editor-setup.mdx +90 -0
  205. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/field-sizing.mdx +75 -0
  206. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/fill.mdx +100 -0
  207. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/filter-blur.mdx +114 -0
  208. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/filter-brightness.mdx +102 -0
  209. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/filter-contrast.mdx +102 -0
  210. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/filter-drop-shadow.mdx +230 -0
  211. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/filter-grayscale.mdx +101 -0
  212. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/filter-hue-rotate.mdx +171 -0
  213. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/filter-invert.mdx +93 -0
  214. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/filter-saturate.mdx +109 -0
  215. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/filter-sepia.mdx +92 -0
  216. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/filter.mdx +113 -0
  217. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/flex-basis.mdx +144 -0
  218. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/flex-direction.mdx +130 -0
  219. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/flex-grow.mdx +129 -0
  220. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/flex-shrink.mdx +95 -0
  221. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/flex-wrap.mdx +118 -0
  222. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/flex.mdx +164 -0
  223. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/float.mdx +197 -0
  224. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/font-family.mdx +119 -0
  225. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/font-feature-settings.mdx +52 -0
  226. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/font-size.mdx +184 -0
  227. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/font-smoothing.mdx +67 -0
  228. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/font-stretch.mdx +138 -0
  229. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/font-style.mdx +62 -0
  230. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/font-variant-numeric.mdx +228 -0
  231. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/font-weight.mdx +105 -0
  232. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/forced-color-adjust.mdx +152 -0
  233. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/functions-and-directives.mdx +322 -0
  234. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/gap.mdx +100 -0
  235. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/grid-auto-columns.mdx +43 -0
  236. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/grid-auto-flow.mdx +58 -0
  237. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/grid-auto-rows.mdx +43 -0
  238. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/grid-column.mdx +124 -0
  239. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/grid-row.mdx +115 -0
  240. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/grid-template-columns.mdx +101 -0
  241. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/grid-template-rows.mdx +108 -0
  242. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/height.mdx +260 -0
  243. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/hover-focus-and-other-states.mdx +4935 -0
  244. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/hyphens.mdx +112 -0
  245. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/inline-size.mdx +191 -0
  246. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/isolation.mdx +30 -0
  247. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/justify-content.mdx +348 -0
  248. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/justify-items.mdx +286 -0
  249. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/justify-self.mdx +282 -0
  250. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/letter-spacing.mdx +100 -0
  251. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/line-clamp.mdx +110 -0
  252. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/line-height.mdx +147 -0
  253. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/list-style-image.mdx +80 -0
  254. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/list-style-position.mdx +67 -0
  255. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/list-style-type.mdx +86 -0
  256. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/margin.mdx +397 -0
  257. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/mask-clip.mdx +66 -0
  258. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/mask-composite.mdx +70 -0
  259. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/mask-image.mdx +933 -0
  260. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/mask-mode.mdx +114 -0
  261. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/mask-origin.mdx +66 -0
  262. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/mask-position.mdx +117 -0
  263. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/mask-repeat.mdx +169 -0
  264. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/mask-size.mdx +104 -0
  265. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/mask-type.mdx +98 -0
  266. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/max-block-size.mdx +153 -0
  267. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/max-height.mdx +164 -0
  268. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/max-inline-size.mdx +185 -0
  269. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/max-width.mdx +214 -0
  270. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/min-block-size.mdx +146 -0
  271. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/min-height.mdx +150 -0
  272. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/min-inline-size.mdx +153 -0
  273. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/min-width.mdx +153 -0
  274. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/mix-blend-mode.mdx +103 -0
  275. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/object-fit.mdx +147 -0
  276. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/object-position.mdx +182 -0
  277. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/opacity.mdx +84 -0
  278. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/order.mdx +94 -0
  279. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/outline-color.mdx +130 -0
  280. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/outline-offset.mdx +72 -0
  281. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/outline-style.mdx +134 -0
  282. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/outline-width.mdx +91 -0
  283. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/overflow-wrap.mdx +130 -0
  284. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/overflow.mdx +665 -0
  285. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/overscroll-behavior.mdx +151 -0
  286. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/padding.mdx +232 -0
  287. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/perspective-origin.mdx +127 -0
  288. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/perspective.mdx +137 -0
  289. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/place-content.mdx +257 -0
  290. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/place-items.mdx +269 -0
  291. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/place-self.mdx +257 -0
  292. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/pointer-events.mdx +101 -0
  293. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/position.mdx +399 -0
  294. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/preflight.mdx +291 -0
  295. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/resize.mdx +110 -0
  296. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/responsive-design.mdx +666 -0
  297. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/rotate.mdx +291 -0
  298. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/scale.mdx +360 -0
  299. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/scroll-behavior.mdx +39 -0
  300. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/scroll-margin.mdx +268 -0
  301. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/scroll-padding.mdx +255 -0
  302. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/scroll-snap-align.mdx +281 -0
  303. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/scroll-snap-stop.mdx +198 -0
  304. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/scroll-snap-type.mdx +284 -0
  305. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/skew.mdx +352 -0
  306. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/stroke-width.mdx +74 -0
  307. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/stroke.mdx +103 -0
  308. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/styling-with-utility-classes.mdx +1020 -0
  309. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/table-layout.mdx +212 -0
  310. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/text-align.mdx +162 -0
  311. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/text-decoration-color.mdx +155 -0
  312. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/text-decoration-line.mdx +143 -0
  313. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/text-decoration-style.mdx +75 -0
  314. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/text-decoration-thickness.mdx +75 -0
  315. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/text-indent.mdx +74 -0
  316. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/text-overflow.mdx +103 -0
  317. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/text-shadow.mdx +177 -0
  318. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/text-transform.mdx +106 -0
  319. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/text-underline-offset.mdx +74 -0
  320. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/text-wrap.mdx +144 -0
  321. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/theme.mdx +1142 -0
  322. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/top-right-bottom-left.mdx +296 -0
  323. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/touch-action.mdx +95 -0
  324. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/transform-origin.mdx +130 -0
  325. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/transform-style.mdx +110 -0
  326. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/transform.mdx +51 -0
  327. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/transition-behavior.mdx +76 -0
  328. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/transition-delay.mdx +76 -0
  329. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/transition-duration.mdx +82 -0
  330. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/transition-property.mdx +137 -0
  331. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/transition-timing-function.mdx +89 -0
  332. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/translate.mdx +487 -0
  333. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/upgrade-guide.mdx +910 -0
  334. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/user-select.mdx +114 -0
  335. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/vertical-align.mdx +194 -0
  336. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/visibility.mdx +287 -0
  337. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/white-space.mdx +211 -0
  338. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/width.mdx +264 -0
  339. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/will-change.mdx +47 -0
  340. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/word-break.mdx +94 -0
  341. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs/z-index.mdx +104 -0
  342. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs-catalog.md +251 -0
  343. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs-index.tsx +237 -0
  344. package/extensions/enact-repo/skills/tailwind-4-docs/references/docs-source.txt +6 -0
  345. package/extensions/enact-repo/skills/tailwind-4-docs/references/gotchas.md +18 -0
  346. package/extensions/enact-repo/skills/tailwind-4-docs/scripts/sync_tailwind_docs.py +183 -0
  347. package/extensions/enact-repo/skills/tdd/SKILL.md +49 -0
  348. package/extensions/enact-repo/skills/tdd/references/mocking.md +59 -0
  349. package/extensions/enact-repo/skills/tdd/references/tests.md +77 -0
  350. package/extensions/enact-repo/skills/teach/SKILL.md +88 -0
  351. package/extensions/enact-repo/skills/teach/references/GLOSSARY-FORMAT.md +35 -0
  352. package/extensions/enact-repo/skills/teach/references/LEARNING-RECORD-FORMAT.md +46 -0
  353. package/extensions/enact-repo/skills/teach/references/MISSION-FORMAT.md +31 -0
  354. package/extensions/enact-repo/skills/teach/references/PHILOSOPHY.md +65 -0
  355. package/extensions/enact-repo/skills/teach/references/RESOURCES-FORMAT.md +32 -0
  356. package/extensions/enact-repo/skills/testing/SKILL.md +54 -0
  357. package/extensions/enact-repo/skills/to-spec/SKILL.md +85 -0
  358. package/extensions/enact-repo/skills/to-tickets/SKILL.md +75 -0
  359. package/extensions/enact-repo/skills/to-tickets/references/TICKET-TEMPLATES.md +39 -0
  360. package/extensions/enact-repo/skills/triage/SKILL.md +99 -0
  361. package/extensions/enact-repo/skills/triage/references/AGENT-BRIEF.md +207 -0
  362. package/extensions/enact-repo/skills/triage/references/NEEDS-INFO-TEMPLATE.md +17 -0
  363. package/extensions/enact-repo/skills/triage/references/OUT-OF-SCOPE.md +105 -0
  364. package/extensions/enact-repo/skills/ui-ux-pro-max/SKILL.md +32 -0
  365. package/extensions/enact-repo/skills/ui-ux-pro-max/references/Workflow.md +207 -0
  366. package/extensions/enact-repo/skills/ui-ux-pro-max/references/workflow-reference.md +175 -0
  367. package/extensions/enact-repo/skills/wayfinder/SKILL.md +100 -0
  368. package/extensions/enact-repo/skills/wayfinder/references/TEMPLATES.md +35 -0
  369. package/extensions/enact-repo/skills/wayfinder/references/TICKET-TYPES.md +8 -0
  370. package/extensions/enact-repo/skills/writing-great-skills/SKILL.md +93 -0
  371. package/extensions/enact-repo/skills/writing-great-skills/references/GLOSSARY.md +201 -0
  372. package/package.json +18 -14
  373. package/scripts/enact-extensions.mjs +255 -873
  374. package/scripts/lib/allowed-plugins.mjs +52 -0
  375. package/scripts/lib/run-repo-doctor.mjs +571 -0
  376. package/scripts/lib/run-repo-install.mjs +1579 -0
  377. package/scripts/lib/run-sync.mjs +1 -1
  378. package/scripts/lib/run-validate.mjs +23 -37
  379. package/scripts/lib/skill-validator.mjs +872 -0
  380. package/spec/enact.json +21 -3
  381. package/spec/enact.md +13 -12
  382. package/spec/kimi.json +100 -0
  383. package/spec/opencode.json +64 -0
  384. package/dist/install.d.ts +0 -235
  385. package/dist/install.d.ts.map +0 -1
  386. package/dist/install.js +0 -697
  387. package/dist/install.js.map +0 -1
  388. package/dist/internal/claude.d.ts +0 -65
  389. package/dist/internal/claude.d.ts.map +0 -1
  390. package/dist/internal/claude.js +0 -200
  391. package/dist/internal/claude.js.map +0 -1
  392. package/dist/internal/codex.d.ts +0 -43
  393. package/dist/internal/codex.d.ts.map +0 -1
  394. package/dist/internal/codex.js +0 -211
  395. package/dist/internal/codex.js.map +0 -1
  396. package/dist/internal/cursor.d.ts +0 -2
  397. package/dist/internal/cursor.d.ts.map +0 -1
  398. package/dist/internal/cursor.js +0 -6
  399. package/dist/internal/cursor.js.map +0 -1
  400. package/dist/principles.d.ts +0 -28
  401. package/dist/principles.d.ts.map +0 -1
  402. package/dist/principles.js +0 -159
  403. package/dist/principles.js.map +0 -1
  404. package/dist/provision.d.ts +0 -30
  405. package/dist/provision.d.ts.map +0 -1
  406. package/dist/provision.js +0 -202
  407. package/dist/provision.js.map +0 -1
  408. package/extensions/dev-state/.agents/plugin.json +0 -36
  409. package/extensions/dev-state/skills/dev-state-plan-graduation/SKILL.md +0 -194
  410. package/extensions/dev-state/skills/dev-state-plan-graduation/agents/openai.yaml +0 -4
  411. package/extensions/dev-state/skills/dev-state-plan-graduation/references/reference.md +0 -130
  412. package/extensions/enact-context/.agents/plugin.json +0 -41
  413. package/extensions/enact-context/.mcp.json +0 -8
  414. package/extensions/enact-context/README.md +0 -25
  415. package/extensions/enact-context/assets/icon.png +0 -0
  416. package/extensions/enact-context/assets/logo.png +0 -0
  417. package/extensions/enact-context/hooks/hooks.json +0 -115
  418. package/extensions/enact-context/skills/enact-context/SKILL.md +0 -151
  419. package/extensions/enact-context/skills/enact-context/scripts/install.sh +0 -69
  420. package/extensions/enact-core/.agents/plugin.json +0 -40
  421. package/extensions/enact-core/OPERATING-PRINCIPLES.md +0 -7
  422. package/extensions/enact-core/hooks/hooks.json +0 -26
  423. package/extensions/enact-evolve/.agents/plugin.json +0 -47
  424. package/extensions/enact-evolve/agents/evolve-session-analyst.toml +0 -37
  425. package/extensions/enact-evolve/assets/icon.png +0 -0
  426. package/extensions/enact-evolve/assets/logo.png +0 -0
  427. package/extensions/enact-evolve/skills/mined-investigate-loop/SKILL.md +0 -68
  428. package/extensions/enact-evolve/skills/session-analysis/SKILL.md +0 -98
  429. package/extensions/enact-evolve/skills/session-analysis/scripts/run-evolve-analysis.sh +0 -343
  430. package/extensions/enact-factory/.agents/plugin.json +0 -49
  431. package/extensions/enact-factory/.mcp.json +0 -8
  432. package/extensions/enact-factory/agents/architect.toml +0 -34
  433. package/extensions/enact-factory/agents/code-reviewer.toml +0 -33
  434. package/extensions/enact-factory/agents/critic.toml +0 -39
  435. package/extensions/enact-factory/agents/executor.toml +0 -26
  436. package/extensions/enact-factory/agents/explore.toml +0 -25
  437. package/extensions/enact-factory/agents/planner.toml +0 -26
  438. package/extensions/enact-factory/agents/verifier.toml +0 -33
  439. package/extensions/enact-factory/assets/icon.png +0 -0
  440. package/extensions/enact-factory/assets/logo.png +0 -0
  441. package/extensions/enact-factory/skills/advisor/SKILL.md +0 -82
  442. package/extensions/enact-factory/skills/ai-slop-cleaner/SKILL.md +0 -57
  443. package/extensions/enact-factory/skills/autonomous-runner/SKILL.md +0 -353
  444. package/extensions/enact-factory/skills/azdo-ci-strategy/SKILL.md +0 -309
  445. package/extensions/enact-factory/skills/azdo-ci-strategy/references/build-failures.md +0 -60
  446. package/extensions/enact-factory/skills/azdo-ci-strategy/references/cli-reference.md +0 -87
  447. package/extensions/enact-factory/skills/azdo-ci-strategy/references/policies-and-pipelines.md +0 -132
  448. package/extensions/enact-factory/skills/azdo-ci-strategy/references/troubleshooting.md +0 -53
  449. package/extensions/enact-factory/skills/cmux-leader/SKILL.md +0 -74
  450. package/extensions/enact-factory/skills/cmux-send/SKILL.md +0 -50
  451. package/extensions/enact-factory/skills/committee/SKILL.md +0 -80
  452. package/extensions/enact-factory/skills/deep-interview/SKILL.md +0 -68
  453. package/extensions/enact-factory/skills/drive-loop/SKILL.md +0 -394
  454. package/extensions/enact-factory/skills/drive-loop/references/contract-schema.md +0 -127
  455. package/extensions/enact-factory/skills/handoff/SKILL.md +0 -75
  456. package/extensions/enact-factory/skills/hyperplan/SKILL.md +0 -59
  457. package/extensions/enact-factory/skills/looplan/SKILL.md +0 -120
  458. package/extensions/enact-factory/skills/plan/SKILL.md +0 -103
  459. package/extensions/enact-factory/skills/remove-deadcode/SKILL.md +0 -46
  460. package/extensions/enact-factory/skills/research/SKILL.md +0 -83
  461. package/extensions/enact-factory/skills/review/SKILL.md +0 -67
  462. package/extensions/enact-factory/skills/security-research/SKILL.md +0 -57
  463. package/extensions/enact-factory/skills/tdd/SKILL.md +0 -62
  464. package/extensions/enact-factory/skills/testing-strategy/SKILL.md +0 -172
  465. package/extensions/enact-factory/skills/trace/SKILL.md +0 -42
  466. package/extensions/enact-factory/skills/ultraqa/SKILL.md +0 -85
  467. package/extensions/enact-factory/skills/work-with-workitem/SKILL.md +0 -56
  468. package/extensions/enact-factory/skills/workitem-triage/SKILL.md +0 -42
  469. package/extensions/enact-wiki/.agents/plugin.json +0 -43
  470. package/extensions/enact-wiki/.mcp.json +0 -15
  471. package/extensions/enact-wiki/README.md +0 -44
  472. package/extensions/enact-wiki/assets/icon.png +0 -0
  473. package/extensions/enact-wiki/assets/logo.png +0 -0
  474. package/extensions/enact-wiki/skills/document-parser/SKILL.md +0 -17
  475. package/extensions/enact-wiki/skills/document-parser/scripts/parse.sh +0 -60
  476. package/extensions/enact-wiki/skills/document-parser/skill.json +0 -9
  477. package/extensions/enact-wiki/skills/enact-wiki/SKILL.md +0 -30
  478. package/extensions/enact-wiki/skills/enact-wiki/references/ingest.md +0 -62
  479. package/extensions/enact-wiki/skills/enact-wiki/references/manage.md +0 -34
  480. package/extensions/enact-wiki/skills/enact-wiki/references/query.md +0 -59
  481. package/extensions/enact-wiki/skills/search-lab/SKILL.md +0 -57
  482. package/extensions/enact-wiki/skills/search-lab/scripts/analyze.ts +0 -23
  483. package/extensions/enact-wiki/skills/wiki/SKILL.md +0 -42
  484. package/extensions/net-revenue-management/.agents/plugin.json +0 -40
  485. package/extensions/net-revenue-management/.mcp.json +0 -9
  486. package/extensions/net-revenue-management/skills/net-revenue-risks/SKILL.md +0 -30
  487. package/extensions/net-revenue-management/skills/net-revenue-scenario/SKILL.md +0 -31
  488. package/extensions/plugin-dev/.agents/plugin.json +0 -41
  489. package/extensions/plugin-dev/agents/agent-creator.md +0 -199
  490. package/extensions/plugin-dev/agents/plugin-validator.md +0 -91
  491. package/extensions/plugin-dev/agents/skill-reviewer.md +0 -212
  492. package/extensions/plugin-dev/skills/agent-development/SKILL.md +0 -641
  493. package/extensions/plugin-dev/skills/agent-development/examples/agent-creation-prompt.md +0 -250
  494. package/extensions/plugin-dev/skills/agent-development/examples/complete-agent-examples.md +0 -461
  495. package/extensions/plugin-dev/skills/agent-development/references/advanced-agent-fields.md +0 -246
  496. package/extensions/plugin-dev/skills/agent-development/references/agent-creation-system-prompt.md +0 -216
  497. package/extensions/plugin-dev/skills/agent-development/references/permission-modes-rules.md +0 -226
  498. package/extensions/plugin-dev/skills/agent-development/references/system-prompt-design.md +0 -464
  499. package/extensions/plugin-dev/skills/agent-development/references/triggering-examples.md +0 -474
  500. package/extensions/plugin-dev/skills/agent-development/scripts/create-agent-skeleton.sh +0 -176
  501. package/extensions/plugin-dev/skills/agent-development/scripts/test-agent-trigger.sh +0 -227
  502. package/extensions/plugin-dev/skills/agent-development/scripts/validate-agent.sh +0 -227
  503. package/extensions/plugin-dev/skills/create-plugin/SKILL.md +0 -505
  504. package/extensions/plugin-dev/skills/hook-development/SKILL.md +0 -861
  505. package/extensions/plugin-dev/skills/hook-development/examples/load-context.sh +0 -55
  506. package/extensions/plugin-dev/skills/hook-development/examples/validate-bash.sh +0 -57
  507. package/extensions/plugin-dev/skills/hook-development/examples/validate-write.sh +0 -48
  508. package/extensions/plugin-dev/skills/hook-development/references/advanced.md +0 -871
  509. package/extensions/plugin-dev/skills/hook-development/references/hook-input-schemas.md +0 -145
  510. package/extensions/plugin-dev/skills/hook-development/references/migration.md +0 -392
  511. package/extensions/plugin-dev/skills/hook-development/references/patterns.md +0 -430
  512. package/extensions/plugin-dev/skills/hook-development/scripts/README.md +0 -181
  513. package/extensions/plugin-dev/skills/hook-development/scripts/hook-linter.sh +0 -153
  514. package/extensions/plugin-dev/skills/hook-development/scripts/test-hook.sh +0 -276
  515. package/extensions/plugin-dev/skills/hook-development/scripts/validate-hook-schema.sh +0 -159
  516. package/extensions/plugin-dev/skills/mcp-integration/SKILL.md +0 -775
  517. package/extensions/plugin-dev/skills/mcp-integration/examples/http-server.json +0 -20
  518. package/extensions/plugin-dev/skills/mcp-integration/examples/sse-server.json +0 -19
  519. package/extensions/plugin-dev/skills/mcp-integration/examples/stdio-server.json +0 -38
  520. package/extensions/plugin-dev/skills/mcp-integration/examples/ws-server.json +0 -26
  521. package/extensions/plugin-dev/skills/mcp-integration/references/authentication.md +0 -601
  522. package/extensions/plugin-dev/skills/mcp-integration/references/server-discovery.md +0 -190
  523. package/extensions/plugin-dev/skills/mcp-integration/references/server-types.md +0 -572
  524. package/extensions/plugin-dev/skills/mcp-integration/references/tool-usage.md +0 -623
  525. package/extensions/plugin-dev/skills/plugin-dev-guide/SKILL.md +0 -221
  526. package/extensions/plugin-dev/skills/plugin-structure/SKILL.md +0 -705
  527. package/extensions/plugin-dev/skills/plugin-structure/examples/advanced-plugin.md +0 -774
  528. package/extensions/plugin-dev/skills/plugin-structure/examples/minimal-plugin.md +0 -83
  529. package/extensions/plugin-dev/skills/plugin-structure/examples/standard-plugin.md +0 -611
  530. package/extensions/plugin-dev/skills/plugin-structure/references/advanced-topics.md +0 -289
  531. package/extensions/plugin-dev/skills/plugin-structure/references/component-patterns.md +0 -592
  532. package/extensions/plugin-dev/skills/plugin-structure/references/github-actions.md +0 -233
  533. package/extensions/plugin-dev/skills/plugin-structure/references/headless-ci-mode.md +0 -193
  534. package/extensions/plugin-dev/skills/plugin-structure/references/manifest-reference.md +0 -625
  535. package/extensions/plugin-dev/skills/plugin-structure/references/output-styles.md +0 -116
  536. package/extensions/plugin-dev/skills/skill-development/SKILL.md +0 -562
  537. package/extensions/plugin-dev/skills/skill-development/examples/complete-skill.md +0 -465
  538. package/extensions/plugin-dev/skills/skill-development/examples/frontmatter-templates.md +0 -167
  539. package/extensions/plugin-dev/skills/skill-development/examples/minimal-skill.md +0 -111
  540. package/extensions/plugin-dev/skills/skill-development/references/advanced-frontmatter.md +0 -225
  541. package/extensions/plugin-dev/skills/skill-development/references/commands-vs-skills.md +0 -39
  542. package/extensions/plugin-dev/skills/skill-development/references/skill-creation-workflow.md +0 -379
  543. package/extensions/plugin-dev/skills/skill-development/references/skill-creator-original.md +0 -210
  544. package/extensions/plugin-dev/skills/start/SKILL.md +0 -82
  545. package/scripts/check-hooks.mjs +0 -174
  546. package/scripts/check-principles.mjs +0 -116
  547. package/scripts/hooks/session-start-drift-check.mjs +0 -48
  548. package/scripts/install.sh +0 -70
  549. package/scripts/lib/auto-update.mjs +0 -75
  550. package/scripts/lib/bundle-hash.mjs +0 -137
  551. package/scripts/lib/hooks.mjs +0 -585
  552. package/scripts/lib/ledger.mjs +0 -163
  553. package/scripts/lib/migrate-artifacts.mjs +0 -144
  554. package/scripts/lib/outdated.mjs +0 -144
  555. package/scripts/lib/postinstall-offer.mjs +0 -83
  556. package/scripts/lib/principles.mjs +0 -109
  557. package/scripts/lib/provision-mcp.mjs +0 -16
  558. package/scripts/lib/resolve-plugin-root.mjs +0 -9
  559. package/scripts/lib/run-install.mjs +0 -500
  560. package/scripts/lib/run-prune.mjs +0 -94
  561. package/scripts/lib/run-uninstall.mjs +0 -244
  562. package/scripts/lib/run-update.mjs +0 -152
  563. package/scripts/lib/serve.mjs +0 -472
  564. package/scripts/lib/ups-router.mjs +0 -392
  565. package/scripts/postinstall.mjs +0 -101
  566. package/scripts/setup-enact-context.sh +0 -28
  567. package/scripts/version-bump.sh +0 -484
  568. package/web/assets/README.md +0 -111
  569. package/web/assets/logo-full.png +0 -0
  570. package/web/assets/logo-slim.png +0 -0
  571. package/web/assets/tokens/base.css +0 -45
  572. package/web/assets/tokens/colors.css +0 -248
  573. package/web/assets/tokens/effects.css +0 -24
  574. package/web/assets/tokens/fonts.css +0 -8
  575. package/web/assets/tokens/index.css +0 -18
  576. package/web/assets/tokens/spacing.css +0 -50
  577. package/web/index.html +0 -1188
@@ -0,0 +1,1579 @@
1
+ /**
2
+ * Repo-local install: `enact-extensions install <bundle> --repo <path>`.
3
+ *
4
+ * Writes a bundle into a git repository as COMMITTED, RELATIVE files that
5
+ * follow each host's documented project-scope standard. Nothing is written
6
+ * under $HOME. Every COMMITTED file is relative; the single absolute-path
7
+ * exception is the gitignored `.claude/settings.local.json` env block (see
8
+ * the claude adapter), which cannot work any other way.
9
+ *
10
+ * claude — plugin root copied to `.claude-plugin/<name>/` (its `skills/` is
11
+ * a RELATIVE SYMLINK to the shared `.agents/skills/` tree, not a
12
+ * copy — see "Shared skills" below); repo marketplace
13
+ * `.claude-plugin/marketplace.json` (marketplace root = repo root,
14
+ * the dir CONTAINING .claude-plugin/, so the plugin entry is
15
+ * `source: "./.claude-plugin/<name>"`); `.claude/settings.json`
16
+ * merged with `extraKnownMarketplaces` (directory source, path "./")
17
+ * and `enabledPlugins`; `.claude/settings.local.json` merged with
18
+ * the absolute LEAN_CTX_*_DIR env block (gitignored via
19
+ * `.claude/.gitignore`). Loads only after the user trusts the folder.
20
+ * codex — `.codex/config.toml` `[mcp_servers.<name>]` tables in a
21
+ * marker-delimited block (user TOML and comments untouched) +
22
+ * `.codex/hooks.json` (canonical nested schema, `--host=codex`).
23
+ * Loads only after the user trusts the project and reviews hooks.
24
+ * cursor — `.cursor/hooks.json` (version 1, camelCase, `--host=cursor`) +
25
+ * `.cursor/mcp.json` (`type: "stdio"`).
26
+ * kimi — `.kimi-code/mcp.json`. Kimi hooks are global-only (enact-vps),
27
+ * so NO hook file is written here.
28
+ * opencode — one generated `.opencode/plugins/<name>.ts` (hooks + MCP via the
29
+ * `config` hook, NO skills path: OpenCode reads `.agents/skills/`).
30
+ *
31
+ * Shared skills: ONE real copy, in `.agents/skills/<skill>/`, written
32
+ * UNCONDITIONALLY for every host (`.agents` is universal — every host,
33
+ * including a claude-only install, needs it present so nothing dangles).
34
+ * Claude's plugin-bundled `skills/` is a relative symlink to that same tree
35
+ * (`../../.agents/skills`), not a second copy: Claude Code dereferences a
36
+ * within-marketplace symlink (see `plan.symlinks` / the claude adapter below).
37
+ *
38
+ * State: `.agents/<name>.lock.json` records every managed file (sha256) and
39
+ * every entry merged into a user file. Re-runs are idempotent.
40
+ *
41
+ * Owner decision (Tarun): hooks, mcp and skills content is ALWAYS force-
42
+ * installed -- our own generated content and our own entries in a shared
43
+ * file are repaired to match the bundle on every install, no `--force` flag
44
+ * needed and no "not managed"/"edited" refusal. Two kinds:
45
+ * - Whole files we exclusively generate (`.claude-plugin/<name>/**`,
46
+ * `.agents/skills/**`, `.opencode/plugins/<name>.ts`, the claude skills
47
+ * symlink): nothing user-authored can live there, so drift or a stale
48
+ * pre-existing entry is just silently overwritten -- no displace/restore,
49
+ * nothing worth preserving. `plan.forcedFiles` marks exactly this subset
50
+ * of `plan.files`.
51
+ * - Merged surfaces shared with the user (`.cursor/hooks.json`,
52
+ * `.cursor/mcp.json`, `.kimi-code/mcp.json`, `.codex/hooks.json`,
53
+ * `.claude/settings.json`, the marker block in `.codex/config.toml`):
54
+ * ONLY our own entries are force-rewritten; a user's own keys/hooks/mcp
55
+ * servers are left completely untouched. A foreign entry that happens to
56
+ * share OUR key/server name is taken over and its original captured into
57
+ * `displaced` for a byte-exact restore on uninstall, exactly like a
58
+ * `--force` takeover always has -- it just no longer needs the flag.
59
+ * `--force` still gates the two surfaces OUTSIDE that scope: the repo-root
60
+ * `.claude-plugin/marketplace.json` and the lean-ctx baseline config files
61
+ * (`.agents/lean-ctx/config/lean-ctx/*.toml`) -- both can plausibly hold
62
+ * independent user content, so a pre-existing foreign file there is still a
63
+ * refused conflict until `--force` takes it over (with restore-on-uninstall,
64
+ * unchanged).
65
+ * Uninstall removes exactly what the lock records; uninstall's OWN drift
66
+ * check (refusing to remove content edited since install without --force)
67
+ * is unaffected by any of the above -- that gate is about safe removal, not
68
+ * install-time repair.
69
+ */
70
+ import { createHash } from "node:crypto";
71
+ import {
72
+ existsSync,
73
+ lstatSync,
74
+ mkdirSync,
75
+ mkdtempSync,
76
+ readFileSync,
77
+ readdirSync,
78
+ readlinkSync,
79
+ realpathSync,
80
+ rmSync,
81
+ rmdirSync,
82
+ statSync,
83
+ symlinkSync,
84
+ writeFileSync,
85
+ } from "node:fs";
86
+ import { homedir, tmpdir } from "node:os";
87
+ import { dirname, isAbsolute, join, resolve } from "node:path";
88
+ import * as TOML from "@iarna/toml";
89
+ import {
90
+ applyHostFlagToHooksDoc,
91
+ assertSupportedHookEvents,
92
+ clampHookTimeoutsForHost,
93
+ deriveOpenCodePlugin,
94
+ materializeCodexSkillMetadata,
95
+ MANIFEST_BY_PLATFORM,
96
+ EXTENSIONS_BLOCK,
97
+ blockMarkers,
98
+ cutExtensionsBlock,
99
+ declaredExtensionsFrom,
100
+ extensionBlock,
101
+ removeExtensionBlock,
102
+ upsertExtensionBlock,
103
+ cutBlock,
104
+ ensureContainer,
105
+ extractTomlTable,
106
+ findBlock,
107
+ makeFileOps,
108
+ reinsertTomlSpans,
109
+ renderTomlBlock,
110
+ siblingOwnersOf,
111
+ siblingOwnsPrefix,
112
+ siblingRecordsJsonEntry,
113
+ entryPresent,
114
+ getAt,
115
+ pathKey,
116
+ removeJsonEntries,
117
+ valueHash,
118
+ projectClaudePluginRoot,
119
+ projectPluginRoot,
120
+ projectCursorHooks,
121
+ unmappedCursorHookEvents,
122
+ unmappedCursorHookMatchers,
123
+ } from "../../dist/index.js";
124
+
125
+ export const REPO_HOSTS = ["claude", "codex", "cursor", "kimi", "opencode"];
126
+ // OpenCode is the ONLY host left needing a flat skills directory: per
127
+ // spec/opencode.json its "documented plugin surface has no skills/agents/
128
+ // commands concept", so a plugin root cannot carry them. Every other host
129
+ // gets skills INSIDE its plugin root, namespaced by plugin name.
130
+ //
131
+ // `.opencode/skills/` is OpenCode's own documented project location, not a
132
+ // shared cross-host pool -- so a collision here is scoped to one host instead
133
+ // of every host, and nothing else reads it.
134
+ const SHARED_SKILLS_DIR = ".opencode/skills";
135
+ /** Declared extension names from `[extensions.<name>]` sub-tables, or []. */
136
+ export function readDeclaredExtensions(configText) {
137
+ if (typeof configText !== "string" || configText.length === 0) return [];
138
+ return declaredExtensionsFrom(parseConfigText(configText, "reading [extensions]"));
139
+ }
140
+ // Shared repo config at the repo root; its presence marks a repo as managed
141
+ // (enact-hook's contract). One owner per root table: enact-hook owns
142
+ // `version`/[hooks]/[rules]/[repo], enact-repo-controls owns [controls], this
143
+ // package owns [skills]. We never parse or rewrite anyone else's table: the
144
+ // [skills] block is appended once at text level and cut at text level.
145
+ export const CONFIG_PATH = "enact-config.toml";
146
+ const GITIGNORE_PATH = ".agents/.gitignore";
147
+ // The global `enact-hook` runtime (not this installer) writes two per-repo
148
+ // logs under `.agents/hooks/` in any managed repo: the deny log (hooks.log)
149
+ // and the activity log (hooks-activity.log), both append-only CSV. They are
150
+ // the runtime's own state -- never written by install -- so they get the
151
+ // SAME managed-gitignore-line treatment as the lean-ctx runtime dirs below:
152
+ // a plain uninstall keeps the lines while a log file is still present, and
153
+ // `--purge` removes the log files themselves before the lines.
154
+ export const HOOK_LOG_FILES = ["hooks.log", "hooks-activity.log"].map((f) => `.agents/hooks/${f}`);
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Repo-local lean-ctx directory layout — ONE source of truth for BOTH uses:
158
+ // where the installer writes/ignores these directories, and the environment
159
+ // variables that tell the `lean-ctx` binary to use them.
160
+ //
161
+ // rel repo-relative directory
162
+ // env the variable lean-ctx reads to find it (null = no variable)
163
+ // runtime lean-ctx creates it itself while in use (never the installer),
164
+ // so it is gitignored rather than committed
165
+ //
166
+ // Without the `env` variables lean-ctx resolves ~/.config/lean-ctx, finds
167
+ // nothing, and silently enforces its built-in command set instead of the
168
+ // repo config we just installed (measured: 223 built-ins vs the repo's 249).
169
+ // ---------------------------------------------------------------------------
170
+ const LEAN_CTX_ROOT = ".agents/lean-ctx";
171
+ const LEAN_CTX_DIRS = [
172
+ { rel: `${LEAN_CTX_ROOT}/config/lean-ctx`, env: "LEAN_CTX_CONFIG_DIR", runtime: false },
173
+ { rel: `${LEAN_CTX_ROOT}/data`, env: "LEAN_CTX_DATA_DIR", runtime: true },
174
+ { rel: `${LEAN_CTX_ROOT}/cache`, env: "LEAN_CTX_CACHE_DIR", runtime: true },
175
+ { rel: `${LEAN_CTX_ROOT}/state`, env: "LEAN_CTX_STATE_DIR", runtime: true },
176
+ { rel: `${LEAN_CTX_ROOT}/bin`, env: null, runtime: true },
177
+ ];
178
+ // lean-ctx config (config.toml, layout.toml) stays committed; only its
179
+ // runtime-generated dirs are ignored.
180
+ const LEAN_CTX_CONFIG_DIR = LEAN_CTX_DIRS[0].rel;
181
+ // RUNTIME dirs lean-ctx itself creates while the plugin is in use (never
182
+ // written by the installer) -- these are exactly what GITIGNORE_LINES hides.
183
+ // A plain uninstall must not remove those gitignore lines while any of these
184
+ // dirs still exist on disk (it would surface them as untracked); `--purge`
185
+ // removes the dirs themselves first (they are OUR runtime's own state, not
186
+ // user data), then the gitignore lines.
187
+ const LEAN_CTX_RUNTIME_DIRS = LEAN_CTX_DIRS.filter((d) => d.runtime).map((d) => d.rel);
188
+ // `.agents/.gitignore` is itself inside `.agents/`, so its patterns are
189
+ // anchored one level down (`.agents/lean-ctx/data` -> `/lean-ctx/data/`).
190
+ const agentsGitignoreLine = (rel) => rel.slice(".agents".length);
191
+ const GITIGNORE_LINES = [
192
+ ...LEAN_CTX_RUNTIME_DIRS.map((rel) => `${agentsGitignoreLine(rel)}/`),
193
+ ...HOOK_LOG_FILES.map(agentsGitignoreLine),
194
+ ];
195
+
196
+ // No `.claude/settings.local.json` env carrier, and therefore no
197
+ // `.claude/.gitignore` line to hide it. Both were removed with the carrier:
198
+ // absolute paths in an installed file are machine-specific and go stale
199
+ // silently on a rename or a fresh clone at a different path.
200
+ // OpenCode's own auto-generated project-local files (verified 2026-09-14,
201
+ // this session, against a live `.opencode/` created by a real OpenCode run
202
+ // -- .scratch/opencode-plugin-example/.opencode/.gitignore lists exactly
203
+ // these as what OpenCode itself manages: "node_modules\npackage.json\n
204
+ // package-lock.json\nbun.lock\n.gitignore"). We never write these
205
+ // ourselves; `--purge` removes them ONLY when nothing else user-owned
206
+ // remains under `.opencode/` (besides our own now-removed `plugins/*.ts`).
207
+ const OPENCODE_AUTOGEN_ENTRIES = new Set(["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"]);
208
+ // ONE marketplace per repo, listing every installed plugin -- not one per
209
+ // plugin. A marketplace.json carries exactly one `name` field, so per-plugin
210
+ // names cannot coexist in it; the previous `<plugin>-local` scheme meant the
211
+ // file's identity was "whoever installed last", and the second plugin silently
212
+ // erased the first's entry (measured: installing enact-repo then enact-wiki
213
+ // with --force left `name: enact-wiki-local`, `plugins: [enact-wiki]`).
214
+ // Claude: "To publish multiple plugins under one marketplace name, list them
215
+ // all in a single marketplace.json." Codex reads this file too, as a
216
+ // legacy-compatible repo marketplace.
217
+ const MARKETPLACE_NAME = "enact-local";
218
+ const MARKETPLACE_PATH = ".claude-plugin/marketplace.json";
219
+ const LOCK_VERSION = 1;
220
+
221
+ const CONFIG_ROOT_CONTENT = "version = 1\n";
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // small utils
225
+ // ---------------------------------------------------------------------------
226
+
227
+ export const sha256 = (data) => createHash("sha256").update(data).digest("hex");
228
+ const has = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
229
+
230
+ export const abs = (repo, rel) => join(repo, ...rel.split("/"));
231
+
232
+ export function readJsonStrict(path, label) {
233
+ try {
234
+ return JSON.parse(readFileSync(path, "utf8"));
235
+ } catch (err) {
236
+ throw new Error(`Cannot parse ${label}: ${err instanceof Error ? err.message : String(err)}`);
237
+ }
238
+ }
239
+
240
+ function listFilesRecursive(root, rel = "") {
241
+ const out = [];
242
+ for (const entry of readdirSync(join(root, rel), { withFileTypes: true })) {
243
+ if (entry.name === ".DS_Store") continue;
244
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
245
+ if (entry.isDirectory()) out.push(...listFilesRecursive(root, childRel));
246
+ else if (entry.isFile()) out.push(childRel);
247
+ }
248
+ return out.sort();
249
+ }
250
+
251
+ function detectJsonFormat(text) {
252
+ const match = /^[\t ]+(?=")/m.exec(text);
253
+ return { indent: match ? match[0] : " ", trailingNewline: text.endsWith("\n") };
254
+ }
255
+
256
+ function serializeJson(doc, format) {
257
+ return `${JSON.stringify(doc, null, format.indent)}${format.trailingNewline ? "\n" : ""}`;
258
+ }
259
+
260
+ function sortObject(obj) {
261
+ return Object.fromEntries(Object.keys(obj).sort().map((k) => [k, obj[k]]));
262
+ }
263
+
264
+ // Drop empty per-rel entries before persisting `displaced` to the lock.
265
+ function pruneDisplaced(displaced) {
266
+ const json = {};
267
+ for (const [rel, m] of Object.entries(displaced.json)) if (Object.keys(m).length > 0) json[rel] = m;
268
+ const toml = {};
269
+ for (const [rel, spans] of Object.entries(displaced.toml)) if (spans.length > 0) toml[rel] = spans;
270
+ return { files: sortObject(displaced.files), json: sortObject(json), toml: sortObject(toml) };
271
+ }
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // repo + bundle resolution
275
+ // ---------------------------------------------------------------------------
276
+
277
+ export function resolveRepoRoot(repoArg) {
278
+ if (!repoArg || typeof repoArg !== "string") {
279
+ throw new Error("--repo <path> is required for a repo-local install");
280
+ }
281
+ if (!existsSync(repoArg) || !statSync(repoArg).isDirectory()) {
282
+ throw new Error(`--repo ${repoArg} is not a directory`);
283
+ }
284
+ const repo = realpathSync(resolve(repoArg));
285
+ if (repo === realpathSync(homedir())) {
286
+ throw new Error(`--repo ${repoArg} is the home directory; repo-local installs must target a git repository root`);
287
+ }
288
+ // Filesystem check, deliberately NOT `git rev-parse --show-toplevel`. A
289
+ // subprocess inherits ambient git state -- global identity, GIT_DIR,
290
+ // core.hooksPath, the agent's git version -- so every fixture that merely
291
+ // wanted a directory this function accepts became a test of the machine it
292
+ // ran on. The check is equivalent to what the subprocess established:
293
+ // rev-parse followed by `root !== repo` accepts a path exactly when that
294
+ // path itself holds `.git` (a directory normally, a file in a worktree or
295
+ // submodule).
296
+ if (!existsSync(join(repo, ".git"))) {
297
+ throw new Error(`--repo ${repoArg} is not a git repository root`);
298
+ }
299
+ return repo;
300
+ }
301
+
302
+ export function parseRepoHosts(host, manifest) {
303
+ const targets = Array.isArray(manifest.targets) ? manifest.targets : REPO_HOSTS;
304
+ if (!host) return REPO_HOSTS.filter((h) => targets.includes(h));
305
+ const parts = [...new Set(String(host).split(",").map((p) => p.trim()).filter(Boolean))];
306
+ for (const part of parts) {
307
+ if (!REPO_HOSTS.includes(part)) {
308
+ throw new Error(`Unknown repo host: "${part}". Valid: ${REPO_HOSTS.join(", ")}`);
309
+ }
310
+ if (!targets.includes(part)) {
311
+ throw new Error(`Plugin ${manifest.name} does not target host: ${part}`);
312
+ }
313
+ }
314
+ return REPO_HOSTS.filter((h) => parts.includes(h));
315
+ }
316
+
317
+ export function readBundle(pluginRoot) {
318
+ const manifestPath = join(pluginRoot, ".agents", "plugin.json");
319
+ if (!existsSync(manifestPath)) {
320
+ throw new Error(`Repo install requires a canonical manifest at ${manifestPath}`);
321
+ }
322
+ const manifest = readJsonStrict(manifestPath, manifestPath);
323
+ if (typeof manifest.name !== "string" || !manifest.name) throw new Error(`${manifestPath} is missing name`);
324
+ const component = (field) => {
325
+ const value = manifest[field];
326
+ if (typeof value !== "string" || !value.trim()) return null;
327
+ const path = resolve(pluginRoot, value.replace(/^\.\//, ""));
328
+ return existsSync(path) ? path : null;
329
+ };
330
+ const hooksPath = component("hooks");
331
+ const mcpPath = component("mcpServers");
332
+ const mcpServers = {};
333
+ for (const [name, def] of Object.entries(mcpPath ? (readJsonStrict(mcpPath, mcpPath).mcpServers ?? {}) : {})) {
334
+ // Only stdio servers (with a command) have a documented project-file form on every host.
335
+ if (def && typeof def === "object" && typeof def.command === "string") mcpServers[name] = def;
336
+ }
337
+ return {
338
+ manifest,
339
+ name: manifest.name,
340
+ version: typeof manifest.version === "string" ? manifest.version : "0.0.0",
341
+ skillsDir: component("skills"),
342
+ hooks: hooksPath ? readJsonStrict(hooksPath, hooksPath) : null,
343
+ mcpServers,
344
+ };
345
+ }
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // plan: the desired repo state
349
+ // files Map<rel, Buffer> whole managed files
350
+ // json Map<rel, {scaffold, keys, items}> entries merged into user JSON
351
+ // toml Map<rel, {tables}> mcp_servers tables merged into user TOML
352
+ // ---------------------------------------------------------------------------
353
+
354
+ function jsonMerge(plan, rel) {
355
+ if (!plan.json.has(rel)) plan.json.set(rel, { scaffold: [], keys: [], items: [] });
356
+ return plan.json.get(rel);
357
+ }
358
+
359
+ function stageDir(prefix, fn) {
360
+ const staging = mkdtempSync(join(tmpdir(), prefix));
361
+ try {
362
+ return fn(staging);
363
+ } finally {
364
+ rmSync(staging, { recursive: true, force: true });
365
+ }
366
+ }
367
+
368
+ // The bundle ships a baseline lean-ctx config (config.toml, layout.toml) at
369
+ // `<pluginRoot>/lean-ctx/`. Installed as managed whole files regardless of
370
+ // which hosts are selected: every host wires `enact-hook lean-ctx mcp` /
371
+ // `lean-ctx pre-tool-use` / `lean-ctx post-tool-use`.
372
+ //
373
+ // Writing the file is only half the job -- something must also POINT the
374
+ // `lean-ctx` binary at it, and that carrier is per-host. Only CLAUDE is wired
375
+ // today (`.claude/settings.local.json` `env`, see the claude adapter below).
376
+ // codex/cursor/kimi/opencode currently get the config file WITHOUT a carrier,
377
+ // so lean-ctx still resolves ~/.config/lean-ctx there and runs on its
378
+ // built-in defaults -- an open gap, not a finished design.
379
+ //
380
+ // Returns whether a config was actually staged: the env carrier is written
381
+ // only when there is a repo-local config for it to point at.
382
+ function addLeanCtxConfig(plan, pluginRoot) {
383
+ let added = false;
384
+ for (const name of ["config.toml", "layout.toml"]) {
385
+ const src = join(pluginRoot, "lean-ctx", name);
386
+ if (existsSync(src)) {
387
+ plan.files.set(`${LEAN_CTX_CONFIG_DIR}/${name}`, readFileSync(src));
388
+ added = true;
389
+ }
390
+ }
391
+ return added;
392
+ }
393
+
394
+ function addSharedSkills(plan, bundle, codexPolicy) {
395
+ if (!bundle.skillsDir) return;
396
+ for (const entry of readdirSync(bundle.skillsDir, { withFileTypes: true })) {
397
+ if (!entry.isDirectory()) continue;
398
+ plan.skills.add(entry.name);
399
+ for (const rel of listFilesRecursive(join(bundle.skillsDir, entry.name))) {
400
+ // Skill content is force-installed (owner decision): always ours, see
401
+ // file-header comment.
402
+ const fileRel = `${SHARED_SKILLS_DIR}/${entry.name}/${rel}`;
403
+ plan.files.set(fileRel, readFileSync(join(bundle.skillsDir, entry.name, rel)));
404
+ plan.forcedFiles.add(fileRel);
405
+ }
406
+ }
407
+ if (!codexPolicy) return;
408
+ stageDir("enact-repo-codex-skills-", (staging) => {
409
+ materializeCodexSkillMetadata(bundle.skillsDir, staging);
410
+ for (const rel of listFilesRecursive(staging)) {
411
+ const fileRel = `${SHARED_SKILLS_DIR}/${rel}`;
412
+ plan.files.set(fileRel, readFileSync(join(staging, rel)));
413
+ plan.forcedFiles.add(fileRel);
414
+ }
415
+ });
416
+ }
417
+
418
+
419
+ // Project a self-contained plugin root for a host that has a plugin format,
420
+ // and stage it into the plan. Every such host namespaces a plugin's skills by
421
+ // the plugin name, which is what lets two extensions ship the same skill name
422
+ // safely -- the reason loose per-host files needed a flat shared skills pool.
423
+ function addPluginRoot(plan, pluginRoot, bundle, platform) {
424
+ const rootRel = `${MANIFEST_BY_PLATFORM[platform].directory}/${bundle.name}`;
425
+ stageDir(`enact-repo-${platform}-`, (staging) => {
426
+ const target = join(staging, "plugin");
427
+ projectPluginRoot(pluginRoot, target, platform);
428
+ // Codex reads per-skill invocation policy from `agents/openai.yaml` beside
429
+ // each SKILL.md. It used to be written into the shared pool; it belongs
430
+ // inside the plugin root now, next to the skills it governs.
431
+ if (platform === "codex" && bundle.skillsDir) {
432
+ materializeCodexSkillMetadata(bundle.skillsDir, join(target, "skills"));
433
+ }
434
+ for (const rel of listFilesRecursive(target)) {
435
+ const fileRel = `${rootRel}/${rel}`;
436
+ plan.files.set(fileRel, readFileSync(join(target, rel)));
437
+ plan.forcedFiles.add(fileRel);
438
+ }
439
+ });
440
+ return rootRel;
441
+ }
442
+
443
+ // Host adapters. Codex is a direct-file adapter like Cursor: a committed repo
444
+ // marketplace is not auto-discovered by Codex, so none is written.
445
+ const HOST_ADAPTERS = {
446
+ claude(plan, bundle, pluginRoot, ctx) {
447
+ const pluginRel = `.claude-plugin/${bundle.name}`;
448
+ stageDir("enact-repo-claude-", (staging) => {
449
+ const target = join(staging, "plugin");
450
+ // Skills are carried INSIDE the plugin root, like every other host with
451
+ // a plugin format -- that is what namespaces them by plugin name. The
452
+ // earlier symlink into a shared `.agents/skills/` pool existed only
453
+ // because that pool existed; it is retired with it.
454
+ projectClaudePluginRoot(pluginRoot, target);
455
+ for (const rel of listFilesRecursive(target)) {
456
+ // The whole projected plugin root is exclusively our own generated
457
+ // content -- force-installed, never gated (see file-header comment).
458
+ const fileRel = `${pluginRel}/${rel}`;
459
+ plan.files.set(fileRel, readFileSync(join(target, rel)));
460
+ plan.forcedFiles.add(fileRel);
461
+ }
462
+ });
463
+ const author = bundle.manifest.author;
464
+ plan.files.set(
465
+ MARKETPLACE_PATH,
466
+ Buffer.from(
467
+ `${JSON.stringify(
468
+ {
469
+ name: MARKETPLACE_NAME,
470
+ owner: { name: typeof author?.name === "string" ? author.name : bundle.name },
471
+ // Seeded from the entry already on disk so a sibling's description
472
+ // and version survive OUR install; only our own entry is rebuilt.
473
+ // Regeneration must not quietly strip fields it did not author.
474
+ plugins: ctx.declaredExtensions.map((name) =>
475
+ name === bundle.name
476
+ ? {
477
+ name,
478
+ source: `./.claude-plugin/${name}`,
479
+ ...(typeof bundle.manifest.description === "string"
480
+ ? { description: bundle.manifest.description }
481
+ : {}),
482
+ version: bundle.version,
483
+ }
484
+ : (ctx.existingMarketplaceEntries.get(name) ?? { name, source: `./.claude-plugin/${name}` }),
485
+ ),
486
+ },
487
+ null,
488
+ 2,
489
+ )}\n`,
490
+ ),
491
+ );
492
+ // NOT forced: a pre-existing FOREIGN marketplace must still be refused
493
+ // until --force, with restore-on-uninstall, because it can plausibly be
494
+ // the user's own. But it IS derived -- we regenerate it whenever the
495
+ // declared set changes, including from a sibling's uninstall -- so it is
496
+ // exempt from the uninstall drift check, which would otherwise read our
497
+ // own regeneration as damage.
498
+ plan.derivedFiles.add(MARKETPLACE_PATH);
499
+ const settings = jsonMerge(plan, ".claude/settings.json");
500
+ settings.keys.push({
501
+ path: ["extraKnownMarketplaces", MARKETPLACE_NAME],
502
+ value: { source: { source: "directory", path: "./" } },
503
+ });
504
+ settings.keys.push({ path: ["enabledPlugins", `${bundle.name}@${MARKETPLACE_NAME}`], value: true });
505
+ // NOTE: the installer deliberately writes NO lean-ctx env carrier here.
506
+ // Scoping lean-ctx to the repo config is done by `enact-hook`, which
507
+ // prefixes LEAN_CTX_CONFIG_DIR/DATA_DIR/STATE_DIR/CACHE_DIR onto the
508
+ // rewritten command, derived from `inv.root` at invocation time. Nothing
509
+ // is stored, so nothing can go stale, and it is not Claude-specific.
510
+ // A settings.local.json `env` block was tried and rejected: it is
511
+ // Claude-only, machine-local, gitignored, and does not travel with the
512
+ // repo. Do not reintroduce it.
513
+ },
514
+
515
+ codex(plan, bundle, pluginRoot) {
516
+ addPluginRoot(plan, pluginRoot, bundle, "codex");
517
+ if (Object.keys(bundle.mcpServers).length > 0) plan.toml.set(".codex/config.toml", { tables: bundle.mcpServers });
518
+ if (bundle.hooks) {
519
+ const merge = jsonMerge(plan, ".codex/hooks.json");
520
+ // clampHookTimeoutsForHost keeps every host's canonical timeout intent
521
+ // (e.g. SessionEnd: 10) UNCHANGED in the source -- it only clamps what
522
+ // gets WRITTEN into .codex/hooks.json down to Codex's own documented
523
+ // per-event maximum (SessionEnd/Interrupt: 3s; see
524
+ // HOST_HOOK_TIMEOUT_MAX_SECONDS in src/internal/hooks.ts), so Codex
525
+ // never has to silently downgrade a value we shipped it.
526
+ const clamped = clampHookTimeoutsForHost(bundle.hooks, "codex");
527
+ for (const [event, entries] of Object.entries(applyHostFlagToHooksDoc(clamped, "codex").hooks ?? {})) {
528
+ for (const entry of entries) merge.items.push({ path: ["hooks", event], value: entry });
529
+ }
530
+ }
531
+ },
532
+
533
+ cursor(plan, bundle, pluginRoot) {
534
+ addPluginRoot(plan, pluginRoot, bundle, "cursor");
535
+ if (bundle.hooks) {
536
+ const dropped = unmappedCursorHookEvents(bundle.hooks);
537
+ if (dropped.length > 0) {
538
+ throw new Error(`Cursor hook projection drops unmapped events with active hooks: ${dropped.join(", ")}`);
539
+ }
540
+ const unmappedMatchers = unmappedCursorHookMatchers(bundle.hooks);
541
+ if (unmappedMatchers.length > 0) {
542
+ throw new Error(
543
+ `Cursor hook projection drops unmapped matcher tokens with active hooks: ${unmappedMatchers.join("; ")}`,
544
+ );
545
+ }
546
+ const merge = jsonMerge(plan, ".cursor/hooks.json");
547
+ merge.scaffold.push({ path: ["version"], value: 1 });
548
+ for (const [event, entries] of Object.entries(projectCursorHooks(bundle.hooks).hooks)) {
549
+ for (const entry of entries) merge.items.push({ path: ["hooks", event], value: entry });
550
+ }
551
+ }
552
+ const mcp = jsonMerge(plan, ".cursor/mcp.json");
553
+ for (const [name, def] of Object.entries(bundle.mcpServers)) {
554
+ mcp.keys.push({ path: ["mcpServers", name], value: { type: "stdio", ...def } });
555
+ }
556
+ },
557
+
558
+ kimi(plan, bundle, pluginRoot) {
559
+ addPluginRoot(plan, pluginRoot, bundle, "kimi");
560
+ const mcp = jsonMerge(plan, ".kimi-code/mcp.json");
561
+ for (const [name, def] of Object.entries(bundle.mcpServers)) {
562
+ mcp.keys.push({ path: ["mcpServers", name], value: def });
563
+ }
564
+ },
565
+
566
+ opencode(plan, bundle) {
567
+ // No skillsDir on purpose: OpenCode natively reads the shared .agents/skills/
568
+ // copy; a skills.paths entry would list every skill twice.
569
+ const source = deriveOpenCodePlugin(
570
+ { hooks: bundle.hooks ?? undefined, mcpServers: { mcpServers: bundle.mcpServers } },
571
+ bundle.name,
572
+ );
573
+ // Generated whole file (hooks + mcp): force-installed, see file-header comment.
574
+ const fileRel = `.opencode/plugins/${bundle.name}.ts`;
575
+ plan.files.set(fileRel, Buffer.from(source));
576
+ plan.forcedFiles.add(fileRel);
577
+ },
578
+ };
579
+
580
+ // `repo` is the ABSOLUTE repo root, required (not optional): the Claude env
581
+ // block carries absolute LEAN_CTX_* paths, and a relative or missing value
582
+ // would silently degrade into lean-ctx resolving ~/.config/lean-ctx again.
583
+ export function buildPlan(pluginRoot, bundle, hosts, repo) {
584
+ if (typeof repo !== "string" || !isAbsolute(repo)) {
585
+ throw new Error(`buildPlan requires the absolute repo root, got ${JSON.stringify(repo)}`);
586
+ }
587
+ const plan = {
588
+ files: new Map(),
589
+ // Subset of `files` keys that are owner-forced (hooks/mcp/skills content
590
+ // exclusively generated by us): always overwritten, never gated behind
591
+ // `--force`, never captured for a displaced-content restore. See the
592
+ // file-header doc comment.
593
+ forcedFiles: new Set(),
594
+ // Files we REGENERATE from a source of truth (today: the marketplace, built
595
+ // from the declared extension set). Still conflict-gated on install, but
596
+ // exempt from the uninstall drift check -- a sibling's install or uninstall
597
+ // legitimately rewrites them.
598
+ derivedFiles: new Set(),
599
+ json: new Map(),
600
+ toml: new Map(),
601
+ gitignore: new Map(),
602
+ symlinks: new Map(),
603
+ skills: new Set(),
604
+ };
605
+ plan.gitignore.set(GITIGNORE_PATH, GITIGNORE_LINES);
606
+ const leanCtxConfig = addLeanCtxConfig(plan, pluginRoot);
607
+
608
+ // The repo's DECLARED extension set, from `[skills].extensions`. Installing a
609
+ // plugin declares it: the file is the user-editable statement of what this
610
+ // repo should have, and install reconciles the repo to it. Order is stable
611
+ // (declaration order, ours appended last) so regenerating the marketplace
612
+ // does not churn the diff.
613
+ const configPath = abs(repo, CONFIG_PATH);
614
+ const configText = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
615
+ const declared = readDeclaredExtensions(configText);
616
+ const declaredExtensions = declared.includes(bundle.name) ? declared : [...declared, bundle.name];
617
+
618
+ // Existing marketplace entries, so regeneration preserves fields belonging to
619
+ // plugins other than the one being installed.
620
+ const existingMarketplaceEntries = new Map();
621
+ const mktPath = abs(repo, MARKETPLACE_PATH);
622
+ if (existsSync(mktPath)) {
623
+ try {
624
+ for (const entry of JSON.parse(readFileSync(mktPath, "utf8")).plugins ?? []) {
625
+ if (entry && typeof entry.name === "string") existingMarketplaceEntries.set(entry.name, entry);
626
+ }
627
+ } catch {
628
+ // Unparsable or foreign: the ordinary conflict gate handles it below.
629
+ }
630
+ }
631
+
632
+ const ctx = { repo, leanCtxConfig, declaredExtensions, existingMarketplaceEntries };
633
+ for (const host of hosts) HOST_ADAPTERS[host](plan, bundle, pluginRoot, ctx);
634
+ plan.declaredExtensions = declaredExtensions;
635
+ // `.agents/skills` is written UNCONDITIONALLY -- .agents is universal, and a
636
+ // claude-only install still needs the real tree its plugin skills/ symlink
637
+ // points at, or the link dangles and Claude Code drops the skills silently.
638
+ // Only for OpenCode. `.agents/skills/` is retired: it was a flat pool shared
639
+ // by every host precisely because we were not building plugin roots, and a
640
+ // flat pool is what made two extensions shipping one skill name unsafe.
641
+ if (hosts.includes("opencode")) addSharedSkills(plan, bundle, false);
642
+ for (const [rel, merge] of [...plan.json]) {
643
+ if (merge.keys.length + merge.items.length === 0) plan.json.delete(rel);
644
+ }
645
+ return plan;
646
+ }
647
+
648
+ // ---------------------------------------------------------------------------
649
+ // JSON merge helpers
650
+ // ---------------------------------------------------------------------------
651
+
652
+ // ---------------------------------------------------------------------------
653
+ // TOML block helpers — text level, so user content and comments are untouched
654
+ // ---------------------------------------------------------------------------
655
+
656
+ // ---------------------------------------------------------------------------
657
+ // lock + filesystem helpers
658
+ // ---------------------------------------------------------------------------
659
+
660
+ export const lockRel = (name) => `.agents/${name}.lock.json`;
661
+
662
+ export function readLock(repo, name) {
663
+ const path = abs(repo, lockRel(name));
664
+ if (!existsSync(path)) return null;
665
+ const lock = readJsonStrict(path, lockRel(name));
666
+ if (lock.plugin !== name) throw new Error(`${lockRel(name)} belongs to plugin ${lock.plugin}, not ${name}`);
667
+ return lock;
668
+ }
669
+
670
+ /**
671
+ * Every OTHER enact plugin installed in this repo, as `{ name, lock }`.
672
+ *
673
+ * Until this existed, ownership was resolved against ONE lock -- the plugin
674
+ * being installed -- so a second plugin saw the first plugin's legitimate
675
+ * output as foreign. `.agents/skills/` is a flat pool shared by every host that
676
+ * reads it, and real bundles collide there (enact-repo and engineering ship 23
677
+ * of the same skill names), so "is this path mine?" is the wrong question. The
678
+ * right one is "does ANY installed plugin own it?".
679
+ *
680
+ * Reads only `.agents/*.lock.json`, skipping `exclude` and any file whose
681
+ * `plugin` field disagrees with its filename (readLock throws on that, and a
682
+ * malformed sibling must not break an unrelated install -- it is reported by
683
+ * that plugin's own doctor instead).
684
+ */
685
+ export function readSiblingLocks(repo, exclude) {
686
+ const dir = abs(repo, ".agents");
687
+ if (!existsSync(dir)) return [];
688
+ const out = [];
689
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
690
+ if (!entry.isFile() || !entry.name.endsWith(".lock.json")) continue;
691
+ const name = entry.name.slice(0, -".lock.json".length);
692
+ if (name === exclude) continue;
693
+ let lock;
694
+ try {
695
+ lock = readLock(repo, name);
696
+ } catch {
697
+ continue;
698
+ }
699
+ if (lock) out.push({ name, lock });
700
+ }
701
+ return out;
702
+ }
703
+
704
+ export const fileSha = (repo, rel) => sha256(readFileSync(abs(repo, rel)));
705
+
706
+ /**
707
+ * Replace any ANCESTOR directory of `rel` that is a symlink with a real
708
+ * directory, before writing into it.
709
+ *
710
+ * `mkdirSync(..., {recursive:true})` follows a symlinked ancestor, so writing a
711
+ * managed file under a path that is (or was) a link silently writes THROUGH it
712
+ * -- the content lands wherever the link points and the link survives, so the
713
+ * result looks fine from the plugin root. This bit on upgrade from the layout
714
+ * where `.claude-plugin/<name>/skills` linked into a shared pool: the new real
715
+ * skills were written into the old pool instead.
716
+ *
717
+ * Scoped to ancestors strictly inside the repo, and only those that are links.
718
+ */
719
+ function replaceSymlinkAncestors(repo, rel, ops) {
720
+ const parts = rel.split("/").slice(0, -1);
721
+ for (let i = 1; i <= parts.length; i++) {
722
+ const dirRel = parts.slice(0, i).join("/");
723
+ const st = lstatSync(abs(repo, dirRel), { throwIfNoEntry: false });
724
+ if (st?.isSymbolicLink()) ops.removeSymlink(dirRel, "replacing a symlinked directory with real content");
725
+ }
726
+ }
727
+
728
+ function recordMissingDirs(repo, rel, createdDirs) {
729
+ const parts = rel.split("/").slice(0, -1);
730
+ for (let i = 1; i <= parts.length; i++) {
731
+ const dir = parts.slice(0, i).join("/");
732
+ if (!existsSync(abs(repo, dir)) && !createdDirs.includes(dir)) createdDirs.push(dir);
733
+ }
734
+ }
735
+
736
+ function removeEmptyDirs(repo, dirs, ops) {
737
+ for (const dir of [...dirs].sort((a, b) => b.split("/").length - a.split("/").length)) {
738
+ ops.rmdirIfEmpty(dir);
739
+ }
740
+ }
741
+
742
+ // Strip the lines this plugin's install added to a managed `.gitignore` (per
743
+ // `ginfo` = one entry of a lock's `gitignore` map: {created, lines,
744
+ // appended}), same convention as the TOML-block / displaced-content removal
745
+ // elsewhere in this file: prefer stripping the exact byte-appended tail, fall
746
+ // back to a line-by-line filter over the owned set. Any line NOT in
747
+ // `ginfo.lines` (including a user's own pre-existing content) is left
748
+ // untouched. Removes the file entirely when it was created by us and is now
749
+ // empty. Returns whether anything changed on disk.
750
+ function removeManagedGitignoreLines(repo, rel, ginfo, ops) {
751
+ const path = abs(repo, rel);
752
+ if (!existsSync(path)) return false;
753
+ const text = readFileSync(path, "utf8");
754
+ const appended = ginfo.appended;
755
+ const out =
756
+ typeof appended === "string" && text.endsWith(appended)
757
+ ? text.slice(0, text.length - appended.length)
758
+ : text
759
+ .split("\n")
760
+ .filter((l) => !new Set(ginfo.lines ?? []).has(l.trim()))
761
+ .join("\n");
762
+ if (ginfo.created && out.trim() === "") return ops.remove(rel, "gitignore lines removed (file now empty)");
763
+ return ops.write(rel, out, "gitignore lines removed");
764
+ }
765
+
766
+ // ---------------------------------------------------------------------------
767
+ // enact-config.toml [skills] — text-level only. The document is parsed solely
768
+ // to refuse invalid TOML and to learn whether OUR `skills` key exists; no other
769
+ // table is read, and the document is never re-serialized.
770
+ // ---------------------------------------------------------------------------
771
+
772
+ function parseConfigText(text, action) {
773
+ try {
774
+ return TOML.parse(text);
775
+ } catch (err) {
776
+ throw new Error(`Cannot parse ${CONFIG_PATH} (${action}): ${err instanceof Error ? err.message : String(err)}`);
777
+ }
778
+ }
779
+
780
+ /**
781
+ * `uninstall --purge` rule for `.opencode/`: OpenCode auto-generates
782
+ * `node_modules/`, `package.json`, `package-lock.json`, `bun.lock` and
783
+ * `.gitignore` there the first time it loads a project plugin (verified
784
+ * 2026-09-14, this session -- see OPENCODE_AUTOGEN_ENTRIES' doc comment).
785
+ * We never write those files ourselves (only `plugins/<name>.ts`, already
786
+ * removed by the managed-file removal loop above). Remove them ONLY when
787
+ * every remaining entry under `.opencode/` is one of OpenCode's own
788
+ * auto-generated names (and `plugins/`, if present, is now empty) --
789
+ * i.e. nothing user-owned is left that could be silently destroyed.
790
+ */
791
+ function purgeOpenCodeAutogenIfClean(repo, ops) {
792
+ const dir = abs(repo, ".opencode");
793
+ if (!existsSync(dir)) return;
794
+ const entries = readdirSync(dir);
795
+ // `plugins/` and `skills/` are both OURS: the generated TS plugin and, since
796
+ // OpenCode is the only host whose plugin surface cannot carry skills, its
797
+ // flat skills directory. Both are emptied by the managed-file removal above,
798
+ // so an empty one is not user content -- but this check runs before
799
+ // removeEmptyDirs, so the now-empty directories are still on disk here.
800
+ // Recursive: removing our managed files leaves the skill directories behind
801
+ // as empty shells until removeEmptyDirs runs (after this), so a top-level
802
+ // listing would report them as user content. What matters is whether any FILE
803
+ // survives, not whether the directory is literally empty.
804
+ const hasAnyFile = (d) =>
805
+ existsSync(d) &&
806
+ readdirSync(d, { withFileTypes: true }).some((e) => (e.isDirectory() ? hasAnyFile(join(d, e.name)) : true));
807
+ const ourDirs = ["plugins", "skills"];
808
+ const notEmpty = ourDirs.filter((d) => hasAnyFile(join(dir, d)));
809
+ const leftover = entries.filter((e) => !ourDirs.includes(e) && !OPENCODE_AUTOGEN_ENTRIES.has(e));
810
+ if (leftover.length > 0 || notEmpty.length > 0) {
811
+ console.log(
812
+ `kept .opencode/ auto-generated files (user-owned content present: ${[...leftover, ...notEmpty.map((d) => `${d}/*`)].join(", ")})`,
813
+ );
814
+ return;
815
+ }
816
+ for (const e of entries) ops.removeDir(`.opencode/${e}`, "OpenCode auto-generated file (--purge)");
817
+ ops.rmdirIfEmpty(".opencode");
818
+ console.log("purged .opencode/ auto-generated files (node_modules/package.json/package-lock.json/bun.lock/.gitignore -- matched OpenCode's own auto-generated shape, nothing else present)");
819
+ }
820
+
821
+ // ---------------------------------------------------------------------------
822
+ // install
823
+ // ---------------------------------------------------------------------------
824
+
825
+ export function runRepoInstall(pluginRoot, options = {}) {
826
+ const repo = resolveRepoRoot(options.repo);
827
+ const force = options.force === true;
828
+ const dryRun = options.dryRun === true;
829
+ const actions = [];
830
+ const ops = makeFileOps(repo, dryRun, actions);
831
+ assertSupportedHookEvents(pluginRoot);
832
+ const bundle = readBundle(pluginRoot);
833
+ const hosts = parseRepoHosts(options.host, bundle.manifest);
834
+ if (hosts.length === 0) throw new Error(`Plugin ${bundle.name} targets no repo host (${REPO_HOSTS.join(", ")})`);
835
+ const plan = buildPlan(pluginRoot, bundle, hosts, repo);
836
+ // Other enact plugins already installed here. Ownership of a shared path is
837
+ // a question about ALL of them, not just this one -- see readSiblingLocks.
838
+ const siblings = readSiblingLocks(repo, bundle.name);
839
+ const prev = readLock(repo, bundle.name) ?? {
840
+ files: {},
841
+ merged: {},
842
+ toml: {},
843
+ gitignore: {},
844
+ symlinks: {},
845
+ config: null,
846
+ createdDirs: [],
847
+ };
848
+ const prevSymlinks = prev.symlinks ?? {};
849
+ const prevDisplaced = prev.displaced ?? { files: {}, json: {}, toml: {} };
850
+ // Originals displaced by a --force takeover of pre-existing foreign content,
851
+ // carried forward from the previous lock and extended below. Re-running
852
+ // install never overwrites an already-recorded original (see per-surface
853
+ // "already recorded" guards): the FIRST takeover is the only one that ever
854
+ // writes here. Uninstall restores exactly what is recorded here.
855
+ const displaced = {
856
+ files: { ...prevDisplaced.files },
857
+ json: Object.fromEntries(Object.entries(prevDisplaced.json ?? {}).map(([rel, m]) => [rel, { ...m }])),
858
+ toml: Object.fromEntries(Object.entries(prevDisplaced.toml ?? {}).map(([rel, spans]) => [rel, [...spans]])),
859
+ };
860
+
861
+ // -- preflight: enact-config.toml must be valid TOML before we append to it
862
+ const configExists = existsSync(abs(repo, CONFIG_PATH));
863
+ const configText = configExists ? readFileSync(abs(repo, CONFIG_PATH), "utf8") : null;
864
+ const configHasExtensions = configExists && parseConfigText(configText, "refusing to append [extensions]").extensions !== undefined;
865
+
866
+ // -- preflight: skills never overwrite a USER-AUTHORED skill dir (not even
867
+ // with --force). A dir owned by THIS plugin, or by any SIBLING plugin
868
+ // installed in the same repo, is fine: `.agents/skills/` is one flat pool
869
+ // shared across plugins by design, and bundles legitimately ship the same
870
+ // skill name (enact-repo and engineering share 23). Checking only `prev`
871
+ // made every sibling-owned dir look foreign, which refused the install with
872
+ // a false "non-managed" claim and advised deleting files another plugin
873
+ // depends on.
874
+ const skillConflicts = [...plan.skills]
875
+ .map((skill) => `${SHARED_SKILLS_DIR}/${skill}`)
876
+ .filter(
877
+ (dirRel) =>
878
+ existsSync(abs(repo, dirRel)) &&
879
+ !Object.keys(prev.files).some((f) => f.startsWith(`${dirRel}/`)) &&
880
+ !siblingOwnsPrefix(siblings, dirRel),
881
+ );
882
+ if (skillConflicts.length > 0) {
883
+ throw new Error(
884
+ `Refusing to overwrite existing user-authored skill dir(s); rename or remove them first:\n ${skillConflicts.join("\n ")}`,
885
+ );
886
+ }
887
+
888
+ // A sibling owning the SAME path with DIFFERENT bytes is the genuinely
889
+ // dangerous case, and it used to pass silently: skill files are forcedFiles,
890
+ // so plugin B simply overwrote plugin A's `handoff/SKILL.md` with an
891
+ // unrelated skill and nothing warned. `.agents/skills/` is flat on every host
892
+ // that reads it, so two plugins cannot ship different skills under one name.
893
+ // Refuse, and do NOT let --force through -- forcing would leave the other
894
+ // plugin's lock pointing at bytes it did not write.
895
+ // `.claude-plugin/marketplace.json` is excluded: it is a multi-plugin
896
+ // REGISTRY, so differing content there is expected and correct -- every
897
+ // plugin adds its own entry. It is still written as a whole file today, so
898
+ // the second install is refused by the ordinary foreign-content gate below;
899
+ // Phase 2 turns it into a merged surface keyed by `plugins[].name`. Listing
900
+ // it here would report a real collision with useless advice ("rename one").
901
+ const SHARED_CONTENT_EXEMPT = new Set([".claude-plugin/marketplace.json"]);
902
+ const divergent = [];
903
+ // BEFORE any file is written: drop a recorded symlink that is no longer in
904
+ // the plan. A managed directory that is still a symlink from an earlier
905
+ // layout would otherwise be FOLLOWED by the writes below -- the skills a
906
+ // plugin root should now own would land wherever the old link pointed, and
907
+ // the link would survive, so nothing looked wrong.
908
+ for (const rel of Object.keys(prevSymlinks)) {
909
+ if (plan.symlinks.has(rel)) continue;
910
+ if (ops.removeSymlink(rel, "symlink dropped out of plan")) written.push(rel);
911
+ }
912
+ for (const [rel, content] of plan.files) {
913
+ if (SHARED_CONTENT_EXEMPT.has(rel)) continue;
914
+ for (const { name: owner, lock } of siblings) {
915
+ const theirs = lock.files?.[rel]?.sha256;
916
+ if (theirs && theirs !== sha256(content)) divergent.push({ rel, owner });
917
+ }
918
+ }
919
+ if (divergent.length > 0) {
920
+ throw new Error(
921
+ `Refusing to install: another plugin already provides these paths with different content.\n` +
922
+ `Two plugins cannot ship different content under the same shared path.\n` +
923
+ divergent.map(({ rel, owner }) => ` ${rel} (installed by ${owner})`).join("\n") +
924
+ `\nRename one of them.`,
925
+ );
926
+ }
927
+
928
+ const edited = [];
929
+ const conflicts = [];
930
+ // Structured tracking (in parallel with the human-readable `conflicts`
931
+ // strings above) of exactly which foreign whole files / JSON keys a
932
+ // --force takeover is about to overwrite, so their original content can be
933
+ // captured before it's lost.
934
+ const foreignFileRels = new Set();
935
+ const foreignJsonKeys = [];
936
+
937
+ // -- preflight: whole managed files. `forcedFiles` (hooks/mcp/skills
938
+ // content, exclusively ours -- see file-header comment) is skipped
939
+ // entirely: drift or a stale pre-existing entry there is never a blocking
940
+ // conflict, just silently overwritten by the apply loop below.
941
+ for (const rel of new Set([...plan.files.keys(), ...Object.keys(prev.files)])) {
942
+ if (plan.forcedFiles.has(rel)) continue;
943
+ if (!existsSync(abs(repo, rel))) continue;
944
+ if (prev.files[rel]) {
945
+ // A derived file is regenerated from the declared set, so a sibling's
946
+ // install legitimately changed it since ours -- not drift.
947
+ if (!prev.files[rel].derived && fileSha(repo, rel) !== prev.files[rel].sha256) edited.push(rel);
948
+ } else if (siblingOwnersOf(siblings, rel).length > 0) {
949
+ // Owned by another installed extension, not by a user. Ownership is a
950
+ // question about every installed plugin, not just this one.
951
+ } else {
952
+ conflicts.push(`${rel} (file exists and is not managed by ${bundle.name})`);
953
+ foreignFileRels.add(rel);
954
+ }
955
+ }
956
+
957
+ // -- preflight: symlinks. The claude skills symlink is in the same
958
+ // owner-forced scope as `forcedFiles` above (skills content, exclusively
959
+ // generated by us) -- no preflight gate at all. `ops.symlink` in the apply
960
+ // phase below already replaces whatever it finds (a stale pre-symlink real
961
+ // directory, a wrong target, nothing) unconditionally.
962
+
963
+ // -- preflight: JSON merges
964
+ const jsonRels = [...new Set([...plan.json.keys(), ...Object.keys(prev.merged)])].sort();
965
+ const jsonDocs = new Map();
966
+ for (const rel of jsonRels) {
967
+ const path = abs(repo, rel);
968
+ const exists = existsSync(path);
969
+ const text = exists ? readFileSync(path, "utf8") : null;
970
+ const doc = exists ? readJsonStrict(path, rel) : {};
971
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw new Error(`${rel} must contain a JSON object`);
972
+ jsonDocs.set(rel, { exists, text, doc });
973
+ // Hooks/mcp/plugin-enablement merges are owner-forced (see file-header
974
+ // comment): our own entries are always repaired to match the bundle on
975
+ // the write below, whether or not they drifted since the last install --
976
+ // never a preflight refusal, never gated behind --force.
977
+ const record = prev.merged[rel];
978
+ // Owned by US or by any SIBLING enact plugin. A key another installed
979
+ // extension wrote is not foreign: capturing it as a "displaced original"
980
+ // made uninstall RESTORE it instead of deleting it, so a shared
981
+ // registration key (`extraKnownMarketplaces.<marketplace>`) survived the
982
+ // last uninstall forever.
983
+ const ownedKeys = new Set([
984
+ ...(record?.entries ?? []).filter((e) => e.type === "key").map((e) => pathKey(e.path)),
985
+ ...siblings.flatMap(({ lock: l }) =>
986
+ (l.merged?.[rel]?.entries ?? []).filter((e) => e.type === "key").map((e) => pathKey(e.path)),
987
+ ),
988
+ ]);
989
+ for (const key of plan.json.get(rel)?.keys ?? []) {
990
+ if (getAt(doc, key.path) !== undefined && !ownedKeys.has(pathKey(key.path))) {
991
+ // A foreign entry sharing OUR key: taken over unconditionally below
992
+ // (captured into `displaced` for a byte-exact restore on uninstall,
993
+ // exactly like a --force takeover always has -- just without needing
994
+ // the flag). A user's OTHER keys are never touched by any of this.
995
+ foreignJsonKeys.push({ rel, dKey: pathKey(key.path) });
996
+ }
997
+ }
998
+ }
999
+
1000
+ // -- preflight: TOML blocks. Same owner-forced scope as the JSON merges
1001
+ // above (.codex/config.toml's mcp_servers block) -- no edited/conflict
1002
+ // gating; `foreign` below still drives the unconditional takeover in the
1003
+ // apply phase.
1004
+ const tomlRels = [...new Set([...plan.toml.keys(), ...Object.keys(prev.toml)])].sort();
1005
+ const tomlDocs = new Map();
1006
+ for (const rel of tomlRels) {
1007
+ const path = abs(repo, rel);
1008
+ const exists = existsSync(path);
1009
+ const text = exists ? readFileSync(path, "utf8") : "";
1010
+ const found = findBlock(text, bundle.name);
1011
+ let outside;
1012
+ try {
1013
+ outside = TOML.parse(found ? text.slice(0, found.start) + text.slice(found.stop) : text);
1014
+ } catch (err) {
1015
+ throw new Error(`Cannot parse ${rel}: ${err instanceof Error ? err.message : String(err)}`);
1016
+ }
1017
+ const foreign = Object.keys(plan.toml.get(rel)?.tables ?? {}).filter((s) => outside.mcp_servers?.[s] !== undefined);
1018
+ tomlDocs.set(rel, { exists, text, foreign });
1019
+ }
1020
+
1021
+ const problems = [];
1022
+ if (edited.length > 0) {
1023
+ problems.push(`Managed content was edited since the last install (re-run with --force to overwrite):\n ${edited.join("\n ")}`);
1024
+ }
1025
+ if (conflicts.length > 0) {
1026
+ problems.push(`Conflict with existing entries not owned by ${bundle.name} (re-run with --force to take them over):\n ${conflicts.join("\n ")}`);
1027
+ }
1028
+ if (problems.length > 0 && !force) throw new Error(problems.join("\n"));
1029
+
1030
+ // -- apply
1031
+ const createdDirs = prev.createdDirs.filter((d) => existsSync(abs(repo, d)));
1032
+ const written = [];
1033
+ const writeRepoFile = (rel, content, reason) => {
1034
+ replaceSymlinkAncestors(repo, rel, ops);
1035
+ recordMissingDirs(repo, rel, createdDirs);
1036
+ if (ops.write(rel, content, reason)) written.push(rel);
1037
+ };
1038
+ const removeRepoFile = (rel, reason) => {
1039
+ if (ops.remove(rel, reason)) written.push(rel);
1040
+ };
1041
+
1042
+ const files = {};
1043
+ for (const rel of Object.keys(prev.files)) {
1044
+ if (plan.files.has(rel) || !existsSync(abs(repo, rel))) continue;
1045
+ // This managed file is dropping out of the plan (e.g. --host narrowed).
1046
+ // If it took over foreign content, restore it instead of deleting it.
1047
+ const displacedFile = displaced.files[rel];
1048
+ if (displacedFile) {
1049
+ const storedPath = abs(repo, displacedFile.storedAt);
1050
+ if (!existsSync(storedPath)) throw new Error(`Missing recorded original for ${rel} at ${displacedFile.storedAt}`);
1051
+ writeRepoFile(rel, readFileSync(storedPath));
1052
+ removeRepoFile(displacedFile.storedAt);
1053
+ delete displaced.files[rel];
1054
+ } else {
1055
+ removeRepoFile(rel);
1056
+ }
1057
+ }
1058
+ // -- capture originals for whole files taking over pre-existing foreign
1059
+ // content this run, BEFORE they get overwritten below. Never recapture: if
1060
+ // an original is already recorded (from an earlier --force takeover), keep it.
1061
+ for (const rel of foreignFileRels) {
1062
+ if (displaced.files[rel]) continue;
1063
+ const storedRel = `.agents/enact-repo-originals/${bundle.name}/${rel}`;
1064
+ writeRepoFile(storedRel, readFileSync(abs(repo, rel)));
1065
+ displaced.files[rel] = { storedAt: storedRel };
1066
+ }
1067
+ for (const [rel, content] of plan.files) {
1068
+ writeRepoFile(rel, content);
1069
+ files[rel] = { sha256: sha256(content), ...(plan.derivedFiles.has(rel) ? { derived: true } : {}) };
1070
+ }
1071
+
1072
+ const symlinks = {};
1073
+ for (const [rel, target] of plan.symlinks) {
1074
+ recordMissingDirs(repo, rel, createdDirs);
1075
+ if (ops.symlink(rel, target)) written.push(rel);
1076
+ symlinks[rel] = { target, created: prevSymlinks[rel]?.created ?? true };
1077
+ }
1078
+
1079
+ const merged = {};
1080
+ for (const rel of jsonRels) {
1081
+ const { exists, text, doc } = jsonDocs.get(rel);
1082
+ const record = prev.merged[rel];
1083
+ const want = plan.json.get(rel);
1084
+ const format = exists ? detectJsonFormat(text) : { indent: " ", trailingNewline: true };
1085
+ const containers = [...(record?.containers ?? [])];
1086
+ const scaffold = [...(record?.scaffold ?? [])];
1087
+
1088
+ if (!want) {
1089
+ const relDisplaced = displaced.json[rel] ?? {};
1090
+ removeJsonEntries(doc, { entries: record.entries, containers, scaffold }, relDisplaced);
1091
+ delete displaced.json[rel];
1092
+ if (!exists) continue;
1093
+ if (record.created && Object.keys(doc).length === 0) removeRepoFile(rel);
1094
+ else writeRepoFile(rel, serializeJson(doc, format));
1095
+ continue;
1096
+ }
1097
+
1098
+ const wanted = new Set([
1099
+ ...want.keys.map((k) => `key${pathKey(k.path)}`),
1100
+ ...want.items.map((i) => `item${pathKey(i.path)}${valueHash(i.value)}`),
1101
+ ]);
1102
+ const stale = (record?.entries ?? []).filter((e) =>
1103
+ e.type === "key" ? !wanted.has(`key${pathKey(e.path)}`) : !wanted.has(`item${pathKey(e.path)}${e.sha256}`),
1104
+ );
1105
+ const relDisplaced = displaced.json[rel] ?? {};
1106
+ removeJsonEntries(doc, { entries: stale }, relDisplaced);
1107
+
1108
+ const created = [];
1109
+ for (const s of want.scaffold) {
1110
+ if (getAt(doc, s.path) === undefined) doc[s.path[0]] = s.value;
1111
+ // Recorded whether or not WE created it. Scaffold is shared repo-level
1112
+ // framing (a marketplace's `name`/`owner`), so recording it only in the
1113
+ // creator's lock left the LAST plugin out with nothing to prune: it
1114
+ // removed its own entry, `plugins` emptied, and `{name, owner}` stayed
1115
+ // behind forever in a marketplace listing no plugins. The prune is
1116
+ // already conditional on nothing else remaining, so recording it in
1117
+ // every owner's lock is safe.
1118
+ if (!scaffold.some((x) => pathKey(x.path) === pathKey(s.path))) scaffold.push({ path: s.path });
1119
+ }
1120
+ const entries = [];
1121
+ for (const key of want.keys) {
1122
+ const parent = ensureContainer(doc, key.path.slice(0, -1), "object", created);
1123
+ const k = key.path[key.path.length - 1];
1124
+ const h = valueHash(key.value);
1125
+ const dKey = pathKey(key.path);
1126
+ const isTakeover = foreignJsonKeys.some((f) => f.rel === rel && f.dKey === dKey);
1127
+ if (isTakeover && has(parent, k) && !has(relDisplaced, dKey)) relDisplaced[dKey] = parent[k];
1128
+ if (!has(parent, k) || valueHash(parent[k]) !== h) parent[k] = key.value;
1129
+ entries.push({ type: "key", path: key.path, sha256: h });
1130
+ }
1131
+ if (Object.keys(relDisplaced).length > 0) displaced.json[rel] = relDisplaced;
1132
+ else delete displaced.json[rel];
1133
+ for (const item of want.items) {
1134
+ const arr = ensureContainer(doc, item.path, "array", created);
1135
+ const h = valueHash(item.value);
1136
+ if (!arr.some((v) => valueHash(v) === h)) arr.push(item.value);
1137
+ entries.push({ type: "item", path: item.path, sha256: h });
1138
+ }
1139
+ // Containers WE created, plus any a sibling enact plugin recorded creating.
1140
+ // Inheriting matters because the second plugin to write into a shared file
1141
+ // creates nothing, so it recorded no container and left an empty `{}`
1142
+ // behind when it uninstalled last. Recording a container the USER created
1143
+ // would be worse -- uninstall would prune a key of theirs -- so this is
1144
+ // deliberately limited to containers enact is known to have created.
1145
+ const siblingContainers = siblings.flatMap(({ lock: l }) => l.merged?.[rel]?.containers ?? []);
1146
+ for (const c of [...created, ...siblingContainers]) {
1147
+ if (c.length > 0 && !containers.some((x) => pathKey(x) === pathKey(c))) containers.push(c);
1148
+ }
1149
+ writeRepoFile(rel, serializeJson(doc, format));
1150
+ // `created` is inherited: if ANY installed enact plugin created this file,
1151
+ // it is ours to remove once the last of us leaves. Recording only our own
1152
+ // creation left the second plugin believing a user owned the file, so an
1153
+ // empty `{}` stayed behind forever.
1154
+ const createdByEnact =
1155
+ record?.created ?? (!exists || siblings.some(({ lock: l }) => l.merged?.[rel]?.created === true));
1156
+ merged[rel] = { created: createdByEnact, containers, scaffold, entries };
1157
+ }
1158
+
1159
+ const toml = {};
1160
+ for (const rel of tomlRels) {
1161
+ const { exists, foreign } = tomlDocs.get(rel);
1162
+ let { text } = tomlDocs.get(rel);
1163
+ const record = prev.toml[rel];
1164
+ const want = plan.toml.get(rel);
1165
+
1166
+ // Cut foreign tables sharing one of OUR managed server names, recording
1167
+ // (byte-exact, incl. comments) any not already recorded from an earlier
1168
+ // takeover -- unconditional (mcp is owner-forced), not gated by --force.
1169
+ const displacedSpans = [...(displaced.toml[rel] ?? [])];
1170
+ for (const s of foreign) {
1171
+ const already = displacedSpans.some((d) => d.server === s);
1172
+ const extracted = extractTomlTable(text, s);
1173
+ text = extracted.text;
1174
+ if (!already && extracted.removed) displacedSpans.push({ server: s, ...extracted.removed });
1175
+ }
1176
+
1177
+ const current = findBlock(text, bundle.name);
1178
+ if (!want) {
1179
+ if (current) text = cutBlock(text, current, record?.prefix ?? "");
1180
+ text = reinsertTomlSpans(text, displacedSpans);
1181
+ delete displaced.toml[rel];
1182
+ if (record?.created && text === "") removeRepoFile(rel);
1183
+ else if (exists || displacedSpans.length > 0) writeRepoFile(rel, text);
1184
+ continue;
1185
+ }
1186
+ if (displacedSpans.length > 0) displaced.toml[rel] = displacedSpans;
1187
+ else delete displaced.toml[rel];
1188
+
1189
+ const block = renderTomlBlock(bundle.name, want.tables);
1190
+ let prefix = record?.prefix ?? "";
1191
+ if (current) {
1192
+ text = text.slice(0, current.start) + block + text.slice(current.stop);
1193
+ } else {
1194
+ prefix = text.length === 0 ? "" : text.endsWith("\n") ? "\n" : "\n\n";
1195
+ text = `${text}${prefix}${block}`;
1196
+ }
1197
+ try {
1198
+ TOML.parse(text);
1199
+ } catch (err) {
1200
+ throw new Error(`Refusing to write invalid TOML to ${rel}: ${err instanceof Error ? err.message : String(err)}`);
1201
+ }
1202
+ writeRepoFile(rel, text);
1203
+ toml[rel] = { created: record ? record.created : !exists, prefix, sha256: sha256(block) };
1204
+ }
1205
+
1206
+ // Managed .gitignore files, keyed by repo-relative path:
1207
+ // .agents/.gitignore keeps the repo-local lean-ctx runtime dirs
1208
+ // (data/cache/state/bin) and the enact-hook logs out
1209
+ // of git; the lean-ctx config/ dir stays committed.
1210
+ // .claude/.gitignore keeps settings.local.json (absolute, machine-
1211
+ // specific LEAN_CTX_*_DIR values) out of git.
1212
+ // Only lines we actually add are ever recorded/owned — a line a user
1213
+ // already has (even one of ours, verbatim) is left unclaimed and survives
1214
+ // uninstall untouched.
1215
+ const gitignore = { ...(prev.gitignore ?? {}) };
1216
+ for (const rel of Object.keys(gitignore)) {
1217
+ // A managed .gitignore that dropped out of the plan (e.g. --host narrowed
1218
+ // away from claude): strip our lines, exactly as uninstall would.
1219
+ if (plan.gitignore.has(rel)) continue;
1220
+ if (removeManagedGitignoreLines(repo, rel, gitignore[rel], ops)) written.push(rel);
1221
+ delete gitignore[rel];
1222
+ }
1223
+ for (const [rel, lines] of plan.gitignore) {
1224
+ const path = abs(repo, rel);
1225
+ const exists = existsSync(path);
1226
+ const text = exists ? readFileSync(path, "utf8") : "";
1227
+ const currentLines = new Set(text.split("\n").map((l) => l.trim()));
1228
+ const missing = lines.filter((l) => !currentLines.has(l));
1229
+ if (missing.length === 0) continue;
1230
+ const prefix = text.length === 0 || text.endsWith("\n") ? "" : "\n";
1231
+ const block = missing.map((l) => `${l}\n`).join("");
1232
+ writeRepoFile(rel, `${text}${prefix}${block}`);
1233
+ const owned = new Set([...(prev.gitignore?.[rel]?.lines ?? []), ...missing]);
1234
+ // `appended` is the exact byte span (prefix + block) this run tacked onto
1235
+ // the end of the file — lets uninstall strip it byte-exact (matching the
1236
+ // TOML block / displaced-content restore convention elsewhere in this
1237
+ // file) when nothing else touched the file afterward. Only reflects the
1238
+ // MOST RECENT append; `lines` (the full owned set) is the correctness
1239
+ // fallback if a later edit moved our lines away from the tail.
1240
+ gitignore[rel] = {
1241
+ created: prev.gitignore?.[rel]?.created ?? !exists,
1242
+ lines: [...owned].sort(),
1243
+ appended: `${prefix}${block}`,
1244
+ };
1245
+ }
1246
+
1247
+ // enact-config.toml — the managed-repo marker AND the declared extension
1248
+ // roster. `[extensions]` is created (with `version = 1`) when missing and
1249
+ // appended at text level when the file exists without it; this extension is
1250
+ // then declared as its own `[extensions.<name>]` table. Both steps are
1251
+ // computed IN MEMORY and written once: re-reading the file between them
1252
+ // would make a dry-run plan differently from the real run, because the first
1253
+ // write has not happened on disk yet. `appended` is the exact span we added,
1254
+ // so `--purge` can cut only that.
1255
+ let config;
1256
+ {
1257
+ let text;
1258
+ if (!configExists) {
1259
+ const appended = `\n${EXTENSIONS_BLOCK}`;
1260
+ text = `${CONFIG_ROOT_CONTENT}${appended}`;
1261
+ config = { created: true, appended };
1262
+ } else if (!configHasExtensions) {
1263
+ const appended = `${configText.length === 0 ? "" : configText.endsWith("\n") ? "\n" : "\n\n"}${EXTENSIONS_BLOCK}`;
1264
+ text = `${configText}${appended}`;
1265
+ config = { created: prev.config?.created ?? false, appended };
1266
+ } else {
1267
+ text = configText;
1268
+ config = prev.config ?? { created: false, appended: null };
1269
+ }
1270
+ const declared = upsertExtensionBlock(text, bundle.name, bundle.version);
1271
+ if (declared !== null) text = declared;
1272
+ if (text !== configText) {
1273
+ parseConfigText(text, "refusing to write invalid TOML");
1274
+ writeRepoFile(CONFIG_PATH, text, `managed-repo marker with [extensions.${bundle.name}]`);
1275
+ }
1276
+ }
1277
+
1278
+ removeEmptyDirs(repo, createdDirs, ops);
1279
+ recordMissingDirs(repo, lockRel(bundle.name), createdDirs);
1280
+ const lock = {
1281
+ lockVersion: LOCK_VERSION,
1282
+ plugin: bundle.name,
1283
+ version: bundle.version,
1284
+ hosts,
1285
+ files: sortObject(files),
1286
+ symlinks: sortObject(symlinks),
1287
+ merged: sortObject(merged),
1288
+ toml: sortObject(toml),
1289
+ gitignore: sortObject(gitignore),
1290
+ config,
1291
+ createdDirs: [...new Set(createdDirs)].filter((d) => d === ".agents" || existsSync(abs(repo, d))).sort(),
1292
+ displaced: pruneDisplaced(displaced),
1293
+ };
1294
+ writeRepoFile(lockRel(bundle.name), `${JSON.stringify(lock, null, 2)}\n`, "install lock file");
1295
+
1296
+ const tag = dryRun ? "[dry-run] " : "";
1297
+ console.log(`${tag}repo install ${bundle.name}@${bundle.version} -> ${repo} (hosts: ${hosts.join(", ")})`);
1298
+ console.log(
1299
+ written.length > 0
1300
+ ? `${dryRun ? "would change" : "changed"} ${written.length} file(s)`
1301
+ : "up to date (no changes)",
1302
+ );
1303
+ // Live-testing finding (2026-09-14): even after trusting the folder, hooks
1304
+ // and skills were observed NOT active in the very FIRST Claude Code
1305
+ // session in this trusted folder (the newly-added extraKnownMarketplaces
1306
+ // entry + enabledPlugins registers on that first launch, but does not
1307
+ // finish activating in time for that same session) -- the SECOND session
1308
+ // works. This matches Claude Code's own documented plugin-activation
1309
+ // caveats (code.claude.com/docs/en/discover-plugins, "Apply plugin
1310
+ // changes without restarting"). Told explicitly here so a user isn't left
1311
+ // thinking the install failed.
1312
+ if (hosts.includes("claude")) {
1313
+ console.log(
1314
+ "claude: the plugin loads after you trust this folder in Claude Code -- if hooks/skills are missing in that FIRST session, start a NEW Claude Code session in this folder (the marketplace registers on first launch; the second session has it active)",
1315
+ );
1316
+ }
1317
+ if (hosts.includes("codex")) console.log("codex: config and hooks load after you trust this project and review hooks (/hooks)");
1318
+ if (hosts.includes("kimi")) console.log("kimi: hooks are global-only (installed by enact-vps); no repo hook file written");
1319
+ return { repo, name: bundle.name, version: bundle.version, hosts, changed: written, actions, dryRun };
1320
+ }
1321
+
1322
+ // ---------------------------------------------------------------------------
1323
+ // uninstall
1324
+ // ---------------------------------------------------------------------------
1325
+
1326
+ export function runRepoUninstall(name, options = {}) {
1327
+ const repo = resolveRepoRoot(options.repo);
1328
+ const force = options.force === true;
1329
+ const dryRun = options.dryRun === true;
1330
+ const actions = [];
1331
+ const ops = makeFileOps(repo, dryRun, actions);
1332
+ const lock = readLock(repo, name);
1333
+ if (!lock) throw new Error(`${name} is not installed in ${repo} (no lock at ${lockRel(name)})`);
1334
+ // Other plugins still installed here; their claims outlive this uninstall.
1335
+ const siblings = readSiblingLocks(repo, name);
1336
+
1337
+ // Captured BEFORE the removal loop deletes the file: uninstalling one
1338
+ // extension must leave a marketplace listing the ones that remain, so the
1339
+ // surviving entries have to be read while they still exist.
1340
+ const uninstallConfigPath = abs(repo, CONFIG_PATH);
1341
+ const uninstallConfigText = existsSync(uninstallConfigPath) ? readFileSync(uninstallConfigPath, "utf8") : "";
1342
+ const remainingExtensions = readDeclaredExtensions(uninstallConfigText).filter((n) => n !== name);
1343
+ const survivingMarketplace = (() => {
1344
+ const p = abs(repo, MARKETPLACE_PATH);
1345
+ if (!existsSync(p)) return null;
1346
+ try {
1347
+ const doc = JSON.parse(readFileSync(p, "utf8"));
1348
+ const kept = (doc.plugins ?? []).filter((e) => e && e.name !== name);
1349
+ return kept.length > 0 ? { ...doc, plugins: kept } : null;
1350
+ } catch {
1351
+ return null;
1352
+ }
1353
+ })();
1354
+
1355
+ const edited = [];
1356
+ for (const [rel, record] of Object.entries(lock.files)) {
1357
+ // Derived files are regenerated whenever the declared set changes -- a
1358
+ // sibling's install or uninstall rewrites them legitimately, so a hash
1359
+ // mismatch here is expected, not user damage.
1360
+ if (record.derived) continue;
1361
+ if (existsSync(abs(repo, rel)) && fileSha(repo, rel) !== record.sha256) edited.push(rel);
1362
+ }
1363
+ for (const [rel, record] of Object.entries(lock.symlinks ?? {})) {
1364
+ // lstat, not existsSync: existsSync follows the link and would silently
1365
+ // skip a dangling one instead of flagging that something is off.
1366
+ const st = lstatSync(abs(repo, rel), { throwIfNoEntry: false });
1367
+ if (st && !(st.isSymbolicLink() && readlinkSync(abs(repo, rel)) === record.target)) edited.push(rel);
1368
+ }
1369
+ const jsonDocs = new Map();
1370
+ for (const [rel, record] of Object.entries(lock.merged)) {
1371
+ const path = abs(repo, rel);
1372
+ if (!existsSync(path)) {
1373
+ edited.push(`${rel} (file removed)`);
1374
+ continue;
1375
+ }
1376
+ const text = readFileSync(path, "utf8");
1377
+ const doc = readJsonStrict(path, rel);
1378
+ for (const entry of record.entries) {
1379
+ // An entry a SIBLING also records is shared registration, not drift:
1380
+ // both plugins legitimately claim `extraKnownMarketplaces.<marketplace>`
1381
+ // and the marketplace's own `name`/`owner`. Whichever uninstalls first
1382
+ // removes it, and the second must not then report it as edited -- that
1383
+ // read the shared state as damage and refused the uninstall outright.
1384
+ if (siblingRecordsJsonEntry(siblings, rel, entry)) continue;
1385
+ if (!entryPresent(doc, entry)) edited.push(`${rel} (${entry.path.join(".")})`);
1386
+ }
1387
+ jsonDocs.set(rel, { text, doc });
1388
+ }
1389
+ for (const [rel, record] of Object.entries(lock.toml)) {
1390
+ const path = abs(repo, rel);
1391
+ const found = existsSync(path) ? findBlock(readFileSync(path, "utf8"), name) : null;
1392
+ if (!found || sha256(found.block) !== record.sha256) edited.push(rel);
1393
+ }
1394
+ // --purge cuts our [skills] block from enact-config.toml; plan it up front so
1395
+ // an edited block is refused like any other edited managed content.
1396
+ let configCut = null;
1397
+ if (options.purge && typeof lock.config?.appended === "string" && existsSync(abs(repo, CONFIG_PATH))) {
1398
+ const text = readFileSync(abs(repo, CONFIG_PATH), "utf8");
1399
+ configCut = cutExtensionsBlock(text, lock.config.appended);
1400
+ if (configCut === null) edited.push(`${CONFIG_PATH} ([extensions] block edited since install)`);
1401
+ }
1402
+ if (edited.length > 0 && !force) {
1403
+ throw new Error(`Managed content was edited since install (re-run with --force to remove anyway):\n ${edited.join("\n ")}`);
1404
+ }
1405
+
1406
+ // Originals displaced by a --force takeover during install; restored below
1407
+ // instead of being dropped along with the rest of the managed content.
1408
+ const lockDisplaced = lock.displaced ?? { files: {}, json: {}, toml: {} };
1409
+
1410
+ for (const rel of Object.keys(lock.files)) {
1411
+ // Reference counting: a path another installed plugin still claims is not
1412
+ // ours to delete. Bundles ship byte-identical skills (cmux-send,
1413
+ // cmux-leader and remove-deadcode are identical across bundles today), so
1414
+ // without this, uninstalling one plugin silently removes a skill a
1415
+ // surviving plugin's lock still points at -- which its doctor then reports
1416
+ // as a missing managed file.
1417
+ const alsoOwnedBy = siblingOwnersOf(siblings, rel);
1418
+ if (alsoOwnedBy.length > 0) {
1419
+ ops.skip?.(rel, `still owned by ${alsoOwnedBy.join(", ")}`);
1420
+ continue;
1421
+ }
1422
+ const displacedFile = lockDisplaced.files[rel];
1423
+ if (displacedFile) {
1424
+ const storedPath = abs(repo, displacedFile.storedAt);
1425
+ if (!existsSync(storedPath)) throw new Error(`Missing recorded original for ${rel} at ${displacedFile.storedAt}`);
1426
+ ops.write(rel, readFileSync(storedPath), "restoring pre-install original content");
1427
+ ops.remove(displacedFile.storedAt, "captured original no longer needed");
1428
+ } else {
1429
+ ops.remove(rel, "managed file");
1430
+ }
1431
+ }
1432
+ for (const rel of Object.keys(lock.symlinks ?? {})) {
1433
+ ops.removeSymlink(rel, "managed symlink");
1434
+ }
1435
+ // Put the marketplace back if other extensions are still declared. The
1436
+ // removal loop above deleted it as one of our managed files; regenerating it
1437
+ // from the survivors is what keeps the remaining plugins loadable.
1438
+ if (survivingMarketplace) {
1439
+ ops.write(
1440
+ MARKETPLACE_PATH,
1441
+ `${JSON.stringify(survivingMarketplace, null, 2)}\n`,
1442
+ `marketplace regenerated without ${name}`,
1443
+ );
1444
+ }
1445
+ // Undeclare this extension: remove its own `[extensions.<name>]` table. The
1446
+ // `[extensions]` root table is cut only by --purge, and only once no other
1447
+ // extension is declared.
1448
+ if (uninstallConfigText.length > 0) {
1449
+ const next = removeExtensionBlock(uninstallConfigText, name);
1450
+ if (next !== uninstallConfigText) {
1451
+ parseConfigText(next, "refusing to write invalid TOML");
1452
+ ops.write(CONFIG_PATH, next, `undeclared [extensions.${name}]`);
1453
+ }
1454
+ }
1455
+ for (const [rel, record] of Object.entries(lock.merged)) {
1456
+ if (!jsonDocs.has(rel)) continue;
1457
+ const { text, doc } = jsonDocs.get(rel);
1458
+ // Reference counting, same law as managed files: an entry a sibling still
1459
+ // records is shared repo-level registration, not ours to delete. Without
1460
+ // this the FIRST uninstall removed `extraKnownMarketplaces.<marketplace>`
1461
+ // and the marketplace's `name`/`owner`, leaving the surviving plugin
1462
+ // unregistered -- and its own uninstall then refused, reporting the
1463
+ // missing shared entry as drift.
1464
+ const retained = record.entries.filter((e) => siblingRecordsJsonEntry(siblings, rel, e));
1465
+ const ours = { ...record, entries: record.entries.filter((e) => !retained.includes(e)) };
1466
+ for (const e of retained) ops.skip(`${rel} (${e.path.join(".")})`, "still registered by another installed plugin");
1467
+ removeJsonEntries(doc, ours, { ...(lockDisplaced.json[rel] ?? {}) });
1468
+ // Remove only a file WE created -- never one the user already had, even if
1469
+ // our entries were the last thing in it. `created` is inherited across
1470
+ // siblings at install time (see below), so the second plugin to write a
1471
+ // shared file still knows enact created it and can clean it up when it is
1472
+ // the last one out.
1473
+ if (record.created && Object.keys(doc).length === 0) ops.remove(rel, "json merge file (now empty)");
1474
+ else ops.write(rel, serializeJson(doc, detectJsonFormat(text)), "json merge entries removed");
1475
+ }
1476
+ for (const [rel, record] of Object.entries(lock.toml)) {
1477
+ const path = abs(repo, rel);
1478
+ if (!existsSync(path)) continue;
1479
+ const text = readFileSync(path, "utf8");
1480
+ const found = findBlock(text, name);
1481
+ if (!found) continue;
1482
+ const out = reinsertTomlSpans(cutBlock(text, found, record.prefix), lockDisplaced.toml[rel] ?? []);
1483
+ if (record.created && out === "") ops.remove(rel, "toml block removed (file now empty)");
1484
+ else ops.write(rel, out, "toml mcp_servers block removed");
1485
+ }
1486
+ // Whether this uninstall must leave managed content behind that only
1487
+ // `--purge` can remove: the lean-ctx runtime dirs AND the enact-hook log
1488
+ // files (both our own runtime's state, never user data) are still
1489
+ // present, so the `.agents/.gitignore` lines hiding them from git must
1490
+ // stay too (removing them would surface those paths as untracked in
1491
+ // `git status`).
1492
+ //
1493
+ // Only `.agents/.gitignore` is ever retained: it is the one hiding that
1494
+ // runtime state. Every other managed .gitignore (today: `.claude/.gitignore`,
1495
+ // hiding settings.local.json) has its lines stripped unconditionally, and
1496
+ // the file itself goes when we created it and nothing else is left in it.
1497
+ const lockGitignore = lock.gitignore ?? {};
1498
+ for (const [rel, ginfo] of Object.entries(lockGitignore)) {
1499
+ if (rel === GITIGNORE_PATH) continue;
1500
+ removeManagedGitignoreLines(repo, rel, ginfo, ops);
1501
+ }
1502
+ let retainedGitignore = false;
1503
+ if (lockGitignore[GITIGNORE_PATH]) {
1504
+ const presentRuntimeDirs = LEAN_CTX_RUNTIME_DIRS.filter((rel) => existsSync(abs(repo, rel)));
1505
+ const presentHookLogFiles = HOOK_LOG_FILES.filter((rel) => existsSync(abs(repo, rel)));
1506
+ if (options.purge) {
1507
+ // Runtime state OUR OWN runtime created (not user data): safe to
1508
+ // remove outright, before the gitignore lines that were hiding it.
1509
+ for (const rel of presentRuntimeDirs) ops.removeDir(rel, "lean-ctx runtime dir (--purge)");
1510
+ for (const rel of presentHookLogFiles) ops.remove(rel, "enact-hook log file (--purge)");
1511
+ }
1512
+ const presentRuntimePaths = [...presentRuntimeDirs, ...presentHookLogFiles];
1513
+ retainedGitignore = !options.purge && presentRuntimePaths.length > 0;
1514
+ if (retainedGitignore) {
1515
+ const kept = [GITIGNORE_PATH, ...(typeof lock.config?.appended === "string" ? [`${CONFIG_PATH} [skills]`] : [])];
1516
+ console.log(
1517
+ `kept ${kept.join(" and ")} -- lean-ctx runtime dirs / enact-hook log files still present (${presentRuntimePaths.join(", ")}); re-run uninstall --purge to remove them too`,
1518
+ );
1519
+ } else {
1520
+ removeManagedGitignoreLines(repo, GITIGNORE_PATH, lockGitignore[GITIGNORE_PATH], ops);
1521
+ }
1522
+ }
1523
+
1524
+ if (options.purge || !retainedGitignore) {
1525
+ ops.remove(lockRel(name), "uninstall lock file");
1526
+ } else {
1527
+ // Something above is still waiting on `--purge`: rewrite the SAME lock
1528
+ // file (same path, same schema) down to a minimal residual -- no
1529
+ // managed files/json/toml left (those were already fully removed
1530
+ // above), just the gitignore/config bookkeeping a later STANDALONE
1531
+ // `uninstall --purge` call needs in order to find and finish the job.
1532
+ // This is what makes the printed "re-run uninstall --purge" message
1533
+ // above actually true: readLock() at the top of that later call will
1534
+ // find this file (and see it as belonging to `name`), not nothing --
1535
+ // this is the fix for the bug where a plain uninstall unconditionally
1536
+ // deleted the lock even while retaining content, so a follow-up
1537
+ // `uninstall --purge` had nothing to read and refused with "not
1538
+ // installed ... (no lock)". `install` re-run against this residual
1539
+ // lock adopts `gitignore`/`config`/`createdDirs` from it unchanged
1540
+ // (see `runRepoInstall`'s `prev = readLock(...) ?? {...}`) and
1541
+ // overwrites it with a full lock again -- idempotent either way.
1542
+ const residual = {
1543
+ lockVersion: LOCK_VERSION,
1544
+ plugin: name,
1545
+ version: lock.version,
1546
+ hosts: [],
1547
+ files: {},
1548
+ symlinks: {},
1549
+ merged: {},
1550
+ toml: {},
1551
+ // Only the still-retained `.agents/.gitignore` record: every other
1552
+ // managed .gitignore was already stripped above, so a later standalone
1553
+ // `--purge` must not try to strip it a second time.
1554
+ gitignore: { [GITIGNORE_PATH]: lockGitignore[GITIGNORE_PATH] },
1555
+ config: lock.config,
1556
+ createdDirs: lock.createdDirs,
1557
+ displaced: { files: {}, json: {}, toml: {} },
1558
+ };
1559
+ ops.write(lockRel(name), `${JSON.stringify(residual, null, 2)}\n`, "residual lock (gitignore/config retained pending --purge)");
1560
+ }
1561
+
1562
+ if (options.purge) {
1563
+ if (configCut !== null) {
1564
+ if (lock.config.created && configCut.trim() === CONFIG_ROOT_CONTENT.trim()) {
1565
+ ops.remove(CONFIG_PATH, "managed-repo marker created by install ([skills] removed, only version = 1 left)");
1566
+ } else {
1567
+ ops.write(CONFIG_PATH, configCut, "[skills] block removed (--purge)");
1568
+ }
1569
+ } else if (typeof lock.config?.appended === "string" && existsSync(abs(repo, CONFIG_PATH))) {
1570
+ console.log(`kept ${CONFIG_PATH} [skills] block (edited since install; --force never cuts an edited block)`);
1571
+ } else if (existsSync(abs(repo, CONFIG_PATH))) {
1572
+ console.log(`kept ${CONFIG_PATH} (install did not write its [skills] block)`);
1573
+ }
1574
+ purgeOpenCodeAutogenIfClean(repo, ops);
1575
+ }
1576
+ removeEmptyDirs(repo, lock.createdDirs, ops);
1577
+ console.log(`${dryRun ? "[dry-run] " : ""}repo uninstall ${name} <- ${repo}${options.purge ? " (purged)" : ""}`);
1578
+ return { repo, name, removedFiles: Object.keys(lock.files).length, actions, dryRun };
1579
+ }