@amsterdamdatalabs/enact-extensions 0.1.42 → 0.1.48

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