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